Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 866dc03fac |
@@ -1,6 +1,6 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Client;
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Client;
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.MxGateway.Client;
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using System.Diagnostics.Metrics;
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.MxGateway.Client;
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.MxGateway.Client;
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.MxGateway.Client;
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Client;
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
// Use the generated nested status enum for the SetBufferedUpdateInterval reply check.
|
// Use the generated nested status enum for the SetBufferedUpdateInterval reply check.
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.MxGateway.Client;
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|
||||||
|
|||||||
@@ -18,15 +18,39 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Sibling mxaccessgw repo's .NET client + contracts. The sibling restored
|
<!-- Vendored mxaccessgw .NET client. Originally consumed via path-based
|
||||||
a proper client library under clients/dotnet/ (May 2026), so this is
|
ProjectReference to the sibling repo, but the sibling repo restructured
|
||||||
back on a path-based ProjectReference per the libs/README unwind plan #1.
|
and the MxGateway.Client.csproj path no longer exists. The DLLs in
|
||||||
Both projects target net10.0; the Contracts project transitively pulls
|
libs/ are the last known-good build (May 2026); they reference proto
|
||||||
Google.Protobuf + Grpc.Core.Api, the Client project transitively pulls
|
types from MxGateway.Contracts.dll using the pre-restructure namespace
|
||||||
Grpc.Net.Client + Polly.Core + Microsoft.Extensions.Logging.Abstractions,
|
(MxGateway.Contracts.Proto). See libs/README.md for the unwinding plan
|
||||||
so the explicit PackageReference shims that backfilled the vendored
|
once the sibling repo restores a client library or we migrate to the
|
||||||
binary references are no longer needed. -->
|
new ZB.MOM.WW.MxGateway.Contracts.Proto namespace. -->
|
||||||
<ProjectReference Include="..\..\..\..\mxaccessgw\clients\dotnet\ZB.MOM.WW.MxGateway.Client\ZB.MOM.WW.MxGateway.Client.csproj"/>
|
<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" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,101 @@
|
|||||||
|
# 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
|
@* 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. *@
|
"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="lib/bootstrap/css/bootstrap.min.css"/>
|
||||||
<link rel="stylesheet" href="theme.css"/>
|
<link rel="stylesheet" href="css/theme.css"/>
|
||||||
<link rel="stylesheet" href="app.css"/>
|
<link rel="stylesheet" href="css/site.css"/>
|
||||||
<HeadOutlet/>
|
<HeadOutlet/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -20,7 +20,20 @@
|
|||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="app-shell">
|
<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="collapse d-lg-block" id="sidebar-collapse">
|
||||||
<nav class="side-rail">
|
<nav class="side-rail">
|
||||||
<div class="rail-eyebrow">Navigation</div>
|
<div class="rail-eyebrow">Navigation</div>
|
||||||
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
|
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
|
||||||
@@ -56,6 +69,7 @@
|
|||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="page">
|
<main class="page">
|
||||||
@Body
|
@Body
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
@using ZB.MOM.WW.OtOpcUa.Admin.Security
|
@using ZB.MOM.WW.OtOpcUa.Admin.Security
|
||||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
|
||||||
<h1 class="page-title">My account</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">My account</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AuthorizeView>
|
<AuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
@rendermode RenderMode.InteractiveServer
|
@rendermode RenderMode.InteractiveServer
|
||||||
@inject HistorianDiagnosticsService Diag
|
@inject HistorianDiagnosticsService Diag
|
||||||
|
|
||||||
<h1 class="page-title">Alarm historian</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">Alarm historian</h4>
|
||||||
|
</div>
|
||||||
<p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p>
|
<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">
|
<section class="agg-grid rise" style="animation-delay:.02s">
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
@inject AuthenticationStateProvider AuthState
|
@inject AuthenticationStateProvider AuthState
|
||||||
@inject ILogger<Certificates> Log
|
@inject ILogger<Certificates> Log
|
||||||
|
|
||||||
<h1 class="page-title">Certificate trust</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">Certificate trust</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="panel notice rise" style="animation-delay:.02s">
|
<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.
|
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 class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title mb-0">@_cluster.Name</h1>
|
<h4 class="mb-0">@_cluster.Name</h4>
|
||||||
<span class="mono text-muted">@_cluster.ClusterId</span>
|
<span class="mono text-muted">@_cluster.ClusterId</span>
|
||||||
@if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> }
|
@if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> }
|
||||||
</div>
|
</div>
|
||||||
@@ -101,44 +101,29 @@ else
|
|||||||
{
|
{
|
||||||
<Generations ClusterId="@ClusterId"/>
|
<Generations ClusterId="@ClusterId"/>
|
||||||
}
|
}
|
||||||
else if (_tab is "equipment" or "uns" or "namespaces" or "drivers" or "tags" or "acls")
|
else if (_tab == "equipment" && _currentDraft is not null)
|
||||||
{
|
{
|
||||||
@* Bug #10 fix — these six tabs are scoped to a generation. Per docs/v2/admin-ui.md the
|
<EquipmentTab GenerationId="@_currentDraft.GenerationId"/>
|
||||||
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
|
else if (_tab == "uns" && _currentDraft is not null)
|
||||||
{
|
{
|
||||||
if (isReadOnly)
|
<UnsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||||
|
}
|
||||||
|
else if (_tab == "namespaces" && _currentDraft is not null)
|
||||||
{
|
{
|
||||||
<section class="panel notice rise mb-3" style="animation-delay:.02s">
|
<NamespacesTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||||
<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;">
|
else if (_tab == "drivers" && _currentDraft is not null)
|
||||||
@switch (_tab)
|
|
||||||
{
|
{
|
||||||
case "equipment": <EquipmentTab GenerationId="@genId.Value"/> break;
|
<DriversTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||||
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 == "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"/>
|
||||||
}
|
}
|
||||||
else if (_tab == "redundancy")
|
else if (_tab == "redundancy")
|
||||||
{
|
{
|
||||||
@@ -148,6 +133,10 @@ else
|
|||||||
{
|
{
|
||||||
<AuditTab ClusterId="@ClusterId"/>
|
<AuditTab ClusterId="@ClusterId"/>
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<section class="panel notice rise" style="animation-delay:.02s">Open a draft to edit this cluster's content.</section>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
@inject ClusterService ClusterSvc
|
@inject ClusterService ClusterSvc
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h1 class="page-title">Clusters</h1>
|
<h4 class="mb-0">Clusters</h4>
|
||||||
<a href="/clusters/new" class="btn btn-primary">New cluster</a>
|
<a href="/clusters/new" class="btn btn-primary">New cluster</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title mb-0">Draft diff</h1>
|
<h4 class="mb-0">Draft diff</h4>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Cluster <span class="mono">@ClusterId</span> — from last published (@(_fromLabel)) → to draft @GenerationId
|
Cluster <span class="mono">@ClusterId</span> — from last published (@(_fromLabel)) → to draft @GenerationId
|
||||||
</small>
|
</small>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title mb-0">Draft editor</h1>
|
<h4 class="mb-0">Draft editor</h4>
|
||||||
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · generation @GenerationId</small>
|
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · generation @GenerationId</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+31
-29
@@ -4,43 +4,45 @@
|
|||||||
nine decision #139 fields in a consistent 3-column Bootstrap grid. Used by EquipmentTab's
|
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. *@
|
create + edit forms so the same UI renders regardless of which flow opened it. *@
|
||||||
|
|
||||||
<div class="panel-head mt-4">OPC 40010 Identification</div>
|
<div class="card mb-3">
|
||||||
<div class="row g-3">
|
<div class="card-body">
|
||||||
<div class="col-md-4">
|
<h6 class="text-muted border-bottom pb-1">OPC 40010 Identification</h6>
|
||||||
<label class="form-label">Manufacturer</label>
|
<div class="mb-2">
|
||||||
<InputText @bind-Value="Equipment!.Manufacturer" class="form-control"/>
|
<label class="form-label small">Manufacturer</label>
|
||||||
|
<InputText @bind-Value="Equipment!.Manufacturer" class="form-control form-control-sm"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="mb-2">
|
||||||
<label class="form-label">Model</label>
|
<label class="form-label small">Model</label>
|
||||||
<InputText @bind-Value="Equipment!.Model" class="form-control"/>
|
<InputText @bind-Value="Equipment!.Model" class="form-control form-control-sm"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="mb-2">
|
||||||
<label class="form-label">Serial number</label>
|
<label class="form-label small">Serial number</label>
|
||||||
<InputText @bind-Value="Equipment!.SerialNumber" class="form-control"/>
|
<InputText @bind-Value="Equipment!.SerialNumber" class="form-control form-control-sm"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="mb-2">
|
||||||
<label class="form-label">Hardware rev</label>
|
<label class="form-label small">Hardware rev</label>
|
||||||
<InputText @bind-Value="Equipment!.HardwareRevision" class="form-control"/>
|
<InputText @bind-Value="Equipment!.HardwareRevision" class="form-control form-control-sm"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="mb-2">
|
||||||
<label class="form-label">Software rev</label>
|
<label class="form-label small">Software rev</label>
|
||||||
<InputText @bind-Value="Equipment!.SoftwareRevision" class="form-control"/>
|
<InputText @bind-Value="Equipment!.SoftwareRevision" class="form-control form-control-sm"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="mb-2">
|
||||||
<label class="form-label">Year of construction</label>
|
<label class="form-label small">Year of construction</label>
|
||||||
<InputNumber @bind-Value="Equipment!.YearOfConstruction" class="form-control"/>
|
<InputNumber @bind-Value="Equipment!.YearOfConstruction" class="form-control form-control-sm"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="mb-2">
|
||||||
<label class="form-label">Asset location</label>
|
<label class="form-label small">Asset location</label>
|
||||||
<InputText @bind-Value="Equipment!.AssetLocation" class="form-control"/>
|
<InputText @bind-Value="Equipment!.AssetLocation" class="form-control form-control-sm"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="mb-2">
|
||||||
<label class="form-label">Manufacturer URI</label>
|
<label class="form-label small">Manufacturer URI</label>
|
||||||
<InputText @bind-Value="Equipment!.ManufacturerUri" class="form-control" placeholder="https://…"/>
|
<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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title mb-0">Equipment CSV import</h1>
|
<h4 class="mb-0">Equipment CSV import</h4>
|
||||||
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · draft generation @GenerationId</small>
|
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · draft generation @GenerationId</small>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a>
|
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a>
|
||||||
|
|||||||
@@ -15,49 +15,58 @@
|
|||||||
@inject GenerationService GenerationSvc
|
@inject GenerationService GenerationSvc
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
<h1 class="page-title mb-4">New cluster</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">New cluster</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
<EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster">
|
<EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster">
|
||||||
<DataAnnotationsValidator/>
|
<DataAnnotationsValidator/>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="card mb-3">
|
||||||
<div class="col-md-6">
|
<div class="card-body">
|
||||||
<label class="form-label">ClusterId <span class="text-danger">*</span></label>
|
<h6 class="text-muted border-bottom pb-1">Identity</h6>
|
||||||
<InputText @bind-Value="_input.ClusterId" class="form-control"/>
|
<div class="mb-2">
|
||||||
<div class="form-text">Stable internal ID. Lowercase alphanumeric + hyphens; ≤ 64 chars.</div>
|
<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"/>
|
<ValidationMessage For="() => _input.ClusterId"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="mb-3">
|
||||||
<label class="form-label">Display name <span class="text-danger">*</span></label>
|
<label class="form-label small">Display name <span class="text-danger">*</span></label>
|
||||||
<InputText @bind-Value="_input.Name" class="form-control"/>
|
<InputText @bind-Value="_input.Name" class="form-control form-control-sm"/>
|
||||||
<ValidationMessage For="() => _input.Name"/>
|
<ValidationMessage For="() => _input.Name"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Enterprise</label>
|
<h6 class="text-muted border-bottom pb-1">Placement</h6>
|
||||||
<InputText @bind-Value="_input.Enterprise" class="form-control"/>
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Enterprise</label>
|
||||||
|
<InputText @bind-Value="_input.Enterprise" class="form-control form-control-sm"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="mb-3">
|
||||||
<label class="form-label">Site</label>
|
<label class="form-label small">Site</label>
|
||||||
<InputText @bind-Value="_input.Site" class="form-control"/>
|
<InputText @bind-Value="_input.Site" class="form-control form-control-sm"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label">Redundancy</label>
|
<h6 class="text-muted border-bottom pb-1">Topology</h6>
|
||||||
<InputSelect @bind-Value="_input.RedundancyMode" class="form-select">
|
<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.None">None (single node)</option>
|
||||||
<option value="@RedundancyMode.Warm">Warm (2 nodes)</option>
|
<option value="@RedundancyMode.Warm">Warm (2 nodes)</option>
|
||||||
<option value="@RedundancyMode.Hot">Hot (2 nodes)</option>
|
<option value="@RedundancyMode.Hot">Hot (2 nodes)</option>
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(_error))
|
@if (!string.IsNullOrEmpty(_error))
|
||||||
{
|
{
|
||||||
<section class="panel notice mt-3">@_error</section>
|
<div class="text-danger small mt-2">@_error</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-3">
|
||||||
<button type="submit" class="btn btn-primary" disabled="@_submitting">Create cluster</button>
|
<button type="submit" class="btn btn-success btn-sm me-1" disabled="@_submitting">Save</button>
|
||||||
<a href="/clusters" class="btn btn-secondary ms-2">Cancel</a>
|
<a href="/clusters" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
@inject FocasDriverDetailService DetailSvc
|
@inject FocasDriverDetailService DetailSvc
|
||||||
|
|
||||||
<h1 class="page-title">FOCAS driver <span class="mono">@InstanceId</span></h1>
|
<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>
|
||||||
|
|
||||||
@if (_loading)
|
@if (_loading)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
@inject IServiceScopeFactory ScopeFactory
|
@inject IServiceScopeFactory ScopeFactory
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<h1 class="page-title">Fleet status</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">Fleet status</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex align-items-center mb-3 gap-2">
|
<div class="d-flex align-items-center mb-3 gap-2">
|
||||||
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
@inject GenerationService GenerationSvc
|
@inject GenerationService GenerationSvc
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
<h1 class="page-title">Fleet overview</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">Fleet overview</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (_clusters is null)
|
@if (_clusters is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
@inject AdminHubConnectionFactory HubFactory
|
@inject AdminHubConnectionFactory HubFactory
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<h1 class="page-title">Driver host status</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">Driver host status</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex align-items-center mb-3 gap-2">
|
<div class="d-flex align-items-center mb-3 gap-2">
|
||||||
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
||||||
|
|||||||
+3
-1
@@ -17,7 +17,9 @@
|
|||||||
|
|
||||||
<PageTitle>Modbus address preview</PageTitle>
|
<PageTitle>Modbus address preview</PageTitle>
|
||||||
|
|
||||||
<h1 class="page-title">Modbus address preview</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">Modbus address preview</h4>
|
||||||
|
</div>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
Paste an address string and watch the parser break it down field by field. Useful for
|
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>.
|
sanity-checking a tag spreadsheet row before adding it to a driver's <span class="mono">DriverConfig</span>.
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
|
|
||||||
<PageTitle>Modbus diagnostics — @DriverInstanceId</PageTitle>
|
<PageTitle>Modbus diagnostics — @DriverInstanceId</PageTitle>
|
||||||
|
|
||||||
<h1 class="page-title">Modbus auto-prohibitions</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">Modbus auto-prohibitions</h4>
|
||||||
|
</div>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
Driver instance <span class="mono">@DriverInstanceId</span>. Live snapshot of coalesced ranges
|
Driver instance <span class="mono">@DriverInstanceId</span>. Live snapshot of coalesced ranges
|
||||||
the planner has learned to read individually (#148 / #150 / #151 / #152).
|
the planner has learned to read individually (#148 / #150 / #151 / #152).
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
@rendermode RenderMode.InteractiveServer
|
@rendermode RenderMode.InteractiveServer
|
||||||
@inject ReservationService ReservationSvc
|
@inject ReservationService ReservationSvc
|
||||||
|
|
||||||
<h1 class="page-title">External-ID reservations</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">External-ID reservations</h4>
|
||||||
|
</div>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
Fleet-wide ZTag + SAPID reservation state (decision #124). Releasing a reservation is a
|
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
|
FleetAdmin-only audit-logged action — only release when the physical asset is permanently
|
||||||
|
|||||||
@@ -15,9 +15,8 @@
|
|||||||
@inject AdminHubConnectionFactory HubFactory
|
@inject AdminHubConnectionFactory HubFactory
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<h1 class="page-title">LDAP group → Admin role grants</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">LDAP group → Admin role grants</h4>
|
||||||
<div class="d-flex justify-content-end mb-3">
|
|
||||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button>
|
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
@inject AdminHubConnectionFactory HubFactory
|
@inject AdminHubConnectionFactory HubFactory
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<h1 class="page-title">Script log viewer</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">Script log viewer</h4>
|
||||||
|
</div>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
Live tail of the <code class="mono">scripts-*.log</code> file produced by the OPC UA Server's
|
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.
|
Roslyn script runtime. Useful for diagnosing virtual-tag and scripted-alarm script errors in production.
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
@inject ClusterService ClusterSvc
|
@inject ClusterService ClusterSvc
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
<h1 class="page-title">Scripted Alarms</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">Scripted Alarms</h4>
|
||||||
|
</div>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
OPC UA Part 9 alarms raised by C# predicate scripts. To author scripted alarms, open a cluster
|
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.
|
draft and use the <strong>Scripted Alarms</strong> tab in the draft editor.
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
@inject ClusterService ClusterSvc
|
@inject ClusterService ClusterSvc
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
<h1 class="page-title">Virtual Tags</h1>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">Virtual Tags</h4>
|
||||||
|
</div>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
Computed tags driven by C# scripts. To author virtual tags, open a cluster draft and use the
|
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.
|
<strong>Virtual Tags</strong> tab in the draft editor.
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@* 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; } = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@* 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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
@*
|
||||||
|
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,4 +12,5 @@
|
|||||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
|
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
|
||||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages
|
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages
|
||||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Clusters
|
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Clusters
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Shared
|
||||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
|||||||
@@ -16,40 +16,12 @@ public sealed class ClusterNodeService(OtOpcUaConfigDbContext db)
|
|||||||
/// tolerance covers a missed heartbeat plus publisher GC pauses.</summary>
|
/// tolerance covers a missed heartbeat plus publisher GC pauses.</summary>
|
||||||
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
public async Task<List<ClusterNode>> ListByClusterAsync(string clusterId, CancellationToken ct)
|
public Task<List<ClusterNode>> ListByClusterAsync(string clusterId, CancellationToken ct) =>
|
||||||
{
|
db.ClusterNodes.AsNoTracking()
|
||||||
var nodes = await db.ClusterNodes.AsNoTracking()
|
|
||||||
.Where(n => n.ClusterId == clusterId)
|
.Where(n => n.ClusterId == clusterId)
|
||||||
.OrderByDescending(n => n.ServiceLevelBase)
|
.OrderByDescending(n => n.ServiceLevelBase)
|
||||||
.ThenBy(n => n.NodeId)
|
.ThenBy(n => n.NodeId)
|
||||||
.ToListAsync(ct).ConfigureAwait(false);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
// 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) =>
|
public static bool IsStale(ClusterNode node) =>
|
||||||
node.LastSeenAt is null || DateTime.UtcNow - node.LastSeenAt.Value > StaleThreshold;
|
node.LastSeenAt is null || DateTime.UtcNow - node.LastSeenAt.Value > StaleThreshold;
|
||||||
|
|||||||
+27
-11
@@ -2,8 +2,10 @@
|
|||||||
Tokens live in theme.css; this sheet only carries layout + the side rail. */
|
Tokens live in theme.css; this sheet only carries layout + the side rail. */
|
||||||
|
|
||||||
/* ── App shell: side rail + page ─────────────────────────────────────────── */
|
/* ── 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 {
|
.app-shell {
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
min-height: calc(100vh - 3.3rem);
|
min-height: calc(100vh - 3.3rem);
|
||||||
}
|
}
|
||||||
@@ -15,8 +17,8 @@
|
|||||||
|
|
||||||
/* ── Side rail ───────────────────────────────────────────────────────────── */
|
/* ── Side rail ───────────────────────────────────────────────────────────── */
|
||||||
.side-rail {
|
.side-rail {
|
||||||
width: 218px;
|
width: 220px;
|
||||||
flex: 0 0 218px;
|
flex: 0 0 220px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
@@ -25,6 +27,28 @@
|
|||||||
border-right: 1px solid var(--rule-strong);
|
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 {
|
.rail-eyebrow {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -90,14 +114,6 @@
|
|||||||
text-decoration: none;
|
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 card centring ─────────────────────────────────────────────────── */
|
||||||
.login-wrap {
|
.login-wrap {
|
||||||
max-width: 380px;
|
max-width: 380px;
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||||
@@ -43,20 +42,10 @@ public sealed class GenerationRefreshHostedService(
|
|||||||
RedundancyCoordinator coordinator,
|
RedundancyCoordinator coordinator,
|
||||||
ILogger<GenerationRefreshHostedService> logger,
|
ILogger<GenerationRefreshHostedService> logger,
|
||||||
TimeSpan? tickInterval = null,
|
TimeSpan? tickInterval = null,
|
||||||
Func<CancellationToken, Task<long?>>? currentGenerationQuery = null,
|
Func<CancellationToken, Task<long?>>? currentGenerationQuery = null) : BackgroundService
|
||||||
Func<long, NodeApplyStatus, string?, CancellationToken, Task>? registerAppliedAsync = null) : BackgroundService
|
|
||||||
{
|
{
|
||||||
private readonly Func<CancellationToken, Task<long?>> _generationQuery = currentGenerationQuery
|
private readonly Func<CancellationToken, Task<long?>> _generationQuery = currentGenerationQuery
|
||||||
?? new Func<CancellationToken, Task<long?>>(ct => DefaultQueryCurrentGenerationAsync(options, logger, ct));
|
?? 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>
|
/// <summary>
|
||||||
/// How often the service polls <c>sp_GetCurrentGenerationForCluster</c>. Default 5 s —
|
/// How often the service polls <c>sp_GetCurrentGenerationForCluster</c>. Default 5 s —
|
||||||
/// low enough that operator publishes take effect promptly, high enough that the
|
/// low enough that operator publishes take effect promptly, high enough that the
|
||||||
@@ -108,18 +97,6 @@ public sealed class GenerationRefreshHostedService(
|
|||||||
|
|
||||||
if (LastAppliedGenerationId is long last && current == last)
|
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
|
return; // no change
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,10 +109,6 @@ public sealed class GenerationRefreshHostedService(
|
|||||||
// lease is open. Publisher ticks in parallel (1s cadence) will observe the band
|
// lease is open. Publisher ticks in parallel (1s cadence) will observe the band
|
||||||
// transition and push it onto the OPC UA Server.ServiceLevel node.
|
// transition and push it onto the OPC UA Server.ServiceLevel node.
|
||||||
var publishRequestId = Guid.NewGuid();
|
var publishRequestId = Guid.NewGuid();
|
||||||
NodeApplyStatus applyStatus;
|
|
||||||
string? applyError = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using (leases.BeginApplyLease(current.Value, publishRequestId))
|
await using (leases.BeginApplyLease(current.Value, publishRequestId))
|
||||||
{
|
{
|
||||||
await coordinator.RefreshAsync(cancellationToken).ConfigureAwait(false);
|
await coordinator.RefreshAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -143,33 +116,7 @@ public sealed class GenerationRefreshHostedService(
|
|||||||
// scripted-alarm engine subscribe to. For now the topology refresh is the
|
// scripted-alarm engine subscribe to. For now the topology refresh is the
|
||||||
// only thing we rewire — everything else still requires a process restart.
|
// 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;
|
LastAppliedGenerationId = current;
|
||||||
RefreshCount++;
|
RefreshCount++;
|
||||||
}
|
}
|
||||||
@@ -210,35 +157,4 @@ public sealed class GenerationRefreshHostedService(
|
|||||||
return null;
|
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.Runtime.CompilerServices;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
|||||||
@@ -110,66 +110,6 @@ public sealed class GenerationRefreshHostedServiceTests : IDisposable
|
|||||||
leases.OpenLeaseCount.ShouldBe(0, "IAsyncDisposable dispose must fire regardless of outcome");
|
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 ---------------------------------------------------
|
// ---- fixture helpers ---------------------------------------------------
|
||||||
|
|
||||||
private async Task<RedundancyCoordinator> SeedCoordinatorAsync()
|
private async Task<RedundancyCoordinator> SeedCoordinatorAsync()
|
||||||
@@ -196,15 +136,11 @@ public sealed class GenerationRefreshHostedServiceTests : IDisposable
|
|||||||
private static GenerationRefreshHostedService NewService(
|
private static GenerationRefreshHostedService NewService(
|
||||||
RedundancyCoordinator coordinator,
|
RedundancyCoordinator coordinator,
|
||||||
ApplyLeaseRegistry leases,
|
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" },
|
new(new NodeOptions { NodeId = "A", ClusterId = "c1", ConfigDbConnectionString = "unused" },
|
||||||
leases, coordinator, NullLogger<GenerationRefreshHostedService>.Instance,
|
leases, coordinator, NullLogger<GenerationRefreshHostedService>.Instance,
|
||||||
tickInterval: TimeSpan.FromSeconds(1),
|
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)
|
private sealed class DbContextFactory(DbContextOptions<OtOpcUaConfigDbContext> options)
|
||||||
: IDbContextFactory<OtOpcUaConfigDbContext>
|
: IDbContextFactory<OtOpcUaConfigDbContext>
|
||||||
|
|||||||
Reference in New Issue
Block a user