chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
18
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor
Normal file
18
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor
Normal file
@@ -0,0 +1,18 @@
|
||||
@* Root Blazor component. *@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>OtOpcUa Admin</title>
|
||||
<base href="/"/>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
|
||||
<link rel="stylesheet" href="app.css"/>
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
<body>
|
||||
<Routes/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="d-flex" style="min-height: 100vh;">
|
||||
<nav class="bg-dark text-light p-3" style="width: 220px;">
|
||||
<h5 class="mb-4">OtOpcUa Admin</h5>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/fleet">Fleet status</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/hosts">Host status</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/role-grants">Role grants</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="small text-light">
|
||||
Signed in as <a class="text-light" href="/account"><strong>@context.User.Identity?.Name</strong></a>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
||||
</div>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button class="btn btn-sm btn-outline-light mt-2" type="submit">Sign out</button>
|
||||
</form>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<a class="btn btn-sm btn-outline-light" href="/login">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="flex-grow-1 p-4">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,129 @@
|
||||
@page "/account"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@using System.Security.Claims
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
|
||||
<h1 class="mb-4">My account</h1>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@{
|
||||
var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "—";
|
||||
var displayName = context.User.Identity?.Name ?? "—";
|
||||
var roles = context.User.Claims
|
||||
.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList();
|
||||
var ldapGroups = context.User.Claims
|
||||
.Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList();
|
||||
}
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Identity</h5>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">Username</dt><dd class="col-sm-8"><code>@username</code></dd>
|
||||
<dt class="col-sm-4">Display name</dt><dd class="col-sm-8">@displayName</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Admin roles</h5>
|
||||
@if (roles.Count == 0)
|
||||
{
|
||||
<p class="text-muted mb-0">No Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-2">
|
||||
@foreach (var r in roles)
|
||||
{
|
||||
<span class="badge bg-primary me-1">@r</span>
|
||||
}
|
||||
</div>
|
||||
<small class="text-muted">LDAP groups: @(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Capabilities</h5>
|
||||
<p class="text-muted small">
|
||||
Each Admin role grants a fixed capability set per <code>admin-ui.md</code> §Admin Roles.
|
||||
Pages below reflect what this session can access; the route's <code>[Authorize]</code> guard
|
||||
is the ground truth — this table mirrors it for readability.
|
||||
</p>
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Capability</th>
|
||||
<th>Required role(s)</th>
|
||||
<th class="text-end">You have it?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var cap in Capabilities)
|
||||
{
|
||||
var has = cap.RequiredRoles.Any(r => roles.Contains(r, StringComparer.OrdinalIgnoreCase));
|
||||
<tr>
|
||||
<td>@cap.Name<br /><small class="text-muted">@cap.Description</small></td>
|
||||
<td>@string.Join(" or ", cap.RequiredRoles)</td>
|
||||
<td class="text-end">
|
||||
@if (has)
|
||||
{
|
||||
<span class="badge bg-success">Yes</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">No</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<form method="post" action="/auth/logout">
|
||||
<button class="btn btn-outline-danger" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@code {
|
||||
private sealed record Capability(string Name, string Description, string[] RequiredRoles);
|
||||
|
||||
// Kept in sync with Program.cs authorization policies + each page's [Authorize] attribute.
|
||||
// When a new page or policy is added, extend this list so operators can self-service check
|
||||
// whether their session has access without trial-and-error navigation.
|
||||
private static readonly IReadOnlyList<Capability> Capabilities =
|
||||
[
|
||||
new("View clusters + fleet status",
|
||||
"Read-only access to the cluster list, fleet dashboard, and generation history.",
|
||||
[AdminRoles.ConfigViewer, AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
||||
new("Edit configuration drafts",
|
||||
"Create and edit draft generations, manage namespace bindings and node ACLs. CanEdit policy.",
|
||||
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
||||
new("Publish generations",
|
||||
"Promote a draft to Published — triggers node roll-out. CanPublish policy.",
|
||||
[AdminRoles.FleetAdmin]),
|
||||
new("Manage certificate trust",
|
||||
"Trust rejected client certs + revoke trust. FleetAdmin-only because the trust decision gates OPC UA client access.",
|
||||
[AdminRoles.FleetAdmin]),
|
||||
new("Manage external-ID reservations",
|
||||
"Reserve / release external IDs that map into Galaxy contained names.",
|
||||
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
@page "/alarms/historian"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian
|
||||
@inject HistorianDiagnosticsService Diag
|
||||
|
||||
<h1>Alarm historian</h1>
|
||||
<p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Drain state</small>
|
||||
<h4><span class="badge @BadgeFor(_status.DrainState)">@_status.DrainState</span></h4>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Queue depth</small>
|
||||
<h4>@_status.QueueDepth.ToString("N0")</h4>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Dead-letter depth</small>
|
||||
<h4 class="@(_status.DeadLetterDepth > 0 ? "text-warning" : "")">@_status.DeadLetterDepth.ToString("N0")</h4>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Last success</small>
|
||||
<h4>@(_status.LastSuccessUtc?.ToString("u") ?? "—")</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_status.LastError))
|
||||
{
|
||||
<div class="alert alert-warning mt-3 mb-0">
|
||||
<strong>Last error:</strong> @_status.LastError
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary" @onclick="RefreshAsync">Refresh</button>
|
||||
<button class="btn btn-warning" disabled="@(_status.DeadLetterDepth == 0)" @onclick="RetryDeadLetteredAsync">
|
||||
Retry dead-lettered (@_status.DeadLetterDepth)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_retryResult is not null)
|
||||
{
|
||||
<div class="alert alert-success mt-3">Requeued @_retryResult row(s) for retry.</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private HistorianSinkStatus _status = new(0, 0, null, null, null, HistorianDrainState.Disabled);
|
||||
private int? _retryResult;
|
||||
|
||||
protected override void OnInitialized() => _status = Diag.GetStatus();
|
||||
|
||||
private Task RefreshAsync()
|
||||
{
|
||||
_status = Diag.GetStatus();
|
||||
_retryResult = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task RetryDeadLetteredAsync()
|
||||
{
|
||||
_retryResult = Diag.TryRetryDeadLettered();
|
||||
_status = Diag.GetStatus();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string BadgeFor(HistorianDrainState s) => s switch
|
||||
{
|
||||
HistorianDrainState.Idle => "bg-success",
|
||||
HistorianDrainState.Draining => "bg-info",
|
||||
HistorianDrainState.BackingOff => "bg-warning text-dark",
|
||||
HistorianDrainState.Disabled => "bg-secondary",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
@page "/certificates"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = AdminRoles.FleetAdmin)]
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@inject CertTrustService Certs
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
@inject ILogger<Certificates> Log
|
||||
|
||||
<h1 class="mb-4">Certificate trust</h1>
|
||||
|
||||
<div class="alert alert-info small mb-4">
|
||||
PKI store root <code>@Certs.PkiStoreRoot</code>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake, so operators should retry the rejected client's connection after trusting.
|
||||
</div>
|
||||
|
||||
@if (_status is not null)
|
||||
{
|
||||
<div class="alert alert-@_statusKind alert-dismissible">
|
||||
@_status
|
||||
<button type="button" class="btn-close" @onclick="ClearStatus"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<h2 class="h4">Rejected (@_rejected.Count)</h2>
|
||||
@if (_rejected.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _rejected)
|
||||
{
|
||||
<tr>
|
||||
<td>@c.Subject</td>
|
||||
<td>@c.Issuer</td>
|
||||
<td><code class="small">@c.Thumbprint</code></td>
|
||||
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-success me-1" @onclick="() => TrustAsync(c)">Trust</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteRejectedAsync(c)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h4 mt-5">Trusted (@_trusted.Count)</h2>
|
||||
@if (_trusted.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No client certs have been explicitly trusted. The server's own application cert lives in <code>own/</code> and is not listed here.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _trusted)
|
||||
{
|
||||
<tr>
|
||||
<td>@c.Subject</td>
|
||||
<td>@c.Issuer</td>
|
||||
<td><code class="small">@c.Thumbprint</code></td>
|
||||
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => UntrustAsync(c)">Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<CertInfo> _rejected = [];
|
||||
private IReadOnlyList<CertInfo> _trusted = [];
|
||||
private string? _status;
|
||||
private string _statusKind = "success";
|
||||
|
||||
protected override void OnInitialized() => Reload();
|
||||
|
||||
private void Reload()
|
||||
{
|
||||
_rejected = Certs.ListRejected();
|
||||
_trusted = Certs.ListTrusted();
|
||||
}
|
||||
|
||||
private async Task TrustAsync(CertInfo c)
|
||||
{
|
||||
if (Certs.TrustRejected(c.Thumbprint))
|
||||
{
|
||||
await LogActionAsync("cert.trust", c);
|
||||
Set($"Trusted cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
|
||||
}
|
||||
else
|
||||
{
|
||||
Set($"Could not trust {Short(c.Thumbprint)} — file missing; another admin may have already handled it.", "warning");
|
||||
}
|
||||
Reload();
|
||||
}
|
||||
|
||||
private async Task DeleteRejectedAsync(CertInfo c)
|
||||
{
|
||||
if (Certs.DeleteRejected(c.Thumbprint))
|
||||
{
|
||||
await LogActionAsync("cert.delete.rejected", c);
|
||||
Set($"Deleted rejected cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
|
||||
}
|
||||
else
|
||||
{
|
||||
Set($"Could not delete {Short(c.Thumbprint)} — file missing.", "warning");
|
||||
}
|
||||
Reload();
|
||||
}
|
||||
|
||||
private async Task UntrustAsync(CertInfo c)
|
||||
{
|
||||
if (Certs.UntrustCert(c.Thumbprint))
|
||||
{
|
||||
await LogActionAsync("cert.untrust", c);
|
||||
Set($"Revoked trust for {c.Subject} ({Short(c.Thumbprint)}).", "success");
|
||||
}
|
||||
else
|
||||
{
|
||||
Set($"Could not revoke {Short(c.Thumbprint)} — file missing.", "warning");
|
||||
}
|
||||
Reload();
|
||||
}
|
||||
|
||||
private async Task LogActionAsync(string action, CertInfo c)
|
||||
{
|
||||
// Cert trust changes are operator-initiated and security-sensitive — Serilog captures the
|
||||
// user + thumbprint trail. CertTrustService also logs at Information on each filesystem
|
||||
// move/delete; this line ties the action to the authenticated admin user so the two logs
|
||||
// correlate. DB-level ConfigAuditLog persistence is deferred — its schema is
|
||||
// cluster-scoped and cert actions are cluster-agnostic.
|
||||
var state = await AuthState.GetAuthenticationStateAsync();
|
||||
var user = state.User.Identity?.Name ?? "(anonymous)";
|
||||
Log.LogInformation("Admin cert action: user={User} action={Action} thumbprint={Thumbprint} subject={Subject}",
|
||||
user, action, c.Thumbprint, c.Subject);
|
||||
}
|
||||
|
||||
private void Set(string message, string kind)
|
||||
{
|
||||
_status = message;
|
||||
_statusKind = kind;
|
||||
}
|
||||
|
||||
private void ClearStatus() => _status = null;
|
||||
|
||||
private static string Short(string thumbprint) =>
|
||||
thumbprint.Length > 12 ? thumbprint[..12] + "…" : thumbprint;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
|
||||
@inject NodeAclService AclSvc
|
||||
@inject PermissionProbeService ProbeSvc
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Access-control grants</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add grant</button>
|
||||
</div>
|
||||
|
||||
@if (_acls is null) { <p>Loading…</p> }
|
||||
else if (_acls.Count == 0) { <p class="text-muted">No ACL grants in this draft. Publish will result in a cluster with no external access.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>LDAP group</th><th>Scope</th><th>Scope ID</th><th>Permissions</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _acls)
|
||||
{
|
||||
<tr>
|
||||
<td>@a.LdapGroup</td>
|
||||
<td>@a.ScopeKind</td>
|
||||
<td><code>@(a.ScopeId ?? "-")</code></td>
|
||||
<td><code>@a.PermissionFlags</code></td>
|
||||
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => RevokeAsync(a.NodeAclRowId)">Revoke</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@* Probe-this-permission — task #196 slice 1 *@
|
||||
<div class="card mt-4 mb-3">
|
||||
<div class="card-header">
|
||||
<strong>Probe this permission</strong>
|
||||
<span class="small text-muted ms-2">
|
||||
Ask the trie "if LDAP group X asks for permission Y on node Z, would it be granted?" —
|
||||
answers the same way the live server does at request time.
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">LDAP group</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeGroup" placeholder="cn=fleet-admin,…"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Namespace</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeNamespaceId" placeholder="ns-1"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">UnsArea</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeUnsAreaId"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">UnsLine</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeUnsLineId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Equipment</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeEquipmentId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Tag</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeTagId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Permission</label>
|
||||
<select class="form-select form-select-sm" @bind="_probePermission">
|
||||
@foreach (var p in Enum.GetValues<NodePermissions>())
|
||||
{
|
||||
if (p == NodePermissions.None) continue;
|
||||
<option value="@p">@p</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RunProbeAsync" disabled="@_probing">Probe</button>
|
||||
@if (_probeResult is not null)
|
||||
{
|
||||
<span class="ms-3">
|
||||
@if (_probeResult.Granted)
|
||||
{
|
||||
<span class="badge bg-success">Granted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">Denied</span>
|
||||
}
|
||||
<span class="small ms-2">
|
||||
Required <code>@_probeResult.Required</code>,
|
||||
Effective <code>@_probeResult.Effective</code>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (_probeResult is not null && _probeResult.Matches.Count > 0)
|
||||
{
|
||||
<table class="table table-sm mt-3 mb-0">
|
||||
<thead><tr><th>LDAP group matched</th><th>Level</th><th>Flags contributed</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var m in _probeResult.Matches)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@m.LdapGroup</code></td>
|
||||
<td>@m.Scope</td>
|
||||
<td><code>@m.PermissionFlags</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else if (_probeResult is not null)
|
||||
{
|
||||
<div class="mt-2 small text-muted">No matching grants for this (group, scope) — effective permission is <code>None</code>.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">LDAP group</label>
|
||||
<input class="form-control" @bind="_group"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Scope kind</label>
|
||||
<select class="form-select" @bind="_scopeKind">
|
||||
@foreach (var k in Enum.GetValues<NodeAclScopeKind>()) { <option value="@k">@k</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Scope ID (empty for Cluster-wide)</label>
|
||||
<input class="form-control" @bind="_scopeId"/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Permissions (bundled presets — per-flag editor in v2.1)</label>
|
||||
<select class="form-select" @bind="_preset">
|
||||
<option value="Read">Read (Browse + Read)</option>
|
||||
<option value="WriteOperate">Read + Write Operate</option>
|
||||
<option value="Engineer">Read + Write Tune + Write Configure</option>
|
||||
<option value="AlarmAck">Read + Alarm Ack</option>
|
||||
<option value="Full">Full (every flag)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<NodeAcl>? _acls;
|
||||
private bool _showForm;
|
||||
private string _group = string.Empty;
|
||||
private NodeAclScopeKind _scopeKind = NodeAclScopeKind.Cluster;
|
||||
private string _scopeId = string.Empty;
|
||||
private string _preset = "Read";
|
||||
private string? _error;
|
||||
|
||||
// Probe-this-permission state
|
||||
private string _probeGroup = string.Empty;
|
||||
private string _probeNamespaceId = string.Empty;
|
||||
private string _probeUnsAreaId = string.Empty;
|
||||
private string _probeUnsLineId = string.Empty;
|
||||
private string _probeEquipmentId = string.Empty;
|
||||
private string _probeTagId = string.Empty;
|
||||
private NodePermissions _probePermission = NodePermissions.Read;
|
||||
private PermissionProbeResult? _probeResult;
|
||||
private bool _probing;
|
||||
|
||||
private async Task RunProbeAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_probeGroup)) { _probeResult = null; return; }
|
||||
_probing = true;
|
||||
try
|
||||
{
|
||||
var scope = new NodeScope
|
||||
{
|
||||
ClusterId = ClusterId,
|
||||
NamespaceId = NullIfBlank(_probeNamespaceId),
|
||||
UnsAreaId = NullIfBlank(_probeUnsAreaId),
|
||||
UnsLineId = NullIfBlank(_probeUnsLineId),
|
||||
EquipmentId = NullIfBlank(_probeEquipmentId),
|
||||
TagId = NullIfBlank(_probeTagId),
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
_probeResult = await ProbeSvc.ProbeAsync(GenerationId, _probeGroup.Trim(), scope, _probePermission, CancellationToken.None);
|
||||
}
|
||||
finally { _probing = false; }
|
||||
}
|
||||
|
||||
private static string? NullIfBlank(string s) => string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
|
||||
private HubConnection? _hub;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || _hub is not null) return;
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
_hub.On<NodeAclChangedMessage>("NodeAclChanged", async msg =>
|
||||
{
|
||||
if (msg.ClusterId != ClusterId || msg.GenerationId != GenerationId) return;
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeCluster", ClusterId);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null) { await _hub.DisposeAsync(); _hub = null; }
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync() =>
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
|
||||
private NodePermissions ResolvePreset() => _preset switch
|
||||
{
|
||||
"Read" => NodePermissions.Browse | NodePermissions.Read,
|
||||
"WriteOperate" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteOperate,
|
||||
"Engineer" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteTune | NodePermissions.WriteConfigure,
|
||||
"AlarmAck" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.AlarmRead | NodePermissions.AlarmAcknowledge,
|
||||
"Full" => unchecked((NodePermissions)(-1)),
|
||||
_ => NodePermissions.Browse | NodePermissions.Read,
|
||||
};
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
if (string.IsNullOrWhiteSpace(_group)) { _error = "LDAP group is required"; return; }
|
||||
|
||||
var scopeId = _scopeKind == NodeAclScopeKind.Cluster ? null
|
||||
: string.IsNullOrWhiteSpace(_scopeId) ? null : _scopeId;
|
||||
|
||||
if (_scopeKind != NodeAclScopeKind.Cluster && scopeId is null)
|
||||
{
|
||||
_error = $"ScopeId required for {_scopeKind}";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await AclSvc.GrantAsync(GenerationId, ClusterId, _group, _scopeKind, scopeId,
|
||||
ResolvePreset(), notes: null, CancellationToken.None);
|
||||
_group = string.Empty; _scopeId = string.Empty;
|
||||
_showForm = false;
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task RevokeAsync(Guid rowId)
|
||||
{
|
||||
await AclSvc.RevokeAsync(rowId, CancellationToken.None);
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject AuditLogService AuditSvc
|
||||
|
||||
<h4>Recent audit log</h4>
|
||||
|
||||
@if (_entries is null) { <p>Loading…</p> }
|
||||
else if (_entries.Count == 0) { <p class="text-muted">No audit entries for this cluster yet.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>When</th><th>Principal</th><th>Event</th><th>Node</th><th>Generation</th><th>Details</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _entries)
|
||||
{
|
||||
<tr>
|
||||
<td>@a.Timestamp.ToString("u")</td>
|
||||
<td>@a.Principal</td>
|
||||
<td><code>@a.EventType</code></td>
|
||||
<td>@a.NodeId</td>
|
||||
<td>@a.GenerationId</td>
|
||||
<td><small class="text-muted">@a.DetailsJson</small></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private List<ConfigAuditLog>? _entries;
|
||||
|
||||
protected override async Task OnParametersSetAsync() =>
|
||||
_entries = await AuditSvc.ListRecentAsync(ClusterId, limit: 100, CancellationToken.None);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
@page "/clusters/{ClusterId}"
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@implements IAsyncDisposable
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@if (_cluster is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_liveBanner is not null)
|
||||
{
|
||||
<div class="alert alert-info py-2 small">
|
||||
<strong>Live update:</strong> @_liveBanner
|
||||
<button type="button" class="btn-close float-end" @onclick="() => _liveBanner = null"></button>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">@_cluster.Name</h1>
|
||||
<code class="text-muted">@_cluster.ClusterId</code>
|
||||
@if (!_cluster.Enabled) { <span class="badge bg-secondary ms-2">Disabled</span> }
|
||||
</div>
|
||||
<div>
|
||||
@if (_currentDraft is not null)
|
||||
{
|
||||
<a href="/clusters/@ClusterId/draft/@_currentDraft.GenerationId" class="btn btn-outline-primary">
|
||||
Edit current draft (gen @_currentDraft.GenerationId)
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-primary" @onclick="CreateDraftAsync" disabled="@_busy">New draft</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><button class="nav-link @Tab("overview")" @onclick='() => _tab = "overview"'>Overview</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("generations")" @onclick='() => _tab = "generations"'>Generations</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("equipment")" @onclick='() => _tab = "equipment"'>Equipment</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("uns")" @onclick='() => _tab = "uns"'>UNS Structure</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("tags")" @onclick='() => _tab = "tags"'>Tags</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("redundancy")" @onclick='() => _tab = "redundancy"'>Redundancy</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("audit")" @onclick='() => _tab = "audit"'>Audit</button></li>
|
||||
</ul>
|
||||
|
||||
@if (_tab == "overview")
|
||||
{
|
||||
<dl class="row">
|
||||
<dt class="col-sm-3">Enterprise / Site</dt><dd class="col-sm-9">@_cluster.Enterprise / @_cluster.Site</dd>
|
||||
<dt class="col-sm-3">Redundancy</dt><dd class="col-sm-9">@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))</dd>
|
||||
<dt class="col-sm-3">Current published</dt>
|
||||
<dd class="col-sm-9">
|
||||
@if (_currentPublished is not null) { <span>@_currentPublished.GenerationId (@_currentPublished.PublishedAt?.ToString("u"))</span> }
|
||||
else { <span class="text-muted">none published yet</span> }
|
||||
</dd>
|
||||
<dt class="col-sm-3">Created</dt><dd class="col-sm-9">@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy</dd>
|
||||
</dl>
|
||||
}
|
||||
else if (_tab == "generations")
|
||||
{
|
||||
<Generations ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "equipment" && _currentDraft is not null)
|
||||
{
|
||||
<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"/>
|
||||
}
|
||||
else if (_tab == "redundancy")
|
||||
{
|
||||
<RedundancyTab ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "audit")
|
||||
{
|
||||
<AuditTab ClusterId="@ClusterId"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">Open a draft to edit this cluster's content.</p>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private ServerCluster? _cluster;
|
||||
private ConfigGeneration? _currentDraft;
|
||||
private ConfigGeneration? _currentPublished;
|
||||
private string _tab = "overview";
|
||||
private bool _busy;
|
||||
private HubConnection? _hub;
|
||||
private string? _liveBanner;
|
||||
|
||||
private string Tab(string key) => _tab == key ? "active" : string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
await ConnectHubAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_cluster = await ClusterSvc.FindAsync(ClusterId, CancellationToken.None);
|
||||
var gens = await GenerationSvc.ListRecentAsync(ClusterId, 50, CancellationToken.None);
|
||||
_currentDraft = gens.FirstOrDefault(g => g.Status == GenerationStatus.Draft);
|
||||
_currentPublished = gens.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||
}
|
||||
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hub.On<NodeStateChangedMessage>("NodeStateChanged", async msg =>
|
||||
{
|
||||
if (msg.ClusterId != ClusterId) return;
|
||||
_liveBanner = $"Node {msg.NodeId}: {msg.LastAppliedStatus ?? "seen"} at {msg.LastAppliedAt?.ToString("u") ?? msg.LastSeenAt?.ToString("u") ?? "-"}";
|
||||
await LoadAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeCluster", ClusterId);
|
||||
}
|
||||
|
||||
private async Task CreateDraftAsync()
|
||||
{
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var draft = await GenerationSvc.CreateDraftAsync(ClusterId, createdBy: "admin-ui", CancellationToken.None);
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/draft/{draft.GenerationId}");
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null) await _hub.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
@page "/clusters"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject ClusterService ClusterSvc
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Clusters</h1>
|
||||
<a href="/clusters/new" class="btn btn-primary">New cluster</a>
|
||||
</div>
|
||||
|
||||
@if (_clusters is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_clusters.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No clusters yet. Create the first one.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ClusterId</th><th>Name</th><th>Enterprise</th><th>Site</th>
|
||||
<th>RedundancyMode</th><th>NodeCount</th><th>Enabled</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@c.ClusterId</code></td>
|
||||
<td>@c.Name</td>
|
||||
<td>@c.Enterprise</td>
|
||||
<td>@c.Site</td>
|
||||
<td>@c.RedundancyMode</td>
|
||||
<td>@c.NodeCount</td>
|
||||
<td>
|
||||
@if (c.Enabled) { <span class="badge bg-success">Active</span> }
|
||||
else { <span class="badge bg-secondary">Disabled</span> }
|
||||
</td>
|
||||
<td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ServerCluster>? _clusters;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
|
||||
@* Per-section diff renderer — the base used by DiffViewer for every known TableName. Caps
|
||||
output at RowCap rows so a pathological draft (e.g. 20k tags churned) can't freeze the
|
||||
Blazor render; overflow banner tells operator how many rows were hidden. *@
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>@Title</strong>
|
||||
<small class="text-muted ms-2">@Description</small>
|
||||
</div>
|
||||
<div>
|
||||
@if (_added > 0) { <span class="badge bg-success me-1">+@_added</span> }
|
||||
@if (_removed > 0) { <span class="badge bg-danger me-1">−@_removed</span> }
|
||||
@if (_modified > 0) { <span class="badge bg-warning text-dark me-1">~@_modified</span> }
|
||||
@if (_total == 0) { <span class="badge bg-secondary">no changes</span> }
|
||||
</div>
|
||||
</div>
|
||||
@if (_total == 0)
|
||||
{
|
||||
<div class="card-body text-muted small">No changes in this section.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_total > RowCap)
|
||||
{
|
||||
<div class="alert alert-warning mb-0 small rounded-0">
|
||||
Showing the first @RowCap of @_total rows — cap protects the browser from megabyte-class
|
||||
diffs. Inspect the remainder via the SQL <code>sp_ComputeGenerationDiff</code> directly.
|
||||
</div>
|
||||
}
|
||||
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>LogicalId</th><th style="width: 120px;">Change</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _visibleRows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.LogicalId</code></td>
|
||||
<td>
|
||||
@switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break;
|
||||
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break;
|
||||
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break;
|
||||
default: <span class="badge bg-secondary">@r.ChangeKind</span> break;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Default row-cap per section — matches task #156's acceptance criterion.</summary>
|
||||
public const int DefaultRowCap = 1000;
|
||||
|
||||
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
|
||||
[Parameter] public string Description { get; set; } = string.Empty;
|
||||
[Parameter, EditorRequired] public IReadOnlyList<DiffRow> Rows { get; set; } = [];
|
||||
[Parameter] public int RowCap { get; set; } = DefaultRowCap;
|
||||
|
||||
private int _total;
|
||||
private int _added;
|
||||
private int _removed;
|
||||
private int _modified;
|
||||
private List<DiffRow> _visibleRows = [];
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_total = Rows.Count;
|
||||
_added = 0; _removed = 0; _modified = 0;
|
||||
foreach (var r in Rows)
|
||||
{
|
||||
switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": _added++; break;
|
||||
case "Removed": _removed++; break;
|
||||
case "Modified": _modified++; break;
|
||||
}
|
||||
}
|
||||
_visibleRows = _total > RowCap ? Rows.Take(RowCap).ToList() : Rows.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
@page "/clusters/{ClusterId}/draft/{GenerationId:long}/diff"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject GenerationService GenerationSvc
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">Draft diff</h1>
|
||||
<small class="text-muted">
|
||||
Cluster <code>@ClusterId</code> — from last published (@(_fromLabel)) → to draft @GenerationId
|
||||
</small>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to editor</a>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Computing diff…</p>
|
||||
}
|
||||
else if (_error is not null)
|
||||
{
|
||||
<div class="alert alert-danger">@_error</div>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No differences — draft is structurally identical to the last published generation.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="small text-muted mb-3">
|
||||
@_rows.Count row@(_rows.Count == 1 ? "" : "s") across @_sectionsWithChanges of @Sections.Count sections.
|
||||
Each section is capped at @DiffSection.DefaultRowCap rows to keep the browser responsive on pathological drafts.
|
||||
</p>
|
||||
|
||||
@foreach (var sec in Sections)
|
||||
{
|
||||
<DiffSection Title="@sec.Title"
|
||||
Description="@sec.Description"
|
||||
Rows="@RowsFor(sec.TableName)"/>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered section definitions — each maps a <c>TableName</c> emitted by
|
||||
/// <c>sp_ComputeGenerationDiff</c> to a human label + description. The proc currently
|
||||
/// emits Namespace/DriverInstance/Equipment/Tag; UnsLine + NodeAcl entries render as
|
||||
/// empty "no changes" cards until the proc is extended (tracked in tasks #196 + #156
|
||||
/// follow-up). Six sections total matches the task #156 target.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyList<SectionDef> Sections = new[]
|
||||
{
|
||||
new SectionDef("Namespace", "Namespaces", "OPC UA namespace URIs + enablement"),
|
||||
new SectionDef("DriverInstance", "Driver instances","Per-cluster driver configuration rows"),
|
||||
new SectionDef("Equipment", "Equipment", "UNS level-5 rows + identification fields"),
|
||||
new SectionDef("Tag", "Tags", "Per-device tag definitions + poll-group binding"),
|
||||
new SectionDef("UnsLine", "UNS structure", "Site / Area / Line hierarchy (proc-extension pending)"),
|
||||
new SectionDef("NodeAcl", "ACLs", "LDAP-group → node-scope permission grants (logical id = LdapGroup|ScopeKind|ScopeId)"),
|
||||
};
|
||||
|
||||
private List<DiffRow>? _rows;
|
||||
private string _fromLabel = "(empty)";
|
||||
private string? _error;
|
||||
private int _sectionsWithChanges;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var all = await GenerationSvc.ListRecentAsync(ClusterId, 50, CancellationToken.None);
|
||||
var from = all.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||
_fromLabel = from is null ? "(empty)" : $"gen {from.GenerationId}";
|
||||
_rows = await GenerationSvc.ComputeDiffAsync(from?.GenerationId ?? 0, GenerationId, CancellationToken.None);
|
||||
_sectionsWithChanges = Sections.Count(s => _rows.Any(r => r.TableName == s.TableName));
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private IReadOnlyList<DiffRow> RowsFor(string tableName) =>
|
||||
_rows?.Where(r => r.TableName == tableName).ToList() ?? [];
|
||||
|
||||
private sealed record SectionDef(string TableName, string Title, string Description);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
@page "/clusters/{ClusterId}/draft/{GenerationId:long}"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject DraftValidationService ValidationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">Draft editor</h1>
|
||||
<small class="text-muted">Cluster <code>@ClusterId</code> · generation @GenerationId</small>
|
||||
</div>
|
||||
<div>
|
||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId">Back to cluster</a>
|
||||
<a class="btn btn-outline-primary ms-2" href="/clusters/@ClusterId/draft/@GenerationId/diff">View diff</a>
|
||||
<button class="btn btn-primary ms-2" disabled="@(_errors.Count != 0 || _busy)" @onclick="PublishAsync">Publish</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><button class="nav-link @Active("equipment")" @onclick='() => _tab = "equipment"'>Equipment</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("uns")" @onclick='() => _tab = "uns"'>UNS</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("scripts")" @onclick='() => _tab = "scripts"'>Scripts</button></li>
|
||||
</ul>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
@if (_tab == "equipment") { <EquipmentTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "uns") { <UnsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card sticky-top">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Validation</strong>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="RevalidateAsync">Re-run</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (_validating) { <p class="text-muted">Checking…</p> }
|
||||
else if (_errors.Count == 0) { <div class="alert alert-success mb-0">No validation errors — safe to publish.</div> }
|
||||
else
|
||||
{
|
||||
<div class="alert alert-danger mb-2">@_errors.Count error@(_errors.Count == 1 ? "" : "s")</div>
|
||||
<ul class="list-unstyled">
|
||||
@foreach (var e in _errors)
|
||||
{
|
||||
<li class="mb-2">
|
||||
<span class="badge bg-danger me-1">@e.Code</span>
|
||||
<small>@e.Message</small>
|
||||
@if (!string.IsNullOrEmpty(e.Context)) { <div class="text-muted"><code>@e.Context</code></div> }
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_publishError is not null) { <div class="alert alert-danger mt-3">@_publishError</div> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
private string _tab = "equipment";
|
||||
private List<ValidationError> _errors = [];
|
||||
private bool _validating;
|
||||
private bool _busy;
|
||||
private string? _publishError;
|
||||
|
||||
private string Active(string k) => _tab == k ? "active" : string.Empty;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await RevalidateAsync();
|
||||
|
||||
private async Task RevalidateAsync()
|
||||
{
|
||||
_validating = true;
|
||||
try
|
||||
{
|
||||
var errors = await ValidationSvc.ValidateAsync(GenerationId, CancellationToken.None);
|
||||
_errors = errors.ToList();
|
||||
}
|
||||
finally { _validating = false; }
|
||||
}
|
||||
|
||||
private async Task PublishAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_publishError = null;
|
||||
try
|
||||
{
|
||||
await GenerationSvc.PublishAsync(ClusterId, GenerationId, notes: "Published via Admin UI", CancellationToken.None);
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}");
|
||||
}
|
||||
catch (Exception ex) { _publishError = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
@using System.Text.Json
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Modbus
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject DriverInstanceService DriverSvc
|
||||
@inject NamespaceService NsSvc
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>DriverInstances</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add driver</button>
|
||||
</div>
|
||||
|
||||
@if (_drivers is null) { <p>Loading…</p> }
|
||||
else if (_drivers.Count == 0) { <p class="text-muted">No drivers configured in this draft.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>DriverInstanceId</th><th>Name</th><th>Type</th><th>Namespace</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var d in _drivers)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@d.DriverInstanceId</code></td>
|
||||
<td>@d.Name</td>
|
||||
<td>
|
||||
@if (string.Equals(d.DriverType, "Focas", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<a href="/drivers/focas/@d.DriverInstanceId">@d.DriverType</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@d.DriverType
|
||||
}
|
||||
</td>
|
||||
<td><code>@d.NamespaceId</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showForm && _namespaces is not null)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control" @bind="_name"/>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">DriverType</label>
|
||||
<select class="form-select" @bind="_type">
|
||||
<option>Galaxy</option>
|
||||
<option>Modbus</option>
|
||||
<option>AbCip</option>
|
||||
<option>AbLegacy</option>
|
||||
<option>S7</option>
|
||||
<option>Focas</option>
|
||||
<option>OpcUaClient</option>
|
||||
</select>
|
||||
<div class="form-text">Type string must match the driver's registered factory name; this dropdown wraps the canonical names.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Namespace</label>
|
||||
<select class="form-select" @bind="_nsId">
|
||||
@foreach (var n in _namespaces) { <option value="@n.NamespaceId">@n.Kind — @n.NamespaceUri</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
@if (string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@* #147 — typed editor for Modbus drivers. The generic textarea is a fall-back
|
||||
for driver types that haven't yet shipped a typed editor. *@
|
||||
<label class="form-label">Modbus options (typed editor)</label>
|
||||
<ModbusOptionsEditor Model="_modbusOptions"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<label class="form-label">DriverConfig JSON (schemaless per driver type)</label>
|
||||
<textarea class="form-control font-monospace" rows="6" @bind="_config"></textarea>
|
||||
<div class="form-text">Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<DriverInstance>? _drivers;
|
||||
private List<Namespace>? _namespaces;
|
||||
private bool _showForm;
|
||||
private string _name = string.Empty;
|
||||
private string _type = "Modbus";
|
||||
private string _nsId = string.Empty;
|
||||
private string _config = "{}";
|
||||
private string? _error;
|
||||
|
||||
// #147 — typed editor model for Modbus drivers. Defaults match ModbusDriverOptions
|
||||
// defaults so an unedited form produces config equivalent to the historical
|
||||
// pre-typed-editor wire output. Serialised to _config on Save when type=Modbus.
|
||||
private ModbusOptionsEditor.ModbusOptionsViewModel _modbusOptions = new();
|
||||
private static readonly JsonSerializerOptions ModbusJsonOptions = new() { WriteIndented = true };
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_namespaces = await NsSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_nsId = _namespaces.FirstOrDefault()?.NamespaceId ?? string.Empty;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_nsId))
|
||||
{
|
||||
_error = "Name and Namespace are required";
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
// #147 — for Modbus drivers serialize the typed editor model into the DriverConfig
|
||||
// JSON column. Other driver types still use the raw textarea contents until each
|
||||
// ships its own typed editor (decision #94 — per-driver schema validation arrives
|
||||
// per driver phase).
|
||||
var configJson = string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase)
|
||||
? SerializeModbusOptions(_modbusOptions)
|
||||
: _config;
|
||||
|
||||
await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, configJson, CancellationToken.None);
|
||||
_name = string.Empty; _config = "{}";
|
||||
_modbusOptions = new();
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the view-model field names onto the JSON shape <c>ModbusDriverFactoryExtensions</c>
|
||||
/// consumes. Hand-rolled because the DTO uses millisecond / byte field flavours that the
|
||||
/// view model exposes as TimeSpan-derived integers; a System.Text.Json round-trip would
|
||||
/// emit the .NET-native names instead.
|
||||
/// </summary>
|
||||
private static string SerializeModbusOptions(ModbusOptionsEditor.ModbusOptionsViewModel m) =>
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
host = m.Host,
|
||||
port = m.Port,
|
||||
unitId = m.UnitId,
|
||||
family = m.Family.ToString(),
|
||||
melsecSubFamily = m.MelsecSubFamily.ToString(),
|
||||
keepAlive = new
|
||||
{
|
||||
enabled = m.KeepAliveEnabled,
|
||||
timeMs = m.KeepAliveTimeSec * 1000,
|
||||
intervalMs = m.KeepAliveIntervalSec * 1000,
|
||||
retryCount = m.KeepAliveRetryCount,
|
||||
},
|
||||
reconnect = new
|
||||
{
|
||||
initialDelayMs = m.ReconnectInitialDelayMs,
|
||||
maxDelayMs = m.ReconnectMaxDelayMs,
|
||||
backoffMultiplier = m.ReconnectBackoffMultiplier,
|
||||
},
|
||||
maxRegistersPerRead = m.MaxRegistersPerRead,
|
||||
maxRegistersPerWrite = m.MaxRegistersPerWrite,
|
||||
maxCoilsPerRead = m.MaxCoilsPerRead,
|
||||
maxReadGap = m.MaxReadGap,
|
||||
useFC15ForSingleCoilWrites = m.UseFC15ForSingleCoilWrites,
|
||||
useFC16ForSingleRegisterWrites = m.UseFC16ForSingleRegisterWrites,
|
||||
writeOnChangeOnly = m.WriteOnChangeOnly,
|
||||
tags = Array.Empty<object>(),
|
||||
}, ModbusJsonOptions);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||
@inject EquipmentService EquipmentSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Equipment (draft gen @GenerationId)</h4>
|
||||
<div>
|
||||
<button class="btn btn-outline-primary btn-sm me-2" @onclick="GoImport">Import CSV…</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_equipment is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_equipment.Count == 0 && !_showForm)
|
||||
{
|
||||
<p class="text-muted">No equipment in this draft yet.</p>
|
||||
}
|
||||
else if (_equipment.Count > 0)
|
||||
{
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>EquipmentId</th><th>Name</th><th>MachineCode</th><th>ZTag</th><th>SAPID</th>
|
||||
<th>Manufacturer / Model</th><th>Serial</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in _equipment)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@e.EquipmentId</code></td>
|
||||
<td>@e.Name</td>
|
||||
<td>@e.MachineCode</td>
|
||||
<td>@e.ZTag</td>
|
||||
<td>@e.SAPID</td>
|
||||
<td>@e.Manufacturer / @e.Model</td>
|
||||
<td>@e.SerialNumber</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(e)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h5>@(_editMode ? "Edit equipment" : "New equipment")</h5>
|
||||
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="equipment-form">
|
||||
<DataAnnotationsValidator/>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Name (UNS segment)</label>
|
||||
<InputText @bind-Value="_draft.Name" class="form-control"/>
|
||||
<ValidationMessage For="() => _draft.Name"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">MachineCode</label>
|
||||
<InputText @bind-Value="_draft.MachineCode" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DriverInstanceId</label>
|
||||
<InputText @bind-Value="_draft.DriverInstanceId" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">UnsLineId</label>
|
||||
<InputText @bind-Value="_draft.UnsLineId" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">ZTag</label>
|
||||
<InputText @bind-Value="_draft.ZTag" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">SAPID</label>
|
||||
<InputText @bind-Value="_draft.SAPID" class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IdentificationFields Equipment="_draft"/>
|
||||
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm ms-2" @onclick="Cancel">Cancel</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private void GoImport() => Nav.NavigateTo($"/clusters/{ClusterId}/draft/{GenerationId}/import-equipment");
|
||||
private List<Equipment>? _equipment;
|
||||
private bool _showForm;
|
||||
private bool _editMode;
|
||||
private Equipment _draft = NewBlankDraft();
|
||||
private string? _error;
|
||||
|
||||
private static Equipment NewBlankDraft() => new()
|
||||
{
|
||||
EquipmentId = string.Empty, DriverInstanceId = string.Empty,
|
||||
UnsLineId = string.Empty, Name = string.Empty, MachineCode = string.Empty,
|
||||
};
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_equipment = await EquipmentSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private void StartAdd()
|
||||
{
|
||||
_draft = NewBlankDraft();
|
||||
_editMode = false;
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void StartEdit(Equipment row)
|
||||
{
|
||||
// Shallow-clone so Cancel doesn't mutate the list-displayed row with in-flight form edits.
|
||||
_draft = new Equipment
|
||||
{
|
||||
EquipmentRowId = row.EquipmentRowId,
|
||||
GenerationId = row.GenerationId,
|
||||
EquipmentId = row.EquipmentId,
|
||||
EquipmentUuid = row.EquipmentUuid,
|
||||
DriverInstanceId = row.DriverInstanceId,
|
||||
DeviceId = row.DeviceId,
|
||||
UnsLineId = row.UnsLineId,
|
||||
Name = row.Name,
|
||||
MachineCode = row.MachineCode,
|
||||
ZTag = row.ZTag,
|
||||
SAPID = row.SAPID,
|
||||
Manufacturer = row.Manufacturer,
|
||||
Model = row.Model,
|
||||
SerialNumber = row.SerialNumber,
|
||||
HardwareRevision = row.HardwareRevision,
|
||||
SoftwareRevision = row.SoftwareRevision,
|
||||
YearOfConstruction = row.YearOfConstruction,
|
||||
AssetLocation = row.AssetLocation,
|
||||
ManufacturerUri = row.ManufacturerUri,
|
||||
DeviceManualUri = row.DeviceManualUri,
|
||||
EquipmentClassRef = row.EquipmentClassRef,
|
||||
Enabled = row.Enabled,
|
||||
};
|
||||
_editMode = true;
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
_showForm = false;
|
||||
_editMode = false;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
if (_editMode)
|
||||
{
|
||||
await EquipmentSvc.UpdateAsync(_draft, CancellationToken.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
_draft.EquipmentUuid = Guid.NewGuid();
|
||||
_draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid);
|
||||
_draft.GenerationId = GenerationId;
|
||||
await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
|
||||
}
|
||||
_showForm = false;
|
||||
_editMode = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Guid id)
|
||||
{
|
||||
await EquipmentSvc.DeleteAsync(id, CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<h4>Generations</h4>
|
||||
|
||||
@if (_generations is null) { <p>Loading…</p> }
|
||||
else if (_generations.Count == 0) { <p class="text-muted">No generations in this cluster yet.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Status</th><th>Created</th><th>Published</th><th>PublishedBy</th><th>Notes</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var g in _generations)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@g.GenerationId</code></td>
|
||||
<td>@StatusBadge(g.Status)</td>
|
||||
<td><small>@g.CreatedAt.ToString("u") by @g.CreatedBy</small></td>
|
||||
<td><small>@(g.PublishedAt?.ToString("u") ?? "-")</small></td>
|
||||
<td><small>@g.PublishedBy</small></td>
|
||||
<td><small>@g.Notes</small></td>
|
||||
<td>
|
||||
@if (g.Status == GenerationStatus.Draft)
|
||||
{
|
||||
<a class="btn btn-sm btn-primary" href="/clusters/@ClusterId/draft/@g.GenerationId">Open</a>
|
||||
}
|
||||
else if (g.Status is GenerationStatus.Published or GenerationStatus.Superseded)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-warning" @onclick="() => RollbackAsync(g.GenerationId)">Roll back to this</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_error is not null) { <div class="alert alert-danger">@_error</div> }
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private List<ConfigGeneration>? _generations;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync() =>
|
||||
_generations = await GenerationSvc.ListRecentAsync(ClusterId, 100, CancellationToken.None);
|
||||
|
||||
private async Task RollbackAsync(long targetId)
|
||||
{
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await GenerationSvc.RollbackAsync(ClusterId, targetId, notes: $"Rollback via Admin UI", CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private static MarkupString StatusBadge(GenerationStatus s) => s switch
|
||||
{
|
||||
GenerationStatus.Draft => new MarkupString("<span class='badge bg-info'>Draft</span>"),
|
||||
GenerationStatus.Published => new MarkupString("<span class='badge bg-success'>Published</span>"),
|
||||
GenerationStatus.Superseded => new MarkupString("<span class='badge bg-secondary'>Superseded</span>"),
|
||||
_ => new MarkupString($"<span class='badge bg-light text-dark'>{s}</span>"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
|
||||
@* Reusable OPC 40010 Machinery Identification editor. Binds to an Equipment row and renders the
|
||||
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. *@
|
||||
|
||||
<h6 class="mt-4">OPC 40010 Identification</h6>
|
||||
<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>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public Equipment? Equipment { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
@page "/clusters/{ClusterId}/draft/{GenerationId:long}/import-equipment"
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject DriverInstanceService DriverSvc
|
||||
@inject UnsService UnsSvc
|
||||
@inject EquipmentImportBatchService BatchSvc
|
||||
@inject NavigationManager Nav
|
||||
@inject AuthenticationStateProvider AuthProvider
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">Equipment CSV import</h1>
|
||||
<small class="text-muted">Cluster <code>@ClusterId</code> · draft generation @GenerationId</small>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info small mb-3">
|
||||
Accepts <code>@EquipmentCsvImporter.VersionMarker</code>-headered CSV per Stream B.3.
|
||||
Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns).
|
||||
Optional columns cover the OPC 40010 Identification fields. Paste the file contents
|
||||
or upload directly — the parser runs client-stream-side and shows a row-level preview
|
||||
before anything lands in the draft. ZTag + SAPID uniqueness across the fleet is NOT
|
||||
enforced here yet (see task #197); for now the finalise may fail at commit time if a
|
||||
reservation conflict exists.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-secondary small mb-3">
|
||||
<strong>Per-tag addressing for Modbus drivers</strong> isn't part of equipment import —
|
||||
tags are configured at the driver-instance level via the
|
||||
<a href="/clusters/@ClusterId/draft/@GenerationId">Drivers tab</a>. Use the
|
||||
<a href="/modbus/address-preview" target="_blank">address-preview tool</a> to sanity-check
|
||||
grammar strings (<code>40001:F:CDAB</code>, <code>HR1:I</code>, <code>V2000</code> for
|
||||
DL205 family, etc.) before pasting them into the driver config.
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Target driver instance (for every accepted row)</label>
|
||||
<select class="form-select" @bind="_driverInstanceId">
|
||||
<option value="">-- select driver --</option>
|
||||
@if (_drivers is not null)
|
||||
{
|
||||
@foreach (var d in _drivers) { <option value="@d.DriverInstanceId">@d.DriverInstanceId</option> }
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Target UNS line (for every accepted row)</label>
|
||||
<select class="form-select" @bind="_unsLineId">
|
||||
<option value="">-- select line --</option>
|
||||
@if (_unsLines is not null)
|
||||
{
|
||||
@foreach (var l in _unsLines) { <option value="@l.UnsLineId">@l.UnsLineId — @l.Name</option> }
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 pt-4">
|
||||
<InputFile OnChange="HandleFileAsync" class="form-control form-control-sm" accept=".csv,.txt"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="form-label">CSV content (paste or uploaded)</label>
|
||||
<textarea class="form-control font-monospace" rows="8" @bind="_csvText"
|
||||
placeholder="# OtOpcUaCsv v1 ZTag,MachineCode,SAPID,EquipmentId,…"/>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="ParseAsync" disabled="@_busy">Parse</button>
|
||||
<button class="btn btn-sm btn-primary ms-2" @onclick="StageAndFinaliseAsync"
|
||||
disabled="@(_parseResult is null || _parseResult.AcceptedRows.Count == 0 || string.IsNullOrWhiteSpace(_driverInstanceId) || string.IsNullOrWhiteSpace(_unsLineId) || _busy)">
|
||||
Stage + Finalise
|
||||
</button>
|
||||
@if (_parseError is not null) { <span class="alert alert-danger ms-3 py-1 px-2 small">@_parseError</span> }
|
||||
@if (_result is not null) { <span class="alert alert-success ms-3 py-1 px-2 small">@_result</span> }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_parseResult is not null)
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-success text-white">
|
||||
Accepted (@_parseResult.AcceptedRows.Count)
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
@if (_parseResult.AcceptedRows.Count == 0)
|
||||
{
|
||||
<p class="text-muted p-3 mb-0">No accepted rows.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead>
|
||||
<tr><th>ZTag</th><th>Machine</th><th>Name</th><th>Line</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _parseResult.AcceptedRows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.ZTag</code></td>
|
||||
<td>@r.MachineCode</td>
|
||||
<td>@r.Name</td>
|
||||
<td>@r.UnsLineName</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-danger text-white">
|
||||
Rejected (@_parseResult.RejectedRows.Count)
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
@if (_parseResult.RejectedRows.Count == 0)
|
||||
{
|
||||
<p class="text-muted p-3 mb-0">No rejections.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead><tr><th>Line</th><th>Reason</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var e in _parseResult.RejectedRows)
|
||||
{
|
||||
<tr>
|
||||
<td>@e.LineNumber</td>
|
||||
<td class="small">@e.Reason</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
private List<DriverInstance>? _drivers;
|
||||
private List<UnsLine>? _unsLines;
|
||||
private string _driverInstanceId = string.Empty;
|
||||
private string _unsLineId = string.Empty;
|
||||
private string _csvText = string.Empty;
|
||||
private EquipmentCsvParseResult? _parseResult;
|
||||
private string? _parseError;
|
||||
private string? _result;
|
||||
private bool _busy;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_unsLines = await UnsSvc.ListLinesAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task HandleFileAsync(InputFileChangeEventArgs e)
|
||||
{
|
||||
// 5 MiB cap — refuses pathological uploads that would OOM the server.
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024);
|
||||
using var reader = new StreamReader(stream);
|
||||
_csvText = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
private void ParseAsync()
|
||||
{
|
||||
_parseError = null;
|
||||
_parseResult = null;
|
||||
_result = null;
|
||||
try { _parseResult = EquipmentCsvImporter.Parse(_csvText); }
|
||||
catch (InvalidCsvFormatException ex) { _parseError = ex.Message; }
|
||||
catch (Exception ex) { _parseError = $"Parse failed: {ex.Message}"; }
|
||||
}
|
||||
|
||||
private async Task StageAndFinaliseAsync()
|
||||
{
|
||||
if (_parseResult is null) return;
|
||||
_busy = true;
|
||||
_result = null;
|
||||
_parseError = null;
|
||||
try
|
||||
{
|
||||
var auth = await AuthProvider.GetAuthenticationStateAsync();
|
||||
var createdBy = auth.User.Identity?.Name ?? "unknown";
|
||||
|
||||
var batch = await BatchSvc.CreateBatchAsync(ClusterId, createdBy, CancellationToken.None);
|
||||
await BatchSvc.StageRowsAsync(batch.Id, _parseResult.AcceptedRows, _parseResult.RejectedRows, CancellationToken.None);
|
||||
await BatchSvc.FinaliseBatchAsync(batch.Id, GenerationId, _driverInstanceId, _unsLineId, CancellationToken.None);
|
||||
|
||||
_result = $"Finalised batch {batch.Id:N} — {_parseResult.AcceptedRows.Count} rows added.";
|
||||
// Pause 600 ms so the success banner is visible, then navigate back.
|
||||
await Task.Delay(600);
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/draft/{GenerationId}");
|
||||
}
|
||||
catch (Exception ex) { _parseError = $"Finalise failed: {ex.Message}"; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject NamespaceService NsSvc
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Namespaces</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add namespace</button>
|
||||
</div>
|
||||
|
||||
@if (_namespaces is null) { <p>Loading…</p> }
|
||||
else if (_namespaces.Count == 0) { <p class="text-muted">No namespaces defined in this draft.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>NamespaceId</th><th>Kind</th><th>URI</th><th>Enabled</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var n in _namespaces)
|
||||
{
|
||||
<tr><td><code>@n.NamespaceId</code></td><td>@n.Kind</td><td>@n.NamespaceUri</td><td>@(n.Enabled ? "yes" : "no")</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label">NamespaceUri</label><input class="form-control" @bind="_uri"/></div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Kind</label>
|
||||
<select class="form-select" @bind="_kind">
|
||||
<option value="@NamespaceKind.Equipment">Equipment</option>
|
||||
<option value="@NamespaceKind.SystemPlatform">SystemPlatform (Galaxy)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private List<Namespace>? _namespaces;
|
||||
private bool _showForm;
|
||||
private string _uri = string.Empty;
|
||||
private NamespaceKind _kind = NamespaceKind.Equipment;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync() =>
|
||||
_namespaces = await NsSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_uri)) return;
|
||||
await NsSvc.AddAsync(GenerationId, ClusterId, _uri, _kind, CancellationToken.None);
|
||||
_uri = string.Empty;
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
@page "/clusters/new"
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<h1 class="mb-4">New cluster</h1>
|
||||
|
||||
<EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster">
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<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))
|
||||
{
|
||||
<div class="alert alert-danger mt-3">@_error</div>
|
||||
}
|
||||
|
||||
<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>
|
||||
|
||||
@code {
|
||||
private sealed class Input
|
||||
{
|
||||
[Required, RegularExpression("^[a-z0-9-]{1,64}$", ErrorMessage = "Lowercase alphanumeric + hyphens only")]
|
||||
public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
[Required, StringLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(32)] public string Enterprise { get; set; } = "zb";
|
||||
[StringLength(32)] public string Site { get; set; } = "dev";
|
||||
public RedundancyMode RedundancyMode { get; set; } = RedundancyMode.None;
|
||||
}
|
||||
|
||||
private Input _input = new();
|
||||
private bool _submitting;
|
||||
private string? _error;
|
||||
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
_submitting = true;
|
||||
_error = null;
|
||||
|
||||
try
|
||||
{
|
||||
var cluster = new ServerCluster
|
||||
{
|
||||
ClusterId = _input.ClusterId,
|
||||
Name = _input.Name,
|
||||
Enterprise = _input.Enterprise,
|
||||
Site = _input.Site,
|
||||
RedundancyMode = _input.RedundancyMode,
|
||||
NodeCount = _input.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2,
|
||||
Enabled = true,
|
||||
CreatedBy = "admin-ui",
|
||||
};
|
||||
|
||||
await ClusterSvc.CreateAsync(cluster, createdBy: "admin-ui", CancellationToken.None);
|
||||
await GenerationSvc.CreateDraftAsync(cluster.ClusterId, createdBy: "admin-ui", CancellationToken.None);
|
||||
|
||||
Nav.NavigateTo($"/clusters/{cluster.ClusterId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally { _submitting = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject ClusterNodeService NodeSvc
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h4>Redundancy topology</h4>
|
||||
@if (_roleChangedBanner is not null)
|
||||
{
|
||||
<div class="alert alert-info small mb-2">@_roleChangedBanner</div>
|
||||
}
|
||||
<p class="text-muted small">
|
||||
One row per <code>ClusterNode</code> in this cluster. Role, <code>ApplicationUri</code>,
|
||||
and <code>ServiceLevelBase</code> are authored separately; the Admin UI shows them read-only
|
||||
here so operators can confirm the published topology without touching it. LastSeen older than
|
||||
@((int)ClusterNodeService.StaleThreshold.TotalSeconds)s is flagged Stale — the node has
|
||||
stopped heart-beating and is likely down. Role swap goes through the server-side
|
||||
<code>RedundancyCoordinator</code> apply-lease flow, not direct DB edits.
|
||||
</p>
|
||||
|
||||
@if (_nodes is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_nodes.Count == 0)
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
No ClusterNode rows for this cluster. The server process needs at least one entry
|
||||
(with a non-blank <code>ApplicationUri</code>) before it can start up per OPC UA spec.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var primaries = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Primary);
|
||||
var secondaries = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Secondary);
|
||||
var standalone = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Standalone);
|
||||
var staleCount = _nodes.Count(ClusterNodeService.IsStale);
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Nodes</h6>
|
||||
<div class="fs-3">@_nodes.Count</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-success"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Primary</h6>
|
||||
<div class="fs-3 text-success">@primaries</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-info"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Secondary</h6>
|
||||
<div class="fs-3 text-info">@secondaries</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card @(staleCount > 0 ? "border-warning" : "")"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Stale</h6>
|
||||
<div class="fs-3 @(staleCount > 0 ? "text-warning" : "")">@staleCount</div>
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@if (primaries == 0 && standalone == 0)
|
||||
{
|
||||
<div class="alert alert-danger small mb-3">
|
||||
No Primary or Standalone node — the cluster has no authoritative write target. Secondaries
|
||||
stay read-only until one of them gets promoted via <code>RedundancyCoordinator</code>.
|
||||
</div>
|
||||
}
|
||||
else if (primaries > 1)
|
||||
{
|
||||
<div class="alert alert-danger small mb-3">
|
||||
<strong>Split-brain:</strong> @primaries nodes claim the Primary role. Apply-lease
|
||||
enforcement should have made this impossible at the coordinator level. Investigate
|
||||
immediately — one of the rows was likely hand-edited.
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Role</th>
|
||||
<th>Host</th>
|
||||
<th class="text-end">OPC UA port</th>
|
||||
<th class="text-end">ServiceLevel base</th>
|
||||
<th>ApplicationUri</th>
|
||||
<th>Enabled</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var n in _nodes)
|
||||
{
|
||||
<tr class="@RowClass(n)">
|
||||
<td><code>@n.NodeId</code></td>
|
||||
<td><span class="badge @RoleBadge(n.RedundancyRole)">@n.RedundancyRole</span></td>
|
||||
<td>@n.Host</td>
|
||||
<td class="text-end"><code>@n.OpcUaPort</code></td>
|
||||
<td class="text-end">@n.ServiceLevelBase</td>
|
||||
<td class="small text-break"><code>@n.ApplicationUri</code></td>
|
||||
<td>
|
||||
@if (n.Enabled) { <span class="badge bg-success">Enabled</span> }
|
||||
else { <span class="badge bg-secondary">Disabled</span> }
|
||||
</td>
|
||||
<td class="small @(ClusterNodeService.IsStale(n) ? "text-warning fw-bold" : "")">
|
||||
@(n.LastSeenAt is null ? "never" : FormatAge(n.LastSeenAt.Value))
|
||||
@if (ClusterNodeService.IsStale(n)) { <span class="badge bg-warning text-dark ms-1">Stale</span> }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<ClusterNode>? _nodes;
|
||||
private HubConnection? _hub;
|
||||
private string? _roleChangedBanner;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_nodes = await NodeSvc.ListByClusterAsync(ClusterId, CancellationToken.None);
|
||||
if (_hub is null) await ConnectHubAsync();
|
||||
}
|
||||
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hub.On<RoleChangedMessage>("RoleChanged", async msg =>
|
||||
{
|
||||
if (msg.ClusterId != ClusterId) return;
|
||||
_roleChangedBanner = $"Role changed on {msg.NodeId}: {msg.FromRole} → {msg.ToRole} at {msg.ObservedAtUtc:HH:mm:ss 'UTC'}";
|
||||
_nodes = await NodeSvc.ListByClusterAsync(ClusterId, CancellationToken.None);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeCluster", ClusterId);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null)
|
||||
{
|
||||
await _hub.DisposeAsync();
|
||||
_hub = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RowClass(ClusterNode n) =>
|
||||
ClusterNodeService.IsStale(n) ? "table-warning" :
|
||||
!n.Enabled ? "table-secondary" : "";
|
||||
|
||||
private static string RoleBadge(RedundancyRole r) => r switch
|
||||
{
|
||||
RedundancyRole.Primary => "bg-success",
|
||||
RedundancyRole.Secondary => "bg-info",
|
||||
RedundancyRole.Standalone => "bg-primary",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime t)
|
||||
{
|
||||
var age = DateTime.UtcNow - t;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
Monaco-backed C# code editor (Phase 7 Stream F). Progressive enhancement:
|
||||
textarea renders immediately, Monaco mounts via JS interop after first render.
|
||||
Monaco script tags are loaded once from the parent layout (wwwroot/js/monaco-loader.js
|
||||
pulls the CDN bundle).
|
||||
|
||||
Stream F keeps the interop surface small — bind `Source` two-way, and the parent
|
||||
tab re-renders on change for the dependency preview. The test-harness button
|
||||
lives in the parent so one editor can drive multiple script types.
|
||||
*@
|
||||
|
||||
<div class="script-editor">
|
||||
<textarea class="form-control font-monospace" rows="14" spellcheck="false"
|
||||
@bind="Source" @bind:event="oninput" id="@_editorId">@Source</textarea>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Source { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> SourceChanged { get; set; }
|
||||
|
||||
private readonly string _editorId = $"script-editor-{Guid.NewGuid():N}";
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", _editorId);
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// Monaco bundle not yet loaded on this page — textarea fallback is
|
||||
// still functional.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Scripting
|
||||
@inject ScriptService ScriptSvc
|
||||
@inject ScriptTestHarnessService Harness
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0">Scripts</h4>
|
||||
<small class="text-muted">C# (Roslyn). Used by virtual tags + scripted alarms.</small>
|
||||
</div>
|
||||
<button class="btn btn-primary" @onclick="StartNew">+ New script</button>
|
||||
</div>
|
||||
|
||||
<script src="/js/monaco-loader.js"></script>
|
||||
|
||||
@if (_loading) { <p class="text-muted">Loading…</p> }
|
||||
else if (_scripts.Count == 0 && _editing is null)
|
||||
{
|
||||
<div class="alert alert-info">No scripts yet in this draft.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="list-group">
|
||||
@foreach (var s in _scripts)
|
||||
{
|
||||
<button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")"
|
||||
@onclick="() => Open(s)">
|
||||
<strong>@s.Name</strong>
|
||||
<div class="small text-muted font-monospace">@s.ScriptId</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
@if (_editing is not null)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>@(_isNew ? "New script" : _editing.Name)</strong>
|
||||
<div>
|
||||
@if (!_isNew)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger me-2" @onclick="DeleteAsync">Delete</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control" @bind="_editing.Name"/>
|
||||
</div>
|
||||
<label class="form-label">Source</label>
|
||||
<ScriptEditor @bind-Source="_editing.SourceCode"/>
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="PreviewDependencies">Analyze dependencies</button>
|
||||
<button class="btn btn-sm btn-outline-info ms-2" @onclick="RunHarnessAsync" disabled="@_harnessBusy">Run test harness</button>
|
||||
</div>
|
||||
|
||||
@if (_dependencies is not null)
|
||||
{
|
||||
<div class="mt-3">
|
||||
<strong>Inferred reads</strong>
|
||||
@if (_dependencies.Reads.Count == 0) { <span class="text-muted ms-2">none</span> }
|
||||
else
|
||||
{
|
||||
<ul class="mb-1">
|
||||
@foreach (var r in _dependencies.Reads) { <li><code>@r</code></li> }
|
||||
</ul>
|
||||
}
|
||||
<strong>Inferred writes</strong>
|
||||
@if (_dependencies.Writes.Count == 0) { <span class="text-muted ms-2">none</span> }
|
||||
else
|
||||
{
|
||||
<ul class="mb-1">
|
||||
@foreach (var w in _dependencies.Writes) { <li><code>@w</code></li> }
|
||||
</ul>
|
||||
}
|
||||
@if (_dependencies.Rejections.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger mt-2">
|
||||
<strong>Non-literal paths rejected:</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var r in _dependencies.Rejections) { <li>@r.Message</li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_testResult is not null)
|
||||
{
|
||||
<div class="mt-3 border-top pt-3">
|
||||
<strong>Harness result:</strong> <span class="badge bg-secondary">@_testResult.Outcome</span>
|
||||
@if (_testResult.Outcome == ScriptTestOutcome.Success)
|
||||
{
|
||||
<div>Output: <code>@(_testResult.Output?.ToString() ?? "null")</code></div>
|
||||
@if (_testResult.Writes.Count > 0)
|
||||
{
|
||||
<div class="mt-1"><strong>Writes:</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var kv in _testResult.Writes) { <li><code>@kv.Key</code> = <code>@(kv.Value?.ToString() ?? "null")</code></li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (_testResult.Errors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning mt-2 mb-0">
|
||||
@foreach (var e in _testResult.Errors) { <div>@e</div> }
|
||||
</div>
|
||||
}
|
||||
@if (_testResult.LogEvents.Count > 0)
|
||||
{
|
||||
<div class="mt-2"><strong>Script log output:</strong>
|
||||
<ul class="small mb-0">
|
||||
@foreach (var e in _testResult.LogEvents) { <li>[@e.Level] @e.RenderMessage()</li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private bool _loading = true;
|
||||
private bool _busy;
|
||||
private bool _harnessBusy;
|
||||
private bool _isNew;
|
||||
private List<Script> _scripts = [];
|
||||
private Script? _editing;
|
||||
private DependencyExtractionResult? _dependencies;
|
||||
private ScriptTestResult? _testResult;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void Open(Script s)
|
||||
{
|
||||
_editing = new Script
|
||||
{
|
||||
ScriptRowId = s.ScriptRowId, GenerationId = s.GenerationId,
|
||||
ScriptId = s.ScriptId, Name = s.Name, SourceCode = s.SourceCode,
|
||||
SourceHash = s.SourceHash, Language = s.Language,
|
||||
};
|
||||
_isNew = false;
|
||||
_dependencies = null;
|
||||
_testResult = null;
|
||||
}
|
||||
|
||||
private void StartNew()
|
||||
{
|
||||
_editing = new Script
|
||||
{
|
||||
GenerationId = GenerationId, ScriptId = "",
|
||||
Name = "new-script", SourceCode = "return 0;", SourceHash = "",
|
||||
};
|
||||
_isNew = true;
|
||||
_dependencies = null;
|
||||
_testResult = null;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (_editing is null) return;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
if (_isNew)
|
||||
await ScriptSvc.AddAsync(GenerationId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
||||
else
|
||||
await ScriptSvc.UpdateAsync(GenerationId, _editing.ScriptId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_isNew = false;
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (_editing is null || _isNew) return;
|
||||
await ScriptSvc.DeleteAsync(GenerationId, _editing.ScriptId, CancellationToken.None);
|
||||
_editing = null;
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private void PreviewDependencies()
|
||||
{
|
||||
if (_editing is null) return;
|
||||
_dependencies = DependencyExtractor.Extract(_editing.SourceCode);
|
||||
}
|
||||
|
||||
private async Task RunHarnessAsync()
|
||||
{
|
||||
if (_editing is null) return;
|
||||
_harnessBusy = true;
|
||||
try
|
||||
{
|
||||
_dependencies ??= DependencyExtractor.Extract(_editing.SourceCode);
|
||||
var inputs = new Dictionary<string, DataValueSnapshot>();
|
||||
foreach (var read in _dependencies.Reads)
|
||||
inputs[read] = new DataValueSnapshot(0.0, 0u, DateTime.UtcNow, DateTime.UtcNow);
|
||||
_testResult = await Harness.RunVirtualTagAsync(_editing.SourceCode, inputs, CancellationToken.None);
|
||||
}
|
||||
finally { _harnessBusy = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
@using System.Text.Json
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Modbus
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
@inject TagService TagSvc
|
||||
@inject DriverInstanceService DriverSvc
|
||||
@inject EquipmentService EquipmentSvc
|
||||
|
||||
@*
|
||||
#155 — interactive Tag CRUD scoped to a draft generation. Conditional editor: when the
|
||||
selected DriverInstance is Modbus, the address input switches to ModbusAddressEditor (#145)
|
||||
so users get the live-parse preview + grammar validation. Other driver types fall back to
|
||||
a generic JSON textarea, matching the DriversTab pattern from #147.
|
||||
*@
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Tags (draft gen @GenerationId)</h4>
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add tag</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small text-muted">Filter by driver</label>
|
||||
<select class="form-select form-select-sm" @bind="_filterDriverId" @bind:after="ReloadAsync">
|
||||
<option value="">— all drivers —</option>
|
||||
@if (_drivers is not null)
|
||||
{
|
||||
@foreach (var d in _drivers)
|
||||
{
|
||||
<option value="@d.DriverInstanceId">@d.Name (@d.DriverType)</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_tags is null) { <p>Loading…</p> }
|
||||
else if (_tags.Count == 0 && !_showForm) { <p class="text-muted">No tags in this filter.</p> }
|
||||
else if (_tags.Count > 0)
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Driver</th><th>Equipment</th><th>DataType</th><th>Access</th><th>TagConfig</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var t in _tags)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.Name</td>
|
||||
<td><code>@t.DriverInstanceId</code></td>
|
||||
<td>@(t.EquipmentId ?? "—")</td>
|
||||
<td>@t.DataType</td>
|
||||
<td>@t.AccessLevel</td>
|
||||
<td class="font-monospace small text-truncate" style="max-width:18rem">@t.TagConfig</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(t)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(t.TagRowId)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h5>@(_editMode ? "Edit tag" : "New tag")</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control" @bind="_draft.Name"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DriverInstance</label>
|
||||
<select class="form-select" @bind="_draft.DriverInstanceId" @bind:after="OnDriverChanged">
|
||||
<option value="">— select driver —</option>
|
||||
@if (_drivers is not null)
|
||||
{
|
||||
@foreach (var d in _drivers) { <option value="@d.DriverInstanceId">@d.Name (@d.DriverType)</option> }
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Equipment (optional)</label>
|
||||
<select class="form-select" @bind="_draft.EquipmentId">
|
||||
<option value="">— none (folder-path mode) —</option>
|
||||
@if (_equipment is not null)
|
||||
{
|
||||
@foreach (var e in _equipment) { <option value="@e.EquipmentId">@e.Name</option> }
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DataType</label>
|
||||
<input class="form-control" @bind="_draft.DataType" placeholder="Boolean / Int32 / Float / etc."/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">AccessLevel</label>
|
||||
<select class="form-select" @bind="_draft.AccessLevel">
|
||||
@foreach (var a in Enum.GetValues<TagAccessLevel>())
|
||||
{
|
||||
<option value="@a">@a</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check mt-4">
|
||||
<input type="checkbox" class="form-check-input" @bind="_draft.WriteIdempotent"/>
|
||||
<label class="form-check-label">WriteIdempotent</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
@if (_isModbus)
|
||||
{
|
||||
<ModbusAddressEditor @bind-AddressString="_modbusAddress"
|
||||
@bind-AddressString:after="OnAddressChanged"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<label class="form-label">TagConfig (driver-specific JSON or string)</label>
|
||||
<textarea class="form-control font-monospace" rows="3" @bind="_draft.TagConfig"
|
||||
placeholder='@("{\"address\": ...}")'></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* #156 — advanced Modbus fields. Collapsed by default; the basic form covers the
|
||||
typical edit workflow. Expander surfaces Deadband (#141) / UnitId override (#142) /
|
||||
CoalesceProhibited (#143) for the multi-slave / noisy-analog / protected-hole
|
||||
deployments. Save-side flushes these into TagConfig as a structured JSON object
|
||||
that ModbusTagDto's BuildTag honours alongside the address string. *@
|
||||
@if (_isModbus)
|
||||
{
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-sm btn-link p-0"
|
||||
@onclick="() => _showAdvanced = !_showAdvanced">
|
||||
@(_showAdvanced ? "▼ Advanced" : "▶ Advanced") (Deadband / UnitId override / CoalesceProhibited)
|
||||
</button>
|
||||
</div>
|
||||
@if (_showAdvanced)
|
||||
{
|
||||
<div class="row g-3 mt-1 ps-3 border-start">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Deadband
|
||||
<span class="text-muted">(numeric scalar types only)</span>
|
||||
</label>
|
||||
<input type="number" step="any" class="form-control form-control-sm"
|
||||
@bind="_advancedDeadband" @bind:after="OnAdvancedChanged"
|
||||
placeholder="e.g. 0.5"/>
|
||||
<div class="form-text">Suppress publishes when |new - last| < threshold.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">UnitId override
|
||||
<span class="text-muted">(0–255, blank = use driver default)</span>
|
||||
</label>
|
||||
<input type="number" min="0" max="255" class="form-control form-control-sm"
|
||||
@bind="_advancedUnitId" @bind:after="OnAdvancedChanged"
|
||||
placeholder="leave blank for driver-level"/>
|
||||
<div class="form-text">Per-tag MBAP unit ID. Required when fronting a multi-slave gateway.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">CoalesceProhibited</label>
|
||||
<div class="form-check mt-1">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
@bind="_advancedCoalesceProhibited" @bind:after="OnAdvancedChanged"/>
|
||||
<label class="form-check-label">Read in isolation (#143)</label>
|
||||
</div>
|
||||
<div class="form-text">Use when surrounding registers are write-only or fault on read.</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="Cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<Tag>? _tags;
|
||||
private List<DriverInstance>? _drivers;
|
||||
private List<Equipment>? _equipment;
|
||||
private string _filterDriverId = string.Empty;
|
||||
|
||||
private bool _showForm;
|
||||
private bool _editMode;
|
||||
private Tag _draft = NewBlankDraft();
|
||||
private string? _error;
|
||||
private bool _isModbus;
|
||||
private string? _modbusAddress;
|
||||
|
||||
// #156 — advanced Modbus fields. Bound separately from _draft.TagConfig because they
|
||||
// round-trip through a structured JSON shape, not a single string. Synced into TagConfig
|
||||
// by OnAdvancedChanged / OnAddressChanged (whichever fires).
|
||||
private bool _showAdvanced;
|
||||
private double? _advancedDeadband;
|
||||
private byte? _advancedUnitId;
|
||||
private bool _advancedCoalesceProhibited;
|
||||
|
||||
private static Tag NewBlankDraft() => new()
|
||||
{
|
||||
TagId = string.Empty, DriverInstanceId = string.Empty, Name = string.Empty,
|
||||
DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = string.Empty,
|
||||
};
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_equipment = await EquipmentSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_tags = await TagSvc.ListAsync(GenerationId,
|
||||
string.IsNullOrWhiteSpace(_filterDriverId) ? null : _filterDriverId,
|
||||
equipmentId: null,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
private void StartAdd()
|
||||
{
|
||||
_draft = NewBlankDraft();
|
||||
_editMode = false;
|
||||
_modbusAddress = null;
|
||||
_isModbus = false;
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
ResetAdvanced();
|
||||
}
|
||||
|
||||
private void ResetAdvanced()
|
||||
{
|
||||
_showAdvanced = false;
|
||||
_advancedDeadband = null;
|
||||
_advancedUnitId = null;
|
||||
_advancedCoalesceProhibited = false;
|
||||
}
|
||||
|
||||
private void StartEdit(Tag row)
|
||||
{
|
||||
_draft = new Tag
|
||||
{
|
||||
TagRowId = row.TagRowId,
|
||||
GenerationId = row.GenerationId,
|
||||
TagId = row.TagId,
|
||||
DriverInstanceId = row.DriverInstanceId,
|
||||
DeviceId = row.DeviceId,
|
||||
EquipmentId = row.EquipmentId,
|
||||
Name = row.Name,
|
||||
FolderPath = row.FolderPath,
|
||||
DataType = row.DataType,
|
||||
AccessLevel = row.AccessLevel,
|
||||
WriteIdempotent = row.WriteIdempotent,
|
||||
PollGroupId = row.PollGroupId,
|
||||
TagConfig = row.TagConfig,
|
||||
};
|
||||
_editMode = true;
|
||||
OnDriverChanged();
|
||||
// Try to extract addressString + advanced fields from existing JSON config so the
|
||||
// form pre-fills correctly when an operator hits Edit on an existing row.
|
||||
ResetAdvanced();
|
||||
if (_isModbus) HydrateModbusFromTagConfig(row.TagConfig);
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void HydrateModbusFromTagConfig(string tagConfig)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("addressString", out var addr) && addr.ValueKind == JsonValueKind.String)
|
||||
_modbusAddress = addr.GetString();
|
||||
if (root.TryGetProperty("deadband", out var db) && db.ValueKind is JsonValueKind.Number)
|
||||
_advancedDeadband = db.GetDouble();
|
||||
if (root.TryGetProperty("unitId", out var uid) && uid.ValueKind is JsonValueKind.Number)
|
||||
_advancedUnitId = uid.GetByte();
|
||||
if (root.TryGetProperty("coalesceProhibited", out var cp) && cp.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
_advancedCoalesceProhibited = cp.GetBoolean();
|
||||
|
||||
// Auto-expand the advanced panel when any of those fields was actually set so
|
||||
// operators see immediately what's been configured.
|
||||
if (_advancedDeadband.HasValue || _advancedUnitId.HasValue || _advancedCoalesceProhibited)
|
||||
_showAdvanced = true;
|
||||
}
|
||||
catch { /* Malformed JSON falls back to empty advanced state. */ }
|
||||
}
|
||||
|
||||
private void OnDriverChanged()
|
||||
{
|
||||
var driver = _drivers?.FirstOrDefault(d => d.DriverInstanceId == _draft.DriverInstanceId);
|
||||
_isModbus = driver is not null
|
||||
&& string.Equals(driver.DriverType, "Modbus", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void OnAddressChanged() => RefreshTagConfigJson();
|
||||
private void OnAdvancedChanged() => RefreshTagConfigJson();
|
||||
|
||||
/// <summary>
|
||||
/// Re-serializes the current address + advanced fields into TagConfig as a structured
|
||||
/// JSON object. ModbusTagDto's BuildTag honours every field — addressString drives
|
||||
/// the parser, while the structured bits (deadband / unitId / coalesceProhibited)
|
||||
/// pass through directly. Fields with default / empty values are omitted from the
|
||||
/// JSON to keep diffs in the existing draft-diff viewer clean.
|
||||
/// </summary>
|
||||
private void RefreshTagConfigJson()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_modbusAddress)
|
||||
&& !_advancedDeadband.HasValue
|
||||
&& !_advancedUnitId.HasValue
|
||||
&& !_advancedCoalesceProhibited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = new Dictionary<string, object?>();
|
||||
if (!string.IsNullOrWhiteSpace(_modbusAddress)) payload["addressString"] = _modbusAddress;
|
||||
if (_advancedDeadband.HasValue) payload["deadband"] = _advancedDeadband.Value;
|
||||
if (_advancedUnitId.HasValue) payload["unitId"] = _advancedUnitId.Value;
|
||||
if (_advancedCoalesceProhibited) payload["coalesceProhibited"] = true;
|
||||
|
||||
_draft.TagConfig = JsonSerializer.Serialize(payload);
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
_showForm = false;
|
||||
_editMode = false;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_draft.Name) || string.IsNullOrWhiteSpace(_draft.DriverInstanceId))
|
||||
{
|
||||
_error = "Name and DriverInstance are required.";
|
||||
return;
|
||||
}
|
||||
if (_editMode)
|
||||
await TagSvc.UpdateAsync(_draft, CancellationToken.None);
|
||||
else
|
||||
await TagSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
|
||||
_showForm = false;
|
||||
_editMode = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Guid id)
|
||||
{
|
||||
await TagSvc.DeleteAsync(id, CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject UnsService UnsSvc
|
||||
|
||||
<div class="alert alert-info small mb-3">
|
||||
Drag any line in the <strong>UNS Lines</strong> table onto an area row in <strong>UNS Areas</strong>
|
||||
to re-parent it. A preview modal shows the impact (equipment re-home count) + lets you confirm
|
||||
or cancel. If another operator modifies the draft while you're confirming, you'll see a 409
|
||||
refresh-required modal instead of clobbering their work.
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h4>UNS Areas</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showAreaForm = true">Add area</button>
|
||||
</div>
|
||||
|
||||
@if (_areas is null) { <p>Loading…</p> }
|
||||
else if (_areas.Count == 0) { <p class="text-muted">No areas yet.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>AreaId</th><th>Name</th><th class="small text-muted">(drop target)</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _areas)
|
||||
{
|
||||
<tr class="@(_hoverAreaId == a.UnsAreaId ? "table-primary" : "")"
|
||||
@ondragover="e => OnAreaDragOver(e, a.UnsAreaId)"
|
||||
@ondragover:preventDefault
|
||||
@ondragleave="() => _hoverAreaId = null"
|
||||
@ondrop="() => OnLineDroppedAsync(a.UnsAreaId)"
|
||||
@ondrop:preventDefault>
|
||||
<td><code>@a.UnsAreaId</code></td>
|
||||
<td>@a.Name</td>
|
||||
<td class="small text-muted">drop here</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showAreaForm)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="mb-2"><label class="form-label">Name (lowercase segment)</label><input class="form-control" @bind="_newAreaName"/></div>
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddAreaAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showAreaForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h4>UNS Lines</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showLineForm = true" disabled="@(_areas is null || _areas.Count == 0)">Add line</button>
|
||||
</div>
|
||||
|
||||
@if (_lines is null) { <p>Loading…</p> }
|
||||
else if (_lines.Count == 0) { <p class="text-muted">No lines yet.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>LineId</th><th>Area</th><th>Name</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var l in _lines)
|
||||
{
|
||||
<tr draggable="true"
|
||||
@ondragstart="() => _dragLineId = l.UnsLineId"
|
||||
@ondragend="() => { _dragLineId = null; _hoverAreaId = null; }"
|
||||
style="cursor: grab;">
|
||||
<td><code>@l.UnsLineId</code></td>
|
||||
<td><code>@l.UnsAreaId</code></td>
|
||||
<td>@l.Name</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showLineForm && _areas is not null)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Area</label>
|
||||
<select class="form-select" @bind="_newLineAreaId">
|
||||
@foreach (var a in _areas) { <option value="@a.UnsAreaId">@a.Name (@a.UnsAreaId)</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2"><label class="form-label">Name</label><input class="form-control" @bind="_newLineName"/></div>
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddLineAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showLineForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Preview / confirm modal for a pending drag-drop move *@
|
||||
@if (_pendingPreview is not null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm UNS move</h5>
|
||||
<button type="button" class="btn-close" @onclick="CancelMove"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>@_pendingPreview.HumanReadableSummary</p>
|
||||
<p class="text-muted small">
|
||||
Equipment re-homed: <strong>@_pendingPreview.AffectedEquipmentCount</strong>.
|
||||
Tags re-parented: <strong>@_pendingPreview.AffectedTagCount</strong>.
|
||||
</p>
|
||||
@if (_pendingPreview.CascadeWarnings.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning small mb-0">
|
||||
<ul class="mb-0">
|
||||
@foreach (var w in _pendingPreview.CascadeWarnings) { <li>@w</li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick="CancelMove">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="ConfirmMoveAsync" disabled="@_committing">Confirm move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* 409 concurrent-edit modal — another operator changed the draft between preview + commit *@
|
||||
@if (_conflictMessage is not null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-danger">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title">Draft changed — refresh required</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>@_conflictMessage</p>
|
||||
<p class="small text-muted">
|
||||
Concurrency guard per DraftRevisionToken prevented overwriting the peer
|
||||
operator's edit. Reload the tab + redo the move on the current draft state.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" @onclick="ReloadAfterConflict">Reload draft</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<UnsArea>? _areas;
|
||||
private List<UnsLine>? _lines;
|
||||
private bool _showAreaForm;
|
||||
private bool _showLineForm;
|
||||
private string _newAreaName = string.Empty;
|
||||
private string _newLineName = string.Empty;
|
||||
private string _newLineAreaId = string.Empty;
|
||||
|
||||
private string? _dragLineId;
|
||||
private string? _hoverAreaId;
|
||||
private UnsImpactPreview? _pendingPreview;
|
||||
private UnsMoveOperation? _pendingMove;
|
||||
private bool _committing;
|
||||
private string? _conflictMessage;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_areas = await UnsSvc.ListAreasAsync(GenerationId, CancellationToken.None);
|
||||
_lines = await UnsSvc.ListLinesAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task AddAreaAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_newAreaName)) return;
|
||||
await UnsSvc.AddAreaAsync(GenerationId, ClusterId, _newAreaName, notes: null, CancellationToken.None);
|
||||
_newAreaName = string.Empty;
|
||||
_showAreaForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private async Task AddLineAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_newLineName) || string.IsNullOrWhiteSpace(_newLineAreaId)) return;
|
||||
await UnsSvc.AddLineAsync(GenerationId, _newLineAreaId, _newLineName, notes: null, CancellationToken.None);
|
||||
_newLineName = string.Empty;
|
||||
_showLineForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private void OnAreaDragOver(DragEventArgs _, string areaId) => _hoverAreaId = areaId;
|
||||
|
||||
private async Task OnLineDroppedAsync(string targetAreaId)
|
||||
{
|
||||
var lineId = _dragLineId;
|
||||
_hoverAreaId = null;
|
||||
_dragLineId = null;
|
||||
if (string.IsNullOrWhiteSpace(lineId)) return;
|
||||
|
||||
var line = _lines?.FirstOrDefault(l => l.UnsLineId == lineId);
|
||||
if (line is null || line.UnsAreaId == targetAreaId) return;
|
||||
|
||||
var snapshot = await UnsSvc.LoadSnapshotAsync(GenerationId, CancellationToken.None);
|
||||
var move = new UnsMoveOperation(
|
||||
Kind: UnsMoveKind.LineMove,
|
||||
SourceClusterId: ClusterId,
|
||||
TargetClusterId: ClusterId,
|
||||
SourceLineId: lineId,
|
||||
TargetAreaId: targetAreaId);
|
||||
try
|
||||
{
|
||||
_pendingPreview = UnsImpactAnalyzer.Analyze(snapshot, move);
|
||||
_pendingMove = move;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_conflictMessage = ex.Message; // CrossCluster or validation failure surfaces here
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelMove()
|
||||
{
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
}
|
||||
|
||||
private async Task ConfirmMoveAsync()
|
||||
{
|
||||
if (_pendingPreview is null || _pendingMove is null) return;
|
||||
_committing = true;
|
||||
try
|
||||
{
|
||||
await UnsSvc.MoveLineAsync(
|
||||
GenerationId,
|
||||
_pendingPreview.RevisionToken,
|
||||
_pendingMove.SourceLineId!,
|
||||
_pendingMove.TargetAreaId!,
|
||||
CancellationToken.None);
|
||||
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (DraftRevisionConflictException ex)
|
||||
{
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
_conflictMessage = ex.Message;
|
||||
}
|
||||
finally { _committing = false; }
|
||||
}
|
||||
|
||||
private async Task ReloadAfterConflict()
|
||||
{
|
||||
_conflictMessage = null;
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
@page "/drivers/focas/{InstanceId}"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@inject FocasDriverDetailService DetailSvc
|
||||
|
||||
<h1 class="mb-3">FOCAS driver <code>@InstanceId</code></h1>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_detail is null)
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
No FOCAS driver instance with id <code>@InstanceId</code> was found.
|
||||
<div class="small text-muted mt-1">
|
||||
Either the id is wrong, or the instance's <code>DriverType</code> is not "Focas". The list of drivers per cluster draft is on the <a href="/clusters">Clusters</a> page.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Name</h6>
|
||||
<div class="fs-5">@_detail.Instance.Name</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Cluster</h6>
|
||||
<div class="fs-5"><code>@_detail.Instance.ClusterId</code></div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Namespace</h6>
|
||||
<div class="fs-5"><code>@_detail.Instance.NamespaceId</code></div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card @(_detail.Instance.Enabled ? "border-success" : "border-secondary")"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Enabled</h6>
|
||||
<div class="fs-5">@(_detail.Instance.Enabled ? "Yes" : "No")</div>
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@if (_detail.ParseError is not null)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>DriverConfig JSON failed to parse:</strong> @_detail.ParseError
|
||||
<div class="small text-muted mt-1">
|
||||
Falling back to raw-JSON view below; the per-section tables are hidden because the shape couldn't be projected.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_detail.Config is not null)
|
||||
{
|
||||
<h2 class="h5 mt-4">Devices</h2>
|
||||
@if (_detail.Config.Devices is null || _detail.Config.Devices.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No devices configured.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>HostAddress</th><th>DeviceName</th><th>Series</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var d in _detail.Config.Devices)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@d.HostAddress</code></td>
|
||||
<td>@(d.DeviceName ?? "—")</td>
|
||||
<td>@(string.IsNullOrEmpty(d.Series) ? "Unknown" : d.Series)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Tags</h2>
|
||||
@if (_detail.Config.Tags is null || _detail.Config.Tags.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No tags configured.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="small text-muted">@_detail.Config.Tags.Count tag(s) configured.</p>
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Name</th><th>Device</th><th>Address</th><th>DataType</th><th>Writable</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var t in _detail.Config.Tags)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.Name</td>
|
||||
<td><code class="small">@t.DeviceHostAddress</code></td>
|
||||
<td><code>@t.Address</code></td>
|
||||
<td>@t.DataType</td>
|
||||
<td>@(t.Writable ? "Yes" : "No")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Driver behaviour</h2>
|
||||
<table class="table table-sm align-middle" style="max-width: 640px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style="width: 30%;">Probe</th>
|
||||
<td>
|
||||
@if (_detail.Config.Probe is { } probe)
|
||||
{
|
||||
<span class="badge @(probe.Enabled ? "bg-success" : "bg-secondary")">@(probe.Enabled ? "Enabled" : "Disabled")</span>
|
||||
<span class="ms-2 small text-muted">Interval: @(probe.Interval ?? "default")</span>
|
||||
}
|
||||
else { <span class="text-muted">default (enabled)</span> }
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Alarm projection</th>
|
||||
<td>
|
||||
@if (_detail.Config.AlarmProjection is { } ap)
|
||||
{
|
||||
<span class="badge @(ap.Enabled ? "bg-success" : "bg-secondary")">@(ap.Enabled ? "Enabled" : "Disabled")</span>
|
||||
<span class="ms-2 small text-muted">PollInterval: @(ap.PollInterval ?? "default")</span>
|
||||
}
|
||||
else { <span class="text-muted">disabled (default)</span> }
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Handle recycling</th>
|
||||
<td>
|
||||
@if (_detail.Config.HandleRecycle is { } hr)
|
||||
{
|
||||
<span class="badge @(hr.Enabled ? "bg-warning text-dark" : "bg-secondary")">@(hr.Enabled ? "Enabled" : "Disabled")</span>
|
||||
<span class="ms-2 small text-muted">Interval: @(hr.Interval ?? "default (01:00:00)")</span>
|
||||
}
|
||||
else { <span class="text-muted">disabled (default)</span> }
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Host status</h2>
|
||||
@if (_detail.HostStatuses.Count == 0)
|
||||
{
|
||||
<div class="alert alert-secondary small">
|
||||
No <code>DriverHostStatus</code> rows yet for this instance. The Server publishes its first
|
||||
tick ~2 s after the driver starts — if this stays empty after a minute, check that the Server is running and the instance is in a published generation.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th class="text-end" title="Consecutive failures">Fail#</th>
|
||||
<th>Breaker last opened</th>
|
||||
<th>Last recycled</th>
|
||||
<th>Last seen</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _detail.HostStatuses)
|
||||
{
|
||||
<tr class="@(IsStale(r) ? "table-warning" : "")">
|
||||
<td><code>@r.NodeId</code></td>
|
||||
<td>@r.HostName</td>
|
||||
<td><span class="badge @StateBadge(r.State)">@r.State</span></td>
|
||||
<td class="text-end small">@r.ConsecutiveFailures</td>
|
||||
<td class="small">@FormatUtc(r.LastCircuitBreakerOpenUtc)</td>
|
||||
<td class="small">@FormatUtc(r.LastRecycleUtc)</td>
|
||||
<td class="small @(IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td>
|
||||
<td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Raw DriverConfig JSON</h2>
|
||||
<pre class="small bg-light border p-3"><code>@_detail.Instance.DriverConfig</code></pre>
|
||||
|
||||
<div class="mt-4 small text-muted">
|
||||
Docs: <code>docs/drivers/FOCAS.md</code> (getting started) · <code>docs/v2/focas-deployment.md</code> (NSSM + pipe ACL) · <code>docs/drivers/FOCAS-Test-Fixture.md</code> (test coverage).
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string InstanceId { get; set; } = string.Empty;
|
||||
|
||||
private FocasDriverDetail? _detail;
|
||||
private bool _loading = true;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try { _detail = await DetailSvc.GetAsync(InstanceId, CancellationToken.None); }
|
||||
finally { _loading = false; }
|
||||
}
|
||||
|
||||
private static bool IsStale(FocasHostStatusRow r) =>
|
||||
DateTime.UtcNow - r.LastSeenUtc > TimeSpan.FromSeconds(30);
|
||||
|
||||
private static string StateBadge(string state) => state switch
|
||||
{
|
||||
"Running" => "bg-success",
|
||||
"Faulted" => "bg-danger",
|
||||
"Starting" => "bg-info",
|
||||
"Stopped" => "bg-secondary",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
|
||||
private static string FormatUtc(DateTime? utc) =>
|
||||
utc is null ? "—" : utc.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
|
||||
private static string FormatAge(DateTime utc)
|
||||
{
|
||||
var age = DateTime.UtcNow - utc;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 48) return $"{(int)age.TotalHours}h ago";
|
||||
return utc.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
}
|
||||
172
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor
Normal file
172
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor
Normal file
@@ -0,0 +1,172 @@
|
||||
@page "/fleet"
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@implements IDisposable
|
||||
|
||||
<h1 class="mb-4">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">
|
||||
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
|
||||
Refresh
|
||||
</button>
|
||||
<span class="text-muted small">
|
||||
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
No node state recorded yet. Nodes publish their state to the central DB on each poll; if
|
||||
this list is empty, either no nodes have been registered or the poller hasn't run yet.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Nodes</h6>
|
||||
<div class="fs-3">@_rows.Count</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-success"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Applied</h6>
|
||||
<div class="fs-3 text-success">@_rows.Count(r => r.Status == "Applied")</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-warning"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Stale</h6>
|
||||
<div class="fs-3 text-warning">@_rows.Count(r => IsStale(r))</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-danger"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Failed</h6>
|
||||
<div class="fs-3 text-danger">@_rows.Count(r => r.Status == "Failed")</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Cluster</th>
|
||||
<th>Generation</th>
|
||||
<th>Status</th>
|
||||
<th>Last applied</th>
|
||||
<th>Last seen</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr class="@RowClass(r)">
|
||||
<td><code>@r.NodeId</code></td>
|
||||
<td>@r.ClusterId</td>
|
||||
<td>@(r.GenerationId?.ToString() ?? "—")</td>
|
||||
<td>
|
||||
<span class="badge @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
|
||||
</td>
|
||||
<td>@FormatAge(r.AppliedAt)</td>
|
||||
<td class="@(IsStale(r) ? "text-warning" : "")">@FormatAge(r.SeenAt)</td>
|
||||
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
// Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees
|
||||
// the most recent published state without polling ahead of the broadcaster.
|
||||
private const int RefreshIntervalSeconds = 5;
|
||||
|
||||
private List<FleetNodeRow>? _rows;
|
||||
private bool _refreshing;
|
||||
private DateTime? _lastRefreshUtc;
|
||||
private Timer? _timer;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshAsync();
|
||||
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
|
||||
state: null,
|
||||
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
if (_refreshing) return;
|
||||
_refreshing = true;
|
||||
try
|
||||
{
|
||||
using var scope = ScopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
|
||||
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow(
|
||||
s.NodeId, n.ClusterId, s.CurrentGenerationId,
|
||||
s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null,
|
||||
s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt))
|
||||
.OrderBy(r => r.ClusterId)
|
||||
.ThenBy(r => r.NodeId)
|
||||
.ToListAsync();
|
||||
_rows = rows;
|
||||
_lastRefreshUtc = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsStale(FleetNodeRow r)
|
||||
{
|
||||
if (r.SeenAt is null) return true;
|
||||
return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
private static string RowClass(FleetNodeRow r) => r.Status switch
|
||||
{
|
||||
"Failed" => "table-danger",
|
||||
_ when IsStale(r) => "table-warning",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static string StatusBadge(string? status) => status switch
|
||||
{
|
||||
"Applied" => "bg-success",
|
||||
"Failed" => "bg-danger",
|
||||
"Applying" => "bg-info",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime? t)
|
||||
{
|
||||
if (t is null) return "—";
|
||||
var age = DateTime.UtcNow - t.Value;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||
return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
|
||||
internal sealed record FleetNodeRow(
|
||||
string NodeId, string ClusterId, long? GenerationId,
|
||||
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
@page "/"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<h1 class="mb-4">Fleet overview</h1>
|
||||
|
||||
@if (_clusters is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_clusters.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
No clusters configured yet. <a href="/clusters/new">Create the first cluster</a>.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body"><h6 class="text-muted">Clusters</h6><div class="fs-2">@_clusters.Count</div></div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body"><h6 class="text-muted">Active drafts</h6><div class="fs-2">@_activeDraftCount</div></div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body"><h6 class="text-muted">Published generations</h6><div class="fs-2">@_publishedCount</div></div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body"><h6 class="text-muted">Disabled clusters</h6><div class="fs-2">@_clusters.Count(c => !c.Enabled)</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4 mb-3">Clusters</h4>
|
||||
<table class="table table-hover">
|
||||
<thead><tr><th>ClusterId</th><th>Name</th><th>Enterprise / Site</th><th>Redundancy</th><th>Enabled</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<tr style="cursor: pointer;">
|
||||
<td><code>@c.ClusterId</code></td>
|
||||
<td>@c.Name</td>
|
||||
<td>@c.Enterprise / @c.Site</td>
|
||||
<td>@c.RedundancyMode</td>
|
||||
<td>@(c.Enabled ? "Yes" : "No")</td>
|
||||
<td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ServerCluster>? _clusters;
|
||||
private int _activeDraftCount;
|
||||
private int _publishedCount;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
|
||||
foreach (var c in _clusters)
|
||||
{
|
||||
var gens = await GenerationSvc.ListRecentAsync(c.ClusterId, 50, CancellationToken.None);
|
||||
_activeDraftCount += gens.Count(g => g.Status.ToString() == "Draft");
|
||||
_publishedCount += gens.Count(g => g.Status.ToString() == "Published");
|
||||
}
|
||||
}
|
||||
}
|
||||
233
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor
Normal file
233
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor
Normal file
@@ -0,0 +1,233 @@
|
||||
@page "/hosts"
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h1 class="mb-4">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">
|
||||
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
|
||||
Refresh
|
||||
</button>
|
||||
<span class="text-muted small">
|
||||
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info small mb-4">
|
||||
Each row is one host reported by a driver instance on a server node. Galaxy drivers report
|
||||
per-Platform / per-AppEngine entries; Modbus drivers report the PLC endpoint. Rows age out
|
||||
of the Server's publisher on every 10-second heartbeat — rows whose LastSeen is older than
|
||||
30s are flagged Stale, which usually means the owning Server process has crashed or lost
|
||||
its DB connection.
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<div class="alert alert-secondary">
|
||||
No host-status rows yet. The Server publishes its first tick 2s after startup; if this list stays empty, check that the Server is running and the driver implements <code>IHostConnectivityProbe</code>.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Hosts</h6>
|
||||
<div class="fs-3">@_rows.Count</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-success"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Running</h6>
|
||||
<div class="fs-3 text-success">@_rows.Count(r => r.State == DriverHostState.Running && !HostStatusService.IsStale(r))</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-warning"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Stale</h6>
|
||||
<div class="fs-3 text-warning">@_rows.Count(HostStatusService.IsStale)</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-danger"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Faulted</h6>
|
||||
<div class="fs-3 text-danger">@_rows.Count(r => r.State == DriverHostState.Faulted)</div>
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@if (_rows.Any(HostStatusService.IsFlagged))
|
||||
{
|
||||
var flaggedCount = _rows.Count(HostStatusService.IsFlagged);
|
||||
<div class="alert alert-danger small mb-3">
|
||||
<strong>@flaggedCount host@(flaggedCount == 1 ? "" : "s")</strong>
|
||||
reporting ≥ @HostStatusService.FailureFlagThreshold consecutive failures — circuit breaker
|
||||
may trip soon. Inspect the resilience columns below to locate.
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key))
|
||||
{
|
||||
<h2 class="h5 mt-4">Cluster: <code>@cluster.Key</code></h2>
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Driver</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th class="text-end" title="Consecutive failures — resets when a call succeeds or the breaker closes">Fail#</th>
|
||||
<th class="text-end" title="In-flight capability calls (bulkhead-depth proxy)">In-flight</th>
|
||||
<th>Breaker opened</th>
|
||||
<th>Last transition</th>
|
||||
<th>Last seen</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in cluster)
|
||||
{
|
||||
<tr class="@RowClass(r)">
|
||||
<td><code>@r.NodeId</code></td>
|
||||
<td><code>@r.DriverInstanceId</code></td>
|
||||
<td>@r.HostName</td>
|
||||
<td>
|
||||
<span class="badge @StateBadge(r.State)">@r.State</span>
|
||||
@if (HostStatusService.IsStale(r))
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1">Stale</span>
|
||||
}
|
||||
@if (HostStatusService.IsFlagged(r))
|
||||
{
|
||||
<span class="badge bg-danger ms-1" title="≥ @HostStatusService.FailureFlagThreshold consecutive failures">Flagged</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end small @(HostStatusService.IsFlagged(r) ? "text-danger fw-bold" : "")">
|
||||
@r.ConsecutiveFailures
|
||||
</td>
|
||||
<td class="text-end small">@r.CurrentBulkheadDepth</td>
|
||||
<td class="small">
|
||||
@(r.LastCircuitBreakerOpenUtc is null ? "—" : FormatAge(r.LastCircuitBreakerOpenUtc.Value))
|
||||
</td>
|
||||
<td class="small">@FormatAge(r.StateChangedUtc)</td>
|
||||
<td class="small @(HostStatusService.IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td>
|
||||
<td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
// Mirrors HostStatusPublisher.HeartbeatInterval — polling ahead of the broadcaster
|
||||
// produces stale-looking rows mid-cycle.
|
||||
private const int RefreshIntervalSeconds = 10;
|
||||
|
||||
private List<HostStatusRow>? _rows;
|
||||
private bool _refreshing;
|
||||
private DateTime? _lastRefreshUtc;
|
||||
private Timer? _timer;
|
||||
private HubConnection? _hub;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshAsync();
|
||||
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
|
||||
state: null,
|
||||
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||
await ConnectHubAsync();
|
||||
}
|
||||
|
||||
// Phase 6.1 Stream E.2 — subscribe to FleetStatusHub so resilience deltas upsert the
|
||||
// matching row without waiting for the next RefreshIntervalSeconds tick. The 10 s
|
||||
// poll stays as a safety net in case the hub connection is down.
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
var hubUrl = Nav.ToAbsoluteUri("/hubs/fleet");
|
||||
_hub = new HubConnectionBuilder().WithUrl(hubUrl).WithAutomaticReconnect().Build();
|
||||
_hub.On<ResilienceStatusChangedMessage>("ResilienceStatusChanged", OnResilienceChanged);
|
||||
try
|
||||
{
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeFleet");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Hub is best-effort; polling refresh is the fallback. Swallow connect errors
|
||||
// so the page still renders against the initial RefreshAsync pass.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(ResilienceStatusChangedMessage msg)
|
||||
{
|
||||
if (_rows is null) return;
|
||||
var idx = _rows.FindIndex(r =>
|
||||
r.DriverInstanceId == msg.DriverInstanceId && r.HostName == msg.HostName);
|
||||
if (idx < 0) return;
|
||||
var prior = _rows[idx];
|
||||
_rows[idx] = prior with
|
||||
{
|
||||
ConsecutiveFailures = msg.ConsecutiveFailures,
|
||||
LastCircuitBreakerOpenUtc = msg.LastCircuitBreakerOpenUtc,
|
||||
CurrentBulkheadDepth = msg.CurrentBulkheadDepth,
|
||||
LastRecycleUtc = msg.LastRecycleUtc,
|
||||
};
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
if (_refreshing) return;
|
||||
_refreshing = true;
|
||||
try
|
||||
{
|
||||
using var scope = ScopeFactory.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<HostStatusService>();
|
||||
_rows = (await svc.ListAsync()).ToList();
|
||||
_lastRefreshUtc = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string RowClass(HostStatusRow r) => r.State switch
|
||||
{
|
||||
DriverHostState.Faulted => "table-danger",
|
||||
_ when HostStatusService.IsStale(r) => "table-warning",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static string StateBadge(DriverHostState s) => s switch
|
||||
{
|
||||
DriverHostState.Running => "bg-success",
|
||||
DriverHostState.Stopped => "bg-secondary",
|
||||
DriverHostState.Faulted => "bg-danger",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime t)
|
||||
{
|
||||
var age = DateTime.UtcNow - t;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_timer?.Dispose();
|
||||
if (_hub is not null)
|
||||
{
|
||||
try { await _hub.DisposeAsync(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
100
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor
Normal file
100
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor
Normal file
@@ -0,0 +1,100 @@
|
||||
@page "/login"
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Authentication.Cookies
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Security
|
||||
@inject IHttpContextAccessor Http
|
||||
@inject ILdapAuthService LdapAuth
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="mb-4">OtOpcUa Admin — sign in</h4>
|
||||
|
||||
<EditForm Model="_input" OnValidSubmit="SignInAsync" FormName="login">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<InputText @bind-Value="_input.Username" class="form-control" autocomplete="username"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<InputText type="password" @bind-Value="_input.Password" class="form-control" autocomplete="current-password"/>
|
||||
</div>
|
||||
|
||||
@if (_error is not null) { <div class="alert alert-danger">@_error</div> }
|
||||
|
||||
<button class="btn btn-primary w-100" type="submit" disabled="@_busy">
|
||||
@(_busy ? "Signing in…" : "Sign in")
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
<hr/>
|
||||
<small class="text-muted">
|
||||
LDAP bind against the configured directory. Dev defaults to GLAuth on
|
||||
<code>localhost:3893</code>.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private sealed class Input
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private Input _input = new();
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
private async Task SignInAsync()
|
||||
{
|
||||
_error = null;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_input.Username) || string.IsNullOrWhiteSpace(_input.Password))
|
||||
{
|
||||
_error = "Username and password are required";
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await LdapAuth.AuthenticateAsync(_input.Username, _input.Password, CancellationToken.None);
|
||||
if (!result.Success)
|
||||
{
|
||||
_error = result.Error ?? "Sign-in failed";
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Roles.Count == 0)
|
||||
{
|
||||
_error = "Sign-in succeeded but no Admin roles mapped for your LDAP groups. Contact your administrator.";
|
||||
return;
|
||||
}
|
||||
|
||||
var ctx = Http.HttpContext
|
||||
?? throw new InvalidOperationException("HttpContext unavailable at sign-in");
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, result.DisplayName ?? result.Username ?? _input.Username),
|
||||
new(ClaimTypes.NameIdentifier, _input.Username),
|
||||
};
|
||||
foreach (var role in result.Roles)
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
foreach (var group in result.Groups)
|
||||
claims.Add(new Claim("ldap_group", group));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity));
|
||||
|
||||
ctx.Response.Redirect("/");
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
|
||||
@*
|
||||
#145 — Live address-string parser preview for Modbus tag editing. Bound to a string
|
||||
AddressString; on every input keystroke the parser runs and surfaces the resolved
|
||||
breakdown (Region, PduOffset, DataType, Bit, ByteOrder, ArrayCount, StringLength) or
|
||||
the parse error. Family flag drives the parser's family-native branch (#144).
|
||||
|
||||
Re-uses the same ModbusAddressParser the wire driver uses, so grammar drift is
|
||||
impossible by construction. Internal-namespace component called from the larger
|
||||
DriverInstance editor.
|
||||
*@
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Address string</label>
|
||||
<input type="text" class="form-control @(IsValid ? "is-valid" : Diagnostic is null ? "" : "is-invalid")"
|
||||
value="@AddressString"
|
||||
@oninput="@OnInputChanged"
|
||||
placeholder="e.g. 40001:F:CDAB:5"/>
|
||||
@if (IsValid && _parsed is not null)
|
||||
{
|
||||
<div class="form-text text-success">
|
||||
<strong>Parsed:</strong>
|
||||
Region=<code>@_parsed.Region</code>
|
||||
Offset=<code>@_parsed.Offset</code>
|
||||
Type=<code>@_parsed.DataType</code>
|
||||
@if (_parsed.Bit.HasValue) { <text>Bit=<code>@_parsed.Bit</code></text> }
|
||||
@if (_parsed.ByteOrder != ModbusByteOrder.BigEndian) { <text>Order=<code>@_parsed.ByteOrder</code></text> }
|
||||
@if (_parsed.ArrayCount.HasValue) { <text>Array[<code>@_parsed.ArrayCount</code>]</text> }
|
||||
@if (_parsed.StringLength > 0) { <text>StrLen=<code>@_parsed.StringLength</code></text> }
|
||||
</div>
|
||||
}
|
||||
else if (Diagnostic is not null)
|
||||
{
|
||||
<div class="invalid-feedback">@Diagnostic</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? AddressString { get; set; }
|
||||
[Parameter] public EventCallback<string?> AddressStringChanged { get; set; }
|
||||
[Parameter] public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
||||
[Parameter] public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
|
||||
[Parameter] public EventCallback<ParsedModbusAddress?> ParsedChanged { get; set; }
|
||||
|
||||
private ParsedModbusAddress? _parsed;
|
||||
private string? Diagnostic;
|
||||
private bool IsValid => _parsed is not null && Diagnostic is null;
|
||||
|
||||
protected override void OnParametersSet() => Reparse();
|
||||
|
||||
private async Task OnInputChanged(ChangeEventArgs e)
|
||||
{
|
||||
AddressString = e.Value as string;
|
||||
await AddressStringChanged.InvokeAsync(AddressString);
|
||||
Reparse();
|
||||
await ParsedChanged.InvokeAsync(_parsed);
|
||||
}
|
||||
|
||||
private void Reparse()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(AddressString))
|
||||
{
|
||||
_parsed = null;
|
||||
Diagnostic = null;
|
||||
return;
|
||||
}
|
||||
if (ModbusAddressParser.TryParse(AddressString, Family, MelsecSubFamily, out var parsed, out var err))
|
||||
{
|
||||
_parsed = parsed;
|
||||
Diagnostic = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_parsed = null;
|
||||
Diagnostic = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
@page "/modbus/address-preview"
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
|
||||
@*
|
||||
#149 — standalone preview / sanity-check tool for Modbus address strings. The Admin UI
|
||||
doesn't yet have a per-tag CRUD surface (tags are seeded via SQL or arrive at runtime
|
||||
through ITagDiscovery), so the ModbusAddressEditor component shipped in #145 needs a
|
||||
page where operators can paste an address string and confirm it parses to what they
|
||||
expect before committing it to a config row.
|
||||
|
||||
Doubles as a "did the parser ship correctly" smoke target for QA + a copy-pasteable
|
||||
grammar reference for users skimming the docs.
|
||||
*@
|
||||
|
||||
<PageTitle>Modbus address preview</PageTitle>
|
||||
|
||||
<div class="container py-4">
|
||||
<h1>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 <code>DriverConfig</code>.
|
||||
Full grammar: <a href="https://github.com/" target="_blank">docs/v2/modbus-addressing.md</a>.
|
||||
</p>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">PLC family hint (drives the family-native branch)</label>
|
||||
<select class="form-select" @bind="_family">
|
||||
@foreach (var f in Enum.GetValues<ModbusFamily>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (_family == ModbusFamily.MELSEC)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">MELSEC sub-family</label>
|
||||
<select class="form-select" @bind="_melsecSubFamily">
|
||||
@foreach (var f in Enum.GetValues<MelsecFamily>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<ModbusAddressEditor @bind-AddressString="_address"
|
||||
Family="_family"
|
||||
MelsecSubFamily="_melsecSubFamily"/>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-5">Quick-reference grammar</h3>
|
||||
<pre class="bg-light p-3 rounded small">@_grammarReference</pre>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? _address;
|
||||
private ModbusFamily _family = ModbusFamily.Generic;
|
||||
private MelsecFamily _melsecSubFamily = MelsecFamily.Q_L_iQR;
|
||||
|
||||
// Held as a const string rather than inline markup so the Razor parser doesn't try to
|
||||
// interpret the angle-bracket grammar tokens as element open/close.
|
||||
private const string _grammarReference = @"<region><offset>[.<bit>][:<type>[<len>]][:<order>][:<count>]
|
||||
|
||||
Examples (post-#146 type codes):
|
||||
40001 HoldingRegisters[0], Int16
|
||||
400001 same, 6-digit form
|
||||
40001:F Float32 (HR[0..1])
|
||||
40001:F:CDAB Float32 word-swapped
|
||||
40001:STR20 20-char ASCII string
|
||||
40001:S:5 Int16[5] array (3-field shorthand)
|
||||
40001:I:CDAB:10 Int32[10] word-swapped (4-field strict)
|
||||
40001.5 bit 5 of HR[0]
|
||||
HR1:I Int32 via mnemonic region (matches Wonderware)
|
||||
C100 Coil 100 (mnemonic, 1-based)
|
||||
V2000:F:CDAB DL205 V-memory at PDU 1024 (Family=DL205)
|
||||
D100:I MELSEC D-register 100, Int32 (Family=MELSEC)
|
||||
|
||||
Type codes: BOOL, S (Int16), US (UInt16), I (Int32), UI (UInt32),
|
||||
I_64 (Int64), UI_64 (UInt64), F, D, BCD, BCD_32, STR<n>
|
||||
Byte order: ABCD (BE default), CDAB (word-swap), BADC (byte-swap), DCBA (full reverse)";
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
@page "/modbus/diagnostics/{DriverInstanceId}"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@inject DriverDiagnosticsClient Diagnostics
|
||||
|
||||
@*
|
||||
#154 — operator-facing view of the Server's auto-prohibition state for a Modbus driver.
|
||||
Fetches via DriverDiagnosticsClient (HttpClient against the Server's HealthEndpointsHost).
|
||||
Refreshes on demand; auto-refresh is a future task once a SignalR diag channel exists.
|
||||
*@
|
||||
|
||||
<PageTitle>Modbus diagnostics — @DriverInstanceId</PageTitle>
|
||||
|
||||
<div class="container py-4">
|
||||
<h1>Modbus auto-prohibitions</h1>
|
||||
<p class="text-muted">
|
||||
Driver instance <code>@DriverInstanceId</code>. Live snapshot of coalesced ranges
|
||||
the planner has learned to read individually (#148 / #150 / #151 / #152).
|
||||
</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="LoadAsync" disabled="@_loading">
|
||||
@(_loading ? "Loading…" : "Refresh")
|
||||
</button>
|
||||
@if (_lastRefreshed is not null)
|
||||
{
|
||||
<span class="text-muted ms-3 small">Last refreshed @_lastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss")</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_error is not null)
|
||||
{
|
||||
<div class="alert alert-danger">@_error</div>
|
||||
}
|
||||
else if (_response is null)
|
||||
{
|
||||
<p class="text-muted">Click <strong>Refresh</strong> to load.</p>
|
||||
}
|
||||
else if (_response.Count == 0)
|
||||
{
|
||||
<div class="alert alert-success">No auto-prohibitions. The planner is coalescing freely.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Unit</th>
|
||||
<th>Region</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Span</th>
|
||||
<th>Status</th>
|
||||
<th>Last probed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _response.Ranges.OrderBy(r => r.UnitId).ThenBy(r => r.Region).ThenBy(r => r.StartAddress))
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.UnitId</code></td>
|
||||
<td><code>@r.Region</code></td>
|
||||
<td><code>@r.StartAddress</code></td>
|
||||
<td><code>@r.EndAddress</code></td>
|
||||
<td>@(r.EndAddress - r.StartAddress + 1)</td>
|
||||
<td>
|
||||
@if (r.BisectionPending)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">BISECTING</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">ISOLATED</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small text-muted">@FormatTimeSince(r.LastProbedUtc)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string DriverInstanceId { get; set; } = string.Empty;
|
||||
|
||||
private ModbusAutoProhibitionsResponse? _response;
|
||||
private string? _error;
|
||||
private bool _loading;
|
||||
private DateTime? _lastRefreshed;
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
_response = await Diagnostics.GetModbusAutoProhibitedRangesAsync(DriverInstanceId);
|
||||
_lastRefreshed = DateTime.UtcNow;
|
||||
if (_response is null)
|
||||
_error = $"Server reports driver '{DriverInstanceId}' is not present or is not a Modbus driver.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Fetch failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatTimeSince(DateTime utc)
|
||||
{
|
||||
var span = DateTime.UtcNow - utc;
|
||||
if (span.TotalSeconds < 60) return $"{(int)span.TotalSeconds}s ago";
|
||||
if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes}m ago";
|
||||
if (span.TotalHours < 24) return $"{(int)span.TotalHours}h ago";
|
||||
return $"{(int)span.TotalDays}d ago";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
|
||||
@*
|
||||
#145 — Driver-instance options panel for the Modbus driver. Surfaces every option group
|
||||
added by #136-#144 so users can configure the driver via the UI rather than hand-editing
|
||||
DriverConfig JSON. Bound to a ModbusOptionsViewModel; the parent page round-trips that
|
||||
model to the DriverConfig.json column on save.
|
||||
*@
|
||||
|
||||
<div class="modbus-options-editor">
|
||||
|
||||
<h5>Connection</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">Host</label>
|
||||
<input class="form-control" @bind="Model.Host"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" class="form-control" @bind="Model.Port"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Default UnitId</label>
|
||||
<input type="number" class="form-control" @bind="Model.UnitId"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>Family (#144)</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">PLC family</label>
|
||||
<select class="form-select" @bind="Model.Family">
|
||||
@foreach (var f in Enum.GetValues<ModbusFamily>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (Model.Family == ModbusFamily.MELSEC)
|
||||
{
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">MELSEC sub-family</label>
|
||||
<select class="form-select" @bind="Model.MelsecSubFamily">
|
||||
@foreach (var f in Enum.GetValues<MelsecFamily>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h5>Keep-alive (#139)</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-3">
|
||||
<div class="form-check mt-4">
|
||||
<input type="checkbox" class="form-check-input" @bind="Model.KeepAliveEnabled"/>
|
||||
<label class="form-check-label">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Time (s)</label>
|
||||
<input type="number" class="form-control" @bind="Model.KeepAliveTimeSec"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Interval (s)</label>
|
||||
<input type="number" class="form-control" @bind="Model.KeepAliveIntervalSec"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Retry count</label>
|
||||
<input type="number" class="form-control" @bind="Model.KeepAliveRetryCount"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>Reconnect (#139)</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label">Initial delay (ms)</label>
|
||||
<input type="number" class="form-control" @bind="Model.ReconnectInitialDelayMs"/>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label">Max delay (ms)</label>
|
||||
<input type="number" class="form-control" @bind="Model.ReconnectMaxDelayMs"/>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label">Backoff multiplier</label>
|
||||
<input type="number" step="0.1" class="form-control" @bind="Model.ReconnectBackoffMultiplier"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>Protocol (#140)</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Max regs / read</label>
|
||||
<input type="number" class="form-control" @bind="Model.MaxRegistersPerRead"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Max regs / write</label>
|
||||
<input type="number" class="form-control" @bind="Model.MaxRegistersPerWrite"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Max coils / read</label>
|
||||
<input type="number" class="form-control" @bind="Model.MaxCoilsPerRead"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Max read gap (#143)</label>
|
||||
<input type="number" class="form-control" @bind="Model.MaxReadGap"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="Model.UseFC15ForSingleCoilWrites"/>
|
||||
<label class="form-check-label">Use FC15 for single coil</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="Model.UseFC16ForSingleRegisterWrites"/>
|
||||
<label class="form-check-label">Use FC16 for single reg</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="Model.WriteOnChangeOnly"/>
|
||||
<label class="form-check-label">Write-on-change only (#141)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public ModbusOptionsViewModel Model { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// UI binding model. Maps 1:1 onto the JSON DTO the driver factory accepts; serialised
|
||||
/// to DriverConfig.json by the calling save handler. Defaults match
|
||||
/// <c>ModbusDriverOptions</c> defaults so unedited rows produce the historical wire
|
||||
/// output verbatim.
|
||||
/// </summary>
|
||||
public sealed class ModbusOptionsViewModel
|
||||
{
|
||||
public string Host { get; set; } = "127.0.0.1";
|
||||
public int Port { get; set; } = 502;
|
||||
public byte UnitId { get; set; } = 1;
|
||||
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
||||
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
|
||||
|
||||
public bool KeepAliveEnabled { get; set; } = true;
|
||||
public int KeepAliveTimeSec { get; set; } = 30;
|
||||
public int KeepAliveIntervalSec { get; set; } = 10;
|
||||
public int KeepAliveRetryCount { get; set; } = 3;
|
||||
|
||||
public int ReconnectInitialDelayMs { get; set; } = 0;
|
||||
public int ReconnectMaxDelayMs { get; set; } = 30000;
|
||||
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
|
||||
|
||||
public int MaxRegistersPerRead { get; set; } = 125;
|
||||
public int MaxRegistersPerWrite { get; set; } = 123;
|
||||
public int MaxCoilsPerRead { get; set; } = 2000;
|
||||
public int MaxReadGap { get; set; } = 0;
|
||||
|
||||
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
|
||||
public bool UseFC16ForSingleRegisterWrites { get; set; } = false;
|
||||
public bool WriteOnChangeOnly { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
@page "/reservations"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize(Policy = "CanPublish")]
|
||||
@inject ReservationService ReservationSvc
|
||||
|
||||
<h1 class="mb-4">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
|
||||
retired and its ID needs to be reused by a different equipment.
|
||||
</p>
|
||||
|
||||
<h4 class="mt-4">Active</h4>
|
||||
@if (_active is null) { <p>Loading…</p> }
|
||||
else if (_active.Count == 0) { <p class="text-muted">No active reservations.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>Kind</th><th>Value</th><th>EquipmentUuid</th><th>Cluster</th><th>First published</th><th>Last published</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _active)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.Kind</code></td>
|
||||
<td><code>@r.Value</code></td>
|
||||
<td><code>@r.EquipmentUuid</code></td>
|
||||
<td>@r.ClusterId</td>
|
||||
<td><small>@r.FirstPublishedAt.ToString("u") by @r.FirstPublishedBy</small></td>
|
||||
<td><small>@r.LastPublishedAt.ToString("u")</small></td>
|
||||
<td><button class="btn btn-sm btn-outline-danger" @onclick='() => OpenReleaseDialog(r)'>Release…</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h4 class="mt-4">Released (most recent 100)</h4>
|
||||
@if (_released is null) { <p>Loading…</p> }
|
||||
else if (_released.Count == 0) { <p class="text-muted">No released reservations yet.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>Kind</th><th>Value</th><th>Released at</th><th>By</th><th>Reason</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _released)
|
||||
{
|
||||
<tr><td><code>@r.Kind</code></td><td><code>@r.Value</code></td><td>@r.ReleasedAt?.ToString("u")</td><td>@r.ReleasedBy</td><td>@r.ReleaseReason</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_releasing is not null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Release reservation <code>@_releasing.Kind</code> = <code>@_releasing.Value</code></h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This makes the (Kind, Value) pair available for a different EquipmentUuid in a future publish. Audit-logged.</p>
|
||||
<label class="form-label">Reason (required)</label>
|
||||
<textarea class="form-control" rows="3" @bind="_reason"></textarea>
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-2">@_error</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick='() => _releasing = null'>Cancel</button>
|
||||
<button class="btn btn-danger" @onclick="ReleaseAsync" disabled="@_busy">Release</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ExternalIdReservation>? _active;
|
||||
private List<ExternalIdReservation>? _released;
|
||||
private ExternalIdReservation? _releasing;
|
||||
private string _reason = string.Empty;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_active = await ReservationSvc.ListActiveAsync(CancellationToken.None);
|
||||
_released = await ReservationSvc.ListReleasedAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private void OpenReleaseDialog(ExternalIdReservation r)
|
||||
{
|
||||
_releasing = r;
|
||||
_reason = string.Empty;
|
||||
_error = null;
|
||||
}
|
||||
|
||||
private async Task ReleaseAsync()
|
||||
{
|
||||
if (_releasing is null || string.IsNullOrWhiteSpace(_reason)) { _error = "Reason is required"; return; }
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
await ReservationSvc.ReleaseAsync(_releasing.Kind.ToString(), _releasing.Value, _reason, CancellationToken.None);
|
||||
_releasing = null;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
@page "/role-grants"
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Services
|
||||
@inject ILdapGroupRoleMappingService RoleSvc
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject AclChangeNotifier Notifier
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h1 class="mb-4">LDAP group → Admin role grants</h1>
|
||||
|
||||
<div class="alert alert-info small mb-4">
|
||||
Maps LDAP groups to Admin UI roles (ConfigViewer / ConfigEditor / FleetAdmin). Control-plane
|
||||
only — OPC UA data-path authorization reads <code>NodeAcl</code> rows directly and is
|
||||
unaffected by these mappings (see decision #150). A fleet-wide grant applies across every
|
||||
cluster; a cluster-scoped grant only binds within the named cluster. The same LDAP group
|
||||
may hold different roles on different clusters.
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No role grants defined yet. Without at least one FleetAdmin grant,
|
||||
only the bootstrap admin can publish drafts.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr><th>LDAP group</th><th>Role</th><th>Scope</th><th>Created</th><th>Notes</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.LdapGroup</code></td>
|
||||
<td><span class="badge bg-secondary">@r.Role</span></td>
|
||||
<td>@(r.IsSystemWide ? "Fleet-wide" : $"Cluster: {r.ClusterId}")</td>
|
||||
<td class="small">@r.CreatedAtUtc.ToString("yyyy-MM-dd")</td>
|
||||
<td class="small text-muted">@r.Notes</td>
|
||||
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(r.Id)">Revoke</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h5>New role grant</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">LDAP group (DN)</label>
|
||||
<input class="form-control" @bind="_group" placeholder="cn=fleet-admin,ou=groups,dc=…"/>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Role</label>
|
||||
<select class="form-select" @bind="_role">
|
||||
@foreach (var r in Enum.GetValues<AdminRole>())
|
||||
{
|
||||
<option value="@r">@r</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 pt-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="systemWide" @bind="_isSystemWide"/>
|
||||
<label class="form-check-label" for="systemWide">Fleet-wide</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Cluster @(_isSystemWide ? "(disabled)" : "")</label>
|
||||
<select class="form-select" @bind="_clusterId" disabled="@_isSystemWide">
|
||||
<option value="">-- select --</option>
|
||||
@if (_clusters is not null)
|
||||
{
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<option value="@c.ClusterId">@c.ClusterId</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes (optional)</label>
|
||||
<input class="form-control" @bind="_notes"/>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<LdapGroupRoleMapping>? _rows;
|
||||
private List<ServerCluster>? _clusters;
|
||||
private bool _showForm;
|
||||
private string _group = string.Empty;
|
||||
private AdminRole _role = AdminRole.ConfigViewer;
|
||||
private bool _isSystemWide;
|
||||
private string _clusterId = string.Empty;
|
||||
private string? _notes;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_rows = await RoleSvc.ListAllAsync(CancellationToken.None);
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private void StartAdd()
|
||||
{
|
||||
_group = string.Empty;
|
||||
_role = AdminRole.ConfigViewer;
|
||||
_isSystemWide = false;
|
||||
_clusterId = string.Empty;
|
||||
_notes = null;
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var row = new LdapGroupRoleMapping
|
||||
{
|
||||
LdapGroup = _group.Trim(),
|
||||
Role = _role,
|
||||
IsSystemWide = _isSystemWide,
|
||||
ClusterId = _isSystemWide ? null : (string.IsNullOrWhiteSpace(_clusterId) ? null : _clusterId),
|
||||
Notes = string.IsNullOrWhiteSpace(_notes) ? null : _notes,
|
||||
};
|
||||
await RoleSvc.CreateAsync(row, CancellationToken.None);
|
||||
await Notifier.NotifyRoleGrantsChangedAsync(CancellationToken.None);
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Guid id)
|
||||
{
|
||||
await RoleSvc.DeleteAsync(id, CancellationToken.None);
|
||||
await Notifier.NotifyRoleGrantsChangedAsync(CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private HubConnection? _hub;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || _hub is not null) return;
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
_hub.On<RoleGrantsChangedMessage>("RoleGrantsChanged", async _ =>
|
||||
{
|
||||
await ReloadAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeFleet");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null) { await _hub.DisposeAsync(); _hub = null; }
|
||||
}
|
||||
}
|
||||
11
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Routes.razor
Normal file
11
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Routes.razor
Normal file
@@ -0,0 +1,11 @@
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
|
||||
|
||||
<Router AppAssembly="@typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="@typeof(MainLayout)"><p>Not found.</p></LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
14
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor
Normal file
14
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor
Normal file
@@ -0,0 +1,14 @@
|
||||
@using System.Net.Http
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Http
|
||||
@using Microsoft.JSInterop
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components
|
||||
@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
|
||||
31
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Hubs/AlertHub.cs
Normal file
31
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Hubs/AlertHub.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes sticky alerts (crash-loop circuit trips, failed applies, reservation-release
|
||||
/// anomalies) to subscribed admin clients. Alerts don't auto-clear — the operator acks them
|
||||
/// from the UI via <see cref="AcknowledgeAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class AlertHub : Hub
|
||||
{
|
||||
public const string AllAlertsGroup = "__alerts__";
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, AllAlertsGroup);
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Client-initiated ack. The server side of ack persistence is deferred — v2.1.</summary>
|
||||
public Task AcknowledgeAsync(string alertId) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
public sealed record AlertMessage(
|
||||
string AlertId,
|
||||
string Severity,
|
||||
string Title,
|
||||
string Detail,
|
||||
DateTime RaisedAtUtc,
|
||||
string? ClusterId,
|
||||
string? NodeId);
|
||||
54
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusHub.cs
Normal file
54
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusHub.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes per-node generation-apply state changes (<c>ClusterNodeGenerationState</c>) to
|
||||
/// subscribed browser clients. Clients call <c>SubscribeCluster(clusterId)</c> on connect to
|
||||
/// scope notifications; the server sends <c>NodeStateChanged</c> messages whenever the poller
|
||||
/// observes a delta.
|
||||
/// </summary>
|
||||
public sealed class FleetStatusHub : Hub
|
||||
{
|
||||
public Task SubscribeCluster(string clusterId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clusterId)) return Task.CompletedTask;
|
||||
return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(clusterId));
|
||||
}
|
||||
|
||||
public Task UnsubscribeCluster(string clusterId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clusterId)) return Task.CompletedTask;
|
||||
return Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(clusterId));
|
||||
}
|
||||
|
||||
/// <summary>Clients call this once to also receive fleet-wide status — used by the dashboard.</summary>
|
||||
public Task SubscribeFleet() => Groups.AddToGroupAsync(Context.ConnectionId, FleetGroup);
|
||||
|
||||
public const string FleetGroup = "__fleet__";
|
||||
public static string GroupName(string clusterId) => $"cluster:{clusterId}";
|
||||
}
|
||||
|
||||
public sealed record NodeStateChangedMessage(
|
||||
string NodeId,
|
||||
string ClusterId,
|
||||
long? CurrentGenerationId,
|
||||
string? LastAppliedStatus,
|
||||
string? LastAppliedError,
|
||||
DateTime? LastAppliedAt,
|
||||
DateTime? LastSeenAt);
|
||||
|
||||
/// <summary>
|
||||
/// Pushed by <c>FleetStatusPoller</c> when it observes a change in a
|
||||
/// <c>DriverInstanceResilienceStatus</c> row. Closes the last Phase 6.1 Stream E.2/E.3
|
||||
/// deferral — lets the Admin <c>/hosts</c> page upsert the matching row without the
|
||||
/// 10-second polling round-trip. Keyed on (DriverInstanceId, HostName); the client
|
||||
/// fan-outs to the matching row by matching both.
|
||||
/// </summary>
|
||||
public sealed record ResilienceStatusChangedMessage(
|
||||
string DriverInstanceId,
|
||||
string HostName,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
int CurrentBulkheadDepth,
|
||||
DateTime? LastRecycleUtc);
|
||||
BIN
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusPoller.cs
Normal file
BIN
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusPoller.cs
Normal file
Binary file not shown.
140
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
Normal file
140
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenTelemetry.Metrics;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Components;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseSerilog((ctx, cfg) => cfg
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File("logs/otopcua-admin-.log", rollingInterval: RollingInterval.Day));
|
||||
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(o =>
|
||||
{
|
||||
o.Cookie.Name = "OtOpcUa.Admin";
|
||||
o.LoginPath = "/login";
|
||||
o.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy("CanEdit", p => p.RequireRole(AdminRoles.ConfigEditor, AdminRoles.FleetAdmin))
|
||||
.AddPolicy("CanPublish", p => p.RequireRole(AdminRoles.FleetAdmin));
|
||||
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||
opt.UseSqlServer(builder.Configuration.GetConnectionString("ConfigDb")
|
||||
?? throw new InvalidOperationException("ConnectionStrings:ConfigDb not configured")));
|
||||
|
||||
builder.Services.AddScoped<ClusterService>();
|
||||
builder.Services.AddScoped<GenerationService>();
|
||||
builder.Services.AddScoped<EquipmentService>();
|
||||
builder.Services.AddScoped<TagService>();
|
||||
builder.Services.AddScoped<UnsService>();
|
||||
builder.Services.AddScoped<NamespaceService>();
|
||||
builder.Services.AddScoped<DriverInstanceService>();
|
||||
builder.Services.AddScoped<FocasDriverDetailService>();
|
||||
|
||||
// #154 — Server diagnostics client. Default base URL points at the same machine's
|
||||
// HealthEndpointsHost (loopback :4841); deployments with remote Servers set
|
||||
// "DriverDiagnostics:ServerBaseUrl" in appsettings.json.
|
||||
builder.Services.AddHttpClient<DriverDiagnosticsClient>(client =>
|
||||
{
|
||||
var baseUrl = builder.Configuration["DriverDiagnostics:ServerBaseUrl"] ?? "http://localhost:4841/";
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
builder.Services.AddScoped<NodeAclService>();
|
||||
builder.Services.AddScoped<PermissionProbeService>();
|
||||
builder.Services.AddScoped<AclChangeNotifier>();
|
||||
builder.Services.AddScoped<ReservationService>();
|
||||
builder.Services.AddScoped<DraftValidationService>();
|
||||
builder.Services.AddScoped<AuditLogService>();
|
||||
builder.Services.AddScoped<HostStatusService>();
|
||||
builder.Services.AddScoped<ClusterNodeService>();
|
||||
builder.Services.AddSingleton<RedundancyMetrics>();
|
||||
builder.Services.AddScoped<EquipmentImportBatchService>();
|
||||
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
|
||||
|
||||
// Phase 7 Stream F — scripting + virtual tag + scripted alarm draft services, test
|
||||
// harness, and historian diagnostics. The historian sink is the Null variant here —
|
||||
// the real SqliteStoreAndForwardSink lives in the server process. Admin reads status
|
||||
// from whichever sink is provided at composition time.
|
||||
builder.Services.AddScoped<ScriptService>();
|
||||
builder.Services.AddScoped<VirtualTagService>();
|
||||
builder.Services.AddScoped<ScriptedAlarmService>();
|
||||
builder.Services.AddScoped<ScriptTestHarnessService>();
|
||||
builder.Services.AddScoped<HistorianDiagnosticsService>();
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.IAlarmHistorianSink>(
|
||||
ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.NullAlarmHistorianSink.Instance);
|
||||
|
||||
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
||||
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
||||
// filesystem operations.
|
||||
builder.Services.Configure<CertTrustOptions>(builder.Configuration.GetSection(CertTrustOptions.SectionName));
|
||||
builder.Services.AddSingleton<CertTrustService>();
|
||||
|
||||
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
|
||||
builder.Services.Configure<LdapOptions>(
|
||||
builder.Configuration.GetSection("Authentication:Ldap"));
|
||||
builder.Services.AddScoped<ILdapAuthService, LdapAuthService>();
|
||||
|
||||
// SignalR real-time fleet status + alerts (admin-ui.md §"Real-Time Updates").
|
||||
builder.Services.AddHostedService<FleetStatusPoller>();
|
||||
|
||||
// OpenTelemetry Prometheus exporter — Meter stream from RedundancyMetrics + any future
|
||||
// Admin-side instrumentation lands on the /metrics endpoint Prometheus scrapes. Pull-based
|
||||
// means no OTel Collector deployment required for the common deploy-in-a-K8s case; appsettings
|
||||
// Metrics:Prometheus:Enabled=false disables the endpoint entirely for locked-down deployments.
|
||||
var metricsEnabled = builder.Configuration.GetValue("Metrics:Prometheus:Enabled", true);
|
||||
if (metricsEnabled)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(m => m
|
||||
.AddMeter(RedundancyMetrics.MeterName)
|
||||
.AddPrometheusExporter());
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseStaticFiles();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapPost("/auth/logout", async (HttpContext ctx) =>
|
||||
{
|
||||
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
ctx.Response.Redirect("/");
|
||||
});
|
||||
|
||||
app.MapHub<FleetStatusHub>("/hubs/fleet");
|
||||
app.MapHub<AlertHub>("/hubs/alerts");
|
||||
|
||||
if (metricsEnabled)
|
||||
{
|
||||
// Prometheus scrape endpoint — expose instrumentation registered in the OTel MeterProvider
|
||||
// above. Emits text-format metrics at /metrics; auth is intentionally NOT required (Prometheus
|
||||
// scrape jobs typically run on a trusted network). Operators who need auth put the endpoint
|
||||
// behind a reverse-proxy basic-auth gate per fleet-ops convention.
|
||||
app.MapPrometheusScrapingEndpoint();
|
||||
}
|
||||
|
||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
public partial class Program;
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
public interface ILdapAuthService
|
||||
{
|
||||
Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>Outcome of an LDAP bind attempt. <see cref="Roles"/> is the mapped-set of Admin roles.</summary>
|
||||
public sealed record LdapAuthResult(
|
||||
bool Success,
|
||||
string? DisplayName,
|
||||
string? Username,
|
||||
IReadOnlyList<string> Groups,
|
||||
IReadOnlyList<string> Roles,
|
||||
string? Error);
|
||||
160
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthService.cs
Normal file
160
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthService.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novell.Directory.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP bind-and-search authentication mirrored from ScadaLink's <c>LdapAuthService</c>
|
||||
/// (CLAUDE.md memory: <c>scadalink_reference.md</c>) — same bind semantics, TLS guard, and
|
||||
/// service-account search-then-bind path. Adapted for the Admin app's role-mapping shape
|
||||
/// (LDAP group names → Admin roles via <see cref="LdapOptions.GroupToRole"/>).
|
||||
/// </summary>
|
||||
public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapAuthService> logger)
|
||||
: ILdapAuthService
|
||||
{
|
||||
private readonly LdapOptions _options = options.Value;
|
||||
|
||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return new(false, null, null, [], [], "Username is required");
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return new(false, null, null, [], [], "Password is required");
|
||||
|
||||
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
||||
return new(false, null, username, [], [],
|
||||
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new LdapConnection();
|
||||
if (_options.UseTls) conn.SecureSocketLayer = true;
|
||||
|
||||
await Task.Run(() => conn.Connect(_options.Server, _options.Port), ct);
|
||||
|
||||
var bindDn = await ResolveUserDnAsync(conn, username, ct);
|
||||
await Task.Run(() => conn.Bind(bindDn, password), ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
||||
await Task.Run(() => conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
||||
|
||||
var displayName = username;
|
||||
var groups = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var filter = $"(cn={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter,
|
||||
attrs: null, // request ALL attributes so we can inspect memberOf + dn-derived group
|
||||
typesOnly: false), ct);
|
||||
|
||||
while (results.HasMore())
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = results.Next();
|
||||
var name = entry.GetAttribute(_options.DisplayNameAttribute);
|
||||
if (name is not null) displayName = name.StringValue;
|
||||
|
||||
var groupAttr = entry.GetAttribute(_options.GroupAttribute);
|
||||
if (groupAttr is not null)
|
||||
{
|
||||
foreach (var groupDn in groupAttr.StringValueArray)
|
||||
groups.Add(ExtractFirstRdnValue(groupDn));
|
||||
}
|
||||
|
||||
// Fallback: GLAuth places users under ou=PrimaryGroup,baseDN. When the
|
||||
// directory doesn't populate memberOf (or populates it differently), the
|
||||
// user's primary group name is recoverable from the second RDN of the DN.
|
||||
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
|
||||
{
|
||||
var primary = ExtractOuSegment(entry.Dn);
|
||||
if (primary is not null) groups.Add(primary);
|
||||
}
|
||||
}
|
||||
catch (LdapException) { break; } // no-more-entries signalled by exception
|
||||
}
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
|
||||
}
|
||||
|
||||
conn.Disconnect();
|
||||
|
||||
var roles = RoleMapper.Map(groups, _options.GroupToRole);
|
||||
return new(true, displayName, username, groups, roles, null);
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP bind failed for {User}", username);
|
||||
return new(false, null, username, [], [], "Invalid username or password");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
|
||||
return new(false, null, username, [], [], "Unexpected authentication error");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
|
||||
{
|
||||
if (username.Contains('=')) return username; // already a DN
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
||||
{
|
||||
await Task.Run(() =>
|
||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
||||
|
||||
var filter = $"(uid={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
|
||||
|
||||
if (results.HasMore())
|
||||
return results.Next().Dn;
|
||||
|
||||
throw new LdapException("User not found", LdapException.NoSuchObject,
|
||||
$"No entry for uid={username}");
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(_options.SearchBase)
|
||||
? $"cn={username}"
|
||||
: $"cn={username},{_options.SearchBase}";
|
||||
}
|
||||
|
||||
internal static string EscapeLdapFilter(string input) =>
|
||||
input.Replace("\\", "\\5c")
|
||||
.Replace("*", "\\2a")
|
||||
.Replace("(", "\\28")
|
||||
.Replace(")", "\\29")
|
||||
.Replace("\0", "\\00");
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the first <c>ou=Value</c> segment from a DN. GLAuth encodes a user's primary
|
||||
/// group as an <c>ou=</c> RDN immediately above the user's <c>cn=</c>, so this recovers
|
||||
/// the group name when <see cref="LdapOptions.GroupAttribute"/> is absent from the entry.
|
||||
/// </summary>
|
||||
internal static string? ExtractOuSegment(string dn)
|
||||
{
|
||||
var segments = dn.Split(',');
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var trimmed = segment.Trim();
|
||||
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
|
||||
return trimmed[3..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string ExtractFirstRdnValue(string dn)
|
||||
{
|
||||
var equalsIdx = dn.IndexOf('=');
|
||||
if (equalsIdx < 0) return dn;
|
||||
|
||||
var valueStart = equalsIdx + 1;
|
||||
var commaIdx = dn.IndexOf(',', valueStart);
|
||||
return commaIdx > valueStart ? dn[valueStart..commaIdx] : dn[valueStart..];
|
||||
}
|
||||
}
|
||||
38
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapOptions.cs
Normal file
38
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapOptions.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP + role-mapping configuration for the Admin UI. Bound from <c>appsettings.json</c>
|
||||
/// <c>Authentication:Ldap</c> section. Defaults point at the local GLAuth dev instance (see
|
||||
/// <c>C:\publish\glauth\auth.md</c>).
|
||||
/// </summary>
|
||||
public sealed class LdapOptions
|
||||
{
|
||||
public const string SectionName = "Authentication:Ldap";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string Server { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 3893;
|
||||
public bool UseTls { get; set; }
|
||||
|
||||
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
||||
public bool AllowInsecureLdap { get; set; }
|
||||
|
||||
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
|
||||
|
||||
/// <summary>
|
||||
/// Service-account DN used for search-then-bind. When empty, a direct-bind with
|
||||
/// <c>cn={user},{SearchBase}</c> is attempted.
|
||||
/// </summary>
|
||||
public string ServiceAccountDn { get; set; } = string.Empty;
|
||||
public string ServiceAccountPassword { get; set; } = string.Empty;
|
||||
|
||||
public string DisplayNameAttribute { get; set; } = "cn";
|
||||
public string GroupAttribute { get; set; } = "memberOf";
|
||||
|
||||
/// <summary>
|
||||
/// Maps LDAP group name → Admin role. Group match is case-insensitive. A user gets every
|
||||
/// role whose source group is in their membership list. Example dev mapping:
|
||||
/// <code>"ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin"</code>
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
23
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/RoleMapper.cs
Normal file
23
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Security/RoleMapper.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic LDAP-group-to-Admin-role mapper driven by <see cref="LdapOptions.GroupToRole"/>.
|
||||
/// Every returned role corresponds to a group the user actually holds; no inference.
|
||||
/// </summary>
|
||||
public static class RoleMapper
|
||||
{
|
||||
public static IReadOnlyList<string> Map(
|
||||
IReadOnlyCollection<string> ldapGroups,
|
||||
IReadOnlyDictionary<string, string> groupToRole)
|
||||
{
|
||||
if (groupToRole.Count == 0) return [];
|
||||
|
||||
var roles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var group in ldapGroups)
|
||||
{
|
||||
if (groupToRole.TryGetValue(group, out var role))
|
||||
roles.Add(role);
|
||||
}
|
||||
return [.. roles];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin SignalR push helper for ACL + role-grant invalidation — slice 2 of task #196.
|
||||
/// Lets the Admin services + razor pages invalidate connected peers' views without each
|
||||
/// one having to know the hub wiring. Two message kinds: <c>NodeAclChanged</c> (cluster-scoped)
|
||||
/// and <c>RoleGrantsChanged</c> (fleet-wide — role mappings cross cluster boundaries).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Intentionally fire-and-forget — a failed hub send doesn't rollback the DB write that
|
||||
/// triggered it. Worst-case an operator sees stale data until their next poll or manual
|
||||
/// refresh; better than a transient hub blip blocking the authoritative write path.
|
||||
/// </remarks>
|
||||
public sealed class AclChangeNotifier(IHubContext<FleetStatusHub> fleetHub, ILogger<AclChangeNotifier> logger)
|
||||
{
|
||||
public async Task NotifyNodeAclChangedAsync(string clusterId, long generationId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = new NodeAclChangedMessage(ClusterId: clusterId, GenerationId: generationId, ObservedAtUtc: DateTime.UtcNow);
|
||||
await fleetHub.Clients.Group(FleetStatusHub.GroupName(clusterId))
|
||||
.SendAsync("NodeAclChanged", msg, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "NodeAclChanged push failed for cluster {ClusterId} gen {GenerationId}", clusterId, generationId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyRoleGrantsChangedAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = new RoleGrantsChangedMessage(ObservedAtUtc: DateTime.UtcNow);
|
||||
await fleetHub.Clients.Group(FleetStatusHub.FleetGroup)
|
||||
.SendAsync("RoleGrantsChanged", msg, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "RoleGrantsChanged push failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record NodeAclChangedMessage(string ClusterId, long GenerationId, DateTime ObservedAtUtc);
|
||||
public sealed record RoleGrantsChangedMessage(DateTime ObservedAtUtc);
|
||||
16
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs
Normal file
16
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The three admin roles per <c>admin-ui.md</c> §"Admin Roles" — mapped from LDAP groups at
|
||||
/// sign-in. Each role has a fixed set of capabilities (cluster CRUD, draft → publish, fleet
|
||||
/// admin). The ACL-driven runtime permissions (<c>NodePermissions</c>) govern OPC UA clients;
|
||||
/// these roles govern the Admin UI itself.
|
||||
/// </summary>
|
||||
public static class AdminRoles
|
||||
{
|
||||
public const string ConfigViewer = "ConfigViewer";
|
||||
public const string ConfigEditor = "ConfigEditor";
|
||||
public const string FleetAdmin = "FleetAdmin";
|
||||
|
||||
public static IReadOnlyList<string> All => [ConfigViewer, ConfigEditor, FleetAdmin];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class AuditLogService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ConfigAuditLog>> ListRecentAsync(string? clusterId, int limit, CancellationToken ct)
|
||||
{
|
||||
var q = db.ConfigAuditLogs.AsNoTracking();
|
||||
if (clusterId is not null) q = q.Where(a => a.ClusterId == clusterId);
|
||||
return q.OrderByDescending(a => a.Timestamp).Take(limit).ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Points the Admin UI at the OPC UA Server's PKI store root so
|
||||
/// <see cref="CertTrustService"/> can list and move certs between the
|
||||
/// <c>rejected/</c> and <c>trusted/</c> directories the server maintains. Must match the
|
||||
/// <c>OpcUaServer:PkiStoreRoot</c> the Server process is configured with.
|
||||
/// </summary>
|
||||
public sealed class CertTrustOptions
|
||||
{
|
||||
public const string SectionName = "CertTrust";
|
||||
|
||||
/// <summary>
|
||||
/// Absolute path to the PKI root. Defaults to
|
||||
/// <c>%ProgramData%\OtOpcUa\pki</c> — matches <c>OpcUaServerOptions.PkiStoreRoot</c>'s
|
||||
/// default so a standard side-by-side install needs no override.
|
||||
/// </summary>
|
||||
public string PkiStoreRoot { get; init; } =
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"OtOpcUa", "pki");
|
||||
}
|
||||
135
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs
Normal file
135
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for a certificate file found in one of the OPC UA server's PKI stores. The
|
||||
/// <see cref="FilePath"/> is the absolute path of the DER/CRT file the stack created when it
|
||||
/// rejected the cert (for <see cref="CertStoreKind.Rejected"/>) or when an operator trusted
|
||||
/// it (for <see cref="CertStoreKind.Trusted"/>).
|
||||
/// </summary>
|
||||
public sealed record CertInfo(
|
||||
string Thumbprint,
|
||||
string Subject,
|
||||
string Issuer,
|
||||
DateTime NotBefore,
|
||||
DateTime NotAfter,
|
||||
string FilePath,
|
||||
CertStoreKind Store);
|
||||
|
||||
public enum CertStoreKind
|
||||
{
|
||||
Rejected,
|
||||
Trusted,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem-backed view over the OPC UA server's PKI store. The Opc.Ua stack uses a
|
||||
/// Directory-typed store — each cert is a <c>.der</c> file under <c>{root}/{store}/certs/</c>
|
||||
/// with a filename derived from subject + thumbprint. This service exposes operators for the
|
||||
/// Admin UI: list rejected, list trusted, trust a rejected cert (move to trusted), remove a
|
||||
/// rejected cert (delete), untrust a previously trusted cert (delete from trusted).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Admin process is separate from the Server process; this service deliberately has no
|
||||
/// Opc.Ua dependency — it works on the on-disk layout directly so it can run on the Admin
|
||||
/// host even when the Server isn't installed locally, as long as the PKI root is reachable
|
||||
/// (typical deployment has Admin + Server side-by-side on the same machine).
|
||||
///
|
||||
/// Trust/untrust requires the Server to re-read its trust list. The Opc.Ua stack re-reads
|
||||
/// the Directory store on each new incoming connection, so there's no explicit signal
|
||||
/// needed — the next client handshake picks up the change. Operators should retry the
|
||||
/// rejected client's connection after trusting.
|
||||
/// </remarks>
|
||||
public sealed class CertTrustService
|
||||
{
|
||||
private readonly CertTrustOptions _options;
|
||||
private readonly ILogger<CertTrustService> _logger;
|
||||
|
||||
public CertTrustService(IOptions<CertTrustOptions> options, ILogger<CertTrustService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string PkiStoreRoot => _options.PkiStoreRoot;
|
||||
|
||||
public IReadOnlyList<CertInfo> ListRejected() => ListStore(CertStoreKind.Rejected);
|
||||
public IReadOnlyList<CertInfo> ListTrusted() => ListStore(CertStoreKind.Trusted);
|
||||
|
||||
/// <summary>
|
||||
/// Move the cert with <paramref name="thumbprint"/> from the rejected store to the
|
||||
/// trusted store. No-op returns false if the rejected file doesn't exist (already moved
|
||||
/// by another operator, or thumbprint mismatch). Overwrites an existing trusted copy
|
||||
/// silently — idempotent.
|
||||
/// </summary>
|
||||
public bool TrustRejected(string thumbprint)
|
||||
{
|
||||
var cert = FindInStore(CertStoreKind.Rejected, thumbprint);
|
||||
if (cert is null) return false;
|
||||
|
||||
var trustedDir = CertsDir(CertStoreKind.Trusted);
|
||||
Directory.CreateDirectory(trustedDir);
|
||||
var destPath = Path.Combine(trustedDir, Path.GetFileName(cert.FilePath));
|
||||
File.Move(cert.FilePath, destPath, overwrite: true);
|
||||
_logger.LogInformation("Trusted cert {Thumbprint} (subject={Subject}) — moved {From} → {To}",
|
||||
cert.Thumbprint, cert.Subject, cert.FilePath, destPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool DeleteRejected(string thumbprint) => DeleteFromStore(CertStoreKind.Rejected, thumbprint);
|
||||
public bool UntrustCert(string thumbprint) => DeleteFromStore(CertStoreKind.Trusted, thumbprint);
|
||||
|
||||
private bool DeleteFromStore(CertStoreKind store, string thumbprint)
|
||||
{
|
||||
var cert = FindInStore(store, thumbprint);
|
||||
if (cert is null) return false;
|
||||
File.Delete(cert.FilePath);
|
||||
_logger.LogInformation("Deleted cert {Thumbprint} (subject={Subject}) from {Store} store",
|
||||
cert.Thumbprint, cert.Subject, store);
|
||||
return true;
|
||||
}
|
||||
|
||||
private CertInfo? FindInStore(CertStoreKind store, string thumbprint) =>
|
||||
ListStore(store).FirstOrDefault(c =>
|
||||
string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private IReadOnlyList<CertInfo> ListStore(CertStoreKind store)
|
||||
{
|
||||
var dir = CertsDir(store);
|
||||
if (!Directory.Exists(dir)) return [];
|
||||
|
||||
var results = new List<CertInfo>();
|
||||
foreach (var path in Directory.EnumerateFiles(dir))
|
||||
{
|
||||
// Skip CRL sidecars + private-key files — trust operations only concern public certs.
|
||||
var ext = Path.GetExtension(path);
|
||||
if (!ext.Equals(".der", StringComparison.OrdinalIgnoreCase) &&
|
||||
!ext.Equals(".crt", StringComparison.OrdinalIgnoreCase) &&
|
||||
!ext.Equals(".cer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cert = X509CertificateLoader.LoadCertificateFromFile(path);
|
||||
results.Add(new CertInfo(
|
||||
cert.Thumbprint, cert.Subject, cert.Issuer,
|
||||
cert.NotBefore.ToUniversalTime(), cert.NotAfter.ToUniversalTime(),
|
||||
path, store));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A malformed file in the store shouldn't take down the page. Surface it in logs
|
||||
// but skip — operators see the other certs and can clean the bad file manually.
|
||||
_logger.LogWarning(ex, "Failed to parse cert at {Path} — skipping", path);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private string CertsDir(CertStoreKind store) =>
|
||||
Path.Combine(_options.PkiStoreRoot, store == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-side service for ClusterNode rows + their cluster-scoped redundancy view. Consumed
|
||||
/// by the RedundancyTab on the cluster detail page. Writes (role swap, node enable/disable)
|
||||
/// are not supported here — role swap happens through the RedundancyCoordinator apply-lease
|
||||
/// flow on the server side and would conflict with any direct DB mutation from Admin.
|
||||
/// </summary>
|
||||
public sealed class ClusterNodeService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>Stale-threshold matching <c>HostStatusService.StaleThreshold</c> — 30s of clock
|
||||
/// 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()
|
||||
.Where(n => n.ClusterId == clusterId)
|
||||
.OrderByDescending(n => n.ServiceLevelBase)
|
||||
.ThenBy(n => n.NodeId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public static bool IsStale(ClusterNode node) =>
|
||||
node.LastSeenAt is null || DateTime.UtcNow - node.LastSeenAt.Value > StaleThreshold;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Cluster CRUD surface used by the Blazor pages. Writes go through stored procs in later
|
||||
/// phases; Phase 1 reads via EF Core directly (DENY SELECT on <c>dbo</c> schema means this
|
||||
/// service connects as a DB owner during dev — production swaps in a read-only view grant).
|
||||
/// </summary>
|
||||
public sealed class ClusterService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ServerCluster>> ListAsync(CancellationToken ct) =>
|
||||
db.ServerClusters.AsNoTracking().OrderBy(c => c.ClusterId).ToListAsync(ct);
|
||||
|
||||
public Task<ServerCluster?> FindAsync(string clusterId, CancellationToken ct) =>
|
||||
db.ServerClusters.AsNoTracking().FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
|
||||
|
||||
public async Task<ServerCluster> CreateAsync(ServerCluster cluster, string createdBy, CancellationToken ct)
|
||||
{
|
||||
cluster.CreatedAt = DateTime.UtcNow;
|
||||
cluster.CreatedBy = createdBy;
|
||||
db.ServerClusters.Add(cluster);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return cluster;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the managed <see cref="DraftValidator"/> against a draft's snapshot loaded from the
|
||||
/// Configuration DB. Used by the draft editor's inline validation panel and by the publish
|
||||
/// dialog's pre-check. Structural-only SQL checks live in <c>sp_ValidateDraft</c>; this layer
|
||||
/// owns the content / cross-generation / regex rules.
|
||||
/// </summary>
|
||||
public sealed class DraftValidationService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public async Task<IReadOnlyList<ValidationError>> ValidateAsync(long draftId, CancellationToken ct)
|
||||
{
|
||||
var draft = await db.ConfigGenerations.AsNoTracking()
|
||||
.FirstOrDefaultAsync(g => g.GenerationId == draftId, ct)
|
||||
?? throw new InvalidOperationException($"Draft {draftId} not found");
|
||||
|
||||
var snapshot = new DraftSnapshot
|
||||
{
|
||||
GenerationId = draft.GenerationId,
|
||||
ClusterId = draft.ClusterId,
|
||||
Namespaces = await db.Namespaces.AsNoTracking().Where(n => n.GenerationId == draftId).ToListAsync(ct),
|
||||
DriverInstances = await db.DriverInstances.AsNoTracking().Where(d => d.GenerationId == draftId).ToListAsync(ct),
|
||||
Devices = await db.Devices.AsNoTracking().Where(d => d.GenerationId == draftId).ToListAsync(ct),
|
||||
UnsAreas = await db.UnsAreas.AsNoTracking().Where(a => a.GenerationId == draftId).ToListAsync(ct),
|
||||
UnsLines = await db.UnsLines.AsNoTracking().Where(l => l.GenerationId == draftId).ToListAsync(ct),
|
||||
Equipment = await db.Equipment.AsNoTracking().Where(e => e.GenerationId == draftId).ToListAsync(ct),
|
||||
Tags = await db.Tags.AsNoTracking().Where(t => t.GenerationId == draftId).ToListAsync(ct),
|
||||
PollGroups = await db.PollGroups.AsNoTracking().Where(p => p.GenerationId == draftId).ToListAsync(ct),
|
||||
|
||||
PriorEquipment = await db.Equipment.AsNoTracking()
|
||||
.Where(e => e.GenerationId != draftId
|
||||
&& db.ConfigGenerations.Any(g => g.GenerationId == e.GenerationId && g.ClusterId == draft.ClusterId))
|
||||
.ToListAsync(ct),
|
||||
ActiveReservations = await db.ExternalIdReservations.AsNoTracking()
|
||||
.Where(r => r.ReleasedAt == null)
|
||||
.ToListAsync(ct),
|
||||
};
|
||||
|
||||
return DraftValidator.Validate(snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #154 — Admin-side client for the Server's driver-diagnostics HTTP endpoints. Wraps
|
||||
/// <see cref="HttpClient"/> so Blazor pages can fetch per-driver runtime state from a
|
||||
/// remote Server process. The base URL is configured at registration time
|
||||
/// (typically read from <c>appsettings.json</c> at startup).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One client instance per Server endpoint. Multi-server deployments register multiple
|
||||
/// keyed clients. Errors propagate as exceptions; pages catch and surface to the
|
||||
/// operator rather than swallowing.
|
||||
/// </remarks>
|
||||
public sealed class DriverDiagnosticsClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public DriverDiagnosticsClient(HttpClient http) => _http = http;
|
||||
|
||||
/// <summary>
|
||||
/// Fetch the current Modbus auto-prohibition list for the named driver instance.
|
||||
/// Returns null when the Server reports the driver doesn't exist or isn't a Modbus
|
||||
/// driver. Throws on transport / serialization failures.
|
||||
/// </summary>
|
||||
public async Task<ModbusAutoProhibitionsResponse?> GetModbusAutoProhibitedRangesAsync(
|
||||
string driverInstanceId, CancellationToken ct = default)
|
||||
{
|
||||
var resp = await _http.GetAsync(
|
||||
$"/diagnostics/drivers/{Uri.EscapeDataString(driverInstanceId)}/modbus/auto-prohibited", ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (resp.StatusCode is System.Net.HttpStatusCode.NotFound or System.Net.HttpStatusCode.BadRequest)
|
||||
return null;
|
||||
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return await resp.Content.ReadFromJsonAsync<ModbusAutoProhibitionsResponse>(cancellationToken: ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server response shape for the Modbus auto-prohibition diagnostic. Mirrors the JSON the
|
||||
/// <c>HealthEndpointsHost</c> serialises; fields are flat strings/numbers so the
|
||||
/// Admin-side client doesn't take a dependency on the Driver.Modbus assembly's
|
||||
/// <c>ModbusAutoProhibition</c> record.
|
||||
/// </summary>
|
||||
public sealed class ModbusAutoProhibitionsResponse
|
||||
{
|
||||
public string DriverInstanceId { get; set; } = string.Empty;
|
||||
public int Count { get; set; }
|
||||
public List<ModbusAutoProhibitionRow> Ranges { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ModbusAutoProhibitionRow
|
||||
{
|
||||
public byte UnitId { get; set; }
|
||||
public string Region { get; set; } = string.Empty;
|
||||
public ushort StartAddress { get; set; }
|
||||
public ushort EndAddress { get; set; }
|
||||
public DateTime LastProbedUtc { get; set; }
|
||||
public bool BisectionPending { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class DriverInstanceService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<DriverInstance>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.GenerationId == generationId)
|
||||
.OrderBy(d => d.DriverInstanceId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<DriverInstance> AddAsync(
|
||||
long draftId, string clusterId, string namespaceId, string name, string driverType,
|
||||
string driverConfigJson, CancellationToken ct)
|
||||
{
|
||||
var di = new DriverInstance
|
||||
{
|
||||
GenerationId = draftId,
|
||||
DriverInstanceId = $"drv-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
NamespaceId = namespaceId,
|
||||
Name = name,
|
||||
DriverType = driverType,
|
||||
DriverConfig = driverConfigJson,
|
||||
};
|
||||
db.DriverInstances.Add(di);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return di;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 4180 CSV parser for equipment import per decision #95 and Phase 6.4 Stream B.1.
|
||||
/// Produces a validated <see cref="EquipmentCsvParseResult"/> the caller (CSV import
|
||||
/// modal + staging tables) consumes. Pure-parser concern — no DB access, no staging
|
||||
/// writes; those live in the follow-up Stream B.2 work.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Header contract</b>: line 1 must be exactly <c># OtOpcUaCsv v1</c> (version
|
||||
/// marker). Line 2 is the column header row. Unknown columns are rejected; required
|
||||
/// columns must all be present. The version bump handshake lets future shapes parse
|
||||
/// without ambiguity — v2 files go through a different parser variant.</para>
|
||||
///
|
||||
/// <para><b>Required columns</b> per decision #117: ZTag, MachineCode, SAPID,
|
||||
/// EquipmentId, EquipmentUuid, Name, UnsAreaName, UnsLineName.</para>
|
||||
///
|
||||
/// <para><b>Optional columns</b> per decision #139: Manufacturer, Model, SerialNumber,
|
||||
/// HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation,
|
||||
/// ManufacturerUri, DeviceManualUri.</para>
|
||||
///
|
||||
/// <para><b>Row validation</b>: blank required field → rejected; duplicate ZTag within
|
||||
/// the same file → rejected. Duplicate against the DB isn't detected here — the
|
||||
/// staged-import finalize step (Stream B.4) catches that.</para>
|
||||
/// </remarks>
|
||||
public static class EquipmentCsvImporter
|
||||
{
|
||||
public const string VersionMarker = "# OtOpcUaCsv v1";
|
||||
|
||||
public static IReadOnlyList<string> RequiredColumns { get; } = new[]
|
||||
{
|
||||
"ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid",
|
||||
"Name", "UnsAreaName", "UnsLineName",
|
||||
};
|
||||
|
||||
public static IReadOnlyList<string> OptionalColumns { get; } = new[]
|
||||
{
|
||||
"Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision",
|
||||
"YearOfConstruction", "AssetLocation", "ManufacturerUri", "DeviceManualUri",
|
||||
};
|
||||
|
||||
public static EquipmentCsvParseResult Parse(string csvText)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(csvText);
|
||||
|
||||
var rows = SplitLines(csvText);
|
||||
if (rows.Count == 0)
|
||||
throw new InvalidCsvFormatException("CSV is empty.");
|
||||
|
||||
if (!string.Equals(rows[0].Trim(), VersionMarker, StringComparison.Ordinal))
|
||||
throw new InvalidCsvFormatException(
|
||||
$"CSV header line 1 must be exactly '{VersionMarker}' — got '{rows[0]}'. " +
|
||||
"Files without the version marker are rejected so future-format files don't parse ambiguously.");
|
||||
|
||||
if (rows.Count < 2)
|
||||
throw new InvalidCsvFormatException("CSV has no column header row (line 2) or data rows.");
|
||||
|
||||
var headerCells = SplitCsvRow(rows[1]);
|
||||
ValidateHeader(headerCells);
|
||||
|
||||
var accepted = new List<EquipmentCsvRow>();
|
||||
var rejected = new List<EquipmentCsvRowError>();
|
||||
var ztagsSeen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var colIndex = headerCells
|
||||
.Select((name, idx) => (name, idx))
|
||||
.ToDictionary(t => t.name, t => t.idx, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 2; i < rows.Count; i++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rows[i])) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var cells = SplitCsvRow(rows[i]);
|
||||
if (cells.Length != headerCells.Length)
|
||||
{
|
||||
rejected.Add(new EquipmentCsvRowError(
|
||||
LineNumber: i + 1,
|
||||
Reason: $"Column count {cells.Length} != header count {headerCells.Length}."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var row = BuildRow(cells, colIndex);
|
||||
var missing = RequiredColumns.Where(c => string.IsNullOrWhiteSpace(GetCell(row, c))).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
rejected.Add(new EquipmentCsvRowError(i + 1, $"Blank required column(s): {string.Join(", ", missing)}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ztagsSeen.Add(row.ZTag))
|
||||
{
|
||||
rejected.Add(new EquipmentCsvRowError(i + 1, $"Duplicate ZTag '{row.ZTag}' within file."));
|
||||
continue;
|
||||
}
|
||||
|
||||
accepted.Add(row);
|
||||
}
|
||||
catch (InvalidCsvFormatException ex)
|
||||
{
|
||||
rejected.Add(new EquipmentCsvRowError(i + 1, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
return new EquipmentCsvParseResult(accepted, rejected);
|
||||
}
|
||||
|
||||
private static void ValidateHeader(string[] headerCells)
|
||||
{
|
||||
var seen = new HashSet<string>(headerCells, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Missing required
|
||||
var missingRequired = RequiredColumns.Where(r => !seen.Contains(r)).ToList();
|
||||
if (missingRequired.Count > 0)
|
||||
throw new InvalidCsvFormatException($"Header is missing required column(s): {string.Join(", ", missingRequired)}");
|
||||
|
||||
// Unknown columns (not in required ∪ optional)
|
||||
var known = new HashSet<string>(RequiredColumns.Concat(OptionalColumns), StringComparer.OrdinalIgnoreCase);
|
||||
var unknown = headerCells.Where(c => !known.Contains(c)).ToList();
|
||||
if (unknown.Count > 0)
|
||||
throw new InvalidCsvFormatException(
|
||||
$"Header has unknown column(s): {string.Join(", ", unknown)}. " +
|
||||
"Bump the version marker to define a new shape before adding columns.");
|
||||
|
||||
// Duplicates
|
||||
var dupe = headerCells.GroupBy(c => c, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1);
|
||||
if (dupe is not null)
|
||||
throw new InvalidCsvFormatException($"Header has duplicate column '{dupe.Key}'.");
|
||||
}
|
||||
|
||||
private static EquipmentCsvRow BuildRow(string[] cells, Dictionary<string, int> colIndex) => new()
|
||||
{
|
||||
ZTag = cells[colIndex["ZTag"]],
|
||||
MachineCode = cells[colIndex["MachineCode"]],
|
||||
SAPID = cells[colIndex["SAPID"]],
|
||||
EquipmentId = cells[colIndex["EquipmentId"]],
|
||||
EquipmentUuid = cells[colIndex["EquipmentUuid"]],
|
||||
Name = cells[colIndex["Name"]],
|
||||
UnsAreaName = cells[colIndex["UnsAreaName"]],
|
||||
UnsLineName = cells[colIndex["UnsLineName"]],
|
||||
Manufacturer = colIndex.TryGetValue("Manufacturer", out var mi) ? cells[mi] : null,
|
||||
Model = colIndex.TryGetValue("Model", out var moi) ? cells[moi] : null,
|
||||
SerialNumber = colIndex.TryGetValue("SerialNumber", out var si) ? cells[si] : null,
|
||||
HardwareRevision = colIndex.TryGetValue("HardwareRevision", out var hi) ? cells[hi] : null,
|
||||
SoftwareRevision = colIndex.TryGetValue("SoftwareRevision", out var swi) ? cells[swi] : null,
|
||||
YearOfConstruction = colIndex.TryGetValue("YearOfConstruction", out var yi) ? cells[yi] : null,
|
||||
AssetLocation = colIndex.TryGetValue("AssetLocation", out var ai) ? cells[ai] : null,
|
||||
ManufacturerUri = colIndex.TryGetValue("ManufacturerUri", out var mui) ? cells[mui] : null,
|
||||
DeviceManualUri = colIndex.TryGetValue("DeviceManualUri", out var dui) ? cells[dui] : null,
|
||||
};
|
||||
|
||||
private static string GetCell(EquipmentCsvRow row, string colName) => colName switch
|
||||
{
|
||||
"ZTag" => row.ZTag,
|
||||
"MachineCode" => row.MachineCode,
|
||||
"SAPID" => row.SAPID,
|
||||
"EquipmentId" => row.EquipmentId,
|
||||
"EquipmentUuid" => row.EquipmentUuid,
|
||||
"Name" => row.Name,
|
||||
"UnsAreaName" => row.UnsAreaName,
|
||||
"UnsLineName" => row.UnsLineName,
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>Split the raw text on line boundaries. Handles \r\n + \n + \r.</summary>
|
||||
private static List<string> SplitLines(string csv) =>
|
||||
csv.Split(["\r\n", "\n", "\r"], StringSplitOptions.None).ToList();
|
||||
|
||||
/// <summary>Split one CSV row with RFC 4180 quoted-field handling.</summary>
|
||||
private static string[] SplitCsvRow(string row)
|
||||
{
|
||||
var cells = new List<string>();
|
||||
var sb = new StringBuilder();
|
||||
var inQuotes = false;
|
||||
|
||||
for (var i = 0; i < row.Length; i++)
|
||||
{
|
||||
var ch = row[i];
|
||||
if (inQuotes)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
// Escaped quote "" inside quoted field.
|
||||
if (i + 1 < row.Length && row[i + 1] == '"')
|
||||
{
|
||||
sb.Append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(ch);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ch == ',')
|
||||
{
|
||||
cells.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
}
|
||||
else if (ch == '"' && sb.Length == 0)
|
||||
{
|
||||
inQuotes = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cells.Add(sb.ToString());
|
||||
return cells.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One parsed equipment row with required + optional fields.</summary>
|
||||
public sealed class EquipmentCsvRow
|
||||
{
|
||||
// Required (decision #117)
|
||||
public required string ZTag { get; init; }
|
||||
public required string MachineCode { get; init; }
|
||||
public required string SAPID { get; init; }
|
||||
public required string EquipmentId { get; init; }
|
||||
public required string EquipmentUuid { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string UnsAreaName { get; init; }
|
||||
public required string UnsLineName { get; init; }
|
||||
|
||||
// Optional (decision #139 — OPC 40010 Identification fields)
|
||||
public string? Manufacturer { get; init; }
|
||||
public string? Model { get; init; }
|
||||
public string? SerialNumber { get; init; }
|
||||
public string? HardwareRevision { get; init; }
|
||||
public string? SoftwareRevision { get; init; }
|
||||
public string? YearOfConstruction { get; init; }
|
||||
public string? AssetLocation { get; init; }
|
||||
public string? ManufacturerUri { get; init; }
|
||||
public string? DeviceManualUri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One row-level rejection captured by the parser. Line-number is 1-based in the source file.</summary>
|
||||
public sealed record EquipmentCsvRowError(int LineNumber, string Reason);
|
||||
|
||||
/// <summary>Parser output — accepted rows land in staging; rejected rows surface in the preview modal.</summary>
|
||||
public sealed record EquipmentCsvParseResult(
|
||||
IReadOnlyList<EquipmentCsvRow> AcceptedRows,
|
||||
IReadOnlyList<EquipmentCsvRowError> RejectedRows);
|
||||
|
||||
/// <summary>Thrown for file-level format problems (missing version marker, bad header, etc.).</summary>
|
||||
public sealed class InvalidCsvFormatException(string message) : Exception(message);
|
||||
@@ -0,0 +1,324 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Staged-import orchestrator per Phase 6.4 Stream B.2-B.4. Covers the four operator
|
||||
/// actions: CreateBatch → StageRows (chunked) → FinaliseBatch (atomic apply into
|
||||
/// <see cref="Equipment"/>) → DropBatch (rollback of pre-finalise state).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>FinaliseBatch runs inside one EF transaction + bulk-inserts accepted rows into
|
||||
/// <see cref="Equipment"/>. Rejected rows stay behind as audit evidence; the batch row
|
||||
/// gains <see cref="EquipmentImportBatch.FinalisedAtUtc"/> so future writes know it's
|
||||
/// archived. DropBatch removes the batch + its cascaded rows.</para>
|
||||
///
|
||||
/// <para>Idempotence: calling FinaliseBatch twice throws <see cref="ImportBatchAlreadyFinalisedException"/>
|
||||
/// rather than double-inserting. Operator refreshes the admin page to see the first
|
||||
/// finalise completed.</para>
|
||||
///
|
||||
/// <para>ExternalIdReservation merging (ZTag + SAPID uniqueness) is NOT done here — a
|
||||
/// narrower follow-up wires it once the concurrent-insert test matrix is green.</para>
|
||||
/// </remarks>
|
||||
public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>Create a new empty batch header. Returns the row with Id populated.</summary>
|
||||
public async Task<EquipmentImportBatch> CreateBatchAsync(string clusterId, string createdBy, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(createdBy);
|
||||
|
||||
var batch = new EquipmentImportBatch
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ClusterId = clusterId,
|
||||
CreatedBy = createdBy,
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
db.EquipmentImportBatches.Add(batch);
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
return batch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stage one chunk of rows into the batch. Caller usually feeds
|
||||
/// <see cref="EquipmentCsvImporter.Parse"/> output here — each
|
||||
/// <see cref="EquipmentCsvRow"/> becomes one accepted <see cref="EquipmentImportRow"/>,
|
||||
/// each rejected parser error becomes one row with <see cref="EquipmentImportRow.IsAccepted"/> false.
|
||||
/// </summary>
|
||||
public async Task StageRowsAsync(
|
||||
Guid batchId,
|
||||
IReadOnlyList<EquipmentCsvRow> acceptedRows,
|
||||
IReadOnlyList<EquipmentCsvRowError> rejectedRows,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var batch = await db.EquipmentImportBatches.FirstOrDefaultAsync(b => b.Id == batchId, ct).ConfigureAwait(false)
|
||||
?? throw new ImportBatchNotFoundException($"Batch {batchId} not found.");
|
||||
|
||||
if (batch.FinalisedAtUtc is not null)
|
||||
throw new ImportBatchAlreadyFinalisedException(
|
||||
$"Batch {batchId} finalised at {batch.FinalisedAtUtc:o}; no more rows can be staged.");
|
||||
|
||||
foreach (var row in acceptedRows)
|
||||
{
|
||||
db.EquipmentImportRows.Add(new EquipmentImportRow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
IsAccepted = true,
|
||||
ZTag = row.ZTag,
|
||||
MachineCode = row.MachineCode,
|
||||
SAPID = row.SAPID,
|
||||
EquipmentId = row.EquipmentId,
|
||||
EquipmentUuid = row.EquipmentUuid,
|
||||
Name = row.Name,
|
||||
UnsAreaName = row.UnsAreaName,
|
||||
UnsLineName = row.UnsLineName,
|
||||
Manufacturer = row.Manufacturer,
|
||||
Model = row.Model,
|
||||
SerialNumber = row.SerialNumber,
|
||||
HardwareRevision = row.HardwareRevision,
|
||||
SoftwareRevision = row.SoftwareRevision,
|
||||
YearOfConstruction = row.YearOfConstruction,
|
||||
AssetLocation = row.AssetLocation,
|
||||
ManufacturerUri = row.ManufacturerUri,
|
||||
DeviceManualUri = row.DeviceManualUri,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var error in rejectedRows)
|
||||
{
|
||||
db.EquipmentImportRows.Add(new EquipmentImportRow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
IsAccepted = false,
|
||||
RejectReason = error.Reason,
|
||||
LineNumberInFile = error.LineNumber,
|
||||
// Required columns need values for EF; reject rows use sentinel placeholders.
|
||||
ZTag = "", MachineCode = "", SAPID = "", EquipmentId = "", EquipmentUuid = "",
|
||||
Name = "", UnsAreaName = "", UnsLineName = "",
|
||||
});
|
||||
}
|
||||
|
||||
batch.RowsStaged += acceptedRows.Count + rejectedRows.Count;
|
||||
batch.RowsAccepted += acceptedRows.Count;
|
||||
batch.RowsRejected += rejectedRows.Count;
|
||||
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Drop the batch (pre-finalise rollback). Cascaded row delete removes staged rows.</summary>
|
||||
public async Task DropBatchAsync(Guid batchId, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.EquipmentImportBatches.FirstOrDefaultAsync(b => b.Id == batchId, ct).ConfigureAwait(false);
|
||||
if (batch is null) return;
|
||||
if (batch.FinalisedAtUtc is not null)
|
||||
throw new ImportBatchAlreadyFinalisedException(
|
||||
$"Batch {batchId} already finalised at {batch.FinalisedAtUtc:o}; cannot drop.");
|
||||
|
||||
db.EquipmentImportBatches.Remove(batch);
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomic finalise. Inserts every accepted row into the live
|
||||
/// <see cref="Equipment"/> table under the target generation + stamps
|
||||
/// <see cref="EquipmentImportBatch.FinalisedAtUtc"/>. Failure rolls the whole tx
|
||||
/// back — <see cref="Equipment"/> never partially mutates.
|
||||
/// </summary>
|
||||
public async Task FinaliseBatchAsync(
|
||||
Guid batchId, long generationId, string driverInstanceIdForRows, string unsLineIdForRows, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.EquipmentImportBatches
|
||||
.Include(b => b.Rows)
|
||||
.FirstOrDefaultAsync(b => b.Id == batchId, ct)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new ImportBatchNotFoundException($"Batch {batchId} not found.");
|
||||
|
||||
if (batch.FinalisedAtUtc is not null)
|
||||
throw new ImportBatchAlreadyFinalisedException(
|
||||
$"Batch {batchId} already finalised at {batch.FinalisedAtUtc:o}.");
|
||||
|
||||
// EF InMemory provider doesn't honour BeginTransaction; SQL Server provider does.
|
||||
// Tests run the happy path under in-memory; production SQL Server runs the atomic tx.
|
||||
var supportsTx = db.Database.IsRelational();
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||
if (supportsTx)
|
||||
tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Snapshot active reservations that overlap this batch's ZTag + SAPID set — one
|
||||
// round-trip instead of N. Released rows (ReleasedAt IS NOT NULL) are ignored so
|
||||
// an explicitly-released value can be reused.
|
||||
var accepted = batch.Rows.Where(r => r.IsAccepted).ToList();
|
||||
var zTags = accepted.Where(r => !string.IsNullOrWhiteSpace(r.ZTag))
|
||||
.Select(r => r.ZTag).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var sapIds = accepted.Where(r => !string.IsNullOrWhiteSpace(r.SAPID))
|
||||
.Select(r => r.SAPID).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
var existingReservations = await db.ExternalIdReservations
|
||||
.Where(r => r.ReleasedAt == null &&
|
||||
((r.Kind == ReservationKind.ZTag && zTags.Contains(r.Value)) ||
|
||||
(r.Kind == ReservationKind.SAPID && sapIds.Contains(r.Value))))
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
var resByKey = existingReservations.ToDictionary(
|
||||
r => (r.Kind, r.Value.ToLowerInvariant()),
|
||||
r => r);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var firstPublishedBy = batch.CreatedBy;
|
||||
|
||||
foreach (var row in accepted)
|
||||
{
|
||||
var equipmentUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.NewGuid();
|
||||
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(),
|
||||
GenerationId = generationId,
|
||||
EquipmentId = row.EquipmentId,
|
||||
EquipmentUuid = equipmentUuid,
|
||||
DriverInstanceId = driverInstanceIdForRows,
|
||||
UnsLineId = unsLineIdForRows,
|
||||
Name = row.Name,
|
||||
MachineCode = row.MachineCode,
|
||||
ZTag = row.ZTag,
|
||||
SAPID = row.SAPID,
|
||||
Manufacturer = row.Manufacturer,
|
||||
Model = row.Model,
|
||||
SerialNumber = row.SerialNumber,
|
||||
HardwareRevision = row.HardwareRevision,
|
||||
SoftwareRevision = row.SoftwareRevision,
|
||||
YearOfConstruction = short.TryParse(row.YearOfConstruction, out var y) ? y : null,
|
||||
AssetLocation = row.AssetLocation,
|
||||
ManufacturerUri = row.ManufacturerUri,
|
||||
DeviceManualUri = row.DeviceManualUri,
|
||||
});
|
||||
|
||||
MergeReservation(row.ZTag, ReservationKind.ZTag, equipmentUuid, batch.ClusterId,
|
||||
firstPublishedBy, nowUtc, resByKey);
|
||||
MergeReservation(row.SAPID, ReservationKind.SAPID, equipmentUuid, batch.ClusterId,
|
||||
firstPublishedBy, nowUtc, resByKey);
|
||||
}
|
||||
|
||||
batch.FinalisedAtUtc = nowUtc;
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsReservationUniquenessViolation(ex))
|
||||
{
|
||||
throw new ExternalIdReservationConflictException(
|
||||
"Finalise rejected: one or more ZTag/SAPID values were reserved by another operator " +
|
||||
"between batch preview and commit. Inspect active reservations + retry after resolving the conflict.",
|
||||
ex);
|
||||
}
|
||||
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge one external-ID reservation for an equipment row. Three outcomes:
|
||||
/// (1) value is empty → skip; (2) reservation exists for same <paramref name="equipmentUuid"/>
|
||||
/// → bump <c>LastPublishedAt</c>; (3) reservation exists for a different EquipmentUuid
|
||||
/// → throw <see cref="ExternalIdReservationConflictException"/> with the conflicting UUID
|
||||
/// so the caller sees which equipment already owns the value; (4) no reservation → create new.
|
||||
/// </summary>
|
||||
private void MergeReservation(
|
||||
string? value,
|
||||
ReservationKind kind,
|
||||
Guid equipmentUuid,
|
||||
string clusterId,
|
||||
string firstPublishedBy,
|
||||
DateTime nowUtc,
|
||||
Dictionary<(ReservationKind, string), ExternalIdReservation> cache)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return;
|
||||
|
||||
var key = (kind, value.ToLowerInvariant());
|
||||
if (cache.TryGetValue(key, out var existing))
|
||||
{
|
||||
if (existing.EquipmentUuid != equipmentUuid)
|
||||
throw new ExternalIdReservationConflictException(
|
||||
$"{kind} '{value}' is already reserved by EquipmentUuid {existing.EquipmentUuid} " +
|
||||
$"(first published {existing.FirstPublishedAt:u} on cluster '{existing.ClusterId}'). " +
|
||||
$"Refusing to re-assign to {equipmentUuid}.");
|
||||
|
||||
existing.LastPublishedAt = nowUtc;
|
||||
return;
|
||||
}
|
||||
|
||||
var fresh = new ExternalIdReservation
|
||||
{
|
||||
ReservationId = Guid.NewGuid(),
|
||||
Kind = kind,
|
||||
Value = value,
|
||||
EquipmentUuid = equipmentUuid,
|
||||
ClusterId = clusterId,
|
||||
FirstPublishedAt = nowUtc,
|
||||
FirstPublishedBy = firstPublishedBy,
|
||||
LastPublishedAt = nowUtc,
|
||||
};
|
||||
db.ExternalIdReservations.Add(fresh);
|
||||
cache[key] = fresh;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the <see cref="DbUpdateException"/> root-cause was the filtered-unique
|
||||
/// index <c>UX_ExternalIdReservation_KindValue_Active</c> — i.e. another transaction
|
||||
/// won the race between our cache-load + commit. SQL Server surfaces this as 2601 / 2627.
|
||||
/// </summary>
|
||||
private static bool IsReservationUniquenessViolation(DbUpdateException ex)
|
||||
{
|
||||
for (Exception? inner = ex; inner is not null; inner = inner.InnerException)
|
||||
{
|
||||
if (inner is Microsoft.Data.SqlClient.SqlException sql &&
|
||||
(sql.Number == 2601 || sql.Number == 2627) &&
|
||||
sql.Message.Contains("UX_ExternalIdReservation_KindValue_Active", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>List batches created by the given user. Finalised batches are archived; include them on demand.</summary>
|
||||
public async Task<IReadOnlyList<EquipmentImportBatch>> ListByUserAsync(string createdBy, bool includeFinalised, CancellationToken ct)
|
||||
{
|
||||
var query = db.EquipmentImportBatches.AsNoTracking().Where(b => b.CreatedBy == createdBy);
|
||||
if (!includeFinalised)
|
||||
query = query.Where(b => b.FinalisedAtUtc == null);
|
||||
return await query.OrderByDescending(b => b.CreatedAtUtc).ToListAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ImportBatchNotFoundException(string message) : Exception(message);
|
||||
public sealed class ImportBatchAlreadyFinalisedException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a <c>FinaliseBatchAsync</c> call detects that one of its ZTag/SAPID values is
|
||||
/// already reserved by a different EquipmentUuid — either from a prior published generation
|
||||
/// or a concurrent finalise that won the race. The operator sees the message + the conflicting
|
||||
/// equipment ownership so they can resolve the conflict (pick a new ZTag, release the existing
|
||||
/// reservation via <c>sp_ReleaseExternalIdReservation</c>, etc.) and retry the finalise.
|
||||
/// </summary>
|
||||
public sealed class ExternalIdReservationConflictException : Exception
|
||||
{
|
||||
public ExternalIdReservationConflictException(string message) : base(message) { }
|
||||
public ExternalIdReservationConflictException(string message, Exception inner) : base(message, inner) { }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Equipment CRUD scoped to a generation. The Admin app writes against Draft generations only;
|
||||
/// Published generations are read-only (to create changes, clone to a new draft via
|
||||
/// <see cref="GenerationService.CreateDraftAsync"/>).
|
||||
/// </summary>
|
||||
public sealed class EquipmentService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<Equipment>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.Equipment.AsNoTracking()
|
||||
.Where(e => e.GenerationId == generationId)
|
||||
.OrderBy(e => e.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<Equipment?> FindAsync(long generationId, string equipmentId, CancellationToken ct) =>
|
||||
db.Equipment.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.GenerationId == generationId && e.EquipmentId == equipmentId, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new equipment row in the given draft. The EquipmentId is auto-derived from
|
||||
/// a fresh EquipmentUuid per decision #125; operator-supplied IDs are rejected upstream.
|
||||
/// </summary>
|
||||
public async Task<Equipment> CreateAsync(long draftId, Equipment input, CancellationToken ct)
|
||||
{
|
||||
input.GenerationId = draftId;
|
||||
input.EquipmentUuid = input.EquipmentUuid == Guid.Empty ? Guid.NewGuid() : input.EquipmentUuid;
|
||||
input.EquipmentId = DraftValidator.DeriveEquipmentId(input.EquipmentUuid);
|
||||
db.Equipment.Add(input);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return input;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Equipment updated, CancellationToken ct)
|
||||
{
|
||||
// Only editable fields are persisted; EquipmentId + EquipmentUuid are immutable once set.
|
||||
var existing = await db.Equipment
|
||||
.FirstOrDefaultAsync(e => e.EquipmentRowId == updated.EquipmentRowId, ct)
|
||||
?? throw new InvalidOperationException($"Equipment row {updated.EquipmentRowId} not found");
|
||||
|
||||
existing.Name = updated.Name;
|
||||
existing.MachineCode = updated.MachineCode;
|
||||
existing.ZTag = updated.ZTag;
|
||||
existing.SAPID = updated.SAPID;
|
||||
existing.Manufacturer = updated.Manufacturer;
|
||||
existing.Model = updated.Model;
|
||||
existing.SerialNumber = updated.SerialNumber;
|
||||
existing.HardwareRevision = updated.HardwareRevision;
|
||||
existing.SoftwareRevision = updated.SoftwareRevision;
|
||||
existing.YearOfConstruction = updated.YearOfConstruction;
|
||||
existing.AssetLocation = updated.AssetLocation;
|
||||
existing.ManufacturerUri = updated.ManufacturerUri;
|
||||
existing.DeviceManualUri = updated.DeviceManualUri;
|
||||
existing.DriverInstanceId = updated.DriverInstanceId;
|
||||
existing.DeviceId = updated.DeviceId;
|
||||
existing.UnsLineId = updated.UnsLineId;
|
||||
existing.EquipmentClassRef = updated.EquipmentClassRef;
|
||||
existing.Enabled = updated.Enabled;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid equipmentRowId, CancellationToken ct)
|
||||
{
|
||||
var row = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentRowId == equipmentRowId, ct);
|
||||
if (row is null) return;
|
||||
db.Equipment.Remove(row);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Per-instance detail view for FOCAS driver rows. Loads the latest
|
||||
/// <see cref="DriverInstance"/> row for the requested <c>DriverInstanceId</c> (most-recent
|
||||
/// draft wins when multiple rows exist across generations), parses the schemaless
|
||||
/// <c>DriverConfig</c> JSON into <see cref="FocasDriverConfigView"/>, and joins the
|
||||
/// per-device <see cref="DriverHostStatus"/> rows so the Admin page can render host
|
||||
/// state + consecutive-failure counters next to each configured device.
|
||||
/// </summary>
|
||||
public sealed class FocasDriverDetailService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||
};
|
||||
|
||||
public async Task<FocasDriverDetail?> GetAsync(string driverInstanceId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverInstanceId)) return null;
|
||||
|
||||
var instance = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.DriverInstanceId == driverInstanceId
|
||||
&& d.DriverType.ToLower() == "focas")
|
||||
.OrderByDescending(d => d.GenerationId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (instance is null) return null;
|
||||
|
||||
FocasDriverConfigView? config = null;
|
||||
string? parseError = null;
|
||||
try { config = JsonSerializer.Deserialize<FocasDriverConfigView>(instance.DriverConfig, JsonOpts); }
|
||||
catch (JsonException ex) { parseError = ex.Message; }
|
||||
|
||||
var hostStatuses = await (from s in db.DriverHostStatuses.AsNoTracking()
|
||||
where s.DriverInstanceId == driverInstanceId
|
||||
join r in db.DriverInstanceResilienceStatuses.AsNoTracking()
|
||||
on new { s.DriverInstanceId, s.HostName }
|
||||
equals new { r.DriverInstanceId, r.HostName } into rj
|
||||
from r in rj.DefaultIfEmpty()
|
||||
orderby s.HostName
|
||||
select new FocasHostStatusRow(
|
||||
s.NodeId,
|
||||
s.HostName,
|
||||
s.State.ToString(),
|
||||
s.StateChangedUtc,
|
||||
s.LastSeenUtc,
|
||||
s.Detail,
|
||||
r != null ? r.ConsecutiveFailures : 0,
|
||||
r != null ? r.LastCircuitBreakerOpenUtc : null,
|
||||
r != null ? r.LastRecycleUtc : null)).ToListAsync(ct);
|
||||
|
||||
return new FocasDriverDetail(instance, config, parseError, hostStatuses);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Projected view of a FOCAS driver's parsed config. Unknown fields are ignored.</summary>
|
||||
public sealed record FocasDriverConfigView
|
||||
{
|
||||
public List<FocasDeviceView>? Devices { get; set; }
|
||||
public List<FocasTagView>? Tags { get; set; }
|
||||
public FocasProbeView? Probe { get; set; }
|
||||
public FocasAlarmProjectionView? AlarmProjection { get; set; }
|
||||
public FocasHandleRecycleView? HandleRecycle { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasDeviceView
|
||||
{
|
||||
public string? HostAddress { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
public string? Series { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasTagView
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? DeviceHostAddress { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? DataType { get; set; }
|
||||
public bool Writable { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed record FocasProbeView
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? Interval { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasAlarmProjectionView
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string? PollInterval { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasHandleRecycleView
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string? Interval { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Composite payload returned to the Admin page.</summary>
|
||||
public sealed record FocasDriverDetail(
|
||||
DriverInstance Instance,
|
||||
FocasDriverConfigView? Config,
|
||||
string? ParseError,
|
||||
IReadOnlyList<FocasHostStatusRow> HostStatuses);
|
||||
|
||||
public sealed record FocasHostStatusRow(
|
||||
string NodeId,
|
||||
string HostName,
|
||||
string State,
|
||||
DateTime StateChangedUtc,
|
||||
DateTime LastSeenUtc,
|
||||
string? Detail,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
DateTime? LastRecycleUtc);
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the draft → diff → publish workflow (decision #89). Publish + rollback call into the
|
||||
/// stored procedures; diff queries <c>sp_ComputeGenerationDiff</c>.
|
||||
/// </summary>
|
||||
public sealed class GenerationService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public async Task<ConfigGeneration> CreateDraftAsync(string clusterId, string createdBy, CancellationToken ct)
|
||||
{
|
||||
var gen = new ConfigGeneration
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
Status = GenerationStatus.Draft,
|
||||
CreatedBy = createdBy,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.ConfigGenerations.Add(gen);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return gen;
|
||||
}
|
||||
|
||||
public Task<List<ConfigGeneration>> ListRecentAsync(string clusterId, int limit, CancellationToken ct) =>
|
||||
db.ConfigGenerations.AsNoTracking()
|
||||
.Where(g => g.ClusterId == clusterId)
|
||||
.OrderByDescending(g => g.GenerationId)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task PublishAsync(string clusterId, long draftGenerationId, string? notes, CancellationToken ct)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.sp_PublishGeneration @ClusterId = {0}, @DraftGenerationId = {1}, @Notes = {2}",
|
||||
[clusterId, draftGenerationId, (object?)notes ?? DBNull.Value],
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task RollbackAsync(string clusterId, long targetGenerationId, string? notes, CancellationToken ct)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.sp_RollbackToGeneration @ClusterId = {0}, @TargetGenerationId = {1}, @Notes = {2}",
|
||||
[clusterId, targetGenerationId, (object?)notes ?? DBNull.Value],
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<List<DiffRow>> ComputeDiffAsync(long from, long to, CancellationToken ct)
|
||||
{
|
||||
var results = new List<DiffRow>();
|
||||
await using var conn = (SqlConnection)db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_ComputeGenerationDiff @FromGenerationId = @f, @ToGenerationId = @t";
|
||||
cmd.Parameters.AddWithValue("@f", from);
|
||||
cmd.Parameters.AddWithValue("@t", to);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(new DiffRow(reader.GetString(0), reader.GetString(1), reader.GetString(2)));
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DiffRow(string TableName, string LogicalId, string ChangeKind);
|
||||
@@ -0,0 +1,32 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Surfaces the local-node historian queue health on the Admin UI's
|
||||
/// <c>/alarms/historian</c> diagnostics page (Phase 7 plan decisions #16/#21).
|
||||
/// Exposes queue depth / drain state / last-error, and lets the operator retry
|
||||
/// dead-lettered rows without restarting the node.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The sink injected here is the server-process <see cref="IAlarmHistorianSink"/>.
|
||||
/// When <see cref="NullAlarmHistorianSink"/> is bound (historian disabled for this
|
||||
/// deployment), <see cref="TryRetryDeadLettered"/> silently returns 0 and
|
||||
/// <see cref="GetStatus"/> reports <see cref="HistorianDrainState.Disabled"/>.
|
||||
/// </remarks>
|
||||
public sealed class HistorianDiagnosticsService(IAlarmHistorianSink sink)
|
||||
{
|
||||
public HistorianSinkStatus GetStatus() => sink.GetStatus();
|
||||
|
||||
/// <summary>
|
||||
/// Operator action from the UI's "Retry dead-lettered" button. Returns the number
|
||||
/// of rows revived so the UI can flash a confirmation. When the live sink doesn't
|
||||
/// implement retry (test doubles, Null sink), returns 0.
|
||||
/// </summary>
|
||||
public int TryRetryDeadLettered()
|
||||
{
|
||||
if (sink is SqliteStoreAndForwardSink concrete)
|
||||
return concrete.RetryDeadLettered();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One row per <see cref="DriverHostStatus"/> record, enriched with the owning
|
||||
/// <c>ClusterNode.ClusterId</c> (left-join) + the per-<c>(DriverInstanceId, HostName)</c>
|
||||
/// <see cref="DriverInstanceResilienceStatus"/> counters (also left-join) so the Admin
|
||||
/// <c>/hosts</c> page renders the resilience surface inline with host state.
|
||||
/// </summary>
|
||||
public sealed record HostStatusRow(
|
||||
string NodeId,
|
||||
string? ClusterId,
|
||||
string DriverInstanceId,
|
||||
string HostName,
|
||||
DriverHostState State,
|
||||
DateTime StateChangedUtc,
|
||||
DateTime LastSeenUtc,
|
||||
string? Detail,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
int CurrentBulkheadDepth,
|
||||
DateTime? LastRecycleUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Read-side service for the Admin UI's per-host drill-down. Loads
|
||||
/// <see cref="DriverHostStatus"/> rows (written by the Server process's
|
||||
/// <c>HostStatusPublisher</c>) and left-joins <c>ClusterNode</c> so each row knows which
|
||||
/// cluster it belongs to — the Admin UI groups by cluster for the fleet-wide view.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The publisher heartbeat is 10s (<c>HostStatusPublisher.HeartbeatInterval</c>). The
|
||||
/// Admin page also polls every ~10s and treats rows with <c>LastSeenUtc</c> older than
|
||||
/// <c>StaleThreshold</c> (30s) as stale — covers a missed heartbeat tolerance plus
|
||||
/// a generous buffer for clock skew and publisher GC pauses.
|
||||
/// </remarks>
|
||||
public sealed class HostStatusService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Consecutive-failure threshold at which <see cref="IsFlagged"/> returns <c>true</c>
|
||||
/// so the Admin UI can paint a red badge. Matches Phase 6.1 decision #143's conservative
|
||||
/// half-of-breaker-threshold convention — flags before the breaker actually opens.</summary>
|
||||
public const int FailureFlagThreshold = 3;
|
||||
|
||||
public async Task<IReadOnlyList<HostStatusRow>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
// Two LEFT JOINs:
|
||||
// 1. ClusterNodes on NodeId — row persists even when its owning ClusterNode row
|
||||
// hasn't been created yet (first-boot bootstrap case).
|
||||
// 2. DriverInstanceResilienceStatuses on (DriverInstanceId, HostName) — resilience
|
||||
// counters haven't been sampled yet for brand-new hosts, so a missing row means
|
||||
// zero failures + never-opened breaker.
|
||||
var rows = await (from s in db.DriverHostStatuses.AsNoTracking()
|
||||
join n in db.ClusterNodes.AsNoTracking()
|
||||
on s.NodeId equals n.NodeId into nodeJoin
|
||||
from n in nodeJoin.DefaultIfEmpty()
|
||||
join r in db.DriverInstanceResilienceStatuses.AsNoTracking()
|
||||
on new { s.DriverInstanceId, s.HostName } equals new { r.DriverInstanceId, r.HostName } into resilJoin
|
||||
from r in resilJoin.DefaultIfEmpty()
|
||||
orderby s.NodeId, s.DriverInstanceId, s.HostName
|
||||
select new HostStatusRow(
|
||||
s.NodeId,
|
||||
n != null ? n.ClusterId : null,
|
||||
s.DriverInstanceId,
|
||||
s.HostName,
|
||||
s.State,
|
||||
s.StateChangedUtc,
|
||||
s.LastSeenUtc,
|
||||
s.Detail,
|
||||
r != null ? r.ConsecutiveFailures : 0,
|
||||
r != null ? r.LastCircuitBreakerOpenUtc : null,
|
||||
r != null ? r.CurrentBulkheadDepth : 0,
|
||||
r != null ? r.LastRecycleUtc : null)).ToListAsync(ct);
|
||||
return rows;
|
||||
}
|
||||
|
||||
public static bool IsStale(HostStatusRow row) =>
|
||||
DateTime.UtcNow - row.LastSeenUtc > StaleThreshold;
|
||||
|
||||
/// <summary>
|
||||
/// Red-badge predicate — <c>true</c> when the host has accumulated enough consecutive
|
||||
/// failures that an operator should take notice before the breaker trips.
|
||||
/// </summary>
|
||||
public static bool IsFlagged(HostStatusRow row) =>
|
||||
row.ConsecutiveFailures >= FailureFlagThreshold;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class NamespaceService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<Namespace>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.Namespaces.AsNoTracking()
|
||||
.Where(n => n.GenerationId == generationId)
|
||||
.OrderBy(n => n.NamespaceId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<Namespace> AddAsync(
|
||||
long draftId, string clusterId, string namespaceUri, NamespaceKind kind, CancellationToken ct)
|
||||
{
|
||||
var ns = new Namespace
|
||||
{
|
||||
GenerationId = draftId,
|
||||
NamespaceId = $"ns-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
NamespaceUri = namespaceUri,
|
||||
Kind = kind,
|
||||
};
|
||||
db.Namespaces.Add(ns);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return ns;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class NodeAclService(OtOpcUaConfigDbContext db, AclChangeNotifier? notifier = null)
|
||||
{
|
||||
public Task<List<NodeAcl>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.NodeAcls.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.LdapGroup)
|
||||
.ThenBy(a => a.ScopeKind)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<NodeAcl> GrantAsync(
|
||||
long draftId, string clusterId, string ldapGroup, NodeAclScopeKind scopeKind, string? scopeId,
|
||||
NodePermissions permissions, string? notes, CancellationToken ct)
|
||||
{
|
||||
var acl = new NodeAcl
|
||||
{
|
||||
GenerationId = draftId,
|
||||
NodeAclId = $"acl-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
LdapGroup = ldapGroup,
|
||||
ScopeKind = scopeKind,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = permissions,
|
||||
Notes = notes,
|
||||
};
|
||||
db.NodeAcls.Add(acl);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
if (notifier is not null)
|
||||
await notifier.NotifyNodeAclChangedAsync(clusterId, draftId, ct);
|
||||
|
||||
return acl;
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(Guid nodeAclRowId, CancellationToken ct)
|
||||
{
|
||||
var row = await db.NodeAcls.FirstOrDefaultAsync(a => a.NodeAclRowId == nodeAclRowId, ct);
|
||||
if (row is null) return;
|
||||
db.NodeAcls.Remove(row);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
if (notifier is not null)
|
||||
await notifier.NotifyNodeAclChangedAsync(row.ClusterId, row.GenerationId, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Runs an ad-hoc permission probe against a draft or published generation's NodeAcl rows —
|
||||
/// "if LDAP group X asks for permission Y on node Z, would the trie grant it, and which
|
||||
/// rows contributed?" Powers the AclsTab "Probe this permission" form per the #196 sub-slice.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Thin wrapper over <see cref="PermissionTrieBuilder"/> + <see cref="PermissionTrie.CollectMatches"/> —
|
||||
/// the same code path the Server's dispatch layer uses at request time, so a probe result
|
||||
/// is guaranteed to match what the live server would decide. The probe is read-only + has
|
||||
/// no side effects; failing probes do NOT generate audit log rows.
|
||||
/// </remarks>
|
||||
public sealed class PermissionProbeService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="required"/> against the NodeAcl rows of
|
||||
/// <paramref name="generationId"/> for a request by <paramref name="ldapGroup"/> at
|
||||
/// <paramref name="scope"/>. Returns whether the permission would be granted + the list
|
||||
/// of matching grants so the UI can show *why*.
|
||||
/// </summary>
|
||||
public async Task<PermissionProbeResult> ProbeAsync(
|
||||
long generationId,
|
||||
string ldapGroup,
|
||||
NodeScope scope,
|
||||
NodePermissions required,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ldapGroup);
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
|
||||
var rows = await db.NodeAcls.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId && a.ClusterId == scope.ClusterId)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var trie = PermissionTrieBuilder.Build(scope.ClusterId, generationId, rows);
|
||||
var matches = trie.CollectMatches(scope, [ldapGroup]);
|
||||
|
||||
var effective = NodePermissions.None;
|
||||
foreach (var m in matches)
|
||||
effective |= m.PermissionFlags;
|
||||
|
||||
var granted = (effective & required) == required;
|
||||
return new PermissionProbeResult(
|
||||
Granted: granted,
|
||||
Required: required,
|
||||
Effective: effective,
|
||||
Matches: matches);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a <see cref="PermissionProbeService.ProbeAsync"/> call.</summary>
|
||||
public sealed record PermissionProbeResult(
|
||||
bool Granted,
|
||||
NodePermissions Required,
|
||||
NodePermissions Effective,
|
||||
IReadOnlyList<MatchedGrant> Matches);
|
||||
102
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/RedundancyMetrics.cs
Normal file
102
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/RedundancyMetrics.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry-compatible instrumentation for the redundancy surface. Uses in-box
|
||||
/// <see cref="System.Diagnostics.Metrics"/> so no NuGet dependency is required to emit —
|
||||
/// any MeterListener (dotnet-counters, OpenTelemetry.Extensions.Hosting OTLP exporter,
|
||||
/// Prometheus exporter, etc.) picks up the instruments by the <see cref="MeterName"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exporter configuration (OTLP, Prometheus, etc.) is intentionally NOT wired here —
|
||||
/// that's a deployment-ops decision that belongs in <c>Program.cs</c> behind an
|
||||
/// <c>appsettings</c> toggle. This class owns only the Meter + instruments so the
|
||||
/// production data stream exists regardless of exporter availability.
|
||||
///
|
||||
/// Counter + gauge names follow the otel-semantic-conventions pattern:
|
||||
/// <c>otopcua.redundancy.*</c> with tags for ClusterId + (for transitions) FromRole/ToRole/NodeId.
|
||||
/// </remarks>
|
||||
public sealed class RedundancyMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "ZB.MOM.WW.OtOpcUa.Redundancy";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _roleTransitions;
|
||||
private readonly object _gaugeLock = new();
|
||||
private readonly Dictionary<string, ClusterGaugeState> _gaugeState = new();
|
||||
|
||||
public RedundancyMetrics()
|
||||
{
|
||||
_meter = new Meter(MeterName, version: "1.0.0");
|
||||
_roleTransitions = _meter.CreateCounter<long>(
|
||||
"otopcua.redundancy.role_transition",
|
||||
unit: "{transition}",
|
||||
description: "Observed RedundancyRole changes per node — tagged FromRole, ToRole, NodeId, ClusterId.");
|
||||
|
||||
// Observable gauges — the callback reports whatever the last Observe*Count call stashed.
|
||||
_meter.CreateObservableGauge(
|
||||
"otopcua.redundancy.primary_count",
|
||||
ObservePrimaryCounts,
|
||||
unit: "{node}",
|
||||
description: "Count of Primary-role nodes per cluster (should be 1 for N+1 redundant clusters, 0 during failover).");
|
||||
_meter.CreateObservableGauge(
|
||||
"otopcua.redundancy.secondary_count",
|
||||
ObserveSecondaryCounts,
|
||||
unit: "{node}",
|
||||
description: "Count of Secondary-role nodes per cluster.");
|
||||
_meter.CreateObservableGauge(
|
||||
"otopcua.redundancy.stale_count",
|
||||
ObserveStaleCounts,
|
||||
unit: "{node}",
|
||||
description: "Count of cluster nodes whose LastSeenAt is older than StaleThreshold.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the per-cluster snapshot consumed by the ObservableGauges. Poller calls this
|
||||
/// at the end of every tick so the collectors see fresh numbers on the next observation
|
||||
/// window (by default 1s for dotnet-counters, configurable per exporter).
|
||||
/// </summary>
|
||||
public void SetClusterCounts(string clusterId, int primary, int secondary, int stale)
|
||||
{
|
||||
lock (_gaugeLock)
|
||||
{
|
||||
_gaugeState[clusterId] = new ClusterGaugeState(primary, secondary, stale);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increment the role_transition counter when a node's RedundancyRole changes. Tags
|
||||
/// allow breakdowns by from/to roles (e.g. Primary → Secondary for planned failover vs
|
||||
/// Primary → Standalone for emergency recovery) + by cluster for multi-site fleets.
|
||||
/// </summary>
|
||||
public void RecordRoleTransition(string clusterId, string nodeId, string fromRole, string toRole)
|
||||
{
|
||||
_roleTransitions.Add(1,
|
||||
new KeyValuePair<string, object?>("cluster.id", clusterId),
|
||||
new KeyValuePair<string, object?>("node.id", nodeId),
|
||||
new KeyValuePair<string, object?>("from_role", fromRole),
|
||||
new KeyValuePair<string, object?>("to_role", toRole));
|
||||
}
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
|
||||
private IEnumerable<Measurement<long>> ObservePrimaryCounts() => SnapshotGauge(s => s.Primary);
|
||||
private IEnumerable<Measurement<long>> ObserveSecondaryCounts() => SnapshotGauge(s => s.Secondary);
|
||||
private IEnumerable<Measurement<long>> ObserveStaleCounts() => SnapshotGauge(s => s.Stale);
|
||||
|
||||
private IEnumerable<Measurement<long>> SnapshotGauge(Func<ClusterGaugeState, int> selector)
|
||||
{
|
||||
List<Measurement<long>> results;
|
||||
lock (_gaugeLock)
|
||||
{
|
||||
results = new List<Measurement<long>>(_gaugeState.Count);
|
||||
foreach (var (cluster, state) in _gaugeState)
|
||||
results.Add(new Measurement<long>(selector(state),
|
||||
new KeyValuePair<string, object?>("cluster.id", cluster)));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private readonly record struct ClusterGaugeState(int Primary, int Secondary, int Stale);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fleet-wide external-ID reservation inspector + FleetAdmin-only release flow per
|
||||
/// <c>admin-ui.md §"Release an external-ID reservation"</c>. Release is audit-logged
|
||||
/// (<see cref="ConfigAuditLog"/>) via <c>sp_ReleaseExternalIdReservation</c>.
|
||||
/// </summary>
|
||||
public sealed class ReservationService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ExternalIdReservation>> ListActiveAsync(CancellationToken ct) =>
|
||||
db.ExternalIdReservations.AsNoTracking()
|
||||
.Where(r => r.ReleasedAt == null)
|
||||
.OrderBy(r => r.Kind).ThenBy(r => r.Value)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<List<ExternalIdReservation>> ListReleasedAsync(CancellationToken ct) =>
|
||||
db.ExternalIdReservations.AsNoTracking()
|
||||
.Where(r => r.ReleasedAt != null)
|
||||
.OrderByDescending(r => r.ReleasedAt)
|
||||
.Take(100)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task ReleaseAsync(string kind, string value, string reason, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
throw new ArgumentException("ReleaseReason is required (audit invariant)", nameof(reason));
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.sp_ReleaseExternalIdReservation @Kind = {0}, @Value = {1}, @ReleaseReason = {2}",
|
||||
[kind, value, reason],
|
||||
ct);
|
||||
}
|
||||
}
|
||||
66
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs
Normal file
66
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Draft-generation CRUD for <see cref="Script"/> rows — the C# source code referenced
|
||||
/// by Phase 7 virtual tags and scripted alarms. <see cref="Script.SourceHash"/> is
|
||||
/// recomputed on every save so Core.Scripting's compile cache sees a fresh key when
|
||||
/// source changes and reuses the compile when it doesn't.
|
||||
/// </summary>
|
||||
public sealed class ScriptService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<Script>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.Scripts.AsNoTracking()
|
||||
.Where(s => s.GenerationId == generationId)
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<Script?> GetAsync(long generationId, string scriptId, CancellationToken ct) =>
|
||||
db.Scripts.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.GenerationId == generationId && s.ScriptId == scriptId, ct);
|
||||
|
||||
public async Task<Script> AddAsync(long generationId, string name, string sourceCode, CancellationToken ct)
|
||||
{
|
||||
var s = new Script
|
||||
{
|
||||
GenerationId = generationId,
|
||||
ScriptId = $"scr-{Guid.NewGuid():N}"[..20],
|
||||
Name = name,
|
||||
SourceCode = sourceCode,
|
||||
SourceHash = ComputeHash(sourceCode),
|
||||
};
|
||||
db.Scripts.Add(s);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return s;
|
||||
}
|
||||
|
||||
public async Task<Script> UpdateAsync(long generationId, string scriptId, string name, string sourceCode, CancellationToken ct)
|
||||
{
|
||||
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct)
|
||||
?? throw new InvalidOperationException($"Script '{scriptId}' not found in generation {generationId}");
|
||||
s.Name = name;
|
||||
s.SourceCode = sourceCode;
|
||||
s.SourceHash = ComputeHash(sourceCode);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return s;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(long generationId, string scriptId, CancellationToken ct)
|
||||
{
|
||||
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct);
|
||||
if (s is null) return;
|
||||
db.Scripts.Remove(s);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
internal static string ComputeHash(string source)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(source ?? string.Empty));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Serilog; // resolves Serilog.ILogger explicitly in signatures
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Dry-run harness for the Phase 7 scripting UI. Takes a script + a synthetic input
|
||||
/// map + evaluates once, returns the output (or rejection / exception) plus any
|
||||
/// logger emissions the script produced. Per Phase 7 plan decision #22: only inputs
|
||||
/// the <see cref="DependencyExtractor"/> identified can be supplied, so a dependency
|
||||
/// the harness can't prove statically surfaces as a harness error, not a runtime
|
||||
/// surprise later.
|
||||
/// </summary>
|
||||
public sealed class ScriptTestHarnessService
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="source"/> as a virtual-tag script (return value is the
|
||||
/// tag's new value). <paramref name="inputs"/> supplies synthetic
|
||||
/// <see cref="DataValueSnapshot"/>s for every path the extractor found.
|
||||
/// </summary>
|
||||
public async Task<ScriptTestResult> RunVirtualTagAsync(
|
||||
string source, IDictionary<string, DataValueSnapshot> inputs, CancellationToken ct)
|
||||
{
|
||||
var deps = DependencyExtractor.Extract(source);
|
||||
if (!deps.IsValid)
|
||||
return ScriptTestResult.DependencyRejections(deps.Rejections);
|
||||
|
||||
var missing = deps.Reads.Where(r => !inputs.ContainsKey(r)).ToArray();
|
||||
if (missing.Length > 0)
|
||||
return ScriptTestResult.MissingInputs(missing);
|
||||
|
||||
var extra = inputs.Keys.Where(k => !deps.Reads.Contains(k)).ToArray();
|
||||
if (extra.Length > 0)
|
||||
return ScriptTestResult.UnknownInputs(extra);
|
||||
|
||||
ScriptEvaluator<HarnessVirtualTagContext, object?> evaluator;
|
||||
try
|
||||
{
|
||||
evaluator = ScriptEvaluator<HarnessVirtualTagContext, object?>.Compile(source);
|
||||
}
|
||||
catch (Exception compileEx)
|
||||
{
|
||||
return ScriptTestResult.Threw(compileEx.Message, []);
|
||||
}
|
||||
var capturing = new CapturingSink();
|
||||
var logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(capturing).CreateLogger();
|
||||
var ctx = new HarnessVirtualTagContext(inputs, logger);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await evaluator.RunAsync(ctx, ct);
|
||||
return ScriptTestResult.Ok(result, ctx.Writes, capturing.Events);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ScriptTestResult.Threw(ex.Message, capturing.Events);
|
||||
}
|
||||
}
|
||||
|
||||
// Public so Roslyn's script compilation can reference the context type through the
|
||||
// ScriptGlobals<T> surface. The harness instantiates this directly; operators never see it.
|
||||
public sealed class HarnessVirtualTagContext(
|
||||
IDictionary<string, DataValueSnapshot> inputs, Serilog.ILogger logger) : ScriptContext
|
||||
{
|
||||
public Dictionary<string, object?> Writes { get; } = [];
|
||||
public override DataValueSnapshot GetTag(string path) =>
|
||||
inputs.TryGetValue(path, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, Ua.StatusCodes.BadNotFound, null, DateTime.UtcNow);
|
||||
public override void SetVirtualTag(string path, object? value) => Writes[path] = value;
|
||||
public override DateTime Now => DateTime.UtcNow;
|
||||
public override Serilog.ILogger Logger => logger;
|
||||
}
|
||||
|
||||
private sealed class CapturingSink : ILogEventSink
|
||||
{
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
public void Emit(LogEvent e) => Events.Add(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Harness outcome: outputs, write-set, logger events, or a rejection/throw reason.</summary>
|
||||
public sealed record ScriptTestResult(
|
||||
ScriptTestOutcome Outcome,
|
||||
object? Output,
|
||||
IReadOnlyDictionary<string, object?> Writes,
|
||||
IReadOnlyList<LogEvent> LogEvents,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
public static ScriptTestResult Ok(object? output, IReadOnlyDictionary<string, object?> writes, IReadOnlyList<LogEvent> logs) =>
|
||||
new(ScriptTestOutcome.Success, output, writes, logs, []);
|
||||
public static ScriptTestResult Threw(string reason, IReadOnlyList<LogEvent> logs) =>
|
||||
new(ScriptTestOutcome.Threw, null, new Dictionary<string, object?>(), logs, [reason]);
|
||||
public static ScriptTestResult DependencyRejections(IReadOnlyList<DependencyRejection> rejs) =>
|
||||
new(ScriptTestOutcome.DependencyRejected, null, new Dictionary<string, object?>(), [],
|
||||
rejs.Select(r => r.Message).ToArray());
|
||||
public static ScriptTestResult MissingInputs(string[] paths) =>
|
||||
new(ScriptTestOutcome.MissingInputs, null, new Dictionary<string, object?>(), [],
|
||||
paths.Select(p => $"Missing synthetic input: {p}").ToArray());
|
||||
public static ScriptTestResult UnknownInputs(string[] paths) =>
|
||||
new(ScriptTestOutcome.UnknownInputs, null, new Dictionary<string, object?>(), [],
|
||||
paths.Select(p => $"Input '{p}' is not referenced by the script — remove it").ToArray());
|
||||
}
|
||||
|
||||
public enum ScriptTestOutcome
|
||||
{
|
||||
Success,
|
||||
Threw,
|
||||
DependencyRejected,
|
||||
MissingInputs,
|
||||
UnknownInputs,
|
||||
}
|
||||
|
||||
file static class Ua
|
||||
{
|
||||
// Mirrors OPC UA StatusCodes.BadNotFound without pulling the OPC stack into Admin.
|
||||
public static class StatusCodes { public const uint BadNotFound = 0x803E0000; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>Draft-generation CRUD for <see cref="ScriptedAlarm"/> rows.</summary>
|
||||
public sealed class ScriptedAlarmService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ScriptedAlarm>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.ScriptedAlarms.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.EquipmentId).ThenBy(a => a.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<ScriptedAlarm> AddAsync(
|
||||
long generationId, string equipmentId, string name, string alarmType,
|
||||
int severity, string messageTemplate, string predicateScriptId,
|
||||
bool historizeToAveva, bool retain, CancellationToken ct)
|
||||
{
|
||||
var a = new ScriptedAlarm
|
||||
{
|
||||
GenerationId = generationId,
|
||||
ScriptedAlarmId = $"sal-{Guid.NewGuid():N}"[..20],
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
AlarmType = alarmType,
|
||||
Severity = severity,
|
||||
MessageTemplate = messageTemplate,
|
||||
PredicateScriptId = predicateScriptId,
|
||||
HistorizeToAveva = historizeToAveva,
|
||||
Retain = retain,
|
||||
};
|
||||
db.ScriptedAlarms.Add(a);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return a;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(long generationId, string scriptedAlarmId, CancellationToken ct)
|
||||
{
|
||||
var a = await db.ScriptedAlarms.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||
if (a is null) return;
|
||||
db.ScriptedAlarms.Remove(a);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the persistent state row (ack/confirm/shelve) for this alarm identity —
|
||||
/// alarm state is NOT generation-scoped per Phase 7 plan decision #14, so the
|
||||
/// lookup is by <see cref="ScriptedAlarm.ScriptedAlarmId"/> only.
|
||||
/// </summary>
|
||||
public Task<ScriptedAlarmState?> GetStateAsync(string scriptedAlarmId, CancellationToken ct) =>
|
||||
db.ScriptedAlarmStates.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||
}
|
||||
71
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/TagService.cs
Normal file
71
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/TagService.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #155 — Tag CRUD scoped to a draft generation. Tags are the canonical signal definitions
|
||||
/// (one row per OPC UA variable) the Server materialises into the address space at startup.
|
||||
/// Mirrors the shape of <see cref="EquipmentService"/>; writes are restricted to draft
|
||||
/// generations only (published generations are immutable per the validation pipeline).
|
||||
/// </summary>
|
||||
public sealed class TagService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>Lists all tags in a generation, ordered by name. Optional driver / equipment filter.</summary>
|
||||
public Task<List<Tag>> ListAsync(long generationId,
|
||||
string? driverInstanceId = null,
|
||||
string? equipmentId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var query = db.Tags.AsNoTracking().Where(t => t.GenerationId == generationId);
|
||||
if (!string.IsNullOrWhiteSpace(driverInstanceId))
|
||||
query = query.Where(t => t.DriverInstanceId == driverInstanceId);
|
||||
if (!string.IsNullOrWhiteSpace(equipmentId))
|
||||
query = query.Where(t => t.EquipmentId == equipmentId);
|
||||
return query.OrderBy(t => t.Name).ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new tag row in the given draft. TagId is auto-derived as a GUID — the
|
||||
/// human-friendly Name is the user-facing identifier.
|
||||
/// </summary>
|
||||
public async Task<Tag> CreateAsync(long draftId, Tag input, CancellationToken ct)
|
||||
{
|
||||
input.GenerationId = draftId;
|
||||
if (string.IsNullOrWhiteSpace(input.TagId))
|
||||
input.TagId = Guid.NewGuid().ToString("N");
|
||||
db.Tags.Add(input);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return input;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Tag updated, CancellationToken ct)
|
||||
{
|
||||
var existing = await db.Tags
|
||||
.FirstOrDefaultAsync(t => t.TagRowId == updated.TagRowId, ct)
|
||||
?? throw new InvalidOperationException($"Tag row {updated.TagRowId} not found");
|
||||
|
||||
// Editable fields. TagId / GenerationId are immutable; the Validation pipeline rejects
|
||||
// changes that would break referential integrity (sp_ValidateDraft per decision #110).
|
||||
existing.Name = updated.Name;
|
||||
existing.DriverInstanceId = updated.DriverInstanceId;
|
||||
existing.DeviceId = updated.DeviceId;
|
||||
existing.EquipmentId = updated.EquipmentId;
|
||||
existing.FolderPath = updated.FolderPath;
|
||||
existing.DataType = updated.DataType;
|
||||
existing.AccessLevel = updated.AccessLevel;
|
||||
existing.WriteIdempotent = updated.WriteIdempotent;
|
||||
existing.PollGroupId = updated.PollGroupId;
|
||||
existing.TagConfig = updated.TagConfig;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid tagRowId, CancellationToken ct)
|
||||
{
|
||||
var existing = await db.Tags.FirstOrDefaultAsync(t => t.TagRowId == tagRowId, ct);
|
||||
if (existing is null) return;
|
||||
db.Tags.Remove(existing);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
213
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs
Normal file
213
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-function impact preview for UNS structural moves per Phase 6.4 Stream A.2. Given
|
||||
/// a <see cref="UnsMoveOperation"/> plus a snapshot of the draft's UNS tree and its
|
||||
/// equipment + tag counts, returns an <see cref="UnsImpactPreview"/> the Admin UI shows
|
||||
/// in a confirmation modal before committing the move.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Stateless + deterministic — testable without EF or a live draft. The caller
|
||||
/// (Razor page) loads the draft's snapshot via the normal Configuration services, passes
|
||||
/// it in, and the analyzer counts + categorises the impact. The returned
|
||||
/// <see cref="UnsImpactPreview.RevisionToken"/> is the token the caller must re-check at
|
||||
/// confirm time; a mismatch means another operator mutated the draft between preview +
|
||||
/// confirm and the operation needs to be refreshed (decision on concurrent-edit safety
|
||||
/// in Phase 6.4 Scope).</para>
|
||||
///
|
||||
/// <para>Cross-cluster moves are rejected here (decision #82) — equipment is
|
||||
/// cluster-scoped; the UI disables the drop target and surfaces an Export/Import workflow
|
||||
/// toast instead.</para>
|
||||
/// </remarks>
|
||||
public static class UnsImpactAnalyzer
|
||||
{
|
||||
/// <summary>Run the analyzer. Returns a populated preview or throws for invalid operations.</summary>
|
||||
public static UnsImpactPreview Analyze(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ArgumentNullException.ThrowIfNull(move);
|
||||
|
||||
// Cross-cluster guard — the analyzer refuses rather than silently re-homing.
|
||||
if (!string.Equals(move.SourceClusterId, move.TargetClusterId, StringComparison.OrdinalIgnoreCase))
|
||||
throw new CrossClusterMoveRejectedException(
|
||||
"Equipment is cluster-scoped (decision #82). Use Export → Import to migrate equipment " +
|
||||
"across clusters; drag/drop rejected.");
|
||||
|
||||
return move.Kind switch
|
||||
{
|
||||
UnsMoveKind.LineMove => AnalyzeLineMove(snapshot, move),
|
||||
UnsMoveKind.AreaRename => AnalyzeAreaRename(snapshot, move),
|
||||
UnsMoveKind.LineMerge => AnalyzeLineMerge(snapshot, move),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(move), move.Kind, $"Unsupported move kind {move.Kind}"),
|
||||
};
|
||||
}
|
||||
|
||||
private static UnsImpactPreview AnalyzeLineMove(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
||||
{
|
||||
var line = snapshot.FindLine(move.SourceLineId!)
|
||||
?? throw new UnsMoveValidationException($"Source line '{move.SourceLineId}' not found in draft {snapshot.DraftGenerationId}.");
|
||||
|
||||
var targetArea = snapshot.FindArea(move.TargetAreaId!)
|
||||
?? throw new UnsMoveValidationException($"Target area '{move.TargetAreaId}' not found in draft {snapshot.DraftGenerationId}.");
|
||||
|
||||
var warnings = new List<string>();
|
||||
if (targetArea.LineIds.Contains(line.LineId, StringComparer.OrdinalIgnoreCase))
|
||||
warnings.Add($"Target area '{targetArea.Name}' already contains line '{line.Name}' — dropping a no-op move.");
|
||||
|
||||
// If the target area has a line with the same display name as the mover, warn about
|
||||
// visual ambiguity even though the IDs differ (operators frequently reuse line names).
|
||||
if (targetArea.LineIds.Any(lid =>
|
||||
snapshot.FindLine(lid) is { } sibling &&
|
||||
string.Equals(sibling.Name, line.Name, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(sibling.LineId, line.LineId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
warnings.Add($"Target area '{targetArea.Name}' already has a line named '{line.Name}'. Consider renaming before the move.");
|
||||
}
|
||||
|
||||
return new UnsImpactPreview
|
||||
{
|
||||
AffectedEquipmentCount = line.EquipmentCount,
|
||||
AffectedTagCount = line.TagCount,
|
||||
CascadeWarnings = warnings,
|
||||
RevisionToken = snapshot.RevisionToken,
|
||||
HumanReadableSummary =
|
||||
$"Moving line '{line.Name}' from area '{snapshot.FindAreaByLineId(line.LineId)?.Name ?? "?"}' " +
|
||||
$"to '{targetArea.Name}' will re-home {line.EquipmentCount} equipment + re-parent {line.TagCount} tags.",
|
||||
};
|
||||
}
|
||||
|
||||
private static UnsImpactPreview AnalyzeAreaRename(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
||||
{
|
||||
var area = snapshot.FindArea(move.SourceAreaId!)
|
||||
?? throw new UnsMoveValidationException($"Source area '{move.SourceAreaId}' not found in draft {snapshot.DraftGenerationId}.");
|
||||
|
||||
var affectedEquipment = area.LineIds
|
||||
.Select(lid => snapshot.FindLine(lid)?.EquipmentCount ?? 0)
|
||||
.Sum();
|
||||
var affectedTags = area.LineIds
|
||||
.Select(lid => snapshot.FindLine(lid)?.TagCount ?? 0)
|
||||
.Sum();
|
||||
|
||||
return new UnsImpactPreview
|
||||
{
|
||||
AffectedEquipmentCount = affectedEquipment,
|
||||
AffectedTagCount = affectedTags,
|
||||
CascadeWarnings = [],
|
||||
RevisionToken = snapshot.RevisionToken,
|
||||
HumanReadableSummary =
|
||||
$"Renaming area '{area.Name}' → '{move.NewName}' cascades to {area.LineIds.Count} lines / " +
|
||||
$"{affectedEquipment} equipment / {affectedTags} tags.",
|
||||
};
|
||||
}
|
||||
|
||||
private static UnsImpactPreview AnalyzeLineMerge(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
||||
{
|
||||
var src = snapshot.FindLine(move.SourceLineId!)
|
||||
?? throw new UnsMoveValidationException($"Source line '{move.SourceLineId}' not found.");
|
||||
var dst = snapshot.FindLine(move.TargetLineId!)
|
||||
?? throw new UnsMoveValidationException($"Target line '{move.TargetLineId}' not found.");
|
||||
|
||||
var warnings = new List<string>();
|
||||
if (!string.Equals(snapshot.FindAreaByLineId(src.LineId)?.AreaId,
|
||||
snapshot.FindAreaByLineId(dst.LineId)?.AreaId,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
warnings.Add($"Lines '{src.Name}' and '{dst.Name}' are in different areas. The merge will re-parent equipment + tags into '{dst.Name}'s area.");
|
||||
}
|
||||
|
||||
return new UnsImpactPreview
|
||||
{
|
||||
AffectedEquipmentCount = src.EquipmentCount,
|
||||
AffectedTagCount = src.TagCount,
|
||||
CascadeWarnings = warnings,
|
||||
RevisionToken = snapshot.RevisionToken,
|
||||
HumanReadableSummary =
|
||||
$"Merging line '{src.Name}' into '{dst.Name}': {src.EquipmentCount} equipment + {src.TagCount} tags re-parent. " +
|
||||
$"The source line is deleted at commit.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Kind of UNS structural move the analyzer understands.</summary>
|
||||
public enum UnsMoveKind
|
||||
{
|
||||
/// <summary>Drag a whole line from one area to another.</summary>
|
||||
LineMove,
|
||||
|
||||
/// <summary>Rename an area (cascades to the UNS paths of every equipment + tag below it).</summary>
|
||||
AreaRename,
|
||||
|
||||
/// <summary>Merge two lines into one; source line's equipment + tags are re-parented.</summary>
|
||||
LineMerge,
|
||||
}
|
||||
|
||||
/// <summary>One UNS structural move request.</summary>
|
||||
/// <param name="Kind">Move variant — selects which source + target fields are required.</param>
|
||||
/// <param name="SourceClusterId">Cluster of the source node. Must match <see cref="TargetClusterId"/> (decision #82).</param>
|
||||
/// <param name="TargetClusterId">Cluster of the target node.</param>
|
||||
/// <param name="SourceAreaId">Source area id for <see cref="UnsMoveKind.AreaRename"/>.</param>
|
||||
/// <param name="SourceLineId">Source line id for <see cref="UnsMoveKind.LineMove"/> / <see cref="UnsMoveKind.LineMerge"/>.</param>
|
||||
/// <param name="TargetAreaId">Target area id for <see cref="UnsMoveKind.LineMove"/>.</param>
|
||||
/// <param name="TargetLineId">Target line id for <see cref="UnsMoveKind.LineMerge"/>.</param>
|
||||
/// <param name="NewName">New display name for <see cref="UnsMoveKind.AreaRename"/>.</param>
|
||||
public sealed record UnsMoveOperation(
|
||||
UnsMoveKind Kind,
|
||||
string SourceClusterId,
|
||||
string TargetClusterId,
|
||||
string? SourceAreaId = null,
|
||||
string? SourceLineId = null,
|
||||
string? TargetAreaId = null,
|
||||
string? TargetLineId = null,
|
||||
string? NewName = null);
|
||||
|
||||
/// <summary>Snapshot of the UNS tree + counts the analyzer walks.</summary>
|
||||
public sealed class UnsTreeSnapshot
|
||||
{
|
||||
public required long DraftGenerationId { get; init; }
|
||||
public required DraftRevisionToken RevisionToken { get; init; }
|
||||
public required IReadOnlyList<UnsAreaSummary> Areas { get; init; }
|
||||
public required IReadOnlyList<UnsLineSummary> Lines { get; init; }
|
||||
|
||||
public UnsAreaSummary? FindArea(string areaId) =>
|
||||
Areas.FirstOrDefault(a => string.Equals(a.AreaId, areaId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public UnsLineSummary? FindLine(string lineId) =>
|
||||
Lines.FirstOrDefault(l => string.Equals(l.LineId, lineId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public UnsAreaSummary? FindAreaByLineId(string lineId) =>
|
||||
Areas.FirstOrDefault(a => a.LineIds.Contains(lineId, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public sealed record UnsAreaSummary(string AreaId, string Name, IReadOnlyList<string> LineIds);
|
||||
|
||||
public sealed record UnsLineSummary(string LineId, string Name, int EquipmentCount, int TagCount);
|
||||
|
||||
/// <summary>
|
||||
/// Opaque per-draft revision fingerprint. Preview fetches the current token + stores it
|
||||
/// in the <see cref="UnsImpactPreview.RevisionToken"/>. Confirm compares the token against
|
||||
/// the draft's live value; mismatch means another operator mutated the draft between
|
||||
/// preview + commit — raise <c>409 Conflict / refresh-required</c> in the UI.
|
||||
/// </summary>
|
||||
public sealed record DraftRevisionToken(string Value)
|
||||
{
|
||||
/// <summary>Compare two tokens for equality; null-safe.</summary>
|
||||
public bool Matches(DraftRevisionToken? other) =>
|
||||
other is not null &&
|
||||
string.Equals(Value, other.Value, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Output of <see cref="UnsImpactAnalyzer.Analyze"/>.</summary>
|
||||
public sealed class UnsImpactPreview
|
||||
{
|
||||
public required int AffectedEquipmentCount { get; init; }
|
||||
public required int AffectedTagCount { get; init; }
|
||||
public required IReadOnlyList<string> CascadeWarnings { get; init; }
|
||||
public required DraftRevisionToken RevisionToken { get; init; }
|
||||
public required string HumanReadableSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Thrown when a move targets a different cluster than the source (decision #82).</summary>
|
||||
public sealed class CrossClusterMoveRejectedException(string message) : Exception(message);
|
||||
|
||||
/// <summary>Thrown when the move operation references a source / target that doesn't exist in the draft.</summary>
|
||||
public sealed class UnsMoveValidationException(string message) : Exception(message);
|
||||
180
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
Normal file
180
src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class UnsService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<UnsArea>> ListAreasAsync(long generationId, CancellationToken ct) =>
|
||||
db.UnsAreas.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<List<UnsLine>> ListLinesAsync(long generationId, CancellationToken ct) =>
|
||||
db.UnsLines.AsNoTracking()
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<UnsArea> AddAreaAsync(long draftId, string clusterId, string name, string? notes, CancellationToken ct)
|
||||
{
|
||||
var area = new UnsArea
|
||||
{
|
||||
GenerationId = draftId,
|
||||
UnsAreaId = $"area-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
Name = name,
|
||||
Notes = notes,
|
||||
};
|
||||
db.UnsAreas.Add(area);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return area;
|
||||
}
|
||||
|
||||
public async Task<UnsLine> AddLineAsync(long draftId, string unsAreaId, string name, string? notes, CancellationToken ct)
|
||||
{
|
||||
var line = new UnsLine
|
||||
{
|
||||
GenerationId = draftId,
|
||||
UnsLineId = $"line-{Guid.NewGuid():N}"[..20],
|
||||
UnsAreaId = unsAreaId,
|
||||
Name = name,
|
||||
Notes = notes,
|
||||
};
|
||||
db.UnsLines.Add(line);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return line;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the full UNS tree snapshot for the analyzer. Walks areas + lines in the draft
|
||||
/// and counts equipment + tags per line. Returns the snapshot plus a deterministic
|
||||
/// revision token computed by SHA-256'ing the sorted (kind, id, parent, name) tuples —
|
||||
/// stable across processes + changes whenever any row is added / modified / deleted.
|
||||
/// </summary>
|
||||
public async Task<UnsTreeSnapshot> LoadSnapshotAsync(long generationId, CancellationToken ct)
|
||||
{
|
||||
var areas = await db.UnsAreas.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var lines = await db.UnsLines.AsNoTracking()
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.UnsLineId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var equipmentCounts = await db.Equipment.AsNoTracking()
|
||||
.Where(e => e.GenerationId == generationId)
|
||||
.GroupBy(e => e.UnsLineId)
|
||||
.Select(g => new { LineId = g.Key, Count = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
var equipmentByLine = equipmentCounts.ToDictionary(x => x.LineId, x => x.Count, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var lineSummaries = lines.Select(l =>
|
||||
new UnsLineSummary(
|
||||
LineId: l.UnsLineId,
|
||||
Name: l.Name,
|
||||
EquipmentCount: equipmentByLine.GetValueOrDefault(l.UnsLineId),
|
||||
TagCount: 0)).ToList();
|
||||
|
||||
var areaSummaries = areas.Select(a =>
|
||||
new UnsAreaSummary(
|
||||
AreaId: a.UnsAreaId,
|
||||
Name: a.Name,
|
||||
LineIds: lines.Where(l => string.Equals(l.UnsAreaId, a.UnsAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(l => l.UnsLineId).ToList())).ToList();
|
||||
|
||||
return new UnsTreeSnapshot
|
||||
{
|
||||
DraftGenerationId = generationId,
|
||||
RevisionToken = ComputeRevisionToken(areas, lines),
|
||||
Areas = areaSummaries,
|
||||
Lines = lineSummaries,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomic re-parent of a line to a new area inside the same draft. The caller must pass
|
||||
/// the revision token it observed at preview time — a mismatch raises
|
||||
/// <see cref="DraftRevisionConflictException"/> so the UI can show the 409 concurrent-edit
|
||||
/// modal instead of silently overwriting a peer's work.
|
||||
/// </summary>
|
||||
public async Task MoveLineAsync(
|
||||
long generationId,
|
||||
DraftRevisionToken expected,
|
||||
string lineId,
|
||||
string targetAreaId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expected);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(lineId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetAreaId);
|
||||
|
||||
var supportsTx = db.Database.IsRelational();
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||
if (supportsTx) tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var areas = await db.UnsAreas
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var lines = await db.UnsLines
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.UnsLineId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var current = ComputeRevisionToken(areas, lines);
|
||||
if (!current.Matches(expected))
|
||||
throw new DraftRevisionConflictException(
|
||||
$"Draft {generationId} changed since preview. Expected revision {expected.Value}, saw {current.Value}. " +
|
||||
"Refresh + redo the move.");
|
||||
|
||||
var line = lines.FirstOrDefault(l => string.Equals(l.UnsLineId, lineId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException($"Line '{lineId}' not found in draft {generationId}.");
|
||||
|
||||
if (!areas.Any(a => string.Equals(a.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"Target area '{targetAreaId}' not found in draft {generationId}.");
|
||||
|
||||
if (string.Equals(line.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
return; // no-op drop — same area
|
||||
|
||||
line.UnsAreaId = targetAreaId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static DraftRevisionToken ComputeRevisionToken(IReadOnlyList<UnsArea> areas, IReadOnlyList<UnsLine> lines)
|
||||
{
|
||||
var sb = new StringBuilder(capacity: 256 + (areas.Count + lines.Count) * 80);
|
||||
foreach (var a in areas.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal))
|
||||
sb.Append("A:").Append(a.UnsAreaId).Append('|').Append(a.Name).Append('|').Append(a.Notes ?? "").Append(';');
|
||||
foreach (var l in lines.OrderBy(l => l.UnsLineId, StringComparer.Ordinal))
|
||||
sb.Append("L:").Append(l.UnsLineId).Append('|').Append(l.UnsAreaId).Append('|').Append(l.Name).Append('|').Append(l.Notes ?? "").Append(';');
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
return new DraftRevisionToken(Convert.ToHexStringLower(hash)[..16]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when a UNS move's expected revision token no longer matches the live draft
|
||||
/// — another operator mutated the draft between preview + commit. Caller surfaces a 409-style
|
||||
/// "refresh required" modal in the Admin UI.</summary>
|
||||
public sealed class DraftRevisionConflictException(string message) : Exception(message);
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Draft-aware write surface over <see cref="NodeAcl"/>. Replaces direct
|
||||
/// <see cref="NodeAclService"/> CRUD for Admin UI grant authoring; the raw service stays
|
||||
/// as the read / delete surface. Enforces the invariants listed in Phase 6.2 Stream D.2:
|
||||
/// scope-uniqueness per (LdapGroup, ScopeKind, ScopeId, GenerationId), grant shape
|
||||
/// consistency, and no empty permission masks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Per decision #129 grants are additive — <see cref="NodePermissions.None"/> is
|
||||
/// rejected at write time. Explicit Deny is v2.1 and is not representable in the current
|
||||
/// <c>NodeAcl</c> row; attempts to express it (e.g. empty permission set) surface as
|
||||
/// <see cref="InvalidNodeAclGrantException"/>.</para>
|
||||
///
|
||||
/// <para>Draft scope: writes always target an unpublished (Draft-state) generation id.
|
||||
/// Once a generation publishes, its rows are frozen.</para>
|
||||
/// </remarks>
|
||||
public sealed class ValidatedNodeAclAuthoringService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>Add a new grant row to the given draft generation.</summary>
|
||||
public async Task<NodeAcl> GrantAsync(
|
||||
long draftGenerationId,
|
||||
string clusterId,
|
||||
string ldapGroup,
|
||||
NodeAclScopeKind scopeKind,
|
||||
string? scopeId,
|
||||
NodePermissions permissions,
|
||||
string? notes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ldapGroup);
|
||||
|
||||
ValidateGrantShape(scopeKind, scopeId, permissions);
|
||||
await EnsureNoDuplicate(draftGenerationId, clusterId, ldapGroup, scopeKind, scopeId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var row = new NodeAcl
|
||||
{
|
||||
GenerationId = draftGenerationId,
|
||||
NodeAclId = $"acl-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
LdapGroup = ldapGroup,
|
||||
ScopeKind = scopeKind,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = permissions,
|
||||
Notes = notes,
|
||||
};
|
||||
db.NodeAcls.Add(row);
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace an existing grant's permission set in place. Validates the new shape;
|
||||
/// rejects attempts to blank-out to None (that's a Revoke via <see cref="NodeAclService"/>).
|
||||
/// </summary>
|
||||
public async Task<NodeAcl> UpdatePermissionsAsync(
|
||||
Guid nodeAclRowId,
|
||||
NodePermissions newPermissions,
|
||||
string? notes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (newPermissions == NodePermissions.None)
|
||||
throw new InvalidNodeAclGrantException(
|
||||
"Permission set cannot be None — revoke the row instead of writing an empty grant.");
|
||||
|
||||
var row = await db.NodeAcls.FirstOrDefaultAsync(a => a.NodeAclRowId == nodeAclRowId, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new InvalidNodeAclGrantException($"NodeAcl row {nodeAclRowId} not found.");
|
||||
|
||||
row.PermissionFlags = newPermissions;
|
||||
if (notes is not null) row.Notes = notes;
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static void ValidateGrantShape(NodeAclScopeKind scopeKind, string? scopeId, NodePermissions permissions)
|
||||
{
|
||||
if (permissions == NodePermissions.None)
|
||||
throw new InvalidNodeAclGrantException(
|
||||
"Permission set cannot be None — grants must carry at least one flag (decision #129, additive only).");
|
||||
|
||||
if (scopeKind == NodeAclScopeKind.Cluster && !string.IsNullOrEmpty(scopeId))
|
||||
throw new InvalidNodeAclGrantException(
|
||||
"Cluster-scope grants must have null ScopeId. ScopeId only applies to sub-cluster scopes.");
|
||||
|
||||
if (scopeKind != NodeAclScopeKind.Cluster && string.IsNullOrEmpty(scopeId))
|
||||
throw new InvalidNodeAclGrantException(
|
||||
$"ScopeKind={scopeKind} requires a populated ScopeId.");
|
||||
}
|
||||
|
||||
private async Task EnsureNoDuplicate(
|
||||
long generationId, string clusterId, string ldapGroup, NodeAclScopeKind scopeKind, string? scopeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await db.NodeAcls.AsNoTracking()
|
||||
.AnyAsync(a => a.GenerationId == generationId
|
||||
&& a.ClusterId == clusterId
|
||||
&& a.LdapGroup == ldapGroup
|
||||
&& a.ScopeKind == scopeKind
|
||||
&& a.ScopeId == scopeId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (exists)
|
||||
throw new InvalidNodeAclGrantException(
|
||||
$"A grant for (LdapGroup={ldapGroup}, ScopeKind={scopeKind}, ScopeId={scopeId}) already exists in generation {generationId}. " +
|
||||
"Update the existing row's permissions instead of inserting a duplicate.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when a <see cref="NodeAcl"/> grant authoring request violates an invariant.</summary>
|
||||
public sealed class InvalidNodeAclGrantException(string message) : Exception(message);
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>Draft-generation CRUD for <see cref="VirtualTag"/> rows.</summary>
|
||||
public sealed class VirtualTagService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<VirtualTag>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.VirtualTags.AsNoTracking()
|
||||
.Where(v => v.GenerationId == generationId)
|
||||
.OrderBy(v => v.EquipmentId).ThenBy(v => v.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<VirtualTag> AddAsync(
|
||||
long generationId, string equipmentId, string name, string dataType, string scriptId,
|
||||
bool changeTriggered, int? timerIntervalMs, bool historize, CancellationToken ct)
|
||||
{
|
||||
var v = new VirtualTag
|
||||
{
|
||||
GenerationId = generationId,
|
||||
VirtualTagId = $"vt-{Guid.NewGuid():N}"[..20],
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
DataType = dataType,
|
||||
ScriptId = scriptId,
|
||||
ChangeTriggered = changeTriggered,
|
||||
TimerIntervalMs = timerIntervalMs,
|
||||
Historize = historize,
|
||||
};
|
||||
db.VirtualTags.Add(v);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return v;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(long generationId, string virtualTagId, CancellationToken ct)
|
||||
{
|
||||
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct);
|
||||
if (v is null) return;
|
||||
db.VirtualTags.Remove(v);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<VirtualTag> UpdateEnabledAsync(long generationId, string virtualTagId, bool enabled, CancellationToken ct)
|
||||
{
|
||||
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct)
|
||||
?? throw new InvalidOperationException($"VirtualTag '{virtualTagId}' not found in generation {generationId}");
|
||||
v.Enabled = enabled;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Admin</RootNamespace>
|
||||
<AssemblyName>OtOpcUa.Admin</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0"/>
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0"/>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3"/>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.15.3-beta.1"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Admin.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
32
src/Server/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
Normal file
32
src/Server/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"ConfigDb": "Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;"
|
||||
},
|
||||
"Authentication": {
|
||||
"Ldap": {
|
||||
"Enabled": true,
|
||||
"Server": "localhost",
|
||||
"Port": 3893,
|
||||
"UseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"SearchBase": "dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,ou=svcaccts,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"DisplayNameAttribute": "cn",
|
||||
"GroupAttribute": "memberOf",
|
||||
"GroupToRole": {
|
||||
"ReadOnly": "ConfigViewer",
|
||||
"ReadWrite": "ConfigEditor",
|
||||
"AlarmAck": "FleetAdmin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information"
|
||||
},
|
||||
"Metrics": {
|
||||
"Prometheus": {
|
||||
"Enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css
Normal file
3
src/Server/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css
Normal file
@@ -0,0 +1,3 @@
|
||||
/* OtOpcUa Admin — ScadaLink-parity palette. Keep it minimal here; lean on Bootstrap 5. */
|
||||
body { background-color: #f5f6fa; }
|
||||
.nav-link.active { background-color: rgba(255,255,255,0.1); border-radius: 4px; }
|
||||
@@ -0,0 +1,59 @@
|
||||
// Phase 7 Stream F — Monaco editor loader for ScriptEditor.razor.
|
||||
// Progressive enhancement: the textarea is authoritative until Monaco attaches;
|
||||
// after attach, Monaco syncs back into the textarea on every change so Blazor's
|
||||
// @bind still sees the latest value.
|
||||
|
||||
(function () {
|
||||
if (window.otOpcUaScriptEditor) return;
|
||||
|
||||
const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs';
|
||||
let loaderPromise = null;
|
||||
|
||||
function ensureLoader() {
|
||||
if (loaderPromise) return loaderPromise;
|
||||
loaderPromise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `${MONACO_CDN}/loader.js`;
|
||||
script.onload = () => {
|
||||
window.require.config({ paths: { vs: MONACO_CDN } });
|
||||
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
|
||||
};
|
||||
script.onerror = () => reject(new Error('Monaco CDN unreachable'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return loaderPromise;
|
||||
}
|
||||
|
||||
window.otOpcUaScriptEditor = {
|
||||
attach: async function (textareaId) {
|
||||
const ta = document.getElementById(textareaId);
|
||||
if (!ta) return;
|
||||
const monaco = await ensureLoader();
|
||||
|
||||
// Mount Monaco over the textarea. The textarea stays in the DOM as the
|
||||
// source of truth for Blazor's @bind — Monaco mirrors into it on every
|
||||
// keystroke so server-side state stays in sync.
|
||||
const host = document.createElement('div');
|
||||
host.style.height = '340px';
|
||||
host.style.border = '1px solid #ced4da';
|
||||
host.style.borderRadius = '0.25rem';
|
||||
ta.style.display = 'none';
|
||||
ta.parentNode.insertBefore(host, ta);
|
||||
|
||||
const editor = monaco.editor.create(host, {
|
||||
value: ta.value,
|
||||
language: 'csharp',
|
||||
theme: 'vs',
|
||||
automaticLayout: true,
|
||||
fontSize: 13,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
});
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
ta.value = editor.getValue();
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Server-level alarm-condition state machine. Tracks one entry per registered
|
||||
/// condition; consumes value changes from the four sub-attribute references in
|
||||
/// <see cref="AlarmConditionInfo"/> (InAlarm / Priority / Description / Acked) and
|
||||
/// raises <see cref="TransitionRaised"/> on Active / Acknowledged / Inactive
|
||||
/// transitions per OPC UA Part 9 (simplified). Operator acknowledgement routes
|
||||
/// through <see cref="IAlarmAcknowledger"/> against <c>AckMsgWriteRef</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the driver-agnostic replacement for <c>GalaxyAlarmTracker</c>. The
|
||||
/// service does not own subscription lifecycle — PR 2.3 will wire DriverNodeManager
|
||||
/// to subscribe through the driver's <c>ISubscribable</c> and forward value changes
|
||||
/// here via <see cref="OnValueChanged"/>. Keeping the service free of subscription
|
||||
/// plumbing makes it trivially testable and lets future drivers feed it from any
|
||||
/// value source (in-process, gRPC, named pipe).
|
||||
/// </remarks>
|
||||
public sealed class AlarmConditionService : IDisposable
|
||||
{
|
||||
private readonly Func<DateTime> _clock;
|
||||
|
||||
// ConditionId → state.
|
||||
private readonly ConcurrentDictionary<string, AlarmConditionState> _conditions =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Sub-attribute full ref → (conditionId, which field). Multiple conditions may
|
||||
// observe the same sub-attribute (rare but legal); the value is a list to support
|
||||
// fan-out on a single value change.
|
||||
private readonly ConcurrentDictionary<string, List<(string ConditionId, AlarmField Field)>> _refToCondition =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly object _refMapLock = new();
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when a registered condition transitions Active / Acknowledged / Inactive.
|
||||
/// Handlers must be cheap; the event is raised on whatever thread feeds
|
||||
/// <see cref="OnValueChanged"/> and blocks the value-change pipeline.
|
||||
/// </summary>
|
||||
public event EventHandler<AlarmConditionTransition>? TransitionRaised;
|
||||
|
||||
public AlarmConditionService() : this(() => DateTime.UtcNow) { }
|
||||
|
||||
/// <summary>Test seam — inject a fixed clock for deterministic transition timestamps.</summary>
|
||||
internal AlarmConditionService(Func<DateTime> clock)
|
||||
{
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
|
||||
/// <summary>Number of currently tracked conditions. Diagnostic only.</summary>
|
||||
public int TrackedCount => _conditions.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Register a condition. Idempotent — repeat calls for the same
|
||||
/// <paramref name="conditionId"/> are a no-op. The acker is captured for the
|
||||
/// condition's lifetime; pass null when the driver does not accept acks.
|
||||
/// </summary>
|
||||
public void Track(string conditionId, AlarmConditionInfo info, IAlarmAcknowledger? acker = null)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(conditionId);
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
|
||||
var state = new AlarmConditionState(conditionId, info, acker);
|
||||
if (!_conditions.TryAdd(conditionId, state)) return;
|
||||
|
||||
lock (_refMapLock)
|
||||
{
|
||||
AddRefMapping(info.InAlarmRef, conditionId, AlarmField.InAlarm);
|
||||
AddRefMapping(info.PriorityRef, conditionId, AlarmField.Priority);
|
||||
AddRefMapping(info.DescAttrNameRef, conditionId, AlarmField.DescAttrName);
|
||||
AddRefMapping(info.AckedRef, conditionId, AlarmField.Acked);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deregister a condition. No-op when not tracked.</summary>
|
||||
public void Untrack(string conditionId)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (!_conditions.TryRemove(conditionId, out var state)) return;
|
||||
|
||||
lock (_refMapLock)
|
||||
{
|
||||
RemoveRefMapping(state.Info.InAlarmRef, conditionId);
|
||||
RemoveRefMapping(state.Info.PriorityRef, conditionId);
|
||||
RemoveRefMapping(state.Info.DescAttrNameRef, conditionId);
|
||||
RemoveRefMapping(state.Info.AckedRef, conditionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the set of sub-attribute references the service currently needs
|
||||
/// subscribed. Callers wire one subscription per ref through the driver's
|
||||
/// <see cref="ISubscribable"/>; PR 2.3 owns that wiring.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> GetSubscribedReferences()
|
||||
{
|
||||
lock (_refMapLock) return [.. _refToCondition.Keys];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator acknowledgement entry point. Returns false when the condition is
|
||||
/// not tracked, the condition has no acker registered, the condition has no
|
||||
/// <c>AckMsgWriteRef</c>, or the acker reports the write failed.
|
||||
/// </summary>
|
||||
public Task<bool> AcknowledgeAsync(string conditionId, string comment, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_disposed || !_conditions.TryGetValue(conditionId, out var state))
|
||||
return Task.FromResult(false);
|
||||
if (state.Acker is null || string.IsNullOrEmpty(state.Info.AckMsgWriteRef))
|
||||
return Task.FromResult(false);
|
||||
return state.Acker.WriteAckMessageAsync(state.Info.AckMsgWriteRef, comment ?? string.Empty, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot every tracked condition's current state. Diagnostic / dashboard use only.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AlarmConditionSnapshot> Snapshot()
|
||||
{
|
||||
return [.. _conditions.Values.Select(s =>
|
||||
{
|
||||
lock (s.Lock)
|
||||
return new AlarmConditionSnapshot(s.ConditionId, s.InAlarm, s.Acked, s.Priority, s.Description);
|
||||
})];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feed a value change for one of the registered sub-attribute references.
|
||||
/// The service runs the state machine and raises <see cref="TransitionRaised"/>
|
||||
/// when the change produces a lifecycle transition. Unknown references are
|
||||
/// silently dropped — the caller may register and unregister concurrently with
|
||||
/// value-change delivery, and a stale callback for a recently-untracked
|
||||
/// condition must not throw.
|
||||
/// </summary>
|
||||
public void OnValueChanged(string fullReference, DataValueSnapshot value)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (string.IsNullOrEmpty(fullReference)) return;
|
||||
|
||||
List<(string ConditionId, AlarmField Field)>? targets;
|
||||
lock (_refMapLock)
|
||||
{
|
||||
if (!_refToCondition.TryGetValue(fullReference, out targets) || targets.Count == 0) return;
|
||||
// Snapshot under lock; the state machine runs outside.
|
||||
targets = [.. targets];
|
||||
}
|
||||
|
||||
var now = _clock();
|
||||
foreach (var (conditionId, field) in targets)
|
||||
{
|
||||
if (!_conditions.TryGetValue(conditionId, out var state)) continue;
|
||||
|
||||
AlarmConditionTransition? transition = null;
|
||||
lock (state.Lock)
|
||||
{
|
||||
transition = ApplyValue(state, field, value, now);
|
||||
}
|
||||
|
||||
if (transition is { } t)
|
||||
{
|
||||
TransitionRaised?.Invoke(this, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply one value change to one condition. Returns a transition when the
|
||||
/// change crosses a state boundary; null otherwise. Caller holds <c>state.Lock</c>.
|
||||
/// </summary>
|
||||
private static AlarmConditionTransition? ApplyValue(
|
||||
AlarmConditionState state, AlarmField field, DataValueSnapshot value, DateTime now)
|
||||
{
|
||||
AlarmConditionTransition? transition = null;
|
||||
state.LastUpdateUtc = now;
|
||||
|
||||
switch (field)
|
||||
{
|
||||
case AlarmField.InAlarm:
|
||||
{
|
||||
var wasActive = state.InAlarm;
|
||||
var isActive = value.Value is bool b && b;
|
||||
state.InAlarm = isActive;
|
||||
if (!wasActive && isActive)
|
||||
{
|
||||
// Reset Acked on every active transition so a re-alarm requires fresh ack.
|
||||
state.Acked = false;
|
||||
transition = new AlarmConditionTransition(
|
||||
state.ConditionId, AlarmStateTransition.Active,
|
||||
state.Priority, state.Description, now);
|
||||
}
|
||||
else if (wasActive && !isActive)
|
||||
{
|
||||
transition = new AlarmConditionTransition(
|
||||
state.ConditionId, AlarmStateTransition.Inactive,
|
||||
state.Priority, state.Description, now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AlarmField.Priority:
|
||||
state.Priority = CoercePriority(value.Value, state.Priority);
|
||||
break;
|
||||
case AlarmField.DescAttrName:
|
||||
state.Description = value.Value as string;
|
||||
break;
|
||||
case AlarmField.Acked:
|
||||
{
|
||||
var wasAcked = state.Acked;
|
||||
var isAcked = value.Value is bool b && b;
|
||||
state.Acked = isAcked;
|
||||
// Only fire Acknowledged on false → true while still active. The first
|
||||
// post-Track callback often arrives with isAcked == wasAcked (state starts
|
||||
// Acked=true so an initially-quiet alarm doesn't misfire).
|
||||
if (!wasAcked && isAcked && state.InAlarm)
|
||||
{
|
||||
transition = new AlarmConditionTransition(
|
||||
state.ConditionId, AlarmStateTransition.Acknowledged,
|
||||
state.Priority, state.Description, now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return transition;
|
||||
}
|
||||
|
||||
private static int CoercePriority(object? raw, int fallback) => raw switch
|
||||
{
|
||||
int i => i,
|
||||
short s => s,
|
||||
long l when l <= int.MaxValue => (int)l,
|
||||
byte b => b,
|
||||
ushort us => us,
|
||||
uint ui when ui <= int.MaxValue => (int)ui,
|
||||
_ => fallback,
|
||||
};
|
||||
|
||||
private void AddRefMapping(string? fullRef, string conditionId, AlarmField field)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fullRef)) return;
|
||||
if (!_refToCondition.TryGetValue(fullRef, out var list))
|
||||
{
|
||||
list = [];
|
||||
_refToCondition[fullRef] = list;
|
||||
}
|
||||
list.Add((conditionId, field));
|
||||
}
|
||||
|
||||
private void RemoveRefMapping(string? fullRef, string conditionId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fullRef)) return;
|
||||
if (!_refToCondition.TryGetValue(fullRef, out var list)) return;
|
||||
list.RemoveAll(t => string.Equals(t.ConditionId, conditionId, StringComparison.OrdinalIgnoreCase));
|
||||
if (list.Count == 0) _refToCondition.TryRemove(fullRef, out _);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_conditions.Clear();
|
||||
lock (_refMapLock) _refToCondition.Clear();
|
||||
}
|
||||
|
||||
private enum AlarmField { InAlarm, Priority, DescAttrName, Acked }
|
||||
|
||||
/// <summary>Per-condition mutable state. Access guarded by <see cref="Lock"/>.</summary>
|
||||
private sealed class AlarmConditionState(string conditionId, AlarmConditionInfo info, IAlarmAcknowledger? acker)
|
||||
{
|
||||
public readonly object Lock = new();
|
||||
public string ConditionId { get; } = conditionId;
|
||||
public AlarmConditionInfo Info { get; } = info;
|
||||
public IAlarmAcknowledger? Acker { get; } = acker;
|
||||
|
||||
public bool InAlarm;
|
||||
|
||||
// Default Acked=true so the first post-Track callback (.Acked=true on a quiet
|
||||
// alarm) doesn't misfire as a transition. Active sets it back to false.
|
||||
public bool Acked = true;
|
||||
|
||||
public int Priority;
|
||||
public string? Description;
|
||||
public DateTime LastUpdateUtc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle transition for an alarm condition. Mirrors OPC UA Part 9 alarm states
|
||||
/// simplified to the active / acknowledged / inactive triplet that every driver in
|
||||
/// the repo exposes today.
|
||||
/// </summary>
|
||||
public enum AlarmStateTransition
|
||||
{
|
||||
/// <summary>InAlarm flipped false → true. Default to unacknowledged.</summary>
|
||||
Active,
|
||||
|
||||
/// <summary>Acked flipped false → true while the alarm is still active.</summary>
|
||||
Acknowledged,
|
||||
|
||||
/// <summary>InAlarm flipped true → false.</summary>
|
||||
Inactive,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One alarm-state transition raised by <see cref="AlarmConditionService.TransitionRaised"/>.
|
||||
/// </summary>
|
||||
/// <param name="ConditionId">Stable identifier the caller registered the condition under (typically the driver's alarm full reference).</param>
|
||||
/// <param name="Transition">Which state the alarm transitioned to.</param>
|
||||
/// <param name="Priority">Latest known priority. 0 when no priority sub-attribute was registered or no value has been observed yet.</param>
|
||||
/// <param name="Description">Latest known description text; null when not registered or not yet observed.</param>
|
||||
/// <param name="AtUtc">Server-clock UTC of the value change that produced this transition.</param>
|
||||
public sealed record AlarmConditionTransition(
|
||||
string ConditionId,
|
||||
AlarmStateTransition Transition,
|
||||
int Priority,
|
||||
string? Description,
|
||||
DateTime AtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Read-only snapshot of an alarm condition's current state. Used for diagnostics
|
||||
/// and dashboards; not part of the live transition stream.
|
||||
/// </summary>
|
||||
public sealed record AlarmConditionSnapshot(
|
||||
string ConditionId,
|
||||
bool InAlarm,
|
||||
bool Acked,
|
||||
int Priority,
|
||||
string? Description);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Strategy for routing operator acknowledgement writes back to the underlying driver.
|
||||
/// Decouples <see cref="AlarmConditionService"/> from any specific driver's write API
|
||||
/// so the service can be tested without a real driver and reused across drivers with
|
||||
/// different write paths.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PR 2.3 supplies a default implementation that writes through the driver's
|
||||
/// <c>IWritable.WriteAsync</c> using the <c>AckMsgWriteRef</c> from
|
||||
/// <c>AlarmConditionInfo</c>. Drivers that route acks differently (e.g. a dedicated
|
||||
/// RPC) can supply a custom implementation when registering the condition.
|
||||
/// </remarks>
|
||||
public interface IAlarmAcknowledger
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes the operator's <paramref name="comment"/> to <paramref name="ackMsgWriteRef"/>.
|
||||
/// Returns true on driver-reported success, false otherwise. Implementations should
|
||||
/// propagate cancellation but never throw on a write that the driver cleanly rejects.
|
||||
/// </summary>
|
||||
Task<bool> WriteAckMessageAsync(string ackMsgWriteRef, string comment, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Stability;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Task #248 — bridges the gap surfaced by the Phase 7 live smoke (#240) where
|
||||
/// <c>DriverInstance</c> rows in the central config DB had no path to materialise
|
||||
/// as live <see cref="Core.Abstractions.IDriver"/> instances in <see cref="DriverHost"/>.
|
||||
/// Called from <c>OpcUaServerService.ExecuteAsync</c> after the bootstrap loads
|
||||
/// the published generation, before address-space build.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per row: looks up the <c>DriverType</c> string in
|
||||
/// <see cref="DriverFactoryRegistry"/>, calls the factory with the row's
|
||||
/// <c>DriverInstanceId</c> + <c>DriverConfig</c> JSON to construct an
|
||||
/// <see cref="Core.Abstractions.IDriver"/>, then registers via
|
||||
/// <see cref="DriverHost.RegisterAsync"/> which invokes <c>InitializeAsync</c>
|
||||
/// under the host's lifecycle semantics.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Unknown <c>DriverType</c> = factory not registered = log a warning and skip.
|
||||
/// Per plan decision #12 (driver isolation), failure to construct or initialize
|
||||
/// one driver doesn't prevent the rest from coming up — the Server keeps serving
|
||||
/// the others' subtrees + the operator can fix the misconfigured row + republish
|
||||
/// to retry.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DriverInstanceBootstrapper(
|
||||
DriverFactoryRegistry factories,
|
||||
DriverHost driverHost,
|
||||
ScheduledRecycleHostedService recycleHost,
|
||||
ILoggerFactory loggerFactory,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<DriverInstanceBootstrapper> logger)
|
||||
{
|
||||
// IDriverSupervisor instances, looked up by DriverInstanceId. The bootstrapper
|
||||
// consults DI at run time because no driver ships a supervisor today — the
|
||||
// dictionary is built from optional DI registrations; Tier C drivers that
|
||||
// register one via `services.AddKeyedSingleton<IDriverSupervisor>(instanceId, ...)`
|
||||
// become eligible for scheduled recycle. Others silently skip.
|
||||
private readonly IReadOnlyDictionary<string, IDriverSupervisor> _supervisors =
|
||||
scopeFactory.CreateScope().ServiceProvider
|
||||
.GetServices<IDriverSupervisor>()
|
||||
.ToDictionary(s => s.DriverInstanceId, StringComparer.OrdinalIgnoreCase);
|
||||
public async Task<int> RegisterDriversFromGenerationAsync(long generationId, CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
|
||||
var rows = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.GenerationId == generationId && d.Enabled)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var registered = 0;
|
||||
var skippedUnknownType = 0;
|
||||
var failedInit = 0;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var factory = factories.TryGet(row.DriverType);
|
||||
if (factory is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"DriverInstance {Id} skipped — DriverType '{Type}' has no registered factory (known: {Known})",
|
||||
row.DriverInstanceId, row.DriverType, string.Join(",", factories.RegisteredTypes));
|
||||
skippedUnknownType++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var driver = factory(row.DriverInstanceId, row.DriverConfig);
|
||||
await driverHost.RegisterAsync(driver, row.DriverConfig, ct).ConfigureAwait(false);
|
||||
registered++;
|
||||
logger.LogInformation(
|
||||
"DriverInstance {Id} ({Type}) registered + initialized", row.DriverInstanceId, row.DriverType);
|
||||
|
||||
// Scheduled-recycle opt-in — only meaningful for Tier C out-of-process hosts,
|
||||
// and only when the row's ResilienceConfig carries a positive
|
||||
// RecycleIntervalSeconds AND the deployment wired an IDriverSupervisor for
|
||||
// this DriverInstanceId. Silently skipping when any of those is absent is the
|
||||
// intended zero-config-default behaviour.
|
||||
TryRegisterScheduledRecycle(row.DriverInstanceId, row.DriverType, row.ResilienceConfig);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Plan decision #12 — driver isolation. Log + continue so one bad row
|
||||
// doesn't deny the OPC UA endpoint to the rest of the fleet.
|
||||
logger.LogError(ex,
|
||||
"DriverInstance {Id} ({Type}) failed to initialize — driver state will reflect Faulted; operator can republish to retry",
|
||||
row.DriverInstanceId, row.DriverType);
|
||||
failedInit++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"DriverInstanceBootstrapper: gen={Gen} registered={Registered} skippedUnknownType={Skipped} failedInit={Failed}",
|
||||
generationId, registered, skippedUnknownType, failedInit);
|
||||
return registered;
|
||||
}
|
||||
|
||||
private void TryRegisterScheduledRecycle(string driverInstanceId, string driverType, string? resilienceJson)
|
||||
{
|
||||
var tier = factories.GetTier(driverType);
|
||||
if (tier != DriverTier.C) return;
|
||||
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, resilienceJson, out _);
|
||||
if (options.RecycleIntervalSeconds is not int secs) return;
|
||||
|
||||
if (!_supervisors.TryGetValue(driverInstanceId, out var supervisor))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"DriverInstance {Id} ({Type}) has RecycleIntervalSeconds={Secs} in ResilienceConfig but no IDriverSupervisor registered; scheduled recycle will not fire",
|
||||
driverInstanceId, driverType, secs);
|
||||
return;
|
||||
}
|
||||
|
||||
var scheduler = new ScheduledRecycleScheduler(
|
||||
tier,
|
||||
TimeSpan.FromSeconds(secs),
|
||||
DateTime.UtcNow,
|
||||
supervisor,
|
||||
loggerFactory.CreateLogger<ScheduledRecycleScheduler>());
|
||||
recycleHost.AddScheduler(scheduler);
|
||||
logger.LogInformation(
|
||||
"Scheduled recycle armed for Tier C driver {Id} ({Type}) — interval {Interval}, first fire at {Next:o}",
|
||||
driverInstanceId, driverType, TimeSpan.FromSeconds(secs), scheduler.NextRecycleUtc);
|
||||
}
|
||||
}
|
||||
71
src/Server/ZB.MOM.WW.OtOpcUa.Server/History/HistoryRouter.cs
Normal file
71
src/Server/ZB.MOM.WW.OtOpcUa.Server/History/HistoryRouter.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.History;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IHistoryRouter"/> implementation.
|
||||
/// </summary>
|
||||
public sealed class HistoryRouter : IHistoryRouter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IHistorianDataSource> _registry =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(string fullReferencePrefix, IHistorianDataSource source)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(fullReferencePrefix);
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
if (!_registry.TryAdd(fullReferencePrefix, source))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A historian data source is already registered for prefix '{fullReferencePrefix}'.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IHistorianDataSource? Resolve(string fullReference)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(fullReference);
|
||||
|
||||
// Longest-prefix match. Sources are typically a handful per server, so a linear
|
||||
// scan is fine and avoids building a trie for a low-cardinality registry.
|
||||
IHistorianDataSource? best = null;
|
||||
var bestPrefixLength = -1;
|
||||
|
||||
foreach (var (prefix, source) in _registry)
|
||||
{
|
||||
if (fullReference.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& prefix.Length > bestPrefixLength)
|
||||
{
|
||||
best = source;
|
||||
bestPrefixLength = prefix.Length;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes every registered source and prevents further registrations or
|
||||
/// resolutions. Sources may not all be disposable — null-safe disposal pattern.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
foreach (var source in _registry.Values)
|
||||
{
|
||||
try { source.Dispose(); }
|
||||
catch { /* best-effort — server shutdown should not throw on a misbehaving source */ }
|
||||
}
|
||||
|
||||
_registry.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.History;
|
||||
|
||||
/// <summary>
|
||||
/// Server-level routing of OPC UA HistoryRead service calls to a registered
|
||||
/// <see cref="IHistorianDataSource"/>. One router per server instance; sources are
|
||||
/// registered at startup keyed by a driver-side full-reference prefix (typically the
|
||||
/// driver instance id).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The router decouples history availability from the driver lifecycle: a driver
|
||||
/// can restart (or be temporarily disconnected) without taking history offline,
|
||||
/// and a single historian can serve nodes from multiple drivers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Resolution is by longest-prefix match so a per-driver source registered under
|
||||
/// <c>"galaxy"</c> wins over a fallback registered under empty string.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IHistoryRouter : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a full reference to its registered data source, or null when no source
|
||||
/// covers it.
|
||||
/// </summary>
|
||||
IHistorianDataSource? Resolve(string fullReference);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a data source for full references that start with
|
||||
/// <paramref name="fullReferencePrefix"/>. Throws when the prefix is already
|
||||
/// registered — duplicate registrations indicate a startup-config bug rather than
|
||||
/// a runtime concern.
|
||||
/// </summary>
|
||||
void Register(string fullReferencePrefix, IHistorianDataSource source);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.History;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that registers the configured <see cref="WonderwareHistorianClient"/>
|
||||
/// as a source on the server-level <see cref="IHistoryRouter"/> at startup. Per-namespace
|
||||
/// prefix is the driver instance id the operator binds the historian to (typically
|
||||
/// "galaxy"); future per-area or per-equipment overrides can register under longer prefixes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PR 3.W only wires this when <c>Historian:Wonderware:Enabled=true</c> in config. The
|
||||
/// hosted service does its work in <see cref="StartAsync"/> and stays passive afterward;
|
||||
/// <see cref="StopAsync"/> is a no-op since router disposal happens through the singleton's
|
||||
/// own DI lifecycle.
|
||||
/// </remarks>
|
||||
public sealed class WonderwareHistorianBootstrap : IHostedService
|
||||
{
|
||||
private readonly IHistoryRouter _router;
|
||||
private readonly WonderwareHistorianClient _client;
|
||||
private readonly string _prefix;
|
||||
private readonly ILogger<WonderwareHistorianBootstrap> _logger;
|
||||
|
||||
public WonderwareHistorianBootstrap(
|
||||
IHistoryRouter router,
|
||||
WonderwareHistorianClient client,
|
||||
string fullReferencePrefix,
|
||||
ILogger<WonderwareHistorianBootstrap> logger)
|
||||
{
|
||||
_router = router ?? throw new ArgumentNullException(nameof(router));
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_prefix = fullReferencePrefix ?? throw new ArgumentNullException(nameof(fullReferencePrefix));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_router.Register(_prefix, (IHistorianDataSource)_client);
|
||||
_logger.LogInformation(
|
||||
"Wonderware historian sidecar registered as IHistoryRouter source under prefix '{Prefix}'",
|
||||
_prefix);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// Prefix already registered (e.g. server restart without DI rebuild). Tolerate
|
||||
// — the existing registration is the same singleton instance and stays valid.
|
||||
_logger.LogWarning(ex,
|
||||
"Wonderware historian source already registered for prefix '{Prefix}' — leaving existing entry", _prefix);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
143
src/Server/ZB.MOM.WW.OtOpcUa.Server/HostStatusPublisher.cs
Normal file
143
src/Server/ZB.MOM.WW.OtOpcUa.Server/HostStatusPublisher.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Walks every registered driver once per heartbeat interval, asks each
|
||||
/// <see cref="IHostConnectivityProbe"/>-capable driver for its current
|
||||
/// <see cref="HostConnectivityStatus"/> list, and upserts one
|
||||
/// <see cref="DriverHostStatus"/> row per (NodeId, DriverInstanceId, HostName) into the
|
||||
/// central config DB. Powers the Admin UI's per-host drill-down page (LMX follow-up #7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Polling rather than event-driven: simpler, and matches the cadence the Admin UI
|
||||
/// consumes. An event-subscription optimization (push on <c>OnHostStatusChanged</c> for
|
||||
/// immediate reflection) is a straightforward follow-up but adds lifecycle complexity
|
||||
/// — drivers can be registered after the publisher starts, and subscribing to each
|
||||
/// one's event on register + unsubscribing on unregister requires DriverHost to expose
|
||||
/// lifecycle events it doesn't today.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="DriverHostStatus.LastSeenUtc"/> advances every heartbeat so the Admin UI
|
||||
/// can flag stale rows from a crashed Server process independent of
|
||||
/// <see cref="DriverHostStatus.State"/> — a Faulted publisher that stops heartbeating
|
||||
/// stays Faulted in the DB but its LastSeenUtc ages out, which is the signal
|
||||
/// operators actually want.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the DB is unreachable on a given tick, the publisher logs and moves on — it
|
||||
/// does not retry or buffer. The next heartbeat picks up the current-state snapshot,
|
||||
/// which is more useful than replaying stale transitions after a long outage.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HostStatusPublisher(
|
||||
DriverHost driverHost,
|
||||
NodeOptions nodeOptions,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<HostStatusPublisher> logger) : BackgroundService
|
||||
{
|
||||
internal static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Wait a short moment at startup so NodeBootstrap's RegisterAsync calls have had a
|
||||
// chance to land. First tick runs immediately after so a freshly-started Server
|
||||
// surfaces its host topology in the Admin UI without waiting a full interval.
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await PublishOnceAsync(stoppingToken); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never take down the Server on a publisher failure. Log and continue —
|
||||
// stale-row detection on the Admin side will surface the outage.
|
||||
logger.LogWarning(ex, "Host-status publisher tick failed — will retry next heartbeat");
|
||||
}
|
||||
|
||||
try { await Task.Delay(HeartbeatInterval, stoppingToken); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task PublishOnceAsync(CancellationToken ct)
|
||||
{
|
||||
var driverIds = driverHost.RegisteredDriverIds;
|
||||
if (driverIds.Count == 0) return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
|
||||
foreach (var driverId in driverIds)
|
||||
{
|
||||
var driver = driverHost.GetDriver(driverId);
|
||||
if (driver is not IHostConnectivityProbe probe) continue;
|
||||
|
||||
IReadOnlyList<HostConnectivityStatus> statuses;
|
||||
try { statuses = probe.GetHostStatuses(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Driver {DriverId} GetHostStatuses threw — skipping this tick", driverId);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var status in statuses)
|
||||
{
|
||||
await UpsertAsync(db, driverId, status, now, ct);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task UpsertAsync(OtOpcUaConfigDbContext db, string driverId,
|
||||
HostConnectivityStatus status, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var mapped = MapState(status.State);
|
||||
var existing = await db.DriverHostStatuses.SingleOrDefaultAsync(r =>
|
||||
r.NodeId == nodeOptions.NodeId
|
||||
&& r.DriverInstanceId == driverId
|
||||
&& r.HostName == status.HostName, ct);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
db.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = nodeOptions.NodeId,
|
||||
DriverInstanceId = driverId,
|
||||
HostName = status.HostName,
|
||||
State = mapped,
|
||||
StateChangedUtc = status.LastChangedUtc,
|
||||
LastSeenUtc = now,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.LastSeenUtc = now;
|
||||
if (existing.State != mapped)
|
||||
{
|
||||
existing.State = mapped;
|
||||
existing.StateChangedUtc = status.LastChangedUtc;
|
||||
}
|
||||
}
|
||||
|
||||
internal static DriverHostState MapState(HostState state) => state switch
|
||||
{
|
||||
HostState.Running => DriverHostState.Running,
|
||||
HostState.Stopped => DriverHostState.Stopped,
|
||||
HostState.Faulted => DriverHostState.Faulted,
|
||||
_ => DriverHostState.Unknown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.3 A.2 + Phase 6.1 Stream D follow-up — polls
|
||||
/// <c>sp_GetCurrentGenerationForCluster</c> on a cadence and, when a newer generation
|
||||
/// is detected, wraps the apply in an <see cref="ApplyLeaseRegistry"/> lease
|
||||
/// (flipping ServiceLevel to <see cref="ServiceLevelBand.PrimaryMidApply"/>) and
|
||||
/// refreshes the <see cref="RedundancyCoordinator"/> so operator role-swaps take
|
||||
/// effect without a process restart.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Before this service shipped, the Server only ever saw the generation in force
|
||||
/// at process start (<see cref="SealedBootstrap"/>). Peer-published generations
|
||||
/// silently accumulated in the shared config DB; the running node kept serving
|
||||
/// the generation it had sealed on boot until the operator restarted it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Closes the Phase 6.3 D.1 design hole around <c>PrimaryMidApply</c>: the
|
||||
/// <c>coordinator.BeginApplyLease(...)</c> wrap now encloses an actual apply
|
||||
/// (the coordinator refresh + future subscriber fan-out). Lease dispose fires
|
||||
/// on every exit path — success, exception, cancellation — so
|
||||
/// <c>ApplyLeaseRegistry</c> can never pin a crashed refresh at
|
||||
/// PrimaryMidApply.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately narrow scope: refreshes <see cref="RedundancyCoordinator"/>
|
||||
/// only. Driver re-init, virtual-tag re-bind, script-engine reload, etc. remain
|
||||
/// as follow-up wiring — add subscribers to this service's apply path as those
|
||||
/// components grow hot-reload support. The lease wrap is the right seam for
|
||||
/// those subscribers to hook.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GenerationRefreshHostedService(
|
||||
NodeOptions options,
|
||||
ApplyLeaseRegistry leases,
|
||||
RedundancyCoordinator coordinator,
|
||||
ILogger<GenerationRefreshHostedService> logger,
|
||||
TimeSpan? tickInterval = null,
|
||||
Func<CancellationToken, Task<long?>>? currentGenerationQuery = null) : BackgroundService
|
||||
{
|
||||
private readonly Func<CancellationToken, Task<long?>> _generationQuery = currentGenerationQuery
|
||||
?? new Func<CancellationToken, Task<long?>>(ct => DefaultQueryCurrentGenerationAsync(options, logger, 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
|
||||
/// overhead on the central DB is negligible even across a 100-node fleet.
|
||||
/// </summary>
|
||||
public TimeSpan TickInterval { get; } = tickInterval ?? TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Newest generation the service has applied. Exposed for diagnostics +
|
||||
/// <see cref="TickCount"/> style health surfaces. <c>null</c> before the first
|
||||
/// successful poll.
|
||||
/// </summary>
|
||||
public long? LastAppliedGenerationId { get; private set; }
|
||||
|
||||
/// <summary>Successful ticks — whether or not a generation change was detected.</summary>
|
||||
public int TickCount { get; private set; }
|
||||
|
||||
/// <summary>Ticks that observed a generation change and ran a refresh.</summary>
|
||||
public int RefreshCount { get; private set; }
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"GenerationRefreshHostedService running — polling every {Tick}s",
|
||||
TickInterval.TotalSeconds);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TickAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "GenerationRefreshHostedService tick failed");
|
||||
}
|
||||
|
||||
try { await Task.Delay(TickInterval, stoppingToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
// internal for tests — single-tick entry point.
|
||||
internal async Task TickAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await _generationQuery(cancellationToken).ConfigureAwait(false);
|
||||
TickCount++;
|
||||
if (current is null) return;
|
||||
|
||||
if (LastAppliedGenerationId is long last && current == last)
|
||||
{
|
||||
return; // no change
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Generation change detected — {Previous} → {Current}; applying",
|
||||
LastAppliedGenerationId?.ToString() ?? "(none)", current);
|
||||
|
||||
// Lease wraps the apply window: ServiceLevelCalculator reads
|
||||
// ApplyLeaseRegistry.IsApplyInProgress and returns PrimaryMidApply (200) while any
|
||||
// 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))
|
||||
{
|
||||
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.
|
||||
}
|
||||
|
||||
LastAppliedGenerationId = current;
|
||||
RefreshCount++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default generation-query implementation — reads via
|
||||
/// <c>sp_GetCurrentGenerationForCluster</c>. Returns <c>null</c> when no generation
|
||||
/// has been published yet, or when the DB call fails (logged at Warning; next tick
|
||||
/// retries). Tests inject a stub <see cref="Func{CancellationToken, Task}"/> via the
|
||||
/// <c>currentGenerationQuery</c> constructor parameter instead.
|
||||
/// </summary>
|
||||
private static async Task<long?> DefaultQueryCurrentGenerationAsync(
|
||||
NodeOptions options,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
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_GetCurrentGenerationForCluster @NodeId=@n, @ClusterId=@c";
|
||||
cmd.Parameters.AddWithValue("@n", options.NodeId);
|
||||
cmd.Parameters.AddWithValue("@c", options.ClusterId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return reader.GetInt64(0);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "sp_GetCurrentGenerationForCluster failed — will retry");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/Server/ZB.MOM.WW.OtOpcUa.Server/Hosting/PeerHttpProbeLoop.cs
Normal file
103
src/Server/ZB.MOM.WW.OtOpcUa.Server/Hosting/PeerHttpProbeLoop.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.3 Stream B.1 — HTTP peer-probe loop. Polls every configured peer's
|
||||
/// <c>/healthz</c> endpoint on a fast cadence (default 2 s) with a short timeout
|
||||
/// (default 1 s) and writes the result to <see cref="PeerReachabilityTracker"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Fast-fail layer — the UA probe short-circuits when HTTP says dead, so a failing
|
||||
/// peer is detected within ~2 s without paying the cost of a full OPC UA session
|
||||
/// setup on every tick.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Writes preserve the last UA-health bit so a transient HTTP blip doesn't stomp the
|
||||
/// authoritative UA reading until the next UA tick. <see cref="PeerReachability"/>
|
||||
/// is a record; we compose a new one per update.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Probe target is derived as <c>http://{peer.Host}:{peer.DashboardPort}/healthz</c>.
|
||||
/// The server's own health-endpoints host serves <c>/healthz</c> on the dashboard
|
||||
/// port, so this is symmetric with what peers expect to be probed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class PeerHttpProbeLoop(
|
||||
RedundancyCoordinator coordinator,
|
||||
PeerReachabilityTracker tracker,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<PeerHttpProbeLoop> logger,
|
||||
PeerProbeOptions? options = null) : BackgroundService
|
||||
{
|
||||
private readonly PeerProbeOptions _options = options ?? new PeerProbeOptions();
|
||||
internal const string HttpClientName = "PeerHttpProbe";
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"PeerHttpProbeLoop running — probe every {Interval}ms, timeout {Timeout}ms",
|
||||
_options.HttpProbeInterval.TotalMilliseconds, _options.HttpProbeTimeout.TotalMilliseconds);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TickAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "PeerHttpProbeLoop tick failed");
|
||||
}
|
||||
|
||||
try { await Task.Delay(_options.HttpProbeInterval, stoppingToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
// internal for tests — lets a unit test drive a single tick synchronously without the loop.
|
||||
internal async Task TickAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var topology = coordinator.Current;
|
||||
if (topology is null || topology.Peers.Count == 0) return;
|
||||
|
||||
// Probe every peer in parallel — one slow peer shouldn't block the cadence for others.
|
||||
var probes = topology.Peers.Select(p => ProbeAsync(p, cancellationToken)).ToArray();
|
||||
await Task.WhenAll(probes).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ProbeAsync(RedundancyPeer peer, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"http://{peer.Host}:{peer.DashboardPort}/healthz";
|
||||
var healthy = false;
|
||||
try
|
||||
{
|
||||
using var client = httpClientFactory.CreateClient(HttpClientName);
|
||||
client.Timeout = _options.HttpProbeTimeout;
|
||||
using var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
healthy = response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return; // shutdown — drop the result rather than writing a false-unhealthy
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
// Any transport-level failure counts as unhealthy — connection refused, timeout,
|
||||
// DNS fail, TLS fail. Swallow + mark unhealthy; don't log every tick, only when
|
||||
// state transitions.
|
||||
healthy = false;
|
||||
}
|
||||
|
||||
var previous = tracker.Get(peer.NodeId);
|
||||
if (previous.HttpHealthy != healthy)
|
||||
{
|
||||
logger.LogInformation("Peer {NodeId} HTTP probe {Transition} ({Url})",
|
||||
peer.NodeId, healthy ? "Healthy" : "Unhealthy", url);
|
||||
}
|
||||
tracker.Update(peer.NodeId, previous with { HttpHealthy = healthy });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the Phase 6.3 Stream B peer-probe HostedServices
|
||||
/// (<see cref="PeerHttpProbeLoop"/> + <see cref="PeerUaProbeLoop"/>). Drives cadence +
|
||||
/// timeout for the two-layer probe model. Defaults match the spec in
|
||||
/// <c>docs/v2/implementation/phase-6-3-redundancy-runtime.md</c> §Stream B.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// HTTP layer is the fast-fail at 2 s / 1 s timeout; UA layer is authoritative at 10 s /
|
||||
/// 5 s timeout. The UA probe short-circuits when the HTTP probe last reported the peer
|
||||
/// unhealthy, to avoid burning TCP sessions on a known-dead endpoint.
|
||||
/// </remarks>
|
||||
public sealed class PeerProbeOptions
|
||||
{
|
||||
/// <summary>How often <see cref="PeerHttpProbeLoop"/> ticks. Default 2 s.</summary>
|
||||
public TimeSpan HttpProbeInterval { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Per-request timeout for the HTTP <c>/healthz</c> probe. Default 1 s.</summary>
|
||||
public TimeSpan HttpProbeTimeout { get; init; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>How often <see cref="PeerUaProbeLoop"/> ticks. Default 10 s.</summary>
|
||||
public TimeSpan UaProbeInterval { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Per-request timeout for the OPC UA endpoint discovery probe. Default 5 s.</summary>
|
||||
public TimeSpan UaProbeTimeout { get; init; } = TimeSpan.FromSeconds(5);
|
||||
}
|
||||
133
src/Server/ZB.MOM.WW.OtOpcUa.Server/Hosting/PeerUaProbeLoop.cs
Normal file
133
src/Server/ZB.MOM.WW.OtOpcUa.Server/Hosting/PeerUaProbeLoop.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.3 Stream B.2 — OPC UA peer-probe loop. Opens a minimal discovery session to
|
||||
/// each peer's OPC UA endpoint on a slow cadence (default 10 s) and records
|
||||
/// <see cref="PeerReachability.UaHealthy"/> in the tracker.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Authoritative layer — the OPC UA discovery call verifies the endpoint actually
|
||||
/// serves UA traffic (not just that the host OS answers a TCP connect on 4840).
|
||||
/// If the peer passes UA discovery, it can serve real client sessions.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Short-circuits when the HTTP probe (<see cref="PeerHttpProbeLoop"/>) last marked
|
||||
/// the peer unhealthy — no point burning a full TCP+OPC UA handshake on a peer the
|
||||
/// fast-fail probe already says is dead. In that case <see cref="PeerReachability.UaHealthy"/>
|
||||
/// is cleared (stale-UA-state protection) so a sustained HTTP outage doesn't leave
|
||||
/// an ancient UaHealthy=true reading feeding the ServiceLevel calculator.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Implementation uses <c>DiscoveryClient.GetEndpoints</c> rather than opening a
|
||||
/// full authenticated <c>Session</c> — the discovery endpoint is server-side cheap
|
||||
/// (no session state), needs no certificate trust, and is specifically designed for
|
||||
/// availability pinging. Timeout bounded by <see cref="PeerProbeOptions.UaProbeTimeout"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class PeerUaProbeLoop(
|
||||
RedundancyCoordinator coordinator,
|
||||
PeerReachabilityTracker tracker,
|
||||
ILogger<PeerUaProbeLoop> logger,
|
||||
PeerProbeOptions? options = null,
|
||||
Func<string, TimeSpan, CancellationToken, Task<bool>>? endpointProbe = null) : BackgroundService
|
||||
{
|
||||
private readonly PeerProbeOptions _options = options ?? new PeerProbeOptions();
|
||||
private readonly Func<string, TimeSpan, CancellationToken, Task<bool>> _endpointProbe
|
||||
= endpointProbe ?? DefaultEndpointProbeAsync;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"PeerUaProbeLoop running — probe every {Interval}ms, timeout {Timeout}ms",
|
||||
_options.UaProbeInterval.TotalMilliseconds, _options.UaProbeTimeout.TotalMilliseconds);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TickAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "PeerUaProbeLoop tick failed");
|
||||
}
|
||||
|
||||
try { await Task.Delay(_options.UaProbeInterval, stoppingToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
// internal for tests — single-tick entry point.
|
||||
internal async Task TickAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var topology = coordinator.Current;
|
||||
if (topology is null || topology.Peers.Count == 0) return;
|
||||
|
||||
var probes = topology.Peers.Select(p => ProbeAsync(p, cancellationToken)).ToArray();
|
||||
await Task.WhenAll(probes).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ProbeAsync(RedundancyPeer peer, CancellationToken cancellationToken)
|
||||
{
|
||||
var previous = tracker.Get(peer.NodeId);
|
||||
|
||||
// Short-circuit: don't waste a UA handshake when HTTP says the peer is down. Clear
|
||||
// the UA bit so the publisher doesn't see a stale "UA still healthy" reading.
|
||||
if (!previous.HttpHealthy)
|
||||
{
|
||||
if (previous.UaHealthy)
|
||||
{
|
||||
tracker.Update(peer.NodeId, previous with { UaHealthy = false });
|
||||
logger.LogInformation("Peer {NodeId} UA probe cleared (HTTP unhealthy)", peer.NodeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var endpoint = $"opc.tcp://{peer.Host}:{peer.OpcUaPort}";
|
||||
var healthy = await _endpointProbe(endpoint, _options.UaProbeTimeout, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (previous.UaHealthy != healthy)
|
||||
{
|
||||
logger.LogInformation("Peer {NodeId} UA probe {Transition} ({Endpoint})",
|
||||
peer.NodeId, healthy ? "Healthy" : "Unhealthy", endpoint);
|
||||
}
|
||||
tracker.Update(peer.NodeId, previous with { UaHealthy = healthy });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default probe — <c>DiscoveryClient.GetEndpoints</c> against the peer's OPC UA
|
||||
/// endpoint. Lightweight (no session, no certificate trust). Returns <c>true</c>
|
||||
/// iff the call returns at least one advertised endpoint within the supplied
|
||||
/// timeout; any transport, protocol, or timeout failure counts as unhealthy.
|
||||
/// </summary>
|
||||
internal static async Task<bool> DefaultEndpointProbeAsync(
|
||||
string endpointUrl, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
linked.CancelAfter(timeout);
|
||||
|
||||
var config = EndpointConfiguration.Create();
|
||||
config.OperationTimeout = (int)timeout.TotalMilliseconds;
|
||||
|
||||
using var discoveryClient = DiscoveryClient.Create(new Uri(endpointUrl), config);
|
||||
var endpoints = await Task.Run(() => discoveryClient.GetEndpoints(null), linked.Token).ConfigureAwait(false);
|
||||
return endpoints is { Count: > 0 };
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.3 Stream C (task #147) glue — drives <see cref="RedundancyStatePublisher"/> on
|
||||
/// a periodic tick and pushes the resulting ServiceLevel / ServerUriArray /
|
||||
/// RedundancySupport values onto the OPC UA Server node via
|
||||
/// <see cref="ServerRedundancyNodeWriter"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The OPC UA <c>ServerObject</c> exists only after <c>StandardServer.OnServerStarted</c>
|
||||
/// has run, which is inside <see cref="OpcUaApplicationHost.StartAsync"/>. This hosted
|
||||
/// service polls for <c>host.Server?.CurrentInstance</c> to become non-null before
|
||||
/// binding the writer — the server boot sequence doesn't expose a "ready" event.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tick cadence is 1 s by default. The publisher is edge-triggered internally so a
|
||||
/// no-change tick is cheap; the writer is also idempotent so we can safely apply the
|
||||
/// same values every tick without generating spurious OPC UA notifications.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RedundancyPublisherHostedService(
|
||||
OpcUaApplicationHost host,
|
||||
RedundancyStatePublisher publisher,
|
||||
RedundancyCoordinator coordinator,
|
||||
ILogger<RedundancyPublisherHostedService> logger,
|
||||
ILoggerFactory loggerFactory) : BackgroundService
|
||||
{
|
||||
public TimeSpan TickInterval { get; init; } = TimeSpan.FromSeconds(1);
|
||||
public TimeSpan ServerReadyPollInterval { get; init; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// 0. Load topology from the shared config DB. RefreshAsync (not InitializeAsync)
|
||||
// so an invariant violation degrades to ServiceLevelBand.InvalidTopology rather
|
||||
// than crashing the hosted service — operator visibility beats fail-fast here.
|
||||
await coordinator.RefreshAsync(stoppingToken).ConfigureAwait(false);
|
||||
|
||||
// 1. Wait for OPC UA server's ServerObject to materialize.
|
||||
var writer = await WaitForServerReadyAsync(stoppingToken).ConfigureAwait(false);
|
||||
if (writer is null) return; // cancelled before startup completed
|
||||
|
||||
// 2. Subscribe writer to publisher events — edge-triggered ServiceLevel +
|
||||
// ServerUriArray updates from the publisher fan out onto the Server node.
|
||||
publisher.OnStateChanged += OnServiceLevelChanged;
|
||||
publisher.OnServerUriArrayChanged += OnServerUriArrayChanged;
|
||||
|
||||
// 3. One-time RedundancySupport from the coordinator's current topology. If the
|
||||
// topology isn't loaded yet, we'll retry on the first compute-publish tick.
|
||||
ApplyRedundancySupportIfKnown(writer);
|
||||
|
||||
logger.LogInformation(
|
||||
"RedundancyPublisherHostedService running — tick every {Tick}ms",
|
||||
TickInterval.TotalMilliseconds);
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
publisher.ComputeAndPublish();
|
||||
ApplyRedundancySupportIfKnown(writer); // cheap + idempotent
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "RedundancyStatePublisher tick failed");
|
||||
}
|
||||
|
||||
try { await Task.Delay(TickInterval, stoppingToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
publisher.OnStateChanged -= OnServiceLevelChanged;
|
||||
publisher.OnServerUriArrayChanged -= OnServerUriArrayChanged;
|
||||
}
|
||||
|
||||
void OnServiceLevelChanged(ServiceLevelSnapshot snap) => writer.ApplyServiceLevel(snap.Value);
|
||||
void OnServerUriArrayChanged(IReadOnlyList<string> uris) => writer.ApplyServerUriArray(uris);
|
||||
}
|
||||
|
||||
private async Task<ServerRedundancyNodeWriter?> WaitForServerReadyAsync(CancellationToken ct)
|
||||
{
|
||||
// Bounded retry so a genuine failure to start doesn't pin the hosted service forever.
|
||||
// 60s is generous — production boot is ~2s on this box; cert PKI + certificate-creation
|
||||
// cases have been observed to take up to 15s cold.
|
||||
//
|
||||
// StandardServer.CurrentInstance throws BadServerHalted before OnServerStarted has run,
|
||||
// rather than returning null, so we catch that specifically and retry. Other
|
||||
// ServiceResultException codes (e.g. BadInternalError) are still propagated — a true
|
||||
// boot failure shouldn't look like "not ready yet".
|
||||
var deadline = DateTime.UtcNow.AddSeconds(60);
|
||||
while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline)
|
||||
{
|
||||
Opc.Ua.Server.IServerInternal? serverInternal = null;
|
||||
try { serverInternal = host.Server?.CurrentInstance; }
|
||||
catch (Opc.Ua.ServiceResultException ex) when (ex.StatusCode == Opc.Ua.StatusCodes.BadServerHalted)
|
||||
{
|
||||
// Server is mid-startup — keep polling.
|
||||
}
|
||||
|
||||
if (serverInternal?.ServerObject is not null)
|
||||
{
|
||||
var writerLogger = loggerFactory.CreateLogger<ServerRedundancyNodeWriter>();
|
||||
return new ServerRedundancyNodeWriter(serverInternal, writerLogger);
|
||||
}
|
||||
|
||||
try { await Task.Delay(ServerReadyPollInterval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return null; }
|
||||
}
|
||||
|
||||
if (!ct.IsCancellationRequested)
|
||||
logger.LogError("OPC UA ServerObject did not materialize within 60s — Phase 6.3 Stream C wiring is inactive");
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ApplyRedundancySupportIfKnown(ServerRedundancyNodeWriter writer)
|
||||
{
|
||||
var topology = coordinator.Current;
|
||||
if (topology is null) return;
|
||||
writer.ApplyRedundancySupport(topology.Mode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Samples <see cref="DriverResilienceStatusTracker"/> at a fixed tick + upserts each
|
||||
/// <c>(DriverInstanceId, HostName)</c> snapshot into <see cref="DriverInstanceResilienceStatus"/>
|
||||
/// so Admin <c>/hosts</c> can render live resilience counters across restarts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Closes the HostedService piece of Phase 6.1 Stream E.2 flagged as a follow-up
|
||||
/// when the tracker shipped in PR #82. The Admin UI column-refresh piece (red badge when
|
||||
/// ConsecutiveFailures > breakerThreshold / 2 + SignalR push) is still deferred to
|
||||
/// the visual-compliance pass — this service owns the persistence half alone.</para>
|
||||
///
|
||||
/// <para>Tick interval defaults to 5 s. Persistence is best-effort: a DB outage during
|
||||
/// a tick logs + continues; the next tick tries again with the latest snapshots. The
|
||||
/// hosted service never crashes the app on sample failure.</para>
|
||||
///
|
||||
/// <para><see cref="PersistOnceAsync"/> factored as a public method so tests can drive
|
||||
/// it directly, matching the <see cref="ScheduledRecycleHostedService.TickOnceAsync"/>
|
||||
/// pattern for deterministic unit-test timing.</para>
|
||||
/// </remarks>
|
||||
public sealed class ResilienceStatusPublisherHostedService : BackgroundService
|
||||
{
|
||||
private readonly DriverResilienceStatusTracker _tracker;
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbContextFactory;
|
||||
private readonly ILogger<ResilienceStatusPublisherHostedService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>Tick interval — how often the tracker snapshot is persisted.</summary>
|
||||
public TimeSpan TickInterval { get; }
|
||||
|
||||
/// <summary>Snapshot of the tick count for diagnostics + test assertions.</summary>
|
||||
public int TickCount { get; private set; }
|
||||
|
||||
public ResilienceStatusPublisherHostedService(
|
||||
DriverResilienceStatusTracker tracker,
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbContextFactory,
|
||||
ILogger<ResilienceStatusPublisherHostedService> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
TimeSpan? tickInterval = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tracker);
|
||||
ArgumentNullException.ThrowIfNull(dbContextFactory);
|
||||
|
||||
_tracker = tracker;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
TickInterval = tickInterval ?? TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ResilienceStatusPublisherHostedService starting — tick interval = {Interval}",
|
||||
TickInterval);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TickInterval, _timeProvider, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await PersistOnceAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation("ResilienceStatusPublisherHostedService stopping after {TickCount} tick(s).", TickCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Take one snapshot of the tracker + upsert each pair into the persistence table.
|
||||
/// Swallows transient exceptions + logs them; never throws from a sample failure.
|
||||
/// </summary>
|
||||
public async Task PersistOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
TickCount++;
|
||||
var snapshot = _tracker.Snapshot();
|
||||
if (snapshot.Count == 0) return;
|
||||
|
||||
try
|
||||
{
|
||||
await using var db = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
foreach (var (driverInstanceId, hostName, counters) in snapshot)
|
||||
{
|
||||
var existing = await db.DriverInstanceResilienceStatuses
|
||||
.FirstOrDefaultAsync(x => x.DriverInstanceId == driverInstanceId && x.HostName == hostName, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
db.DriverInstanceResilienceStatuses.Add(new DriverInstanceResilienceStatus
|
||||
{
|
||||
DriverInstanceId = driverInstanceId,
|
||||
HostName = hostName,
|
||||
LastCircuitBreakerOpenUtc = counters.LastBreakerOpenUtc,
|
||||
ConsecutiveFailures = counters.ConsecutiveFailures,
|
||||
CurrentBulkheadDepth = counters.CurrentInFlight,
|
||||
LastRecycleUtc = counters.LastRecycleUtc,
|
||||
BaselineFootprintBytes = counters.BaselineFootprintBytes,
|
||||
CurrentFootprintBytes = counters.CurrentFootprintBytes,
|
||||
LastSampledUtc = now,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.LastCircuitBreakerOpenUtc = counters.LastBreakerOpenUtc;
|
||||
existing.ConsecutiveFailures = counters.ConsecutiveFailures;
|
||||
existing.CurrentBulkheadDepth = counters.CurrentInFlight;
|
||||
existing.LastRecycleUtc = counters.LastRecycleUtc;
|
||||
existing.BaselineFootprintBytes = counters.BaselineFootprintBytes;
|
||||
existing.CurrentFootprintBytes = counters.CurrentFootprintBytes;
|
||||
existing.LastSampledUtc = now;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"ResilienceStatusPublisher persistence tick failed; next tick will retry with latest snapshots.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Stability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Drives one or more <see cref="ScheduledRecycleScheduler"/> instances on a fixed tick
|
||||
/// cadence. Closes Phase 6.1 Stream B.4 by turning the shipped-as-pure-logic scheduler
|
||||
/// into a running background feature.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Registered as a singleton in Program.cs. Each Tier C driver instance that wants a
|
||||
/// scheduled recycle registers its scheduler via
|
||||
/// <see cref="AddScheduler(ScheduledRecycleScheduler)"/> at startup. The hosted service
|
||||
/// wakes every <see cref="TickInterval"/> (default 1 min) and calls
|
||||
/// <see cref="ScheduledRecycleScheduler.TickAsync"/> on each registered scheduler.</para>
|
||||
///
|
||||
/// <para>Scheduler registration is closed after <see cref="ExecuteAsync"/> starts — callers
|
||||
/// must register before the host starts, typically during DI setup. Adding a scheduler
|
||||
/// mid-flight throws to avoid confusing "some ticks saw my scheduler, some didn't" races.</para>
|
||||
/// </remarks>
|
||||
public sealed class ScheduledRecycleHostedService : BackgroundService
|
||||
{
|
||||
private readonly List<ScheduledRecycleScheduler> _schedulers = [];
|
||||
private readonly ILogger<ScheduledRecycleHostedService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _started;
|
||||
|
||||
/// <summary>How often <see cref="ScheduledRecycleScheduler.TickAsync"/> fires on each registered scheduler.</summary>
|
||||
public TimeSpan TickInterval { get; }
|
||||
|
||||
public ScheduledRecycleHostedService(
|
||||
ILogger<ScheduledRecycleHostedService> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
TimeSpan? tickInterval = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
TickInterval = tickInterval ?? TimeSpan.FromMinutes(1);
|
||||
}
|
||||
|
||||
/// <summary>Register a scheduler to drive. Must be called before the host starts.</summary>
|
||||
public void AddScheduler(ScheduledRecycleScheduler scheduler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scheduler);
|
||||
if (_started)
|
||||
throw new InvalidOperationException(
|
||||
"Cannot register a ScheduledRecycleScheduler after the hosted service has started. " +
|
||||
"Register all schedulers during DI configuration / startup.");
|
||||
_schedulers.Add(scheduler);
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of the current tick count — diagnostics only.</summary>
|
||||
public int TickCount { get; private set; }
|
||||
|
||||
/// <summary>Snapshot of the number of registered schedulers — diagnostics only.</summary>
|
||||
public int SchedulerCount => _schedulers.Count;
|
||||
|
||||
public override Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_started = true;
|
||||
return base.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"ScheduledRecycleHostedService starting — {Count} scheduler(s), tick interval = {Interval}",
|
||||
_schedulers.Count, TickInterval);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TickInterval, _timeProvider, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await TickOnceAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation("ScheduledRecycleHostedService stopping after {TickCount} tick(s).", TickCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute one scheduler tick against every registered scheduler. Factored out of the
|
||||
/// <see cref="ExecuteAsync"/> loop so tests can drive it directly without needing to
|
||||
/// synchronize with <see cref="Task.Delay(TimeSpan, TimeProvider, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
public async Task TickOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
TickCount++;
|
||||
|
||||
foreach (var scheduler in _schedulers)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fired = await scheduler.TickAsync(now, cancellationToken).ConfigureAwait(false);
|
||||
if (fired)
|
||||
_logger.LogInformation("Scheduled recycle fired at {Now:o}; next = {Next:o}",
|
||||
now, scheduler.NextRecycleUtc);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A single scheduler fault must not take down the rest — log + continue.
|
||||
_logger.LogError(ex,
|
||||
"ScheduledRecycleScheduler tick failed at {Now:o}; continuing to other schedulers.", now);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/Server/ZB.MOM.WW.OtOpcUa.Server/NodeBootstrap.cs
Normal file
64
src/Server/ZB.MOM.WW.OtOpcUa.Server/NodeBootstrap.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Bootstraps a node: fetches the current generation from the central DB via
|
||||
/// <c>sp_GetCurrentGenerationForCluster</c>. If the DB is unreachable and a LiteDB cache entry
|
||||
/// exists, falls back to cached config per decision #79 (degraded-but-running).
|
||||
/// </summary>
|
||||
public sealed class NodeBootstrap(
|
||||
NodeOptions options,
|
||||
ILocalConfigCache localCache,
|
||||
ILogger<NodeBootstrap> logger)
|
||||
{
|
||||
public async Task<BootstrapResult> LoadCurrentGenerationAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new SqlConnection(options.ConfigDbConnectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId=@n, @ClusterId=@c";
|
||||
cmd.Parameters.AddWithValue("@n", options.NodeId);
|
||||
cmd.Parameters.AddWithValue("@c", options.ClusterId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
{
|
||||
logger.LogWarning("Cluster {Cluster} has no Published generation yet", options.ClusterId);
|
||||
return BootstrapResult.EmptyFromDb();
|
||||
}
|
||||
|
||||
var generationId = reader.GetInt64(0);
|
||||
logger.LogInformation("Bootstrapped from central DB: generation {GenerationId}", generationId);
|
||||
return BootstrapResult.FromDb(generationId);
|
||||
}
|
||||
catch (Exception ex) when (ex is SqlException or InvalidOperationException or TimeoutException)
|
||||
{
|
||||
logger.LogWarning(ex, "Central DB unreachable; trying LiteDB cache fallback (decision #79)");
|
||||
var cached = await localCache.GetMostRecentAsync(options.ClusterId, ct);
|
||||
if (cached is null)
|
||||
throw new BootstrapException(
|
||||
"Central DB unreachable and no local cache available — cannot bootstrap.", ex);
|
||||
|
||||
logger.LogWarning("Bootstrapping from cache: generation {GenerationId} cached at {At}",
|
||||
cached.GenerationId, cached.CachedAt);
|
||||
return BootstrapResult.FromCache(cached.GenerationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record BootstrapResult(long? GenerationId, BootstrapSource Source)
|
||||
{
|
||||
public static BootstrapResult FromDb(long g) => new(g, BootstrapSource.CentralDb);
|
||||
public static BootstrapResult FromCache(long g) => new(g, BootstrapSource.LocalCache);
|
||||
public static BootstrapResult EmptyFromDb() => new(null, BootstrapSource.CentralDb);
|
||||
}
|
||||
|
||||
public enum BootstrapSource { CentralDb, LocalCache }
|
||||
|
||||
public sealed class BootstrapException(string message, Exception inner) : Exception(message, inner);
|
||||
28
src/Server/ZB.MOM.WW.OtOpcUa.Server/NodeOptions.cs
Normal file
28
src/Server/ZB.MOM.WW.OtOpcUa.Server/NodeOptions.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Bootstrap configuration read from <c>appsettings.json</c> (decision #18) — the minimum a
|
||||
/// node needs to reach the central config DB and identify itself. Everything else comes from
|
||||
/// the DB after bootstrap succeeds.
|
||||
/// </summary>
|
||||
public sealed class NodeOptions
|
||||
{
|
||||
public const string SectionName = "Node";
|
||||
|
||||
/// <summary>Stable node ID matching <c>ClusterNode.NodeId</c> in the central config DB.</summary>
|
||||
public required string NodeId { get; init; }
|
||||
|
||||
/// <summary>Cluster this node belongs to.</summary>
|
||||
public required string ClusterId { get; init; }
|
||||
|
||||
/// <summary>SQL Server connection string for the central config DB.</summary>
|
||||
public required string ConfigDbConnectionString { get; init; }
|
||||
|
||||
/// <summary>Path to the LiteDB local cache file.</summary>
|
||||
public string LocalCachePath { get; init; } = "config_cache.db";
|
||||
|
||||
/// <summary>Phase 6.2 authorization pipeline config. Disabled by default.</summary>
|
||||
public AuthorizationOptions Authorization { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Standalone <see cref="HttpListener"/> host for <c>/healthz</c> and <c>/readyz</c>
|
||||
/// separate from the OPC UA binding. Per <c>docs/v2/implementation/phase-6-1-resilience-
|
||||
/// and-observability.md</c> §Stream C.1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Binds to <c>http://localhost:4841</c> by default — loopback avoids the Windows URL-ACL
|
||||
/// elevation requirement that binding to <c>http://+:4841</c> (wildcard) would impose.
|
||||
/// When a deployment needs remote probing, a reverse proxy or explicit netsh urlacl grant
|
||||
/// is the expected path; documented in <c>docs/v2/Server-Deployment.md</c> in a follow-up.
|
||||
/// </remarks>
|
||||
public sealed class HealthEndpointsHost : IAsyncDisposable
|
||||
{
|
||||
private readonly string _prefix;
|
||||
private readonly DriverHost _driverHost;
|
||||
private readonly Func<bool> _configDbHealthy;
|
||||
private readonly Func<bool> _usingStaleConfig;
|
||||
private readonly ILogger<HealthEndpointsHost> _logger;
|
||||
private readonly HttpListener _listener = new();
|
||||
private readonly DateTime _startedUtc = DateTime.UtcNow;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _acceptLoop;
|
||||
private bool _disposed;
|
||||
|
||||
public HealthEndpointsHost(
|
||||
DriverHost driverHost,
|
||||
ILogger<HealthEndpointsHost> logger,
|
||||
Func<bool>? configDbHealthy = null,
|
||||
Func<bool>? usingStaleConfig = null,
|
||||
string prefix = "http://localhost:4841/")
|
||||
{
|
||||
_driverHost = driverHost;
|
||||
_logger = logger;
|
||||
_configDbHealthy = configDbHealthy ?? (() => true);
|
||||
_usingStaleConfig = usingStaleConfig ?? (() => false);
|
||||
_prefix = prefix.EndsWith('/') ? prefix : prefix + "/";
|
||||
_listener.Prefixes.Add(_prefix);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_listener.Start();
|
||||
_cts = new CancellationTokenSource();
|
||||
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
|
||||
_logger.LogInformation("Health endpoints listening on {Prefix}", _prefix);
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
HttpListenerContext ctx;
|
||||
try
|
||||
{
|
||||
ctx = await _listener.GetContextAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpListenerException) when (ct.IsCancellationRequested) { break; }
|
||||
catch (ObjectDisposedException) { break; }
|
||||
|
||||
_ = Task.Run(() => HandleAsync(ctx), ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleAsync(HttpListenerContext ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = ctx.Request.Url?.AbsolutePath ?? "/";
|
||||
switch (path)
|
||||
{
|
||||
case "/healthz":
|
||||
await WriteHealthzAsync(ctx).ConfigureAwait(false);
|
||||
break;
|
||||
case "/readyz":
|
||||
await WriteReadyzAsync(ctx).ConfigureAwait(false);
|
||||
break;
|
||||
default:
|
||||
// #154 — driver-diagnostics path family. URL shape:
|
||||
// /diagnostics/drivers/{driverInstanceId}/modbus/auto-prohibited
|
||||
// Driver-agnostic at the URL level so future driver types (S7, AbCip,
|
||||
// FOCAS) can add their own per-type subpaths.
|
||||
if (path.StartsWith("/diagnostics/drivers/", StringComparison.Ordinal))
|
||||
await WriteDriverDiagnosticsAsync(ctx, path).ConfigureAwait(false);
|
||||
else
|
||||
ctx.Response.StatusCode = 404;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Health endpoint handler failure");
|
||||
try { ctx.Response.StatusCode = 500; } catch { /* ignore */ }
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { ctx.Response.Close(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteHealthzAsync(HttpListenerContext ctx)
|
||||
{
|
||||
var configHealthy = _configDbHealthy();
|
||||
var staleConfig = _usingStaleConfig();
|
||||
// /healthz is 200 when process alive + (config DB reachable OR cache-warm).
|
||||
// Stale-config still serves 200 so the process isn't flagged dead when the DB
|
||||
// blips; the body surfaces the stale flag for operators.
|
||||
var healthy = configHealthy || staleConfig;
|
||||
ctx.Response.StatusCode = healthy ? 200 : 503;
|
||||
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
status = healthy ? "healthy" : "unhealthy",
|
||||
uptimeSeconds = (int)(DateTime.UtcNow - _startedUtc).TotalSeconds,
|
||||
configDbReachable = configHealthy,
|
||||
usingStaleConfig = staleConfig,
|
||||
});
|
||||
await WriteBodyAsync(ctx, body).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task WriteReadyzAsync(HttpListenerContext ctx)
|
||||
{
|
||||
var snapshots = BuildSnapshots();
|
||||
var verdict = DriverHealthReport.Aggregate(snapshots);
|
||||
ctx.Response.StatusCode = DriverHealthReport.HttpStatus(verdict);
|
||||
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
verdict = verdict.ToString(),
|
||||
uptimeSeconds = (int)(DateTime.UtcNow - _startedUtc).TotalSeconds,
|
||||
drivers = snapshots.Select(d => new
|
||||
{
|
||||
id = d.DriverInstanceId,
|
||||
state = d.State.ToString(),
|
||||
detail = d.DetailMessage,
|
||||
}).ToArray(),
|
||||
degradedDrivers = snapshots
|
||||
.Where(d => d.State == DriverState.Degraded || d.State == DriverState.Reconnecting)
|
||||
.Select(d => d.DriverInstanceId)
|
||||
.ToArray(),
|
||||
});
|
||||
await WriteBodyAsync(ctx, body).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private IReadOnlyList<DriverHealthSnapshot> BuildSnapshots()
|
||||
{
|
||||
var list = new List<DriverHealthSnapshot>();
|
||||
foreach (var id in _driverHost.RegisteredDriverIds)
|
||||
{
|
||||
var driver = _driverHost.GetDriver(id);
|
||||
if (driver is null) continue;
|
||||
var health = driver.GetHealth();
|
||||
list.Add(new DriverHealthSnapshot(driver.DriverInstanceId, health.State, health.LastError));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #154 — driver-diagnostics endpoint family. Routes
|
||||
/// <c>/diagnostics/drivers/{driverId}/modbus/auto-prohibited</c> to the live
|
||||
/// <see cref="ModbusDriver"/> instance's <see cref="ModbusDriver.GetAutoProhibitedRanges"/>.
|
||||
/// 404 when the driver instance doesn't exist; 400 when it exists but isn't a Modbus
|
||||
/// driver (the per-type endpoint is wrong for this row).
|
||||
/// </summary>
|
||||
private async Task WriteDriverDiagnosticsAsync(HttpListenerContext ctx, string path)
|
||||
{
|
||||
// Path shape: /diagnostics/drivers/{id}/modbus/auto-prohibited
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length < 4 || segments[0] != "diagnostics" || segments[1] != "drivers")
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
var driverId = segments[2];
|
||||
var driver = _driverHost.GetDriver(driverId);
|
||||
if (driver is null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
await WriteBodyAsync(ctx, JsonSerializer.Serialize(new { error = $"Driver '{driverId}' not found" })).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-driver-type subpath dispatch. Today only Modbus is wired; future drivers add
|
||||
// their own segments[3] cases.
|
||||
if (segments.Length >= 5 && segments[3] == "modbus" && segments[4] == "auto-prohibited")
|
||||
{
|
||||
if (driver is not ModbusDriver modbus)
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
await WriteBodyAsync(ctx, JsonSerializer.Serialize(new { error = $"Driver '{driverId}' is not a Modbus driver (type: {driver.DriverType})" })).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
var ranges = modbus.GetAutoProhibitedRanges();
|
||||
ctx.Response.StatusCode = 200;
|
||||
await WriteBodyAsync(ctx, JsonSerializer.Serialize(new
|
||||
{
|
||||
driverInstanceId = driverId,
|
||||
count = ranges.Count,
|
||||
ranges = ranges.Select(r => new
|
||||
{
|
||||
unitId = r.UnitId,
|
||||
region = r.Region.ToString(),
|
||||
startAddress = r.StartAddress,
|
||||
endAddress = r.EndAddress,
|
||||
lastProbedUtc = r.LastProbedUtc,
|
||||
bisectionPending = r.BisectionPending,
|
||||
}).ToArray(),
|
||||
})).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.StatusCode = 404;
|
||||
}
|
||||
|
||||
private static async Task WriteBodyAsync(HttpListenerContext ctx, string body)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
ctx.Response.ContentType = "application/json; charset=utf-8";
|
||||
ctx.Response.ContentLength64 = bytes.LongLength;
|
||||
await ctx.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_cts?.Cancel();
|
||||
try { _listener.Stop(); } catch { /* ignore */ }
|
||||
if (_acceptLoop is not null)
|
||||
{
|
||||
try { await _acceptLoop.ConfigureAwait(false); } catch { /* ignore */ }
|
||||
}
|
||||
_listener.Close();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Holds pre-loaded <see cref="EquipmentNamespaceContent"/> snapshots keyed by
|
||||
/// <c>DriverInstanceId</c>. Populated once during <see cref="OpcUaServerService"/> startup
|
||||
/// (after <see cref="NodeBootstrap"/> resolves the generation) so the synchronous lookup
|
||||
/// delegate on <see cref="OpcUaApplicationHost"/> can serve the walker from memory without
|
||||
/// blocking on async DB I/O mid-dispatch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The registry is intentionally a shared mutable singleton with set-once-per-bootstrap
|
||||
/// semantics rather than an immutable map passed by value — the composition in Program.cs
|
||||
/// builds <see cref="OpcUaApplicationHost"/> before <see cref="NodeBootstrap"/> runs, so the
|
||||
/// registry must exist at DI-compose time but be empty until the generation is known. A
|
||||
/// driver registered after the initial populate pass simply returns null from
|
||||
/// <see cref="Get"/> + the wire-in falls back to the "no UNS content, let DiscoverAsync own
|
||||
/// it" path that PR #155 established.</para>
|
||||
/// </remarks>
|
||||
public sealed class DriverEquipmentContentRegistry
|
||||
{
|
||||
private readonly Dictionary<string, EquipmentNamespaceContent> _content =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
public EquipmentNamespaceContent? Get(string driverInstanceId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _content.TryGetValue(driverInstanceId, out var c) ? c : null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Set(string driverInstanceId, EquipmentNamespaceContent content)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_content[driverInstanceId] = content;
|
||||
}
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) { return _content.Count; } }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the current driver → content map. Returns a copy so callers can iterate
|
||||
/// without holding the lock. Used at authorization bootstrap to merge all namespaces
|
||||
/// into a single <see cref="Security.NodeScopeResolver"/> path index.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, EquipmentNamespaceContent> Snapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return new Dictionary<string, EquipmentNamespaceContent>(_content, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
1530
src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
Normal file
1530
src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user