- RestartDriver / ReconnectDriver messages + AdminOperationsActor
handlers (broadcast via driver-control DPS topic; audited via
ConfigEdits).
- DriverHostActor subscribes to driver-control; locates the
matching child DriverInstanceActor and stops+respawns it
(Restart) or sends it a ForceReconnect internal message
(Reconnect — re-enters Reconnecting state without full stop).
DriverInstanceSpec constructor call uses named args to handle
the full 6-parameter signature.
- New DriverOperator authorization policy mapped to DriverOperator
or FleetAdmin role; documented in docs/security.md. Map LDAP
group via GroupToRole (e.g. "ot-driver-operator": "DriverOperator").
- DriverStatusPanel renders Reconnect + Restart buttons when the
user holds the DriverOperator policy (hidden otherwise). Restart
requires an in-page Razor confirm block (no JS confirm, keeps
SignalR event loop unblocked). Both buttons show a spinner and
are disabled during in-flight; result chip auto-clears after 8s.
Username sourced from AuthenticationStateProvider.
Reconnect resolves to "ForceReconnect" (re-enter Reconnecting,
not full stop+respawn) — transport drops and retries while actor
and in-memory state are preserved. All DriverInstanceActor states
handle ForceReconnect safely (no-op when already in transition).
- AdminProbeService routes TestDriverConnect through
IAdminOperationsClient with a 65s outer guard (actor side already
clamps to [1,60]).
- Added generic AskAsync<T> to IAdminOperationsClient interface and
AdminOperationsClient impl, delegating straight to the Akka proxy.
- DriverTestConnectButton renders the button + inline result chip,
auto-clears after 30s, disables during in-flight.
- Wired into all 9 typed driver pages directly under the
identity section. Sources timeout from the form's
ProbeTimeoutSeconds; sources config JSON from the form's
current Options (operator can test BEFORE saving).
ModbusDriverProbe.DriverType was "Modbus" but the AdminUI's
ModbusDriverPage persists DriverInstance.DriverType = "ModbusTcp".
GalaxyDriverProbe used the runtime DriverTypeName constant
("GalaxyMxGateway") but the AdminUI saves "Galaxy". The probe DI
lookup is case-insensitive but not name-insensitive, so Test
Connect would fail to find a probe for these two drivers.
Cheap-and-fast probe: open TCP socket to the configured endpoint,
close immediately. Surfaces SocketError on failure, latency on
success, "timed out" on caller cancel. Sufficient for the AdminUI
Test Connect "can we reach the host?" question. Richer protocol-
level probes (OPC UA session open, FOCAS handshake, gRPC ping)
are a documented follow-up. Each probe registered as
AddSingleton<IDriverProbe, X> in DriverFactoryBootstrap so they
flow through DI into AdminOperationsActor.
Historian.Wonderware returns a clean "TCP probe not applicable"
result because it communicates over a Windows named pipe, not TCP.
Also adds OpcUaClient + Historian.Wonderware.Client project
references to Host.csproj (both were missing from the driver
ItemGroup).
- IDriverProbe abstraction in Core.Abstractions; one impl per driver
type, resolved by DriverType string. Phase 7.3 + 7.4 add concrete
probes for the 9 supported driver types.
- TestDriverConnect / TestDriverConnectResult messages.
- AdminOperationsActor.HandleTestDriverConnectAsync looks up the probe
by DriverType, runs it with a [1,60]s clamped timeout, and returns
success/latency or failure/message. Probes that throw or time out
surface as soft failures.
Live panel subscribed to the /hubs/driverstatus SignalR feed —
renders state chip, last-success age, 5-min error count, last
error message. Auto-reconnect; dimmed when no push arrives for 30s.
Hidden for new instances (nothing deployed yet); shown read-only
on every edit-mode page. Reconnect/Restart buttons land in Phase 8.
- IDriverHealthPublisher in Core.Abstractions + NullDriverHealthPublisher
no-op for tests/dev-stub paths.
- AkkaDriverHealthPublisher in Runtime forwards to the cluster-wide
`driver-health` DPS topic.
- DriverInstanceActor instrumented to publish snapshots on every
observable state change + a periodic 30s heartbeat so the AdminUI
snapshot store warms up for newly-joined SignalR clients.
- Sliding 5-minute Faulted-count tracked per actor via Queue<DateTime>.
- DriverHostActor.SpawnChild threads clusterId (_localNode.Value) and
the health publisher down to every DriverInstanceActor child.
- ServiceCollectionExtensions.AddOtOpcUaRuntime registers
AkkaDriverHealthPublisher as IDriverHealthPublisher singleton.
- S7DriverPage.FormModel now preserves Tags through Form ↔ Options
translation (was hard-coding Tags = [] on every save, silently
destroying any tag list that operators had configured).
- Add FormModel_RoundTrip tests for OpcUaClient and Historian
mirror classes — both were translating Options ↔ form-model
entirely untested.
- Surface S7 Tags in the round-trip test so this regression
can't reach merge again.
All 9 driver types now have typed pages; DriverEditRouter dispatches
to them directly. Unknown DriverType strings (e.g. legacy rows) render
an explicit error notice instead of falling through to a generic
editor — the failure mode is now visible, not silent.
DriverEditRouter now dispatches every known DriverType to its typed
page. The legacy DriverEdit fallback remains in ResolveComponentType
for forward-compatibility with as-yet-unknown driver types but is no
longer reached for any current driver.
Wires the POCO-only driver contracts into the AdminUI csproj so the
9 typed *DriverPage.razor components from Phase 4 can compile against
the real Options classes without dragging native driver deps in.
Removes both @page directives from DriverEdit.razor. The picker owns
/drivers/new; the router owns /drivers/{id} and dispatches via
DynamicComponent (currently falls back to DriverEdit for every driver
type — Phase 4 populates the type map one driver at a time).
Adds /clusters/{ClusterId}/drivers/new picker page (Task 3.1). Renders
a 9-card Bootstrap grid — one card per driver type — each linking to
/clusters/{ClusterId}/drivers/new/{slug}. No data fetch; type list is
hardcoded. Route collides with DriverEdit.razor's same directive; Task
3.3 removes the duplicate to resolve the runtime ambiguity.
No functional change — the identity, resilience, and save-bar are now
each in their own reusable component so the typed driver pages (Phase 4)
can share them. The middle "Driver config (JSON)" panel stays inlined
for now — it's replaced wholesale by typed forms in Phase 4.
Move WonderwareHistorianClientOptions to a new
Driver.Historian.Wonderware.Client.Contracts sibling project. The record
had no using directives and uses only primitive types (string, TimeSpan)
so the contracts project is dependency-free.
Convert one doc-comment reference:
<see cref="WonderwareHistorianClient"/> → <c>WonderwareHistorianClient</c>
per the approved decision — no compilable usings were present.
The runtime Driver.Historian.Wonderware.Client project gains a
ProjectReference to .Contracts; the .slnx is updated accordingly.
Move GalaxyDriverOptions (and nested records GalaxyGatewayOptions,
GalaxyMxAccessOptions, GalaxyRepositoryOptions, GalaxyReconnectOptions)
from Config/GalaxyDriverOptions.cs into a new Driver.Galaxy.Contracts
sibling project at the contracts root (no Config/ subdirectory). The
existing namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config is preserved
unchanged — it is a runtime ABI concern and all consumers already import
it via the namespace qualifier.
No doc-comment substitutions required — the only cref in the file
(<see cref="ApiKeySecretRef"/>) is an intra-type parameter reference
that resolves within the contracts project itself.
The options file had no using directives and no NuGet type surface;
the contracts project is dependency-free. The runtime Driver.Galaxy
project gains a ProjectReference to .Contracts; the .slnx is updated
accordingly.
Move OpcUaClientDriverOptions and all companion enums (OpcUaTargetNamespaceKind,
OpcUaSecurityMode, OpcUaSecurityPolicy, OpcUaAuthType) to a new
Driver.OpcUaClient.Contracts sibling project. The options file had no
using directives — all types were defined in the same file — so no
NuGet mirror enum pattern was required.
Convert two doc-comment references:
<see cref="OpcUaClientDriver.InitializeAsync"/> → <c>OpcUaClientDriver.InitializeAsync</c>
<see cref="OpcUaClientDriver.ValidateNamespaceKind"/> → <c>OpcUaClientDriver.ValidateNamespaceKind</c>
per the approved decision — no compilable usings were present.
The runtime Driver.OpcUaClient project gains a ProjectReference to .Contracts;
the .slnx is updated accordingly.
Move FocasDriverOptions (and companion option types), FocasCncSeries,
and the FocasDataType enum to a new Driver.FOCAS.Contracts sibling
project. FocasDataTypeExtensions (which uses DriverDataType from
Core.Abstractions) stays in the runtime driver as FocasDataTypeExtensions.cs.
Convert two doc-comment references:
<see cref="FocasDriver.InitializeAsync"/> → <c>FocasDriver.InitializeAsync</c>
<see cref="FocasAddress.TryParse"/> → <c>FocasAddress.TryParse</c>
per the approved decision — no compilable usings were present in the
moved files.
The runtime Driver.FOCAS project gains a ProjectReference to .Contracts;
the .slnx is updated accordingly.
Introduces Driver.S7.Contracts (dependency-free POCO project) and moves
S7DriverOptions / S7ProbeOptions / S7TagDefinition / S7DataType into it.
Adds S7CpuType enum mirroring S7.Net.CpuType exactly (7 values with
explicit integer codes). Runtime S7CpuTypeMap bridges S7CpuType →
S7.Net.CpuType at the single Plc construction site in S7Driver.InitializeAsync.
S7DriverFactoryExtensions and S7CommandBase updated to use S7CpuType; test
files updated to match (S7_1500Profile, S7DriverScaffoldTests). AdminUI can
now reference Driver.S7.Contracts without pulling in S7netplus.
Move TwinCATDriverOptions and TwinCATDataType enum to a new
Driver.TwinCAT.Contracts sibling project. TwinCATDataTypeExtensions
(which uses DriverDataType from Core.Abstractions) stays in the
runtime driver as TwinCATDataTypeExtensions.cs.
Replace two doc-comment references:
<see cref="Core.Abstractions.PollGroupEngine"/> → <c>PollGroupEngine</c>
<see cref="TwinCATAmsAddress.TryParse"/> → <c>TwinCATAmsAddress.TryParse</c>
per the approved decision — no compilable usings were present.
The runtime Driver.TwinCAT project gains a ProjectReference to .Contracts;
the .slnx is updated accordingly.
Move AbLegacyDriverOptions, AbLegacyDataType enum, and
AbLegacyPlcFamilyProfile (including AbLegacyPlcFamily enum) to a new
Driver.AbLegacy.Contracts sibling project. All three files are zero-dep
after splitting AbLegacyDataTypeExtensions (which uses DriverDataType
from Core.Abstractions) into a new file that stays in the runtime driver.
Drop the doc-comment <see cref="AbLegacyAddress.TryParse"/> reference and
replace with <c>AbLegacyAddress.TryParse</c> per the approved decision.
The PlcFamilies using directive is retained in the contracts project since
both namespaces live there.
The runtime Driver.AbLegacy project gains a ProjectReference to .Contracts;
the .slnx is updated accordingly.
Move AbCipDriverOptions (and AbCipDataType enum) to a new
Driver.AbCip.Contracts sibling project. AbCipDataTypeExtensions
(which uses DriverDataType from Core.Abstractions) stays in the
runtime driver as AbCipDataTypeExtensions.cs.
Replace two doc-comment <see cref="Core.Abstractions.IAlarmSource"/>
and <see cref="Core.Abstractions.IHostConnectivityProbe"/> with <c>X</c>
per the approved decision — no compilable using was present.
The runtime Driver.AbCip project gains a ProjectReference to .Contracts;
the .slnx is updated accordingly.
Move ModbusDriverOptions (and companion option types) to a new
Driver.Modbus.Contracts sibling project. The contracts project
references only Driver.Modbus.Addressing (itself zero-dep and
Admin-safe) because ModbusDriverOptions.Probe/Family/Region
properties use enum types that live there.
Drop 'using ZB.MOM.WW.OtOpcUa.Core.Abstractions' and replace
<see cref="IHostConnectivityProbe"/> with <c>IHostConnectivityProbe</c>
per the approved decision — the using was doc-comment-only.
The runtime Driver.Modbus project gains a ProjectReference back to
.Contracts; the .slnx is updated accordingly.
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
The v2 plan's blessed install path (scripts/install/Install-Services.ps1)
registers the host via `sc.exe create binPath=...OtOpcUa.Host.exe`, but the
binary never called `UseWindowsService`. Without it, the Service Control
Manager waits ~30s for the process to call SetServiceStatus(Running) and
then kills it — the install script's design was incomplete.
Two changes:
- Host.csproj: drop the `IsOSPlatform('Windows')` condition on the
Microsoft.Extensions.Hosting.WindowsServices package reference so the
package is always available. The runtime helper used by
UseWindowsService gates on WindowsServiceHelpers.IsWindowsService()
internally, so it's a no-op when running as a console app or under
Linux/macOS — the binary stays cross-platform-buildable.
- Program.cs: call builder.Host.UseWindowsService(options =>
options.ServiceName = "OtOpcUaHost") immediately after CreateBuilder.
When the host is launched by SCM, WindowsServiceLifetime takes over
the IHostLifetime slot and reports START/STOP correctly. When launched
by `dotnet run` or `OtOpcUa.Host.exe` from a console, it's a no-op.
Verified end-to-end on wonder-app-vd03.zmr.zimmer.com: `sc.exe create`
followed by `sc.exe start OtOpcUaHost` transitions from START_PENDING to
RUNNING; /login + /health/ready + /health/active all return 200; service
survives SSH session close and auto-starts on boot per the AUTO_START
flag set by the installer script.
Closes the gap where Tag rows with EquipmentId=NULL + Namespace.Kind=SystemPlatform
(Galaxy hierarchy) existed in ConfigDb but were never surfaced in the OPC UA
address space. Now they materialise as Variable nodes under a folder named for
their FolderPath, browseable through any OPC UA client.
Layers touched:
- IOpcUaAddressSpaceSink: new EnsureVariable(nodeId, parentFolderId, displayName,
dataType) signature on the sink interface, NullSink, DeferredSink, SdkSink.
- OtOpcUaNodeManager.EnsureVariable: creates a BaseDataVariableState parented
under the named folder (or root), initial Value=null +
StatusCode=BadWaitingForInitialData; resolves Tag.DataType strings to the
matching OPC UA built-in NodeId. Idempotent.
- Phase7CompositionResult: new GalaxyTags collection of GalaxyTagPlan records
carrying (TagId, DriverInstanceId, FolderPath, DisplayName, DataType,
MxAccessRef). Constructor overloads keep existing call sites compiling.
- Phase7Composer.Compose: now takes Tag + Namespace inputs, filters for
SystemPlatform-namespace tags with EquipmentId=NULL, emits GalaxyTagPlan
rows with MXAccess ref "FolderPath.Name".
- Phase7Plan: new AddedGalaxyTags / RemovedGalaxyTags / ChangedGalaxyTags
collections + GalaxyTagDelta record; IsEmpty + needsRebuild updated.
- Phase7Planner.Compute: diffs GalaxyTags by TagId via existing DiffById helper.
- DeploymentArtifact.ParseComposition: reads the Tags + Namespaces +
DriverInstances arrays the ConfigComposer already emits, applies the same
SystemPlatform filter, returns the same GalaxyTagPlan list as the composer
so artifact-side and compose-side plans agree.
- Phase7Applier: new MaterialiseGalaxyTags pass that ensures one folder per
distinct FolderPath then one Variable per tag. NodeId for the variable is
"<FolderPath>.<Name>" matching the MXAccess ref so the future Galaxy
SubscribeBulk wiring can address them directly.
- OpcUaPublishActor.RebuildAddressSpace: invokes MaterialiseGalaxyTags after
MaterialiseHierarchy. _lastApplied initialiser updated for the new ctor.
- seed-clusters.sql: pre-existing TestMachine_001.TestAlarm001..003 rows
needed no change — the composer/applier now picks them up automatically.
Verified end-to-end via docker-dev: deploy click → driver-a logs
"Phase7Applier: Galaxy tags materialised (tags=3, folders=1)" → OPC UA Client
CLI browses the three Variable nodes under TestMachine_001 folder. Reads
return BadWaitingForInitialData status (expected — Galaxy driver's
SubscribeBulk wiring to push values into the nodes is the remaining
follow-up).
User confirmed the mxaccessgw client (Galaxy driver) doesn't need Windows
— only the gateway worker has that constraint. This wires the Galaxy
driver into the docker-dev fleet:
- docker-compose.yml: GALAXY_MXGW_API_KEY env var on every host service
(admin nodes harmlessly ignore it; driver-role nodes pick it up when
the seeded DriverInstance resolves ApiKeySecretRef=env:GALAXY_MXGW_API_KEY).
Default value matches the key the operator provided; override via shell
env (GALAXY_MXGW_API_KEY=... docker compose up -d) to rotate without
editing compose.
- seed-clusters.sql: now creates a SystemPlatform Namespace
(MAIN-galaxy, urn:zb:docker-dev:galaxy) plus a GalaxyMxGateway
DriverInstance (MAIN-galaxy-mxgw) in the MAIN cluster pointing at
http://10.100.0.48:5120 with UseTls=false. Idempotent via IF NOT EXISTS.
- DriverInstanceActor.ShouldStub: clarified the doc comment — only the
legacy "Galaxy" type name and "Historian.Wonderware" are Windows-only;
the v2 "GalaxyMxGateway" driver is .NET 10 cross-platform (gRPC to an
external gateway) and is NOT stubbed.
- README: documents the final operator step — sign in, click "Deploy
current configuration" on /deployments to materialise the seeded
Galaxy driver into a running gRPC connection. Raw DriverInstance rows
don't spawn drivers on their own; the v2 lifecycle requires a sealed
Deployment first.
User chose to revert the MxAccess Gateway rebrand on the login card. Keep
the layout fix from c064ec1 (no panel-head top strip; inline h1.login-title)
and just put the original product name back.
Two small UX fixes:
- AuthEndpoints.LogoutAsync now redirects browser callers to /login after
SignOutAsync instead of returning 204 NoContent. 204 was correct for the
REST contract but left browsers stuck on the page they came from (the
cookie was cleared but no navigation happened, so "Sign out" appeared
to do nothing). API callers can still opt into the status-only behavior
by sending `Accept: application/json`.
- Login.razor drops the .panel-head top strip; the sign-in card now reads
as a self-contained form with an inline title "MxAccess Gateway Admin —
sign in". Added a .login-title CSS class to site.css that matches the
panel-head's typographic weight without the bar.
Two fixes surfaced while bringing up the docker-dev stack end-to-end:
- HealthEndpoints.MapOtOpcUaHealth now calls .AllowAnonymous() on /health/ready,
/health/active, /healthz. Without it the AddOtOpcUaAuth fallback policy 401s
every probe and Traefik marks every backend unhealthy → all three cluster
routes return 503.
- cluster-seed entrypoint no longer attempts to apply Migrate-To-V2.sql via
sqlcmd. The EF-generated idempotent script puts CREATE PROCEDURE inside
IF NOT EXISTS BEGIN ... END blocks (procs must be first in their batch),
so sqlcmd fails with "Must declare the scalar variable @FromGenerationId".
EF's own runner handles this; sqlcmd doesn't. The seed now just waits for
the schema and applies row inserts. Migrations remain the operator's job:
dotnet ef database update --project src/Core/.../Configuration \
--startup-project src/Server/.../Host
Also: LDAP service removed (bitnami/openldap:2.6 image retired, legacy tag
crashes mid-setup with exit 68); every host now runs with
Authentication__Ldap__DevStubMode=true. Bumped LDAP+Traefik dashboard host
ports to avoid collisions with the sister scadalink dev stack (3893→3894,
8080→8089).
Confirmed working end-to-end: all three Traefik routes return HTTP 200,
cluster-seed populates ServerCluster (MAIN/SITE-A/SITE-B) + ClusterNode
(driver-a/b, site-a-1/2, site-b-1/2) rows on first boot.
ScriptedAlarmActor (Runtime/ScriptedAlarms) shipped a while back — the
"Engine wiring (F9 ScriptedAlarmActor) is pending" stub message was
misleading. Also drop the matching "(F9)" / "(future)" parentheticals
in the intro panel and frame the empty state as a current-window
condition, not a missing feature.
Port the ScadaLink CentralUI sidebar pattern into the OtOpcUa AdminUI:
- Drop the top app-bar. Brand moves into the side rail's header — same
visual rhythm as ScadaLink's NavMenu.
- New NavSection.razor: collapsible eyebrow toggle (rail-eyebrow-toggle CSS)
with a chevron + label. Mirrors ScadaLink/Components/Layout/NavSection.
- New NavSidebar.razor: interactive island carrying the three section
groups (Navigation / Scripting / Live) + session block. Marked
@rendermode InteractiveServer; MainLayout itself stays static-rendered
because layouts can't take a RenderFragment Body across an interactive
boundary.
- New wwwroot/js/nav-state.js: window.navState.get/.set persists the
expanded-section list to the otopcua_nav cookie (one-year lifetime,
SameSite=Lax). Same shape as ScadaLink's scadabridge_nav.
- New LoginLayout.razor + @layout LoginLayout on Login.razor: the login
page now renders without the side rail — clean centred card.
- MainLayout.razor: slimmed down to the d-flex shell + hamburger toggle +
<NavSidebar/> + @Body.
- Login.razor: also drops the trailing "LDAP bind against the configured
directory..." footer that the user asked to remove.
- site.css: adds .side-rail .brand styles (mirrored from ScadaLink) and
the .rail-eyebrow-toggle / .rail-eyebrow-chevron / .rail-section-body
styles for the new collapsible UI.
Auto-expand on page load: NavSidebar seeds the expanded set from the
current URL's first path segment (in OnInitialized so it works even on
the very first server render) and from the cookie (in OnAfterRenderAsync
once JS interop is available). LocationChanged hooks keep the expanded
state in sync as the user navigates between sections.
Six interlocking fixes surfaced while smoke-testing the fused Host in a browser:
- Host/Program.cs: UseStaticWebAssets() opts into the RCL static-asset pipeline
in any environment (auto-only in Development), MapStaticAssets().AllowAnonymous()
exempts CSS/JS from the AddOtOpcUaAuth fallback policy, and
AddCascadingAuthenticationState() lets <AuthorizeView/> work inside interactive
components (NavSidebar's session block).
- Security/ServiceCollectionExtensions: ILdapAuthService Scoped → Singleton —
consumed by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
Crash only surfaced in Development (ValidateOnBuild=true).
- Security/Endpoints/AuthEndpoints: /auth/login now dispatches on Content-Type —
application/json keeps the original 204/401/503 contract for tests, and
application/x-www-form-urlencoded (the browser <form>) gets a redirect dance.
DisableAntiforgery on the login endpoint (it's the entry point, no prior session)
and AllowAnonymous to override the fallback policy.
- Security/Ldap/LdapOptions + LdapAuthService: real DevStubMode property; when
true the auth service bypasses the LDAP bind and returns a FleetAdmin role so
dev/test can navigate the full Admin UI without GLAuth running.
- AdminUI/EndpointRouteBuilderExtensions: doc-comment update about static-asset
flow (the actual MapStaticAssets call lives in Host/Program.cs).