The two most-edited entities for daily operator workflows. Both follow the
same single-page edit-or-create pattern from batches 1 + 2 with RowVersion
optimistic concurrency.
- EquipmentEdit.razor /clusters/{id}/equipment/{new|EquipmentId}
- EquipmentId is system-generated on create (decision #125): EQ-{first
12 hex chars of a new EquipmentUuid}.
- UNS line + driver instance selects are scoped to the cluster.
- All 9 OPC 40010 identification fields surfaced as an optional panel.
- MachineCode uniqueness checked client-side before EF unique index
enforces it server-side.
- TagEdit.razor /clusters/{id}/tags/{new|TagId}
- Equipment vs FolderPath input switches based on the selected
driver's namespace kind — Equipment-kind requires EquipmentId,
SystemPlatform-kind requires FolderPath (decision #110 invariant
enforced client-side; sp_ValidateDraft re-enforces server-side at
deploy).
- DataType select uses the OPC UA built-in primitive type names.
- TagConfig validated as JSON pre-flight.
ClusterEquipment + ClusterTags list pages get New / Edit affordances.
All 9 integration tests still green.
Same single-page edit-or-create pattern as batch 1, applied to the
foundational topology entities. After this batch the whole hierarchy
(cluster → nodes → UNS areas → UNS lines → namespaces → drivers) is
fully editable through the UI.
- ClusterEdit.razor /clusters/{id}/edit
Update + delete for an existing cluster. NodeCount stays coupled to
RedundancyMode (None→1, Warm/Hot→2). ModifiedBy taken from
AuthenticationStateProvider.
- NodeEdit.razor /clusters/{id}/nodes/{new|nodeId}
Full ClusterNode CRUD. ApplicationUri uniqueness is enforced by EF
index; ServiceLevelBase defaults to 200 (primary preference) on
create; per-node DriverConfigOverridesJson validated as JSON.
- UnsAreaEdit.razor /clusters/{id}/uns/areas/{new|id}
- UnsLineEdit.razor /clusters/{id}/uns/lines/{new|id}
UNS structure CRUD; Lines pick their parent Area from a select that
loads the cluster's areas.
List pages updated:
- ClusterOverview now shows an "Edit cluster" button + a "New node"
action on the nodes panel + per-row Edit buttons.
- ClusterUns gains New/Edit affordances for both Areas and Lines.
All 9 integration tests still green; no regressions.
Pattern proof for the live-edit forms gated by Phases A–D's read views.
Each entity gets a single edit page handling both create (route param
omitted) and update (route param present) modes, with RowVersion-based
optimistic concurrency checked against EF Core's
DbUpdateConcurrencyException.
Pattern:
- @page "/clusters/{id}/<thing>/new"
- @page "/clusters/{id}/<thing>/{rowId}"
- IsNew computed from rowId presence
- EditForm + DataAnnotations validation
- byte[] RowVersion stashed on FormModel; assigned to
Entry(e).Property(e => e.RowVersion).OriginalValue before SaveChanges
- Delete button (edit mode only) flows through the same RowVersion check
- Concurrency conflict surfaces as an inline error panel; user reloads
This batch:
- NamespaceEdit.razor — small entity, validates the pattern
- DriverEdit.razor — keystone for everything downstream
(Equipment/Tag/VirtualTag/ScriptedAlarm),
JSON config editor per Q1 with reformat
on save and validation pre-flight
- ClusterNamespaces row gains an Edit button + New action
- ClusterDrivers expanded view gains an Edit button + New action
Equipment/UnsArea/UnsLine/Tag/ACL/VirtualTag/ScriptedAlarm/Script forms
follow this same template in subsequent F15.2 batches.
All 9 integration tests still green; no v2 test regressions.
- ClusterAudit (/clusters/{id}/audit) — reads ConfigAuditLog with the
EventId/CorrelationId columns added in F3; shown as a Cluster tab
- VirtualTags (/virtual-tags) — fleet-wide read view
- ScriptedAlarms (/scripted-alarms) — fleet-wide read view
- Scripts (/scripts) — fleet-wide; expandable code preview
- RoleGrants (/role-grants) — per Q4, surfaces the fleet-wide
LDAP-group → role mapping from
Authentication:Ldap:GroupToRole
(read-only; reload via host restart)
- Certificates (/certificates) — own/trusted/issuer/rejected store
contents resolved against
OpcUa:PkiStoreRoot config (F13a)
- Reservations (/reservations) — ExternalIdReservation table
- AlarmsHistorian (/alarms-historian) — live HistorianAdapterActor sink
status via the F11 GetStatus query;
5s polling
ScriptLog deferred (needs the F16-deferred ScriptLogHub bridge).
ClusterNav extended with the Audit tab.
Adds an AdminUI → Runtime project reference so the historian status page can
inject IRequiredActor<HistorianAdapterActorKey>. NuGet audit suppression for
the transitive Opc.Ua.Core advisory mirrored from the Runtime project.
All 104 v2 tests still green.
Per Q3 of the rebuild plan, each v1 ClusterDetail tab becomes a separate
route under /clusters/{id}/<tab>. This batch adds read-only table views
for the six core config entity types; live-edit forms with RowVersion
concurrency land in Phase C.2 once the read-view shape is reviewed.
- ClusterEquipment /clusters/{id}/equipment — joins via DriverInstance
so the cluster scope works
- ClusterUns /clusters/{id}/uns — Areas + Lines tables
- ClusterNamespaces /clusters/{id}/namespaces — Kind + URI + Enabled chip
- ClusterDrivers /clusters/{id}/drivers — collapsed list with JSON
config expandable per Q1
(typed editors deferred)
- ClusterTags /clusters/{id}/tags — first 200 by name + filter
- ClusterAcls /clusters/{id}/acls — LDAP group + scope +
NodePermissions bits
Shared ClusterNav.razor extracted; ClusterOverview + ClusterRedundancy
updated to use it. _Imports.razor adds Components.Shared so the shared
nav is in scope across pages.
- ClustersList (/clusters) — table view, row-click opens detail
- NewCluster (/clusters/new) — EditForm with DataAnnotations; redundancy
mode + node-count coupling enforced client-side (None→1, Warm/Hot→2);
CreatedBy taken from AuthenticationStateProvider
- ClusterOverview (/clusters/{id}) — cluster details + last-deployment
badge + node list. Per Q3, the legacy 10-tab monolith is split into
separate routes; this page hosts the Overview "tab" as its primary slot
- ClusterRedundancy (/clusters/{id}/redundancy) — static ServiceLevelBase
config view; live ServiceLevel comes via RedundancyStateActor DPS topic
(deferred to its own follow-up once the SignalR bridge lands)
The other 8 v1 cluster tabs (Equipment, UNS, Namespaces, Drivers, Tags,
ACLs, ScriptedAlarms, Scripts, Audit) land in Phase C/D.
Implements Phase A of the F15 rebuild plan: minimum-viable Admin surface
with a working sign-in path and a fleet-state landing page. Decisions Q1–Q5
of docs/v2/AdminUI-rebuild-plan.md were taken as recommended.
- App.razor (moved into AdminUI library from the Host stub; vendored
Bootstrap from RCL wwwroot — no public CDN, air-gap safe)
- Routes.razor (AuthorizeRouteView enforces page-level [Authorize])
- RedirectToLogin.razor (preserves returnUrl through the auth hop)
- Login.razor (static SSR, posts to /auth/login; Q5 wording about
generic-vs-specific LDAP errors)
- Account.razor (identity + fleet roles + raw LDAP groups; Q4 — no
per-cluster grants; fleet-wide LDAP-group → role mapping only)
- Fleet.razor (per-node deployment status: reads NodeDeploymentState
+ unions with IClusterRoleInfo.MembersWithRole("driver") so freshly-
joined nodes appear as "waiting"; 10s auto-refresh)
- Hosts.razor (Akka cluster topology: members, status, roles, role-
leader; 5s auto-refresh)
Host's stub App.razor deleted; Program.cs now points at
AdminUI.Components.App via an added using.
All 104 v2 tests remain green.
New per-admin-node actor that subscribes to the fleet-status DistributedPubSub
topic + forwards every FleetStatusChanged snapshot to all SignalR clients
connected to FleetStatusHub via IHubContext.
Wired via WithOtOpcUaSignalRBridges (new AkkaConfigurationBuilder extension in
AdminUI.Hubs) — Program.cs calls it inside the if(hasAdmin) block alongside
WithOtOpcUaControlPlaneSingletons.
Per-node subscription rather than cluster-singleton: every admin node forwards
its own snapshots to its own connected clients. Simpler than singleton
coordination + acceptable because the messages are small and SignalR fan-out
is per-node anyway.
- New Commons.Messages.Fleet.GetDiagnostics request record.
- DriverHostActor handles GetDiagnostics in all three states (Steady, Applying,
Stale); replies with a NodeDiagnosticsSnapshot built from _currentRevision
+ the local NodeId. Drivers list is empty until F7 wires the per-instance
children.
- FleetDiagnosticsClient now resolves the target via ActorSelection at
akka.tcp://{system}@{nodeId}/user/driver-host and Asks with a 3s timeout.
On timeout/peer-down it returns an empty snapshot so the UI degrades
gracefully rather than throwing.
Two new integration tests in Host.IntegrationTests:
- GetDiagnostics_returns_snapshot_with_target_NodeId verifies the
cross-node Ask/Reply works.
- GetDiagnostics_after_deploy_reports_current_revision exercises the
end-to-end path: AdminOps starts a deployment, both DriverHostActors
apply, then diagnostics reports the new revision on both nodes.
All 98 v2 tests pass (was 96 + 2 new).
Injects AuthenticationStateProvider and reads the current user's identity
name on Deploy click, replacing the "(current user)" placeholder.
Anonymous case falls back to "(anonymous)" — should never hit in practice
since the page requires FleetAdmin/ConfigEditor.
Task 56: removes the legacy in-process Server + Admin Web project + their test
projects (Server.Tests, Admin.Tests, Admin.E2ETests). The fused OtOpcUa.Host
binary built across Phases 1-9 is now the sole production entry point.
What happened to the 47 legacy Admin Blazor pages: per follow-up F15, the
v1 architecture's draft/publish UX is replaced by v2's live-edit + snapshot-
deploy model, so a 1:1 migration is not meaningful. The mechanical move via
git mv preserves the history; service classes + page bodies that referenced
removed v1 types (ConfigGeneration, RedundancyRole, GenerationId) were
deleted. AdminUI now ships a minimal Home page + the v2 Deployments page.
Per-page rebuild against the v2 surface is tracked as F15. The v2 Deployments
page (Task 52) is the only first-party UI shipping in this PR.
Task 57: solution build green; 84+ tests green across active v2 + legacy
driver test projects.
Adds the empty project skeletons that subsequent v2 tasks fill in:
src/Core/ZB.MOM.WW.OtOpcUa.Commons (types, interfaces, message contracts)
src/Core/ZB.MOM.WW.OtOpcUa.Cluster (Akka.Hosting + cluster wiring)
src/Server/ZB.MOM.WW.OtOpcUa.Security (cookie+JWT auth, LDAP)
src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane (admin-role cluster singletons)
src/Server/ZB.MOM.WW.OtOpcUa.Runtime (per-node driver actors)
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer (OPC UA SDK application host)
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI (Razor class library)
src/Server/ZB.MOM.WW.OtOpcUa.Host (single fused web binary)
Each project sets TreatWarningsAsErrors=true in its own csproj (per the
Directory.Build.props deviation note in the previous commit). NuGetAuditSuppress
entries cover transitive vulnerability advisories the new strictness surfaces:
- GHSA-g94r-2vxg-569j (OpenTelemetry.Api 1.9.0 via Akka.Cluster.Hosting/Tools)
- GHSA-h958-fxgg-g7w3 (Opc.Ua.Core 1.5.374.126 via OpcUaServer)
- GHSA-37gx-xxp4-5rgx + GHSA-w3x6-4m5h-cxqf (legacy advisories already accepted)
OpcUaServer pins OPCFoundation.NetStandard.Opc.Ua.Configuration to 1.5.374.126
via VersionOverride to match Opc.Ua.Server's transitive Opc.Ua.Core (same
constraint as the legacy Server project).
Runtime does NOT project-reference any concrete Driver.* assemblies; drivers
load reflectively at runtime (Phase 6). Runtime gets the IDriver contract
through Core.Abstractions instead.
Host's Microsoft.Extensions.Hosting.WindowsServices is conditional on the
Windows OS so the project builds on macOS dev machines.
Build verification: dotnet build -> 438 warnings (all pre-existing xUnit1051
in legacy Server.Tests/Admin.Tests), 0 errors. Closes Task 9 (build green
smoke check, no separate commit).