feat(adminui): F15 Phase D — logic + ops pages
Some checks failed
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been cancelled
v2-ci / integration (push) Has been cancelled
v2-ci / build (push) Has been cancelled

- ClusterAudit (/clusters/{id}/audit) — reads ConfigAuditLog with the
  EventId/CorrelationId columns added in F3; shown as a Cluster tab
- VirtualTags (/virtual-tags)            — fleet-wide read view
- ScriptedAlarms (/scripted-alarms)      — fleet-wide read view
- Scripts (/scripts)                     — fleet-wide; expandable code preview
- RoleGrants (/role-grants)              — per Q4, surfaces the fleet-wide
                                           LDAP-group → role mapping from
                                           Authentication:Ldap:GroupToRole
                                           (read-only; reload via host restart)
- Certificates (/certificates)           — own/trusted/issuer/rejected store
                                           contents resolved against
                                           OpcUa:PkiStoreRoot config (F13a)
- Reservations (/reservations)           — ExternalIdReservation table
- AlarmsHistorian (/alarms-historian)    — live HistorianAdapterActor sink
                                           status via the F11 GetStatus query;
                                           5s polling

ScriptLog deferred (needs the F16-deferred ScriptLogHub bridge).
ClusterNav extended with the Audit tab.

Adds an AdminUI → Runtime project reference so the historian status page can
inject IRequiredActor<HistorianAdapterActorKey>. NuGet audit suppression for
the transitive Opc.Ua.Core advisory mirrored from the Runtime project.

All 104 v2 tests still green.
This commit is contained in:
Joseph Doherty
2026-05-26 08:01:23 -04:00
parent 396052a126
commit 74161f9460
11 changed files with 668 additions and 0 deletions

View File

@@ -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<HistorianAdapterActorKey> HistorianActor
@implements IDisposable
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Alarms historian sink</h4>
</div>
<section class="panel notice rise" style="animation-delay:.02s">
Snapshot from the local node's <span class="mono">HistorianAdapterActor</span>. Default sink
is a no-op (<span class="mono">NullAlarmHistorianSink</span>); production wires
<span class="mono">SqliteStoreAndForwardSink</span> with the Wonderware historian sidecar
behind it. Polling every @PollSeconds s.
</section>
@if (_status is null)
{
<p class="mt-3">Loading…</p>
}
else
{
<section class="card-grid rise mt-3" style="animation-delay:.08s">
<div class="metric-card">
<div class="panel-head">Queue</div>
<div class="kv"><span class="k">Depth</span><span class="v numeric">@_status.QueueDepth</span></div>
<div class="kv"><span class="k">Dead-lettered</span><span class="v numeric">@_status.DeadLetterDepth</span></div>
<div class="kv"><span class="k">Evicted (lifetime)</span><span class="v numeric">@_status.EvictedCount</span></div>
</div>
<div class="metric-card">
<div class="panel-head">Drain state</div>
<div class="kv"><span class="k">State</span><span class="v"><span class="@StateChipClass(_status.DrainState)">@_status.DrainState</span></span></div>
<div class="kv"><span class="k">Last drain</span><span class="v">@(_status.LastDrainUtc?.ToString("u") ?? "—")</span></div>
<div class="kv"><span class="k">Last success</span><span class="v">@(_status.LastSuccessUtc?.ToString("u") ?? "—")</span></div>
@if (!string.IsNullOrWhiteSpace(_status.LastError))
{
<div class="kv"><span class="k">Last error</span><span class="v text-danger small">@_status.LastError</span></div>
}
</div>
</section>
}
@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<HistorianSinkStatus>(
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();
}

View File

@@ -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
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">OPC UA certificates</h4>
</div>
<section class="panel notice rise" style="animation-delay:.02s">
PKI store layout: <span class="mono">{PkiStoreRoot}/own</span> (this server's identity),
<span class="mono">issuer</span> / <span class="mono">trusted</span> (peers we accept),
<span class="mono">rejected</span> (peers we've turned away). F13a wires SDK
auto-creation so the own-store self-signs on first boot.
</section>
@if (_rows is null)
{
<p class="mt-3">Loading…</p>
}
else
{
@foreach (var store in _rows)
{
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@store.Label &middot; @store.Certificates.Count entry@(store.Certificates.Count == 1 ? "" : "s")</div>
@if (string.IsNullOrEmpty(store.Path))
{
<div style="padding:1rem" class="text-muted">No path configured.</div>
}
else if (!Directory.Exists(store.Path))
{
<div style="padding:1rem" class="text-muted">
<span class="mono">@store.Path</span> doesn't exist yet. It will be created on first boot.
</div>
}
else if (store.Certificates.Count == 0)
{
<div style="padding:1rem" class="text-muted">No certificates in <span class="mono">@store.Path</span>.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Subject</th>
<th>Issuer</th>
<th>Thumbprint</th>
<th>Not before</th>
<th>Not after</th>
</tr>
</thead>
<tbody>
@foreach (var c in store.Certificates)
{
<tr>
<td><span class="mono small">@c.Subject</span></td>
<td><span class="mono small">@c.Issuer</span></td>
<td><span class="mono small">@c.Thumbprint[..16]…</span></td>
<td>@c.NotBefore.ToString("u")</td>
<td>@c.NotAfter.ToString("u")</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
}
@code {
private List<StoreView>? _rows;
protected override void OnInitialized()
{
var pkiRoot = Config.GetValue<string?>("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<X509Certificate2>());
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<X509Certificate2> Certificates);
}

View File

@@ -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<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Audit log &middot; <span class="mono">@ClusterId</span></h4>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="audit" />
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
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.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count row@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No audit rows for this cluster yet.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Principal</th>
<th>Event</th>
<th>Node</th>
<th>Correlation</th>
<th>Details</th>
</tr>
</thead>
<tbody>
@foreach (var a in _rows)
{
<tr>
<td><span class="mono small">@a.Timestamp.ToString("u")</span></td>
<td>@a.Principal</td>
<td><span class="chip chip-idle">@a.EventType</span></td>
<td><span class="mono small">@(a.NodeId ?? "—")</span></td>
<td><span class="mono small">@(a.CorrelationId?.ToString("N")[..8] ?? "—")</span></td>
<td class="text-muted small" style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
@(a.DetailsJson ?? "")
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private const int PageSize = 200;
[Parameter] public string ClusterId { get; set; } = "";
private List<ConfigAuditLog>? _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();
}
}

View File

@@ -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<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">External ID reservations</h4>
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
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.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count reservation@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No reservations yet.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Kind</th>
<th>Value</th>
<th>Equipment UUID</th>
<th>Cluster</th>
</tr>
</thead>
<tbody>
@foreach (var r in _rows)
{
<tr>
<td><span class="chip chip-idle">@r.Kind</span></td>
<td><span class="mono">@r.Value</span></td>
<td><span class="mono small">@r.EquipmentUuid</span></td>
<td><span class="mono small">@r.ClusterId</span></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private List<ExternalIdReservation>? _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();
}
}

View File

@@ -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<LdapOptions> Ldap
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Role grants</h4>
</div>
<section class="panel notice rise" style="animation-delay:.02s">
LDAP group membership determines fleet roles. Edit the mapping in
<span class="mono">appsettings.json</span> under <span class="mono">Authentication:Ldap:GroupToRole</span>
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.
</section>
@if (_options is null)
{
<p>Loading…</p>
}
else
{
<section class="card-grid rise mt-3" style="animation-delay:.08s">
<div class="metric-card">
<div class="panel-head">LDAP binding</div>
<div class="kv"><span class="k">Enabled</span><span class="v">@(_options.Enabled ? "yes" : "no")</span></div>
<div class="kv"><span class="k">Server</span><span class="v mono">@_options.Server:@_options.Port</span></div>
<div class="kv"><span class="k">UseTls</span><span class="v">@_options.UseTls</span></div>
<div class="kv"><span class="k">SearchBase</span><span class="v mono small">@_options.SearchBase</span></div>
@if (!_options.UseTls && _options.AllowInsecureLdap)
{
<div class="kv"><span class="k">Warning</span><span class="v"><span class="chip chip-alert">Plaintext credentials over LDAP — dev mode only</span></span></div>
}
</div>
</section>
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Group → role mapping (@(_options.GroupToRole?.Count ?? 0))</div>
@if (_options.GroupToRole is null || _options.GroupToRole.Count == 0)
{
<div style="padding:1rem" class="text-muted">
No mapping configured. Every authenticated user lands with zero roles —
the fallback authorization policy will refuse every request. Add a
<span class="mono">GroupToRole</span> entry before deploying.
</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>LDAP group</th><th>Resolved role</th></tr></thead>
<tbody>
@foreach (var kvp in _options.GroupToRole.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
{
<tr>
<td><span class="mono">@kvp.Key</span></td>
<td><span class="chip chip-idle">@kvp.Value</span></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private LdapOptions? _options;
protected override void OnInitialized()
{
_options = Ldap.Value;
}
}

View File

@@ -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<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Scripted alarms</h4>
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
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.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count scripted alarm@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No scripted alarms defined.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>ScriptedAlarmId</th>
<th>Name</th>
<th>Equipment</th>
<th>Type</th>
<th class="num">Severity</th>
<th>Predicate</th>
<th>Flags</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var a in _rows)
{
<tr>
<td><span class="mono small">@a.ScriptedAlarmId</span></td>
<td>@a.Name</td>
<td><span class="mono small">@a.EquipmentId</span></td>
<td>@a.AlarmType</td>
<td class="num">@a.Severity</td>
<td><span class="mono small">@a.PredicateScriptId</span></td>
<td>
@if (a.HistorizeToAveva) { <span class="chip chip-idle me-1">historize</span> }
@if (a.Retain) { <span class="chip chip-idle">retain</span> }
</td>
<td>
@if (a.Enabled) { <span class="chip chip-ok">Enabled</span> }
else { <span class="chip chip-idle">Disabled</span> }
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private List<ScriptedAlarm>? _rows;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_rows = await db.ScriptedAlarms.AsNoTracking()
.OrderBy(a => a.Name)
.ToListAsync();
}
}

View File

@@ -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<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Scripts</h4>
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
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.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count script@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No scripts defined.</div>
}
else
{
@foreach (var s in _rows)
{
<details style="border-top:1px solid var(--rule)">
<summary style="padding:.75rem 1rem;cursor:pointer">
<span class="mono">@s.ScriptId</span>
&middot; <span>@s.Name</span>
&middot; <span class="chip chip-idle ms-1">@s.Language</span>
<span class="text-muted small ms-2 mono">hash=@s.SourceHash[..12]…</span>
</summary>
<div style="padding:0 1rem 1rem">
<pre class="mono small" style="background:var(--surface-2);padding:1rem;border-radius:4px;overflow:auto">@s.SourceCode</pre>
</div>
</details>
}
}
</section>
}
@code {
private List<Script>? _rows;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_rows = await db.Scripts.AsNoTracking()
.OrderBy(s => s.Name)
.ToListAsync();
}
}

View File

@@ -0,0 +1,82 @@
@page "/virtual-tags"
@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<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Virtual tags</h4>
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
Virtual tags evaluate a script per equipment instance and publish the result as an OPC UA
variable. ChangeTriggered = re-evaluate when any dependency changes; TimerIntervalMs
re-evaluates on a periodic timer. Live editing lands in a Phase C.2-equivalent follow-up.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count virtual tag@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No virtual tags defined.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>VirtualTagId</th>
<th>Name</th>
<th>Equipment</th>
<th>Data type</th>
<th>Script</th>
<th>Trigger</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var v in _rows)
{
<tr>
<td><span class="mono small">@v.VirtualTagId</span></td>
<td>@v.Name</td>
<td><span class="mono small">@v.EquipmentId</span></td>
<td><span class="mono small">@v.DataType</span></td>
<td><span class="mono small">@v.ScriptId</span></td>
<td>
@if (v.ChangeTriggered) { <span class="chip chip-idle me-1">change</span> }
@if (v.TimerIntervalMs is int ms) { <span class="chip chip-idle">@(ms)ms</span> }
</td>
<td>
@if (v.Enabled) { <span class="chip chip-ok">Enabled</span> }
else { <span class="chip chip-idle">Disabled</span> }
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private List<VirtualTag>? _rows;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_rows = await db.VirtualTags.AsNoTracking()
.OrderBy(v => v.Name)
.ToListAsync();
}
}

View File

@@ -16,6 +16,7 @@
<li class="nav-item"><a class="nav-link @Active("drivers")" href="/clusters/@ClusterId/drivers">Drivers</a></li> <li class="nav-item"><a class="nav-link @Active("drivers")" href="/clusters/@ClusterId/drivers">Drivers</a></li>
<li class="nav-item"><a class="nav-link @Active("tags")" href="/clusters/@ClusterId/tags">Tags</a></li> <li class="nav-item"><a class="nav-link @Active("tags")" href="/clusters/@ClusterId/tags">Tags</a></li>
<li class="nav-item"><a class="nav-link @Active("acls")" href="/clusters/@ClusterId/acls">ACLs</a></li> <li class="nav-item"><a class="nav-link @Active("acls")" href="/clusters/@ClusterId/acls">ACLs</a></li>
<li class="nav-item"><a class="nav-link @Active("audit")" href="/clusters/@ClusterId/audit">Audit</a></li>
<li class="nav-item"><a class="nav-link @Active("redundancy")" href="/clusters/@ClusterId/redundancy">Redundancy</a></li> <li class="nav-item"><a class="nav-link @Active("redundancy")" href="/clusters/@ClusterId/redundancy">Redundancy</a></li>
</ul> </ul>

View File

@@ -14,12 +14,15 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/> <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/> <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- OpenTelemetry.Api transitively via ControlPlane -> Akka.Cluster.Tools. --> <!-- OpenTelemetry.Api transitively via ControlPlane -> Akka.Cluster.Tools. -->
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-g94r-2vxg-569j"/> <NuGetAuditSuppress Include="https://github.com/advisories/GHSA-g94r-2vxg-569j"/>
<!-- Opc.Ua.Core transitively via Runtime → OpcUaServer; advisory accepted at the host. -->
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-h958-fxgg-g7w3"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -5,3 +5,4 @@
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode @using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared