3 Commits

Author SHA1 Message Date
Joseph Doherty f23e368a74 fix(server, admin): wire sp_RegisterNodeGenerationApplied + overlay heartbeat onto ClusterNode
dbo.sp_RegisterNodeGenerationApplied was defined by the initial
StoredProcedures migration but had zero callers in src/. The server
polled sp_GetCurrentGenerationForCluster every 5s but never reported
back, so dbo.ClusterNodeGenerationState stayed empty for every node
and both the Admin UI Fleet status page ("No node state recorded")
and the cluster-detail Redundancy LastSeenAt indicator ("never
STALE") showed broken liveness forever.

Server side (GenerationRefreshHostedService):
* New testable seam: Func<long, NodeApplyStatus, string?, CT, Task>?
  registerAppliedAsync constructor parameter, defaulting to a real
  sp_RegisterNodeGenerationApplied call against the central DB.
* TickAsync now calls the proc at two points: after every successful
  apply with NodeApplyStatus.Applied, and on every no-change tick as
  a heartbeat (also Applied) so LastSeenAt stays fresh.
* Apply failures now wrap the lease + coordinator.RefreshAsync in a
  try/catch, report NodeApplyStatus.Failed with the exception message,
  and advance LastAppliedGenerationId regardless of outcome so we
  don't loop on the same broken apply every 5s.
* Register-call failures are best-effort (LogDebug heartbeat, LogWarning
  apply-report) — a transient DB outage during reporting must not
  crash the publisher or block the next apply.

Admin side (ClusterNodeService.ListByClusterAsync): the Redundancy tab
reads ClusterNode.LastSeenAt, but no current writer maintains that
column — the heartbeat goes to ClusterNodeGenerationState.LastSeenAt.
Overlay the GenerationState heartbeat onto the returned ClusterNode
rows when more recent, so IsStale + the Redundancy table column
reflect actual liveness without a schema change or new write path.

Tests: 3 new cases on GenerationRefreshHostedServiceTests verify
first-apply reports Applied, no-change ticks heartbeat with Applied,
and register-call failure does not roll back the cursor or block
subsequent ticks. All 8 GenerationRefresh tests pass.

Verified live on node-dev-a / cluster-dev: dbo.ClusterNodeGenerationState
now populated with CurrentGenerationId=1, LastAppliedStatus=Applied,
fresh LastSeenAt. Fleet status page shows the node (KPIs NODES 1 /
APPLIED 1 / STALE 0 / FAILED 0). Redundancy tab KPI STALE went 1\xe2\x86\x920 and
the row shows a real LAST SEEN timestamp. Bonus: FleetStatusHub
SignalR push now fires the cluster-page Live update banner on every
heartbeat because there are finally state changes to push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:22:59 -04:00
Joseph Doherty c8de58d6d3 fix(admin-ui): render published gen read-only on the 6 cluster-detail content tabs
The Equipment, UNS Structure, Namespaces, Drivers, Tags, and ACLs tabs
all rendered only an "Open a draft to edit" placeholder when no draft
was open — even when the cluster had a fully populated published
generation. docs/v2/admin-ui.md \xa7Cluster Detail describes these as
"read-only views of the published generation" with an "Edit in draft"
affordance; that view was never wired. The earlier code path also
correctly rendered nothing when the cluster had no published gen yet,
which was indistinguishable from the broken state.

Collapse the six per-tab conditions into one shared branch that threads
the published gen ID into the existing tab components when no draft
exists, wrapped in <fieldset disabled> so any Add/Edit button click in
the read-only state cannot silently mutate published rows even though
the tab components themselves don't yet honor an IsReadOnly flag.
Banner above the content explains the state. Surgical: zero changes to
the ~1500 LoC across the six tab components.

Verified live on cluster-dev gen 1: Drivers tab now shows the
cluster-dev-galaxy-main GalaxyMxGateway row read-only; Namespaces tab
shows cluster-dev-galaxy-ns SystemPlatform row; both with the read-only
banner and visibly disabled affordances.

Follow-up worth doing later: refactor each tab component to accept an
IsReadOnly parameter so the disabled-affordance UX is per-tab rather
than a blanket fieldset opacity wash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:22:32 -04:00
Joseph Doherty 8fe7c8bea6 refactor(driver-galaxy): switch to sibling-repo MxGateway client + drop vendored libs
The sibling mxaccessgw repo (clients/dotnet/) restored a proper client
library + contracts under the new ZB.MOM.WW.MxGateway namespace, so the
binary-vendoring stopgap from PR Driver.Galaxy-016 can unwind via plan #1
of libs/README.md.

- csproj: replace <Reference HintPath="libs\MxGateway.*.dll"> with a
  ProjectReference into ..\..\..\..\mxaccessgw\clients\dotnet  ZB.MOM.WW.MxGateway.Client\. The five backfill PackageReference shims
  (Google.Protobuf, Grpc.Core.Api, Grpc.Net.Client, Polly.Core,
  Microsoft.Extensions.Logging.Abstractions) are now transitive again.
- Source: 'using MxGateway.X' -> 'using ZB.MOM.WW.MxGateway.X' across
  19 driver files + 14 test files. No fully-qualified MxGateway.* usages
  in code, so no behavioural changes — purely a using-prefix flip.
- libs/: deleted MxGateway.Client.dll, MxGateway.Contracts.dll, README.md
  (orphan after the unwind).

Verified: dotnet build clean (Release), all 245 Driver.Galaxy unit tests
pass, OtOpcUa service running with the new client DLL loaded
(opc.tcp://localhost:4840/OtOpcUa, no FileNotFound/TypeLoad/
MissingMethod in startup logs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:55:15 -04:00
69 changed files with 418 additions and 586 deletions
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,5 +1,5 @@
using MxGateway.Client;
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,5 +1,5 @@
using MxGateway.Client;
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
@@ -1,7 +1,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
@@ -2,7 +2,7 @@ using System.Diagnostics.Metrics;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Client;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,7 +1,7 @@
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,8 +1,8 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
// Use the generated nested status enum for the SetBufferedUpdateInterval reply check.
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using System.Runtime.CompilerServices;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -18,39 +18,15 @@
</ItemGroup>
<ItemGroup>
<!-- Vendored mxaccessgw .NET client. Originally consumed via path-based
ProjectReference to the sibling repo, but the sibling repo restructured
and the MxGateway.Client.csproj path no longer exists. The DLLs in
libs/ are the last known-good build (May 2026); they reference proto
types from MxGateway.Contracts.dll using the pre-restructure namespace
(MxGateway.Contracts.Proto). See libs/README.md for the unwinding plan
once the sibling repo restores a client library or we migrate to the
new ZB.MOM.WW.MxGateway.Contracts.Proto namespace. -->
<Reference Include="MxGateway.Client">
<HintPath>libs\MxGateway.Client.dll</HintPath>
<Private>true</Private>
</Reference>
<Reference Include="MxGateway.Contracts">
<HintPath>libs\MxGateway.Contracts.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<!-- Transitive deps the vendored MxGateway.Client.dll was actually built
against (verified by reflecting GetReferencedAssemblies on the DLL —
see libs/README.md). Versions align with the sibling mxaccessgw repo's
current Server / Worker projects so binary-compat stays close to what
the team uses elsewhere. Pre-Driver.Galaxy-016 the csproj declared
`Polly` (the v7 API) instead of `Polly.Core` (the v8 API the DLL was
built against) — a package-name mistake, not just a version skew —
which would surface as a runtime MissingMethodException the first
time the client's retry pipeline ran. -->
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
<PackageReference Include="Grpc.Core.Api" Version="2.76.0" />
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="Polly.Core" Version="8.6.6" />
<!-- Sibling mxaccessgw repo's .NET client + contracts. The sibling restored
a proper client library under clients/dotnet/ (May 2026), so this is
back on a path-based ProjectReference per the libs/README unwind plan #1.
Both projects target net10.0; the Contracts project transitively pulls
Google.Protobuf + Grpc.Core.Api, the Client project transitively pulls
Grpc.Net.Client + Polly.Core + Microsoft.Extensions.Logging.Abstractions,
so the explicit PackageReference shims that backfilled the vendored
binary references are no longer needed. -->
<ProjectReference Include="..\..\..\..\mxaccessgw\clients\dotnet\ZB.MOM.WW.MxGateway.Client\ZB.MOM.WW.MxGateway.Client.csproj"/>
</ItemGroup>
<ItemGroup>
@@ -1,101 +0,0 @@
# Vendored MxGateway client DLLs
This directory holds binary copies of `MxGateway.Client.dll` and
`MxGateway.Contracts.dll` from the sibling `mxaccessgw` repo's last known-good
build (May 2026). The DLLs are referenced from the driver's csproj as
`<Reference HintPath="…" />` items rather than `ProjectReference`.
## Provenance
Both DLLs are built from this team's own `mxaccessgw` source tree — they are
not third-party binaries. The build commit + checksums below are recorded so
future readers can verify the artefacts match the expected source without
needing to ask the original author.
| File | Source commit | SHA-256 |
|---|---|---|
| `MxGateway.Client.dll` | `dd7ca1634e2d2b8a866c81f0009bf87ee9427750` (mxaccessgw repo, pre-restructure) | `3507f770adc8c1b27b2fc4645079c6e4e02d5c65b9545c12d637cd2a080a00bd` |
| `MxGateway.Contracts.dll` | `dd7ca1634e2d2b8a866c81f0009bf87ee9427750` (mxaccessgw repo, pre-restructure) | `437dc6cb6994c7c4d858c82f69af890732c7ffbfa0463fbd8a63ce7930d251b4` |
The build commit is the same for both DLLs and is embedded as
`AssemblyInformationalVersion` inside each binary — re-verify by running:
`ilspycmd <dll> | grep AssemblyInformationalVersion`.
To re-verify the checksums (e.g. after a clone):
```bash
sha256sum libs/MxGateway.Client.dll libs/MxGateway.Contracts.dll
```
If either SHA-256 or the embedded source commit no longer matches what's
listed above, the artefact has been replaced — verify before trusting.
## Why vendored
The sibling `mxaccessgw` repo restructured: the `clients/dotnet/MxGateway.Client`
project the driver previously referenced via path-based `ProjectReference` no
longer exists, and the proto contracts moved from the `MxGateway.Contracts.Proto`
namespace to `ZB.MOM.WW.MxGateway.Contracts.Proto`. The driver's source still
expects the pre-restructure namespace, so re-pointing at the new contracts would
require a global namespace rename across ~19 driver files PLUS reimplementing
the `MxGatewayClient` / `MxGatewaySession` / `GalaxyRepositoryClient` types the
old client library provided (the sibling repo dropped the client library
entirely, keeping only the contracts).
Vendoring the binaries unblocked the build in minutes instead of hours, freezes
the gateway contract surface at a known-good version, and preserves the option
to migrate properly later without an emergency rewrite.
## What's vendored
| File | Built against |
|---|---|
| `MxGateway.Client.dll` | net10.0, references `MxGateway.Contracts.dll` |
| `MxGateway.Contracts.dll` | net10.0, proto namespace `MxGateway.Contracts.Proto[.Galaxy]` |
The NuGet packages the vendored DLLs reference (verified by reflecting
`Assembly.GetReferencedAssemblies()` against `MxGateway.Client.dll`) are
declared as direct `PackageReference` in the driver csproj — when the dropped
`ProjectReference` was in place those packages were transitively provided;
with binary references the consumer must declare them explicitly:
| Package | Reason |
|---|---|
| `Google.Protobuf` 3.34.1 | Proto message types in `MxGateway.Contracts.dll` |
| `Grpc.Core.Api` 2.76.0 | Base gRPC client types in `MxGateway.Client.dll` |
| `Grpc.Net.Client` 2.76.0 | HTTP/2 transport used by `MxGatewayClient` |
| `Microsoft.Extensions.Logging.Abstractions` 10.0.7 | `ILogger` used by the client |
| `Polly.Core` 8.6.6 | Retry pipeline used by `MxGatewayClient` |
Versions match the sibling mxaccessgw repo's current Server / Worker
projects (`ZB.MOM.WW.MxGateway.Server.csproj`,
`ZB.MOM.WW.MxGateway.Worker.csproj`) so the runtime versions stay close to
what the gateway team uses. The pre-Driver.Galaxy-016 declarations were
incorrect — most visibly `Polly 8.5.2` was declared where the DLL actually
needs `Polly.Core` (a different package: `Polly` v7 is the older fluent API;
`Polly.Core` v8 is the modern resilience-pipeline API the gateway client was
built against). A `Polly` reference would have failed at runtime with
`MissingMethodException` the first time a retry pipeline ran.
## Decompiled-source archive
The vendored DLLs are byte-for-byte the build output. The full source can be
recovered with `ilspycmd MxGateway.Client.dll > MxGateway.Client.cs` if a code
review or audit needs it.
## How to unwind
Either path closes the vendored-binary debt:
1. **Sibling repo restores `MxGateway.Client.csproj`** (or publishes a NuGet
package). Switch the csproj back to a `ProjectReference` / `PackageReference`,
delete this directory.
2. **Driver migrates to the new `ZB.MOM.WW.MxGateway.Contracts.Proto`
namespace.** Global namespace rename across the ~19 consuming source files,
plus re-implementing `MxGatewayClient` / `MxGatewaySession` /
`GalaxyRepositoryClient` (≈2,200 LoC of behavioural client code) either
inlined into this driver or as a fresh sibling library. Delete this
directory.
Either way: when unwinding, also drop the five `PackageReference` lines added
to the csproj alongside the `<Reference>` items — the new ProjectReference /
PackageReference will provide them transitively again.
@@ -9,8 +9,8 @@
@* Admin-010: Bootstrap 5 is vendored under wwwroot/lib/bootstrap/ per admin-ui.md
"Tech Stack" — no public-CDN dependency so air-gapped fleet deployments work. *@
<link rel="stylesheet" href="lib/bootstrap/css/bootstrap.min.css"/>
<link rel="stylesheet" href="css/theme.css"/>
<link rel="stylesheet" href="css/site.css"/>
<link rel="stylesheet" href="theme.css"/>
<link rel="stylesheet" href="app.css"/>
<HeadOutlet/>
</head>
<body>
@@ -20,56 +20,42 @@
</AuthorizeView>
</header>
<div class="app-shell d-flex flex-column flex-lg-row">
@* Hamburger toggle: visible only on viewports <lg.
Bootstrap collapse JS lives in bootstrap.bundle.min.js (loaded in App.razor). *@
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
type="button"
data-bs-toggle="collapse"
data-bs-target="#sidebar-collapse"
aria-controls="sidebar-collapse"
aria-expanded="false"
aria-label="Toggle navigation">
&#9776;
</button>
<div class="app-shell">
<nav class="side-rail">
<div class="rail-eyebrow">Navigation</div>
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
<NavLink class="rail-link" href="/fleet" Match="NavLinkMatch.Prefix">Fleet status</NavLink>
<NavLink class="rail-link" href="/hosts" Match="NavLinkMatch.Prefix">Host status</NavLink>
<NavLink class="rail-link" href="/clusters" Match="NavLinkMatch.Prefix">Clusters</NavLink>
<NavLink class="rail-link" href="/reservations" Match="NavLinkMatch.Prefix">Reservations</NavLink>
<NavLink class="rail-link" href="/certificates" Match="NavLinkMatch.Prefix">Certificates</NavLink>
<NavLink class="rail-link" href="/role-grants" Match="NavLinkMatch.Prefix">Role grants</NavLink>
<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="/script-log" Match="NavLinkMatch.Prefix">Script log</NavLink>
<div class="collapse d-lg-block" id="sidebar-collapse">
<nav class="side-rail">
<div class="rail-eyebrow">Navigation</div>
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
<NavLink class="rail-link" href="/fleet" Match="NavLinkMatch.Prefix">Fleet status</NavLink>
<NavLink class="rail-link" href="/hosts" Match="NavLinkMatch.Prefix">Host status</NavLink>
<NavLink class="rail-link" href="/clusters" Match="NavLinkMatch.Prefix">Clusters</NavLink>
<NavLink class="rail-link" href="/reservations" Match="NavLinkMatch.Prefix">Reservations</NavLink>
<NavLink class="rail-link" href="/certificates" Match="NavLinkMatch.Prefix">Certificates</NavLink>
<NavLink class="rail-link" href="/role-grants" Match="NavLinkMatch.Prefix">Role grants</NavLink>
<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="/script-log" Match="NavLinkMatch.Prefix">Script log</NavLink>
<div class="rail-foot">
<AuthorizeView>
<Authorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
<div class="rail-roles">
@string.Join(", ", context.User.Claims
.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
</div>
<form method="post" action="/auth/logout">
<AntiforgeryToken />
<button class="rail-btn" type="submit">Sign out</button>
</form>
</Authorized>
<NotAuthorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-btn" href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</div>
</nav>
</div>
<div class="rail-foot">
<AuthorizeView>
<Authorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
<div class="rail-roles">
@string.Join(", ", context.User.Claims
.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
</div>
<form method="post" action="/auth/logout">
<AntiforgeryToken />
<button class="rail-btn" type="submit">Sign out</button>
</form>
</Authorized>
<NotAuthorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-btn" href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</div>
</nav>
<main class="page">
@Body
@@ -4,9 +4,7 @@
@using ZB.MOM.WW.OtOpcUa.Admin.Security
@using ZB.MOM.WW.OtOpcUa.Admin.Services
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">My account</h4>
</div>
<h1 class="page-title">My account</h1>
<AuthorizeView>
<Authorized>
@@ -6,9 +6,7 @@
@rendermode RenderMode.InteractiveServer
@inject HistorianDiagnosticsService Diag
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Alarm historian</h4>
</div>
<h1 class="page-title">Alarm historian</h1>
<p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p>
<section class="agg-grid rise" style="animation-delay:.02s">
@@ -7,9 +7,7 @@
@inject AuthenticationStateProvider AuthState
@inject ILogger<Certificates> Log
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Certificate trust</h4>
</div>
<h1 class="page-title">Certificate trust</h1>
<section class="panel notice rise" style="animation-delay:.02s">
PKI store root <span class="mono">@Certs.PkiStoreRoot</span>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake.
@@ -44,7 +44,7 @@ else
}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0">@_cluster.Name</h4>
<h1 class="page-title mb-0">@_cluster.Name</h1>
<span class="mono text-muted">@_cluster.ClusterId</span>
@if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> }
</div>
@@ -101,29 +101,44 @@ else
{
<Generations ClusterId="@ClusterId"/>
}
else if (_tab == "equipment" && _currentDraft is not null)
else if (_tab is "equipment" or "uns" or "namespaces" or "drivers" or "tags" or "acls")
{
<EquipmentTab GenerationId="@_currentDraft.GenerationId"/>
}
else if (_tab == "uns" && _currentDraft is not null)
{
<UnsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
}
else if (_tab == "namespaces" && _currentDraft is not null)
{
<NamespacesTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
}
else if (_tab == "drivers" && _currentDraft is not null)
{
<DriversTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
}
else if (_tab == "tags" && _currentDraft is not null)
{
<TagsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
}
else if (_tab == "acls" && _currentDraft is not null)
{
<AclsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
@* Bug #10 fix — these six tabs are scoped to a generation. Per docs/v2/admin-ui.md the
design intent is a read-only view of the published generation when no draft is open
("Edit in draft" affordance), and the editable view of the draft when one is open.
The earlier implementation rendered nothing in the no-draft case, leaving operators
with just the "Open a draft to edit" placeholder. We now route both states through
the same tab components, gating edits via <fieldset disabled> so a button click in
the read-only state cannot silently mutate the published rows even though the tab
components themselves haven't been refactored to honor an IsReadOnly flag yet. *@
var genId = _currentDraft?.GenerationId ?? _currentPublished?.GenerationId;
var isReadOnly = _currentDraft is null;
if (genId is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
No published generation yet. Click <strong>New draft</strong> above to author this cluster's first generation.
</section>
}
else
{
if (isReadOnly)
{
<section class="panel notice rise mb-3" style="animation-delay:.02s">
<strong>Read-only view</strong> of published generation @genId. Click <strong>New draft</strong> above to make changes.
</section>
}
<fieldset disabled="@isReadOnly" style="border:0;padding:0;margin:0;min-width:0;">
@switch (_tab)
{
case "equipment": <EquipmentTab GenerationId="@genId.Value"/> break;
case "uns": <UnsTab GenerationId="@genId.Value" ClusterId="@ClusterId"/> break;
case "namespaces": <NamespacesTab GenerationId="@genId.Value" ClusterId="@ClusterId"/> break;
case "drivers": <DriversTab GenerationId="@genId.Value" ClusterId="@ClusterId"/> break;
case "tags": <TagsTab GenerationId="@genId.Value" ClusterId="@ClusterId"/> break;
case "acls": <AclsTab GenerationId="@genId.Value" ClusterId="@ClusterId"/> break;
}
</fieldset>
}
}
else if (_tab == "redundancy")
{
@@ -133,10 +148,6 @@ else
{
<AuditTab ClusterId="@ClusterId"/>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">Open a draft to edit this cluster's content.</section>
}
}
@code {
@@ -4,8 +4,8 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject ClusterService ClusterSvc
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Clusters</h4>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="page-title">Clusters</h1>
<a href="/clusters/new" class="btn btn-primary">New cluster</a>
</div>
@@ -16,7 +16,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0">Draft diff</h4>
<h1 class="page-title mb-0">Draft diff</h1>
<small class="text-muted">
Cluster <span class="mono">@ClusterId</span> — from last published (@(_fromLabel)) → to draft @GenerationId
</small>
@@ -20,7 +20,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0">Draft editor</h4>
<h1 class="page-title mb-0">Draft editor</h1>
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · generation @GenerationId</small>
</div>
<div>
@@ -4,45 +4,43 @@
nine decision #139 fields in a consistent 3-column Bootstrap grid. Used by EquipmentTab's
create + edit forms so the same UI renders regardless of which flow opened it. *@
<div class="card mb-3">
<div class="card-body">
<h6 class="text-muted border-bottom pb-1">OPC 40010 Identification</h6>
<div class="mb-2">
<label class="form-label small">Manufacturer</label>
<InputText @bind-Value="Equipment!.Manufacturer" class="form-control form-control-sm"/>
</div>
<div class="mb-2">
<label class="form-label small">Model</label>
<InputText @bind-Value="Equipment!.Model" class="form-control form-control-sm"/>
</div>
<div class="mb-2">
<label class="form-label small">Serial number</label>
<InputText @bind-Value="Equipment!.SerialNumber" class="form-control form-control-sm"/>
</div>
<div class="mb-2">
<label class="form-label small">Hardware rev</label>
<InputText @bind-Value="Equipment!.HardwareRevision" class="form-control form-control-sm"/>
</div>
<div class="mb-2">
<label class="form-label small">Software rev</label>
<InputText @bind-Value="Equipment!.SoftwareRevision" class="form-control form-control-sm"/>
</div>
<div class="mb-2">
<label class="form-label small">Year of construction</label>
<InputNumber @bind-Value="Equipment!.YearOfConstruction" class="form-control form-control-sm"/>
</div>
<div class="mb-2">
<label class="form-label small">Asset location</label>
<InputText @bind-Value="Equipment!.AssetLocation" class="form-control form-control-sm"/>
</div>
<div class="mb-2">
<label class="form-label small">Manufacturer URI</label>
<InputText @bind-Value="Equipment!.ManufacturerUri" class="form-control form-control-sm" placeholder="https://&hellip;"/>
</div>
<div class="mb-2">
<label class="form-label small">Device manual URI</label>
<InputText @bind-Value="Equipment!.DeviceManualUri" class="form-control form-control-sm" placeholder="https://&hellip;"/>
</div>
<div class="panel-head mt-4">OPC 40010 Identification</div>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Manufacturer</label>
<InputText @bind-Value="Equipment!.Manufacturer" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Model</label>
<InputText @bind-Value="Equipment!.Model" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Serial number</label>
<InputText @bind-Value="Equipment!.SerialNumber" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Hardware rev</label>
<InputText @bind-Value="Equipment!.HardwareRevision" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Software rev</label>
<InputText @bind-Value="Equipment!.SoftwareRevision" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Year of construction</label>
<InputNumber @bind-Value="Equipment!.YearOfConstruction" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Asset location</label>
<InputText @bind-Value="Equipment!.AssetLocation" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Manufacturer URI</label>
<InputText @bind-Value="Equipment!.ManufacturerUri" class="form-control" placeholder="https://…"/>
</div>
<div class="col-md-4">
<label class="form-label">Device manual URI</label>
<InputText @bind-Value="Equipment!.DeviceManualUri" class="form-control" placeholder="https://…"/>
</div>
</div>
@@ -23,7 +23,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0">Equipment CSV import</h4>
<h1 class="page-title mb-0">Equipment CSV import</h1>
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · draft generation @GenerationId</small>
</div>
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a>
@@ -15,58 +15,49 @@
@inject GenerationService GenerationSvc
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">New cluster</h4>
</div>
<h1 class="page-title mb-4">New cluster</h1>
<EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster">
<DataAnnotationsValidator/>
<div class="card mb-3">
<div class="card-body">
<h6 class="text-muted border-bottom pb-1">Identity</h6>
<div class="mb-2">
<label class="form-label small">ClusterId <span class="text-danger">*</span></label>
<InputText @bind-Value="_input.ClusterId" class="form-control form-control-sm"/>
<div class="form-text">Stable internal ID. Lowercase alphanumeric + hyphens; &le; 64 chars.</div>
<ValidationMessage For="() => _input.ClusterId"/>
</div>
<div class="mb-3">
<label class="form-label small">Display name <span class="text-danger">*</span></label>
<InputText @bind-Value="_input.Name" class="form-control form-control-sm"/>
<ValidationMessage For="() => _input.Name"/>
</div>
<h6 class="text-muted border-bottom pb-1">Placement</h6>
<div class="mb-2">
<label class="form-label small">Enterprise</label>
<InputText @bind-Value="_input.Enterprise" class="form-control form-control-sm"/>
</div>
<div class="mb-3">
<label class="form-label small">Site</label>
<InputText @bind-Value="_input.Site" class="form-control form-control-sm"/>
</div>
<h6 class="text-muted border-bottom pb-1">Topology</h6>
<div class="mb-2">
<label class="form-label small">Redundancy</label>
<InputSelect @bind-Value="_input.RedundancyMode" class="form-select form-select-sm">
<option value="@RedundancyMode.None">None (single node)</option>
<option value="@RedundancyMode.Warm">Warm (2 nodes)</option>
<option value="@RedundancyMode.Hot">Hot (2 nodes)</option>
</InputSelect>
</div>
@if (!string.IsNullOrEmpty(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
<div class="mt-3">
<button type="submit" class="btn btn-success btn-sm me-1" disabled="@_submitting">Save</button>
<a href="/clusters" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">ClusterId <span class="text-danger">*</span></label>
<InputText @bind-Value="_input.ClusterId" class="form-control"/>
<div class="form-text">Stable internal ID. Lowercase alphanumeric + hyphens; ≤ 64 chars.</div>
<ValidationMessage For="() => _input.ClusterId"/>
</div>
<div class="col-md-6">
<label class="form-label">Display name <span class="text-danger">*</span></label>
<InputText @bind-Value="_input.Name" class="form-control"/>
<ValidationMessage For="() => _input.Name"/>
</div>
<div class="col-md-4">
<label class="form-label">Enterprise</label>
<InputText @bind-Value="_input.Enterprise" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Site</label>
<InputText @bind-Value="_input.Site" class="form-control"/>
</div>
<div class="col-md-4">
<label class="form-label">Redundancy</label>
<InputSelect @bind-Value="_input.RedundancyMode" class="form-select">
<option value="@RedundancyMode.None">None (single node)</option>
<option value="@RedundancyMode.Warm">Warm (2 nodes)</option>
<option value="@RedundancyMode.Hot">Hot (2 nodes)</option>
</InputSelect>
</div>
</div>
@if (!string.IsNullOrEmpty(_error))
{
<section class="panel notice mt-3">@_error</section>
}
<div class="mt-4">
<button type="submit" class="btn btn-primary" disabled="@_submitting">Create cluster</button>
<a href="/clusters" class="btn btn-secondary ms-2">Cancel</a>
</div>
</EditForm>
@@ -3,9 +3,7 @@
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@inject FocasDriverDetailService DetailSvc
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">FOCAS driver <span class="mono">@InstanceId</span></h4>
</div>
<h1 class="page-title">FOCAS driver <span class="mono">@InstanceId</span></h1>
@if (_loading)
{
@@ -8,9 +8,7 @@
@inject IServiceScopeFactory ScopeFactory
@implements IDisposable
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Fleet status</h4>
</div>
<h1 class="page-title">Fleet status</h1>
<div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
@@ -8,9 +8,7 @@
@inject GenerationService GenerationSvc
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Fleet overview</h4>
</div>
<h1 class="page-title">Fleet overview</h1>
@if (_clusters is null)
{
@@ -12,9 +12,7 @@
@inject AdminHubConnectionFactory HubFactory
@implements IAsyncDisposable
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Driver host status</h4>
</div>
<h1 class="page-title">Driver host status</h1>
<div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
@@ -17,9 +17,7 @@
<PageTitle>Modbus address preview</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Modbus address preview</h4>
</div>
<h1 class="page-title">Modbus address preview</h1>
<p class="text-muted">
Paste an address string and watch the parser break it down field by field. Useful for
sanity-checking a tag spreadsheet row before adding it to a driver's <span class="mono">DriverConfig</span>.
@@ -13,9 +13,7 @@
<PageTitle>Modbus diagnostics — @DriverInstanceId</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Modbus auto-prohibitions</h4>
</div>
<h1 class="page-title">Modbus auto-prohibitions</h1>
<p class="text-muted">
Driver instance <span class="mono">@DriverInstanceId</span>. Live snapshot of coalesced ranges
the planner has learned to read individually (#148 / #150 / #151 / #152).
@@ -9,9 +9,7 @@
@rendermode RenderMode.InteractiveServer
@inject ReservationService ReservationSvc
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">External-ID reservations</h4>
</div>
<h1 class="page-title">External-ID reservations</h1>
<p class="text-muted">
Fleet-wide ZTag + SAPID reservation state (decision #124). Releasing a reservation is a
FleetAdmin-only audit-logged action — only release when the physical asset is permanently
@@ -15,8 +15,9 @@
@inject AdminHubConnectionFactory HubFactory
@implements IAsyncDisposable
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">LDAP group &rarr; Admin role grants</h4>
<h1 class="page-title">LDAP group → Admin role grants</h1>
<div class="d-flex justify-content-end mb-3">
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button>
</div>
@@ -8,9 +8,7 @@
@inject AdminHubConnectionFactory HubFactory
@implements IAsyncDisposable
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Script log viewer</h4>
</div>
<h1 class="page-title">Script log viewer</h1>
<p class="text-muted">
Live tail of the <code class="mono">scripts-*.log</code> file produced by the OPC UA Server's
Roslyn script runtime. Useful for diagnosing virtual-tag and scripted-alarm script errors in production.
@@ -10,9 +10,7 @@
@inject ClusterService ClusterSvc
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Scripted Alarms</h4>
</div>
<h1 class="page-title">Scripted Alarms</h1>
<p class="text-muted">
OPC UA Part 9 alarms raised by C# predicate scripts. To author scripted alarms, open a cluster
draft and use the <strong>Scripted Alarms</strong> tab in the draft editor.
@@ -9,9 +9,7 @@
@inject ClusterService ClusterSvc
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Virtual Tags</h4>
</div>
<h1 class="page-title">Virtual Tags</h1>
<p class="text-muted">
Computed tags driven by C# scripts. To author virtual tags, open a cluster draft and use the
<strong>Virtual Tags</strong> tab in the draft editor.
@@ -1,17 +0,0 @@
@* Reusable loading spinner *@
@if (IsLoading)
{
<div class="d-flex align-items-center text-secondary @CssClass">
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<span>@Message</span>
</div>
}
@code {
[Parameter] public bool IsLoading { get; set; }
[Parameter] public string Message { get; set; } = "Loading...";
[Parameter] public string CssClass { get; set; } = "";
}
@@ -1,7 +0,0 @@
@* Status chip — wraps the theme.css .chip / .chip-ok / .chip-warn / .chip-bad / .chip-idle classes. *@
<span class="chip @CssClass">@Text</span>
@code {
[Parameter] public string Text { get; set; } = "";
[Parameter] public string CssClass { get; set; } = "chip-idle";
}
@@ -1,139 +0,0 @@
@*
Reusable toast notification component.
Toasts intentionally float above modal dialogs so confirmation feedback
(Success/Error) is visible even while a dialog is open.
*@
@implements IDisposable
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1090;" aria-live="polite" aria-atomic="true">
@foreach (var toast in _toasts)
{
<div class="toast show mb-2" role="alert">
<div class="toast-header @GetHeaderClass(toast.Type)">
<strong class="me-auto">@toast.Title</strong>
<button type="button" class="btn-close btn-close-white" @onclick="() => Dismiss(toast)"></button>
</div>
<div class="toast-body">@toast.Message</div>
</div>
}
</div>
@code {
private const int DefaultAutoDismissMs = 5000;
private readonly List<ToastItem> _toasts = new();
private readonly object _lock = new();
// Cancels all pending auto-dismiss delays when the component is disposed
// so their continuations never touch a disposed component.
private readonly CancellationTokenSource _disposalCts = new();
/// <summary>Number of toasts currently displayed.</summary>
public int ToastCount
{
get { lock (_lock) { return _toasts.Count; } }
}
public void ShowSuccess(string message, string title = "Success", int? autoDismissMs = null)
{
AddToast(title, message, ToastType.Success, autoDismissMs);
}
public void ShowError(string message, string title = "Error", int? autoDismissMs = null)
{
AddToast(title, message, ToastType.Error, autoDismissMs);
}
public void ShowWarning(string message, string title = "Warning", int? autoDismissMs = null)
{
AddToast(title, message, ToastType.Warning, autoDismissMs);
}
public void ShowInfo(string message, string title = "Info", int? autoDismissMs = null)
{
AddToast(title, message, ToastType.Info, autoDismissMs);
}
private void AddToast(string title, string message, ToastType type, int? autoDismissMs)
{
// If the component is already disposed, do not add or schedule anything.
if (_disposalCts.IsCancellationRequested) return;
var toast = new ToastItem { Title = title, Message = message, Type = type };
lock (_lock)
{
_toasts.Add(toast);
}
StateHasChanged();
var dismissMs = autoDismissMs ?? DefaultAutoDismissMs;
_ = AutoDismissAsync(toast, dismissMs, _disposalCts.Token);
}
/// <summary>
/// Removes a toast after its dismiss delay. The delay is bound to the
/// component's disposal token: if the host page is disposed first, the
/// delay is cancelled and the continuation never touches the disposed
/// component — no <see cref="ObjectDisposedException"/> escapes.
/// </summary>
private async Task AutoDismissAsync(ToastItem toast, int dismissMs, CancellationToken token)
{
try
{
await Task.Delay(dismissMs, token);
}
catch (OperationCanceledException)
{
return;
}
if (token.IsCancellationRequested) return;
lock (_lock)
{
_toasts.Remove(toast);
}
try
{
await InvokeAsync(StateHasChanged);
}
catch (ObjectDisposedException)
{
// Component disposed between the token check and the render — ignore.
}
}
private void Dismiss(ToastItem toast)
{
lock (_lock)
{
_toasts.Remove(toast);
}
}
private static string GetHeaderClass(ToastType type) => type switch
{
ToastType.Success => "bg-success text-white",
ToastType.Error => "bg-danger text-white",
ToastType.Warning => "bg-warning text-dark",
ToastType.Info => "bg-info text-dark",
_ => "bg-secondary text-white"
};
public void Dispose()
{
_disposalCts.Cancel();
_disposalCts.Dispose();
}
private enum ToastType { Success, Error, Warning, Info }
private class ToastItem
{
public string Title { get; init; } = "";
public string Message { get; init; } = "";
public ToastType Type { get; init; }
}
}
@@ -12,5 +12,4 @@
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Clusters
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Shared
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@@ -16,12 +16,40 @@ public sealed class ClusterNodeService(OtOpcUaConfigDbContext db)
/// tolerance covers a missed heartbeat plus publisher GC pauses.</summary>
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
public Task<List<ClusterNode>> ListByClusterAsync(string clusterId, CancellationToken ct) =>
db.ClusterNodes.AsNoTracking()
public async Task<List<ClusterNode>> ListByClusterAsync(string clusterId, CancellationToken ct)
{
var nodes = await db.ClusterNodes.AsNoTracking()
.Where(n => n.ClusterId == clusterId)
.OrderByDescending(n => n.ServiceLevelBase)
.ThenBy(n => n.NodeId)
.ToListAsync(ct);
.ToListAsync(ct).ConfigureAwait(false);
// Bug #12 fix follow-up — the live-node heartbeat lands on
// ClusterNodeGenerationState.LastSeenAt (written by sp_RegisterNodeGenerationApplied
// on every generation poll). The ClusterNode.LastSeenAt column is a legacy slot that
// no current writer maintains, so reading it directly would show "never STALE"
// forever for every running node. Overlay the GenerationState heartbeat onto the
// returned ClusterNode rows when it's more recent so the Redundancy tab + IsStale
// predicate reflect actual liveness without needing a new write path or schema change.
var nodeIds = nodes.Select(n => n.NodeId).ToList();
if (nodeIds.Count > 0)
{
var heartbeats = await db.ClusterNodeGenerationStates.AsNoTracking()
.Where(s => nodeIds.Contains(s.NodeId))
.Select(s => new { s.NodeId, s.LastSeenAt })
.ToListAsync(ct).ConfigureAwait(false);
var beatByNode = heartbeats.ToDictionary(s => s.NodeId, s => s.LastSeenAt);
foreach (var n in nodes)
{
if (beatByNode.TryGetValue(n.NodeId, out var hb) && hb is not null
&& (n.LastSeenAt is null || hb > n.LastSeenAt))
{
n.LastSeenAt = hb;
}
}
}
return nodes;
}
public static bool IsStale(ClusterNode node) =>
node.LastSeenAt is null || DateTime.UtcNow - node.LastSeenAt.Value > StaleThreshold;
@@ -2,10 +2,8 @@
Tokens live in theme.css; this sheet only carries layout + the side rail. */
/* ── App shell: side rail + page ─────────────────────────────────────────── */
/* The outer flex direction is supplied by Bootstrap utilities on the wrapper
(`d-flex flex-column flex-lg-row`) so the mobile hamburger row stacks above
the rail on <lg viewports and the rail sits beside the page on lg+. */
.app-shell {
display: flex;
align-items: stretch;
min-height: calc(100vh - 3.3rem);
}
@@ -17,8 +15,8 @@
/* ── Side rail ───────────────────────────────────────────────────────────── */
.side-rail {
width: 220px;
flex: 0 0 220px;
width: 218px;
flex: 0 0 218px;
display: flex;
flex-direction: column;
gap: 0.15rem;
@@ -27,28 +25,6 @@
border-right: 1px solid var(--rule-strong);
}
/* On lg+ keep the side rail pinned so it stays visible when content scrolls. */
@media (min-width: 992px) {
#sidebar-collapse {
position: sticky;
top: 0;
height: 100vh;
align-self: flex-start;
z-index: 1020;
}
}
/* When the side rail is collapsed under <lg viewports the Bootstrap collapse
container removes the fixed width; restore full width on mobile. */
@media (max-width: 991.98px) {
.side-rail {
width: 100%;
min-width: 100%;
max-width: 100%;
height: auto;
}
}
.rail-eyebrow {
font-size: 0.68rem;
font-weight: 600;
@@ -114,6 +90,14 @@
text-decoration: none;
}
/* ── Page headings — uppercase eyebrow, calm spacing ─────────────────────── */
.page-title {
font-size: 1.15rem;
font-weight: 600;
margin: 0 0 1rem;
color: var(--ink);
}
/* ── Login card centring ─────────────────────────────────────────────────── */
.login-wrap {
max-width: 380px;
@@ -1,6 +1,7 @@
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
@@ -42,10 +43,20 @@ public sealed class GenerationRefreshHostedService(
RedundancyCoordinator coordinator,
ILogger<GenerationRefreshHostedService> logger,
TimeSpan? tickInterval = null,
Func<CancellationToken, Task<long?>>? currentGenerationQuery = null) : BackgroundService
Func<CancellationToken, Task<long?>>? currentGenerationQuery = null,
Func<long, NodeApplyStatus, string?, CancellationToken, Task>? registerAppliedAsync = null) : BackgroundService
{
private readonly Func<CancellationToken, Task<long?>> _generationQuery = currentGenerationQuery
?? new Func<CancellationToken, Task<long?>>(ct => DefaultQueryCurrentGenerationAsync(options, logger, ct));
// Bug #12 fix — the server now reports applied-generation state + heartbeat back to the
// central DB via sp_RegisterNodeGenerationApplied. Before this wiring the proc had zero
// callers, so dbo.ClusterNodeGenerationState stayed empty for every node and the Admin UI
// Fleet status page + cluster-detail Redundancy LastSeenAt both showed "no node state /
// never STALE" indefinitely. Tests inject a stub via the registerAppliedAsync parameter.
private readonly Func<long, NodeApplyStatus, string?, CancellationToken, Task> _registerApplied = registerAppliedAsync
?? new Func<long, NodeApplyStatus, string?, CancellationToken, Task>(
(gen, status, err, ct) => DefaultRegisterAppliedAsync(options, logger, gen, status, err, ct));
/// <summary>
/// How often the service polls <c>sp_GetCurrentGenerationForCluster</c>. Default 5 s —
/// low enough that operator publishes take effect promptly, high enough that the
@@ -97,6 +108,18 @@ public sealed class GenerationRefreshHostedService(
if (LastAppliedGenerationId is long last && current == last)
{
// Heartbeat — re-stamps LastSeenAt on dbo.ClusterNodeGenerationState so the Admin
// Fleet status page + cluster Redundancy tab can detect the node is alive without
// a generation change. Best-effort: a transient DB error here must not throw out of
// the tick (the next tick will retry) and must not block applies.
try
{
await _registerApplied(current.Value, NodeApplyStatus.Applied, null, cancellationToken).ConfigureAwait(false);
}
catch (Exception hbEx) when (hbEx is not OperationCanceledException)
{
logger.LogDebug(hbEx, "Heartbeat to sp_RegisterNodeGenerationApplied failed; will retry next tick");
}
return; // no change
}
@@ -109,14 +132,44 @@ public sealed class GenerationRefreshHostedService(
// lease is open. Publisher ticks in parallel (1s cadence) will observe the band
// transition and push it onto the OPC UA Server.ServiceLevel node.
var publishRequestId = Guid.NewGuid();
await using (leases.BeginApplyLease(current.Value, publishRequestId))
NodeApplyStatus applyStatus;
string? applyError = null;
try
{
await coordinator.RefreshAsync(cancellationToken).ConfigureAwait(false);
// Future: fire a domain event that driver hosts / virtual-tag engine /
// scripted-alarm engine subscribe to. For now the topology refresh is the
// only thing we rewire — everything else still requires a process restart.
await using (leases.BeginApplyLease(current.Value, publishRequestId))
{
await coordinator.RefreshAsync(cancellationToken).ConfigureAwait(false);
// Future: fire a domain event that driver hosts / virtual-tag engine /
// scripted-alarm engine subscribe to. For now the topology refresh is the
// only thing we rewire — everything else still requires a process restart.
}
applyStatus = NodeApplyStatus.Applied;
}
catch (Exception applyEx) when (applyEx is not OperationCanceledException)
{
applyStatus = NodeApplyStatus.Failed;
applyError = applyEx.Message;
logger.LogError(applyEx, "Apply of generation {Generation} failed; will report Failed status to central DB", current);
// fall through to register so operators see the failed apply in /fleet
}
// Always tell the central DB what happened with this apply attempt — success or
// failure. The proc upserts dbo.ClusterNodeGenerationState (CurrentGenerationId +
// LastAppliedAt + LastAppliedStatus + LastAppliedError + LastSeenAt). Failure here
// mustn't prevent us from advancing LastAppliedGenerationId — the apply already
// happened (or already failed); the publish is purely observability.
try
{
await _registerApplied(current.Value, applyStatus, applyError, cancellationToken).ConfigureAwait(false);
}
catch (Exception regEx) when (regEx is not OperationCanceledException)
{
logger.LogWarning(regEx, "sp_RegisterNodeGenerationApplied call failed for gen {Generation} status {Status}", current, applyStatus);
}
// Advance the cursor even on Failed — the proc has been told; next tick will heartbeat
// and a future generation will trigger a fresh apply attempt. Pinning the cursor on
// failure would loop us through the same broken apply every 5s.
LastAppliedGenerationId = current;
RefreshCount++;
}
@@ -157,4 +210,35 @@ public sealed class GenerationRefreshHostedService(
return null;
}
}
/// <summary>
/// Default register-applied implementation — calls <c>sp_RegisterNodeGenerationApplied</c>
/// to MERGE-upsert <see cref="ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState"/>
/// for this node. Called both at apply completion (success or failure) and on every
/// no-change heartbeat tick so <c>LastSeenAt</c> stays fresh in the central DB and the
/// Admin UI Fleet status page + Redundancy LastSeenAt indicator can detect a healthy node.
/// Bug #12 fix — wires the previously-orphaned proc into the apply loop.
/// </summary>
private static async Task DefaultRegisterAppliedAsync(
NodeOptions options,
ILogger logger,
long generationId,
NodeApplyStatus status,
string? error,
CancellationToken cancellationToken)
{
await using var conn = new SqlConnection(options.ConfigDbConnectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "EXEC dbo.sp_RegisterNodeGenerationApplied @NodeId=@n, @GenerationId=@g, @Status=@s, @Error=@e";
cmd.Parameters.AddWithValue("@n", options.NodeId);
cmd.Parameters.AddWithValue("@g", generationId);
cmd.Parameters.AddWithValue("@s", status.ToString());
cmd.Parameters.AddWithValue("@e", (object?)error ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
// Single-line trace so soak runs can see heartbeat ticks without flooding at Info.
logger.LogTrace("Reported gen {Generation} status {Status} to central DB", generationId, status);
}
}
@@ -1,7 +1,7 @@
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,6 +1,6 @@
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,7 +1,7 @@
using System.Diagnostics.Metrics;
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,6 +1,6 @@
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,6 +1,6 @@
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,5 +1,5 @@
using System.Diagnostics;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,6 +1,6 @@
using System.Runtime.CompilerServices;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -1,6 +1,6 @@
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,5 +1,5 @@
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -1,4 +1,4 @@
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
@@ -110,6 +110,66 @@ public sealed class GenerationRefreshHostedServiceTests : IDisposable
leases.OpenLeaseCount.ShouldBe(0, "IAsyncDisposable dispose must fire regardless of outcome");
}
// Bug #12 fix — verifies the previously-missing wiring: applies and heartbeats both
// emit sp_RegisterNodeGenerationApplied so Admin UI Fleet status + Redundancy LastSeenAt
// surface live state.
[Fact]
public async Task First_apply_reports_Applied_status_to_central_db()
{
var coordinator = await SeedCoordinatorAsync();
var leases = new ApplyLeaseRegistry();
var calls = new List<(long Gen, NodeApplyStatus Status, string? Error)>();
var service = NewService(coordinator, leases, currentGeneration: () => 42, registerCalls: calls);
await service.TickAsync(CancellationToken.None);
calls.Count.ShouldBe(1, "exactly one register call per apply window");
calls[0].Gen.ShouldBe(42);
calls[0].Status.ShouldBe(NodeApplyStatus.Applied);
calls[0].Error.ShouldBeNull();
}
[Fact]
public async Task No_change_tick_heartbeats_with_Applied_status()
{
var coordinator = await SeedCoordinatorAsync();
var leases = new ApplyLeaseRegistry();
var calls = new List<(long Gen, NodeApplyStatus Status, string? Error)>();
var service = NewService(coordinator, leases, currentGeneration: () => 42, registerCalls: calls);
await service.TickAsync(CancellationToken.None); // initial apply
await service.TickAsync(CancellationToken.None); // no-change heartbeat
await service.TickAsync(CancellationToken.None); // no-change heartbeat
calls.Count.ShouldBe(3, "one apply call + two heartbeat calls");
calls.ShouldAllBe(c => c.Gen == 42 && c.Status == NodeApplyStatus.Applied && c.Error == null);
}
[Fact]
public async Task Register_call_failure_does_not_break_apply_or_block_subsequent_ticks()
{
var coordinator = await SeedCoordinatorAsync();
var leases = new ApplyLeaseRegistry();
var registerCallCount = 0;
var service = new GenerationRefreshHostedService(
new NodeOptions { NodeId = "A", ClusterId = "c1", ConfigDbConnectionString = "unused" },
leases, coordinator, NullLogger<GenerationRefreshHostedService>.Instance,
tickInterval: TimeSpan.FromSeconds(1),
currentGenerationQuery: _ => Task.FromResult<long?>(42),
registerAppliedAsync: (gen, status, err, ct) =>
{
registerCallCount++;
throw new InvalidOperationException("simulated DB outage during register");
});
await service.TickAsync(CancellationToken.None); // apply succeeds, register throws
await service.TickAsync(CancellationToken.None); // heartbeat throws
registerCallCount.ShouldBe(2, "both register attempts must run");
service.LastAppliedGenerationId.ShouldBe(42, "register failure must not roll back the cursor");
}
// ---- fixture helpers ---------------------------------------------------
private async Task<RedundancyCoordinator> SeedCoordinatorAsync()
@@ -136,11 +196,15 @@ public sealed class GenerationRefreshHostedServiceTests : IDisposable
private static GenerationRefreshHostedService NewService(
RedundancyCoordinator coordinator,
ApplyLeaseRegistry leases,
Func<long?> currentGeneration) =>
Func<long?> currentGeneration,
List<(long Gen, NodeApplyStatus Status, string? Error)>? registerCalls = null) =>
new(new NodeOptions { NodeId = "A", ClusterId = "c1", ConfigDbConnectionString = "unused" },
leases, coordinator, NullLogger<GenerationRefreshHostedService>.Instance,
tickInterval: TimeSpan.FromSeconds(1),
currentGenerationQuery: _ => Task.FromResult(currentGeneration()));
currentGenerationQuery: _ => Task.FromResult(currentGeneration()),
registerAppliedAsync: registerCalls is null
? (_, _, _, _) => Task.CompletedTask
: (gen, status, err, _) => { registerCalls.Add((gen, status, err)); return Task.CompletedTask; });
private sealed class DbContextFactory(DbContextOptions<OtOpcUaConfigDbContext> options)
: IDbContextFactory<OtOpcUaConfigDbContext>