Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05a0596fb1 | |||
| 219d10a22d | |||
| 607dc51dec | |||
| 9d86287d08 | |||
| 2697af31d1 | |||
| 52997ee164 | |||
| 21eac21409 | |||
| 8b08566f41 | |||
| 50787823d3 | |||
| 7e22e2250c | |||
| d21f6947e1 | |||
| 7fa863f6da | |||
| f427dc4f26 | |||
| 3e3f7588bd | |||
| c02f016f1d | |||
| a1325299ce | |||
| 14fb2b05ed | |||
| da141497f8 | |||
| 9892ceae9a | |||
| 59858129cb | |||
| e248e037e7 | |||
| ae980aef5d | |||
| 2662ac08e4 | |||
| 45740578c9 | |||
| 5ae67a48ba | |||
| d055cb059e | |||
| 74161f9460 | |||
| 396052a126 | |||
| fd0cc4dfdb | |||
| 850d6774ea | |||
| 5c754ecffd | |||
| 68c6f36cfe | |||
| 36c4751571 | |||
| 229282ad8b | |||
| b0a2bb037d | |||
| ba6e5dd7f9 | |||
| 686138123f | |||
| cd5540cb1a |
@@ -81,21 +81,21 @@
|
||||
{"id": "F4", "subject": "Follow-up: Harden AuditWriterActor.WrapDetails JSON synthesis with System.Text.Json", "status": "completed", "classification": "small", "estMinutes": 5, "parallelizableWith": ["F3"], "blockedBy": [], "commit": "f57f61d", "deviation": "Moot — F3 deleted WrapDetails entirely (EventId/CorrelationId now live in dedicated columns).", "origin": "Self-review of Task 33 — WrapDetails uses string concat; malformed caller DetailsJson would produce invalid JSON and trip the CK_ConfigAuditLog_DetailsJson_IsJson constraint, killing the entire flush batch. Discard this task if F3 lands first (F3 removes WrapDetails entirely)."},
|
||||
{"id": "F5", "subject": "Follow-up: ConfigPublishCoordinator multi-node happy-path test", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "5cfbe8b", "deviation": "Delivered by Task 59 — DeployHappyPathTests.StartDeployment_seals_after_both_nodes_apply exercises the exact 'dispatch to N driver nodes, all ack, seals' flow via the real 2-node TwoNodeClusterHarness rather than a multi-system TestKit. Cleaner because it tests the production code path end-to-end.", "origin": "Self-review of Task 30 — single-ActorSystem TestKit can't simulate the plan's 'dispatch to N driver nodes, all ack, seals' happy path because DiscoverDriverNodes() needs real cluster membership. Add a multi-system test (two ActorSystems joined into one cluster, driver-role on the second)."},
|
||||
{"id": "F6", "subject": "Follow-up: RedundancyStateActor publisher abstraction so tests don't need DPS bootstrap", "status": "completed", "classification": "small", "estMinutes": 10, "parallelizableWith": [], "blockedBy": [], "commit": "dfc143c", "origin": "Self-review of Task 35 — RedundancyStateActorTests are skipped because single-node DistributedPubSub bootstrap is unreliable in TestKit. Inject an Action<object> broadcast so tests can replace it with a probe; un-skip both tests."},
|
||||
{"id": "F7", "subject": "Follow-up: DriverInstanceActor full engine wiring (subscriptions, writes, ApplyDelta diff)", "status": "pending", "classification": "standard", "estMinutes": 45, "parallelizableWith": [], "blockedBy": [44], "origin": "Self-review of Task 41 — subscription publishing, ApplyDelta diffing, bad-quality-on-disconnect, write path, and supervisor backoff are stubbed. Wire after OpcUaPublishActor lands."},
|
||||
{"id": "F8", "subject": "Follow-up: VirtualTagActor engine wiring (compile expression, subscribe deps, publish result)", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 42 — VirtualTagEngine.Evaluate not called; DependencyValueChanged just buffers."},
|
||||
{"id": "F9", "subject": "Follow-up: ScriptedAlarmActor engine wiring + state persistence", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 43 — AlarmConditionService not called; PreRestart persistence to ScriptedAlarmState DB not wired; HistorianAdapter rows not emitted."},
|
||||
{"id": "F10", "subject": "Follow-up: OpcUaPublishActor SDK integration (address-space writes + ServiceLevel + RebuildAddressSpace)", "status": "pending", "classification": "high-risk", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [47], "origin": "Self-review of Task 44 — SDK calls stubbed; counters only. Wire after Phase 7 OpcUaServer extraction."},
|
||||
{"id": "F11", "subject": "Follow-up: HistorianAdapterActor named-pipe IPC + SqliteStoreAndForwardSink wiring", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 45 — stub buffers in-memory; named-pipe + SQLite store-and-forward not wired."},
|
||||
{"id": "F7", "subject": "Follow-up: DriverInstanceActor full engine wiring (subscriptions, writes, ApplyDelta diff)", "status": "completed", "classification": "standard", "estMinutes": 45, "parallelizableWith": [], "blockedBy": [44], "origin": "Self-review of Task 41 — subscription publishing, ApplyDelta diffing, bad-quality-on-disconnect, write path, and supervisor backoff are stubbed. Wire after OpcUaPublishActor lands.", "shipped": "All three pieces landed: (1) spawn lifecycle in DriverHostActor (DriverSpawnPlanner + IDriverFactory seam) — da14149, (2) ISubscribable wiring + OPC UA status-code → OpcUaQuality severity-bit mapping + DetachSubscription on disconnect/PostStop, (3) IWritable.WriteAsync write path with 5s timeout, status-code bubble-up, and AttributeValuePublished published to parent on every OnDataChange — both shipped in the F7-residual batch. Host DI binding (DriverFactoryBootstrap registers AbCip/AbLegacy/FOCAS/Galaxy/Modbus/S7/TwinCAT factories) lives in src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/."},
|
||||
{"id": "F8", "subject": "Follow-up: VirtualTagActor engine wiring (compile expression, subscribe deps, publish result)", "status": "partial", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 42 — VirtualTagEngine.Evaluate not called; DependencyValueChanged just buffers.", "shipped": "(1) IVirtualTagEvaluator seam + NullVirtualTagEvaluator default. VirtualTagActor calls evaluator on DependencyValueChanged, dedupes unchanged results, emits EvaluationResult to parent, publishes Warning ScriptLogEntry on failure. (2) DependencyMuxActor in Runtime fans out DriverInstanceActor.AttributeValuePublished from DriverHostActor through to interested VirtualTagActor subscribers. VirtualTagActor takes dependencyRefs + mux ActorRef in Props, registers interest in PreStart, unregisters in PostStop. WithOtOpcUaRuntimeActors spawns the mux + threads it into DriverHostActor. Production binding to Core.VirtualTags.VirtualTagEngine (expression compile + dep extraction) still TODO — split as F8b."},
|
||||
{"id": "F9", "subject": "Follow-up: ScriptedAlarmActor engine wiring + state persistence", "status": "partial", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 43 — AlarmConditionService not called; PreRestart persistence to ScriptedAlarmState DB not wired; HistorianAdapter rows not emitted.", "shipped": "(1) IScriptedAlarmEvaluator seam + NullScriptedAlarmEvaluator default. ScriptedAlarmActor takes AlarmConfig (id/name/path/severity/predicate), evaluates on DependencyValueChanged, publishes AlarmTransitionEvent + ScriptLogEntry on every transition. (2) IAlarmActorStateStore seam in Commons.Engines + NullAlarmActorStateStore default + EfAlarmActorStateStore production adapter over the ScriptedAlarmState entity. ScriptedAlarmActor PreStart loads + restores; every Transition fires a fire-and-forget save with lastAckUser. Predicate binding to Core.ScriptedAlarms.ScriptedAlarmEngine still TODO — split as F9b."},
|
||||
{"id": "F10", "subject": "Follow-up: OpcUaPublishActor SDK integration (address-space writes + ServiceLevel + RebuildAddressSpace)", "status": "partial", "classification": "high-risk", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [47], "origin": "Self-review of Task 44 — SDK calls stubbed; counters only. Wire after Phase 7 OpcUaServer extraction.", "shipped": "(1) IOpcUaAddressSpaceSink + IServiceLevelPublisher seams in Commons.OpcUa with Null* defaults. OpcUaPublishActor routes through the sink, dedupes ServiceLevelChanged, subscribes to redundancy-state DPS topic, maps redundancy snapshot to a coarse ServiceLevel (Primary+leader=240, Primary=200, Secondary=100, Detached=0). (2) OtOpcUaNodeManager (CustomNodeManager2) + OtOpcUaSdkServer (StandardServer subclass) + SdkAddressSpaceSink in OpcUaServer — lazy variable creation on first WriteValue, WriteAlarmState shape, RebuildAddressSpace tear-down. Variable updates propagate via ClearChangeMasks so subscribed OPC UA clients see them. Tests boot a real StandardServer + verify sink writes show up in the manager. Production wiring through OpcUaApplicationHost.StartAsync (default server = OtOpcUaSdkServer) + IServiceLevelPublisher SDK binding + #109 OpcUaPublishActor→Phase7Applier integration are the remaining pieces."},
|
||||
{"id": "F11", "subject": "Follow-up: HistorianAdapterActor named-pipe IPC + SqliteStoreAndForwardSink wiring", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "6861381", "deviationNotes": "Reshaped HistorianAdapterActor around the existing IAlarmHistorianSink abstraction (alarm-event shape, not the original tag-history-row stub). Defaults to NullAlarmHistorianSink; production deployments wire SqliteStoreAndForwardSink + WonderwareHistorianClient via AddOtOpcUaRuntime overrides. Actor now exposes GetStatus returning HistorianSinkStatus for diagnostics. Named-pipe transport implementation lives unchanged in src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs — the actor is intentionally just a fire-and-forget bridge.", "origin": "Self-review of Task 45 — stub buffers in-memory; named-pipe + SQLite store-and-forward not wired."},
|
||||
{"id": "F12", "subject": "Follow-up: PeerOpcUaProbeActor real opc.tcp ping (replace Ok=true stub)", "status": "completed", "classification": "small", "estMinutes": 20, "parallelizableWith": [], "blockedBy": [], "commit": "b06e3ae", "deviation": "TCP-connect probe rather than full OPC UA Hello/Acknowledge handshake. Enough for the redundancy calc; deeper liveness signals can layer on later without changing the actor's contract.", "origin": "Self-review of Task 45 — RunProbe always returns Ok=true; replace with OPC UA Client connect."},
|
||||
{"id": "F13", "subject": "Follow-up: Full OpcUaApplicationHost extraction (security/alarms/history/observability)", "status": "pending", "classification": "high-risk", "estMinutes": 120, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 46 — facade only boots ApplicationInstance + StandardServer. Legacy 391-line file pulls Server.Security/Alarms/History/Observability. Pull those into thin OpcUaServer interfaces."},
|
||||
{"id": "F14", "subject": "Follow-up: Migrate side-effecting Phase7Composer (EquipmentNodeWalker, trace logs, node cache)", "status": "pending", "classification": "standard", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 47 — pure version covers the projection. Walker + alarm sink registration + cache mutation stay in legacy until split into Phase7Applier."},
|
||||
{"id": "F15", "subject": "Follow-up: Migrate 47 legacy Admin Blazor components into AdminUI library", "status": "pending", "classification": "high-risk", "estMinutes": 180, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 48 — only MapAdminUI scaffold + 1 new page (Deployments). 47 pages stay in legacy Admin (accepted-broken) until Task 56."},
|
||||
{"id": "F13", "subject": "Follow-up: Full OpcUaApplicationHost extraction (security/alarms/history/observability)", "status": "partial", "classification": "high-risk", "estMinutes": 120, "parallelizableWith": [], "blockedBy": [], "commit": "36c4751-partial", "deviationNotes": "F13a (cert auto-creation) shipped in 36c4751. Remaining: endpoint-security wiring (SecurityProfileResolver into ServerConfiguration.SecurityPolicies), LDAP user-token validator (the OPC UA UserNameToken path; HTTP-layer LDAP auth is separate and already in OtOpcUa.Security), scripted-alarm node manager creation, history backend wiring, observability hooks (OpenTelemetry metrics + traces). These are gated by F10's OpcUaPublishActor SDK integration — until F10 lands, nothing instantiates OpcUaApplicationHost so the missing wiring is dead weight.", "origin": "Self-review of Task 46 — facade only boots ApplicationInstance + StandardServer. Legacy 391-line file pulls Server.Security/Alarms/History/Observability. Pull those into thin OpcUaServer interfaces."},
|
||||
{"id": "F14", "subject": "Follow-up: Migrate side-effecting Phase7Composer (EquipmentNodeWalker, trace logs, node cache)", "status": "partial", "classification": "standard", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 47 — pure version covers the projection. Walker + alarm sink registration + cache mutation stay in legacy until split into Phase7Applier.", "shipped": "Phase7Plan + Phase7Planner.Compute (pure diff over EquipmentNodes/DriverInstancePlans/ScriptedAlarmPlans by stable id, with Added/Removed/Changed lists). Phase7Applier consumes plan + IOpcUaAddressSpaceSink: drives RebuildAddressSpace on Equipment/Alarm topology change, writes inactive AlarmState for removed nodes, catches + logs sink faults. Driver-only changes correctly skip the rebuild (DriverHostActor's spawn-plan in Runtime handles those). Walker integration with the real SDK NodeManager is the remaining piece — split as F14b (consumes the existing EquipmentNodeWalker once F10b lands an SDK builder)."},
|
||||
{"id": "F15", "subject": "Follow-up: Migrate 47 legacy Admin Blazor components into AdminUI library", "status": "completed", "classification": "high-risk", "estMinutes": 180, "commit": "Phase A-D (read views) + F15.2 batches 1-4 (live-edit CRUD) + F15.3 (live alerts/script-log/CSV import/Monaco)", "deviationNotes": "All 4 phases of read-only views shipped: Phase A (shell/auth/fleet/hosts), B (cluster CRUD + Overview/Redundancy), C (Equipment/UNS/Namespaces/Drivers/Tags/ACLs), D (Audit/VirtualTags/ScriptedAlarms/Scripts/RoleGrants/Certificates/Reservations/AlarmsHistorian). Per Q1–Q5 of docs/v2/AdminUI-rebuild-plan.md: typed driver editors deferred, top-level VirtualTags/ScriptedAlarms kept (Q2 reversed for cross-cluster discoverability), routes-not-tabs adopted, fleet-wide LDAP→role map only, generic login errors. Live-edit forms (F15.2) and ScriptLog page (depends on F16 ScriptLogHub) are explicit follow-ups.", "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 48 — only MapAdminUI scaffold + 1 new page (Deployments). 47 pages stay in legacy Admin (accepted-broken) until Task 56."},
|
||||
{"id": "F16", "subject": "Follow-up: Bridge FleetStatusBroadcaster → SignalR hubs (FleetStatusHub / AlertHub / ScriptLogHub)", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "f18c285", "deviation": "FleetStatusHub bridge landed. AlertHub + ScriptLogHub deferred — they need upstream message contracts that aren't defined yet (alerts emerge from F9 ScriptedAlarmActor, script logs from F8 VirtualTagActor).", "origin": "Self-review of Task 49 — hubs are passive Hub subclasses; the bridge from FleetStatusBroadcaster.broadcast → IHubContext is not wired."},
|
||||
{"id": "F17", "subject": "Follow-up: FleetDiagnosticsClient real Akka ActorSelection round-trip (GetDiagnosticsRequest)", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "8f32b89", "origin": "Self-review of Task 51 — client returns an empty snapshot stub. Add GetDiagnosticsRequest contract + DriverHostActor handler + real Ask/Reply."},
|
||||
{"id": "F18", "subject": "Follow-up: Thread HttpContext.User.Identity.Name into Deployments page (createdBy)", "status": "completed", "classification": "small", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [], "commit": "b266f63", "origin": "Self-review of Task 52 — Deployments.razor hardcodes createdBy=\"(current user)\"; needs @inject AuthenticationStateProvider."},
|
||||
{"id": "F19", "subject": "Follow-up: RuntimeStartup extension for driver-role node-actor spawn", "status": "completed", "classification": "standard", "estMinutes": 20, "parallelizableWith": [], "blockedBy": [], "commit": "09d6676", "origin": "Self-review of Task 53 — only admin-role singletons are wired via WithOtOpcUaControlPlaneSingletons. Driver-role nodes need a parallel WithOtOpcUaRuntimeActors that spawns DriverHostActor."},
|
||||
{"id": "F20", "subject": "Follow-up: Wire DriverInstanceActor.ShouldStub() into DriverHostActor child spawn", "status": "pending", "classification": "small", "estMinutes": 10, "parallelizableWith": ["F7"], "blockedBy": [], "origin": "Self-review of Task 55 — ShouldStub helper exists but nothing calls it. Folds into F7 when DriverHostActor learns to spawn DriverInstanceActor children."},
|
||||
{"id": "F21", "subject": "Follow-up: docker-compose.yml for Host.IntegrationTests (real SQL Server + OpenLDAP)", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Deviation from Task 58 — TwoNodeClusterHarness uses EF InMemoryDatabase + StubLdapAuthService. For Mac-friendly local runs against real SQL constraints + LDAP, add tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml (SQL Server + OpenLDAP), wire EF SqlServer provider behind an env var (OTOPCUA_HARNESS_USE_SQL=1), and add a test trait so CI can run both modes."},
|
||||
{"id": "F22", "subject": "Follow-up: failover scenario integration tests (kill-mid-apply, split-brain, restart-during-deploy)", "status": "pending", "classification": "standard", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [], "origin": "Deviation from Task 59 — happy-path + idempotency landed but design §8 cases 3-7 need controlled node-down primitives on TwoNodeClusterHarness (StopNodeAsync, RestartNodeAsync, PartitionBetweenAsync). Add those + 5 scenario tests."}
|
||||
{"id": "F20", "subject": "Follow-up: Wire DriverInstanceActor.ShouldStub() into DriverHostActor child spawn", "status": "completed", "classification": "small", "estMinutes": 10, "parallelizableWith": ["F7"], "blockedBy": [], "origin": "Self-review of Task 55 — ShouldStub helper exists but nothing calls it. Folds into F7 when DriverHostActor learns to spawn DriverInstanceActor children.", "shipped": "DriverHostActor.SpawnChild now calls DriverInstanceActor.ShouldStub(type, _localRoles) and routes Windows-only driver types to the stub path on non-Windows / dev-role hosts. Verified by DriverHostActorReconcileTests.Galaxy_on_non_windows_is_stubbed_by_ShouldStub_check."},
|
||||
{"id": "F21", "subject": "Follow-up: docker-compose.yml for Host.IntegrationTests (real SQL Server + OpenLDAP)", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "b0a2bb0", "deviationNotes": "Stack shipped (SQL on 14331, OpenLDAP on 3894). HarnessMode reads OTOPCUA_HARNESS_USE_SQL=1 / USE_LDAP=1 from env; SQL mode uses per-harness unique DB via EnsureCreated. Compose itself not local-validated — DESKTOP-6JL3KKO has no Docker per CLAUDE.md; CI on Linux will exercise the real path. The xunit test-trait split was punted — env vars are simpler and cover the same use case (one suite, two modes, no test-class duplication).", "origin": "Deviation from Task 58 — TwoNodeClusterHarness uses EF InMemoryDatabase + StubLdapAuthService. For Mac-friendly local runs against real SQL constraints + LDAP, add tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml (SQL Server + OpenLDAP), wire EF SqlServer provider behind an env var (OTOPCUA_HARNESS_USE_SQL=1), and add a test trait so CI can run both modes."},
|
||||
{"id": "F22", "subject": "Follow-up: failover scenario integration tests (kill-mid-apply, split-brain, restart-during-deploy)", "status": "completed", "classification": "standard", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [], "commit": "cd5540c", "deviationNotes": "Shipped 3 scenarios on the existing 2-node harness: stop-shrinks, restart-rejoins-same-port, deploy-with-one-node-down. Split-brain via simulated partition deferred — Akka.Hosting + xunit don't expose transport-level interference cleanly. The graceful-shutdown + rejoin path is what production actually exercises; ungraceful kill-mid-apply non-deterministic under SBR's 15s stable-after.", "origin": "Deviation from Task 59 — happy-path + idempotency landed but design §8 cases 3-7 need controlled node-down primitives on TwoNodeClusterHarness (StopNodeAsync, RestartNodeAsync, PartitionBetweenAsync). Add those + 5 scenario tests."}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
# Admin UI rebuild plan (F15)
|
||||
|
||||
**Status:** UX kickoff — proposals to react to before any per-page rebuild starts.
|
||||
**Last updated:** 2026-05-26 on `v2-akka-fuse`.
|
||||
|
||||
## Why this isn't a straight port
|
||||
|
||||
The v1 Admin UI was built around `ConfigGeneration` draft → publish:
|
||||
operators edited a **draft** generation, the system computed a **diff** against the
|
||||
last published one, and a manual **Publish** sealed it. Six full pages
|
||||
(`DraftEditor`, `DiffViewer`, `DiffSection`, `Generations`, plus the per-tab
|
||||
"viewing draft N" header) lived to make this workflow legible.
|
||||
|
||||
v2 replaces that with **live-edit + snapshot-deploy** (decisions #14a–#14e on this
|
||||
branch). Edits write directly to live tables guarded by `RowVersion`
|
||||
concurrency; deploying is a single click that snapshots the current live state
|
||||
and dispatches it via Akka. Drift between "current live" and "last sealed
|
||||
deployment" surfaces as a one-line indicator on the
|
||||
[Deployments](../../src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor)
|
||||
page.
|
||||
|
||||
That collapses **six pages → zero** before we ship a line of new Razor. The
|
||||
remaining ~41 legacy pages map to ~30 v2 pages once redundant fleet-wide views
|
||||
fold into their cluster-tab equivalents.
|
||||
|
||||
## Inventory: 47 legacy pages → v2 disposition
|
||||
|
||||
Source: `git show 76310b8^ -- 'src/Server/ZB.MOM.WW.OtOpcUa.Admin/**/*.razor'`.
|
||||
|
||||
### Site shell (5 files) — port
|
||||
|
||||
| Legacy | v2 status | Notes |
|
||||
|---|---|---|
|
||||
| `App.razor`, `Routes.razor`, `_Imports.razor` | Port | Boilerplate; minor render-mode tweaks |
|
||||
| `Layout/MainLayout.razor` | ✅ Already in v2 | Done in Task 48 |
|
||||
| `Components/Pages/Login.razor`, `Account.razor` | Port | Auth endpoints changed (cookie+JWT hybrid, Task 26); login form posts to `/auth/login` now |
|
||||
|
||||
### Shared widgets (5 files) — port
|
||||
|
||||
| Legacy | v2 status |
|
||||
|---|---|
|
||||
| `StatusBadge.razor` | ✅ Already in v2 |
|
||||
| `LoadingSpinner.razor` | ✅ Already in v2 |
|
||||
| `ToastNotification.razor` | ✅ Already in v2 |
|
||||
| `ClusterAuthorizeView.razor`, `RedirectToLogin.razor` | Port — adjust for v2 `IUserAuthenticator` |
|
||||
|
||||
### Fleet (1 file) — reshape
|
||||
|
||||
| Legacy | v2 strategy |
|
||||
|---|---|
|
||||
| `Fleet.razor` | **Reshape.** Drop the v1 "poller reads central DB" data source. v2 reads `NodeDeploymentState` (Applied / Failed / Stale per node) + subscribes to `FleetStatusHub` for live `ServiceLevel` updates (already wired in F16) + queries `IFleetDiagnosticsClient.GetDiagnostics` (F17) for per-node driver health. Single page, similar shape to v1. |
|
||||
|
||||
### Cluster CRUD (3 files) — port
|
||||
|
||||
| Legacy | v2 strategy |
|
||||
|---|---|
|
||||
| `ClustersList.razor` | Port |
|
||||
| `NewCluster.razor` | Port |
|
||||
| `ClusterDetail.razor` | **Port — drop draft/publish chrome.** No "New draft" button; no "current published" sidebar. Replace with "Last deployed" badge + a "Deploy" button (already a SignalR-aware widget on the Deployments page; this becomes a cluster-scoped variant). |
|
||||
|
||||
### Draft/publish workflow (4 files) — **drop entirely**
|
||||
|
||||
| Legacy | v2 strategy |
|
||||
|---|---|
|
||||
| `DraftEditor.razor` | **Drop.** No drafts in v2. |
|
||||
| `DiffViewer.razor` | **Drop.** Drift indicator replaces it on Deployments page. |
|
||||
| `DiffSection.razor` | **Drop.** |
|
||||
| `Generations.razor` | **Drop — replaced by `Deployments.razor`** (already shipped in v2 ahead of F15). |
|
||||
|
||||
### Cluster tabs (11 files) — port as live-edit forms
|
||||
|
||||
Each becomes a live-edit surface: load the entity, bind to a form, save with
|
||||
`RowVersion` concurrency check (409 on conflict → toast + reload). No "viewing
|
||||
draft N" header; no per-tab snapshot view.
|
||||
|
||||
| Legacy tab | v2 strategy |
|
||||
|---|---|
|
||||
| `EquipmentTab.razor` | Port — UNS path tree picker stays |
|
||||
| `UnsTab.razor` | Port — same |
|
||||
| `NamespacesTab.razor` | Port |
|
||||
| `DriversTab.razor` | Port — **driver-type-specific editors are a separate question (see below)** |
|
||||
| `TagsTab.razor` | Port |
|
||||
| `AclsTab.razor` | Port — wire to v2 LDAP group → role mapping (see RoleGrants question) |
|
||||
| `RedundancyTab.razor` | Port — surface v2 `ServiceLevel` calc (Task 35) instead of v1 redundancy state machine |
|
||||
| `ScriptedAlarmsTab.razor` | Port |
|
||||
| `ScriptsTab.razor` | Port |
|
||||
| `VirtualTagsTab.razor` | Port |
|
||||
| `AuditTab.razor` | Port — wire to v2 `ConfigAuditLog` (post-F3 schema: `EventId`, `CorrelationId` columns) |
|
||||
|
||||
### Cluster-scoped editors (3 files) — port as reusable inputs
|
||||
|
||||
| Legacy | v2 strategy |
|
||||
|---|---|
|
||||
| `IdentificationFields.razor` | Port |
|
||||
| `ImportEquipment.razor` | Port |
|
||||
| `ScriptEditor.razor` | Port |
|
||||
|
||||
### Cross-cluster pages (8 files) — mixed
|
||||
|
||||
| Legacy | v2 strategy |
|
||||
|---|---|
|
||||
| `Hosts.razor` | Port — reshape to "Akka cluster members" (showing `host:port` NodeIds, roles, redundancy state) |
|
||||
| `Certificates.razor` | Port — F13a's `PkiStoreRoot` becomes the data source |
|
||||
| `Reservations.razor` | Port |
|
||||
| `RoleGrants.razor` | **Reshape.** v1 was cluster-scoped role grants; v2 uses LDAP group → role mapping (see Q4 below) |
|
||||
| `AlarmsHistorian.razor` | Port — wire to F11's `HistorianAdapterActor.GetStatus` (queue depth + drain state) |
|
||||
| `ScriptLog.razor` | Port — needs SignalR hub bridge (F16 deferred ScriptLogHub) |
|
||||
| `ScriptedAlarms.razor` (top-level) | **Possibly drop** (see Q2 below) |
|
||||
| `VirtualTags.razor` (top-level) | **Possibly drop** (see Q2 below) |
|
||||
|
||||
### Driver-typed editors (5 files) — sequencing decision needed
|
||||
|
||||
| Legacy | v2 strategy |
|
||||
|---|---|
|
||||
| `Drivers/FocasDetail.razor` | Defer — JSON editor in `DriversTab` covers the same config initially |
|
||||
| `Modbus/ModbusOptionsEditor.razor` | Same |
|
||||
| `Modbus/ModbusAddressEditor.razor` | Same |
|
||||
| `Modbus/ModbusAddressPreview.razor` | Same |
|
||||
| `Modbus/ModbusDiagnostics.razor` | Port — separate from the config editor, this is operational telemetry |
|
||||
|
||||
### Account (1 file) — port
|
||||
|
||||
| Legacy | v2 strategy |
|
||||
|---|---|
|
||||
| `Account.razor` | Port — minor reshape for JWT (token expiry UI, refresh button) |
|
||||
|
||||
## Summary by disposition
|
||||
|
||||
| Disposition | Count |
|
||||
|---|---|
|
||||
| Already in v2 | 5 |
|
||||
| Port as-is | 22 |
|
||||
| Port + reshape | 7 |
|
||||
| **Drop (replaced by live-edit / Deployments page)** | **5** |
|
||||
| Drop (redundant with cluster tab) | 2 (pending Q2) |
|
||||
| Defer (driver-typed editors) | 4 |
|
||||
| **Total active rebuild** | ~30 pages |
|
||||
|
||||
## Open design questions
|
||||
|
||||
These need answers before per-page sequencing starts. They drive how many
|
||||
phases the rebuild takes and what gets cut.
|
||||
|
||||
### Q1 — Driver-typed editors: ship now or defer?
|
||||
|
||||
**Context.** v1 had typed editors for Modbus + FOCAS driver config. They sat
|
||||
behind a generic JSON editor for the other six driver types. The typed editors
|
||||
caught operator typos that the JSON editor missed (port ranges, slave-ID
|
||||
collisions, address-map overlaps).
|
||||
|
||||
**Options.**
|
||||
- **Defer all typed editors.** Ship `DriversTab` with a JSON editor first; add
|
||||
typed editors per-driver as field requests come in. Saves ~1 day on F15.
|
||||
- **Port the existing two.** Modbus + FOCAS were already validated against
|
||||
field use. The other six driver types stay JSON-only.
|
||||
- **Ship all eight typed editors.** Most work, best UX. ~3 extra days on F15.
|
||||
|
||||
**Recommendation:** Defer. The OPC UA dual-endpoint tests + driver
|
||||
engine wiring (F7-F10) are higher-leverage and need attention first.
|
||||
|
||||
### Q2 — Top-level `ScriptedAlarms.razor` and `VirtualTags.razor`: keep or drop?
|
||||
|
||||
**Context.** In v1, these were fleet-wide views of every scripted alarm and
|
||||
virtual tag across every cluster. The cluster tabs let you edit them; the
|
||||
top-level pages let you find them across clusters.
|
||||
|
||||
**Options.**
|
||||
- **Drop.** Fleet-wide view is rare; cluster scope covers 95% of use.
|
||||
- **Keep as read-only.** Cross-cluster search + drill-down to the per-cluster tab.
|
||||
|
||||
**Recommendation:** Drop, but expose a global search on the top nav that
|
||||
matches cluster + alarm/tag names if operators ask.
|
||||
|
||||
### Q3 — ClusterDetail: 10 tabs or split routes?
|
||||
|
||||
**Context.** v1 had 10 nav-tabs inside `ClusterDetail.razor`. Some are very
|
||||
heavy (Tags can be 10k rows; AuditTab streams). All 10 share render state.
|
||||
|
||||
**Options.**
|
||||
- **Keep tabs.** Familiar; one URL per cluster.
|
||||
- **Split into routes.** `/clusters/{id}/equipment`, `/clusters/{id}/tags`,
|
||||
etc. Better deep-linking, better load (one tab's data per page), easier auth
|
||||
scoping.
|
||||
|
||||
**Recommendation:** Split into routes. The v1 monolith was already groaning
|
||||
under the live-update SignalR fan-in; routes let each surface manage its own
|
||||
subscription lifecycle.
|
||||
|
||||
### Q4 — RoleGrants: cluster-scoped table or LDAP group → role map?
|
||||
|
||||
**Context.** v1 had a per-cluster `RoleGrants` table where you mapped users to
|
||||
cluster-scoped roles (ClusterAdmin, ClusterOperator, etc.). v2 introduced
|
||||
LDAP-driven auth: LDAP group membership maps to OPC UA permissions
|
||||
(`ReadOnly`, `WriteOperate`, `WriteTune`, `WriteConfigure`, `AlarmAck`)
|
||||
fleet-wide.
|
||||
|
||||
**Options.**
|
||||
- **Keep v1 model.** Cluster-scoped grants survive; LDAP just provides the
|
||||
username.
|
||||
- **Replace with fleet-wide LDAP-group → role mapping.** v2's `LdapOptions`
|
||||
already has a `GroupToRole` dictionary; surface that in a single fleet-level
|
||||
page.
|
||||
- **Both.** LDAP map for fleet-wide defaults; per-cluster overrides for
|
||||
scoping.
|
||||
|
||||
**Recommendation:** Fleet-wide LDAP-group → role map only. Per-cluster scoping
|
||||
adds combinatorial complexity that v2's redundancy model doesn't need
|
||||
(every driver-role node runs every driver in the fleet).
|
||||
|
||||
### Q5 — Login UI: backed by `/auth/login` (cookie+JWT hybrid) — what about LDAP error UX?
|
||||
|
||||
**Context.** v2's `/auth/login` does an LDAP bind. Failures come back as
|
||||
specific reasons (invalid creds vs. service-account misconfig vs. server
|
||||
unreachable). The default behavior is to lump them all into "Login failed."
|
||||
|
||||
**Options.**
|
||||
- **Generic "Login failed."** Safer; doesn't leak whether the username exists.
|
||||
- **Specific error categories.** Helps operators diagnose deploy issues.
|
||||
|
||||
**Recommendation:** Generic for production deployments, specific when
|
||||
`Authentication:Ldap:AllowInsecureLdap=true` (dev mode signal).
|
||||
|
||||
## Proposed sequencing (4 phases)
|
||||
|
||||
Each phase is independently mergeable. The branch ships when Phase A is in;
|
||||
Phases B–D can follow as smaller PRs.
|
||||
|
||||
### Phase A — Shell + auth + fleet (minimum-viable Admin)
|
||||
~½–1 day. Ships a working admin surface with no config editing.
|
||||
- Port `App.razor`, `Routes.razor`, `_Imports.razor`
|
||||
- Port `Login.razor` (post Q5)
|
||||
- Port `Account.razor`
|
||||
- Reshape `Fleet.razor` against v2 data sources
|
||||
- Port `Hosts.razor` reshape
|
||||
|
||||
### Phase B — Cluster CRUD + Overview/Redundancy tabs
|
||||
~1 day. Adds cluster browse + readonly redundancy view.
|
||||
- Port `ClustersList`, `NewCluster`, `ClusterDetail` (Overview tab only)
|
||||
- Port `RedundancyTab` (read-only — surfaces v2 `ServiceLevel`)
|
||||
- Split into routes if Q3 = split
|
||||
|
||||
### Phase C — Config editor tabs
|
||||
~2 days. The big chunk — the live-edit config surface.
|
||||
- `EquipmentTab`, `UnsTab`, `NamespacesTab`
|
||||
- `DriversTab` (JSON-only initially per Q1)
|
||||
- `TagsTab`
|
||||
- `AclsTab` post Q4 reshape
|
||||
- `ImportEquipment`, `IdentificationFields`
|
||||
|
||||
### Phase D — Logic + ops pages
|
||||
~1 day.
|
||||
- `VirtualTagsTab`, `ScriptedAlarmsTab`, `ScriptsTab`, `ScriptEditor`
|
||||
- `AuditTab` against new ConfigAuditLog schema
|
||||
- `RoleGrants` post Q4 reshape
|
||||
- `Certificates`
|
||||
- `Reservations`
|
||||
- `AlarmsHistorian`, `ScriptLog` (depends on F16 ScriptLogHub deferred)
|
||||
|
||||
## Out of scope for F15
|
||||
|
||||
- Typed driver editors (Q1, deferred unless reversed)
|
||||
- Top-level fleet-wide ScriptedAlarms / VirtualTags pages (Q2, recommended drop)
|
||||
- Per-cluster RoleGrants (Q4, recommended drop)
|
||||
- ScriptLogHub SignalR bridge (F16 deferred — only needed for Phase D's
|
||||
ScriptLog page; can move to a separate F16-extension follow-up)
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// Persistence seam for <c>ScriptedAlarmActor</c>'s in-memory state across actor restarts.
|
||||
/// Captures only the slice the actor's 3-state machine needs (Inactive / Active /
|
||||
/// Acknowledged + last transition + last-ack user). The fuller GxP audit trail
|
||||
/// (<see cref="Configuration.Entities.ScriptedAlarmState"/>'s Comments/Confirmed/Shelving)
|
||||
/// stays in the production engine binding — this seam is the small surface the actor
|
||||
/// consumes directly.
|
||||
/// </summary>
|
||||
public interface IAlarmActorStateStore
|
||||
{
|
||||
Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct);
|
||||
Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Persisted slice of <c>ScriptedAlarmActor</c>'s state. Active is NOT persisted —
|
||||
/// it re-derives from the evaluator on startup per Phase 7 decision #14. <c>State</c> here
|
||||
/// distinguishes Acknowledged vs not-yet-acknowledged for cases where the actor came up
|
||||
/// Active and operator interaction had already happened.</summary>
|
||||
/// <param name="AlarmId">Matches <c>ScriptedAlarm.ScriptedAlarmId</c>.</param>
|
||||
/// <param name="State">Inactive / Active / Acknowledged — the actor's 3-state enum, projected to string.</param>
|
||||
/// <param name="LastTransitionUtc">When the actor last transitioned.</param>
|
||||
/// <param name="LastAckUser">Who acknowledged most recently. Null when never acked.</param>
|
||||
public sealed record AlarmActorStateSnapshot(
|
||||
string AlarmId,
|
||||
string State,
|
||||
DateTime LastTransitionUtc,
|
||||
string? LastAckUser);
|
||||
|
||||
/// <summary>No-op default. Bound when no production store is configured (tests, smoke runs).
|
||||
/// Load returns null → actor boots Inactive; Save is a no-op so state doesn't leak.</summary>
|
||||
public sealed class NullAlarmActorStateStore : IAlarmActorStateStore
|
||||
{
|
||||
public static readonly NullAlarmActorStateStore Instance = new();
|
||||
private NullAlarmActorStateStore() { }
|
||||
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct) =>
|
||||
Task.FromResult<AlarmActorStateSnapshot?>(null);
|
||||
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the scripted-alarm predicate engine. Production binds this to a
|
||||
/// wrapper around <c>ScriptedAlarmEngine</c> from <c>Core.ScriptedAlarms</c>; default
|
||||
/// binding is <see cref="NullScriptedAlarmEvaluator"/> which keeps the alarm in its
|
||||
/// current state (so an unconfigured node never spuriously alarms).
|
||||
/// </summary>
|
||||
public interface IScriptedAlarmEvaluator
|
||||
{
|
||||
ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies);
|
||||
}
|
||||
|
||||
/// <summary>Result of one alarm-predicate evaluation. <c>Active</c> is only meaningful when
|
||||
/// <c>Success</c> is true; on failure the caller should keep the prior state and log Reason.</summary>
|
||||
public sealed record ScriptedAlarmEvalResult(bool Success, bool Active, string? Reason)
|
||||
{
|
||||
public static ScriptedAlarmEvalResult Ok(bool active) => new(true, active, null);
|
||||
public static ScriptedAlarmEvalResult Failure(string reason) => new(false, false, reason);
|
||||
}
|
||||
|
||||
/// <summary>Default that always returns <c>Active = false, Success = true</c>. Safe no-op:
|
||||
/// no alarm fires when no real engine is bound.</summary>
|
||||
public sealed class NullScriptedAlarmEvaluator : IScriptedAlarmEvaluator
|
||||
{
|
||||
public static readonly NullScriptedAlarmEvaluator Instance = new();
|
||||
private NullScriptedAlarmEvaluator() { }
|
||||
public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies)
|
||||
=> ScriptedAlarmEvalResult.Ok(active: false);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the compiled virtual-tag expression engine. Runtime consumes this so
|
||||
/// <see cref="VirtualTagActor"/> can stay free of Roslyn / scripting machinery and the
|
||||
/// production wiring binds an adapter over <c>VirtualTagEngine</c> from
|
||||
/// <c>Core.VirtualTags</c>.
|
||||
/// </summary>
|
||||
public interface IVirtualTagEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="expression"/> against the snapshot in
|
||||
/// <paramref name="dependencies"/>. Implementations must not throw — script failures
|
||||
/// are reported via <see cref="VirtualTagEvalResult.Failure"/>.
|
||||
/// </summary>
|
||||
VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies);
|
||||
}
|
||||
|
||||
/// <summary>Result of one virtual-tag expression eval. Stash a Reason on every Failure so
|
||||
/// callers can emit a useful <c>ScriptLogEntry</c> to operators.</summary>
|
||||
public sealed record VirtualTagEvalResult(bool Success, object? Value, string? Reason)
|
||||
{
|
||||
public static readonly VirtualTagEvalResult NoChange = new(true, null, "no-change");
|
||||
public static VirtualTagEvalResult Ok(object? value) => new(true, value, null);
|
||||
public static VirtualTagEvalResult Failure(string reason) => new(false, null, reason);
|
||||
}
|
||||
|
||||
/// <summary>Returns <see cref="VirtualTagEvalResult.NoChange"/> from every call. Bound by default
|
||||
/// when the production <c>VirtualTagEngine</c> adapter hasn't been registered (Mac dev, tests).</summary>
|
||||
public sealed class NullVirtualTagEvaluator : IVirtualTagEvaluator
|
||||
{
|
||||
public static readonly NullVirtualTagEvaluator Instance = new();
|
||||
private NullVirtualTagEvaluator() { }
|
||||
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||
=> VirtualTagEvalResult.NoChange;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
|
||||
/// <summary>
|
||||
/// Live alarm transition published on the cluster <c>alerts</c> DistributedPubSub topic.
|
||||
/// Emitted by ScriptedAlarmActor (and future native-alarm bridges) when an alarm condition
|
||||
/// transitions; consumed by <c>AlertSignalRBridge</c> for browser fan-out and by historian
|
||||
/// adapters for durable storage.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">Stable condition identity (matches <c>ScriptedAlarm.ScriptedAlarmId</c> for scripted alarms).</param>
|
||||
/// <param name="EquipmentPath">UNS path of the Equipment node the alarm hangs under. Doubles as the SourceNode.</param>
|
||||
/// <param name="AlarmName">Operator-visible alarm name.</param>
|
||||
/// <param name="TransitionKind">Activated / Cleared / Acknowledged / Confirmed / Shelved / Unshelved / Disabled / Enabled / CommentAdded.</param>
|
||||
/// <param name="Severity">1–1000 numeric severity (OPC UA convention).</param>
|
||||
/// <param name="Message">Fully-rendered message text — template tokens already resolved.</param>
|
||||
/// <param name="User">Operator who triggered the transition. "system" for engine-driven events.</param>
|
||||
/// <param name="TimestampUtc">When the transition occurred.</param>
|
||||
public sealed record AlarmTransitionEvent(
|
||||
string AlarmId,
|
||||
string EquipmentPath,
|
||||
string AlarmName,
|
||||
string TransitionKind,
|
||||
int Severity,
|
||||
string Message,
|
||||
string User,
|
||||
DateTime TimestampUtc);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// One line of script log output published on the cluster <c>script-logs</c> DPS topic.
|
||||
/// Emitted by VirtualTagActor + ScriptedAlarmActor when their hosted scripts call into
|
||||
/// the runtime's logging facade; consumed by <c>ScriptLogSignalRBridge</c> for live
|
||||
/// browser tail-style viewing.
|
||||
/// </summary>
|
||||
/// <param name="ScriptId">The Script row this entry came from (matches <c>Script.ScriptId</c>).</param>
|
||||
/// <param name="Level">"Trace" / "Debug" / "Information" / "Warning" / "Error" / "Critical" — Serilog levels.</param>
|
||||
/// <param name="Message">Operator-facing log message; template tokens already resolved.</param>
|
||||
/// <param name="TimestampUtc">When the script emitted the entry.</param>
|
||||
/// <param name="VirtualTagId">VirtualTag context, if logged from a virtual tag evaluation. Null otherwise.</param>
|
||||
/// <param name="AlarmId">ScriptedAlarm context, if logged from an alarm predicate. Null otherwise.</param>
|
||||
/// <param name="EquipmentId">Equipment scope, if the script ran in a per-equipment context. Null for fleet-wide scripts.</param>
|
||||
public sealed record ScriptLogEntry(
|
||||
string ScriptId,
|
||||
string Level,
|
||||
string Message,
|
||||
DateTime TimestampUtc,
|
||||
string? VirtualTagId,
|
||||
string? AlarmId,
|
||||
string? EquipmentId);
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Central <see cref="Meter"/> + <see cref="ActivitySource"/> definitions for OtOpcUa.
|
||||
/// All Akka actors, the OPC UA publish path, and the deploy coordinator emit through these
|
||||
/// pre-created instruments so a single OpenTelemetry / Prometheus binding in <c>Host</c>
|
||||
/// catches everything. No exporter is required — instruments are no-op until a listener
|
||||
/// attaches, so tests and dev hosts pay nothing for instrumentation that nobody scrapes.
|
||||
///
|
||||
/// Instrument names follow the OpenTelemetry semantic convention pattern
|
||||
/// <c>otopcua.<subsystem>.<event></c>. Subsystem is one of: deploy, driver,
|
||||
/// virtualtag, scriptedalarm, opcua, redundancy.
|
||||
/// </summary>
|
||||
public static class OtOpcUaTelemetry
|
||||
{
|
||||
public const string MeterName = "ZB.MOM.WW.OtOpcUa";
|
||||
public const string ActivitySourceName = "ZB.MOM.WW.OtOpcUa";
|
||||
|
||||
/// <summary>Singleton <see cref="Meter"/> all counters/histograms hang off.</summary>
|
||||
public static readonly Meter Meter = new(MeterName);
|
||||
|
||||
/// <summary>Singleton <see cref="ActivitySource"/> used to start spans wrapping deploy/apply/rebuild.</summary>
|
||||
public static readonly ActivitySource ActivitySource = new(ActivitySourceName);
|
||||
|
||||
// ---------------- Deployment / driver-host coordination ----------------
|
||||
|
||||
/// <summary>Incremented every time DriverHostActor finishes applying a deployment (Ack or Reject).</summary>
|
||||
public static readonly Counter<long> DeploymentApplied =
|
||||
Meter.CreateCounter<long>("otopcua.deploy.applied", unit: "{deployment}",
|
||||
description: "Deployments applied by a driver-role node (outcome=ack|reject).");
|
||||
|
||||
/// <summary>Time from DriverHostActor receiving DispatchDeployment to emitting the ack/reject.</summary>
|
||||
public static readonly Histogram<double> DeploymentApplyDurationSec =
|
||||
Meter.CreateHistogram<double>("otopcua.deploy.apply.duration", unit: "s",
|
||||
description: "Driver-role apply latency from DispatchDeployment → Ack/Reject.");
|
||||
|
||||
/// <summary>DriverInstanceActor spawn count (added=new instance; stop=disposed).</summary>
|
||||
public static readonly Counter<long> DriverInstanceLifecycle =
|
||||
Meter.CreateCounter<long>("otopcua.driver.lifecycle", unit: "{event}",
|
||||
description: "DriverInstanceActor lifecycle transitions (event=spawn|stop|fault).");
|
||||
|
||||
// ---------------- VirtualTag / ScriptedAlarm engines ----------------
|
||||
|
||||
public static readonly Counter<long> VirtualTagEval =
|
||||
Meter.CreateCounter<long>("otopcua.virtualtag.eval", unit: "{eval}",
|
||||
description: "Virtual-tag evaluations attempted (outcome=ok|fail|skip).");
|
||||
|
||||
public static readonly Counter<long> ScriptedAlarmTransition =
|
||||
Meter.CreateCounter<long>("otopcua.scriptedalarm.transition", unit: "{transition}",
|
||||
description: "Scripted-alarm state transitions (state=active|acknowledged|inactive).");
|
||||
|
||||
// ---------------- OPC UA address-space + redundancy ----------------
|
||||
|
||||
public static readonly Counter<long> OpcUaSinkWrite =
|
||||
Meter.CreateCounter<long>("otopcua.opcua.sink.write", unit: "{write}",
|
||||
description: "Writes that landed in IOpcUaAddressSpaceSink (kind=value|alarm|rebuild).");
|
||||
|
||||
public static readonly Counter<long> ServiceLevelChange =
|
||||
Meter.CreateCounter<long>("otopcua.redundancy.service_level_change", unit: "{change}",
|
||||
description: "OPC UA Server.ServiceLevel transitions emitted by the redundancy state.");
|
||||
|
||||
// ---------------- Convenience helpers ----------------
|
||||
|
||||
/// <summary>
|
||||
/// Starts a deploy span tagged with the deployment id. Caller disposes to close. Returns
|
||||
/// null when no listener is attached so the call site stays cheap on undecorated builds.
|
||||
/// </summary>
|
||||
public static Activity? StartDeployApplySpan(string deploymentId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("otopcua.deploy.apply", ActivityKind.Internal);
|
||||
activity?.SetTag("otopcua.deployment_id", deploymentId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>Span wrapping a full OPC UA address-space rebuild (Phase7 plan → apply).</summary>
|
||||
public static Activity? StartAddressSpaceRebuildSpan()
|
||||
=> ActivitySource.StartActivity("otopcua.opcua.address_space_rebuild", ActivityKind.Internal);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper <see cref="IOpcUaAddressSpaceSink"/> that defers to an inner sink swapped in at
|
||||
/// runtime. Needed because the production sink (<c>SdkAddressSpaceSink</c>) wraps an
|
||||
/// <c>OtOpcUaNodeManager</c> that only exists after the SDK <c>StandardServer</c> has
|
||||
/// started — but Akka actors resolve their sink dependency at construction time, before
|
||||
/// the hosted service has booted the SDK.
|
||||
///
|
||||
/// Bound as a singleton in DI on driver-role hosts; the OPC UA hosted service calls
|
||||
/// <see cref="SetSink"/> once the server is up. Until that swap happens, every call is a
|
||||
/// no-op against <see cref="NullOpcUaAddressSpaceSink"/>, so the actor stays safe to
|
||||
/// receive messages from the moment it boots.
|
||||
/// </summary>
|
||||
public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
private volatile IOpcUaAddressSpaceSink _inner = NullOpcUaAddressSpaceSink.Instance;
|
||||
|
||||
/// <summary>Swap in the production sink. Pass <c>null</c> to revert to the null sink
|
||||
/// (used during graceful shutdown so post-stop writes don't hit a half-disposed manager).</summary>
|
||||
public void SetSink(IOpcUaAddressSpaceSink? sink) =>
|
||||
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
|
||||
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
||||
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
|
||||
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||
|
||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Late-binding adapter that holds an inner <see cref="IServiceLevelPublisher"/> reference
|
||||
/// swappable at runtime. Mirrors <see cref="DeferredAddressSpaceSink"/>: Akka actors resolve
|
||||
/// the publisher at DI time, but the production <c>SdkServiceLevelPublisher</c> only exists
|
||||
/// after <c>StandardServer.Start</c>. The Host's hosted service swaps the inner once the SDK
|
||||
/// is up; until then writes route through <see cref="NullServiceLevelPublisher"/>.
|
||||
/// </summary>
|
||||
public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
|
||||
{
|
||||
private volatile IServiceLevelPublisher _inner = NullServiceLevelPublisher.Instance;
|
||||
|
||||
/// <summary>Swap the underlying publisher. Pass null to revert to the Null no-op.</summary>
|
||||
public void SetInner(IServiceLevelPublisher? inner) =>
|
||||
_inner = inner ?? NullServiceLevelPublisher.Instance;
|
||||
|
||||
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the OPC UA SDK's address space. <c>OpcUaPublishActor</c> consumes this
|
||||
/// so the Runtime project doesn't reference <c>Opc.Ua.Server</c> directly — production
|
||||
/// binds a real SDK-backed sink in the fused Host's wiring, dev/Mac binds the
|
||||
/// <see cref="NullOpcUaAddressSpaceSink"/> no-op.
|
||||
/// </summary>
|
||||
public interface IOpcUaAddressSpaceSink
|
||||
{
|
||||
/// <summary>Write a Variable node's current value + quality + source timestamp.</summary>
|
||||
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc);
|
||||
|
||||
/// <summary>Write an alarm-condition Variable's active/acknowledged state.</summary>
|
||||
void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a folder node exists under the given parent. Used by <c>Phase7Applier</c> to
|
||||
/// materialise the UNS Area/Line/Equipment hierarchy in the address space. When
|
||||
/// <paramref name="parentNodeId"/> is null the folder is parented under the namespace
|
||||
/// root. Idempotent: calling twice with the same id is safe.
|
||||
/// </summary>
|
||||
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
|
||||
|
||||
/// <summary>
|
||||
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
||||
/// successful deployment apply so the node manager reflects the new config. Idempotent.
|
||||
/// </summary>
|
||||
void RebuildAddressSpace();
|
||||
}
|
||||
|
||||
/// <summary>OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained
|
||||
/// codes; the engine actors only need this 3-state classification.</summary>
|
||||
public enum OpcUaQuality { Good, Uncertain, Bad }
|
||||
|
||||
/// <summary>No-op sink. Bound by default so the actors are safe to run in dev / Mac /
|
||||
/// integration tests without a real SDK behind them.</summary>
|
||||
public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
public static readonly NullOpcUaAddressSpaceSink Instance = new();
|
||||
private NullOpcUaAddressSpaceSink() { }
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||
public void RebuildAddressSpace() { }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Writes the OPC UA Server object's <c>ServiceLevel</c> Variable (0–255). Production binds
|
||||
/// a sink that pokes the SDK's ServiceLevel node; tests + dev mode bind
|
||||
/// <see cref="NullServiceLevelPublisher"/> which just records the most recently set level
|
||||
/// for inspection.
|
||||
/// </summary>
|
||||
public interface IServiceLevelPublisher
|
||||
{
|
||||
void Publish(byte serviceLevel);
|
||||
}
|
||||
|
||||
/// <summary>No-op default that retains the last-written ServiceLevel in
|
||||
/// <see cref="LastPublished"/>. Used by dev mode + verified by tests.</summary>
|
||||
public sealed class NullServiceLevelPublisher : IServiceLevelPublisher
|
||||
{
|
||||
public static readonly NullServiceLevelPublisher Instance = new();
|
||||
private NullServiceLevelPublisher() { }
|
||||
public byte LastPublished { get; private set; }
|
||||
public void Publish(byte serviceLevel) => LastPublished = serviceLevel;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the process-wide driver registry. Runtime consumes this instead of
|
||||
/// <c>DriverFactoryRegistry</c> directly so the Runtime project doesn't pull in
|
||||
/// <c>ZB.MOM.WW.OtOpcUa.Core</c> (which would drag in Polly + driver hosting). The fused
|
||||
/// Host binds a <c>DriverFactoryRegistryAdapter</c> after every <c>Driver.*.Register()</c>
|
||||
/// extension has run.
|
||||
/// </summary>
|
||||
public interface IDriverFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Return a new <see cref="IDriver"/> for the given <paramref name="driverType"/>, or
|
||||
/// <c>null</c> when no factory is registered for that type (missing assembly, typo, etc.).
|
||||
/// The DriverHostActor logs + skips the row rather than failing the whole apply.
|
||||
/// </summary>
|
||||
IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson);
|
||||
|
||||
/// <summary>Driver-type names this factory can materialise. Mostly for diagnostics + logs.</summary>
|
||||
IReadOnlyCollection<string> SupportedTypes { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>null</c> from every <see cref="IDriverFactory.TryCreate"/> call. Bound when the
|
||||
/// fused Host hasn't registered any concrete driver assemblies yet (Mac dev path, smoke
|
||||
/// tests). DriverHostActor sees zero supported types and treats the deployment as a no-op.
|
||||
/// </summary>
|
||||
public sealed class NullDriverFactory : IDriverFactory
|
||||
{
|
||||
public static readonly NullDriverFactory Instance = new();
|
||||
private NullDriverFactory() { }
|
||||
|
||||
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => null;
|
||||
public IReadOnlyCollection<string> SupportedTypes { get; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts the existing <see cref="DriverFactoryRegistry"/> (v1 surface, still the
|
||||
/// concrete singleton every driver assembly registers itself against) to the v2
|
||||
/// <see cref="IDriverFactory"/> abstraction consumed by Runtime. The fused Host binds
|
||||
/// this in DI once each <c>Driver.*.Register(registry)</c> call has completed.
|
||||
/// </summary>
|
||||
public sealed class DriverFactoryRegistryAdapter : IDriverFactory
|
||||
{
|
||||
private readonly DriverFactoryRegistry _registry;
|
||||
|
||||
public DriverFactoryRegistryAdapter(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
_registry = registry;
|
||||
}
|
||||
|
||||
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
var factory = _registry.TryGet(driverType);
|
||||
return factory?.Invoke(driverInstanceId, driverConfigJson);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> SupportedTypes => _registry.RegisteredTypes;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
@* Root Blazor component for the fused OtOpcUa.Host. Static-rendered shell; child components
|
||||
opt into InteractiveServer on a per-component basis (the auth-related <Routes/> stays
|
||||
non-interactive so cookie SignInAsync still runs while ASP.NET owns the HTTP response).
|
||||
|
||||
Vendored Bootstrap 5 lives in this RCL's wwwroot/lib/bootstrap; the RCL static-asset
|
||||
pipeline maps it under /_content/ZB.MOM.WW.OtOpcUa.AdminUI/... — no public-CDN dependency
|
||||
so air-gapped fleet deployments keep working (Admin-010). *@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>OtOpcUa Admin</title>
|
||||
<base href="/"/>
|
||||
<link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/css/bootstrap.min.css"/>
|
||||
<link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/css/theme.css"/>
|
||||
<link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/css/site.css"/>
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
<body>
|
||||
<Routes/>
|
||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -46,8 +46,14 @@
|
||||
<div class="rail-eyebrow">Scripting</div>
|
||||
<NavLink class="rail-link" href="/virtual-tags" Match="NavLinkMatch.Prefix">Virtual tags</NavLink>
|
||||
<NavLink class="rail-link" href="/scripted-alarms" Match="NavLinkMatch.Prefix">Scripted alarms</NavLink>
|
||||
<NavLink class="rail-link" href="/scripts" Match="NavLinkMatch.Prefix">Scripts</NavLink>
|
||||
<NavLink class="rail-link" href="/script-log" Match="NavLinkMatch.Prefix">Script log</NavLink>
|
||||
|
||||
<div class="rail-eyebrow">Live</div>
|
||||
<NavLink class="rail-link" href="/deployments" Match="NavLinkMatch.Prefix">Deployments</NavLink>
|
||||
<NavLink class="rail-link" href="/alerts" Match="NavLinkMatch.Prefix">Alerts</NavLink>
|
||||
<NavLink class="rail-link" href="/alarms-historian" Match="NavLinkMatch.Prefix">Alarms historian</NavLink>
|
||||
|
||||
<div class="rail-foot">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
@page "/account"
|
||||
@* v1's Account page surfaced per-cluster role grants alongside identity. v2 dropped per-cluster
|
||||
grants in favour of fleet-wide LDAP-group → role mapping (Q4 of the AdminUI rebuild plan), so
|
||||
this version only shows identity + the resolved fleet roles + raw LDAP groups for
|
||||
troubleshooting. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@using System.Security.Claims
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">My account</h4>
|
||||
</div>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@{
|
||||
var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? context.User.Identity?.Name ?? "—";
|
||||
var displayName = context.User.Identity?.Name ?? "—";
|
||||
var roles = context.User.Claims
|
||||
.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var ldapGroups = context.User.Claims
|
||||
.Where(c => c.Type == "ldap_group").Select(c => c.Value)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
<section class="card-grid rise" style="animation-delay:.02s">
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div class="kv"><span class="k">Username</span><span class="v mono">@username</span></div>
|
||||
<div class="kv"><span class="k">Display name</span><span class="v">@displayName</span></div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Fleet roles</div>
|
||||
<div class="kv">
|
||||
<span class="k">Resolved roles</span>
|
||||
<span class="v">
|
||||
@if (roles.Count == 0)
|
||||
{
|
||||
<span class="text-muted">none — sign-in should have been blocked; session claim is likely stale</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var r in roles)
|
||||
{
|
||||
<span class="chip chip-idle me-1">@r</span>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<span class="k">LDAP groups</span>
|
||||
<span class="v">
|
||||
@if (ldapGroups.Count == 0)
|
||||
{
|
||||
<span class="text-muted">none</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var g in ldapGroups)
|
||||
{
|
||||
<span class="chip me-1 mono">@g</span>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.08s">
|
||||
Fleet roles come from LDAP group membership via the
|
||||
<span class="mono">Authentication:Ldap:GroupToRole</span> mapping. To change them,
|
||||
edit the LDAP group on the directory server; the next sign-in picks up the change.
|
||||
Sign out + sign back in to refresh the cookie claim.
|
||||
</section>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
@@ -0,0 +1,91 @@
|
||||
@page "/alarms-historian"
|
||||
@* Live status of the local node's IAlarmHistorianSink (queue depth, drain state) via the
|
||||
HistorianAdapterActor.GetStatus query landed in F11. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Akka.Actor
|
||||
@using Akka.Hosting
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian
|
||||
@using ZB.MOM.WW.OtOpcUa.Runtime
|
||||
@using ZB.MOM.WW.OtOpcUa.Runtime.Historian
|
||||
@inject IRequiredActor<HistorianAdapterActorKey> HistorianActor
|
||||
@implements IDisposable
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Alarms historian sink</h4>
|
||||
</div>
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Snapshot from the local node's <span class="mono">HistorianAdapterActor</span>. Default sink
|
||||
is a no-op (<span class="mono">NullAlarmHistorianSink</span>); production wires
|
||||
<span class="mono">SqliteStoreAndForwardSink</span> with the Wonderware historian sidecar
|
||||
behind it. Polling every @PollSeconds s.
|
||||
</section>
|
||||
|
||||
@if (_status is null)
|
||||
{
|
||||
<p class="mt-3">Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="card-grid rise mt-3" style="animation-delay:.08s">
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Queue</div>
|
||||
<div class="kv"><span class="k">Depth</span><span class="v numeric">@_status.QueueDepth</span></div>
|
||||
<div class="kv"><span class="k">Dead-lettered</span><span class="v numeric">@_status.DeadLetterDepth</span></div>
|
||||
<div class="kv"><span class="k">Evicted (lifetime)</span><span class="v numeric">@_status.EvictedCount</span></div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Drain state</div>
|
||||
<div class="kv"><span class="k">State</span><span class="v"><span class="@StateChipClass(_status.DrainState)">@_status.DrainState</span></span></div>
|
||||
<div class="kv"><span class="k">Last drain</span><span class="v">@(_status.LastDrainUtc?.ToString("u") ?? "—")</span></div>
|
||||
<div class="kv"><span class="k">Last success</span><span class="v">@(_status.LastSuccessUtc?.ToString("u") ?? "—")</span></div>
|
||||
@if (!string.IsNullOrWhiteSpace(_status.LastError))
|
||||
{
|
||||
<div class="kv"><span class="k">Last error</span><span class="v text-danger small">@_status.LastError</span></div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const int PollSeconds = 5;
|
||||
|
||||
private HistorianSinkStatus? _status;
|
||||
private Timer? _timer;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshAsync();
|
||||
_timer = new Timer(_ => _ = InvokeAsync(RefreshAsync), null,
|
||||
TimeSpan.FromSeconds(PollSeconds), TimeSpan.FromSeconds(PollSeconds));
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_status = await HistorianActor.ActorRef.Ask<HistorianSinkStatus>(
|
||||
HistorianAdapterActor.GetStatus.Instance, TimeSpan.FromSeconds(2));
|
||||
StateHasChanged();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Actor unavailable (admin-only node, not driver-role) — leave _status null and let
|
||||
// the page show "Loading…". A dedicated "this role doesn't run a historian" message
|
||||
// would be nicer; lands when we add role gating to the UI.
|
||||
}
|
||||
}
|
||||
|
||||
private static string StateChipClass(HistorianDrainState state) => state switch
|
||||
{
|
||||
HistorianDrainState.Disabled => "chip chip-idle",
|
||||
HistorianDrainState.Idle => "chip chip-idle",
|
||||
HistorianDrainState.Draining => "chip chip-ok",
|
||||
HistorianDrainState.BackingOff => "chip chip-caution",
|
||||
_ => "chip chip-idle",
|
||||
};
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
@page "/alerts"
|
||||
@* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent
|
||||
AlarmTransitionEvent entries. Engine wiring (ScriptedAlarmActor publish on the `alerts`
|
||||
topic) lands with F9; until then the connection stays open and the table is empty. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Alerts</h4>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="conn-pill" data-state="@(_connected ? "connected" : "disconnected")">
|
||||
<span class="dot"></span><span>@(_connected ? "live" : "disconnected")</span>
|
||||
</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ClearAsync">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Live alarm transitions from the cluster's <span class="mono">alerts</span> DPS topic. Shows
|
||||
the most-recent @Capacity entries since the page opened; reload for a fresh window. Sources:
|
||||
ScriptedAlarmActor, native AB CIP ALMD bridge (F9), Galaxy alarm bridge (future).
|
||||
</section>
|
||||
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<section class="panel notice rise mt-3" style="animation-delay:.08s">
|
||||
No alarms yet. Engine wiring (F9 ScriptedAlarmActor) is pending; once it ships the table
|
||||
below will start populating in real time.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Recent transitions (@_rows.Count)</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Alarm</th>
|
||||
<th>Equipment</th>
|
||||
<th>Kind</th>
|
||||
<th class="num">Severity</th>
|
||||
<th>User</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
|
||||
<td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td>
|
||||
<td><span class="mono small">@e.EquipmentPath</span></td>
|
||||
<td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
|
||||
<td class="num">@e.Severity</td>
|
||||
<td>@e.User</td>
|
||||
<td>@e.Message</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const int Capacity = 200;
|
||||
|
||||
private readonly List<AlarmTransitionEvent> _rows = new();
|
||||
private HubConnection? _hub;
|
||||
private bool _connected;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri(AlertHub.Endpoint))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hub.On<AlarmTransitionEvent>(AlertHub.MethodName, evt =>
|
||||
{
|
||||
_rows.Insert(0, evt);
|
||||
if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1);
|
||||
InvokeAsync(StateHasChanged);
|
||||
});
|
||||
_hub.Closed += _ => { _connected = false; return InvokeAsync(StateHasChanged); };
|
||||
_hub.Reconnected += _ => { _connected = true; return InvokeAsync(StateHasChanged); };
|
||||
|
||||
try
|
||||
{
|
||||
await _hub.StartAsync();
|
||||
_connected = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Connection failures (admin-only deployment, hub not mapped, etc.) leave the page
|
||||
// showing "disconnected" — operator action: reload or talk to the host operator.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearAsync()
|
||||
{
|
||||
_rows.Clear();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private static string KindChipClass(string kind) => kind switch
|
||||
{
|
||||
"Activated" => "chip-alert",
|
||||
"Cleared" => "chip-ok",
|
||||
"Acknowledged" or "Confirmed" => "chip-caution",
|
||||
"Shelved" or "Disabled" => "chip-idle",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null) await _hub.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
@page "/certificates"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using System.Security.Cryptography.X509Certificates
|
||||
@using Microsoft.Extensions.Configuration
|
||||
@inject IConfiguration Config
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">OPC UA certificates</h4>
|
||||
</div>
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
PKI store layout: <span class="mono">{PkiStoreRoot}/own</span> (this server's identity),
|
||||
<span class="mono">issuer</span> / <span class="mono">trusted</span> (peers we accept),
|
||||
<span class="mono">rejected</span> (peers we've turned away). F13a wires SDK
|
||||
auto-creation so the own-store self-signs on first boot.
|
||||
</section>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p class="mt-3">Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var store in _rows)
|
||||
{
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@store.Label · @store.Certificates.Count entry@(store.Certificates.Count == 1 ? "" : "s")</div>
|
||||
@if (string.IsNullOrEmpty(store.Path))
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No path configured.</div>
|
||||
}
|
||||
else if (!Directory.Exists(store.Path))
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">
|
||||
<span class="mono">@store.Path</span> doesn't exist yet. It will be created on first boot.
|
||||
</div>
|
||||
}
|
||||
else if (store.Certificates.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No certificates in <span class="mono">@store.Path</span>.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Subject</th>
|
||||
<th>Issuer</th>
|
||||
<th>Thumbprint</th>
|
||||
<th>Not before</th>
|
||||
<th>Not after</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var c in store.Certificates)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono small">@c.Subject</span></td>
|
||||
<td><span class="mono small">@c.Issuer</span></td>
|
||||
<td><span class="mono small">@c.Thumbprint[..16]…</span></td>
|
||||
<td>@c.NotBefore.ToString("u")</td>
|
||||
<td>@c.NotAfter.ToString("u")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<StoreView>? _rows;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var pkiRoot = Config.GetValue<string?>("OpcUa:PkiStoreRoot") ?? "pki";
|
||||
_rows = new()
|
||||
{
|
||||
LoadStore("Own", Path.Combine(pkiRoot, "own", "certs")),
|
||||
LoadStore("Trusted peers", Path.Combine(pkiRoot, "trusted", "certs")),
|
||||
LoadStore("Trusted issuers", Path.Combine(pkiRoot, "issuer", "certs")),
|
||||
LoadStore("Rejected", Path.Combine(pkiRoot, "rejected", "certs")),
|
||||
};
|
||||
}
|
||||
|
||||
private static StoreView LoadStore(string label, string path)
|
||||
{
|
||||
var view = new StoreView(label, path, new List<X509Certificate2>());
|
||||
if (!Directory.Exists(path)) return view;
|
||||
foreach (var file in Directory.EnumerateFiles(path).Where(IsCertFile))
|
||||
{
|
||||
try { view.Certificates.Add(X509CertificateLoader.LoadCertificateFromFile(file)); }
|
||||
catch { /* ignore unreadable entries */ }
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
private static bool IsCertFile(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path);
|
||||
return ext.Equals(".der", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".cer", StringComparison.OrdinalIgnoreCase)
|
||||
|| ext.Equals(".crt", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed record StoreView(string Label, string Path, List<X509Certificate2> Certificates);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
@page "/clusters/{ClusterId}/acls/new"
|
||||
@page "/clusters/{ClusterId}/acls/{NodeAclId}"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New ACL grant" : "Edit ACL grant") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="acls" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">ACL <span class="mono">@NodeAclId</span> not found.</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Grant</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="aid">NodeAclId</label>
|
||||
<InputText id="aid" @bind-Value="_form.NodeAclId" disabled="@(!IsNew)" class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="grp">LDAP group</label>
|
||||
<InputText id="grp" @bind-Value="_form.LdapGroup" class="form-control form-control-sm mono"
|
||||
placeholder="cn=Operators,ou=FleetAdmin,dc=lmxopcua,dc=local" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="scope">Scope kind</label>
|
||||
<InputSelect id="scope" @bind-Value="_form.ScopeKind" class="form-select form-select-sm">
|
||||
<option value="@NodeAclScopeKind.Cluster">Cluster</option>
|
||||
<option value="@NodeAclScopeKind.Namespace">Namespace</option>
|
||||
<option value="@NodeAclScopeKind.UnsArea">UnsArea</option>
|
||||
<option value="@NodeAclScopeKind.UnsLine">UnsLine</option>
|
||||
<option value="@NodeAclScopeKind.Equipment">Equipment</option>
|
||||
<option value="@NodeAclScopeKind.FolderSegment">FolderSegment</option>
|
||||
<option value="@NodeAclScopeKind.Tag">Tag</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="target">Scope target ID</label>
|
||||
<InputText id="target" @bind-Value="_form.ScopeId" class="form-control form-control-sm mono"
|
||||
placeholder="Leave blank for cluster-wide" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Permissions</label>
|
||||
<div>
|
||||
@foreach (var bit in PermissionBits)
|
||||
{
|
||||
<div class="form-check form-check-inline">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
id="perm-@bit"
|
||||
checked="@_form.HasPerm(bit)"
|
||||
@onchange="e => _form.SetPerm(bit, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="perm-@bit">@bit</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="form-text mt-2">
|
||||
Bundles:
|
||||
<button type="button" class="btn btn-sm btn-link p-0 ms-1" @onclick="() => _form.PermissionFlags = NodePermissions.ReadOnly">ReadOnly</button>
|
||||
·
|
||||
<button type="button" class="btn btn-sm btn-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Operator">Operator</button>
|
||||
·
|
||||
<button type="button" class="btn btn-sm btn-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Engineer">Engineer</button>
|
||||
·
|
||||
<button type="button" class="btn btn-sm btn-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Admin">Admin</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Notes</label>
|
||||
<InputTextArea @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div> }
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button>
|
||||
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary">Cancel</a>
|
||||
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly NodePermissions[] PermissionBits =
|
||||
[
|
||||
NodePermissions.Browse, NodePermissions.Read, NodePermissions.Subscribe, NodePermissions.HistoryRead,
|
||||
NodePermissions.WriteOperate, NodePermissions.WriteTune, NodePermissions.WriteConfigure,
|
||||
NodePermissions.AlarmRead, NodePermissions.AlarmAcknowledge, NodePermissions.AlarmConfirm, NodePermissions.AlarmShelve,
|
||||
NodePermissions.MethodCall,
|
||||
];
|
||||
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? NodeAclId { get; set; }
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(NodeAclId);
|
||||
|
||||
private FormModel _form = new();
|
||||
private NodeAcl? _existing;
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (!IsNew)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_existing = await db.NodeAcls.AsNoTracking().FirstOrDefaultAsync(
|
||||
a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
NodeAclId = _existing.NodeAclId,
|
||||
LdapGroup = _existing.LdapGroup,
|
||||
ScopeKind = _existing.ScopeKind,
|
||||
ScopeId = _existing.ScopeId,
|
||||
PermissionFlags = _existing.PermissionFlags,
|
||||
Notes = _existing.Notes,
|
||||
RowVersion = _existing.RowVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.NodeAcls.AnyAsync(a => a.NodeAclId == _form.NodeAclId))
|
||||
{ _error = $"ACL '{_form.NodeAclId}' already exists."; return; }
|
||||
db.NodeAcls.Add(new NodeAcl
|
||||
{
|
||||
NodeAclId = _form.NodeAclId,
|
||||
ClusterId = ClusterId,
|
||||
LdapGroup = _form.LdapGroup,
|
||||
ScopeKind = _form.ScopeKind,
|
||||
ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId,
|
||||
PermissionFlags = _form.PermissionFlags,
|
||||
Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.LdapGroup = _form.LdapGroup;
|
||||
entity.ScopeKind = _form.ScopeKind;
|
||||
entity.ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId;
|
||||
entity.PermissionFlags = _form.PermissionFlags;
|
||||
entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/acls");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this ACL while you were editing."; }
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/acls"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.NodeAcls.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/acls");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this ACL while you were viewing it."; }
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string NodeAclId { get; set; } = "";
|
||||
[Required] public string LdapGroup { get; set; } = "";
|
||||
public NodeAclScopeKind ScopeKind { get; set; } = NodeAclScopeKind.Cluster;
|
||||
public string? ScopeId { get; set; }
|
||||
public NodePermissions PermissionFlags { get; set; } = NodePermissions.None;
|
||||
public string? Notes { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
public bool HasPerm(NodePermissions bit) => PermissionFlags.HasFlag(bit);
|
||||
public void SetPerm(NodePermissions bit, bool on)
|
||||
{
|
||||
if (on) PermissionFlags |= bit;
|
||||
else PermissionFlags &= ~bit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
@page "/clusters/{ClusterId}/acls"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">ACLs · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/acls/new" class="btn btn-primary btn-sm">New ACL grant</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="acls" />
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
ACL rows grant LDAP groups specific <span class="mono">NodePermissions</span> on a scope
|
||||
(a folder, an equipment, a tag). Q4 of the AdminUI rebuild plan dropped per-cluster role
|
||||
grants in favour of fleet-wide LDAP-group → role mapping; ACLs here are the finer-grained
|
||||
per-node scope. Live editing lands in a Phase C.2 follow-up.
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@_rows.Count ACL row@(_rows.Count == 1 ? "" : "s")</div>
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No ACL rows for this cluster — default permissions from the fleet-wide LDAP group mapping apply.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>NodeAclId</th>
|
||||
<th>LDAP group</th>
|
||||
<th>Scope</th>
|
||||
<th>Scope target</th>
|
||||
<th>Permissions</th>
|
||||
<th>Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var a in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono small">@a.NodeAclId</span></td>
|
||||
<td><span class="mono">@a.LdapGroup</span></td>
|
||||
<td>@a.ScopeKind</td>
|
||||
<td><span class="mono small">@(a.ScopeId ?? "—")</span></td>
|
||||
<td>
|
||||
@foreach (var perm in PermissionChips(a.PermissionFlags))
|
||||
{
|
||||
<span class="chip chip-idle me-1">@perm</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-muted small">@(a.Notes ?? "")</td>
|
||||
<td><a href="/clusters/@ClusterId/acls/@a.NodeAclId" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
private List<NodeAcl>? _rows;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_rows = await db.NodeAcls.AsNoTracking()
|
||||
.Where(a => a.ClusterId == ClusterId)
|
||||
.OrderBy(a => a.NodeAclId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> PermissionChips(ZB.MOM.WW.OtOpcUa.Configuration.Enums.NodePermissions flags)
|
||||
{
|
||||
foreach (var v in Enum.GetValues<ZB.MOM.WW.OtOpcUa.Configuration.Enums.NodePermissions>())
|
||||
{
|
||||
// Skip None (zero) and composite values that aren't single bits.
|
||||
var n = (int)v;
|
||||
if (n == 0) continue;
|
||||
if ((n & (n - 1)) != 0) continue;
|
||||
if (flags.HasFlag(v)) yield return v.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
@page "/clusters/{ClusterId}/audit"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Audit log · <span class="mono">@ClusterId</span></h4>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="audit" />
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Latest @PageSize audit rows scoped to this cluster, newest first. EventId/CorrelationId
|
||||
columns (F3) make cross-restart deduplication possible — Akka actors that retry an apply
|
||||
won't insert duplicate rows. Details JSON is shown verbatim.
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@_rows.Count row@(_rows.Count == 1 ? "" : "s")</div>
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No audit rows for this cluster yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Principal</th>
|
||||
<th>Event</th>
|
||||
<th>Node</th>
|
||||
<th>Correlation</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var a in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono small">@a.Timestamp.ToString("u")</span></td>
|
||||
<td>@a.Principal</td>
|
||||
<td><span class="chip chip-idle">@a.EventType</span></td>
|
||||
<td><span class="mono small">@(a.NodeId ?? "—")</span></td>
|
||||
<td><span class="mono small">@(a.CorrelationId?.ToString("N")[..8] ?? "—")</span></td>
|
||||
<td class="text-muted small" style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
@(a.DetailsJson ?? "")
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const int PageSize = 200;
|
||||
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
private List<ConfigAuditLog>? _rows;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_rows = await db.ConfigAuditLogs.AsNoTracking()
|
||||
.Where(a => a.ClusterId == ClusterId)
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.Take(PageSize)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
@page "/clusters/{ClusterId}/drivers"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Drivers · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers/new" class="btn btn-primary btn-sm">New driver</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Per Q1 of the AdminUI rebuild plan, typed driver editors (Modbus, FOCAS) are deferred.
|
||||
The expanded view below shows raw JSON config. Live editing — including a generic JSON
|
||||
editor and per-driver-type forms when operators ask — lands in a Phase C.2 follow-up.
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@_rows.Count driver instance@(_rows.Count == 1 ? "" : "s")</div>
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No driver instances for this cluster.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var d in _rows)
|
||||
{
|
||||
<details style="border-top:1px solid var(--rule)">
|
||||
<summary style="padding:.75rem 1rem;cursor:pointer">
|
||||
<span class="mono">@d.DriverInstanceId</span>
|
||||
· <span>@d.Name</span>
|
||||
· <span class="chip chip-idle ms-1">@d.DriverType</span>
|
||||
@if (!d.Enabled) { <span class="chip chip-idle ms-1">Disabled</span> }
|
||||
<span class="text-muted small ms-2">ns=@d.NamespaceId</span>
|
||||
</summary>
|
||||
<div style="padding:0 1rem 1rem">
|
||||
<div class="d-flex mb-2">
|
||||
<a href="/clusters/@ClusterId/drivers/@d.DriverInstanceId" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
</div>
|
||||
<pre class="mono small" style="background:var(--surface-2);padding:1rem;border-radius:4px;overflow:auto">@FormatJson(d.DriverConfig)</pre>
|
||||
@if (!string.IsNullOrWhiteSpace(d.ResilienceConfig))
|
||||
{
|
||||
<div class="text-muted small mt-2">Resilience overrides:</div>
|
||||
<pre class="mono small" style="background:var(--surface-2);padding:1rem;border-radius:4px;overflow:auto">@FormatJson(d.ResilienceConfig)</pre>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
private List<DriverInstance>? _rows;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_rows = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.ClusterId == ClusterId)
|
||||
.OrderBy(d => d.DriverInstanceId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private static string FormatJson(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return "";
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(raw);
|
||||
return System.Text.Json.JsonSerializer.Serialize(doc.RootElement,
|
||||
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
@page "/clusters/{ClusterId}/edit"
|
||||
@* Edit page for an existing ServerCluster. The /clusters/new route lives in NewCluster.razor;
|
||||
this page handles only the update case so the form can disable ClusterId (immutable). *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Edit cluster · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="overview" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Cluster <span class="mono">@ClusterId</span> was not found.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="clusterEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">ClusterId</label>
|
||||
<input class="form-control form-control-sm mono" value="@ClusterId" disabled />
|
||||
<div class="form-text">Immutable after creation. Operator-visible everywhere; renames would invalidate every downstream reference.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="name">Name</label>
|
||||
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="enterprise">Enterprise</label>
|
||||
<InputText id="enterprise" @bind-Value="_form.Enterprise" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="site">Site</label>
|
||||
<InputText id="site" @bind-Value="_form.Site" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Enabled</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
|
||||
<label class="form-check-label">Spawn drivers + serve endpoints in deployments</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Topology</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="redundancy">Redundancy mode</label>
|
||||
<InputSelect id="redundancy" @bind-Value="_form.RedundancyMode" class="form-select form-select-sm">
|
||||
<option value="@RedundancyMode.None">None (1 node)</option>
|
||||
<option value="@RedundancyMode.Warm">Warm (2 nodes)</option>
|
||||
<option value="@RedundancyMode.Hot">Hot (2 nodes)</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="notes">Notes</label>
|
||||
<InputTextArea id="notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
|
||||
}
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Save changes
|
||||
</button>
|
||||
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary">Cancel</a>
|
||||
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">
|
||||
Delete cluster
|
||||
</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
|
||||
private FormModel _form = new();
|
||||
private ServerCluster? _existing;
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_existing = await db.ServerClusters.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ClusterId == ClusterId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
Name = _existing.Name,
|
||||
Enterprise = _existing.Enterprise,
|
||||
Site = _existing.Site,
|
||||
RedundancyMode = _existing.RedundancyMode,
|
||||
Enabled = _existing.Enabled,
|
||||
Notes = _existing.Notes,
|
||||
};
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.ServerClusters.FirstOrDefaultAsync(c => c.ClusterId == ClusterId);
|
||||
if (entity is null) { _error = "Cluster no longer exists."; return; }
|
||||
|
||||
var auth = await AuthState.GetAuthenticationStateAsync();
|
||||
entity.Name = _form.Name;
|
||||
entity.Enterprise = _form.Enterprise;
|
||||
entity.Site = _form.Site;
|
||||
entity.RedundancyMode = _form.RedundancyMode;
|
||||
entity.NodeCount = _form.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2;
|
||||
entity.Enabled = _form.Enabled;
|
||||
entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes;
|
||||
entity.ModifiedAt = DateTime.UtcNow;
|
||||
entity.ModifiedBy = auth.User.Identity?.Name ?? "(anonymous)";
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.ServerClusters.FirstOrDefaultAsync(c => c.ClusterId == ClusterId);
|
||||
if (entity is null) { Nav.NavigateTo("/clusters"); return; }
|
||||
db.ServerClusters.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo("/clusters");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Delete failed: {ex.Message}. Likely because nodes, namespaces, drivers, or other rows still reference this cluster — remove them first.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required] public string Name { get; set; } = "";
|
||||
[Required] public string Enterprise { get; set; } = "";
|
||||
[Required] public string Site { get; set; } = "";
|
||||
public RedundancyMode RedundancyMode { get; set; } = RedundancyMode.None;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
@page "/clusters/{ClusterId}/equipment"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Equipment · <span class="mono">@ClusterId</span></h4>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/clusters/@ClusterId/equipment/import" class="btn btn-outline-primary btn-sm">Import CSV…</a>
|
||||
<a href="/clusters/@ClusterId/equipment/new" class="btn btn-primary btn-sm">New equipment</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="equipment" />
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Equipment rows are scoped to a UNS line and bound to a single driver. EquipmentId is
|
||||
system-generated (decision #125); browse identifiers are MachineCode (operator) + ZTag
|
||||
(ERP). Live editing lands in a Phase C.2 follow-up.
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@_rows.Count equipment row@(_rows.Count == 1 ? "" : "s")</div>
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No equipment defined for this cluster.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>EquipmentId</th>
|
||||
<th>Name</th>
|
||||
<th>MachineCode</th>
|
||||
<th>ZTag</th>
|
||||
<th>Driver</th>
|
||||
<th>UNS line</th>
|
||||
<th>Identification</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono small">@e.EquipmentId</span></td>
|
||||
<td>@e.Name</td>
|
||||
<td><span class="mono">@e.MachineCode</span></td>
|
||||
<td>@(e.ZTag ?? "—")</td>
|
||||
<td><span class="mono small">@e.DriverInstanceId</span></td>
|
||||
<td><span class="mono small">@e.UnsLineId</span></td>
|
||||
<td class="text-muted small">
|
||||
@if (!string.IsNullOrWhiteSpace(e.Manufacturer)) { <span>@e.Manufacturer</span> }
|
||||
@if (!string.IsNullOrWhiteSpace(e.Model)) { <span class="ms-1">/ @e.Model</span> }
|
||||
</td>
|
||||
<td><a href="/clusters/@ClusterId/equipment/@e.EquipmentId" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
private List<Equipment>? _rows;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var driversInCluster = db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.ClusterId == ClusterId).Select(d => d.DriverInstanceId);
|
||||
_rows = await db.Equipment.AsNoTracking()
|
||||
.Where(e => driversInCluster.Contains(e.DriverInstanceId))
|
||||
.OrderBy(e => e.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
@page "/clusters/{ClusterId}/namespaces"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Namespaces · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/namespaces/new" class="btn btn-primary btn-sm">New namespace</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="namespaces" />
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Namespaces are content (decision #123) — they're served at the OPC UA endpoint and bound
|
||||
to driver instances. NamespaceUri must be unique fleet-wide. Live editing lands in a
|
||||
Phase C.2 follow-up.
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@_rows.Count namespace@(_rows.Count == 1 ? "" : "s")</div>
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No namespaces defined for this cluster.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>NamespaceId</th>
|
||||
<th>Kind</th>
|
||||
<th>URI</th>
|
||||
<th>Status</th>
|
||||
<th>Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var n in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@n.NamespaceId</span></td>
|
||||
<td>@n.Kind</td>
|
||||
<td><span class="mono small">@n.NamespaceUri</span></td>
|
||||
<td>
|
||||
@if (n.Enabled) { <span class="chip chip-ok">Enabled</span> }
|
||||
else { <span class="chip chip-idle">Disabled</span> }
|
||||
</td>
|
||||
<td class="text-muted small">@(n.Notes ?? "")</td>
|
||||
<td><a href="/clusters/@ClusterId/namespaces/@n.NamespaceId" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
private List<Namespace>? _rows;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_rows = await db.Namespaces.AsNoTracking()
|
||||
.Where(n => n.ClusterId == ClusterId)
|
||||
.OrderBy(n => n.NamespaceId)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
@page "/clusters/{ClusterId}"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_cluster is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Cluster <span class="mono">@ClusterId</span> was not found.
|
||||
<a class="ms-2" href="/clusters">Back to list</a>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0">@_cluster.Name</h4>
|
||||
<span class="mono text-muted">@_cluster.ClusterId</span>
|
||||
@if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> }
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/clusters/@ClusterId/edit" class="btn btn-outline-secondary btn-sm">Edit cluster</a>
|
||||
<a href="/deployments" class="btn btn-outline-primary btn-sm">Deployments</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="overview" />
|
||||
|
||||
<section class="card-grid rise" style="animation-delay:.08s">
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Cluster details</div>
|
||||
<div class="kv"><span class="k">Enterprise / Site</span><span class="v">@_cluster.Enterprise / @_cluster.Site</span></div>
|
||||
<div class="kv"><span class="k">Redundancy</span><span class="v">@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))</span></div>
|
||||
<div class="kv"><span class="k">Created</span><span class="v">@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy</span></div>
|
||||
@if (_cluster.ModifiedAt is not null)
|
||||
{
|
||||
<div class="kv"><span class="k">Modified</span><span class="v">@_cluster.ModifiedAt?.ToString("u") by @(_cluster.ModifiedBy ?? "—")</span></div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(_cluster.Notes))
|
||||
{
|
||||
<div class="kv"><span class="k">Notes</span><span class="v">@_cluster.Notes</span></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Last deployment</div>
|
||||
@if (_lastDeployment is null)
|
||||
{
|
||||
<div class="kv"><span class="k">Status</span><span class="v text-muted">none — cluster has never been deployed</span></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="kv"><span class="k">Revision</span><span class="v mono">@_lastDeployment.RevisionHash[..16]…</span></div>
|
||||
<div class="kv"><span class="k">Status</span><span class="v">@_lastDeployment.Status</span></div>
|
||||
<div class="kv"><span class="k">Created</span><span class="v">@_lastDeployment.CreatedAtUtc.ToString("u")</span></div>
|
||||
@if (_lastDeployment.SealedAtUtc is not null)
|
||||
{
|
||||
<div class="kv"><span class="k">Sealed</span><span class="v">@_lastDeployment.SealedAtUtc?.ToString("u")</span></div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head d-flex align-items-center">
|
||||
<span>Nodes</span>
|
||||
<a href="/clusters/@ClusterId/nodes/new" class="btn btn-sm btn-outline-primary ms-auto">New node</a>
|
||||
</div>
|
||||
@if (_nodes is null || _nodes.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No nodes registered.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node ID</th>
|
||||
<th>Host</th>
|
||||
<th>OPC UA port</th>
|
||||
<th>ApplicationUri</th>
|
||||
<th class="num">ServiceLevel base</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var n in _nodes)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@n.NodeId</span></td>
|
||||
<td>@n.Host</td>
|
||||
<td class="num">@n.OpcUaPort</td>
|
||||
<td><span class="mono small">@n.ApplicationUri</span></td>
|
||||
<td class="num">@n.ServiceLevelBase</td>
|
||||
<td><a href="/clusters/@ClusterId/nodes/@n.NodeId" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
|
||||
private bool _loaded;
|
||||
private ServerCluster? _cluster;
|
||||
private List<ClusterNode>? _nodes;
|
||||
private Deployment? _lastDeployment;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_cluster = await db.ServerClusters.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ClusterId == ClusterId);
|
||||
if (_cluster is not null)
|
||||
{
|
||||
_nodes = await db.ClusterNodes.AsNoTracking()
|
||||
.Where(n => n.ClusterId == ClusterId)
|
||||
.OrderBy(n => n.NodeId)
|
||||
.ToListAsync();
|
||||
_lastDeployment = await db.Deployments.AsNoTracking()
|
||||
.OrderByDescending(d => d.CreatedAtUtc)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
@page "/clusters/{ClusterId}/redundancy"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_cluster is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Cluster <span class="mono">@ClusterId</span> was not found.
|
||||
<a class="ms-2" href="/clusters">Back to list</a>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0">@_cluster.Name · Redundancy</h4>
|
||||
<span class="mono text-muted">@_cluster.ClusterId</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="redundancy" />
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
v2 redundancy is computed at runtime by <span class="mono">RedundancyStateActor</span>
|
||||
on each admin node. The values below are the static configuration; the resolved live
|
||||
<span class="mono">ServiceLevel</span> for each peer is broadcast on the
|
||||
<span class="mono">redundancy-state</span> DPS topic and consumed by the OPC UA host's
|
||||
<span class="mono">ServerStatus</span> publisher. See
|
||||
<a href="/docs/v2/Architecture-v2.md">docs/v2/Architecture-v2.md</a>.
|
||||
</section>
|
||||
|
||||
<section class="card-grid rise mt-3" style="animation-delay:.08s">
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Cluster redundancy</div>
|
||||
<div class="kv"><span class="k">Mode</span><span class="v">@_cluster.RedundancyMode</span></div>
|
||||
<div class="kv"><span class="k">Node count</span><span class="v">@_cluster.NodeCount</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Node service-level configuration</div>
|
||||
@if (_nodes is null || _nodes.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No nodes registered.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node ID</th>
|
||||
<th>ApplicationUri</th>
|
||||
<th class="num">ServiceLevel base</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var n in _nodes)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@n.NodeId</span></td>
|
||||
<td><span class="mono small">@n.ApplicationUri</span></td>
|
||||
<td class="num">@n.ServiceLevelBase</td>
|
||||
<td class="text-muted small">
|
||||
@if (n.ServiceLevelBase >= 200) { <text>Primary preference</text> }
|
||||
else if (n.ServiceLevelBase >= 100) { <text>Secondary preference</text> }
|
||||
else { <text>Custom</text> }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
|
||||
private bool _loaded;
|
||||
private ServerCluster? _cluster;
|
||||
private List<ClusterNode>? _nodes;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_cluster = await db.ServerClusters.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ClusterId == ClusterId);
|
||||
if (_cluster is not null)
|
||||
{
|
||||
_nodes = await db.ClusterNodes.AsNoTracking()
|
||||
.Where(n => n.ClusterId == ClusterId)
|
||||
.OrderBy(n => n.NodeId)
|
||||
.ToListAsync();
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
@page "/clusters/{ClusterId}/tags"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Tags · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/tags/new" class="btn btn-primary btn-sm">New tag</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="tags" />
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Tags are bound to a driver instance and (optionally) an equipment + poll group. The view
|
||||
below shows the first @PageSize tags by Name; full pagination + search land in Phase C.2.
|
||||
</section>
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2 mt-3">
|
||||
<input type="text" class="form-control form-control-sm" style="max-width:300px"
|
||||
placeholder="Filter by name (substring)…"
|
||||
@bind="_filter" @bind:event="oninput" />
|
||||
<span class="text-muted small">
|
||||
Showing @VisibleRows.Count of @_rows.Count
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Tags</div>
|
||||
@if (VisibleRows.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No tags match the current filter.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>TagId</th>
|
||||
<th>Name</th>
|
||||
<th>Driver</th>
|
||||
<th>Equipment</th>
|
||||
<th>Data type</th>
|
||||
<th>Access</th>
|
||||
<th>Folder</th>
|
||||
<th>Poll group</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var t in VisibleRows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono small">@t.TagId</span></td>
|
||||
<td>@t.Name</td>
|
||||
<td><span class="mono small">@t.DriverInstanceId</span></td>
|
||||
<td>@(t.EquipmentId ?? "—")</td>
|
||||
<td><span class="mono small">@t.DataType</span></td>
|
||||
<td>@t.AccessLevel</td>
|
||||
<td class="text-muted small">@(t.FolderPath ?? "")</td>
|
||||
<td>@(t.PollGroupId ?? "—")</td>
|
||||
<td><a href="/clusters/@ClusterId/tags/@t.TagId" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const int PageSize = 200;
|
||||
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
private List<Tag>? _rows;
|
||||
private string _filter = "";
|
||||
|
||||
private List<Tag> VisibleRows => (_rows ?? new())
|
||||
.Where(t => string.IsNullOrWhiteSpace(_filter)
|
||||
|| t.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(PageSize)
|
||||
.ToList();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
// Tags don't carry ClusterId; resolve via DriverInstance scoping.
|
||||
var driverIds = db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.ClusterId == ClusterId)
|
||||
.Select(d => d.DriverInstanceId);
|
||||
_rows = await db.Tags.AsNoTracking()
|
||||
.Where(t => driverIds.Contains(t.DriverInstanceId))
|
||||
.OrderBy(t => t.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
@page "/clusters/{ClusterId}/uns"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">UNS structure · <span class="mono">@ClusterId</span></h4>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="uns" />
|
||||
|
||||
@if (_areas is null || _lines is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
UNS levels: Enterprise (cluster) → Site (cluster) → Area → Line → Equipment. Areas and
|
||||
lines are cluster-scoped; equipment hangs under a single line. Live editing lands in a
|
||||
Phase C.2 follow-up.
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head d-flex align-items-center">
|
||||
<span>Areas (level 3) · @_areas.Count</span>
|
||||
<a href="/clusters/@ClusterId/uns/areas/new" class="btn btn-sm btn-outline-primary ms-auto">New area</a>
|
||||
</div>
|
||||
@if (_areas.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No areas defined.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>UnsAreaId</th><th>Name</th><th>Notes</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _areas)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@a.UnsAreaId</span></td>
|
||||
<td>@a.Name</td>
|
||||
<td class="text-muted small">@(a.Notes ?? "")</td>
|
||||
<td><a href="/clusters/@ClusterId/uns/areas/@a.UnsAreaId" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head d-flex align-items-center">
|
||||
<span>Lines (level 4) · @_lines.Count</span>
|
||||
<a href="/clusters/@ClusterId/uns/lines/new" class="btn btn-sm btn-outline-primary ms-auto">New line</a>
|
||||
</div>
|
||||
@if (_lines.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No lines defined.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>UnsLineId</th><th>Name</th><th>Area</th><th>Notes</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var l in _lines)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@l.UnsLineId</span></td>
|
||||
<td>@l.Name</td>
|
||||
<td><span class="mono">@l.UnsAreaId</span></td>
|
||||
<td class="text-muted small">@(l.Notes ?? "")</td>
|
||||
<td><a href="/clusters/@ClusterId/uns/lines/@l.UnsLineId" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
private List<UnsArea>? _areas;
|
||||
private List<UnsLine>? _lines;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_areas = await db.UnsAreas.AsNoTracking()
|
||||
.Where(a => a.ClusterId == ClusterId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync();
|
||||
var areaIds = _areas.Select(a => a.UnsAreaId).ToList();
|
||||
_lines = await db.UnsLines.AsNoTracking()
|
||||
.Where(l => areaIds.Contains(l.UnsAreaId))
|
||||
.OrderBy(l => l.UnsAreaId).ThenBy(l => l.UnsLineId)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
@page "/clusters"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Clusters</h4>
|
||||
<a href="/clusters/new" class="btn btn-primary btn-sm">New cluster</a>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
No clusters defined yet. Use <strong>New cluster</strong> above to create one.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">All clusters</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cluster</th>
|
||||
<th>Site</th>
|
||||
<th>Nodes</th>
|
||||
<th>Redundancy</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var c in _rows)
|
||||
{
|
||||
<tr style="cursor:pointer" @onclick="() => OpenCluster(c.ClusterId)">
|
||||
<td>
|
||||
<span class="mono">@c.ClusterId</span>
|
||||
<div class="text-muted small">@c.Name</div>
|
||||
</td>
|
||||
<td>@c.Enterprise / @c.Site</td>
|
||||
<td class="num">@c.NodeCount</td>
|
||||
<td>@c.RedundancyMode</td>
|
||||
<td>
|
||||
@if (c.Enabled) { <span class="chip chip-ok">Enabled</span> }
|
||||
else { <span class="chip chip-idle">Disabled</span> }
|
||||
</td>
|
||||
<td class="text-muted small">@c.CreatedAt.ToString("u")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ServerCluster>? _rows;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_rows = await db.ServerClusters.AsNoTracking()
|
||||
.OrderBy(c => c.ClusterId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private void OpenCluster(string clusterId) => Nav.NavigateTo($"/clusters/{clusterId}");
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
@page "/clusters/{ClusterId}/drivers/new"
|
||||
@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"
|
||||
@* Per Q1 of the AdminUI rebuild plan — JSON editor only, typed driver editors deferred.
|
||||
DriverInstance is the keystone for everything downstream (Equipment, Tag, VirtualTag,
|
||||
ScriptedAlarm all reference DriverInstanceId), so this is the second edit page after
|
||||
Namespace. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New driver instance" : "Edit driver instance") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="driverEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">@(IsNew ? "Identity" : $"Edit {_form.DriverInstanceId}")</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="instId">DriverInstanceId</label>
|
||||
<InputText id="instId" @bind-Value="_form.DriverInstanceId" disabled="@(!IsNew)"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder="drv-modbus-line3-01" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="name">Display name</label>
|
||||
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm"
|
||||
placeholder="Line 3 Modbus" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="dtype">Driver type</label>
|
||||
<InputSelect id="dtype" @bind-Value="_form.DriverType" disabled="@(!IsNew)"
|
||||
class="form-select form-select-sm">
|
||||
<option value="ModbusTcp">ModbusTcp</option>
|
||||
<option value="AbCip">AbCip</option>
|
||||
<option value="AbLegacy">AbLegacy</option>
|
||||
<option value="S7">S7</option>
|
||||
<option value="TwinCat">TwinCat</option>
|
||||
<option value="Focas">Focas</option>
|
||||
<option value="OpcUaClient">OpcUaClient</option>
|
||||
<option value="Galaxy">Galaxy</option>
|
||||
<option value="Historian.Wonderware">Historian.Wonderware</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="ns">Namespace</label>
|
||||
<InputSelect id="ns" @bind-Value="_form.NamespaceId" class="form-select form-select-sm">
|
||||
@foreach (var ns in _namespaces)
|
||||
{
|
||||
<option value="@ns.NamespaceId">@ns.NamespaceId (@ns.Kind)</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="enabled">Enabled</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox id="enabled" @bind-Value="_form.Enabled" class="form-check-input" />
|
||||
<label class="form-check-label" for="enabled">Spawn this driver in deployments</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Driver config (JSON)</div>
|
||||
<div style="padding:1rem">
|
||||
<InputTextArea @bind-Value="_form.DriverConfig" rows="12"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder='{ "endpoint": "10.0.0.42:502", "slaveId": 1 }' />
|
||||
<div class="form-text">Schemaless per driver type — validated server-side at deploy time. JSON is reformatted on save.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Resilience overrides (optional)</div>
|
||||
<div style="padding:1rem">
|
||||
<InputTextArea @bind-Value="_form.ResilienceConfig" rows="6"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder='Leave blank to use tier defaults' />
|
||||
<div class="form-text">Polly pipeline overrides per docs/v2/driver-stability.md — bulkhead, retry counts, breaker thresholds. Null = use the driver type's tier defaults.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
|
||||
}
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(IsNew ? "Create" : "Save changes")
|
||||
</button>
|
||||
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary">Cancel</a>
|
||||
@if (!IsNew)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? DriverInstanceId { get; set; }
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
||||
|
||||
private FormModel _form = new();
|
||||
private DriverInstance? _existing;
|
||||
private List<Namespace> _namespaces = new();
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_namespaces = await db.Namespaces.AsNoTracking()
|
||||
.Where(n => n.ClusterId == ClusterId)
|
||||
.OrderBy(n => n.NamespaceId)
|
||||
.ToListAsync();
|
||||
|
||||
if (IsNew)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
DriverInstanceId = "",
|
||||
Name = "",
|
||||
DriverType = "ModbusTcp",
|
||||
NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "",
|
||||
Enabled = true,
|
||||
DriverConfig = "{}",
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
_existing = await db.DriverInstances.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
DriverInstanceId = _existing.DriverInstanceId,
|
||||
Name = _existing.Name,
|
||||
DriverType = _existing.DriverType,
|
||||
NamespaceId = _existing.NamespaceId,
|
||||
Enabled = _existing.Enabled,
|
||||
DriverConfig = _existing.DriverConfig,
|
||||
ResilienceConfig = _existing.ResilienceConfig,
|
||||
RowVersion = _existing.RowVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var normalizedConfig = NormalizeJson(_form.DriverConfig);
|
||||
if (normalizedConfig is null)
|
||||
{
|
||||
_error = "DriverConfig is not valid JSON.";
|
||||
return;
|
||||
}
|
||||
var normalizedResilience = NormalizeOptionalJson(_form.ResilienceConfig);
|
||||
if (!string.IsNullOrWhiteSpace(_form.ResilienceConfig) && normalizedResilience is null)
|
||||
{
|
||||
_error = "ResilienceConfig is not valid JSON. Leave blank to use defaults.";
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _form.DriverInstanceId))
|
||||
{
|
||||
_error = $"Driver instance '{_form.DriverInstanceId}' already exists.";
|
||||
return;
|
||||
}
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = _form.DriverInstanceId,
|
||||
ClusterId = ClusterId,
|
||||
NamespaceId = _form.NamespaceId,
|
||||
Name = _form.Name,
|
||||
DriverType = _form.DriverType,
|
||||
Enabled = _form.Enabled,
|
||||
DriverConfig = normalizedConfig,
|
||||
ResilienceConfig = normalizedResilience,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null)
|
||||
{
|
||||
_error = "Row no longer exists.";
|
||||
return;
|
||||
}
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.NamespaceId = _form.NamespaceId;
|
||||
entity.Name = _form.Name;
|
||||
entity.Enabled = _form.Enabled;
|
||||
entity.DriverConfig = normalizedConfig;
|
||||
entity.ResilienceConfig = normalizedResilience;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(
|
||||
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
|
||||
if (entity is null)
|
||||
{
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
return;
|
||||
}
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.DriverInstances.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeJson(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(input);
|
||||
return System.Text.Json.JsonSerializer.Serialize(doc.RootElement);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalJson(string? input) =>
|
||||
string.IsNullOrWhiteSpace(input) ? null : NormalizeJson(input);
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[A-Za-z0-9_-]+$", ErrorMessage = "Use letters, digits, dash, underscore.")]
|
||||
public string DriverInstanceId { get; set; } = "";
|
||||
[Required]
|
||||
public string Name { get; set; } = "";
|
||||
[Required]
|
||||
public string DriverType { get; set; } = "ModbusTcp";
|
||||
[Required]
|
||||
public string NamespaceId { get; set; } = "";
|
||||
public bool Enabled { get; set; } = true;
|
||||
[Required]
|
||||
public string DriverConfig { get; set; } = "{}";
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
@page "/clusters/{ClusterId}/equipment/new"
|
||||
@page "/clusters/{ClusterId}/equipment/{EquipmentId}"
|
||||
@* Equipment CRUD. EquipmentId is system-generated (decision #125) — operator picks Name +
|
||||
MachineCode + UnsLine + Driver; the EquipmentId is derived from the EquipmentUuid on create.
|
||||
OPC 40010 identification fields (Manufacturer, Model, etc.) are all optional. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New equipment" : "Edit equipment") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="equipment" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Equipment <span class="mono">@EquipmentId</span> was not found.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="equipmentEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div style="padding:1rem">
|
||||
@if (!IsNew)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">EquipmentId</label>
|
||||
<input class="form-control form-control-sm mono" value="@EquipmentId" disabled />
|
||||
<div class="form-text">System-generated; never operator-edited.</div>
|
||||
</div>
|
||||
}
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="name">Name</label>
|
||||
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm mono"
|
||||
placeholder="machine-01" />
|
||||
<div class="form-text">UNS level 5 segment; lowercase letters, digits, dashes, up to 32 chars.</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="machinecode">MachineCode</label>
|
||||
<InputText id="machinecode" @bind-Value="_form.MachineCode" class="form-control form-control-sm mono"
|
||||
placeholder="machine_001" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="line">UNS line</label>
|
||||
<InputSelect id="line" @bind-Value="_form.UnsLineId" class="form-select form-select-sm">
|
||||
<option value="">— pick a line —</option>
|
||||
@foreach (var l in _lines)
|
||||
{
|
||||
<option value="@l.UnsLineId">@l.UnsAreaId / @l.UnsLineId — @l.Name</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="driver">Driver instance</label>
|
||||
<InputSelect id="driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
|
||||
<option value="">— pick a driver —</option>
|
||||
@foreach (var d in _drivers)
|
||||
{
|
||||
<option value="@d.DriverInstanceId">@d.DriverInstanceId — @d.Name (@d.DriverType)</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="ztag">ZTag (ERP)</label>
|
||||
<InputText id="ztag" @bind-Value="_form.ZTag" class="form-control form-control-sm" />
|
||||
<div class="form-text">Unique fleet-wide via ExternalIdReservation.</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="sap">SAPID</label>
|
||||
<InputText id="sap" @bind-Value="_form.SAPID" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Enabled</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
|
||||
<label class="form-check-label">Surface in deployments</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">OPC 40010 identification (optional)</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3"><label class="form-label">Manufacturer</label><InputText @bind-Value="_form.Manufacturer" class="form-control form-control-sm" /></div>
|
||||
<div class="col-md-4 mb-3"><label class="form-label">Model</label><InputText @bind-Value="_form.Model" class="form-control form-control-sm" /></div>
|
||||
<div class="col-md-4 mb-3"><label class="form-label">SerialNumber</label><InputText @bind-Value="_form.SerialNumber" class="form-control form-control-sm" /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3"><label class="form-label">HardwareRevision</label><InputText @bind-Value="_form.HardwareRevision" class="form-control form-control-sm" /></div>
|
||||
<div class="col-md-3 mb-3"><label class="form-label">SoftwareRevision</label><InputText @bind-Value="_form.SoftwareRevision" class="form-control form-control-sm" /></div>
|
||||
<div class="col-md-3 mb-3"><label class="form-label">Year of construction</label><InputNumber @bind-Value="_form.YearOfConstruction" class="form-control form-control-sm" /></div>
|
||||
<div class="col-md-3 mb-3"><label class="form-label">AssetLocation</label><InputText @bind-Value="_form.AssetLocation" class="form-control form-control-sm" /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3"><label class="form-label">ManufacturerUri</label><InputText @bind-Value="_form.ManufacturerUri" class="form-control form-control-sm mono" /></div>
|
||||
<div class="col-md-6 mb-3"><label class="form-label">DeviceManualUri</label><InputText @bind-Value="_form.DeviceManualUri" class="form-control form-control-sm mono" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
|
||||
}
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(IsNew ? "Create" : "Save changes")
|
||||
</button>
|
||||
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary">Cancel</a>
|
||||
@if (!IsNew)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? EquipmentId { get; set; }
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(EquipmentId);
|
||||
|
||||
private FormModel _form = new();
|
||||
private Equipment? _existing;
|
||||
private List<UnsLine> _lines = new();
|
||||
private List<DriverInstance> _drivers = new();
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var areaIds = await db.UnsAreas.AsNoTracking()
|
||||
.Where(a => a.ClusterId == ClusterId).Select(a => a.UnsAreaId).ToListAsync();
|
||||
_lines = await db.UnsLines.AsNoTracking()
|
||||
.Where(l => areaIds.Contains(l.UnsAreaId))
|
||||
.OrderBy(l => l.UnsAreaId).ThenBy(l => l.UnsLineId)
|
||||
.ToListAsync();
|
||||
_drivers = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.ClusterId == ClusterId)
|
||||
.OrderBy(d => d.DriverInstanceId)
|
||||
.ToListAsync();
|
||||
|
||||
if (!IsNew)
|
||||
{
|
||||
_existing = await db.Equipment.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
Name = _existing.Name,
|
||||
MachineCode = _existing.MachineCode,
|
||||
UnsLineId = _existing.UnsLineId,
|
||||
DriverInstanceId = _existing.DriverInstanceId,
|
||||
ZTag = _existing.ZTag,
|
||||
SAPID = _existing.SAPID,
|
||||
Manufacturer = _existing.Manufacturer,
|
||||
Model = _existing.Model,
|
||||
SerialNumber = _existing.SerialNumber,
|
||||
HardwareRevision = _existing.HardwareRevision,
|
||||
SoftwareRevision = _existing.SoftwareRevision,
|
||||
YearOfConstruction = _existing.YearOfConstruction,
|
||||
AssetLocation = _existing.AssetLocation,
|
||||
ManufacturerUri = _existing.ManufacturerUri,
|
||||
DeviceManualUri = _existing.DeviceManualUri,
|
||||
Enabled = _existing.Enabled,
|
||||
RowVersion = _existing.RowVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(_form.UnsLineId)) { _error = "Pick a UNS line."; return; }
|
||||
if (string.IsNullOrEmpty(_form.DriverInstanceId)) { _error = "Pick a driver instance."; return; }
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
|
||||
if (await db.Equipment.AnyAsync(e => e.MachineCode == _form.MachineCode))
|
||||
{ _error = $"MachineCode '{_form.MachineCode}' already exists in this fleet."; return; }
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = equipmentId,
|
||||
EquipmentUuid = uuid,
|
||||
DriverInstanceId = _form.DriverInstanceId,
|
||||
UnsLineId = _form.UnsLineId,
|
||||
Name = _form.Name,
|
||||
MachineCode = _form.MachineCode,
|
||||
ZTag = string.IsNullOrWhiteSpace(_form.ZTag) ? null : _form.ZTag,
|
||||
SAPID = string.IsNullOrWhiteSpace(_form.SAPID) ? null : _form.SAPID,
|
||||
Manufacturer = _form.Manufacturer,
|
||||
Model = _form.Model,
|
||||
SerialNumber = _form.SerialNumber,
|
||||
HardwareRevision = _form.HardwareRevision,
|
||||
SoftwareRevision = _form.SoftwareRevision,
|
||||
YearOfConstruction = _form.YearOfConstruction,
|
||||
AssetLocation = _form.AssetLocation,
|
||||
ManufacturerUri = _form.ManufacturerUri,
|
||||
DeviceManualUri = _form.DeviceManualUri,
|
||||
Enabled = _form.Enabled,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.DriverInstanceId = _form.DriverInstanceId;
|
||||
entity.UnsLineId = _form.UnsLineId;
|
||||
entity.Name = _form.Name;
|
||||
entity.MachineCode = _form.MachineCode;
|
||||
entity.ZTag = string.IsNullOrWhiteSpace(_form.ZTag) ? null : _form.ZTag;
|
||||
entity.SAPID = string.IsNullOrWhiteSpace(_form.SAPID) ? null : _form.SAPID;
|
||||
entity.Manufacturer = _form.Manufacturer;
|
||||
entity.Model = _form.Model;
|
||||
entity.SerialNumber = _form.SerialNumber;
|
||||
entity.HardwareRevision = _form.HardwareRevision;
|
||||
entity.SoftwareRevision = _form.SoftwareRevision;
|
||||
entity.YearOfConstruction = _form.YearOfConstruction;
|
||||
entity.AssetLocation = _form.AssetLocation;
|
||||
entity.ManufacturerUri = _form.ManufacturerUri;
|
||||
entity.DeviceManualUri = _form.DeviceManualUri;
|
||||
entity.Enabled = _form.Enabled;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/equipment");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this equipment while you were editing."; }
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/equipment"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.Equipment.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/equipment");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this equipment while you were viewing it."; }
|
||||
catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because tags or virtual tags reference this equipment — remove them first."; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[a-z0-9-]{1,32}$", ErrorMessage = "Lowercase letters, digits, dashes only; max 32 chars.")]
|
||||
public string Name { get; set; } = "";
|
||||
[Required] public string MachineCode { get; set; } = "";
|
||||
[Required] public string UnsLineId { get; set; } = "";
|
||||
[Required] public string DriverInstanceId { get; set; } = "";
|
||||
public string? ZTag { get; set; }
|
||||
public string? SAPID { get; set; }
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? HardwareRevision { get; set; }
|
||||
public string? SoftwareRevision { get; set; }
|
||||
public short? YearOfConstruction { get; set; }
|
||||
public string? AssetLocation { get; set; }
|
||||
public string? ManufacturerUri { get; set; }
|
||||
public string? DeviceManualUri { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
@page "/clusters/{ClusterId}/equipment/import"
|
||||
@* Bulk equipment import via pasted CSV. Header row required; columns:
|
||||
Name, MachineCode, UnsLineId, DriverInstanceId, ZTag, SAPID, Manufacturer, Model
|
||||
Empty optional columns parsed as null. EquipmentId is system-generated per row
|
||||
(matches single-add path in EquipmentEdit.razor). *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Import equipment · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="equipment" />
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Paste CSV below. Required header columns (in order):
|
||||
<span class="mono">Name, MachineCode, UnsLineId, DriverInstanceId</span>.
|
||||
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
|
||||
Each row inserts one Equipment with a freshly-generated EquipmentId. Existing rows are
|
||||
detected by MachineCode and skipped (the importer is additive-only — no updates).
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">CSV</div>
|
||||
<div style="padding:1rem">
|
||||
<textarea class="form-control form-control-sm mono" rows="14"
|
||||
@bind="_csv" @bind:event="oninput"
|
||||
placeholder="Name,MachineCode,UnsLineId,DriverInstanceId,ZTag,SAPID,Manufacturer,Model mixer-01,MX_001,line-3,drv-modbus-line3-01,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
|
||||
}
|
||||
|
||||
@if (_preview is not null)
|
||||
{
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Preview · @_preview.Count row@(_preview.Count == 1 ? "" : "s") to import</div>
|
||||
@if (_preview.Count > 0)
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>MachineCode</th>
|
||||
<th>UNS line</th>
|
||||
<th>Driver</th>
|
||||
<th>ZTag</th>
|
||||
<th>SAPID</th>
|
||||
<th>Manufacturer</th>
|
||||
<th>Model</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var p in _preview)
|
||||
{
|
||||
<tr>
|
||||
<td>@p.Name</td>
|
||||
<td><span class="mono">@p.MachineCode</span></td>
|
||||
<td><span class="mono small">@p.UnsLineId</span></td>
|
||||
<td><span class="mono small">@p.DriverInstanceId</span></td>
|
||||
<td>@(p.ZTag ?? "")</td>
|
||||
<td>@(p.SAPID ?? "")</td>
|
||||
<td>@(p.Manufacturer ?? "")</td>
|
||||
<td>@(p.Model ?? "")</td>
|
||||
<td>
|
||||
@if (p.IsSkipped) { <span class="chip chip-idle">skip — exists</span> }
|
||||
else if (!string.IsNullOrEmpty(p.RowError)) { <span class="chip chip-alert">@p.RowError</span> }
|
||||
else { <span class="chip chip-ok">ready</span> }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button class="btn btn-outline-primary" @onclick="PreviewAsync" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Preview
|
||||
</button>
|
||||
<button class="btn btn-primary" @onclick="ImportAsync"
|
||||
disabled="@(_busy || _preview is null || _preview.All(p => p.IsSkipped || !string.IsNullOrEmpty(p.RowError)))">
|
||||
Import @(_preview?.Count(p => !p.IsSkipped && string.IsNullOrEmpty(p.RowError)) ?? 0) row(s)
|
||||
</button>
|
||||
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
|
||||
private string _csv = "";
|
||||
private List<PreviewRow>? _preview;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"];
|
||||
private static readonly string[] OptionalColumns = ["ZTag", "SAPID", "Manufacturer", "Model"];
|
||||
|
||||
private async Task PreviewAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_error = null;
|
||||
_preview = null;
|
||||
try
|
||||
{
|
||||
var parsed = ParseCsv(_csv);
|
||||
if (parsed is null) return;
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var driversInCluster = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.ClusterId == ClusterId)
|
||||
.Select(d => d.DriverInstanceId)
|
||||
.ToListAsync();
|
||||
var driverSet = driversInCluster.ToHashSet(StringComparer.Ordinal);
|
||||
var areaIds = await db.UnsAreas.AsNoTracking()
|
||||
.Where(a => a.ClusterId == ClusterId)
|
||||
.Select(a => a.UnsAreaId).ToListAsync();
|
||||
var validLines = await db.UnsLines.AsNoTracking()
|
||||
.Where(l => areaIds.Contains(l.UnsAreaId))
|
||||
.Select(l => l.UnsLineId).ToListAsync();
|
||||
var lineSet = validLines.ToHashSet(StringComparer.Ordinal);
|
||||
var existingMachineCodes = await db.Equipment.AsNoTracking()
|
||||
.Select(e => e.MachineCode).ToListAsync();
|
||||
var existingSet = existingMachineCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var row in parsed)
|
||||
{
|
||||
if (existingSet.Contains(row.MachineCode))
|
||||
{
|
||||
row.IsSkipped = true;
|
||||
continue;
|
||||
}
|
||||
if (!driverSet.Contains(row.DriverInstanceId))
|
||||
{
|
||||
row.RowError = $"driver '{row.DriverInstanceId}' not in this cluster";
|
||||
continue;
|
||||
}
|
||||
if (!lineSet.Contains(row.UnsLineId))
|
||||
{
|
||||
row.RowError = $"UNS line '{row.UnsLineId}' not in this cluster";
|
||||
}
|
||||
}
|
||||
_preview = parsed;
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task ImportAsync()
|
||||
{
|
||||
if (_preview is null) return;
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var added = 0;
|
||||
foreach (var row in _preview.Where(p => !p.IsSkipped && string.IsNullOrEmpty(p.RowError)))
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = equipmentId,
|
||||
EquipmentUuid = uuid,
|
||||
DriverInstanceId = row.DriverInstanceId,
|
||||
UnsLineId = row.UnsLineId,
|
||||
Name = row.Name,
|
||||
MachineCode = row.MachineCode,
|
||||
ZTag = row.ZTag,
|
||||
SAPID = row.SAPID,
|
||||
Manufacturer = row.Manufacturer,
|
||||
Model = row.Model,
|
||||
Enabled = true,
|
||||
});
|
||||
added++;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/equipment");
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private List<PreviewRow>? ParseCsv(string csv)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv)) { _error = "CSV is empty."; return null; }
|
||||
var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (lines.Length < 2) { _error = "Need a header row and at least one data row."; return null; }
|
||||
|
||||
var header = lines[0].Split(',').Select(c => c.Trim()).ToArray();
|
||||
for (var i = 0; i < RequiredColumns.Length; i++)
|
||||
{
|
||||
if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_error = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var rows = new List<PreviewRow>();
|
||||
for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++)
|
||||
{
|
||||
var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray();
|
||||
if (parts.Length < RequiredColumns.Length)
|
||||
{
|
||||
rows.Add(new PreviewRow { RowError = $"too few columns (got {parts.Length}, need {RequiredColumns.Length})" });
|
||||
continue;
|
||||
}
|
||||
rows.Add(new PreviewRow
|
||||
{
|
||||
Name = parts[0],
|
||||
MachineCode = parts[1],
|
||||
UnsLineId = parts[2],
|
||||
DriverInstanceId = parts[3],
|
||||
ZTag = NullIfEmpty(parts, 4),
|
||||
SAPID = NullIfEmpty(parts, 5),
|
||||
Manufacturer = NullIfEmpty(parts, 6),
|
||||
Model = NullIfEmpty(parts, 7),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string[] parts, int idx) =>
|
||||
idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
|
||||
|
||||
private sealed class PreviewRow
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string MachineCode { get; set; } = "";
|
||||
public string UnsLineId { get; set; } = "";
|
||||
public string DriverInstanceId { get; set; } = "";
|
||||
public string? ZTag { get; set; }
|
||||
public string? SAPID { get; set; }
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public bool IsSkipped { get; set; }
|
||||
public string? RowError { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
@page "/clusters/{ClusterId}/namespaces/new"
|
||||
@page "/clusters/{ClusterId}/namespaces/{NamespaceId}"
|
||||
@* Live-edit form pattern — one page handles both create (NamespaceId is null) and update.
|
||||
RowVersion is preserved across post-back so EF Core enforces last-write-wins; concurrency
|
||||
conflicts surface as a toast and reload the current row. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New namespace" : "Edit namespace") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/namespaces" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="namespaces" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Namespace <span class="mono">@NamespaceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="namespaceEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">@(IsNew ? "Identity" : $"Edit {_form.NamespaceId}")</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="nsId">NamespaceId</label>
|
||||
<InputText id="nsId" @bind-Value="_form.NamespaceId" disabled="@(!IsNew)"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder="LINE3-equipment" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="kind">Kind</label>
|
||||
<InputSelect id="kind" @bind-Value="_form.Kind" class="form-select form-select-sm">
|
||||
<option value="@NamespaceKind.Equipment">Equipment (raw signals)</option>
|
||||
<option value="@NamespaceKind.SystemPlatform">System Platform (Galaxy / MXAccess)</option>
|
||||
<option value="@NamespaceKind.Simulated">Simulated (replay — reserved)</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="enabled">Enabled</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox id="enabled" @bind-Value="_form.Enabled" class="form-check-input" />
|
||||
<label class="form-check-label" for="enabled">Active in deployments</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="uri">NamespaceUri</label>
|
||||
<InputText id="uri" @bind-Value="_form.NamespaceUri" class="form-control form-control-sm mono"
|
||||
placeholder="urn:zb:warsaw-west:equipment" />
|
||||
<div class="form-text">Must be unique fleet-wide. Clients pin discovery here.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="notes">Notes</label>
|
||||
<InputTextArea id="notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
|
||||
}
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(IsNew ? "Create" : "Save changes")
|
||||
</button>
|
||||
<a href="/clusters/@ClusterId/namespaces" class="btn btn-outline-secondary">Cancel</a>
|
||||
@if (!IsNew)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? NamespaceId { get; set; }
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(NamespaceId);
|
||||
|
||||
private FormModel _form = new();
|
||||
private Namespace? _existing;
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (IsNew)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
NamespaceId = "",
|
||||
Kind = NamespaceKind.Equipment,
|
||||
NamespaceUri = "",
|
||||
Enabled = true,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_existing = await db.Namespaces.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.ClusterId == ClusterId && n.NamespaceId == NamespaceId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
NamespaceId = _existing.NamespaceId,
|
||||
Kind = _existing.Kind,
|
||||
NamespaceUri = _existing.NamespaceUri,
|
||||
Enabled = _existing.Enabled,
|
||||
Notes = _existing.Notes,
|
||||
RowVersion = _existing.RowVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.Namespaces.AnyAsync(n => n.NamespaceId == _form.NamespaceId))
|
||||
{
|
||||
_error = $"Namespace '{_form.NamespaceId}' already exists.";
|
||||
return;
|
||||
}
|
||||
db.Namespaces.Add(new Namespace
|
||||
{
|
||||
NamespaceId = _form.NamespaceId,
|
||||
ClusterId = ClusterId,
|
||||
Kind = _form.Kind,
|
||||
NamespaceUri = _form.NamespaceUri,
|
||||
Enabled = _form.Enabled,
|
||||
Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.Namespaces.FirstOrDefaultAsync(
|
||||
n => n.ClusterId == ClusterId && n.NamespaceId == NamespaceId);
|
||||
if (entity is null)
|
||||
{
|
||||
_error = "Row no longer exists.";
|
||||
return;
|
||||
}
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.Kind = _form.Kind;
|
||||
entity.NamespaceUri = _form.NamespaceUri;
|
||||
entity.Enabled = _form.Enabled;
|
||||
entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/namespaces");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this namespace while you were editing. Reload to see the latest values, then re-apply your changes.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Namespaces.FirstOrDefaultAsync(
|
||||
n => n.ClusterId == ClusterId && n.NamespaceId == NamespaceId);
|
||||
if (entity is null)
|
||||
{
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/namespaces");
|
||||
return;
|
||||
}
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.Namespaces.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/namespaces");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_error = "Another user changed this namespace while you were viewing it. Reload before deleting.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[A-Za-z0-9_-]+$", ErrorMessage = "Use letters, digits, dash, underscore.")]
|
||||
public string NamespaceId { get; set; } = "";
|
||||
public NamespaceKind Kind { get; set; } = NamespaceKind.Equipment;
|
||||
[Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$", ErrorMessage = "Use a URN, e.g. urn:zb:warsaw-west:equipment.")]
|
||||
public string NamespaceUri { get; set; } = "";
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? Notes { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
@page "/clusters/new"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">New cluster</h4>
|
||||
<a href="/clusters" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="newCluster">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="clusterId">Cluster ID</label>
|
||||
<InputText id="clusterId" @bind-Value="_form.ClusterId" class="form-control form-control-sm mono"
|
||||
placeholder="LINE3-OPCUA" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="name">Name</label>
|
||||
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="enterprise">Enterprise</label>
|
||||
<InputText id="enterprise" @bind-Value="_form.Enterprise" class="form-control form-control-sm"
|
||||
placeholder="zb" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="site">Site</label>
|
||||
<InputText id="site" @bind-Value="_form.Site" class="form-control form-control-sm"
|
||||
placeholder="warsaw-west" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Topology</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="redundancy">Redundancy mode</label>
|
||||
<InputSelect id="redundancy" @bind-Value="_form.RedundancyMode" class="form-select form-select-sm">
|
||||
<option value="@RedundancyMode.None">None (1 node)</option>
|
||||
<option value="@RedundancyMode.Warm">Warm (2 nodes, non-transparent)</option>
|
||||
<option value="@RedundancyMode.Hot">Hot (2 nodes, non-transparent)</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">NodeCount is implied — 1 for None, 2 for Warm/Hot.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="notes">Notes</label>
|
||||
<InputTextArea id="notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="panel notice mt-3">@_error</div>
|
||||
}
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Create cluster
|
||||
</button>
|
||||
<a href="/clusters" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private FormModel _form = new();
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (await db.ServerClusters.AnyAsync(c => c.ClusterId == _form.ClusterId))
|
||||
{
|
||||
_error = $"Cluster '{_form.ClusterId}' already exists.";
|
||||
return;
|
||||
}
|
||||
|
||||
var auth = await AuthState.GetAuthenticationStateAsync();
|
||||
var createdBy = auth.User.Identity?.Name ?? "(anonymous)";
|
||||
|
||||
var entity = new ServerCluster
|
||||
{
|
||||
ClusterId = _form.ClusterId,
|
||||
Name = _form.Name,
|
||||
Enterprise = _form.Enterprise,
|
||||
Site = _form.Site,
|
||||
RedundancyMode = _form.RedundancyMode,
|
||||
NodeCount = _form.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2,
|
||||
Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = createdBy,
|
||||
};
|
||||
db.ServerClusters.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{entity.ClusterId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required, System.ComponentModel.DataAnnotations.RegularExpression("^[A-Z0-9_-]+$", ErrorMessage = "Use uppercase letters, digits, dash, underscore.")]
|
||||
public string ClusterId { get; set; } = "";
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
public string Name { get; set; } = "";
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
public string Enterprise { get; set; } = "zb";
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
public string Site { get; set; } = "";
|
||||
public RedundancyMode RedundancyMode { get; set; } = RedundancyMode.None;
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
@page "/clusters/{ClusterId}/nodes/new"
|
||||
@page "/clusters/{ClusterId}/nodes/{NodeId}"
|
||||
@* ClusterNode CRUD. ApplicationUri is fleet-wide unique — the EF unique index enforces this
|
||||
at SaveChanges. ServiceLevelBase defaults: 200 primary, 150 secondary. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New node" : "Edit node") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="overview" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Node <span class="mono">@NodeId</span> was not found in cluster <span class="mono">@ClusterId</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="nodeEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="nodeId">NodeId</label>
|
||||
<InputText id="nodeId" @bind-Value="_form.NodeId" disabled="@(!IsNew)"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder="LINE3-OPCUA-A" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="host">Host</label>
|
||||
<InputText id="host" @bind-Value="_form.Host" class="form-control form-control-sm mono"
|
||||
placeholder="line3-opc-a.plant.local" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="port">OPC UA port</label>
|
||||
<InputNumber id="port" @bind-Value="_form.OpcUaPort" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="dashport">Dashboard port</label>
|
||||
<InputNumber id="dashport" @bind-Value="_form.DashboardPort" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="uri">ApplicationUri</label>
|
||||
<InputText id="uri" @bind-Value="_form.ApplicationUri" class="form-control form-control-sm mono"
|
||||
placeholder="urn:zb:warsaw-west:line3:opc-a" />
|
||||
<div class="form-text">Must be unique fleet-wide. Clients pin trust here — never silently rewrite based on Host.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Redundancy + behaviour</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="slbase">ServiceLevel base</label>
|
||||
<InputNumber id="slbase" @bind-Value="_form.ServiceLevelBase" class="form-control form-control-sm" />
|
||||
<div class="form-text">200 = primary preference, 150 = secondary preference. Live ServiceLevel adjusts down on faults.</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Enabled</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
|
||||
<label class="form-check-label">Join the Akka cluster + serve endpoints</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="overrides">Driver config overrides (JSON, optional)</label>
|
||||
<InputTextArea id="overrides" @bind-Value="_form.DriverConfigOverridesJson" rows="6"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder='{ "drv-modbus-line3-01": { "endpoint": "10.0.0.43:502" } }' />
|
||||
<div class="form-text">Per-node merge over cluster-level <span class="mono">DriverInstance.DriverConfig</span>. Minimal by design — heavy node-specific config is a smell.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
|
||||
}
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(IsNew ? "Create" : "Save changes")
|
||||
</button>
|
||||
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary">Cancel</a>
|
||||
@if (!IsNew)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? NodeId { get; set; }
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(NodeId);
|
||||
|
||||
private FormModel _form = new();
|
||||
private ClusterNode? _existing;
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (IsNew)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
NodeId = "",
|
||||
Host = "",
|
||||
OpcUaPort = 4840,
|
||||
DashboardPort = 8081,
|
||||
ApplicationUri = "",
|
||||
ServiceLevelBase = 200,
|
||||
Enabled = true,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_existing = await db.ClusterNodes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(n => n.ClusterId == ClusterId && n.NodeId == NodeId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
NodeId = _existing.NodeId,
|
||||
Host = _existing.Host,
|
||||
OpcUaPort = _existing.OpcUaPort,
|
||||
DashboardPort = _existing.DashboardPort,
|
||||
ApplicationUri = _existing.ApplicationUri,
|
||||
ServiceLevelBase = _existing.ServiceLevelBase,
|
||||
Enabled = _existing.Enabled,
|
||||
DriverConfigOverridesJson = _existing.DriverConfigOverridesJson,
|
||||
};
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson))
|
||||
{
|
||||
try { using var _ = System.Text.Json.JsonDocument.Parse(_form.DriverConfigOverridesJson); }
|
||||
catch { _error = "DriverConfigOverridesJson is not valid JSON."; return; }
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.ClusterNodes.AnyAsync(n => n.NodeId == _form.NodeId))
|
||||
{
|
||||
_error = $"Node '{_form.NodeId}' already exists.";
|
||||
return;
|
||||
}
|
||||
var auth = await AuthState.GetAuthenticationStateAsync();
|
||||
db.ClusterNodes.Add(new ClusterNode
|
||||
{
|
||||
NodeId = _form.NodeId,
|
||||
ClusterId = ClusterId,
|
||||
Host = _form.Host,
|
||||
OpcUaPort = _form.OpcUaPort,
|
||||
DashboardPort = _form.DashboardPort,
|
||||
ApplicationUri = _form.ApplicationUri,
|
||||
ServiceLevelBase = _form.ServiceLevelBase,
|
||||
Enabled = _form.Enabled,
|
||||
DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = auth.User.Identity?.Name ?? "(anonymous)",
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.ClusterNodes.FirstOrDefaultAsync(
|
||||
n => n.ClusterId == ClusterId && n.NodeId == NodeId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
entity.Host = _form.Host;
|
||||
entity.OpcUaPort = _form.OpcUaPort;
|
||||
entity.DashboardPort = _form.DashboardPort;
|
||||
entity.ApplicationUri = _form.ApplicationUri;
|
||||
entity.ServiceLevelBase = _form.ServiceLevelBase;
|
||||
entity.Enabled = _form.Enabled;
|
||||
entity.DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.ClusterNodes.FirstOrDefaultAsync(
|
||||
n => n.ClusterId == ClusterId && n.NodeId == NodeId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}"); return; }
|
||||
db.ClusterNodes.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[A-Za-z0-9_-]+$")]
|
||||
public string NodeId { get; set; } = "";
|
||||
[Required] public string Host { get; set; } = "";
|
||||
[Range(1, 65535)] public int OpcUaPort { get; set; } = 4840;
|
||||
[Range(1, 65535)] public int DashboardPort { get; set; } = 8081;
|
||||
[Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$")]
|
||||
public string ApplicationUri { get; set; } = "";
|
||||
[Range(0, 255)] public byte ServiceLevelBase { get; set; } = 200;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? DriverConfigOverridesJson { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
@page "/clusters/{ClusterId}/tags/new"
|
||||
@page "/clusters/{ClusterId}/tags/{TagId}"
|
||||
@* Tag CRUD. EquipmentId is required when the chosen driver's namespace is Equipment-kind,
|
||||
forbidden when SystemPlatform-kind (decision #110); the form switches between
|
||||
"pick equipment" and "FolderPath input" based on namespace kind. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New tag" : "Edit tag") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/tags" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="tags" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Tag <span class="mono">@TagId</span> was not found.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="tagEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="tagId">TagId</label>
|
||||
<InputText id="tagId" @bind-Value="_form.TagId" disabled="@(!IsNew)"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder="tag-line3-temp-01" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="name">Name</label>
|
||||
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm"
|
||||
placeholder="Temperature setpoint" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="driver">Driver instance</label>
|
||||
<InputSelect id="driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
|
||||
<option value="">— pick a driver —</option>
|
||||
@foreach (var d in _drivers)
|
||||
{
|
||||
<option value="@d.DriverInstanceId">@d.DriverInstanceId — @d.Name</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="dtype">Data type</label>
|
||||
<InputSelect id="dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
|
||||
@foreach (var dt in DataTypes)
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@{ var driverNamespace = ResolveDriverNamespace(_form.DriverInstanceId); }
|
||||
@if (driverNamespace?.Kind == NamespaceKind.Equipment)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="equipment">Equipment</label>
|
||||
<InputSelect id="equipment" @bind-Value="_form.EquipmentId" class="form-select form-select-sm">
|
||||
<option value="">— pick equipment —</option>
|
||||
@foreach (var e in _equipment.Where(e => e.DriverInstanceId == _form.DriverInstanceId))
|
||||
{
|
||||
<option value="@e.EquipmentId">@e.MachineCode — @e.Name</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
}
|
||||
else if (driverNamespace?.Kind == NamespaceKind.SystemPlatform)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="folder">FolderPath (SystemPlatform namespace)</label>
|
||||
<InputText id="folder" @bind-Value="_form.FolderPath" class="form-control form-control-sm mono"
|
||||
placeholder="GalaxyArea/Machine_001" />
|
||||
<div class="form-text">Galaxy hierarchy preserved as v1 expressed it — no UNS rule.</div>
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_form.DriverInstanceId))
|
||||
{
|
||||
<div class="text-muted small mb-3">Pick a driver to see its namespace kind.</div>
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="access">Access level</label>
|
||||
<InputSelect id="access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
|
||||
<option value="@TagAccessLevel.Read">Read</option>
|
||||
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">WriteIdempotent</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.WriteIdempotent" class="form-check-input" />
|
||||
<label class="form-check-label">Safe to retry writes (decision #44–45)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="pgroup">PollGroupId (optional)</label>
|
||||
<InputText id="pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Tag config (JSON)</div>
|
||||
<div style="padding:1rem">
|
||||
<InputTextArea @bind-Value="_form.TagConfig" rows="8"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder='{ "register": 40001, "scale": 0.1 }' />
|
||||
<div class="form-text">Schemaless per driver type — register / address / scaling / byte-order. Validated server-side at deploy.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
|
||||
}
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(IsNew ? "Create" : "Save changes")
|
||||
</button>
|
||||
<a href="/clusters/@ClusterId/tags" class="btn btn-outline-secondary">Cancel</a>
|
||||
@if (!IsNew)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly string[] DataTypes =
|
||||
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
|
||||
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
|
||||
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? TagId { get; set; }
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(TagId);
|
||||
|
||||
private FormModel _form = new();
|
||||
private Tag? _existing;
|
||||
private List<DriverInstance> _drivers = new();
|
||||
private List<Equipment> _equipment = new();
|
||||
private Dictionary<string, Namespace> _namespacesByDriverInstance = new();
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_drivers = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.ClusterId == ClusterId)
|
||||
.OrderBy(d => d.DriverInstanceId)
|
||||
.ToListAsync();
|
||||
var namespaces = await db.Namespaces.AsNoTracking()
|
||||
.Where(n => n.ClusterId == ClusterId)
|
||||
.ToListAsync();
|
||||
var nsById = namespaces.ToDictionary(n => n.NamespaceId);
|
||||
_namespacesByDriverInstance = _drivers.ToDictionary(
|
||||
d => d.DriverInstanceId,
|
||||
d => nsById.TryGetValue(d.NamespaceId, out var ns) ? ns : namespaces.First());
|
||||
var driverIds = _drivers.Select(d => d.DriverInstanceId).ToHashSet();
|
||||
_equipment = await db.Equipment.AsNoTracking()
|
||||
.Where(e => driverIds.Contains(e.DriverInstanceId))
|
||||
.OrderBy(e => e.MachineCode)
|
||||
.ToListAsync();
|
||||
|
||||
if (!IsNew)
|
||||
{
|
||||
_existing = await db.Tags.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.TagId == TagId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
TagId = _existing.TagId,
|
||||
Name = _existing.Name,
|
||||
DriverInstanceId = _existing.DriverInstanceId,
|
||||
EquipmentId = _existing.EquipmentId,
|
||||
FolderPath = _existing.FolderPath,
|
||||
DataType = _existing.DataType,
|
||||
AccessLevel = _existing.AccessLevel,
|
||||
WriteIdempotent = _existing.WriteIdempotent,
|
||||
PollGroupId = _existing.PollGroupId,
|
||||
TagConfig = _existing.TagConfig,
|
||||
RowVersion = _existing.RowVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_form.DataType = "Float";
|
||||
_form.AccessLevel = TagAccessLevel.Read;
|
||||
_form.TagConfig = "{}";
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private Namespace? ResolveDriverNamespace(string driverId) =>
|
||||
string.IsNullOrEmpty(driverId) ? null
|
||||
: _namespacesByDriverInstance.TryGetValue(driverId, out var ns) ? ns : null;
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(_form.DriverInstanceId)) { _error = "Pick a driver."; return; }
|
||||
var ns = ResolveDriverNamespace(_form.DriverInstanceId);
|
||||
if (ns?.Kind == NamespaceKind.Equipment && string.IsNullOrEmpty(_form.EquipmentId))
|
||||
{ _error = "Driver lives in an Equipment-kind namespace — pick an equipment."; return; }
|
||||
if (ns?.Kind == NamespaceKind.SystemPlatform && !string.IsNullOrEmpty(_form.EquipmentId))
|
||||
{ _error = "Driver lives in a SystemPlatform namespace — EquipmentId must be empty (use FolderPath)."; return; }
|
||||
|
||||
try { using var _ = System.Text.Json.JsonDocument.Parse(_form.TagConfig); }
|
||||
catch { _error = "TagConfig is not valid JSON."; return; }
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.Tags.AnyAsync(t => t.TagId == _form.TagId))
|
||||
{ _error = $"Tag '{_form.TagId}' already exists."; return; }
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = _form.TagId,
|
||||
DriverInstanceId = _form.DriverInstanceId,
|
||||
EquipmentId = string.IsNullOrEmpty(_form.EquipmentId) ? null : _form.EquipmentId,
|
||||
Name = _form.Name,
|
||||
FolderPath = string.IsNullOrWhiteSpace(_form.FolderPath) ? null : _form.FolderPath,
|
||||
DataType = _form.DataType,
|
||||
AccessLevel = _form.AccessLevel,
|
||||
WriteIdempotent = _form.WriteIdempotent,
|
||||
PollGroupId = string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId,
|
||||
TagConfig = _form.TagConfig,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == TagId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.DriverInstanceId = _form.DriverInstanceId;
|
||||
entity.EquipmentId = string.IsNullOrEmpty(_form.EquipmentId) ? null : _form.EquipmentId;
|
||||
entity.Name = _form.Name;
|
||||
entity.FolderPath = string.IsNullOrWhiteSpace(_form.FolderPath) ? null : _form.FolderPath;
|
||||
entity.DataType = _form.DataType;
|
||||
entity.AccessLevel = _form.AccessLevel;
|
||||
entity.WriteIdempotent = _form.WriteIdempotent;
|
||||
entity.PollGroupId = string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId;
|
||||
entity.TagConfig = _form.TagConfig;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/tags");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this tag while you were editing."; }
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == TagId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/tags"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.Tags.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/tags");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this tag while you were viewing it."; }
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string TagId { get; set; } = "";
|
||||
[Required] public string Name { get; set; } = "";
|
||||
[Required] public string DriverInstanceId { get; set; } = "";
|
||||
public string? EquipmentId { get; set; }
|
||||
public string? FolderPath { get; set; }
|
||||
[Required] public string DataType { get; set; } = "Float";
|
||||
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
|
||||
public bool WriteIdempotent { get; set; }
|
||||
public string? PollGroupId { get; set; }
|
||||
[Required] public string TagConfig { get; set; } = "{}";
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
@page "/clusters/{ClusterId}/uns/areas/new"
|
||||
@page "/clusters/{ClusterId}/uns/areas/{UnsAreaId}"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New UNS area" : "Edit UNS area") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/uns" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="uns" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Area <span class="mono">@UnsAreaId</span> was not found.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="unsAreaEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">UNS area (level 3)</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="aid">UnsAreaId</label>
|
||||
<InputText id="aid" @bind-Value="_form.UnsAreaId" disabled="@(!IsNew)"
|
||||
class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="name">Name</label>
|
||||
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="notes">Notes</label>
|
||||
<InputTextArea id="notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
|
||||
}
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(IsNew ? "Create" : "Save changes")
|
||||
</button>
|
||||
<a href="/clusters/@ClusterId/uns" class="btn btn-outline-secondary">Cancel</a>
|
||||
@if (!IsNew)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? UnsAreaId { get; set; }
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(UnsAreaId);
|
||||
|
||||
private FormModel _form = new();
|
||||
private UnsArea? _existing;
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (!IsNew)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_existing = await db.UnsAreas.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
UnsAreaId = _existing.UnsAreaId,
|
||||
Name = _existing.Name,
|
||||
Notes = _existing.Notes,
|
||||
RowVersion = _existing.RowVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.UnsAreas.AnyAsync(a => a.UnsAreaId == _form.UnsAreaId))
|
||||
{ _error = $"Area '{_form.UnsAreaId}' already exists."; return; }
|
||||
db.UnsAreas.Add(new UnsArea
|
||||
{
|
||||
UnsAreaId = _form.UnsAreaId,
|
||||
ClusterId = ClusterId,
|
||||
Name = _form.Name,
|
||||
Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.UnsAreas.FirstOrDefaultAsync(
|
||||
a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.Name = _form.Name;
|
||||
entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/uns");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this area while you were editing. Reload to see the latest values."; }
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.UnsAreas.FirstOrDefaultAsync(
|
||||
a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/uns"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.UnsAreas.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/uns");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this area while you were viewing it."; }
|
||||
catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because lines still reference this area — remove them first."; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string UnsAreaId { get; set; } = "";
|
||||
[Required] public string Name { get; set; } = "";
|
||||
public string? Notes { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
@page "/clusters/{ClusterId}/uns/lines/new"
|
||||
@page "/clusters/{ClusterId}/uns/lines/{UnsLineId}"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New UNS line" : "Edit UNS line") · <span class="mono">@ClusterId</span></h4>
|
||||
<a href="/clusters/@ClusterId/uns" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
<ClusterNav ClusterId="@ClusterId" ActiveTab="uns" />
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Line <span class="mono">@UnsLineId</span> was not found.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="unsLineEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">UNS line (level 4)</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="lid">UnsLineId</label>
|
||||
<InputText id="lid" @bind-Value="_form.UnsLineId" disabled="@(!IsNew)"
|
||||
class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="area">Parent area</label>
|
||||
<InputSelect id="area" @bind-Value="_form.UnsAreaId" class="form-select form-select-sm">
|
||||
@foreach (var area in _areas)
|
||||
{
|
||||
<option value="@area.UnsAreaId">@area.UnsAreaId — @area.Name</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="name">Name</label>
|
||||
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="notes">Notes</label>
|
||||
<InputTextArea id="notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
|
||||
}
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@(IsNew ? "Create" : "Save changes")
|
||||
</button>
|
||||
<a href="/clusters/@ClusterId/uns" class="btn btn-outline-secondary">Cancel</a>
|
||||
@if (!IsNew)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = "";
|
||||
[Parameter] public string? UnsLineId { get; set; }
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(UnsLineId);
|
||||
|
||||
private FormModel _form = new();
|
||||
private UnsLine? _existing;
|
||||
private List<UnsArea> _areas = new();
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_areas = await db.UnsAreas.AsNoTracking()
|
||||
.Where(a => a.ClusterId == ClusterId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync();
|
||||
|
||||
if (!IsNew)
|
||||
{
|
||||
_existing = await db.UnsLines.AsNoTracking()
|
||||
.FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
UnsLineId = _existing.UnsLineId,
|
||||
UnsAreaId = _existing.UnsAreaId,
|
||||
Name = _existing.Name,
|
||||
Notes = _existing.Notes,
|
||||
RowVersion = _existing.RowVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_form.UnsAreaId = _areas.FirstOrDefault()?.UnsAreaId ?? "";
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.UnsLines.AnyAsync(l => l.UnsLineId == _form.UnsLineId))
|
||||
{ _error = $"Line '{_form.UnsLineId}' already exists."; return; }
|
||||
db.UnsLines.Add(new UnsLine
|
||||
{
|
||||
UnsLineId = _form.UnsLineId,
|
||||
UnsAreaId = _form.UnsAreaId,
|
||||
Name = _form.Name,
|
||||
Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.UnsAreaId = _form.UnsAreaId;
|
||||
entity.Name = _form.Name;
|
||||
entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/uns");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this line while you were editing."; }
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId);
|
||||
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/uns"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.UnsLines.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/uns");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this line while you were viewing it."; }
|
||||
catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because equipment still references this line — remove or re-home it first."; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string UnsLineId { get; set; } = "";
|
||||
[Required] public string UnsAreaId { get; set; } = "";
|
||||
[Required] public string Name { get; set; } = "";
|
||||
public string? Notes { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
@page "/fleet"
|
||||
@* Per-node deployment status. v2 reads NodeDeploymentState (the per-(node, deployment) apply
|
||||
progress row owned by each DriverHostActor) and projects the most-recent row per node. The
|
||||
Akka cluster topology comes from IClusterRoleInfo so we can show nodes that haven't applied
|
||||
anything yet alongside nodes that have. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IClusterRoleInfo Cluster
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@implements IDisposable
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Fleet status</h4>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
||||
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
|
||||
Refresh
|
||||
</button>
|
||||
<span class="text-muted small">
|
||||
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
No driver-role nodes are currently Up in the Akka cluster, and no NodeDeploymentState
|
||||
rows have been recorded yet. Either no driver nodes have joined or the cluster is still
|
||||
forming.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="agg-grid rise" style="animation-delay:.02s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Nodes</div>
|
||||
<div class="agg-value numeric">@_rows.Count</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Applied</div>
|
||||
<div class="agg-value numeric">@_rows.Count(r => r.Status == NodeDeploymentStatus.Applied)</div>
|
||||
</div>
|
||||
<div class="agg-card caution">
|
||||
<div class="agg-label">Applying</div>
|
||||
<div class="agg-value numeric">@_rows.Count(r => r.Status == NodeDeploymentStatus.Applying)</div>
|
||||
</div>
|
||||
<div class="agg-card alert">
|
||||
<div class="agg-label">Failed</div>
|
||||
<div class="agg-value numeric">@_rows.Count(r => r.Status == NodeDeploymentStatus.Failed)</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Nodes</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Roles</th>
|
||||
<th>Status</th>
|
||||
<th>Applied at</th>
|
||||
<th>Started at</th>
|
||||
<th>Failure reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@r.NodeId</span></td>
|
||||
<td>
|
||||
@foreach (var role in r.Roles)
|
||||
{
|
||||
<span class="chip chip-idle me-1">@role</span>
|
||||
}
|
||||
</td>
|
||||
<td><span class="chip @StatusChipClass(r.Status)">@StatusLabel(r.Status)</span></td>
|
||||
<td>@(r.AppliedAtUtc?.ToString("u") ?? "—")</td>
|
||||
<td>@(r.StartedAtUtc?.ToString("u") ?? "—")</td>
|
||||
<td>@(r.FailureReason ?? "")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const int RefreshIntervalSeconds = 10;
|
||||
|
||||
private List<NodeRow>? _rows;
|
||||
private bool _refreshing;
|
||||
private DateTime? _lastRefreshUtc;
|
||||
private Timer? _timer;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
_timer = new Timer(_ => _ = InvokeAsync(LoadAsync), null,
|
||||
TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||
TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||
}
|
||||
|
||||
private async Task RefreshAsync() => await LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_refreshing = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
// Project the most-recent NodeDeploymentState per node — that's the row the
|
||||
// DriverHostActor most recently touched, regardless of which deployment it was for.
|
||||
var states = await db.NodeDeploymentStates.AsNoTracking()
|
||||
.GroupBy(s => s.NodeId)
|
||||
.Select(g => g.OrderByDescending(s => s.StartedAtUtc).First())
|
||||
.ToListAsync();
|
||||
var byNode = states.ToDictionary(s => s.NodeId);
|
||||
|
||||
// Union with current Akka driver members so a freshly-joined node that has no
|
||||
// NodeDeploymentState row yet still appears as "waiting".
|
||||
var akkaDrivers = Cluster.MembersWithRole("driver")
|
||||
.Select(n => n.Value).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var allNodes = byNode.Keys.Union(akkaDrivers, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
_rows = allNodes.Select(nodeId =>
|
||||
{
|
||||
byNode.TryGetValue(nodeId, out var state);
|
||||
return new NodeRow(
|
||||
NodeId: nodeId,
|
||||
Roles: akkaDrivers.Contains(nodeId) ? new[] { "driver" } : Array.Empty<string>(),
|
||||
Status: state?.Status,
|
||||
StartedAtUtc: state?.StartedAtUtc,
|
||||
AppliedAtUtc: state?.AppliedAtUtc,
|
||||
FailureReason: state?.FailureReason);
|
||||
}).ToList();
|
||||
_lastRefreshUtc = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string StatusChipClass(NodeDeploymentStatus? status) => status switch
|
||||
{
|
||||
NodeDeploymentStatus.Applied => "chip-ok",
|
||||
NodeDeploymentStatus.Applying => "chip-caution",
|
||||
NodeDeploymentStatus.Failed => "chip-alert",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
private static string StatusLabel(NodeDeploymentStatus? status) => status?.ToString() ?? "waiting";
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
|
||||
private sealed record NodeRow(
|
||||
string NodeId,
|
||||
IReadOnlyCollection<string> Roles,
|
||||
NodeDeploymentStatus? Status,
|
||||
DateTime? StartedAtUtc,
|
||||
DateTime? AppliedAtUtc,
|
||||
string? FailureReason);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
@page "/hosts"
|
||||
@* Akka cluster topology: each member's NodeId (host:port), roles, leader status. v2 reshapes
|
||||
v1's "driver host" page — there are no per-driver host rows yet (driver-instance child actors
|
||||
land with F7). For now this is the cluster-membership view; expand to per-driver rows when
|
||||
DriverHostActor starts spawning DriverInstanceActor children. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Akka.Actor
|
||||
@using Akka.Cluster
|
||||
@inject ActorSystem ActorSystem
|
||||
@implements IDisposable
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Cluster hosts</h4>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
||||
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
|
||||
Refresh
|
||||
</button>
|
||||
<span class="text-muted small">
|
||||
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Each row is one Akka cluster member identified by <span class="mono">host:port</span>. Roles
|
||||
drive which actors run on which node — <span class="mono">admin</span> nodes host the
|
||||
control-plane singletons, <span class="mono">driver</span> nodes host the per-node runtime
|
||||
actors. The leader columns identify which member currently owns each role's singletons.
|
||||
</section>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.08s">
|
||||
No cluster members visible. The local node may still be joining.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="agg-grid rise" style="animation-delay:.08s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Members</div>
|
||||
<div class="agg-value numeric">@_rows.Count</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Up</div>
|
||||
<div class="agg-value numeric">@_rows.Count(r => r.Status == "Up")</div>
|
||||
</div>
|
||||
<div class="agg-card caution">
|
||||
<div class="agg-label">Joining/Leaving</div>
|
||||
<div class="agg-value numeric">@_rows.Count(r => r.Status is "Joining" or "Leaving" or "Exiting")</div>
|
||||
</div>
|
||||
<div class="agg-card alert">
|
||||
<div class="agg-label">Unreachable</div>
|
||||
<div class="agg-value numeric">@_rows.Count(r => r.Unreachable)</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<div class="panel-head">Members</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Address</th>
|
||||
<th>Status</th>
|
||||
<th>Roles</th>
|
||||
<th>Leader for</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<span class="mono">@r.Address</span>
|
||||
@if (r.IsSelf) { <span class="chip chip-idle ms-2">self</span> }
|
||||
</td>
|
||||
<td>
|
||||
<span class="chip @StatusChipClass(r.Status, r.Unreachable)">
|
||||
@(r.Unreachable ? $"{r.Status} (unreachable)" : r.Status)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@foreach (var role in r.Roles)
|
||||
{
|
||||
<span class="chip chip-idle me-1">@role</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (r.LeaderRoles.Count == 0)
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var role in r.LeaderRoles)
|
||||
{
|
||||
<span class="chip chip-ok me-1">@role</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const int RefreshIntervalSeconds = 5;
|
||||
|
||||
private List<MemberRow>? _rows;
|
||||
private bool _refreshing;
|
||||
private DateTime? _lastRefreshUtc;
|
||||
private Timer? _timer;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Refresh();
|
||||
_timer = new Timer(_ => InvokeAsync(() => { Refresh(); StateHasChanged(); }), null,
|
||||
TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||
TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
_refreshing = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
await Task.Yield();
|
||||
Refresh();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
var cluster = Akka.Cluster.Cluster.Get(ActorSystem);
|
||||
var state = cluster.State;
|
||||
var unreachable = state.Unreachable
|
||||
.Select(m => m.Address.ToString()).ToHashSet();
|
||||
var selfAddress = cluster.SelfAddress.ToString();
|
||||
|
||||
_rows = state.Members.Select(m =>
|
||||
{
|
||||
var address = m.Address.ToString();
|
||||
var hostPort = $"{m.Address.Host ?? "?"}:{m.Address.Port ?? 0}";
|
||||
var leaderRoles = m.Roles
|
||||
.Where(role => cluster.State.RoleLeader(role)?.ToString() == address)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return new MemberRow(
|
||||
Address: hostPort,
|
||||
Status: m.Status.ToString(),
|
||||
Roles: m.Roles.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
LeaderRoles: leaderRoles,
|
||||
Unreachable: unreachable.Contains(address),
|
||||
IsSelf: address == selfAddress);
|
||||
})
|
||||
.OrderBy(r => r.Address, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
_lastRefreshUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static string StatusChipClass(string status, bool unreachable) => (status, unreachable) switch
|
||||
{
|
||||
(_, true) => "chip-alert",
|
||||
("Up", _) => "chip-ok",
|
||||
("Joining", _) or ("Leaving", _) or ("Exiting", _) or ("WeaklyUp", _) => "chip-caution",
|
||||
("Down", _) or ("Removed", _) => "chip-alert",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
|
||||
private sealed record MemberRow(
|
||||
string Address,
|
||||
string Status,
|
||||
IReadOnlyCollection<string> Roles,
|
||||
IReadOnlyCollection<string> LeaderRoles,
|
||||
bool Unreachable,
|
||||
bool IsSelf);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@page "/login"
|
||||
@* Login MUST stay anonymously reachable — otherwise the fallback authorization policy
|
||||
would lock operators out of the only way in (Admin-001). Static-rendered on purpose:
|
||||
the form POSTs to /auth/login while ASP.NET still owns an unstarted HTTP response.
|
||||
Calling SignInAsync from an interactive circuit would be too late. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
|
||||
<div class="login-wrap rise" style="animation-delay:.02s">
|
||||
<section class="panel">
|
||||
<div class="panel-head">OtOpcUa Admin — sign in</div>
|
||||
<div style="padding:1.1rem 1.1rem 1.25rem">
|
||||
<form method="post" action="/auth/login" data-enhance="false">
|
||||
@if (ReturnUrl is not null)
|
||||
{
|
||||
<input type="hidden" name="returnUrl" value="@ReturnUrl"/>
|
||||
}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input id="username" name="username" type="text"
|
||||
class="form-control form-control-sm" autocomplete="username"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input id="password" name="password" type="password"
|
||||
class="form-control form-control-sm" autocomplete="current-password"/>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Error))
|
||||
{
|
||||
<div class="panel notice" style="margin-bottom:.85rem">@Error</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-primary w-100" type="submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
<div style="margin-top:1rem;padding-top:.85rem;border-top:1px solid var(--rule);
|
||||
font-size:.78rem;color:var(--ink-faint)">
|
||||
LDAP bind against the configured directory (per Q5 of the AdminUI rebuild plan:
|
||||
generic error in production; specific reason when <span class="mono">Authentication:Ldap:AllowInsecureLdap=true</span>).
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Error message surfaced by /auth/login after a failed bind.</summary>
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Error { get; set; }
|
||||
|
||||
/// <summary>Original protected URL the operator was bounced from; round-tripped to the endpoint.</summary>
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@page "/reservations"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">External ID reservations</h4>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
External IDs (ZTag, SAPID) are reserved fleet-wide via this table. Reservations bind a
|
||||
value to an Equipment's UUID so the ID can move with the equipment across cluster
|
||||
reshuffles without colliding with another cluster's equipment.
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@_rows.Count reservation@(_rows.Count == 1 ? "" : "s")</div>
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No reservations yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kind</th>
|
||||
<th>Value</th>
|
||||
<th>Equipment UUID</th>
|
||||
<th>Cluster</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="chip chip-idle">@r.Kind</span></td>
|
||||
<td><span class="mono">@r.Value</span></td>
|
||||
<td><span class="mono small">@r.EquipmentUuid</span></td>
|
||||
<td><span class="mono small">@r.ClusterId</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ExternalIdReservation>? _rows;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_rows = await db.ExternalIdReservations.AsNoTracking()
|
||||
.OrderBy(r => r.Kind).ThenBy(r => r.Value)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
@page "/role-grants"
|
||||
@* Per Q4 of the AdminUI rebuild plan, v2 replaced v1's per-cluster RoleGrants table with a
|
||||
fleet-wide LDAP-group → role map. This page surfaces the mapping read-only; the source of
|
||||
truth is Authentication:Ldap:GroupToRole in appsettings (editable on the host filesystem, not
|
||||
from the UI yet). *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.Extensions.Options
|
||||
@using ZB.MOM.WW.OtOpcUa.Security.Ldap
|
||||
@inject IOptionsSnapshot<LdapOptions> Ldap
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Role grants</h4>
|
||||
</div>
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
LDAP group membership determines fleet roles. Edit the mapping in
|
||||
<span class="mono">appsettings.json</span> under <span class="mono">Authentication:Ldap:GroupToRole</span>
|
||||
and restart the admin node (or sign out + back in for cached claims to refresh). UI-driven
|
||||
editing of the mapping is deferred — it implies a config-reload mechanism that doesn't exist
|
||||
yet.
|
||||
</section>
|
||||
|
||||
@if (_options is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="card-grid rise mt-3" style="animation-delay:.08s">
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">LDAP binding</div>
|
||||
<div class="kv"><span class="k">Enabled</span><span class="v">@(_options.Enabled ? "yes" : "no")</span></div>
|
||||
<div class="kv"><span class="k">Server</span><span class="v mono">@_options.Server:@_options.Port</span></div>
|
||||
<div class="kv"><span class="k">UseTls</span><span class="v">@_options.UseTls</span></div>
|
||||
<div class="kv"><span class="k">SearchBase</span><span class="v mono small">@_options.SearchBase</span></div>
|
||||
@if (!_options.UseTls && _options.AllowInsecureLdap)
|
||||
{
|
||||
<div class="kv"><span class="k">Warning</span><span class="v"><span class="chip chip-alert">Plaintext credentials over LDAP — dev mode only</span></span></div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Group → role mapping (@(_options.GroupToRole?.Count ?? 0))</div>
|
||||
@if (_options.GroupToRole is null || _options.GroupToRole.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">
|
||||
No mapping configured. Every authenticated user lands with zero roles —
|
||||
the fallback authorization policy will refuse every request. Add a
|
||||
<span class="mono">GroupToRole</span> entry before deploying.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>LDAP group</th><th>Resolved role</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var kvp in _options.GroupToRole.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@kvp.Key</span></td>
|
||||
<td><span class="chip chip-idle">@kvp.Value</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private LdapOptions? _options;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_options = Ldap.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
@page "/scripts/new"
|
||||
@page "/scripts/{ScriptId}"
|
||||
@* Script CRUD. SourceHash is computed automatically from SourceCode on save so the
|
||||
integrity check in v2's deployment pipeline doesn't require operator action. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Security.Cryptography
|
||||
@using System.Text
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New script" : "Edit script")</h4>
|
||||
<a href="/scripts" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise"><span class="mono">@ScriptId</span> not found.</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="scriptEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">ScriptId</label>
|
||||
<InputText @bind-Value="_form.ScriptId" disabled="@(!IsNew)" class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<InputText @bind-Value="_form.Name" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label">Language</label>
|
||||
<InputSelect @bind-Value="_form.Language" class="form-select form-select-sm">
|
||||
<option value="CSharp">CSharp</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3">
|
||||
<div class="panel-head">Source</div>
|
||||
<div style="padding:1rem">
|
||||
@* The textarea stays in the DOM and remains Blazor's source of truth. Monaco
|
||||
mounts a <div> beside it (textarea hides), and the loader's onDidChangeModelContent
|
||||
handler mirrors edits back into the textarea + fires the input event so @bind
|
||||
picks them up. Falls back to the textarea gracefully if Monaco's CDN is
|
||||
unreachable (air-gapped deployments — see monaco-loader.js). *@
|
||||
<InputTextArea id="script-source" @bind-Value="_form.SourceCode"
|
||||
class="form-control form-control-sm mono" rows="20"
|
||||
placeholder="// C# expression body" />
|
||||
<div class="form-text">SHA-256 hash is computed automatically on save. Monaco editor attaches over the textarea on render.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div> }
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button>
|
||||
<a href="/scripts" class="btn btn-outline-secondary">Cancel</a>
|
||||
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ScriptId { get; set; }
|
||||
private bool IsNew => string.IsNullOrEmpty(ScriptId);
|
||||
|
||||
private FormModel _form = new();
|
||||
private Script? _existing;
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (!IsNew)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_existing = await db.Scripts.AsNoTracking().FirstOrDefaultAsync(s => s.ScriptId == ScriptId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
ScriptId = _existing.ScriptId,
|
||||
Name = _existing.Name,
|
||||
Language = _existing.Language,
|
||||
SourceCode = _existing.SourceCode,
|
||||
RowVersion = _existing.RowVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || !_loaded) return;
|
||||
// Inject loader once, then attach over the textarea. Failures are silent — the page
|
||||
// is fully usable via the underlying textarea if Monaco's CDN is unreachable.
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("eval", "if (!document.querySelector('script[data-otopcua=monaco-loader]')) { var s=document.createElement('script'); s.src='/_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-loader.js'; s.dataset.otopcua='monaco-loader'; document.head.appendChild(s); }");
|
||||
// Wait a tick for the loader IIFE to register window.otOpcUaScriptEditor, then attach.
|
||||
await Task.Delay(50);
|
||||
await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", "script-source");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Textarea remains the editor — no-op.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var sourceHash = HashSource(_form.SourceCode);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.Scripts.AnyAsync(s => s.ScriptId == _form.ScriptId))
|
||||
{ _error = $"Script '{_form.ScriptId}' already exists."; return; }
|
||||
db.Scripts.Add(new Script
|
||||
{
|
||||
ScriptId = _form.ScriptId,
|
||||
Name = _form.Name,
|
||||
Language = _form.Language,
|
||||
SourceCode = _form.SourceCode,
|
||||
SourceHash = sourceHash,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.Scripts.FirstOrDefaultAsync(s => s.ScriptId == ScriptId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.Name = _form.Name;
|
||||
entity.Language = _form.Language;
|
||||
entity.SourceCode = _form.SourceCode;
|
||||
entity.SourceHash = sourceHash;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo("/scripts");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this script while you were editing."; }
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Scripts.FirstOrDefaultAsync(s => s.ScriptId == ScriptId);
|
||||
if (entity is null) { Nav.NavigateTo("/scripts"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.Scripts.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo("/scripts");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this script while you were viewing it."; }
|
||||
catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because virtual tags or scripted alarms still reference this script — remove them first."; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private static string HashSource(string source) =>
|
||||
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(source)));
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string ScriptId { get; set; } = "";
|
||||
[Required] public string Name { get; set; } = "";
|
||||
[Required] public string Language { get; set; } = "CSharp";
|
||||
[Required] public string SourceCode { get; set; } = "";
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
@page "/script-log"
|
||||
@* Live script-log tail via SignalR. Subscribes to /hubs/script-log and shows entries from
|
||||
VirtualTagActor / ScriptedAlarmActor script execution. Engine emit lands with F8 + F9. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Script log</h4>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select class="form-select form-select-sm" style="width:auto" @bind="_levelFilter">
|
||||
<option value="">All levels</option>
|
||||
<option value="Trace">Trace+</option>
|
||||
<option value="Debug">Debug+</option>
|
||||
<option value="Information">Information+</option>
|
||||
<option value="Warning">Warning+</option>
|
||||
<option value="Error">Error+</option>
|
||||
</select>
|
||||
<input type="text" class="form-control form-control-sm" style="width:200px"
|
||||
placeholder="Filter script ID…" @bind="_scriptFilter" @bind:event="oninput" />
|
||||
<span class="conn-pill" data-state="@(_connected ? "connected" : "disconnected")">
|
||||
<span class="dot"></span><span>@(_connected ? "live" : "disconnected")</span>
|
||||
</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ClearAsync">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Live tail of <span class="mono">script-logs</span> DPS topic, capped at @Capacity entries.
|
||||
Filter by minimum level + script ID. Sources: VirtualTagActor (F8), ScriptedAlarmActor (F9).
|
||||
</section>
|
||||
|
||||
@if (VisibleRows.Count == 0)
|
||||
{
|
||||
<section class="panel notice rise mt-3" style="animation-delay:.08s">
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<span>No script-log entries yet. Engine emit (F8/F9) is pending.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>No entries match the current filter (@_rows.Count entries available).</span>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Showing @VisibleRows.Count of @_rows.Count</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Level</th>
|
||||
<th>Script</th>
|
||||
<th>Context</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in VisibleRows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
|
||||
<td><span class="chip @LevelChipClass(e.Level)">@e.Level</span></td>
|
||||
<td><span class="mono small">@e.ScriptId</span></td>
|
||||
<td class="text-muted small">
|
||||
@if (!string.IsNullOrEmpty(e.VirtualTagId)) { <span>vtag=@e.VirtualTagId</span> }
|
||||
@if (!string.IsNullOrEmpty(e.AlarmId)) { <span class="ms-1">alarm=@e.AlarmId</span> }
|
||||
@if (!string.IsNullOrEmpty(e.EquipmentId)) { <span class="ms-1">eq=@e.EquipmentId</span> }
|
||||
</td>
|
||||
<td><span class="mono small">@e.Message</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const int Capacity = 500;
|
||||
|
||||
private readonly List<ScriptLogEntry> _rows = new();
|
||||
private HubConnection? _hub;
|
||||
private bool _connected;
|
||||
private string _levelFilter = "";
|
||||
private string _scriptFilter = "";
|
||||
|
||||
private static readonly Dictionary<string, int> LevelRank = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Trace"] = 0, ["Debug"] = 1, ["Information"] = 2, ["Warning"] = 3, ["Error"] = 4, ["Critical"] = 5,
|
||||
};
|
||||
|
||||
private List<ScriptLogEntry> VisibleRows
|
||||
{
|
||||
get
|
||||
{
|
||||
IEnumerable<ScriptLogEntry> q = _rows;
|
||||
if (!string.IsNullOrWhiteSpace(_levelFilter)
|
||||
&& LevelRank.TryGetValue(_levelFilter, out var minRank))
|
||||
{
|
||||
q = q.Where(e => LevelRank.TryGetValue(e.Level, out var r) && r >= minRank);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(_scriptFilter))
|
||||
{
|
||||
q = q.Where(e => e.ScriptId.Contains(_scriptFilter, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
return q.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri(ScriptLogHub.Endpoint))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hub.On<ScriptLogEntry>(ScriptLogHub.MethodName, entry =>
|
||||
{
|
||||
_rows.Insert(0, entry);
|
||||
if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1);
|
||||
InvokeAsync(StateHasChanged);
|
||||
});
|
||||
_hub.Closed += _ => { _connected = false; return InvokeAsync(StateHasChanged); };
|
||||
_hub.Reconnected += _ => { _connected = true; return InvokeAsync(StateHasChanged); };
|
||||
|
||||
try
|
||||
{
|
||||
await _hub.StartAsync();
|
||||
_connected = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Connection error — page shows "disconnected".
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearAsync()
|
||||
{
|
||||
_rows.Clear();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private static string LevelChipClass(string level) => level switch
|
||||
{
|
||||
"Critical" or "Error" => "chip-alert",
|
||||
"Warning" => "chip-caution",
|
||||
"Information" => "chip-idle",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null) await _hub.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
@page "/scripted-alarms/new"
|
||||
@page "/scripted-alarms/{ScriptedAlarmId}"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New scripted alarm" : "Edit scripted alarm")</h4>
|
||||
<a href="/scripted-alarms" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise"><span class="mono">@ScriptedAlarmId</span> not found.</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="scriptedAlarmEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">ScriptedAlarmId</label>
|
||||
<InputText @bind-Value="_form.ScriptedAlarmId" disabled="@(!IsNew)" class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<InputText @bind-Value="_form.Name" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Equipment</label>
|
||||
<InputSelect @bind-Value="_form.EquipmentId" class="form-select form-select-sm">
|
||||
<option value="">— pick equipment —</option>
|
||||
@foreach (var e in _equipment) { <option value="@e.EquipmentId">@e.MachineCode — @e.Name</option> }
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">AlarmType</label>
|
||||
<InputSelect @bind-Value="_form.AlarmType" class="form-select form-select-sm">
|
||||
<option value="LimitAlarm">LimitAlarm</option>
|
||||
<option value="DiscreteAlarm">DiscreteAlarm</option>
|
||||
<option value="OffNormalAlarm">OffNormalAlarm</option>
|
||||
<option value="AlarmCondition">AlarmCondition</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">Severity (1-1000)</label>
|
||||
<InputNumber @bind-Value="_form.Severity" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-3">
|
||||
<label class="form-label">Predicate script</label>
|
||||
<InputSelect @bind-Value="_form.PredicateScriptId" class="form-select form-select-sm">
|
||||
<option value="">— pick script —</option>
|
||||
@foreach (var s in _scripts) { <option value="@s.ScriptId">@s.Name</option> }
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Message template</label>
|
||||
<InputTextArea @bind-Value="_form.MessageTemplate" class="form-control form-control-sm" rows="3"
|
||||
placeholder="{equipment.MachineCode} temperature out of range: {value}°C" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">HistorizeToAveva</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.HistorizeToAveva" class="form-check-input" />
|
||||
<label class="form-check-label">Route to Wonderware sidecar</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Retain</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.Retain" class="form-check-input" />
|
||||
<label class="form-check-label">Retain active alarms on restart</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Enabled</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
|
||||
<label class="form-check-label">Spawn this alarm in deployments</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div> }
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button>
|
||||
<a href="/scripted-alarms" class="btn btn-outline-secondary">Cancel</a>
|
||||
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ScriptedAlarmId { get; set; }
|
||||
private bool IsNew => string.IsNullOrEmpty(ScriptedAlarmId);
|
||||
|
||||
private FormModel _form = new();
|
||||
private ScriptedAlarm? _existing;
|
||||
private List<Equipment> _equipment = new();
|
||||
private List<Script> _scripts = new();
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_equipment = await db.Equipment.AsNoTracking().OrderBy(e => e.MachineCode).ToListAsync();
|
||||
_scripts = await db.Scripts.AsNoTracking().OrderBy(s => s.Name).ToListAsync();
|
||||
if (!IsNew)
|
||||
{
|
||||
_existing = await db.ScriptedAlarms.AsNoTracking().FirstOrDefaultAsync(a => a.ScriptedAlarmId == ScriptedAlarmId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
ScriptedAlarmId = _existing.ScriptedAlarmId,
|
||||
Name = _existing.Name,
|
||||
EquipmentId = _existing.EquipmentId,
|
||||
AlarmType = _existing.AlarmType,
|
||||
Severity = _existing.Severity,
|
||||
PredicateScriptId = _existing.PredicateScriptId,
|
||||
MessageTemplate = _existing.MessageTemplate,
|
||||
HistorizeToAveva = _existing.HistorizeToAveva,
|
||||
Retain = _existing.Retain,
|
||||
Enabled = _existing.Enabled,
|
||||
RowVersion = _existing.RowVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.ScriptedAlarms.AnyAsync(a => a.ScriptedAlarmId == _form.ScriptedAlarmId))
|
||||
{ _error = $"ScriptedAlarm '{_form.ScriptedAlarmId}' already exists."; return; }
|
||||
db.ScriptedAlarms.Add(new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = _form.ScriptedAlarmId,
|
||||
EquipmentId = _form.EquipmentId,
|
||||
Name = _form.Name,
|
||||
AlarmType = _form.AlarmType,
|
||||
Severity = _form.Severity,
|
||||
MessageTemplate = _form.MessageTemplate,
|
||||
PredicateScriptId = _form.PredicateScriptId,
|
||||
HistorizeToAveva = _form.HistorizeToAveva,
|
||||
Retain = _form.Retain,
|
||||
Enabled = _form.Enabled,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == ScriptedAlarmId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.EquipmentId = _form.EquipmentId;
|
||||
entity.Name = _form.Name;
|
||||
entity.AlarmType = _form.AlarmType;
|
||||
entity.Severity = _form.Severity;
|
||||
entity.MessageTemplate = _form.MessageTemplate;
|
||||
entity.PredicateScriptId = _form.PredicateScriptId;
|
||||
entity.HistorizeToAveva = _form.HistorizeToAveva;
|
||||
entity.Retain = _form.Retain;
|
||||
entity.Enabled = _form.Enabled;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo("/scripted-alarms");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this scripted alarm while you were editing."; }
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == ScriptedAlarmId);
|
||||
if (entity is null) { Nav.NavigateTo("/scripted-alarms"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.ScriptedAlarms.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo("/scripted-alarms");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this alarm while you were viewing it."; }
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string ScriptedAlarmId { get; set; } = "";
|
||||
[Required] public string Name { get; set; } = "";
|
||||
[Required] public string EquipmentId { get; set; } = "";
|
||||
[Required] public string AlarmType { get; set; } = "LimitAlarm";
|
||||
[Range(1, 1000)] public int Severity { get; set; } = 500;
|
||||
[Required] public string PredicateScriptId { get; set; } = "";
|
||||
[Required] public string MessageTemplate { get; set; } = "";
|
||||
public bool HistorizeToAveva { get; set; } = true;
|
||||
public bool Retain { get; set; } = true;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
@page "/scripted-alarms"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Scripted alarms</h4>
|
||||
<a href="/scripted-alarms/new" class="btn btn-primary btn-sm">New scripted alarm</a>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Scripted alarms watch a predicate script per equipment instance and fire OPC UA alarms
|
||||
when the predicate transitions true. HistorizeToAveva routes events through the
|
||||
Wonderware historian sidecar (F11) when enabled.
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@_rows.Count scripted alarm@(_rows.Count == 1 ? "" : "s")</div>
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No scripted alarms defined.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ScriptedAlarmId</th>
|
||||
<th>Name</th>
|
||||
<th>Equipment</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Severity</th>
|
||||
<th>Predicate</th>
|
||||
<th>Flags</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var a in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono small">@a.ScriptedAlarmId</span></td>
|
||||
<td>@a.Name</td>
|
||||
<td><span class="mono small">@a.EquipmentId</span></td>
|
||||
<td>@a.AlarmType</td>
|
||||
<td class="num">@a.Severity</td>
|
||||
<td><span class="mono small">@a.PredicateScriptId</span></td>
|
||||
<td>
|
||||
@if (a.HistorizeToAveva) { <span class="chip chip-idle me-1">historize</span> }
|
||||
@if (a.Retain) { <span class="chip chip-idle">retain</span> }
|
||||
</td>
|
||||
<td>
|
||||
@if (a.Enabled) { <span class="chip chip-ok">Enabled</span> }
|
||||
else { <span class="chip chip-idle">Disabled</span> }
|
||||
</td>
|
||||
<td><a href="/scripted-alarms/@a.ScriptedAlarmId" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ScriptedAlarm>? _rows;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_rows = await db.ScriptedAlarms.AsNoTracking()
|
||||
.OrderBy(a => a.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
@page "/scripts"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Scripts</h4>
|
||||
<a href="/scripts/new" class="btn btn-primary btn-sm">New script</a>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Scripts are fleet-wide expression compilations referenced by virtual tags and scripted
|
||||
alarms. The default language is C#; expansion of the editor (Monaco syntax, dependency
|
||||
introspection) lands in Phase D.2.
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@_rows.Count script@(_rows.Count == 1 ? "" : "s")</div>
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No scripts defined.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var s in _rows)
|
||||
{
|
||||
<details style="border-top:1px solid var(--rule)">
|
||||
<summary style="padding:.75rem 1rem;cursor:pointer">
|
||||
<span class="mono">@s.ScriptId</span>
|
||||
· <span>@s.Name</span>
|
||||
· <span class="chip chip-idle ms-1">@s.Language</span>
|
||||
<span class="text-muted small ms-2 mono">hash=@s.SourceHash[..12]…</span>
|
||||
</summary>
|
||||
<div style="padding:0 1rem 1rem">
|
||||
<div class="d-flex mb-2">
|
||||
<a href="/scripts/@s.ScriptId" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
</div>
|
||||
<pre class="mono small" style="background:var(--surface-2);padding:1rem;border-radius:4px;overflow:auto">@s.SourceCode</pre>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Script>? _rows;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_rows = await db.Scripts.AsNoTracking()
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
@page "/virtual-tags/new"
|
||||
@page "/virtual-tags/{VirtualTagId}"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(IsNew ? "New virtual tag" : "Edit virtual tag")</h4>
|
||||
<a href="/virtual-tags" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
|
||||
@if (!_loaded)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!IsNew && _existing is null)
|
||||
{
|
||||
<section class="panel notice rise"><span class="mono">@VirtualTagId</span> not found.</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="vtagEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">VirtualTagId</label>
|
||||
<InputText @bind-Value="_form.VirtualTagId" disabled="@(!IsNew)" class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<InputText @bind-Value="_form.Name" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Equipment</label>
|
||||
<InputSelect @bind-Value="_form.EquipmentId" class="form-select form-select-sm">
|
||||
<option value="">— pick equipment —</option>
|
||||
@foreach (var e in _equipment) { <option value="@e.EquipmentId">@e.MachineCode — @e.Name</option> }
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">DataType</label>
|
||||
<InputText @bind-Value="_form.DataType" class="form-control form-control-sm mono" placeholder="Double" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">Script</label>
|
||||
<InputSelect @bind-Value="_form.ScriptId" class="form-select form-select-sm">
|
||||
<option value="">— pick script —</option>
|
||||
@foreach (var s in _scripts) { <option value="@s.ScriptId">@s.Name (@s.Language)</option> }
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Change-triggered</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.ChangeTriggered" class="form-check-input" />
|
||||
<label class="form-check-label">Re-evaluate on dependency change</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">TimerIntervalMs (optional)</label>
|
||||
<InputNumber @bind-Value="_form.TimerIntervalMs" class="form-control form-control-sm" />
|
||||
<div class="form-text">Periodic re-evaluation. Null = change-trigger only.</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Historize</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.Historize" class="form-check-input" />
|
||||
<label class="form-check-label">Send to Wonderware historian</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Enabled</label>
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
|
||||
<label class="form-check-label">Spawn this virtual tag in deployments</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div> }
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button>
|
||||
<a href="/virtual-tags" class="btn btn-outline-secondary">Cancel</a>
|
||||
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? VirtualTagId { get; set; }
|
||||
private bool IsNew => string.IsNullOrEmpty(VirtualTagId);
|
||||
|
||||
private FormModel _form = new();
|
||||
private VirtualTag? _existing;
|
||||
private List<Equipment> _equipment = new();
|
||||
private List<Script> _scripts = new();
|
||||
private bool _loaded;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_equipment = await db.Equipment.AsNoTracking().OrderBy(e => e.MachineCode).ToListAsync();
|
||||
_scripts = await db.Scripts.AsNoTracking().OrderBy(s => s.Name).ToListAsync();
|
||||
if (!IsNew)
|
||||
{
|
||||
_existing = await db.VirtualTags.AsNoTracking().FirstOrDefaultAsync(v => v.VirtualTagId == VirtualTagId);
|
||||
if (_existing is not null)
|
||||
{
|
||||
_form = new FormModel
|
||||
{
|
||||
VirtualTagId = _existing.VirtualTagId,
|
||||
Name = _existing.Name,
|
||||
EquipmentId = _existing.EquipmentId,
|
||||
DataType = _existing.DataType,
|
||||
ScriptId = _existing.ScriptId,
|
||||
ChangeTriggered = _existing.ChangeTriggered,
|
||||
TimerIntervalMs = _existing.TimerIntervalMs,
|
||||
Historize = _existing.Historize,
|
||||
Enabled = _existing.Enabled,
|
||||
RowVersion = _existing.RowVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_form.DataType = "Double";
|
||||
_form.ChangeTriggered = true;
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(_form.EquipmentId)) { _error = "Pick equipment."; return; }
|
||||
if (string.IsNullOrEmpty(_form.ScriptId)) { _error = "Pick a script."; return; }
|
||||
if (!_form.ChangeTriggered && _form.TimerIntervalMs is null)
|
||||
{ _error = "Pick at least one trigger — change or timer."; return; }
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.VirtualTags.AnyAsync(v => v.VirtualTagId == _form.VirtualTagId))
|
||||
{ _error = $"VirtualTag '{_form.VirtualTagId}' already exists."; return; }
|
||||
db.VirtualTags.Add(new VirtualTag
|
||||
{
|
||||
VirtualTagId = _form.VirtualTagId,
|
||||
EquipmentId = _form.EquipmentId,
|
||||
Name = _form.Name,
|
||||
DataType = _form.DataType,
|
||||
ScriptId = _form.ScriptId,
|
||||
ChangeTriggered = _form.ChangeTriggered,
|
||||
TimerIntervalMs = _form.TimerIntervalMs,
|
||||
Historize = _form.Historize,
|
||||
Enabled = _form.Enabled,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = await db.VirtualTags.FirstOrDefaultAsync(v => v.VirtualTagId == VirtualTagId);
|
||||
if (entity is null) { _error = "Row no longer exists."; return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
entity.EquipmentId = _form.EquipmentId;
|
||||
entity.Name = _form.Name;
|
||||
entity.DataType = _form.DataType;
|
||||
entity.ScriptId = _form.ScriptId;
|
||||
entity.ChangeTriggered = _form.ChangeTriggered;
|
||||
entity.TimerIntervalMs = _form.TimerIntervalMs;
|
||||
entity.Historize = _form.Historize;
|
||||
entity.Enabled = _form.Enabled;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo("/virtual-tags");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this virtual tag while you were editing."; }
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsNew) return;
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.VirtualTags.FirstOrDefaultAsync(v => v.VirtualTagId == VirtualTagId);
|
||||
if (entity is null) { Nav.NavigateTo("/virtual-tags"); return; }
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
|
||||
db.VirtualTags.Remove(entity);
|
||||
await db.SaveChangesAsync();
|
||||
Nav.NavigateTo("/virtual-tags");
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { _error = "Another user changed this virtual tag while you were viewing it."; }
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string VirtualTagId { get; set; } = "";
|
||||
[Required] public string Name { get; set; } = "";
|
||||
[Required] public string EquipmentId { get; set; } = "";
|
||||
[Required] public string DataType { get; set; } = "Double";
|
||||
[Required] public string ScriptId { get; set; } = "";
|
||||
public bool ChangeTriggered { get; set; } = true;
|
||||
public int? TimerIntervalMs { get; set; }
|
||||
public bool Historize { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
@page "/virtual-tags"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Virtual tags</h4>
|
||||
<a href="/virtual-tags/new" class="btn btn-primary btn-sm">New virtual tag</a>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Virtual tags evaluate a script per equipment instance and publish the result as an OPC UA
|
||||
variable. ChangeTriggered = re-evaluate when any dependency changes; TimerIntervalMs
|
||||
re-evaluates on a periodic timer. Live editing lands in a Phase C.2-equivalent follow-up.
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@_rows.Count virtual tag@(_rows.Count == 1 ? "" : "s")</div>
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div style="padding:1rem" class="text-muted">No virtual tags defined.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>VirtualTagId</th>
|
||||
<th>Name</th>
|
||||
<th>Equipment</th>
|
||||
<th>Data type</th>
|
||||
<th>Script</th>
|
||||
<th>Trigger</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var v in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono small">@v.VirtualTagId</span></td>
|
||||
<td>@v.Name</td>
|
||||
<td><span class="mono small">@v.EquipmentId</span></td>
|
||||
<td><span class="mono small">@v.DataType</span></td>
|
||||
<td><span class="mono small">@v.ScriptId</span></td>
|
||||
<td>
|
||||
@if (v.ChangeTriggered) { <span class="chip chip-idle me-1">change</span> }
|
||||
@if (v.TimerIntervalMs is int ms) { <span class="chip chip-idle">@(ms)ms</span> }
|
||||
</td>
|
||||
<td>
|
||||
@if (v.Enabled) { <span class="chip chip-ok">Enabled</span> }
|
||||
else { <span class="chip chip-idle">Disabled</span> }
|
||||
</td>
|
||||
<td><a href="/virtual-tags/@v.VirtualTagId" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<VirtualTag>? _rows;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_rows = await db.VirtualTags.AsNoTracking()
|
||||
.OrderBy(v => v.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@* Bounces an unauthenticated user to /login with the original URL preserved as
|
||||
?returnUrl=. The /auth/login endpoint reads the parameter and forwards after a
|
||||
successful bind so deep-links survive the auth hop. *@
|
||||
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var current = Nav.ToBaseRelativePath(Nav.Uri);
|
||||
var returnUrl = string.IsNullOrEmpty(current) || current.StartsWith("login", StringComparison.OrdinalIgnoreCase)
|
||||
? null
|
||||
: "/" + current;
|
||||
var target = returnUrl is null ? "/login" : $"/login?returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||
Nav.NavigateTo(target, forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
@* Router with AuthorizeRouteView so page-level [Authorize] attributes are enforced
|
||||
(with plain RouteView, the attribute is inert — Admin-001). Unauthenticated users
|
||||
hit the NotAuthorized slot and are bounced to /login; the route they came from is
|
||||
round-tripped as ?returnUrl=. *@
|
||||
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout
|
||||
|
||||
<Router AppAssembly="@typeof(Routes).Assembly" AdditionalAssemblies="@AdditionalAssemblies">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
|
||||
<NotAuthorized>
|
||||
@if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
<RedirectToLogin/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<LayoutView Layout="@typeof(MainLayout)">
|
||||
<p class="text-danger">You do not have permission to view this page.</p>
|
||||
</LayoutView>
|
||||
}
|
||||
</NotAuthorized>
|
||||
<Authorizing>
|
||||
<LayoutView Layout="@typeof(MainLayout)"><p>Authorizing…</p></LayoutView>
|
||||
</Authorizing>
|
||||
</AuthorizeRouteView>
|
||||
</Found>
|
||||
</Router>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Hosts that want to expose pages defined in their own assembly pass them here. The fused
|
||||
/// Host doesn't currently host its own routable pages — everything lives in this RCL — but
|
||||
/// the parameter is here so a downstream consumer (or test rig) can extend without forking
|
||||
/// Routes.razor.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public IEnumerable<System.Reflection.Assembly>? AdditionalAssemblies { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@* Shared nav strip rendered above every cluster-scoped page. Per Q3 of the AdminUI rebuild
|
||||
plan, the v1 monolithic ClusterDetail tab host is split into separate routes — these are
|
||||
`<a href>` links, not Blazor router transitions, so each page bootstraps its own data
|
||||
independently and can opt into a heavier render mode without dragging the others. *@
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public string ClusterId { get; set; } = "";
|
||||
[Parameter, EditorRequired] public string ActiveTab { get; set; } = "";
|
||||
}
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><a class="nav-link @Active("overview")" href="/clusters/@ClusterId">Overview</a></li>
|
||||
<li class="nav-item"><a class="nav-link @Active("equipment")" href="/clusters/@ClusterId/equipment">Equipment</a></li>
|
||||
<li class="nav-item"><a class="nav-link @Active("uns")" href="/clusters/@ClusterId/uns">UNS</a></li>
|
||||
<li class="nav-item"><a class="nav-link @Active("namespaces")" href="/clusters/@ClusterId/namespaces">Namespaces</a></li>
|
||||
<li class="nav-item"><a class="nav-link @Active("drivers")" href="/clusters/@ClusterId/drivers">Drivers</a></li>
|
||||
<li class="nav-item"><a class="nav-link @Active("tags")" href="/clusters/@ClusterId/tags">Tags</a></li>
|
||||
<li class="nav-item"><a class="nav-link @Active("acls")" href="/clusters/@ClusterId/acls">ACLs</a></li>
|
||||
<li class="nav-item"><a class="nav-link @Active("audit")" href="/clusters/@ClusterId/audit">Audit</a></li>
|
||||
<li class="nav-item"><a class="nav-link @Active("redundancy")" href="/clusters/@ClusterId/redundancy">Redundancy</a></li>
|
||||
</ul>
|
||||
|
||||
@code {
|
||||
private string Active(string tab) => tab == ActiveTab ? "active" : "";
|
||||
}
|
||||
@@ -2,8 +2,14 @@ using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||
|
||||
/// <summary>Browser-facing alert / toast push channel. Bridge wiring staged for F16.</summary>
|
||||
/// <summary>
|
||||
/// Browser-facing alert push channel. Subscribers receive
|
||||
/// <see cref="Commons.Messages.Alerts.AlarmTransitionEvent"/> snapshots whenever an alarm fires,
|
||||
/// clears, or is acknowledged on any cluster node. Bridge: <c>AlertSignalRBridge</c> subscribes
|
||||
/// to the <c>alerts</c> DPS topic and forwards to every connected SignalR client.
|
||||
/// </summary>
|
||||
public sealed class AlertHub : Hub
|
||||
{
|
||||
public const string Endpoint = "/hubs/alerts";
|
||||
public const string MethodName = "alarmTransition";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Akka actor that subscribes to the <c>alerts</c> DistributedPubSub topic and forwards each
|
||||
/// <see cref="AlarmTransitionEvent"/> to every SignalR client connected to <see cref="AlertHub"/>.
|
||||
/// Mirrors <c>FleetStatusSignalRBridge</c>'s design — one bridge per admin node, hub fan-out is
|
||||
/// per-node, no cluster-singleton needed.
|
||||
/// </summary>
|
||||
public sealed class AlertSignalRBridge : ReceiveActor
|
||||
{
|
||||
public const string TopicName = "alerts";
|
||||
|
||||
private readonly IHubContext<AlertHub> _hub;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
public static Props Props(IHubContext<AlertHub> hub) =>
|
||||
Akka.Actor.Props.Create(() => new AlertSignalRBridge(hub));
|
||||
|
||||
public AlertSignalRBridge(IHubContext<AlertHub> hub)
|
||||
{
|
||||
_hub = hub;
|
||||
ReceiveAsync<AlarmTransitionEvent>(ForwardAsync);
|
||||
Receive<SubscribeAck>(_ => { /* DPS confirmation */ });
|
||||
}
|
||||
|
||||
protected override void PreStart() =>
|
||||
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(TopicName, Self));
|
||||
|
||||
private async Task ForwardAsync(AlarmTransitionEvent msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _hub.Clients.All.SendAsync(AlertHub.MethodName, msg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "AlertSignalRBridge: SignalR push failed for {AlarmId}", msg.AlarmId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ public static class HubRouteBuilderExtensions
|
||||
{
|
||||
app.MapHub<FleetStatusHub>(FleetStatusHub.Endpoint);
|
||||
app.MapHub<AlertHub>(AlertHub.Endpoint);
|
||||
app.MapHub<ScriptLogHub>(ScriptLogHub.Endpoint);
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||
public static class HubServiceCollectionExtensions
|
||||
{
|
||||
public const string FleetStatusSignalRBridgeName = "fleet-status-signalr-bridge";
|
||||
public const string AlertSignalRBridgeName = "alert-signalr-bridge";
|
||||
public const string ScriptLogSignalRBridgeName = "script-log-signalr-bridge";
|
||||
|
||||
/// <summary>
|
||||
/// Spawns the SignalR bridge actors that forward DPS messages to browser-facing SignalR
|
||||
/// hubs. Currently: <see cref="FleetStatusSignalRBridge"/> (DPS <c>fleet-status</c> topic →
|
||||
/// <see cref="FleetStatusHub"/> clients).
|
||||
/// hubs: <c>fleet-status</c> → <see cref="FleetStatusHub"/>, <c>alerts</c> →
|
||||
/// <see cref="AlertHub"/>, <c>script-logs</c> → <see cref="ScriptLogHub"/>.
|
||||
///
|
||||
/// Call inside the admin-role configurator on the shared <see cref="AkkaConfigurationBuilder"/>:
|
||||
/// <code>
|
||||
@@ -27,13 +29,23 @@ public static class HubServiceCollectionExtensions
|
||||
{
|
||||
builder.WithActors((system, registry, resolver) =>
|
||||
{
|
||||
var hub = resolver.GetService<IHubContext<FleetStatusHub>>();
|
||||
var actor = system.ActorOf(FleetStatusSignalRBridge.Props(hub), FleetStatusSignalRBridgeName);
|
||||
registry.Register<FleetStatusSignalRBridgeKey>(actor);
|
||||
var fleetHub = resolver.GetService<IHubContext<FleetStatusHub>>();
|
||||
var fleetBridge = system.ActorOf(FleetStatusSignalRBridge.Props(fleetHub), FleetStatusSignalRBridgeName);
|
||||
registry.Register<FleetStatusSignalRBridgeKey>(fleetBridge);
|
||||
|
||||
var alertHub = resolver.GetService<IHubContext<AlertHub>>();
|
||||
var alertBridge = system.ActorOf(AlertSignalRBridge.Props(alertHub), AlertSignalRBridgeName);
|
||||
registry.Register<AlertSignalRBridgeKey>(alertBridge);
|
||||
|
||||
var scriptLogHub = resolver.GetService<IHubContext<ScriptLogHub>>();
|
||||
var scriptLogBridge = system.ActorOf(ScriptLogSignalRBridge.Props(scriptLogHub), ScriptLogSignalRBridgeName);
|
||||
registry.Register<ScriptLogSignalRBridgeKey>(scriptLogBridge);
|
||||
});
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Marker key for <see cref="ActorRegistry"/> lookup of the SignalR bridge actor.</summary>
|
||||
/// <summary>Marker keys for <see cref="ActorRegistry"/> lookup of the SignalR bridge actors.</summary>
|
||||
public sealed class FleetStatusSignalRBridgeKey { }
|
||||
public sealed class AlertSignalRBridgeKey { }
|
||||
public sealed class ScriptLogSignalRBridgeKey { }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Browser-facing script-log push channel. Subscribers receive
|
||||
/// <see cref="Commons.Messages.Logging.ScriptLogEntry"/> lines emitted by VirtualTagActor +
|
||||
/// ScriptedAlarmActor as their hosted scripts log diagnostic output. Bridge:
|
||||
/// <c>ScriptLogSignalRBridge</c> subscribes to the <c>script-logs</c> DPS topic and forwards
|
||||
/// to every connected SignalR client.
|
||||
/// </summary>
|
||||
public sealed class ScriptLogHub : Hub
|
||||
{
|
||||
public const string Endpoint = "/hubs/script-log";
|
||||
public const string MethodName = "scriptLogEntry";
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Akka actor that subscribes to the <c>script-logs</c> DistributedPubSub topic and forwards each
|
||||
/// <see cref="ScriptLogEntry"/> to every SignalR client connected to <see cref="ScriptLogHub"/>.
|
||||
/// </summary>
|
||||
public sealed class ScriptLogSignalRBridge : ReceiveActor
|
||||
{
|
||||
public const string TopicName = "script-logs";
|
||||
|
||||
private readonly IHubContext<ScriptLogHub> _hub;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
public static Props Props(IHubContext<ScriptLogHub> hub) =>
|
||||
Akka.Actor.Props.Create(() => new ScriptLogSignalRBridge(hub));
|
||||
|
||||
public ScriptLogSignalRBridge(IHubContext<ScriptLogHub> hub)
|
||||
{
|
||||
_hub = hub;
|
||||
ReceiveAsync<ScriptLogEntry>(ForwardAsync);
|
||||
Receive<SubscribeAck>(_ => { /* DPS confirmation */ });
|
||||
}
|
||||
|
||||
protected override void PreStart() =>
|
||||
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(TopicName, Self));
|
||||
|
||||
private async Task ForwardAsync(ScriptLogEntry msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _hub.Clients.All.SendAsync(ScriptLogHub.MethodName, msg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "ScriptLogSignalRBridge: SignalR push failed for {ScriptId}", msg.ScriptId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,22 @@
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- OpenTelemetry.Api transitively via ControlPlane -> Akka.Cluster.Tools. -->
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-g94r-2vxg-569j"/>
|
||||
<!-- Opc.Ua.Core transitively via Runtime → OpcUaServer; advisory accepted at the host. -->
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-h958-fxgg-g7w3"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.JSInterop
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
@* Root Blazor component for the fused OtOpcUa.Host. Pulls in the AdminUI library's
|
||||
_Imports + the Deployments page. The full layout (sidebar, top bar, etc.) is part of
|
||||
the legacy Admin migration tracked as F15 — for now this is the bare minimum that lets
|
||||
the Razor pipeline render. *@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
<body>
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
|
||||
/// extension into a single <see cref="DriverFactoryRegistry"/> singleton and binds the
|
||||
/// v2 <see cref="IDriverFactory"/> abstraction to a <see cref="DriverFactoryRegistryAdapter"/>
|
||||
/// over it. Replaces the F7 seam's <c>NullDriverFactory</c> default so deploys actually
|
||||
/// materialise real <see cref="IDriver"/> instances on driver-role nodes.
|
||||
///
|
||||
/// Skipped entirely on admin-only nodes — they never run drivers, so the registry doesn't
|
||||
/// need to exist (Program.cs guards via the <c>hasDriver</c> flag).
|
||||
/// </summary>
|
||||
public static class DriverFactoryBootstrap
|
||||
{
|
||||
/// <summary>
|
||||
/// Register the cross-platform driver factories + bind <see cref="IDriverFactory"/>.
|
||||
/// Must be called BEFORE <c>services.AddAkka</c> so the runtime extension can resolve
|
||||
/// <see cref="IDriverFactory"/> from DI when spawning <c>DriverHostActor</c>.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddOtOpcUaDriverFactories(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<DriverFactoryRegistry>(sp =>
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
var loggerFactory = sp.GetService<ILoggerFactory>();
|
||||
Register(registry, loggerFactory);
|
||||
return registry;
|
||||
});
|
||||
services.AddSingleton<IDriverFactory>(sp =>
|
||||
new DriverFactoryRegistryAdapter(sp.GetRequiredService<DriverFactoryRegistry>()));
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoke every cross-platform driver's <c>Register</c> extension. New driver assemblies
|
||||
/// get added here — one line per type. ShouldStub() in <c>DriverInstanceActor</c> still
|
||||
/// handles platform/role-dependent stubbing (e.g. Galaxy on macOS), so registering a
|
||||
/// factory here doesn't mean it always runs in production.
|
||||
/// </summary>
|
||||
private static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
||||
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using SerilogLogger = Serilog.ILogger;
|
||||
using SerilogLog = Serilog.Log;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// F9b — production <see cref="IScriptedAlarmEvaluator"/> binding. Compiles each unique
|
||||
/// predicate once via <see cref="ScriptEvaluator{TContext, TResult}"/> against
|
||||
/// <see cref="AlarmPredicateContext"/> and caches the resulting evaluator. Predicates are
|
||||
/// pure functions returning <c>bool</c>: <see cref="AlarmPredicateContext.SetVirtualTag"/>
|
||||
/// throws so a misbehaving script can't smuggle a side effect into alarm evaluation.
|
||||
///
|
||||
/// Failure modes (compile error, sandbox violation, runtime exception, timeout) all surface
|
||||
/// as <see cref="ScriptedAlarmEvalResult.Failure"/>; <see cref="ScriptedAlarmActor"/>
|
||||
/// preserves the prior state on failure (does not flip Active/Inactive).
|
||||
/// </summary>
|
||||
public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDisposable
|
||||
{
|
||||
private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext<RoslynScriptedAlarmEvaluator>();
|
||||
|
||||
private readonly ConcurrentDictionary<string, ScriptEvaluator<AlarmPredicateContext, bool>> _cache
|
||||
= new(StringComparer.Ordinal);
|
||||
private readonly ILogger<RoslynScriptedAlarmEvaluator> _logger;
|
||||
private readonly TimeSpan _runTimeout;
|
||||
private bool _disposed;
|
||||
|
||||
public RoslynScriptedAlarmEvaluator(ILogger<RoslynScriptedAlarmEvaluator> logger, TimeSpan? runTimeout = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies)
|
||||
{
|
||||
if (_disposed) return ScriptedAlarmEvalResult.Failure("evaluator disposed");
|
||||
if (string.IsNullOrWhiteSpace(predicate)) return ScriptedAlarmEvalResult.Failure("empty predicate");
|
||||
|
||||
ScriptEvaluator<AlarmPredicateContext, bool> evaluator;
|
||||
try
|
||||
{
|
||||
evaluator = _cache.GetOrAdd(predicate, ScriptEvaluator<AlarmPredicateContext, bool>.Compile);
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Alarm {Id}: predicate compile failed", alarmId);
|
||||
return ScriptedAlarmEvalResult.Failure($"compile error: {ex.Message}");
|
||||
}
|
||||
catch (ScriptSandboxViolationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Alarm {Id}: predicate sandbox violation", alarmId);
|
||||
return ScriptedAlarmEvalResult.Failure($"sandbox violation: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Alarm {Id}: predicate compile threw", alarmId);
|
||||
return ScriptedAlarmEvalResult.Failure($"compile failure: {ex.Message}");
|
||||
}
|
||||
|
||||
var readCache = BuildReadCache(dependencies);
|
||||
var context = new AlarmPredicateContext(readCache, ScriptLogger);
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(_runTimeout);
|
||||
var active = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult();
|
||||
return ScriptedAlarmEvalResult.Ok(active);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return ScriptedAlarmEvalResult.Failure($"predicate timed out after {_runTimeout.TotalSeconds:F1}s");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Alarm {Id}: predicate execution threw", alarmId);
|
||||
return ScriptedAlarmEvalResult.Failure($"predicate threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
|
||||
IReadOnlyDictionary<string, object?> deps)
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
||||
foreach (var kv in deps)
|
||||
{
|
||||
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
foreach (var ev in _cache.Values)
|
||||
{
|
||||
try { ev.Dispose(); } catch { /* best-effort */ }
|
||||
}
|
||||
_cache.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
using SerilogLogger = Serilog.ILogger;
|
||||
using SerilogLog = Serilog.Log;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// F8b — production <see cref="IVirtualTagEvaluator"/> binding. Compiles each unique
|
||||
/// expression once via <see cref="ScriptEvaluator{TContext, TResult}"/> (Roslyn-backed
|
||||
/// sandbox) and caches the resulting evaluator keyed by source. Subsequent evaluations are
|
||||
/// in-process method invocations on the dependency dictionary — fast enough to run inline
|
||||
/// inside the actor's message handler.
|
||||
///
|
||||
/// Single-tag mode: cross-tag <c>ctx.SetVirtualTag</c> writes are dropped (logged) because
|
||||
/// fan-out between actors is owned by <c>DependencyMuxActor</c>, not by the eval engine.
|
||||
/// Cycle detection + cascade ordering live in <see cref="VirtualTagEngine"/>; this adapter
|
||||
/// stays single-tag scoped to keep <see cref="VirtualTagActor"/>'s message loop simple.
|
||||
/// </summary>
|
||||
public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposable
|
||||
{
|
||||
private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext<RoslynVirtualTagEvaluator>();
|
||||
|
||||
private readonly ConcurrentDictionary<string, ScriptEvaluator<VirtualTagContext, object?>> _cache
|
||||
= new(StringComparer.Ordinal);
|
||||
private readonly ILogger<RoslynVirtualTagEvaluator> _logger;
|
||||
private readonly TimeSpan _runTimeout;
|
||||
private bool _disposed;
|
||||
|
||||
public RoslynVirtualTagEvaluator(ILogger<RoslynVirtualTagEvaluator> logger, TimeSpan? runTimeout = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||
{
|
||||
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
|
||||
if (string.IsNullOrWhiteSpace(expression)) return VirtualTagEvalResult.Failure("empty expression");
|
||||
|
||||
ScriptEvaluator<VirtualTagContext, object?> evaluator;
|
||||
try
|
||||
{
|
||||
evaluator = _cache.GetOrAdd(expression, ScriptEvaluator<VirtualTagContext, object?>.Compile);
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "VirtualTag {Id}: Roslyn compile failed", virtualTagId);
|
||||
return VirtualTagEvalResult.Failure($"compile error: {ex.Message}");
|
||||
}
|
||||
catch (ScriptSandboxViolationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "VirtualTag {Id}: sandbox violation", virtualTagId);
|
||||
return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "VirtualTag {Id}: compile threw", virtualTagId);
|
||||
return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}");
|
||||
}
|
||||
|
||||
var readCache = BuildReadCache(dependencies);
|
||||
var context = new VirtualTagContext(
|
||||
readCache,
|
||||
setVirtualTag: (path, _) =>
|
||||
_logger.LogDebug("VirtualTag {Id}: cross-tag write to {Path} dropped (single-tag adapter)",
|
||||
virtualTagId, path),
|
||||
logger: ScriptLogger);
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(_runTimeout);
|
||||
var raw = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult();
|
||||
return VirtualTagEvalResult.Ok(raw);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "VirtualTag {Id}: script execution threw", virtualTagId);
|
||||
return VirtualTagEvalResult.Failure($"script threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
|
||||
IReadOnlyDictionary<string, object?> deps)
|
||||
{
|
||||
// VirtualTagContext.GetTag returns a DataValueSnapshot — we wrap each raw dep value
|
||||
// as Good-quality so the script's `(int)ctx.GetTag("a").Value` pattern works. Null
|
||||
// values stay null; the script can null-check via GetTag(path).Value.
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
||||
foreach (var kv in deps)
|
||||
{
|
||||
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
foreach (var ev in _cache.Values)
|
||||
{
|
||||
try { ev.Dispose(); } catch { /* best-effort */ }
|
||||
}
|
||||
_cache.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Wires the OtOpcUa Meter + ActivitySource into OpenTelemetry and exposes a Prometheus
|
||||
/// scrape endpoint at <c>/metrics</c> on the host pipeline. F13d slice — only the meter +
|
||||
/// activity source declared in <see cref="OtOpcUaTelemetry"/> are surfaced; per-Akka
|
||||
/// internals + ASP.NET request metrics stay off by default to keep the scrape payload
|
||||
/// scoped to OtOpcUa-owned signals.
|
||||
/// </summary>
|
||||
public static class ObservabilityExtensions
|
||||
{
|
||||
public static IServiceCollection AddOtOpcUaObservability(this IServiceCollection services)
|
||||
{
|
||||
services.AddOpenTelemetry()
|
||||
.WithMetrics(b => b
|
||||
.AddMeter(OtOpcUaTelemetry.MeterName)
|
||||
.AddPrometheusExporter())
|
||||
.WithTracing(b => b
|
||||
.AddSource(OtOpcUaTelemetry.ActivitySourceName));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mounts the Prometheus scrape endpoint on the existing ASP.NET pipeline. Call after
|
||||
/// <c>app.UseAuthentication/UseAuthorization</c> if metrics access should require auth;
|
||||
/// the default leaves it unauthenticated for local Prometheus scrapes.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapOtOpcUaMetrics(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPrometheusScrapingEndpoint("/metrics");
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IOpcUaUserAuthenticator"/> adapter that bridges OPC UA UserName
|
||||
/// tokens to the same <see cref="ILdapAuthService"/> the Admin UI cookie/JWT flows use, so a
|
||||
/// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA)
|
||||
/// session identities. Roles flow through unchanged — the data-plane ACL evaluator reads
|
||||
/// them off <c>OperationContext.UserIdentity</c> downstream.
|
||||
/// </summary>
|
||||
public sealed class LdapOpcUaUserAuthenticator(
|
||||
ILdapAuthService ldap,
|
||||
ILogger<LdapOpcUaUserAuthenticator> logger)
|
||||
: IOpcUaUserAuthenticator
|
||||
{
|
||||
public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await ldap.AuthenticateAsync(username, password, ct).ConfigureAwait(false);
|
||||
if (!result.Success)
|
||||
{
|
||||
return OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials");
|
||||
}
|
||||
return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, result.Roles);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP authentication threw for OPC UA user {User}", username);
|
||||
return OpcUaUserAuthResult.Deny("Authentication backend error");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the OPC UA SDK lifecycle on driver-role hosts. Reads
|
||||
/// <see cref="OpcUaApplicationHostOptions"/> from the <c>OpcUa</c> config section, boots
|
||||
/// an <see cref="OtOpcUaSdkServer"/> through <see cref="OpcUaApplicationHost"/>, then
|
||||
/// swaps a real <see cref="SdkAddressSpaceSink"/> into the
|
||||
/// <see cref="DeferredAddressSpaceSink"/> singleton so <c>OpcUaPublishActor</c>'s writes
|
||||
/// start landing in the real address space.
|
||||
///
|
||||
/// Tests boot the OPC UA server directly via <see cref="OpcUaApplicationHost"/>; this
|
||||
/// hosted service is the production wiring.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposable
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly DeferredAddressSpaceSink _deferredSink;
|
||||
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
|
||||
private readonly IOpcUaUserAuthenticator _userAuthenticator;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<OtOpcUaServerHostedService> _logger;
|
||||
|
||||
private OpcUaApplicationHost? _appHost;
|
||||
private OtOpcUaSdkServer? _server;
|
||||
|
||||
public OtOpcUaServerHostedService(
|
||||
IConfiguration configuration,
|
||||
DeferredAddressSpaceSink deferredSink,
|
||||
DeferredServiceLevelPublisher deferredServiceLevel,
|
||||
IOpcUaUserAuthenticator userAuthenticator,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_deferredSink = deferredSink;
|
||||
_deferredServiceLevel = deferredServiceLevel;
|
||||
_userAuthenticator = userAuthenticator;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>();
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = new OpcUaApplicationHostOptions();
|
||||
_configuration.GetSection("OpcUa").Bind(options);
|
||||
|
||||
_server = new OtOpcUaSdkServer();
|
||||
_appHost = new OpcUaApplicationHost(
|
||||
options,
|
||||
_loggerFactory.CreateLogger<OpcUaApplicationHost>(),
|
||||
_userAuthenticator);
|
||||
|
||||
try
|
||||
{
|
||||
await _appHost.StartAsync(_server, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"OtOpcUaServerHostedService: SDK start failed; OpcUaPublishActor writes will continue to no-op");
|
||||
// Don't rethrow — the rest of the host (admin UI, driver actors, etc.) can still boot.
|
||||
// Operators see the failure via the logs + can correct config without a process bounce
|
||||
// of the whole binary.
|
||||
return;
|
||||
}
|
||||
|
||||
if (_server.NodeManager is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"OtOpcUaServerHostedService: SDK reported started but NodeManager is null; sink stays Null");
|
||||
return;
|
||||
}
|
||||
|
||||
_deferredSink.SetSink(new SdkAddressSpaceSink(_server.NodeManager));
|
||||
|
||||
// ServiceLevel publisher needs IServerInternal — only available after Start.
|
||||
if (_server.CurrentInstance is { } serverInternal)
|
||||
{
|
||||
_deferredServiceLevel.SetInner(new SdkServiceLevelPublisher(
|
||||
serverInternal,
|
||||
_loggerFactory.CreateLogger<SdkServiceLevelPublisher>()));
|
||||
}
|
||||
|
||||
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound");
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Revert to Null adapters so any in-flight writes from a poison-pilled actor don't hit a
|
||||
// half-disposed NodeManager.
|
||||
_deferredSink.SetSink(null);
|
||||
_deferredServiceLevel.SetInner(null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_appHost is not null) await _appHost.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,24 @@ using Akka.Hosting;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Host;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||
using ZB.MOM.WW.OtOpcUa.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
||||
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
||||
@@ -38,6 +47,51 @@ builder.Host.UseSerilog((ctx, lc) => lc
|
||||
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
|
||||
builder.Services.AddOtOpcUaCluster(builder.Configuration);
|
||||
|
||||
if (hasDriver)
|
||||
{
|
||||
builder.Services.AddOtOpcUaRuntime();
|
||||
// Bind every cross-platform driver factory before AddAkka resolves IDriverFactory — replaces
|
||||
// the F7-default NullDriverFactory with a real DriverFactoryRegistryAdapter so DriverHostActor
|
||||
// can materialise real IDriver instances on deploy.
|
||||
builder.Services.AddOtOpcUaDriverFactories();
|
||||
|
||||
// Deferred sink so Akka actors can resolve IOpcUaAddressSpaceSink at construction time —
|
||||
// the OPC UA hosted service swaps in a real SdkAddressSpaceSink once StandardServer has
|
||||
// started. Until then writes route through NullOpcUaAddressSpaceSink.
|
||||
builder.Services.AddSingleton<DeferredAddressSpaceSink>();
|
||||
builder.Services.AddSingleton<IOpcUaAddressSpaceSink>(sp =>
|
||||
sp.GetRequiredService<DeferredAddressSpaceSink>());
|
||||
|
||||
// Same late-binding pattern for the ServiceLevel publisher — actor wants it at ctor time,
|
||||
// production SdkServiceLevelPublisher needs IServerInternal which only exists after Start.
|
||||
builder.Services.AddSingleton<DeferredServiceLevelPublisher>();
|
||||
builder.Services.AddSingleton<IServiceLevelPublisher>(sp =>
|
||||
sp.GetRequiredService<DeferredServiceLevelPublisher>());
|
||||
|
||||
// F13c — bind UserName tokens to the same LDAP backend the Admin cookie/JWT flows use.
|
||||
// ILdapAuthService is registered by AddOtOpcUaAuth on admin nodes; on driver-only nodes
|
||||
// it isn't, so we register the LDAP options + service unconditionally for driver hosts
|
||||
// to keep parity. The LdapAdapter falls back to Deny on any backend error.
|
||||
// F8b — production virtual-tag evaluator (Roslyn-compiled scripts cached per expression).
|
||||
// Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user
|
||||
// scripts at runtime.
|
||||
builder.Services.AddSingleton<RoslynVirtualTagEvaluator>(sp =>
|
||||
new RoslynVirtualTagEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynVirtualTagEvaluator>()));
|
||||
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
|
||||
|
||||
// F9b — same pattern for scripted-alarm predicates. The actor preserves prior state on
|
||||
// any Failure result, so a misbehaving script can't flip Active/Inactive spuriously.
|
||||
builder.Services.AddSingleton<RoslynScriptedAlarmEvaluator>(sp =>
|
||||
new RoslynScriptedAlarmEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynScriptedAlarmEvaluator>()));
|
||||
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
|
||||
|
||||
builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));
|
||||
builder.Services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
||||
|
||||
builder.Services.AddHostedService<OtOpcUaServerHostedService>();
|
||||
}
|
||||
|
||||
// Akka cluster bootstrap. Role-specific singletons are registered on the AkkaConfigurationBuilder
|
||||
// from inside the configurator lambda. AddAkka spins the ActorSystem at host start.
|
||||
builder.Services.AddAkka("otopcua", (ab, sp) =>
|
||||
@@ -62,6 +116,7 @@ if (hasAdmin)
|
||||
}
|
||||
|
||||
builder.Services.AddOtOpcUaHealth();
|
||||
builder.Services.AddOtOpcUaObservability();
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseSerilogRequestLogging();
|
||||
@@ -77,6 +132,7 @@ if (hasAdmin)
|
||||
}
|
||||
|
||||
app.MapOtOpcUaHealth();
|
||||
app.MapOtOpcUaMetrics();
|
||||
|
||||
Log.Information("OtOpcUa.Host starting with roles=[{Roles}] (admin={HasAdmin}, driver={HasDriver})",
|
||||
string.Join(",", roles), hasAdmin, hasDriver);
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
<AssemblyName>OtOpcUa.Host</AssemblyName>
|
||||
<UserSecretsId>zb-mom-ww-otopcua-host</UserSecretsId>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- Microsoft.CodeAnalysis.CSharp.Scripting (4.12.0, pulled in via Core.Scripting for F8b
|
||||
user-script compilation) requires CodeAnalysis.Common 4.12.0 exactly, but ASP.NET
|
||||
Core's transitive Microsoft.CodeAnalysis.CSharp 5.0.0 wins resolution. Suppress
|
||||
NU1608 — the surface we use from Scripting (ScriptEvaluator + RoslynScriptHost) is
|
||||
stable across the version drift; verified by Core.Scripting.Tests. -->
|
||||
<NoWarn>$(NoWarn);NU1608</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -15,12 +21,18 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting"/>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
|
||||
@@ -28,6 +40,22 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.AdminUI\ZB.MOM.WW.OtOpcUa.AdminUI.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Cross-platform driver assemblies. Each Register(registry, loggerFactory) extension is
|
||||
called from DriverFactoryBootstrap on driver-role nodes; the F7 seam (IDriverFactory)
|
||||
then exposes the registry to DriverHostActor. Galaxy is net10 because it talks gRPC to
|
||||
the out-of-process mxaccessgw worker — the COM-bound net48 piece is over there.
|
||||
Historian.Wonderware (the net48 COM-bridge driver) is intentionally excluded; the
|
||||
net10 .Client gRPC wrapper is what production binds when the historian role is needed. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<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.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.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>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- OpenTelemetry.Api transitively via Akka; Opc.Ua.Core transitively via OpcUaServer. -->
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-g94r-2vxg-569j"/>
|
||||
|
||||
@@ -2,9 +2,26 @@ using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-security profile served by the OPC UA endpoint. F13b ships the three baseline
|
||||
/// profiles defined by docs/security.md; the remaining Aes128/Aes256 variants can be added
|
||||
/// later by extending <see cref="OpcUaSecurityProfile.PolicyUri"/>+<see cref="OpcUaSecurityProfile.Mode"/>
|
||||
/// — the wiring in <c>BuildConfigurationAsync</c> is profile-agnostic.
|
||||
/// </summary>
|
||||
public enum OpcUaSecurityProfile
|
||||
{
|
||||
/// <summary>No signing or encryption. Dev / isolated networks only.</summary>
|
||||
None,
|
||||
/// <summary>Basic256Sha256 + Sign. Messages signed, payload visible on the wire.</summary>
|
||||
Basic256Sha256Sign,
|
||||
/// <summary>Basic256Sha256 + SignAndEncrypt. Full transport protection.</summary>
|
||||
Basic256Sha256SignAndEncrypt,
|
||||
}
|
||||
|
||||
public sealed class OpcUaApplicationHostOptions
|
||||
{
|
||||
public string ApplicationName { get; set; } = "OtOpcUa";
|
||||
@@ -19,6 +36,33 @@ public sealed class OpcUaApplicationHostOptions
|
||||
|
||||
/// <summary>Application config XML path; when set, loaded instead of building from defaults.</summary>
|
||||
public string? ApplicationConfigPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Root of the application's PKI hierarchy. Sub-stores (<c>own</c>, <c>issuer</c>,
|
||||
/// <c>trusted</c>, <c>rejected</c>) are created under this path on first start. Defaults
|
||||
/// to "pki" (relative to the host's working directory) to keep dev flows identical to v1.
|
||||
/// </summary>
|
||||
public string PkiStoreRoot { get; set; } = "pki";
|
||||
|
||||
/// <summary>
|
||||
/// Transport-security profiles exposed by the server. The SDK publishes one endpoint
|
||||
/// descriptor per profile and clients choose at session open. Default = all three
|
||||
/// baseline profiles (None + Basic256Sha256 in both modes); production deployments
|
||||
/// typically drop None.
|
||||
/// </summary>
|
||||
public IList<OpcUaSecurityProfile> EnabledSecurityProfiles { get; set; } = new List<OpcUaSecurityProfile>
|
||||
{
|
||||
OpcUaSecurityProfile.None,
|
||||
OpcUaSecurityProfile.Basic256Sha256Sign,
|
||||
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// When true, unknown client certificates are auto-added to the trusted store on first
|
||||
/// connection. Convenient for dev; should be false in production (operators promote via
|
||||
/// the Admin UI). Has no effect on <c>None</c> endpoints, which don't exchange certs.
|
||||
/// </summary>
|
||||
public bool AutoAcceptUntrustedClientCertificates { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -34,16 +78,20 @@ public sealed class OpcUaApplicationHostOptions
|
||||
public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
{
|
||||
private readonly OpcUaApplicationHostOptions _options;
|
||||
private readonly IOpcUaUserAuthenticator _userAuthenticator;
|
||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||
private ApplicationInstance? _application;
|
||||
private StandardServer? _server;
|
||||
private ImpersonateEventHandler? _impersonateHandler;
|
||||
|
||||
public OpcUaApplicationHost(
|
||||
OpcUaApplicationHostOptions options,
|
||||
ILogger<OpcUaApplicationHost> logger)
|
||||
ILogger<OpcUaApplicationHost> logger,
|
||||
IOpcUaUserAuthenticator? userAuthenticator = null)
|
||||
{
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance;
|
||||
}
|
||||
|
||||
public ApplicationInstance? ApplicationInstance => _application;
|
||||
@@ -60,14 +108,124 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
};
|
||||
|
||||
_ = await BuildConfigurationAsync(cancellationToken);
|
||||
// Certificate validation + auto-creation is part of the full extraction (F13).
|
||||
// For the facade we trust that the configured cert store already exists.
|
||||
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _application.Start(server).ConfigureAwait(false);
|
||||
|
||||
AttachUserAuthenticator();
|
||||
|
||||
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
|
||||
_options.PublicHostname, _options.OpcUaPort);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to <see cref="SessionManager.ImpersonateUser"/> after the SDK has its
|
||||
/// <c>SessionManager</c> ready (only after <c>_application.Start</c>). Anonymous tokens
|
||||
/// pass through; UserName tokens hit <see cref="IOpcUaUserAuthenticator"/> and, on
|
||||
/// success, attach a <see cref="UserIdentity"/> with the mapped role-set to the session
|
||||
/// so downstream ACL checks can read it via <c>OperationContext.UserIdentity</c>.
|
||||
///
|
||||
/// The SDK calls <c>ImpersonateUser</c> synchronously off the session-activation
|
||||
/// thread, so the authenticator's async work is run via <c>GetAwaiter().GetResult()</c>.
|
||||
/// LDAP binds typically complete in <100 ms; if a backing store ever gets that slow
|
||||
/// it should not block the OPC UA stack — callers must enforce their own timeouts inside
|
||||
/// <see cref="IOpcUaUserAuthenticator.AuthenticateUserNameAsync"/>.
|
||||
/// </summary>
|
||||
private void AttachUserAuthenticator()
|
||||
{
|
||||
var sessionManager = _server?.CurrentInstance?.SessionManager;
|
||||
if (sessionManager is null)
|
||||
{
|
||||
_logger.LogWarning("OpcUaApplicationHost: SessionManager unavailable after Start; UserName auth disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
_impersonateHandler = OnImpersonateUser;
|
||||
sessionManager.ImpersonateUser += _impersonateHandler;
|
||||
}
|
||||
|
||||
private void OnImpersonateUser(Session session, ImpersonateEventArgs args) =>
|
||||
HandleImpersonation(_userAuthenticator, args, _logger);
|
||||
|
||||
/// <summary>
|
||||
/// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
|
||||
/// the full SDK. Side-effects are confined to mutating <see cref="ImpersonateEventArgs"/>
|
||||
/// and logging.
|
||||
/// </summary>
|
||||
internal static void HandleImpersonation(
|
||||
IOpcUaUserAuthenticator authenticator,
|
||||
ImpersonateEventArgs args,
|
||||
ILogger logger)
|
||||
{
|
||||
if (args.NewIdentity is not UserNameIdentityToken token)
|
||||
{
|
||||
// Anonymous + X509 tokens — let the SDK's default validation stand.
|
||||
return;
|
||||
}
|
||||
|
||||
string password;
|
||||
try
|
||||
{
|
||||
password = token.DecryptedPassword ?? string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "OpcUaApplicationHost: failed to decrypt UserName token");
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"UserName token decryption failed");
|
||||
return;
|
||||
}
|
||||
|
||||
OpcUaUserAuthResult result;
|
||||
try
|
||||
{
|
||||
result = authenticator
|
||||
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"Authentication failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}",
|
||||
token.UserName, result.Error);
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
result.Error ?? "Invalid credentials");
|
||||
return;
|
||||
}
|
||||
|
||||
args.Identity = new UserIdentity(token);
|
||||
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} ({Roles})",
|
||||
token.UserName, string.Join(",", result.Roles));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guarantees the application instance certificate exists in <c>{PkiStoreRoot}/own</c>.
|
||||
/// The SDK auto-creates a self-signed certificate the first time this is called on a fresh
|
||||
/// PKI tree; subsequent boots reuse the existing cert. Replaces v1's manual "you must
|
||||
/// pre-create the PKI directory tree" friction. Partial slice of follow-up F13 — the
|
||||
/// remaining endpoint-security, user-token validator, and observability wiring stays in
|
||||
/// the follow-up queue.
|
||||
/// </summary>
|
||||
private async Task EnsureApplicationCertificateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// silent: false → SDK logs cert creation events through its own trace plumbing.
|
||||
// minimumKeySize/lifetimeInMonths: 0 → use SDK defaults (2048-bit, 12-month lifetime).
|
||||
var ok = await _application!.CheckApplicationInstanceCertificate(
|
||||
silent: false, minimumKeySize: 0, lifeTimeInMonths: 0, ct: cancellationToken).ConfigureAwait(false);
|
||||
if (!ok)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"OPC UA application certificate validation failed for {_options.ApplicationName}. " +
|
||||
$"Cert store root: {Path.GetFullPath(_options.PkiStoreRoot)}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ApplicationConfiguration> BuildConfigurationAsync(CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.ApplicationConfigPath))
|
||||
@@ -75,28 +233,42 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
return await _application!.LoadApplicationConfiguration(_options.ApplicationConfigPath, silent: true);
|
||||
}
|
||||
|
||||
// Minimal defaults — security and certificate stores hardcoded to local files in
|
||||
// the app's working directory. Full security wiring stays in legacy Server until F13.
|
||||
var serverConfig = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
|
||||
MinRequestThreadCount = 5,
|
||||
MaxRequestThreadCount = 100,
|
||||
MaxQueuedRequestCount = 200,
|
||||
};
|
||||
|
||||
foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles))
|
||||
{
|
||||
serverConfig.SecurityPolicies.Add(policy);
|
||||
}
|
||||
foreach (var token in BuildUserTokenPolicies())
|
||||
{
|
||||
serverConfig.UserTokenPolicies.Add(token);
|
||||
}
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = _options.ApplicationName,
|
||||
ApplicationUri = _options.ApplicationUri,
|
||||
ProductUri = _options.ProductUri,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ServerConfiguration = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
|
||||
MinRequestThreadCount = 5,
|
||||
MaxRequestThreadCount = 100,
|
||||
MaxQueuedRequestCount = 200,
|
||||
},
|
||||
ServerConfiguration = serverConfig,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier { StoreType = "Directory", StorePath = "pki/own", SubjectName = $"CN={_options.ApplicationName}" },
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = "pki/issuer" },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = "pki/trusted" },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = "pki/rejected" },
|
||||
AutoAcceptUntrustedCertificates = false,
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = "Directory",
|
||||
StorePath = Path.Combine(_options.PkiStoreRoot, "own"),
|
||||
SubjectName = $"CN={_options.ApplicationName}",
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "issuer") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") },
|
||||
AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
|
||||
},
|
||||
TransportQuotas = new TransportQuotas(),
|
||||
ClientConfiguration = new ClientConfiguration(),
|
||||
@@ -108,8 +280,80 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps each configured <see cref="OpcUaSecurityProfile"/> to a SDK
|
||||
/// <see cref="ServerSecurityPolicy"/>. Duplicate profiles are silently de-duped because
|
||||
/// the SDK rejects duplicate (policy,mode) pairs at <c>Validate</c> time. Empty input
|
||||
/// falls back to a single None entry so the server doesn't refuse to start with no
|
||||
/// listening endpoints — the misconfiguration is logged and very visible.
|
||||
/// </summary>
|
||||
internal static IEnumerable<ServerSecurityPolicy> BuildSecurityPolicies(IEnumerable<OpcUaSecurityProfile> profiles)
|
||||
{
|
||||
var seen = new HashSet<OpcUaSecurityProfile>();
|
||||
var any = false;
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
if (!seen.Add(profile)) continue;
|
||||
any = true;
|
||||
yield return profile switch
|
||||
{
|
||||
OpcUaSecurityProfile.None => new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
},
|
||||
OpcUaSecurityProfile.Basic256Sha256Sign => new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.Sign,
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
||||
},
|
||||
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt => new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
||||
},
|
||||
_ => throw new InvalidOperationException($"Unknown OpcUaSecurityProfile: {profile}"),
|
||||
};
|
||||
}
|
||||
if (!any)
|
||||
{
|
||||
yield return new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous + UserName token policies. UserName tokens are always SDK-encrypted with
|
||||
/// the server certificate (see docs/security.md "UserName token encryption") so the
|
||||
/// policy works on None endpoints too. F13c will plug a real LDAP-bound validator into
|
||||
/// <c>StandardServer.SessionManager.ImpersonateUser</c>.
|
||||
/// </summary>
|
||||
internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies()
|
||||
{
|
||||
yield return new UserTokenPolicy(UserTokenType.Anonymous)
|
||||
{
|
||||
PolicyId = "anonymous",
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
};
|
||||
yield return new UserTokenPolicy(UserTokenType.UserName)
|
||||
{
|
||||
PolicyId = "username_basic256sha256",
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
||||
};
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
|
||||
{
|
||||
try { sessionManager.ImpersonateUser -= _impersonateHandler; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); }
|
||||
}
|
||||
_impersonateHandler = null;
|
||||
|
||||
try { _application?.Stop(); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
|
||||
return ValueTask.CompletedTask;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// Custom OPC UA <see cref="CustomNodeManager2"/> that owns the writable address space for
|
||||
/// the OtOpcUa server. Variable nodes are created lazily on first <see cref="WriteValue"/>
|
||||
/// under the manager's namespace; subsequent writes update the existing node's Value +
|
||||
/// StatusCode + SourceTimestamp and notify subscribed clients via the standard
|
||||
/// <c>ClearChangeMasks</c> path.
|
||||
///
|
||||
/// This is the F10b production wiring behind the v2 <see cref="IOpcUaAddressSpaceSink"/>
|
||||
/// seam — once a <see cref="SdkAddressSpaceSink"/> is bound, OpcUaPublishActor's writes
|
||||
/// materialise as real OPC UA Variable updates that clients can browse + subscribe to.
|
||||
///
|
||||
/// Node-id encoding uses the manager's default namespace + the caller-supplied string id
|
||||
/// as the identifier portion (e.g. <c>"ns=2;s=eq-1/temp"</c>). Equipment-folder hierarchy
|
||||
/// and OPC UA type metadata still come from the Phase7Applier / EquipmentNodeWalker
|
||||
/// integration (F14b, tracked under #85) — this manager treats every id as a flat
|
||||
/// <see cref="BaseDataVariableState"/> under the namespace root.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns";
|
||||
|
||||
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
|
||||
private FolderState? _root;
|
||||
|
||||
public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
|
||||
: base(server, configuration, DefaultNamespaceUri)
|
||||
{
|
||||
// SystemContext is initialised by the base ctor.
|
||||
}
|
||||
|
||||
public int VariableCount => _variables.Count;
|
||||
public int FolderCount => _folders.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
|
||||
/// variable node on first call; subsequent calls update Value + StatusCode +
|
||||
/// SourceTimestamp and call <c>ClearChangeMasks</c> so subscribed clients see the change.
|
||||
/// </summary>
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(nodeId);
|
||||
var variable = _variables.GetOrAdd(nodeId, CreateVariable);
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
variable.Value = value;
|
||||
variable.StatusCode = StatusFromQuality(quality);
|
||||
variable.Timestamp = sourceTimestampUtc;
|
||||
variable.ClearChangeMasks(SystemContext, includeChildren: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Apply an alarm-state write. Surfaced as a two-element Variable carrying
|
||||
/// <c>[active, acknowledged]</c> — proper <c>AlarmConditionState</c> + event firing
|
||||
/// comes when the F14b walker integration lands and registers real condition nodes.</summary>
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
||||
var variable = _variables.GetOrAdd(alarmNodeId, CreateVariable);
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
variable.Value = new[] { active, acknowledged };
|
||||
variable.StatusCode = StatusCodes.Good;
|
||||
variable.Timestamp = sourceTimestampUtc;
|
||||
variable.ClearChangeMasks(SystemContext, includeChildren: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
|
||||
/// name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
|
||||
/// #85 — used by <see cref="Phase7Applier"/> to materialise the UNS Area/Line/Equipment
|
||||
/// folder hierarchy. Idempotent: the second call with the same id returns the cached
|
||||
/// folder so adding child variables under it still works.
|
||||
/// </summary>
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
||||
|
||||
if (_folders.ContainsKey(folderNodeId)) return;
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (_folders.ContainsKey(folderNodeId)) return;
|
||||
|
||||
var parent = ResolveParentFolder(parentNodeId);
|
||||
var folder = new FolderState(parent)
|
||||
{
|
||||
NodeId = new NodeId(folderNodeId, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(folderNodeId, NamespaceIndex),
|
||||
DisplayName = displayName,
|
||||
EventNotifier = EventNotifiers.None,
|
||||
TypeDefinitionId = ObjectTypeIds.FolderType,
|
||||
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
||||
};
|
||||
parent.AddChild(folder);
|
||||
AddPredefinedNode(SystemContext, folder);
|
||||
_folders[folderNodeId] = folder;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clear every registered variable + folder from the address space. Phase7Applier
|
||||
/// calls this when Equipment/Alarm topology changes; the populator then re-adds via
|
||||
/// EnsureFolder + WriteValue on the next pass.</summary>
|
||||
public void RebuildAddressSpace()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
foreach (var v in _variables.Values)
|
||||
{
|
||||
v.Parent?.RemoveChild(v);
|
||||
PredefinedNodes?.Remove(v.NodeId);
|
||||
}
|
||||
_variables.Clear();
|
||||
|
||||
foreach (var f in _folders.Values)
|
||||
{
|
||||
f.Parent?.RemoveChild(f);
|
||||
PredefinedNodes?.Remove(f.NodeId);
|
||||
}
|
||||
_folders.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private FolderState ResolveParentFolder(string? parentNodeId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(parentNodeId)) return _root!;
|
||||
return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
base.CreateAddressSpace(externalReferences);
|
||||
|
||||
// Create one root folder under Objects/ for every variable we mint to hang under.
|
||||
_root = new FolderState(null)
|
||||
{
|
||||
NodeId = new NodeId("OtOpcUa", NamespaceIndex),
|
||||
BrowseName = new QualifiedName("OtOpcUa", NamespaceIndex),
|
||||
DisplayName = "OtOpcUa",
|
||||
EventNotifier = EventNotifiers.None,
|
||||
TypeDefinitionId = ObjectTypeIds.FolderType,
|
||||
};
|
||||
_root.AddReference(ReferenceTypeIds.Organizes, isInverse: true, ObjectIds.ObjectsFolder);
|
||||
|
||||
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var refs))
|
||||
{
|
||||
refs = new List<IReference>();
|
||||
externalReferences[ObjectIds.ObjectsFolder] = refs;
|
||||
}
|
||||
refs.Add(new NodeStateReference(ReferenceTypeIds.Organizes, isInverse: false, _root.NodeId));
|
||||
|
||||
AddPredefinedNode(SystemContext, _root);
|
||||
}
|
||||
}
|
||||
|
||||
private BaseDataVariableState CreateVariable(string nodeId)
|
||||
{
|
||||
var v = new BaseDataVariableState(_root)
|
||||
{
|
||||
NodeId = new NodeId(nodeId, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(nodeId, NamespaceIndex),
|
||||
DisplayName = nodeId,
|
||||
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
|
||||
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
||||
DataType = DataTypeIds.BaseDataType,
|
||||
ValueRank = ValueRanks.Scalar,
|
||||
AccessLevel = AccessLevels.CurrentRead,
|
||||
UserAccessLevel = AccessLevels.CurrentRead,
|
||||
Historizing = false,
|
||||
};
|
||||
_root?.AddChild(v);
|
||||
AddPredefinedNode(SystemContext, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
private static StatusCode StatusFromQuality(OpcUaQuality quality) => quality switch
|
||||
{
|
||||
OpcUaQuality.Good => StatusCodes.Good,
|
||||
OpcUaQuality.Uncertain => StatusCodes.Uncertain,
|
||||
_ => StatusCodes.Bad,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="StandardServer"/> subclass that wires in the v2 <see cref="OtOpcUaNodeManager"/>.
|
||||
/// Exposes the live node manager after start so callers (<see cref="OpcUaApplicationHost"/>,
|
||||
/// the fused Host's DI binding) can wrap it in a <see cref="SdkAddressSpaceSink"/> and hand
|
||||
/// it to <c>OpcUaPublishActor</c>.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaSdkServer : StandardServer
|
||||
{
|
||||
private OtOpcUaNodeManager? _otOpcUaNodeManager;
|
||||
|
||||
/// <summary>The custom node manager once <c>StartAsync</c> has called
|
||||
/// <see cref="CreateMasterNodeManager"/>. Null until the SDK has bootstrapped.</summary>
|
||||
public OtOpcUaNodeManager? NodeManager => _otOpcUaNodeManager;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override MasterNodeManager CreateMasterNodeManager(
|
||||
IServerInternal server, ApplicationConfiguration configuration)
|
||||
{
|
||||
_otOpcUaNodeManager = new OtOpcUaNodeManager(server, configuration);
|
||||
return new MasterNodeManager(server, configuration, dynamicNamespaceUri: null, _otOpcUaNodeManager);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// Side-effecting orchestrator over <see cref="Phase7Plan"/>. Drives an
|
||||
/// <see cref="IOpcUaAddressSpaceSink"/> to materialise the diff between two
|
||||
/// <see cref="Phase7CompositionResult"/> snapshots:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>RemovedEquipment / RemovedAlarms — write Bad-quality on every removed
|
||||
/// node id then call <c>RebuildAddressSpace</c> at the end so the sink can
|
||||
/// actually tear down the OPC UA folders + variables.</item>
|
||||
/// <item>AddedEquipment / AddedAlarms — same Rebuild trigger (real SDK NodeManager
|
||||
/// will repopulate from the persisted artifact). For now we record the work.</item>
|
||||
/// <item>ChangedEquipment / ChangedAlarms — record what changed; the SDK adapter
|
||||
/// that lands in F10b will decide between in-place property writes and
|
||||
/// tear-down + rebuild.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// This is the side-effecting layer Task 47 deferred to F14. It stays pure-of-SDK so
|
||||
/// production binds a real SDK sink, dev/Mac binds <see cref="NullOpcUaAddressSpaceSink"/>,
|
||||
/// and tests can capture every call.
|
||||
/// </summary>
|
||||
public sealed class Phase7Applier
|
||||
{
|
||||
private readonly IOpcUaAddressSpaceSink _sink;
|
||||
private readonly ILogger<Phase7Applier> _logger;
|
||||
|
||||
public Phase7Applier(IOpcUaAddressSpaceSink sink, ILogger<Phase7Applier> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sink);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_sink = sink;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply <paramref name="plan"/> to the sink. Returns a summary of what was applied so
|
||||
/// callers (OpcUaPublishActor) can correlate the work back to the originating deployment.
|
||||
/// </summary>
|
||||
public Phase7ApplyOutcome Apply(Phase7Plan plan)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
if (plan.IsEmpty)
|
||||
{
|
||||
_logger.LogDebug("Phase7Applier: plan is empty; skipping sink writes");
|
||||
return new Phase7ApplyOutcome(RemovedNodes: 0, AddedNodes: 0, ChangedNodes: 0, RebuildCalled: false);
|
||||
}
|
||||
|
||||
var ts = DateTime.UtcNow;
|
||||
var removedCount = 0;
|
||||
foreach (var eq in plan.RemovedEquipment)
|
||||
{
|
||||
SafeWriteAlarmState(eq.EquipmentId, active: false, acknowledged: false, ts);
|
||||
removedCount++;
|
||||
}
|
||||
foreach (var alarm in plan.RemovedAlarms)
|
||||
{
|
||||
SafeWriteAlarmState(alarm.ScriptedAlarmId, active: false, acknowledged: false, ts);
|
||||
removedCount++;
|
||||
}
|
||||
|
||||
var changedCount =
|
||||
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count;
|
||||
var addedCount =
|
||||
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count;
|
||||
|
||||
// Any add/remove of Equipment or ScriptedAlarm requires a real address-space rebuild.
|
||||
// Driver-instance changes don't touch the address-space topology directly — they go
|
||||
// through DriverHostActor's spawn-plan in Runtime.
|
||||
var needsRebuild =
|
||||
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
|
||||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0;
|
||||
|
||||
if (needsRebuild)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.RebuildAddressSpace();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Phase7Applier: sink.RebuildAddressSpace threw");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Phase7Applier: applied plan (added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
|
||||
addedCount, removedCount, changedCount, needsRebuild);
|
||||
|
||||
return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #85 — build the UNS Area/Line/Equipment folder hierarchy in the address space from a
|
||||
/// composition snapshot. Called by <c>OpcUaPublishActor</c> after a rebuild so OPC UA
|
||||
/// clients browsing the server see proper folder structure instead of flat tag ids.
|
||||
/// Idempotent: each <c>EnsureFolder</c> call returns the existing folder if already
|
||||
/// present, so re-applies are cheap.
|
||||
/// </summary>
|
||||
public void MaterialiseHierarchy(Phase7CompositionResult composition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
|
||||
foreach (var area in composition.UnsAreas)
|
||||
{
|
||||
SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName);
|
||||
}
|
||||
foreach (var line in composition.UnsLines)
|
||||
{
|
||||
SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName);
|
||||
}
|
||||
foreach (var equipment in composition.EquipmentNodes)
|
||||
{
|
||||
// Equipment with no UnsLineId (legacy / dev rows) hang under the root.
|
||||
var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId;
|
||||
SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Phase7Applier: hierarchy materialised (areas={Areas}, lines={Lines}, equipment={Equipment})",
|
||||
composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count);
|
||||
}
|
||||
|
||||
private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
|
||||
{
|
||||
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
|
||||
}
|
||||
|
||||
private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
|
||||
{
|
||||
try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: WriteAlarmState threw for {Node}", nodeId); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.</summary>
|
||||
public sealed record Phase7ApplyOutcome(
|
||||
int RemovedNodes,
|
||||
int AddedNodes,
|
||||
int ChangedNodes,
|
||||
bool RebuildCalled);
|
||||
@@ -2,12 +2,30 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.</summary>
|
||||
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.
|
||||
/// <see cref="UnsAreas"/> + <see cref="UnsLines"/> carry the UNS topology so the applier can
|
||||
/// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries
|
||||
/// its parent line id so the applier knows where to hang each equipment folder.</summary>
|
||||
public sealed record Phase7CompositionResult(
|
||||
IReadOnlyList<UnsAreaProjection> UnsAreas,
|
||||
IReadOnlyList<UnsLineProjection> UnsLines,
|
||||
IReadOnlyList<EquipmentNode> EquipmentNodes,
|
||||
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
|
||||
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans);
|
||||
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans)
|
||||
{
|
||||
/// <summary>Convenience constructor for tests + earlier callers that don't yet carry UNS topology.</summary>
|
||||
public Phase7CompositionResult(
|
||||
IReadOnlyList<EquipmentNode> equipmentNodes,
|
||||
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
|
||||
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
|
||||
: this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
|
||||
equipmentNodes, driverInstancePlans, scriptedAlarmPlans)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
|
||||
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
|
||||
public sealed record EquipmentNode(string EquipmentId, string DisplayName, string UnsLineId);
|
||||
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
|
||||
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
|
||||
@@ -17,18 +35,38 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI
|
||||
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
|
||||
/// startup (Task 53) consumes the result and hands it to the node-manager factory.
|
||||
///
|
||||
/// Full migration of the legacy <c>Server.Phase7.Phase7Composer</c> (which mutates a server-side
|
||||
/// node cache, emits trace logs, and calls into <c>EquipmentNodeWalker</c>) is tracked as
|
||||
/// follow-up F14. This pure version handles the projection step; the side-effecting wiring
|
||||
/// stays in the legacy code until F14 lands.
|
||||
/// #85 — the composer now carries UNS topology (<see cref="UnsAreaProjection"/> +
|
||||
/// <see cref="UnsLineProjection"/>) so <c>Phase7Applier</c> can build the
|
||||
/// <c>Area/Line/Equipment</c> folder hierarchy in the SDK's address space. The legacy
|
||||
/// <c>EquipmentNodeWalker</c> integration that did this server-side is fully replaced by the
|
||||
/// (composer → applier → sink → node manager) chain.
|
||||
/// </summary>
|
||||
public static class Phase7Composer
|
||||
{
|
||||
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS topology.</summary>
|
||||
public static Phase7CompositionResult Compose(
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
||||
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms);
|
||||
|
||||
public static Phase7CompositionResult Compose(
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms)
|
||||
{
|
||||
var areas = unsAreas
|
||||
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
|
||||
.ToList();
|
||||
|
||||
var lines = unsLines
|
||||
.OrderBy(l => l.UnsLineId, StringComparer.Ordinal)
|
||||
.Select(l => new UnsLineProjection(l.UnsLineId, l.UnsAreaId, l.Name))
|
||||
.ToList();
|
||||
|
||||
var nodes = equipment
|
||||
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
|
||||
.Select(e => new EquipmentNode(e.EquipmentId, e.MachineCode, e.UnsLineId))
|
||||
@@ -44,6 +82,6 @@ public static class Phase7Composer
|
||||
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
||||
.ToList();
|
||||
|
||||
return new Phase7CompositionResult(nodes, plans, alarms);
|
||||
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// Pure diff between two <see cref="Phase7CompositionResult"/> snapshots — the
|
||||
/// <c>previous</c> currently-applied composition and the <c>next</c> from a freshly-applied
|
||||
/// deployment. Three lists per entity class (Equipment / DriverInstance / ScriptedAlarm)
|
||||
/// captured by stable identity: added items are new, removed items have to be torn down,
|
||||
/// changed items have the same identity but at least one field differs.
|
||||
///
|
||||
/// OpcUaPublishActor's <c>RebuildAddressSpace</c> consumes this against a real
|
||||
/// <see cref="Commons.OpcUa.IOpcUaAddressSpaceSink"/> binding so re-applies only mutate the
|
||||
/// nodes that actually changed — full tear-down + rebuild is reserved for first-boot or
|
||||
/// drastic schema flips.
|
||||
/// </summary>
|
||||
public sealed record Phase7Plan(
|
||||
IReadOnlyList<EquipmentNode> AddedEquipment,
|
||||
IReadOnlyList<EquipmentNode> RemovedEquipment,
|
||||
IReadOnlyList<Phase7Plan.EquipmentDelta> ChangedEquipment,
|
||||
IReadOnlyList<DriverInstancePlan> AddedDrivers,
|
||||
IReadOnlyList<DriverInstancePlan> RemovedDrivers,
|
||||
IReadOnlyList<Phase7Plan.DriverDelta> ChangedDrivers,
|
||||
IReadOnlyList<ScriptedAlarmPlan> AddedAlarms,
|
||||
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
|
||||
IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms)
|
||||
{
|
||||
public bool IsEmpty =>
|
||||
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
|
||||
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
|
||||
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0;
|
||||
|
||||
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
|
||||
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
|
||||
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
|
||||
}
|
||||
|
||||
public static class Phase7Planner
|
||||
{
|
||||
/// <summary>
|
||||
/// Diff two compositions, emitting Added/Removed/Changed sets per entity class.
|
||||
/// Identity is the entity's stable id (EquipmentId, DriverInstanceId, ScriptedAlarmId).
|
||||
/// Element equality on the projection records doubles as the "did this change" check,
|
||||
/// so any field difference moves an item from "stable" to ChangedX.
|
||||
/// </summary>
|
||||
public static Phase7Plan Compute(Phase7CompositionResult previous, Phase7CompositionResult next)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(previous);
|
||||
ArgumentNullException.ThrowIfNull(next);
|
||||
|
||||
var (addedEq, removedEq, changedEq) = DiffById(
|
||||
previous.EquipmentNodes, next.EquipmentNodes,
|
||||
n => n.EquipmentId,
|
||||
(a, b) => new Phase7Plan.EquipmentDelta(a, b));
|
||||
|
||||
var (addedDrv, removedDrv, changedDrv) = DiffById(
|
||||
previous.DriverInstancePlans, next.DriverInstancePlans,
|
||||
d => d.DriverInstanceId,
|
||||
(a, b) => new Phase7Plan.DriverDelta(a, b));
|
||||
|
||||
var (addedAlarm, removedAlarm, changedAlarm) = DiffById(
|
||||
previous.ScriptedAlarmPlans, next.ScriptedAlarmPlans,
|
||||
a => a.ScriptedAlarmId,
|
||||
(a, b) => new Phase7Plan.AlarmDelta(a, b));
|
||||
|
||||
return new Phase7Plan(
|
||||
addedEq, removedEq, changedEq,
|
||||
addedDrv, removedDrv, changedDrv,
|
||||
addedAlarm, removedAlarm, changedAlarm);
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<T> Added, IReadOnlyList<T> Removed, IReadOnlyList<TDelta> Changed)
|
||||
DiffById<T, TDelta>(
|
||||
IReadOnlyList<T> previous,
|
||||
IReadOnlyList<T> next,
|
||||
Func<T, string> identity,
|
||||
Func<T, T, TDelta> deltaFactory) where T : class
|
||||
{
|
||||
var prevById = previous.ToDictionary(identity, StringComparer.Ordinal);
|
||||
var nextById = next.ToDictionary(identity, StringComparer.Ordinal);
|
||||
|
||||
var added = new List<T>();
|
||||
var removed = new List<T>();
|
||||
var changed = new List<TDelta>();
|
||||
|
||||
foreach (var (id, p) in prevById)
|
||||
{
|
||||
if (!nextById.TryGetValue(id, out var n)) { removed.Add(p); continue; }
|
||||
if (!EqualityComparer<T>.Default.Equals(p, n)) changed.Add(deltaFactory(p, n));
|
||||
}
|
||||
foreach (var (id, n) in nextById)
|
||||
{
|
||||
if (!prevById.ContainsKey(id)) added.Add(n);
|
||||
}
|
||||
|
||||
added.Sort((a, b) => string.CompareOrdinal(identity(a), identity(b)));
|
||||
removed.Sort((a, b) => string.CompareOrdinal(identity(a), identity(b)));
|
||||
return (added, removed, changed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IOpcUaAddressSpaceSink"/> binding for v2 — bridges
|
||||
/// OpcUaPublishActor's writes to the SDK address space owned by
|
||||
/// <see cref="OtOpcUaNodeManager"/>. The host wires this in once the StandardServer has
|
||||
/// been started (so the node manager exists).
|
||||
/// </summary>
|
||||
public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
private readonly OtOpcUaNodeManager _nodeManager;
|
||||
|
||||
public SdkAddressSpaceSink(OtOpcUaNodeManager nodeManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nodeManager);
|
||||
_nodeManager = nodeManager;
|
||||
}
|
||||
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc);
|
||||
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
=> _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
|
||||
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||
|
||||
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IServiceLevelPublisher"/> that writes the OPC UA Server object's
|
||||
/// <c>ServiceLevel</c> Variable through the SDK. Clients reading
|
||||
/// <c>VariableIds.Server_ServiceLevel</c> see the live value updated whenever the redundancy
|
||||
/// state changes — that's the standard OPC UA non-transparent-redundancy signal callers use
|
||||
/// to pick a primary.
|
||||
///
|
||||
/// Uses <see cref="IServerInternal.ServerObject"/> (a <see cref="ServerObjectState"/>) and
|
||||
/// its <see cref="ServerObjectState.ServiceLevel"/> child variable, which the SDK populates
|
||||
/// automatically during <see cref="DiagnosticsNodeManager"/> initialization. Writes are
|
||||
/// guarded by <see cref="IServerInternal.DiagnosticsLock"/> so concurrent diagnostics scans
|
||||
/// from the SDK don't fight with our update.
|
||||
/// </summary>
|
||||
public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
|
||||
{
|
||||
private readonly IServerInternal _serverInternal;
|
||||
private readonly ILogger<SdkServiceLevelPublisher> _logger;
|
||||
|
||||
public SdkServiceLevelPublisher(IServerInternal serverInternal, ILogger<SdkServiceLevelPublisher> logger)
|
||||
{
|
||||
_serverInternal = serverInternal;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void Publish(byte serviceLevel)
|
||||
{
|
||||
var node = _serverInternal.ServerObject?.ServiceLevel;
|
||||
if (node is null)
|
||||
{
|
||||
_logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable; skipping write");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (_serverInternal.DiagnosticsLock)
|
||||
{
|
||||
node.Value = serviceLevel;
|
||||
node.Timestamp = DateTime.UtcNow;
|
||||
node.StatusCode = StatusCodes.Good;
|
||||
node.ClearChangeMasks(_serverInternal.DefaultSystemContext, includeChildren: false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "SdkServiceLevelPublisher: write to Server.ServiceLevel threw");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Validates OPC UA UserName tokens. The SDK already decrypts the token (using the server
|
||||
/// application cert) and hands the cleartext username + password to this seam. Implementations
|
||||
/// decide whether the credentials are valid and what roles to attach for downstream ACL checks.
|
||||
///
|
||||
/// Production implementation lives in the Host project (wraps <c>ILdapAuthService</c>); the
|
||||
/// <see cref="NullOpcUaUserAuthenticator"/> default rejects every attempt so misconfigured
|
||||
/// dev nodes don't silently accept credentials.
|
||||
/// </summary>
|
||||
public interface IOpcUaUserAuthenticator
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves cleartext UserName credentials against the configured backing store. Must not
|
||||
/// throw — callers turn results into <c>ImpersonateEventArgs.IdentityValidationError</c>
|
||||
/// reject codes, and a thrown exception escapes into the OPC UA SDK's session-activation
|
||||
/// path where it surfaces as a generic <c>BadInternalError</c>.
|
||||
/// </summary>
|
||||
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a UserName authentication attempt. <see cref="Roles"/> populates the session identity's role set.</summary>
|
||||
public sealed record OpcUaUserAuthResult(
|
||||
bool Success,
|
||||
string? DisplayName,
|
||||
IReadOnlyList<string> Roles,
|
||||
string? Error)
|
||||
{
|
||||
public static OpcUaUserAuthResult Allow(string displayName, IReadOnlyList<string> roles) =>
|
||||
new(true, displayName, roles, null);
|
||||
|
||||
public static OpcUaUserAuthResult Deny(string error) =>
|
||||
new(false, null, Array.Empty<string>(), error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default deny-all authenticator. Wired by <c>OpcUaApplicationHost</c> when no production
|
||||
/// authenticator is registered in DI — keeps the server safe-by-default rather than accepting
|
||||
/// arbitrary UserName credentials. Production Host DI overrides this with the LDAP adapter.
|
||||
/// </summary>
|
||||
public sealed class NullOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
||||
{
|
||||
public static readonly NullOpcUaUserAuthenticator Instance = new();
|
||||
private NullOpcUaUserAuthenticator() { }
|
||||
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
|
||||
Task.FromResult(OpcUaUserAuthResult.Deny("No UserName authenticator is configured on this server."));
|
||||
}
|
||||
@@ -19,6 +19,10 @@
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal driver-side view of the deployment artifact emitted by
|
||||
/// <c>ConfigComposer.SnapshotAndFlattenAsync</c>. The artifact JSON is the full snapshot —
|
||||
/// for driver spawning we only need the <c>DriverInstances</c> array. Reading just the
|
||||
/// subset keeps allocations cheap on every deploy.
|
||||
/// </summary>
|
||||
public sealed record DriverInstanceSpec(
|
||||
Guid DriverInstanceRowId,
|
||||
string DriverInstanceId,
|
||||
string Name,
|
||||
string DriverType,
|
||||
bool Enabled,
|
||||
string DriverConfig);
|
||||
|
||||
public static class DeploymentArtifact
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parse a deployment artifact blob into the list of driver-instance specs to spawn.
|
||||
/// Empty / malformed blobs return an empty list — callers log + treat as "no drivers".
|
||||
/// </summary>
|
||||
public static IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(ReadOnlySpan<byte> blob)
|
||||
{
|
||||
if (blob.IsEmpty) return Array.Empty<DriverInstanceSpec>();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||
if (!doc.RootElement.TryGetProperty("DriverInstances", out var arr)
|
||||
|| arr.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<DriverInstanceSpec>();
|
||||
}
|
||||
|
||||
var result = new List<DriverInstanceSpec>(arr.GetArrayLength());
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var spec = TryReadSpec(el);
|
||||
if (spec is not null) result.Add(spec);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<DriverInstanceSpec>();
|
||||
}
|
||||
}
|
||||
|
||||
private static DriverInstanceSpec? TryReadSpec(JsonElement el)
|
||||
{
|
||||
var rowId = el.TryGetProperty("DriverInstanceRowId", out var rowEl)
|
||||
&& rowEl.TryGetGuid(out var rid) ? rid : Guid.Empty;
|
||||
var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null;
|
||||
var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
|
||||
var type = el.TryGetProperty("DriverType", out var typeEl) ? typeEl.GetString() : null;
|
||||
var enabled = !el.TryGetProperty("Enabled", out var enEl) || enEl.GetBoolean();
|
||||
var config = el.TryGetProperty("DriverConfig", out var cfgEl) ? cfgEl.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null;
|
||||
|
||||
return new DriverInstanceSpec(
|
||||
DriverInstanceRowId: rowId,
|
||||
DriverInstanceId: id!,
|
||||
Name: name ?? id!,
|
||||
DriverType: type!,
|
||||
Enabled: enabled,
|
||||
DriverConfig: config ?? "{}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the artifact into the projected <see cref="Phase7CompositionResult"/> used by
|
||||
/// <c>Phase7Planner</c> + <c>Phase7Applier</c>. Returns an empty composition for empty/
|
||||
/// malformed blobs so callers can treat parse failure as a no-op deploy.
|
||||
///
|
||||
/// The artifact JSON is produced by <c>ConfigComposer.SnapshotAndFlattenAsync</c> in the
|
||||
/// ControlPlane — its Pascal-case property names match the EF entities. We only need a
|
||||
/// subset of fields per entity class to drive the address-space rebuild on driver-role
|
||||
/// nodes.
|
||||
/// </summary>
|
||||
public static Phase7CompositionResult ParseComposition(ReadOnlySpan<byte> blob)
|
||||
{
|
||||
if (blob.IsEmpty) return Empty();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||
var root = doc.RootElement;
|
||||
|
||||
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
|
||||
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
|
||||
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
||||
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
||||
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
||||
|
||||
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static Phase7CompositionResult Empty() => new(
|
||||
Array.Empty<UnsAreaProjection>(),
|
||||
Array.Empty<UnsLineProjection>(),
|
||||
Array.Empty<EquipmentNode>(),
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
||||
where T : class
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<T>();
|
||||
|
||||
var result = new List<T>(arr.GetArrayLength());
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var item = reader(el);
|
||||
if (item is not null) result.Add(item);
|
||||
}
|
||||
// Match Phase7Composer's natural-key sort so plan diffs are deterministic across
|
||||
// artifact-decode + composer-compose passes.
|
||||
return result.OrderBy(IdentityOf, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static string IdentityOf<T>(T item) where T : class => item switch
|
||||
{
|
||||
UnsAreaProjection a => a.UnsAreaId,
|
||||
UnsLineProjection l => l.UnsLineId,
|
||||
EquipmentNode e => e.EquipmentId,
|
||||
DriverInstancePlan d => d.DriverInstanceId,
|
||||
ScriptedAlarmPlan a => a.ScriptedAlarmId,
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private static UnsAreaProjection? ReadAreaProjection(JsonElement el)
|
||||
{
|
||||
var id = el.TryGetProperty("UnsAreaId", out var idEl) ? idEl.GetString() : null;
|
||||
var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||
return new UnsAreaProjection(id!, name ?? id!);
|
||||
}
|
||||
|
||||
private static UnsLineProjection? ReadLineProjection(JsonElement el)
|
||||
{
|
||||
var id = el.TryGetProperty("UnsLineId", out var idEl) ? idEl.GetString() : null;
|
||||
var areaId = el.TryGetProperty("UnsAreaId", out var areaEl) ? areaEl.GetString() : null;
|
||||
var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(areaId)) return null;
|
||||
return new UnsLineProjection(id!, areaId!, name ?? id!);
|
||||
}
|
||||
|
||||
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
|
||||
{
|
||||
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
|
||||
var displayName = el.TryGetProperty("MachineCode", out var mcEl) ? mcEl.GetString() : null;
|
||||
var lineId = el.TryGetProperty("UnsLineId", out var lineEl) ? lineEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||
return new EquipmentNode(id!, displayName ?? id!, lineId ?? string.Empty);
|
||||
}
|
||||
|
||||
private static DriverInstancePlan? ReadDriverPlan(JsonElement el)
|
||||
{
|
||||
var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null;
|
||||
var type = el.TryGetProperty("DriverType", out var typeEl) ? typeEl.GetString() : null;
|
||||
var config = el.TryGetProperty("DriverConfig", out var cfgEl) ? cfgEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null;
|
||||
return new DriverInstancePlan(id!, type!, config ?? "{}");
|
||||
}
|
||||
|
||||
private static ScriptedAlarmPlan? ReadAlarmPlan(JsonElement el)
|
||||
{
|
||||
var id = el.TryGetProperty("ScriptedAlarmId", out var idEl) ? idEl.GetString() : null;
|
||||
var equipmentId = el.TryGetProperty("EquipmentId", out var eqEl) ? eqEl.GetString() : null;
|
||||
var script = el.TryGetProperty("PredicateScriptId", out var scEl) ? scEl.GetString() : null;
|
||||
var template = el.TryGetProperty("MessageTemplate", out var tmEl) ? tmEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||
return new ScriptedAlarmPlan(id!, equipmentId ?? string.Empty, script ?? string.Empty, template ?? string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
@@ -5,10 +6,12 @@ using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
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;
|
||||
using CommonsNodeId = ZB.MOM.WW.OtOpcUa.Commons.Types.NodeId;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
@@ -38,11 +41,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
private readonly CommonsNodeId _localNode;
|
||||
private readonly IActorRef? _coordinatorOverride;
|
||||
private readonly IDriverFactory _driverFactory;
|
||||
private readonly IReadOnlySet<string> _localRoles;
|
||||
private readonly IActorRef? _dependencyMux;
|
||||
private readonly IActorRef? _opcUaPublishActor;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
private RevisionHash? _currentRevision;
|
||||
private DeploymentId? _applyingDeploymentId;
|
||||
|
||||
private readonly Dictionary<string, ChildEntry> _children = new(StringComparer.Ordinal);
|
||||
|
||||
private sealed record ChildEntry(IActorRef Actor, string DriverType, string LastConfigJson, bool Stubbed);
|
||||
|
||||
public ITimerScheduler Timers { get; set; } = null!;
|
||||
|
||||
public sealed class RetryConfigDbConnection
|
||||
@@ -54,17 +65,30 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
public static Props Props(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
IActorRef? coordinator = null) =>
|
||||
Akka.Actor.Props.Create(() => new DriverHostActor(dbFactory, localNode, coordinator));
|
||||
IActorRef? coordinator = null,
|
||||
IDriverFactory? driverFactory = null,
|
||||
IReadOnlySet<string>? localRoles = null,
|
||||
IActorRef? dependencyMux = null,
|
||||
IActorRef? opcUaPublishActor = null) =>
|
||||
Akka.Actor.Props.Create(() => new DriverHostActor(
|
||||
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor));
|
||||
|
||||
public DriverHostActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
IActorRef? coordinator)
|
||||
IActorRef? coordinator,
|
||||
IDriverFactory? driverFactory = null,
|
||||
IReadOnlySet<string>? localRoles = null,
|
||||
IActorRef? dependencyMux = null,
|
||||
IActorRef? opcUaPublishActor = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_localNode = localNode;
|
||||
_coordinatorOverride = coordinator;
|
||||
_driverFactory = driverFactory ?? NullDriverFactory.Instance;
|
||||
_localRoles = localRoles ?? new HashSet<string>(StringComparer.Ordinal);
|
||||
_dependencyMux = dependencyMux;
|
||||
_opcUaPublishActor = opcUaPublishActor;
|
||||
|
||||
// Default behavior is Steady — PreStart may flip to Stale or replay an orphan apply.
|
||||
Become(Steady);
|
||||
@@ -137,6 +161,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
Receive<DispatchDeployment>(HandleDispatchFromSteady);
|
||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
||||
}
|
||||
|
||||
@@ -155,9 +180,18 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Self.Forward(msg); // re-deliver after we transition back
|
||||
});
|
||||
Receive<GetDiagnostics>(HandleGetDiagnostics);
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
||||
}
|
||||
|
||||
private void ForwardToMux(DriverInstanceActor.AttributeValuePublished msg)
|
||||
{
|
||||
// Pass driver-published values to the dependency mux when one is wired. Without a mux,
|
||||
// VirtualTagActor evaluation can't fire — values just drop here. That's the dev/Mac path
|
||||
// (no virtual tags registered); production binds the mux via the RuntimeActors extension.
|
||||
_dependencyMux?.Tell(msg);
|
||||
}
|
||||
|
||||
private void Stale()
|
||||
{
|
||||
Receive<DispatchDeployment>(_ =>
|
||||
@@ -172,12 +206,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
|
||||
private void HandleGetDiagnostics(GetDiagnostics msg)
|
||||
{
|
||||
// Driver-instance children aren't spawned yet (F7); the snapshot reports an empty driver
|
||||
// list. CurrentRevision is real — it's what the host believes is its applied revision.
|
||||
var drivers = _children
|
||||
.Select(kv => new DriverInstanceDiagnostics(
|
||||
DriverInstanceId: Guid.Empty,
|
||||
Name: kv.Key,
|
||||
State: kv.Value.Stubbed ? "Stubbed" : "Spawned",
|
||||
ConnectedDevices: 0,
|
||||
FaultedDevices: 0,
|
||||
LastChangeUtc: DateTime.UtcNow))
|
||||
.ToArray();
|
||||
var snapshot = new NodeDiagnosticsSnapshot(
|
||||
NodeId: _localNode,
|
||||
CurrentRevision: _currentRevision,
|
||||
Drivers: Array.Empty<DriverInstanceDiagnostics>(),
|
||||
Drivers: drivers,
|
||||
AsOfUtc: DateTime.UtcNow);
|
||||
Sender.Tell(snapshot);
|
||||
}
|
||||
@@ -200,30 +241,165 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_applyingDeploymentId = deploymentId;
|
||||
Become(Applying);
|
||||
|
||||
using var span = OtOpcUaTelemetry.StartDeployApplySpan(deploymentId.ToString());
|
||||
span?.SetTag("otopcua.node_id", _localNode.ToString());
|
||||
span?.SetTag("otopcua.revision", revision.ToString());
|
||||
span?.SetTag("otopcua.correlation_id", correlation.ToString());
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Persist Applying row (idempotent on PK).
|
||||
UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Applying, failureReason: null);
|
||||
|
||||
try
|
||||
{
|
||||
// Future: dispatch ApplyDelta to children, wait for acks. For Task 37/38, just no-op.
|
||||
ReconcileDrivers(deploymentId);
|
||||
_currentRevision = revision;
|
||||
UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Applied, failureReason: null);
|
||||
SendAck(deploymentId, ApplyAckOutcome.Applied, failureReason: null, correlation);
|
||||
_log.Info("DriverHost {Node}: applied deployment {Id} (rev {Rev})", _localNode, deploymentId, revision);
|
||||
// Trigger the OPC UA address-space rebuild so the local SDK reflects the new
|
||||
// composition. The publish actor handles the load-compose-diff-apply pipeline; we
|
||||
// just forward the same correlation id so the audit trail joins up.
|
||||
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation));
|
||||
OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair<string, object?>("outcome", "ack"));
|
||||
_log.Info("DriverHost {Node}: applied deployment {Id} (rev {Rev}, children={Count})",
|
||||
_localNode, deploymentId, revision, _children.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Failed, ex.Message);
|
||||
SendAck(deploymentId, ApplyAckOutcome.Failed, ex.Message, correlation);
|
||||
OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair<string, object?>("outcome", "reject"));
|
||||
span?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_log.Error(ex, "DriverHost {Node}: apply of {Id} failed", _localNode, deploymentId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
OtOpcUaTelemetry.DeploymentApplyDurationSec.Record(sw.Elapsed.TotalSeconds);
|
||||
_applyingDeploymentId = null;
|
||||
Become(Steady);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the deployment artifact + reconcile the set of running <see cref="DriverInstanceActor"/>
|
||||
/// children. Spawn missing, ApplyDelta on config change, stop removed/disabled drivers.
|
||||
/// When the artifact blob is empty (legacy ControlPlane tests, smoke fixtures) or the
|
||||
/// configured <see cref="IDriverFactory"/> can't materialise any of the requested
|
||||
/// types, this is effectively a no-op.
|
||||
/// </summary>
|
||||
private void ReconcileDrivers(DeploymentId deploymentId)
|
||||
{
|
||||
byte[] blob;
|
||||
try
|
||||
{
|
||||
using var db = _dbFactory.CreateDbContext();
|
||||
blob = db.Deployments.AsNoTracking()
|
||||
.Where(d => d.DeploymentId == deploymentId.Value)
|
||||
.Select(d => d.ArtifactBlob)
|
||||
.FirstOrDefault() ?? Array.Empty<byte>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "DriverHost {Node}: failed to load artifact for {Id}; skipping reconcile",
|
||||
_localNode, deploymentId);
|
||||
return;
|
||||
}
|
||||
|
||||
var specs = DeploymentArtifact.ParseDriverInstances(blob);
|
||||
var snapshots = _children.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => new DriverChildSnapshot(kv.Value.DriverType, kv.Value.LastConfigJson),
|
||||
StringComparer.Ordinal);
|
||||
var plan = DriverSpawnPlanner.Compute(snapshots, specs);
|
||||
|
||||
foreach (var id in plan.ToStop) StopChild(id);
|
||||
foreach (var spec in plan.ToApplyDelta) ApplyChildDelta(spec);
|
||||
foreach (var spec in plan.ToSpawn) SpawnChild(spec);
|
||||
}
|
||||
|
||||
private void SpawnChild(DriverInstanceSpec spec)
|
||||
{
|
||||
var stub = DriverInstanceActor.ShouldStub(spec.DriverType, _localRoles);
|
||||
IDriver? driver = null;
|
||||
if (!stub)
|
||||
{
|
||||
try { driver = _driverFactory.TryCreate(spec.DriverType, spec.DriverInstanceId, spec.DriverConfig); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "DriverHost {Node}: factory for {Type} threw on {Id}; stubbing",
|
||||
_localNode, spec.DriverType, spec.DriverInstanceId);
|
||||
}
|
||||
if (driver is null)
|
||||
{
|
||||
_log.Warning(
|
||||
"DriverHost {Node}: no factory for driver type {Type} (instance {Id}); falling back to stub",
|
||||
_localNode, spec.DriverType, spec.DriverInstanceId);
|
||||
stub = true;
|
||||
}
|
||||
}
|
||||
|
||||
IActorRef child;
|
||||
if (stub)
|
||||
{
|
||||
child = Context.ActorOf(
|
||||
DriverInstanceActor.Props(new StubbedDriver(spec.DriverInstanceId, spec.DriverType),
|
||||
reconnectInterval: null, startStubbed: true),
|
||||
ActorNameFor(spec.DriverInstanceId));
|
||||
}
|
||||
else
|
||||
{
|
||||
child = Context.ActorOf(
|
||||
DriverInstanceActor.Props(driver!),
|
||||
ActorNameFor(spec.DriverInstanceId));
|
||||
child.Tell(new DriverInstanceActor.InitializeRequested(spec.DriverConfig));
|
||||
}
|
||||
|
||||
_children[spec.DriverInstanceId] = new ChildEntry(child, spec.DriverType, spec.DriverConfig, stub);
|
||||
_log.Info("DriverHost {Node}: spawned {Type} driver {Id} (stub={Stub})",
|
||||
_localNode, spec.DriverType, spec.DriverInstanceId, stub);
|
||||
}
|
||||
|
||||
private void ApplyChildDelta(DriverInstanceSpec spec)
|
||||
{
|
||||
if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return;
|
||||
entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, CorrelationId.NewId()));
|
||||
_children[spec.DriverInstanceId] = entry with { LastConfigJson = spec.DriverConfig };
|
||||
_log.Debug("DriverHost {Node}: ApplyDelta queued for {Id}", _localNode, spec.DriverInstanceId);
|
||||
}
|
||||
|
||||
private void StopChild(string driverInstanceId)
|
||||
{
|
||||
if (!_children.TryGetValue(driverInstanceId, out var entry)) return;
|
||||
Context.Stop(entry.Actor);
|
||||
_children.Remove(driverInstanceId);
|
||||
_log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId);
|
||||
}
|
||||
|
||||
private static string ActorNameFor(string driverInstanceId)
|
||||
{
|
||||
// Akka actor names cannot contain '/', ':', or whitespace. Mangle defensively.
|
||||
var chars = driverInstanceId.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.' ? c : '_').ToArray();
|
||||
return "drv-" + new string(chars);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal placeholder driver used when no factory is registered for a driver type or when
|
||||
/// <see cref="DriverInstanceActor.ShouldStub"/> returns true. <see cref="DriverInstanceActor"/>
|
||||
/// is started with <c>startStubbed:true</c> so the driver methods on this object never run.
|
||||
/// </summary>
|
||||
private sealed class StubbedDriver : IDriver
|
||||
{
|
||||
public string DriverInstanceId { get; }
|
||||
public string DriverType { get; }
|
||||
public StubbedDriver(string id, string type) { DriverInstanceId = id; DriverType = type; }
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void TryRecoverFromStale()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
@@ -31,6 +33,14 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
public sealed record ApplyResult(bool Success, string? Reason, CorrelationId Correlation);
|
||||
public sealed record WriteAttribute(string TagId, object Value);
|
||||
public sealed record WriteAttributeResult(bool Success, string? Reason);
|
||||
public sealed record Subscribe(IReadOnlyList<string> FullReferences, TimeSpan PublishingInterval);
|
||||
public sealed record SubscriptionEstablished(string DiagnosticId, int ReferenceCount);
|
||||
public sealed record SubscriptionFailed(string Reason);
|
||||
public sealed record Unsubscribe;
|
||||
/// <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);
|
||||
private sealed record DataChangeForward(string FullReference, DataValueSnapshot Snapshot);
|
||||
public sealed class RetryConnect
|
||||
{
|
||||
public static readonly RetryConnect Instance = new();
|
||||
@@ -43,6 +53,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private string? _currentConfigJson;
|
||||
|
||||
/// <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>
|
||||
private ISubscriptionHandle? _subscriptionHandle;
|
||||
private EventHandler<DataChangeEventArgs>? _dataChangeHandler;
|
||||
|
||||
public ITimerScheduler Timers { get; set; } = null!;
|
||||
|
||||
public static Props Props(IDriver driver, TimeSpan? reconnectInterval = null, bool startStubbed = false) =>
|
||||
@@ -67,6 +83,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
_driver = driver;
|
||||
_driverInstanceId = driver.DriverInstanceId;
|
||||
_reconnectInterval = reconnectInterval;
|
||||
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
||||
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
|
||||
new KeyValuePair<string, object?>("driver_type", driver.DriverType));
|
||||
if (startStubbed)
|
||||
{
|
||||
Context.GetLogger().Info("[DEV-STUB] driver={Name} type={Type}",
|
||||
@@ -111,9 +130,13 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
|
||||
_driverInstanceId, msg.Reason);
|
||||
DetachSubscription();
|
||||
Become(Reconnecting);
|
||||
});
|
||||
Receive<WriteAttribute>(HandleWrite);
|
||||
ReceiveAsync<WriteAttribute>(HandleWriteAsync);
|
||||
ReceiveAsync<Subscribe>(HandleSubscribeAsync);
|
||||
ReceiveAsync<Unsubscribe>(_ => UnsubscribeAsync());
|
||||
Receive<DataChangeForward>(OnDataChangeForward);
|
||||
}
|
||||
|
||||
private void Reconnecting()
|
||||
@@ -162,23 +185,141 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleWrite(WriteAttribute msg)
|
||||
private async Task HandleWriteAsync(WriteAttribute msg)
|
||||
{
|
||||
// Per-tag write requires IWritable capability discovery. Skeleton stub — see follow-up F7.
|
||||
if (_driver is IWritable writable)
|
||||
{
|
||||
// Future: writable.WriteAsync(msg.TagId, msg.Value, ct) and Pipe back to Sender.
|
||||
Sender.Tell(new WriteAttributeResult(false, "Write path not yet implemented (F7)"));
|
||||
}
|
||||
else
|
||||
if (_driver is not IWritable writable)
|
||||
{
|
||||
Sender.Tell(new WriteAttributeResult(false, "Driver does not implement IWritable"));
|
||||
return;
|
||||
}
|
||||
|
||||
var replyTo = Sender;
|
||||
var request = new[] { new WriteRequest(msg.TagId, msg.Value) };
|
||||
// Bound the write so a hung backend can't pin this actor forever — decision #44/#45 keeps
|
||||
// retry off by default, but a stalled call still needs an answer.
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try
|
||||
{
|
||||
var results = await writable.WriteAsync(request, cts.Token).ConfigureAwait(false);
|
||||
if (results is { Count: 1 } && IsGoodStatus(results[0].StatusCode))
|
||||
{
|
||||
replyTo.Tell(new WriteAttributeResult(true, null));
|
||||
return;
|
||||
}
|
||||
var status = results is { Count: > 0 } ? results[0].StatusCode : 0xFFFFFFFF;
|
||||
replyTo.Tell(new WriteAttributeResult(false, $"StatusCode=0x{status:X8}"));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
replyTo.Tell(new WriteAttributeResult(false, "write timeout"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
replyTo.Tell(new WriteAttributeResult(false, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSubscribeAsync(Subscribe msg)
|
||||
{
|
||||
if (_driver is not ISubscribable subscribable)
|
||||
{
|
||||
Sender.Tell(new SubscriptionFailed("Driver does not implement ISubscribable"));
|
||||
return;
|
||||
}
|
||||
if (_subscriptionHandle is not null)
|
||||
{
|
||||
// Subscribe-twice — drop the prior subscription before establishing the new one.
|
||||
await UnsubscribeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var replyTo = Sender;
|
||||
var self = Self;
|
||||
try
|
||||
{
|
||||
_dataChangeHandler = (_, args) => self.Tell(new DataChangeForward(args.FullReference, args.Snapshot));
|
||||
subscribable.OnDataChange += _dataChangeHandler;
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
_subscriptionHandle = await subscribable
|
||||
.SubscribeAsync(msg.FullReferences, msg.PublishingInterval, cts.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
replyTo.Tell(new SubscriptionEstablished(_subscriptionHandle.DiagnosticId, msg.FullReferences.Count));
|
||||
_log.Info("DriverInstance {Id}: subscribed to {Count} refs ({Diag})",
|
||||
_driverInstanceId, msg.FullReferences.Count, _subscriptionHandle.DiagnosticId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DetachSubscription();
|
||||
_log.Warning(ex, "DriverInstance {Id}: subscribe failed", _driverInstanceId);
|
||||
replyTo.Tell(new SubscriptionFailed(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UnsubscribeAsync()
|
||||
{
|
||||
if (_driver is not ISubscribable subscribable || _subscriptionHandle is null)
|
||||
{
|
||||
DetachSubscription();
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await subscribable.UnsubscribeAsync(_subscriptionHandle, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "DriverInstance {Id}: unsubscribe threw (continuing)", _driverInstanceId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DetachSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tear down the event handler + null the handle. Called from Unsubscribe path, on
|
||||
/// PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't push
|
||||
/// data-change events to an actor that has lost its driver connection.</summary>
|
||||
private void DetachSubscription()
|
||||
{
|
||||
if (_driver is ISubscribable subscribable && _dataChangeHandler is not null)
|
||||
{
|
||||
subscribable.OnDataChange -= _dataChangeHandler;
|
||||
}
|
||||
_dataChangeHandler = null;
|
||||
_subscriptionHandle = null;
|
||||
}
|
||||
|
||||
private void OnDataChangeForward(DataChangeForward msg)
|
||||
{
|
||||
var quality = QualityFromStatus(msg.Snapshot.StatusCode);
|
||||
var ts = msg.Snapshot.SourceTimestampUtc ?? msg.Snapshot.ServerTimestampUtc;
|
||||
Context.Parent.Tell(new AttributeValuePublished(msg.FullReference, msg.Snapshot.Value, quality, ts));
|
||||
}
|
||||
|
||||
/// <summary>Translate an OPC UA status code to the 3-state <see cref="OpcUaQuality"/> projection
|
||||
/// the publish actor consumes. Severity bits (top 2): 00 = Good, 01 = Uncertain, 10/11 = Bad.</summary>
|
||||
private static OpcUaQuality QualityFromStatus(uint statusCode)
|
||||
{
|
||||
var severity = statusCode >> 30;
|
||||
return severity switch
|
||||
{
|
||||
0 => OpcUaQuality.Good,
|
||||
1 => OpcUaQuality.Uncertain,
|
||||
_ => OpcUaQuality.Bad,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsGoodStatus(uint statusCode) => (statusCode >> 30) == 0;
|
||||
|
||||
protected override void PostStop()
|
||||
{
|
||||
DetachSubscription();
|
||||
try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); }
|
||||
catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); }
|
||||
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
||||
new KeyValuePair<string, object?>("event", "stop"),
|
||||
new KeyValuePair<string, object?>("driver_type", _driver.DriverType));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Pure diff between the currently-running driver children (keyed by
|
||||
/// <c>DriverInstance.DriverInstanceId</c>) and the target spec list from a freshly-applied
|
||||
/// deployment artifact. The DriverHostActor consumes the three lists and calls
|
||||
/// spawn / ApplyDelta / stop on its child actors accordingly.
|
||||
/// </summary>
|
||||
/// <param name="ToSpawn">Specs with no current child — create a new actor.</param>
|
||||
/// <param name="ToApplyDelta">Specs whose child exists but config JSON or type differs.</param>
|
||||
/// <param name="ToStop">DriverInstanceIds currently running but missing from the new artifact, or now disabled.</param>
|
||||
public sealed record DriverSpawnPlan(
|
||||
IReadOnlyList<DriverInstanceSpec> ToSpawn,
|
||||
IReadOnlyList<DriverInstanceSpec> ToApplyDelta,
|
||||
IReadOnlyList<string> ToStop);
|
||||
|
||||
public static class DriverSpawnPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute the spawn/delta/stop sets. Disabled entries in <paramref name="target"/> are
|
||||
/// treated as "not desired here": if a child exists for the id it goes into ToStop,
|
||||
/// otherwise the row is dropped entirely (no spawn for a disabled driver).
|
||||
/// </summary>
|
||||
public static DriverSpawnPlan Compute(
|
||||
IReadOnlyDictionary<string, DriverChildSnapshot> current,
|
||||
IReadOnlyList<DriverInstanceSpec> target)
|
||||
{
|
||||
var toSpawn = new List<DriverInstanceSpec>();
|
||||
var toDelta = new List<DriverInstanceSpec>();
|
||||
var toStop = new List<string>();
|
||||
|
||||
var targetById = new Dictionary<string, DriverInstanceSpec>(StringComparer.Ordinal);
|
||||
foreach (var spec in target) targetById[spec.DriverInstanceId] = spec;
|
||||
|
||||
foreach (var (id, snap) in current)
|
||||
{
|
||||
if (!targetById.TryGetValue(id, out var spec) || !spec.Enabled)
|
||||
{
|
||||
toStop.Add(id);
|
||||
continue;
|
||||
}
|
||||
// Driver type changes can't be reinitialized in-place (factory-bound) — stop + respawn.
|
||||
if (!string.Equals(snap.DriverType, spec.DriverType, StringComparison.Ordinal))
|
||||
{
|
||||
toStop.Add(id);
|
||||
toSpawn.Add(spec);
|
||||
continue;
|
||||
}
|
||||
if (!string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal))
|
||||
{
|
||||
toDelta.Add(spec);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (id, spec) in targetById)
|
||||
{
|
||||
if (!spec.Enabled) continue;
|
||||
if (current.ContainsKey(id)) continue;
|
||||
toSpawn.Add(spec);
|
||||
}
|
||||
|
||||
return new DriverSpawnPlan(toSpawn, toDelta, toStop);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of one running driver child as the host sees it. Used as the diff input.</summary>
|
||||
public sealed record DriverChildSnapshot(string DriverType, string LastConfigJson);
|
||||
@@ -1,32 +1,58 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the named-pipe IPC to the Wonderware historian sidecar with a store-and-forward
|
||||
/// SQLite buffer for pipe outages. Engine wiring (named-pipe client + <c>SqliteStoreAndForwardSink</c>)
|
||||
/// is staged for follow-up F11.
|
||||
/// Thin actor wrapper around <see cref="IAlarmHistorianSink"/>. Engine code (ScriptedAlarmActor,
|
||||
/// Galaxy native alarm bridge, AB CIP ALMD reader) tells <see cref="AlarmHistorianEvent"/>s to this
|
||||
/// actor; the actor enqueues them on the sink fire-and-forget. Production deployments register
|
||||
/// <see cref="SqliteStoreAndForwardSink"/> against <c>IAlarmHistorianSink</c>; the sink owns the
|
||||
/// durable queue + drain-to-Wonderware-pipe loop. The actor here owns nothing operational beyond
|
||||
/// the message contract — its job is to keep the engine actors on Akka's mailbox without blocking
|
||||
/// them on disk I/O or pipe handshakes.
|
||||
///
|
||||
/// Query queue depth + drain health via <see cref="GetStatus"/>.
|
||||
/// </summary>
|
||||
public sealed class HistorianAdapterActor : ReceiveActor
|
||||
{
|
||||
public sealed record HistoryRow(string Source, string AttributeId, object? Value, DateTime TimestampUtc);
|
||||
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private int _buffered;
|
||||
|
||||
public int BufferedCount => _buffered;
|
||||
|
||||
public static Props Props() => Akka.Actor.Props.Create(() => new HistorianAdapterActor());
|
||||
|
||||
public HistorianAdapterActor()
|
||||
public sealed record GetStatus
|
||||
{
|
||||
Receive<HistoryRow>(row =>
|
||||
public static readonly GetStatus Instance = new();
|
||||
}
|
||||
|
||||
private readonly IAlarmHistorianSink _sink;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
public static Props Props(IAlarmHistorianSink? sink = null) =>
|
||||
Akka.Actor.Props.Create(() => new HistorianAdapterActor(sink ?? NullAlarmHistorianSink.Instance));
|
||||
|
||||
public HistorianAdapterActor(IAlarmHistorianSink sink)
|
||||
{
|
||||
_sink = sink;
|
||||
|
||||
Receive<AlarmHistorianEvent>(evt =>
|
||||
{
|
||||
// F11: dispatch to named-pipe sink; on disconnect → buffer in SQLite.
|
||||
Interlocked.Increment(ref _buffered);
|
||||
_log.Debug("Historian: buffered row for {Source}/{Attr} (sink wiring staged for F11)",
|
||||
row.Source, row.AttributeId);
|
||||
// Fire-and-forget: SqliteStoreAndForwardSink persists to local SQLite synchronously
|
||||
// inside EnqueueAsync (it returns once the row is committed), so we don't block on
|
||||
// network/pipe latency. Failures are surfaced via GetStatus's LastError + drain state.
|
||||
_ = EnqueueAsync(evt);
|
||||
});
|
||||
|
||||
Receive<GetStatus>(_ => Sender.Tell(_sink.GetStatus()));
|
||||
}
|
||||
|
||||
private async Task EnqueueAsync(AlarmHistorianEvent evt)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sink.EnqueueAsync(evt, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "Historian sink rejected event for {AlarmId} at {Ts}",
|
||||
evt.AlarmId, evt.TimestampUtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,267 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Single-threaded bridge between Akka messages and the OPC UA SDK address space. Hosted on
|
||||
/// the pinned <c>opcua-synchronized-dispatcher</c> (Task 19 HOCON) so the OPC UA SDK sees
|
||||
/// only one thread per actor instance — its session/subscription locks expect strict
|
||||
/// single-threaded access.
|
||||
/// Single-threaded bridge between Akka messages and the OPC UA SDK address space. Hosted on
|
||||
/// the pinned <c>opcua-synchronized-dispatcher</c> (Task 19 HOCON) so the OPC UA SDK sees
|
||||
/// only one thread per actor instance — its session/subscription locks expect strict
|
||||
/// single-threaded access.
|
||||
///
|
||||
/// Engine wiring (call into <c>OpcUaApplicationHost</c> address-space writes, manage
|
||||
/// <c>ServiceLevel</c> + <c>ServerUriArray</c> nodes, subscribe to the <c>redundancy-state</c>
|
||||
/// DistributedPubSub topic) is staged for follow-up F10. This skeleton compiles + exposes the
|
||||
/// message contracts so producers (DriverInstance, VirtualTag, ScriptedAlarm) can target it.
|
||||
/// Address-space writes route through <see cref="IOpcUaAddressSpaceSink"/>; ServiceLevel
|
||||
/// writes route through <see cref="IServiceLevelPublisher"/>. Production binds SDK-backed
|
||||
/// implementations; dev/Mac/tests bind the Null* defaults so the actor stays decoupled from
|
||||
/// <c>Opc.Ua.Server</c>. The remaining piece is wiring those bindings to a real
|
||||
/// <c>StandardServer</c> address space — tracked as F10b.
|
||||
/// </summary>
|
||||
public sealed class OpcUaPublishActor : ReceiveActor
|
||||
{
|
||||
public const string DispatcherId = "opcua-synchronized-dispatcher";
|
||||
public const string RedundancyStateTopic = "redundancy-state";
|
||||
|
||||
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc);
|
||||
public sealed record AlarmStateUpdate(string AlarmNodeId, bool Active, bool Acknowledged, DateTime TimestampUtc);
|
||||
public sealed record RebuildAddressSpace(CorrelationId Correlation);
|
||||
public sealed record ServiceLevelChanged(byte ServiceLevel);
|
||||
|
||||
public enum OpcUaQuality { Good, Uncertain, Bad }
|
||||
|
||||
private readonly IOpcUaAddressSpaceSink _sink;
|
||||
private readonly IServiceLevelPublisher _serviceLevel;
|
||||
private readonly bool _subscribeRedundancyTopic;
|
||||
private readonly NodeId? _localNode;
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext>? _dbFactory;
|
||||
private readonly Phase7Applier? _applier;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
private int _writes;
|
||||
|
||||
/// <summary>
|
||||
/// Returns Props pre-configured to use the <c>opcua-synchronized-dispatcher</c>. Caller can
|
||||
/// still override by chaining <c>.WithDispatcher(otherId)</c> for unit tests.
|
||||
/// </summary>
|
||||
public static Props Props() =>
|
||||
Akka.Actor.Props.Create(() => new OpcUaPublishActor()).WithDispatcher(DispatcherId);
|
||||
|
||||
/// <summary>Test-only Props that omits the pinned dispatcher requirement.</summary>
|
||||
public static Props PropsForTests() =>
|
||||
Akka.Actor.Props.Create(() => new OpcUaPublishActor());
|
||||
private byte _lastServiceLevel;
|
||||
private Phase7CompositionResult _lastApplied = new(
|
||||
Array.Empty<EquipmentNode>(),
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
public int WriteCount => _writes;
|
||||
public byte LastServiceLevel => _lastServiceLevel;
|
||||
|
||||
public OpcUaPublishActor()
|
||||
/// <summary>Production Props — pins the OPC UA dispatcher + subscribes to the
|
||||
/// <c>redundancy-state</c> DPS topic so cluster transitions drive the local ServiceLevel
|
||||
/// publish path. When <paramref name="dbFactory"/> + <paramref name="applier"/> are supplied,
|
||||
/// <see cref="RebuildAddressSpace"/> reads the latest deployment artifact + drives the
|
||||
/// applier through the sink.</summary>
|
||||
public static Props Props(
|
||||
IOpcUaAddressSpaceSink? sink = null,
|
||||
IServiceLevelPublisher? serviceLevel = null,
|
||||
NodeId? localNode = null,
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null,
|
||||
Phase7Applier? applier = null) =>
|
||||
Akka.Actor.Props.Create(() => new OpcUaPublishActor(
|
||||
sink ?? NullOpcUaAddressSpaceSink.Instance,
|
||||
serviceLevel ?? NullServiceLevelPublisher.Instance,
|
||||
subscribeRedundancyTopic: true,
|
||||
localNode,
|
||||
dbFactory,
|
||||
applier)).WithDispatcher(DispatcherId);
|
||||
|
||||
/// <summary>Test-only Props that omits the pinned-dispatcher requirement and skips the
|
||||
/// DPS subscribe so unit tests can spin up the actor on a vanilla TestKit cluster.</summary>
|
||||
public static Props PropsForTests(
|
||||
IOpcUaAddressSpaceSink? sink = null,
|
||||
IServiceLevelPublisher? serviceLevel = null,
|
||||
bool subscribeRedundancyTopic = false,
|
||||
NodeId? localNode = null,
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null,
|
||||
Phase7Applier? applier = null) =>
|
||||
Akka.Actor.Props.Create(() => new OpcUaPublishActor(
|
||||
sink ?? NullOpcUaAddressSpaceSink.Instance,
|
||||
serviceLevel ?? NullServiceLevelPublisher.Instance,
|
||||
subscribeRedundancyTopic,
|
||||
localNode,
|
||||
dbFactory,
|
||||
applier));
|
||||
|
||||
public OpcUaPublishActor(
|
||||
IOpcUaAddressSpaceSink sink,
|
||||
IServiceLevelPublisher serviceLevel,
|
||||
bool subscribeRedundancyTopic,
|
||||
NodeId? localNode,
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null,
|
||||
Phase7Applier? applier = null)
|
||||
{
|
||||
Receive<AttributeValueUpdate>(msg =>
|
||||
_sink = sink;
|
||||
_serviceLevel = serviceLevel;
|
||||
_subscribeRedundancyTopic = subscribeRedundancyTopic;
|
||||
_localNode = localNode;
|
||||
_dbFactory = dbFactory;
|
||||
_applier = applier;
|
||||
|
||||
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
|
||||
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
|
||||
Receive<RebuildAddressSpace>(HandleRebuild);
|
||||
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
|
||||
Receive<RedundancyStateChanged>(HandleRedundancyStateChanged);
|
||||
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
||||
}
|
||||
|
||||
protected override void PreStart()
|
||||
{
|
||||
if (_subscribeRedundancyTopic)
|
||||
{
|
||||
// F10: call into OpcUaApplicationHost to write the address-space node.
|
||||
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(RedundancyStateTopic, Self));
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleAttributeUpdate(AttributeValueUpdate msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc);
|
||||
Interlocked.Increment(ref _writes);
|
||||
_log.Debug("OpcUaPublish: queued AttributeValueUpdate for {Node} ({Quality}) (write staged for F10)",
|
||||
msg.NodeId, msg.Quality);
|
||||
});
|
||||
Receive<AlarmStateUpdate>(msg =>
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: sink.WriteValue threw for {Node}", msg.NodeId);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleAlarmUpdate(AlarmStateUpdate msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.WriteAlarmState(msg.AlarmNodeId, msg.Active, msg.Acknowledged, msg.TimestampUtc);
|
||||
Interlocked.Increment(ref _writes);
|
||||
_log.Debug("OpcUaPublish: queued AlarmStateUpdate for {Node} (active={Active})",
|
||||
msg.AlarmNodeId, msg.Active);
|
||||
});
|
||||
Receive<RebuildAddressSpace>(msg =>
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Info("OpcUaPublish: address-space rebuild requested (correlation={Correlation}); F10 wires the SDK call",
|
||||
msg.Correlation);
|
||||
});
|
||||
Receive<ServiceLevelChanged>(msg =>
|
||||
_log.Warning(ex, "OpcUaPublish: sink.WriteAlarmState threw for {Node}", msg.AlarmNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRebuild(RebuildAddressSpace msg)
|
||||
{
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
|
||||
|
||||
// Two modes: when dbFactory + applier are wired, do a real diff-and-apply pass against
|
||||
// the latest deployment artifact. Without them, fall back to a raw sink rebuild — the
|
||||
// F10b/dev path before the integration completes.
|
||||
if (_dbFactory is null || _applier is null)
|
||||
{
|
||||
_log.Debug("OpcUaPublish: ServiceLevel={Level} (write staged for F10)", msg.ServiceLevel);
|
||||
});
|
||||
try
|
||||
{
|
||||
_sink.RebuildAddressSpace();
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})",
|
||||
msg.Correlation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var artifact = LoadLatestArtifact();
|
||||
var composition = DeploymentArtifact.ParseComposition(artifact);
|
||||
var plan = Phase7Planner.Compute(_lastApplied, composition);
|
||||
|
||||
if (plan.IsEmpty)
|
||||
{
|
||||
_log.Debug("OpcUaPublish: rebuild requested but plan is empty (correlation={Correlation})",
|
||||
msg.Correlation);
|
||||
return;
|
||||
}
|
||||
|
||||
var outcome = _applier.Apply(plan);
|
||||
_lastApplied = composition;
|
||||
|
||||
// #85 — after the plan diff lands, rebuild the UNS folder hierarchy so OPC UA
|
||||
// clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier
|
||||
// skips folders that already exist with the same node id.
|
||||
_applier.MaterialiseHierarchy(composition);
|
||||
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
|
||||
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
|
||||
msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "OpcUaPublish: rebuild pipeline threw (correlation={Correlation})", msg.Correlation);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read the most recent <c>Sealed</c> deployment's artifact blob from ConfigDb.
|
||||
/// Empty array on any failure — the parser treats empty blob as "no composition".</summary>
|
||||
private byte[] LoadLatestArtifact()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var db = _dbFactory!.CreateDbContext();
|
||||
return db.Deployments.AsNoTracking()
|
||||
.Where(d => d.Status == Configuration.Enums.DeploymentStatus.Sealed)
|
||||
.OrderByDescending(d => d.SealedAtUtc)
|
||||
.Select(d => d.ArtifactBlob)
|
||||
.FirstOrDefault() ?? Array.Empty<byte>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: failed to load latest deployment artifact; rebuild becomes no-op");
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleServiceLevelChanged(ServiceLevelChanged msg)
|
||||
{
|
||||
if (msg.ServiceLevel == _lastServiceLevel) return;
|
||||
_lastServiceLevel = msg.ServiceLevel;
|
||||
try
|
||||
{
|
||||
_serviceLevel.Publish(msg.ServiceLevel);
|
||||
OtOpcUaTelemetry.ServiceLevelChange.Add(1,
|
||||
new KeyValuePair<string, object?>("level", msg.ServiceLevel));
|
||||
_log.Debug("OpcUaPublish: ServiceLevel={Level}", msg.ServiceLevel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: ServiceLevel publisher threw at level {Level}", msg.ServiceLevel);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute a coarse ServiceLevel from the cluster snapshot and forward to the
|
||||
/// <see cref="IServiceLevelPublisher"/>. This is a placeholder for F10b's full health
|
||||
/// aggregation — for now we surface "primary-leader → 240, secondary → 100, detached → 0"
|
||||
/// so the local SDK at least reflects role state. The full <see cref="ServiceLevelCalculator"/>
|
||||
/// path (with DB-reachable, OPC UA probe inputs) lives in <c>RedundancyStateActor</c> on
|
||||
/// admin nodes; this driver-side mirror exists so each node's own SDK exposes a sensible
|
||||
/// ServiceLevel without round-tripping back through the admin singleton.
|
||||
/// </summary>
|
||||
private void HandleRedundancyStateChanged(RedundancyStateChanged msg)
|
||||
{
|
||||
if (_localNode is null) return;
|
||||
|
||||
var local = msg.Nodes.FirstOrDefault(n => n.NodeId == _localNode.Value);
|
||||
if (local is null) return;
|
||||
|
||||
byte level = local.Role switch
|
||||
{
|
||||
RedundancyRole.Primary when local.IsRoleLeaderForDriver => 240,
|
||||
RedundancyRole.Primary => 200,
|
||||
RedundancyRole.Secondary => 100,
|
||||
RedundancyRole.Detached => 0,
|
||||
_ => 0,
|
||||
};
|
||||
Self.Tell(new ServiceLevelChanged(level));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Production-side <see cref="IAlarmActorStateStore"/> backed by the
|
||||
/// <see cref="ScriptedAlarmState"/> table in the central config DB. The actor's
|
||||
/// 3-state enum projects into the table's two persisted dimensions: Acked + an
|
||||
/// internal "_lastActiveState" recorded via a synthetic mapping (Inactive ⇒ Acked,
|
||||
/// Active ⇒ Unacked, Acknowledged ⇒ Acked). ActiveState itself is deliberately NOT
|
||||
/// persisted — re-derives from the evaluator on startup (Phase 7 decision #14).
|
||||
/// </summary>
|
||||
public sealed class EfAlarmActorStateStore : IAlarmActorStateStore
|
||||
{
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
private readonly ILogger<EfAlarmActorStateStore> _logger;
|
||||
|
||||
public EfAlarmActorStateStore(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
ILogger<EfAlarmActorStateStore> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var row = await db.ScriptedAlarmStates.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (row is null) return null;
|
||||
|
||||
var state = MapAckedToActorState(row.AckedState);
|
||||
return new AlarmActorStateSnapshot(
|
||||
AlarmId: alarmId,
|
||||
State: state,
|
||||
LastTransitionUtc: row.UpdatedAtUtc,
|
||||
LastAckUser: row.LastAckUser);
|
||||
}
|
||||
|
||||
public async Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var row = await db.ScriptedAlarmStates
|
||||
.FirstOrDefaultAsync(r => r.ScriptedAlarmId == snapshot.AlarmId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var ackedState = MapActorStateToAcked(snapshot.State);
|
||||
if (row is null)
|
||||
{
|
||||
db.ScriptedAlarmStates.Add(new ScriptedAlarmState
|
||||
{
|
||||
ScriptedAlarmId = snapshot.AlarmId,
|
||||
EnabledState = "Enabled",
|
||||
AckedState = ackedState,
|
||||
ConfirmedState = "Confirmed",
|
||||
ShelvingState = "Unshelved",
|
||||
LastAckUser = snapshot.LastAckUser,
|
||||
LastAckUtc = string.Equals(snapshot.State, "Acknowledged", StringComparison.Ordinal)
|
||||
? snapshot.LastTransitionUtc
|
||||
: null,
|
||||
UpdatedAtUtc = snapshot.LastTransitionUtc,
|
||||
CommentsJson = "[]",
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.AckedState = ackedState;
|
||||
row.LastAckUser = snapshot.LastAckUser ?? row.LastAckUser;
|
||||
if (string.Equals(snapshot.State, "Acknowledged", StringComparison.Ordinal))
|
||||
row.LastAckUtc = snapshot.LastTransitionUtc;
|
||||
row.UpdatedAtUtc = snapshot.LastTransitionUtc;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException ex)
|
||||
{
|
||||
// Two actors racing to save the same alarm is benign — the last writer wins on
|
||||
// UpdatedAtUtc, and the next transition on either side will write again. Log
|
||||
// + drop so a race doesn't crash the dispatcher.
|
||||
_logger.LogDebug(ex, "EfAlarmActorStateStore: concurrency conflict for {AlarmId}; dropping save",
|
||||
snapshot.AlarmId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string MapActorStateToAcked(string actorState) => actorState switch
|
||||
{
|
||||
"Active" => "Unacknowledged",
|
||||
"Acknowledged" => "Acknowledged",
|
||||
// Inactive maps to Acknowledged — when an alarm clears, nothing is left to ack.
|
||||
_ => "Acknowledged",
|
||||
};
|
||||
|
||||
private static string MapAckedToActorState(string ackedState)
|
||||
{
|
||||
// Only Active distinguishes from Acked — Inactive comes from a re-eval, not from
|
||||
// the table. Persisted "Unacknowledged" implies the actor was last Active +
|
||||
// un-acked; we restore it to Active so a restart doesn't drop pending operator work.
|
||||
return string.Equals(ackedState, "Unacknowledged", StringComparison.Ordinal)
|
||||
? "Active"
|
||||
: "Acknowledged";
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,240 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
public enum ScriptedAlarmActorState { Inactive, Active, Acknowledged }
|
||||
|
||||
/// <summary>
|
||||
/// State machine wrapping a single scripted alarm. Transitions:
|
||||
/// <c>Inactive → Active → Acknowledged → Inactive</c>.
|
||||
///
|
||||
/// Engine wiring (compile alarm expression via <c>AlarmConditionService</c>, persist state to
|
||||
/// <c>ScriptedAlarmState</c> ConfigDb table on <c>PreRestart</c>, emit history rows to
|
||||
/// <c>HistorianAdapter</c>) is staged for follow-up F9. This skeleton owns the state machine
|
||||
/// so DriverHostActor can spawn it as a child.
|
||||
/// One scripted alarm. Receives dependency value updates, runs the predicate via an
|
||||
/// injected <see cref="IScriptedAlarmEvaluator"/>, and on transitions publishes both
|
||||
/// an <see cref="AlarmTransitionEvent"/> on the cluster <c>alerts</c> DPS topic and a
|
||||
/// <see cref="ScriptLogEntry"/> on <c>script-logs</c>. Manual <see cref="AcknowledgeAlarm"/>
|
||||
/// + <see cref="ConditionCleared"/> still flow through the same state machine so the
|
||||
/// legacy callers keep working.
|
||||
/// </summary>
|
||||
public sealed class ScriptedAlarmActor : ReceiveActor
|
||||
{
|
||||
public const string AlertsTopic = "alerts";
|
||||
public const string ScriptLogsTopic = "script-logs";
|
||||
|
||||
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
|
||||
public sealed record ConditionMet(string Reason);
|
||||
public sealed record AcknowledgeAlarm(string Actor);
|
||||
public sealed record ConditionCleared;
|
||||
public sealed record StateChanged(string AlarmId, ScriptedAlarmActorState State, DateTime AtUtc);
|
||||
|
||||
private readonly string _alarmId;
|
||||
public sealed record AlarmConfig(
|
||||
string AlarmId,
|
||||
string AlarmName,
|
||||
string EquipmentPath,
|
||||
int Severity,
|
||||
string? Predicate);
|
||||
|
||||
private readonly AlarmConfig _config;
|
||||
private readonly IScriptedAlarmEvaluator _evaluator;
|
||||
private readonly IAlarmActorStateStore _stateStore;
|
||||
private readonly Func<DPSPublisher>? _publisherFactory;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private readonly Dictionary<string, object?> _dependencies = new(StringComparer.Ordinal);
|
||||
|
||||
private ScriptedAlarmActorState _state = ScriptedAlarmActorState.Inactive;
|
||||
private string? _lastAckUser;
|
||||
|
||||
public sealed record StateRestored(ScriptedAlarmActorState State, string? LastAckUser);
|
||||
|
||||
public static Props Props(
|
||||
AlarmConfig config,
|
||||
IScriptedAlarmEvaluator? evaluator = null,
|
||||
Func<DPSPublisher>? publisherFactory = null,
|
||||
IAlarmActorStateStore? stateStore = null) =>
|
||||
Akka.Actor.Props.Create(() => new ScriptedAlarmActor(
|
||||
config,
|
||||
evaluator ?? NullScriptedAlarmEvaluator.Instance,
|
||||
publisherFactory,
|
||||
stateStore ?? NullAlarmActorStateStore.Instance));
|
||||
|
||||
/// <summary>Legacy single-arg ctor kept for callers that only care about the state machine
|
||||
/// (no engine evaluation, no DPS fan-out, no persistence). Equivalent to <c>Props(new AlarmConfig(...))</c>.</summary>
|
||||
public static Props Props(string alarmId) =>
|
||||
Akka.Actor.Props.Create(() => new ScriptedAlarmActor(alarmId));
|
||||
Props(new AlarmConfig(alarmId, alarmId, EquipmentPath: "", Severity: 500, Predicate: null));
|
||||
|
||||
public ScriptedAlarmActor(string alarmId)
|
||||
public ScriptedAlarmActor(
|
||||
AlarmConfig config,
|
||||
IScriptedAlarmEvaluator evaluator,
|
||||
Func<DPSPublisher>? publisherFactory,
|
||||
IAlarmActorStateStore stateStore)
|
||||
{
|
||||
_alarmId = alarmId;
|
||||
_config = config;
|
||||
_evaluator = evaluator;
|
||||
_publisherFactory = publisherFactory;
|
||||
_stateStore = stateStore;
|
||||
|
||||
Receive<ConditionMet>(msg =>
|
||||
Receive<DependencyValueChanged>(OnDependencyChanged);
|
||||
Receive<ConditionMet>(_ => { if (_state == ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Active, user: "system"); });
|
||||
Receive<AcknowledgeAlarm>(msg => { if (_state == ScriptedAlarmActorState.Active) Transition(ScriptedAlarmActorState.Acknowledged, user: msg.Actor); });
|
||||
Receive<ConditionCleared>(_ => { if (_state != ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Inactive, user: "system"); });
|
||||
Receive<StateRestored>(OnStateRestored);
|
||||
}
|
||||
|
||||
protected override void PreStart()
|
||||
{
|
||||
// Load persisted state — when the store has a row, restore in-memory state before the
|
||||
// first dependency-change arrives. Async I/O is piped back as StateRestored so we don't
|
||||
// block the message-loop thread; until it arrives the actor stays at the default Inactive.
|
||||
var self = Self;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
if (_state != ScriptedAlarmActorState.Inactive) return;
|
||||
Transition(ScriptedAlarmActorState.Active);
|
||||
});
|
||||
Receive<AcknowledgeAlarm>(msg =>
|
||||
{
|
||||
if (_state != ScriptedAlarmActorState.Active) return;
|
||||
Transition(ScriptedAlarmActorState.Acknowledged);
|
||||
});
|
||||
Receive<ConditionCleared>(_ =>
|
||||
{
|
||||
if (_state == ScriptedAlarmActorState.Inactive) return;
|
||||
Transition(ScriptedAlarmActorState.Inactive);
|
||||
try
|
||||
{
|
||||
var snapshot = await _stateStore.LoadAsync(_config.AlarmId, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
if (snapshot is null) return;
|
||||
if (!Enum.TryParse<ScriptedAlarmActorState>(snapshot.State, ignoreCase: true, out var parsed))
|
||||
return;
|
||||
self.Tell(new StateRestored(parsed, snapshot.LastAckUser));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "ScriptedAlarm {Id}: state-store load failed; booting Inactive",
|
||||
_config.AlarmId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void Transition(ScriptedAlarmActorState next)
|
||||
private void OnStateRestored(StateRestored msg)
|
||||
{
|
||||
// Active is re-derived from the evaluator at the next DependencyValueChanged — we still
|
||||
// restore Active here so operators don't lose the in-flight transition if a restart races
|
||||
// ahead of the next eval. The first evaluator tick will correct it if the condition cleared.
|
||||
_state = msg.State;
|
||||
_lastAckUser = msg.LastAckUser;
|
||||
_log.Info("ScriptedAlarm {Id}: restored persisted state {State} (lastAck={User})",
|
||||
_config.AlarmId, _state, _lastAckUser ?? "(none)");
|
||||
}
|
||||
|
||||
private void OnDependencyChanged(DependencyValueChanged msg)
|
||||
{
|
||||
_dependencies[msg.TagId] = msg.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(_config.Predicate)) return;
|
||||
|
||||
ScriptedAlarmEvalResult result;
|
||||
try
|
||||
{
|
||||
result = _evaluator.Evaluate(_config.AlarmId, _config.Predicate, _dependencies);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "ScriptedAlarm {Id}: evaluator threw", _config.AlarmId);
|
||||
PublishLog("Error", $"evaluator threw: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
PublishLog("Warning", result.Reason ?? "evaluator failure");
|
||||
return;
|
||||
}
|
||||
|
||||
// Active condition wins regardless of ack state — re-firing is suppressed because
|
||||
// _state already == Active. Cleared moves Active OR Acknowledged → Inactive.
|
||||
if (result.Active && _state == ScriptedAlarmActorState.Inactive)
|
||||
{
|
||||
Transition(ScriptedAlarmActorState.Active, user: "system");
|
||||
}
|
||||
else if (!result.Active && _state != ScriptedAlarmActorState.Inactive)
|
||||
{
|
||||
Transition(ScriptedAlarmActorState.Inactive, user: "system");
|
||||
}
|
||||
}
|
||||
|
||||
private void Transition(ScriptedAlarmActorState next, string user)
|
||||
{
|
||||
var prev = _state;
|
||||
_state = next;
|
||||
_log.Info("ScriptedAlarm {Id}: {From} → {To}", _alarmId, prev, next);
|
||||
Context.Parent.Tell(new StateChanged(_alarmId, next, DateTime.UtcNow));
|
||||
// F9: emit history row via HistorianAdapter; persist state to ScriptedAlarmState DB.
|
||||
if (next == ScriptedAlarmActorState.Acknowledged) _lastAckUser = user;
|
||||
_log.Info("ScriptedAlarm {Id}: {From} → {To}", _config.AlarmId, prev, next);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
Context.Parent.Tell(new StateChanged(_config.AlarmId, next, nowUtc));
|
||||
PersistStateAsync(nowUtc);
|
||||
|
||||
var kind = next switch
|
||||
{
|
||||
ScriptedAlarmActorState.Active => "Activated",
|
||||
ScriptedAlarmActorState.Acknowledged => "Acknowledged",
|
||||
ScriptedAlarmActorState.Inactive => "Cleared",
|
||||
_ => next.ToString(),
|
||||
};
|
||||
|
||||
OtOpcUaTelemetry.ScriptedAlarmTransition.Add(1,
|
||||
new KeyValuePair<string, object?>("state", kind.ToLowerInvariant()));
|
||||
|
||||
var evt = new AlarmTransitionEvent(
|
||||
AlarmId: _config.AlarmId,
|
||||
EquipmentPath: _config.EquipmentPath,
|
||||
AlarmName: _config.AlarmName,
|
||||
TransitionKind: kind,
|
||||
Severity: _config.Severity,
|
||||
Message: $"{_config.AlarmName} {kind}",
|
||||
User: user,
|
||||
TimestampUtc: nowUtc);
|
||||
|
||||
PublishOrFallback(AlertsTopic, evt);
|
||||
PublishLog("Information", $"{_config.AlarmName} {kind} (by {user})");
|
||||
}
|
||||
|
||||
private void PublishLog(string level, string message)
|
||||
{
|
||||
var entry = new ScriptLogEntry(
|
||||
ScriptId: _config.AlarmId,
|
||||
Level: level,
|
||||
Message: message,
|
||||
TimestampUtc: DateTime.UtcNow,
|
||||
VirtualTagId: null,
|
||||
AlarmId: _config.AlarmId,
|
||||
EquipmentId: null);
|
||||
PublishOrFallback(ScriptLogsTopic, entry);
|
||||
}
|
||||
|
||||
private void PublishOrFallback(string topic, object payload)
|
||||
{
|
||||
if (_publisherFactory is not null)
|
||||
{
|
||||
_publisherFactory().Publish(topic, payload);
|
||||
return;
|
||||
}
|
||||
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(topic, payload));
|
||||
}
|
||||
|
||||
private void PersistStateAsync(DateTime nowUtc)
|
||||
{
|
||||
var snapshot = new AlarmActorStateSnapshot(
|
||||
AlarmId: _config.AlarmId,
|
||||
State: _state.ToString(),
|
||||
LastTransitionUtc: nowUtc,
|
||||
LastAckUser: _lastAckUser);
|
||||
|
||||
// Fire-and-forget. Save failures get logged but don't block the message loop —
|
||||
// the worst case is a restart loses one transition, which then re-derives from
|
||||
// the evaluator's next tick anyway.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _stateStore.SaveAsync(snapshot, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "ScriptedAlarm {Id}: state-store save failed", _config.AlarmId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime;
|
||||
|
||||
@@ -14,11 +25,30 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
public const string DriverHostActorName = "driver-host";
|
||||
public const string DbHealthProbeActorName = "db-health";
|
||||
public const string HistorianAdapterActorName = "historian-adapter";
|
||||
public const string DependencyMuxActorName = "dependency-mux";
|
||||
public const string OpcUaPublishActorName = "opcua-publish";
|
||||
|
||||
/// <summary>
|
||||
/// Registers shared runtime services. Currently binds <see cref="IAlarmHistorianSink"/>
|
||||
/// to <see cref="NullAlarmHistorianSink"/> as the default; production deployments
|
||||
/// override this with <c>SqliteStoreAndForwardSink</c> wrapping <c>WonderwareHistorianClient</c>.
|
||||
/// Call this BEFORE <c>AddAkka</c>.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddOtOpcUaRuntime(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
|
||||
services.TryAddSingleton<IDriverFactory>(NullDriverFactory.Instance);
|
||||
services.TryAddSingleton<IOpcUaAddressSpaceSink>(NullOpcUaAddressSpaceSink.Instance);
|
||||
services.TryAddSingleton<IServiceLevelPublisher>(NullServiceLevelPublisher.Instance);
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns the per-node driver-role actors on the host's <see cref="ActorSystem"/>:
|
||||
/// <see cref="DriverHostActor"/> (one per node) and <see cref="DbHealthProbeActor"/>
|
||||
/// (consumed by the health endpoint + redundancy calc).
|
||||
/// <see cref="DriverHostActor"/> (one per node), <see cref="DbHealthProbeActor"/>
|
||||
/// (consumed by the health endpoint + redundancy calc), and
|
||||
/// <see cref="HistorianAdapterActor"/> wrapping the registered <see cref="IAlarmHistorianSink"/>.
|
||||
///
|
||||
/// Mirror of <c>WithOtOpcUaControlPlaneSingletons</c> for the driver role. Both must
|
||||
/// be registered on the same <see cref="AkkaConfigurationBuilder"/> as the cluster
|
||||
@@ -26,26 +56,72 @@ public static class ServiceCollectionExtensions
|
||||
///
|
||||
/// Wire from the fused Host's Program.cs when the node carries the <c>driver</c> role:
|
||||
/// <code>
|
||||
/// if (hasDriver)
|
||||
/// ab.WithOtOpcUaRuntimeActors();
|
||||
/// services.AddOtOpcUaRuntime();
|
||||
/// services.AddAkka("otopcua", (ab, sp) => { ab.WithOtOpcUaClusterBootstrap(sp); if (hasDriver) ab.WithOtOpcUaRuntimeActors(); });
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public static AkkaConfigurationBuilder WithOtOpcUaRuntimeActors(this AkkaConfigurationBuilder builder)
|
||||
{
|
||||
// Production cluster HOCON (akka.conf) carries this dispatcher block, but consumers that
|
||||
// bootstrap their own HOCON (e.g. ServiceCollectionExtensionsTests) wouldn't pick it up
|
||||
// — OpcUaPublishActor.Props pins itself to opcua-synchronized-dispatcher and Akka throws
|
||||
// ConfigurationException if it doesn't exist. Prepend a fallback so the runtime extension
|
||||
// is self-contained.
|
||||
builder.AddHocon(@"
|
||||
opcua-synchronized-dispatcher {
|
||||
type = ""PinnedDispatcher""
|
||||
executor = ""thread-pool-executor""
|
||||
throughput = 1
|
||||
}
|
||||
", HoconAddMode.Prepend);
|
||||
|
||||
builder.WithActors((system, registry, resolver) =>
|
||||
{
|
||||
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
||||
var roleInfo = resolver.GetService<IClusterRoleInfo>();
|
||||
// Fallback to Null* if AddOtOpcUaRuntime wasn't called (e.g., test harnesses).
|
||||
var historianSink = resolver.GetService<IAlarmHistorianSink>() ?? NullAlarmHistorianSink.Instance;
|
||||
var driverFactory = resolver.GetService<IDriverFactory>() ?? NullDriverFactory.Instance;
|
||||
var addressSpaceSink = resolver.GetService<IOpcUaAddressSpaceSink>() ?? NullOpcUaAddressSpaceSink.Instance;
|
||||
var serviceLevel = resolver.GetService<IServiceLevelPublisher>() ?? NullServiceLevelPublisher.Instance;
|
||||
var loggerFactory = resolver.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
|
||||
|
||||
var dbHealth = system.ActorOf(
|
||||
DbHealthProbeActor.Props(dbFactory),
|
||||
DbHealthProbeActorName);
|
||||
registry.Register<DbHealthProbeActorKey>(dbHealth);
|
||||
|
||||
// Dependency mux must be spawned before DriverHostActor so the host can forward
|
||||
// AttributeValuePublished into it from the very first driver spawn.
|
||||
var mux = system.ActorOf(DependencyMuxActor.Props(), DependencyMuxActorName);
|
||||
registry.Register<DependencyMuxActorKey>(mux);
|
||||
|
||||
// OPC UA publish actor — pinned dispatcher, owns the address-space side of the
|
||||
// pipeline. Phase7Applier is constructed here so the actor + applier share the
|
||||
// same sink reference (when DeferredAddressSpaceSink swaps later, both see it).
|
||||
var applier = new Phase7Applier(addressSpaceSink, loggerFactory.CreateLogger<Phase7Applier>());
|
||||
var publishActor = system.ActorOf(
|
||||
OpcUaPublishActor.Props(
|
||||
sink: addressSpaceSink,
|
||||
serviceLevel: serviceLevel,
|
||||
localNode: roleInfo.LocalNode,
|
||||
dbFactory: dbFactory,
|
||||
applier: applier),
|
||||
OpcUaPublishActorName);
|
||||
registry.Register<OpcUaPublishActorKey>(publishActor);
|
||||
|
||||
var driverHost = system.ActorOf(
|
||||
DriverHostActor.Props(dbFactory, roleInfo.LocalNode),
|
||||
DriverHostActor.Props(dbFactory, roleInfo.LocalNode, coordinator: null,
|
||||
driverFactory: driverFactory, localRoles: roleInfo.LocalRoles,
|
||||
dependencyMux: mux,
|
||||
opcUaPublishActor: publishActor),
|
||||
DriverHostActorName);
|
||||
registry.Register<DriverHostActorKey>(driverHost);
|
||||
|
||||
var historian = system.ActorOf(
|
||||
HistorianAdapterActor.Props(historianSink),
|
||||
HistorianAdapterActorName);
|
||||
registry.Register<HistorianAdapterActorKey>(historian);
|
||||
});
|
||||
|
||||
return builder;
|
||||
@@ -55,3 +131,6 @@ public static class ServiceCollectionExtensions
|
||||
/// <summary>Marker key types used by <c>Akka.Hosting</c> to resolve runtime actors from the registry.</summary>
|
||||
public sealed class DriverHostActorKey { }
|
||||
public sealed class DbHealthProbeActorKey { }
|
||||
public sealed class HistorianAdapterActorKey { }
|
||||
public sealed class DependencyMuxActorKey { }
|
||||
public sealed class OpcUaPublishActorKey { }
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
/// <summary>
|
||||
/// Per-node fan-out router from <see cref="DriverInstanceActor.AttributeValuePublished"/>
|
||||
/// to interested <see cref="VirtualTagActor"/> instances. VirtualTagActor sends
|
||||
/// <see cref="RegisterInterest"/> on start-up listing the tag refs it depends on; the mux
|
||||
/// keeps a map of <c>tagRef → Set<IActorRef></c> and on every AttributeValuePublished
|
||||
/// forwards a <see cref="VirtualTagActor.DependencyValueChanged"/> to each interested
|
||||
/// subscriber.
|
||||
///
|
||||
/// DriverHostActor forwards every <c>AttributeValuePublished</c> it receives from its
|
||||
/// DriverInstanceActor children to this mux (one mux per driver-role node). The mux is
|
||||
/// deliberately not a DPS subscriber — virtual-tag evaluation is local to each node and
|
||||
/// would over-emit if it spanned the cluster.
|
||||
/// </summary>
|
||||
public sealed class DependencyMuxActor : ReceiveActor
|
||||
{
|
||||
public const string ActorName = "dependency-mux";
|
||||
|
||||
/// <summary>Register a subscriber's interest in a set of tag refs. Idempotent on re-register —
|
||||
/// the second call replaces the prior interest set for that subscriber.</summary>
|
||||
public sealed record RegisterInterest(IReadOnlyList<string> TagRefs, IActorRef Subscriber);
|
||||
|
||||
/// <summary>Unregister every interest held by <see cref="Subscriber"/>. Sent on PostStop by
|
||||
/// the subscriber, or by Terminated handling when the mux watches.</summary>
|
||||
public sealed record UnregisterInterest(IActorRef Subscriber);
|
||||
|
||||
private readonly Dictionary<string, HashSet<IActorRef>> _byRef = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<IActorRef, HashSet<string>> _bySubscriber = new();
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
public static Props Props() => Akka.Actor.Props.Create<DependencyMuxActor>();
|
||||
|
||||
public DependencyMuxActor()
|
||||
{
|
||||
Receive<RegisterInterest>(OnRegister);
|
||||
Receive<UnregisterInterest>(msg => RemoveSubscriber(msg.Subscriber));
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(OnAttributeValuePublished);
|
||||
Receive<Terminated>(msg => RemoveSubscriber(msg.ActorRef));
|
||||
}
|
||||
|
||||
private void OnRegister(RegisterInterest msg)
|
||||
{
|
||||
// Replace any prior interest set so re-registers on actor restart don't leak old refs.
|
||||
if (_bySubscriber.TryGetValue(msg.Subscriber, out var priorRefs))
|
||||
{
|
||||
foreach (var r in priorRefs)
|
||||
{
|
||||
if (_byRef.TryGetValue(r, out var set))
|
||||
{
|
||||
set.Remove(msg.Subscriber);
|
||||
if (set.Count == 0) _byRef.Remove(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var refs = new HashSet<string>(msg.TagRefs, StringComparer.Ordinal);
|
||||
_bySubscriber[msg.Subscriber] = refs;
|
||||
foreach (var r in refs)
|
||||
{
|
||||
if (!_byRef.TryGetValue(r, out var set))
|
||||
{
|
||||
set = new HashSet<IActorRef>();
|
||||
_byRef[r] = set;
|
||||
}
|
||||
set.Add(msg.Subscriber);
|
||||
}
|
||||
Context.Watch(msg.Subscriber);
|
||||
_log.Debug("DependencyMux: subscriber {Sub} registered for {Count} refs", msg.Subscriber, refs.Count);
|
||||
}
|
||||
|
||||
private void RemoveSubscriber(IActorRef subscriber)
|
||||
{
|
||||
if (!_bySubscriber.TryGetValue(subscriber, out var refs)) return;
|
||||
foreach (var r in refs)
|
||||
{
|
||||
if (_byRef.TryGetValue(r, out var set))
|
||||
{
|
||||
set.Remove(subscriber);
|
||||
if (set.Count == 0) _byRef.Remove(r);
|
||||
}
|
||||
}
|
||||
_bySubscriber.Remove(subscriber);
|
||||
Context.Unwatch(subscriber);
|
||||
_log.Debug("DependencyMux: subscriber {Sub} removed", subscriber);
|
||||
}
|
||||
|
||||
private void OnAttributeValuePublished(DriverInstanceActor.AttributeValuePublished msg)
|
||||
{
|
||||
if (!_byRef.TryGetValue(msg.FullReference, out var subscribers) || subscribers.Count == 0)
|
||||
{
|
||||
// No virtual tag cares about this ref — drop. Common in normal operation; the address
|
||||
// space carries thousands of tags and only a fraction feed virtual-tag expressions.
|
||||
return;
|
||||
}
|
||||
var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc);
|
||||
foreach (var sub in subscribers)
|
||||
{
|
||||
sub.Tell(dep);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,41 +1,158 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a single virtual-tag expression. Receives dependency-tag updates, recomputes the
|
||||
/// expression, and publishes the result to <c>OpcUaPublishActor</c>.
|
||||
///
|
||||
/// Engine wiring (compile expression via <c>VirtualTagEngine</c>, manage subscriptions,
|
||||
/// emit <c>AttributeValueUpdate</c>) is staged for follow-up F8. This skeleton compiles + has
|
||||
/// a basic message contract so DriverHostActor can spawn it as a child.
|
||||
/// expression via an injected <see cref="IVirtualTagEvaluator"/>, and emits an
|
||||
/// <see cref="EvaluationResult"/> to the parent (the publish actor) whenever the value
|
||||
/// actually changes. Script failures publish a Warning <see cref="ScriptLogEntry"/> on the
|
||||
/// <c>script-logs</c> DPS topic so operators see the diagnostic in the live tail.
|
||||
/// </summary>
|
||||
public sealed class VirtualTagActor : ReceiveActor
|
||||
{
|
||||
public const string ScriptLogsTopic = "script-logs";
|
||||
|
||||
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
|
||||
public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation);
|
||||
|
||||
private readonly string _virtualTagId;
|
||||
private readonly string _scriptId;
|
||||
private readonly string _expression;
|
||||
private readonly IVirtualTagEvaluator _evaluator;
|
||||
private readonly Func<DPSPublisher>? _publisherFactory;
|
||||
private readonly IReadOnlyList<string> _dependencyRefs;
|
||||
private readonly IActorRef? _mux;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private readonly Dictionary<string, object?> _dependencies = new(StringComparer.Ordinal);
|
||||
|
||||
public static Props Props(string virtualTagId, string expression) =>
|
||||
Akka.Actor.Props.Create(() => new VirtualTagActor(virtualTagId, expression));
|
||||
private bool _hasLastValue;
|
||||
private object? _lastValue;
|
||||
|
||||
public VirtualTagActor(string virtualTagId, string expression)
|
||||
public static Props Props(
|
||||
string virtualTagId,
|
||||
string expression,
|
||||
IVirtualTagEvaluator? evaluator = null,
|
||||
string? scriptId = null,
|
||||
Func<DPSPublisher>? publisherFactory = null,
|
||||
IReadOnlyList<string>? dependencyRefs = null,
|
||||
IActorRef? mux = null) =>
|
||||
Akka.Actor.Props.Create(() => new VirtualTagActor(
|
||||
virtualTagId, expression,
|
||||
evaluator ?? NullVirtualTagEvaluator.Instance,
|
||||
scriptId ?? virtualTagId,
|
||||
publisherFactory,
|
||||
dependencyRefs ?? Array.Empty<string>(),
|
||||
mux));
|
||||
|
||||
public VirtualTagActor(
|
||||
string virtualTagId,
|
||||
string expression,
|
||||
IVirtualTagEvaluator evaluator,
|
||||
string scriptId,
|
||||
Func<DPSPublisher>? publisherFactory,
|
||||
IReadOnlyList<string> dependencyRefs,
|
||||
IActorRef? mux)
|
||||
{
|
||||
_virtualTagId = virtualTagId;
|
||||
_scriptId = scriptId;
|
||||
_expression = expression;
|
||||
_evaluator = evaluator;
|
||||
_publisherFactory = publisherFactory;
|
||||
_dependencyRefs = dependencyRefs;
|
||||
_mux = mux;
|
||||
|
||||
Receive<DependencyValueChanged>(msg =>
|
||||
Receive<DependencyValueChanged>(OnDependencyChanged);
|
||||
}
|
||||
|
||||
protected override void PreStart()
|
||||
{
|
||||
if (_mux is not null && _dependencyRefs.Count > 0)
|
||||
{
|
||||
_dependencies[msg.TagId] = msg.Value;
|
||||
// Engine wiring (F8): VirtualTagEngine.Evaluate(_expression, _dependencies) → publish.
|
||||
_log.Debug("VirtualTag {Id}: dependency {Tag}={Value} buffered (eval staged for F8)",
|
||||
_virtualTagId, msg.TagId, msg.Value);
|
||||
});
|
||||
_mux.Tell(new DependencyMuxActor.RegisterInterest(_dependencyRefs, Self));
|
||||
}
|
||||
}
|
||||
|
||||
protected override void PostStop()
|
||||
{
|
||||
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self));
|
||||
}
|
||||
|
||||
private void OnDependencyChanged(DependencyValueChanged msg)
|
||||
{
|
||||
_dependencies[msg.TagId] = msg.Value;
|
||||
|
||||
VirtualTagEvalResult result;
|
||||
try
|
||||
{
|
||||
result = _evaluator.Evaluate(_virtualTagId, _expression, _dependencies);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "VirtualTag {Id}: evaluator threw", _virtualTagId);
|
||||
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "fail"));
|
||||
PublishLog("Error", $"evaluator threw: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "fail"));
|
||||
PublishLog("Warning", result.Reason ?? "evaluator failure");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip no-change results. Real evaluator returns Ok(value); Null returns NoChange — both
|
||||
// safe because Null never produces a fresh value.
|
||||
if (ReferenceEquals(result, VirtualTagEvalResult.NoChange))
|
||||
{
|
||||
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "skip"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hasLastValue && Equals(_lastValue, result.Value))
|
||||
{
|
||||
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "skip"));
|
||||
return;
|
||||
}
|
||||
|
||||
_hasLastValue = true;
|
||||
_lastValue = result.Value;
|
||||
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "ok"));
|
||||
var evalResult = new EvaluationResult(_virtualTagId, result.Value, msg.TimestampUtc, CorrelationId.NewId());
|
||||
Context.Parent.Tell(evalResult);
|
||||
}
|
||||
|
||||
private void PublishLog(string level, string message)
|
||||
{
|
||||
var entry = new ScriptLogEntry(
|
||||
ScriptId: _scriptId,
|
||||
Level: level,
|
||||
Message: message,
|
||||
TimestampUtc: DateTime.UtcNow,
|
||||
VirtualTagId: _virtualTagId,
|
||||
AlarmId: null,
|
||||
EquipmentId: null);
|
||||
|
||||
if (_publisherFactory is not null)
|
||||
{
|
||||
_publisherFactory().Publish(ScriptLogsTopic, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(ScriptLogsTopic, entry));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thin seam for tests to capture DPS publishes without standing up a real cluster.
|
||||
/// Production never instantiates this directly — the actor falls through to
|
||||
/// <see cref="DistributedPubSub"/> when the factory is null.
|
||||
/// </summary>
|
||||
public sealed record DPSPublisher(Action<string, object> Publish);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
the reflective-load design.
|
||||
-->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using Akka.Cluster;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Failover scenarios layered on <see cref="TwoNodeClusterHarness"/> Stop/Restart primitives.
|
||||
/// Covers graceful node loss, rejoin on the same Akka port, and deployment under reduced membership.
|
||||
/// </summary>
|
||||
public sealed class FailoverScenarioTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task Stopping_node_b_shrinks_cluster_to_one_up_member()
|
||||
{
|
||||
await using var harness = await TwoNodeClusterHarness.StartAsync();
|
||||
Akka.Cluster.Cluster.Get(harness.NodeASystem).State.Members
|
||||
.Count(m => m.Status == MemberStatus.Up).ShouldBe(2);
|
||||
|
||||
await harness.StopNodeBAsync();
|
||||
await harness.WaitForClusterSizeAsync(1, TimeSpan.FromSeconds(20));
|
||||
|
||||
Akka.Cluster.Cluster.Get(harness.NodeASystem).State.Members
|
||||
.Count(m => m.Status == MemberStatus.Up).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restarted_node_b_rejoins_cluster_on_same_port()
|
||||
{
|
||||
await using var harness = await TwoNodeClusterHarness.StartAsync();
|
||||
|
||||
await harness.StopNodeBAsync();
|
||||
await harness.WaitForClusterSizeAsync(1, TimeSpan.FromSeconds(20));
|
||||
|
||||
await harness.RestartNodeBAsync();
|
||||
|
||||
Akka.Cluster.Cluster.Get(harness.NodeASystem).State.Members
|
||||
.Count(m => m.Status == MemberStatus.Up).ShouldBe(2);
|
||||
Akka.Cluster.Cluster.Get(harness.NodeBSystem).State.Members
|
||||
.Count(m => m.Status == MemberStatus.Up).ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deployment_started_with_node_b_down_seals_with_one_node_state()
|
||||
{
|
||||
// Establishes that ConfigPublishCoordinator.DiscoverDriverNodes snapshots membership at
|
||||
// dispatch time — when only node A is Up, only one ApplyAck is expected and the
|
||||
// deployment seals without B ever participating.
|
||||
await using var harness = await TwoNodeClusterHarness.StartAsync();
|
||||
|
||||
await harness.StopNodeBAsync();
|
||||
await harness.WaitForClusterSizeAsync(1, TimeSpan.FromSeconds(20));
|
||||
|
||||
await using var scope = harness.NodeA.Services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
|
||||
|
||||
var result = await client.StartDeploymentAsync(createdBy: "alice@test", Ct);
|
||||
result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
|
||||
var deploymentId = result.DeploymentId!.Value.Value;
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
await using var db = await CreateDbAsync(harness);
|
||||
var d = await db.Deployments.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.DeploymentId == deploymentId, Ct);
|
||||
return d?.Status == DeploymentStatus.Sealed;
|
||||
}, TimeSpan.FromSeconds(15));
|
||||
|
||||
await using var db = await CreateDbAsync(harness);
|
||||
var nodeStates = await db.NodeDeploymentStates.AsNoTracking()
|
||||
.Where(s => s.DeploymentId == deploymentId)
|
||||
.ToListAsync(Ct);
|
||||
nodeStates.Count.ShouldBe(1);
|
||||
nodeStates[0].Status.ShouldBe(NodeDeploymentStatus.Applied);
|
||||
}
|
||||
|
||||
private static async Task<OtOpcUaConfigDbContext> CreateDbAsync(TwoNodeClusterHarness harness)
|
||||
{
|
||||
var factory = harness.NodeA.Services.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
||||
return await factory.CreateDbContextAsync();
|
||||
}
|
||||
|
||||
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(200);
|
||||
}
|
||||
throw new TimeoutException($"Condition not met within {timeout}");
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// F13c — verifies <see cref="LdapOpcUaUserAuthenticator"/> faithfully translates
|
||||
/// <see cref="ILdapAuthService"/> outcomes into <c>OpcUaUserAuthResult</c> and turns LDAP
|
||||
/// backend exceptions into a denial rather than letting them escape into the SDK.
|
||||
/// </summary>
|
||||
public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Authenticate_LDAP_success_returns_Allow_with_roles()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, new[] { "ConfigEditor" }, null));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.DisplayName.ShouldBe("Alice");
|
||||
result.Roles.ShouldBe(new[] { "ConfigEditor" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password"));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldBe("Invalid username or password");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
|
||||
{
|
||||
var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable"));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("backend");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", Array.Empty<string>(), new[] { "ReadOnly" }, null));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.DisplayName.ShouldBe("alice");
|
||||
}
|
||||
|
||||
private sealed class FakeLdap : ILdapAuthService
|
||||
{
|
||||
private readonly Func<string, LdapAuthResult> _handler;
|
||||
public FakeLdap(LdapAuthResult fixed_) => _handler = _ => fixed_;
|
||||
public FakeLdap(Func<string, LdapAuthResult> handler) => _handler = handler;
|
||||
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
=> Task.FromResult(_handler(username));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# ZB.MOM.WW.OtOpcUa.Host.IntegrationTests
|
||||
|
||||
Two-node Akka cluster integration tests on top of `TwoNodeClusterHarness`.
|
||||
|
||||
## Default mode (no infra required)
|
||||
|
||||
```bash
|
||||
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests
|
||||
```
|
||||
|
||||
Uses `Microsoft.EntityFrameworkCore.InMemory` for `ConfigDb` and a stub `ILdapAuthService` that
|
||||
accepts any username when the password is `valid-password`. Each harness instance creates a
|
||||
unique in-memory database scoped to its lifetime. This is the mode CI runs by default.
|
||||
|
||||
## Real-infra mode (SQL Server + OpenLDAP)
|
||||
|
||||
When you need to exercise EF behaviors that diverge between providers (index uniqueness,
|
||||
`RowVersion` concurrency, JSON columns, migration application) or a real LDAP bind, bring up
|
||||
the bundled compose stack and set the env-var switches:
|
||||
|
||||
```bash
|
||||
docker compose -f tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml up -d
|
||||
|
||||
export OTOPCUA_HARNESS_USE_SQL=1
|
||||
export OTOPCUA_HARNESS_USE_LDAP=1
|
||||
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests
|
||||
|
||||
docker compose -f tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
### SQL Server mode (`OTOPCUA_HARNESS_USE_SQL=1`)
|
||||
|
||||
- Container: `mcr.microsoft.com/mssql/server:2022-latest` on `localhost:14331`
|
||||
- Each `TwoNodeClusterHarness.StartAsync()` creates a unique database
|
||||
`OtOpcUa_Harness_{guid}` via `Database.EnsureCreatedAsync()` and drops it on
|
||||
`DisposeAsync()` (best-effort).
|
||||
- Port `14331` chosen to avoid colliding with the `docker-dev/` fleet (which uses `14330`).
|
||||
|
||||
### LDAP mode (`OTOPCUA_HARNESS_USE_LDAP=1`)
|
||||
|
||||
- Container: `bitnami/openldap:2.6` on `localhost:3894`
|
||||
- Users `alice` / `alice123` and `bob` / `bob123`, all under `ou=FleetAdmin`.
|
||||
- Port `3894` chosen to avoid colliding with the `docker-dev/` fleet (which uses `3893`).
|
||||
|
||||
## Local-dev caveat
|
||||
|
||||
This dev VM (`DESKTOP-6JL3KKO`) does not run Docker locally. Real-infra mode runs on the
|
||||
shared Linux Docker host (`10.100.0.35`) per `docs/v2/dev-environment.md`, or in CI on Linux.
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// F9b — verifies <see cref="RoslynScriptedAlarmEvaluator"/> compiles alarm predicates,
|
||||
/// returns the bool result on success, surfaces compile/runtime errors as Failure (so the
|
||||
/// actor preserves prior state), and rejects predicates that try to ctx.SetVirtualTag (the
|
||||
/// AlarmPredicateContext throws on writes — predicates must stay pure).
|
||||
/// </summary>
|
||||
public sealed class RoslynScriptedAlarmEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Evaluate_predicate_returning_true_reports_Active()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
|
||||
var result = sut.Evaluate(
|
||||
alarmId: "alarm-hi",
|
||||
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
|
||||
dependencies: new Dictionary<string, object?> { ["temp"] = 150 });
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Active.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_predicate_returning_false_reports_Inactive()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
|
||||
var result = sut.Evaluate(
|
||||
alarmId: "alarm-hi",
|
||||
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
|
||||
dependencies: new Dictionary<string, object?> { ["temp"] = 50 });
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Active.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_caches_compiled_predicate_across_calls()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;";
|
||||
|
||||
var first = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = true });
|
||||
var second = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = false });
|
||||
|
||||
first.Active.ShouldBeTrue();
|
||||
second.Active.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_compile_error_returns_Failure()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
|
||||
var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason!.ShouldContain("compile");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
|
||||
// AlarmPredicateContext.SetVirtualTag throws — wrapper catches + reports as Failure.
|
||||
var result = sut.Evaluate(
|
||||
alarmId: "alarm-bad-write",
|
||||
predicate: "ctx.SetVirtualTag(\"x\", 1); return true;",
|
||||
dependencies: new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason!.ShouldContain("threw");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_empty_predicate_returns_Failure()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
|
||||
sut.Evaluate("alarm-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_after_dispose_returns_Failure()
|
||||
{
|
||||
var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||
sut.Dispose();
|
||||
|
||||
var result = sut.Evaluate("alarm", "return true;", new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason!.ShouldContain("disposed");
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// F8b — verifies <see cref="RoslynVirtualTagEvaluator"/> compiles user expressions through
|
||||
/// the Core.Scripting sandbox, runs them against the dependency dictionary, caches the
|
||||
/// compiled assembly per source, and surfaces failures (compile error, sandbox violation,
|
||||
/// runtime throw) as <c>VirtualTagEvalResult.Failure</c> instead of propagating exceptions.
|
||||
/// </summary>
|
||||
public sealed class RoslynVirtualTagEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Evaluate_simple_addition_returns_summed_value()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-sum",
|
||||
expression: "return (int)ctx.GetTag(\"a\").Value + (int)ctx.GetTag(\"b\").Value;",
|
||||
dependencies: new Dictionary<string, object?> { ["a"] = 10, ["b"] = 32 });
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_caches_compiled_expression_across_calls()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;";
|
||||
|
||||
var first = sut.Evaluate("vt-cache", expr, new Dictionary<string, object?> { ["x"] = 5 });
|
||||
var second = sut.Evaluate("vt-cache", expr, new Dictionary<string, object?> { ["x"] = 7 });
|
||||
|
||||
first.Success.ShouldBeTrue(first.Reason);
|
||||
first.Value.ShouldBe(10);
|
||||
second.Success.ShouldBeTrue(second.Reason);
|
||||
second.Value.ShouldBe(14);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_compile_error_returns_Failure_with_reason()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
|
||||
var result = sut.Evaluate("vt-bad", "this is not valid C#;", new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason.ShouldNotBeNull();
|
||||
result.Reason.ShouldContain("compile");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_runtime_exception_returns_Failure_with_reason()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
|
||||
var result = sut.Evaluate(
|
||||
virtualTagId: "vt-div0",
|
||||
expression: "int a = 0; return 1 / a;",
|
||||
dependencies: new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason.ShouldNotBeNull();
|
||||
result.Reason.ShouldContain("threw");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_empty_expression_returns_Failure()
|
||||
{
|
||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
|
||||
sut.Evaluate("vt-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||
sut.Evaluate("vt-empty", " ", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_after_dispose_returns_Failure()
|
||||
{
|
||||
var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
||||
sut.Dispose();
|
||||
|
||||
var result = sut.Evaluate("vt", "return 1;", new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason!.ShouldContain("disposed");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user