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:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View 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>

View File

@@ -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>

View File

@@ -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]),
];
}

View File

@@ -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",
};
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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; }
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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>"),
};
}

View File

@@ -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; }
}

View File

@@ -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&#10;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; }
}
}

View File

@@ -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();
}
}

View File

@@ -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; }
}
}

View File

@@ -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'");
}
}

View File

@@ -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.
}
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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| &lt; threshold.</div>
</div>
<div class="col-md-4">
<label class="form-label small">UnitId override
<span class="text-muted">(0255, 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();
}
}

View File

@@ -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();
}
}

View File

@@ -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'");
}
}

View 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);
}

View File

@@ -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");
}
}
}

View 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 { }
}
}
}

View 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; }
}
}

View File

@@ -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;
}
}
}

View File

@@ -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)";
}

View File

@@ -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";
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View 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>

View 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

View 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);

View 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);

View 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;

View File

@@ -0,0 +1,6 @@
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
public interface ILdapAuthService
{
Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default);
}

View File

@@ -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);

View 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..];
}
}

View 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);
}

View 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];
}
}

View File

@@ -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);

View 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];
}

View File

@@ -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);
}
}

View File

@@ -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");
}

View 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");
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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) { }
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View 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);
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View 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);
}
}

View 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);

View 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);

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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>

View 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
}
}
}

View 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; }

View File

@@ -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 }));
});
},
};
})();

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}
}

View 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();
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View 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,
};
}

View File

@@ -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;
}
}
}

View 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 });
}
}

View File

@@ -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);
}

View 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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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 &gt; 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.");
}
}
}

View File

@@ -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);
}
}
}
}

View 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);

View 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();
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

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