Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f23e368a74 | |||
| c8de58d6d3 | |||
| 8fe7c8bea6 |
@@ -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;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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">
|
||||
☰
|
||||
</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>
|
||||
|
||||
+37
-39
@@ -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://…"/>
|
||||
</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://…"/>
|
||||
</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; ≤ 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">
|
||||
|
||||
+1
-3
@@ -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 → 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;
|
||||
|
||||
+11
-27
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user