diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor new file mode 100644 index 0000000..237d01f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor @@ -0,0 +1,91 @@ +@page "/alarms-historian" +@* Live status of the local node's IAlarmHistorianSink (queue depth, drain state) via the + HistorianAdapterActor.GetStatus query landed in F11. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Akka.Actor +@using Akka.Hosting +@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian +@using ZB.MOM.WW.OtOpcUa.Runtime +@using ZB.MOM.WW.OtOpcUa.Runtime.Historian +@inject IRequiredActor HistorianActor +@implements IDisposable + +
+

Alarms historian sink

+
+ +
+ Snapshot from the local node's HistorianAdapterActor. Default sink + is a no-op (NullAlarmHistorianSink); production wires + SqliteStoreAndForwardSink with the Wonderware historian sidecar + behind it. Polling every @PollSeconds s. +
+ +@if (_status is null) +{ +

Loading…

+} +else +{ +
+
+
Queue
+
Depth@_status.QueueDepth
+
Dead-lettered@_status.DeadLetterDepth
+
Evicted (lifetime)@_status.EvictedCount
+
+ +
+
Drain state
+
State@_status.DrainState
+
Last drain@(_status.LastDrainUtc?.ToString("u") ?? "—")
+
Last success@(_status.LastSuccessUtc?.ToString("u") ?? "—")
+ @if (!string.IsNullOrWhiteSpace(_status.LastError)) + { +
Last error@_status.LastError
+ } +
+
+} + +@code { + private const int PollSeconds = 5; + + private HistorianSinkStatus? _status; + private Timer? _timer; + + protected override async Task OnInitializedAsync() + { + await RefreshAsync(); + _timer = new Timer(_ => _ = InvokeAsync(RefreshAsync), null, + TimeSpan.FromSeconds(PollSeconds), TimeSpan.FromSeconds(PollSeconds)); + } + + private async Task RefreshAsync() + { + try + { + _status = await HistorianActor.ActorRef.Ask( + HistorianAdapterActor.GetStatus.Instance, TimeSpan.FromSeconds(2)); + StateHasChanged(); + } + catch + { + // Actor unavailable (admin-only node, not driver-role) — leave _status null and let + // the page show "Loading…". A dedicated "this role doesn't run a historian" message + // would be nicer; lands when we add role gating to the UI. + } + } + + private static string StateChipClass(HistorianDrainState state) => state switch + { + HistorianDrainState.Disabled => "chip chip-idle", + HistorianDrainState.Idle => "chip chip-idle", + HistorianDrainState.Draining => "chip chip-ok", + HistorianDrainState.BackingOff => "chip chip-caution", + _ => "chip chip-idle", + }; + + public void Dispose() => _timer?.Dispose(); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor new file mode 100644 index 0000000..0ac3009 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor @@ -0,0 +1,111 @@ +@page "/certificates" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using System.Security.Cryptography.X509Certificates +@using Microsoft.Extensions.Configuration +@inject IConfiguration Config + +
+

OPC UA certificates

+
+ +
+ PKI store layout: {PkiStoreRoot}/own (this server's identity), + issuer / trusted (peers we accept), + rejected (peers we've turned away). F13a wires SDK + auto-creation so the own-store self-signs on first boot. +
+ +@if (_rows is null) +{ +

Loading…

+} +else +{ + @foreach (var store in _rows) + { +
+
@store.Label · @store.Certificates.Count entry@(store.Certificates.Count == 1 ? "" : "s")
+ @if (string.IsNullOrEmpty(store.Path)) + { +
No path configured.
+ } + else if (!Directory.Exists(store.Path)) + { +
+ @store.Path doesn't exist yet. It will be created on first boot. +
+ } + else if (store.Certificates.Count == 0) + { +
No certificates in @store.Path.
+ } + else + { +
+ + + + + + + + + + + + @foreach (var c in store.Certificates) + { + + + + + + + + } + +
SubjectIssuerThumbprintNot beforeNot after
@c.Subject@c.Issuer@c.Thumbprint[..16]…@c.NotBefore.ToString("u")@c.NotAfter.ToString("u")
+
+ } +
+ } +} + +@code { + private List? _rows; + + protected override void OnInitialized() + { + var pkiRoot = Config.GetValue("OpcUa:PkiStoreRoot") ?? "pki"; + _rows = new() + { + LoadStore("Own", Path.Combine(pkiRoot, "own", "certs")), + LoadStore("Trusted peers", Path.Combine(pkiRoot, "trusted", "certs")), + LoadStore("Trusted issuers", Path.Combine(pkiRoot, "issuer", "certs")), + LoadStore("Rejected", Path.Combine(pkiRoot, "rejected", "certs")), + }; + } + + private static StoreView LoadStore(string label, string path) + { + var view = new StoreView(label, path, new List()); + if (!Directory.Exists(path)) return view; + foreach (var file in Directory.EnumerateFiles(path).Where(IsCertFile)) + { + try { view.Certificates.Add(X509CertificateLoader.LoadCertificateFromFile(file)); } + catch { /* ignore unreadable entries */ } + } + return view; + } + + private static bool IsCertFile(string path) + { + var ext = Path.GetExtension(path); + return ext.Equals(".der", StringComparison.OrdinalIgnoreCase) + || ext.Equals(".cer", StringComparison.OrdinalIgnoreCase) + || ext.Equals(".crt", StringComparison.OrdinalIgnoreCase); + } + + private sealed record StoreView(string Label, string Path, List Certificates); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor new file mode 100644 index 0000000..13c50dd --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor @@ -0,0 +1,83 @@ +@page "/clusters/{ClusterId}/audit" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

Audit log · @ClusterId

+
+ + + +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ Latest @PageSize audit rows scoped to this cluster, newest first. EventId/CorrelationId + columns (F3) make cross-restart deduplication possible — Akka actors that retry an apply + won't insert duplicate rows. Details JSON is shown verbatim. +
+ +
+
@_rows.Count row@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No audit rows for this cluster yet.
+ } + else + { +
+ + + + + + + + + + + + + @foreach (var a in _rows) + { + + + + + + + + + } + +
TimestampPrincipalEventNodeCorrelationDetails
@a.Timestamp.ToString("u")@a.Principal@a.EventType@(a.NodeId ?? "—")@(a.CorrelationId?.ToString("N")[..8] ?? "—") + @(a.DetailsJson ?? "") +
+
+ } +
+} + +@code { + private const int PageSize = 200; + + [Parameter] public string ClusterId { get; set; } = ""; + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _rows = await db.ConfigAuditLogs.AsNoTracking() + .Where(a => a.ClusterId == ClusterId) + .OrderByDescending(a => a.Timestamp) + .Take(PageSize) + .ToListAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Reservations.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Reservations.razor new file mode 100644 index 0000000..86295cc --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Reservations.razor @@ -0,0 +1,70 @@ +@page "/reservations" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

External ID reservations

+
+ +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ External IDs (ZTag, SAPID) are reserved fleet-wide via this table. Reservations bind a + value to an Equipment's UUID so the ID can move with the equipment across cluster + reshuffles without colliding with another cluster's equipment. +
+ +
+
@_rows.Count reservation@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No reservations yet.
+ } + else + { +
+ + + + + + + + + + + @foreach (var r in _rows) + { + + + + + + + } + +
KindValueEquipment UUIDCluster
@r.Kind@r.Value@r.EquipmentUuid@r.ClusterId
+
+ } +
+} + +@code { + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _rows = await db.ExternalIdReservations.AsNoTracking() + .OrderBy(r => r.Kind).ThenBy(r => r.Value) + .ToListAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor new file mode 100644 index 0000000..caed4c7 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor @@ -0,0 +1,81 @@ +@page "/role-grants" +@* Per Q4 of the AdminUI rebuild plan, v2 replaced v1's per-cluster RoleGrants table with a + fleet-wide LDAP-group → role map. This page surfaces the mapping read-only; the source of + truth is Authentication:Ldap:GroupToRole in appsettings (editable on the host filesystem, not + from the UI yet). *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.Extensions.Options +@using ZB.MOM.WW.OtOpcUa.Security.Ldap +@inject IOptionsSnapshot Ldap + +
+

Role grants

+
+ +
+ LDAP group membership determines fleet roles. Edit the mapping in + appsettings.json under Authentication:Ldap:GroupToRole + and restart the admin node (or sign out + back in for cached claims to refresh). UI-driven + editing of the mapping is deferred — it implies a config-reload mechanism that doesn't exist + yet. +
+ +@if (_options is null) +{ +

Loading…

+} +else +{ +
+
+
LDAP binding
+
Enabled@(_options.Enabled ? "yes" : "no")
+
Server@_options.Server:@_options.Port
+
UseTls@_options.UseTls
+
SearchBase@_options.SearchBase
+ @if (!_options.UseTls && _options.AllowInsecureLdap) + { +
WarningPlaintext credentials over LDAP — dev mode only
+ } +
+
+ +
+
Group → role mapping (@(_options.GroupToRole?.Count ?? 0))
+ @if (_options.GroupToRole is null || _options.GroupToRole.Count == 0) + { +
+ No mapping configured. Every authenticated user lands with zero roles — + the fallback authorization policy will refuse every request. Add a + GroupToRole entry before deploying. +
+ } + else + { +
+ + + + @foreach (var kvp in _options.GroupToRole.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) + { + + + + + } + +
LDAP groupResolved role
@kvp.Key@kvp.Value
+
+ } +
+} + +@code { + private LdapOptions? _options; + + protected override void OnInitialized() + { + _options = Ldap.Value; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarms.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarms.razor new file mode 100644 index 0000000..bb918c4 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarms.razor @@ -0,0 +1,84 @@ +@page "/scripted-alarms" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

Scripted alarms

+
+ +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ Scripted alarms watch a predicate script per equipment instance and fire OPC UA alarms + when the predicate transitions true. HistorizeToAveva routes events through the + Wonderware historian sidecar (F11) when enabled. +
+ +
+
@_rows.Count scripted alarm@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No scripted alarms defined.
+ } + else + { +
+ + + + + + + + + + + + + + + @foreach (var a in _rows) + { + + + + + + + + + + + } + +
ScriptedAlarmIdNameEquipmentTypeSeverityPredicateFlagsStatus
@a.ScriptedAlarmId@a.Name@a.EquipmentId@a.AlarmType@a.Severity@a.PredicateScriptId + @if (a.HistorizeToAveva) { historize } + @if (a.Retain) { retain } + + @if (a.Enabled) { Enabled } + else { Disabled } +
+
+ } +
+} + +@code { + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _rows = await db.ScriptedAlarms.AsNoTracking() + .OrderBy(a => a.Name) + .ToListAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor new file mode 100644 index 0000000..7a4684e --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor @@ -0,0 +1,61 @@ +@page "/scripts" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

Scripts

+
+ +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ Scripts are fleet-wide expression compilations referenced by virtual tags and scripted + alarms. The default language is C#; expansion of the editor (Monaco syntax, dependency + introspection) lands in Phase D.2. +
+ +
+
@_rows.Count script@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No scripts defined.
+ } + else + { + @foreach (var s in _rows) + { +
+ + @s.ScriptId + · @s.Name + · @s.Language + hash=@s.SourceHash[..12]… + +
+
@s.SourceCode
+
+
+ } + } +
+} + +@code { + private List