merge: AdminUI sweep — Clusters pages (btn-groups, notice-alert, stale-prose deletions)

This commit is contained in:
Joseph Doherty
2026-08-10 16:33:24 -04:00
10 changed files with 44 additions and 121 deletions
@@ -31,32 +31,6 @@ else if (!IsNew && _existing is null)
}
else
{
<div class="alert alert-info" role="alert">
<strong>What this grant does depends on <span class="mono">Security:Acl:Enabled</span> on the
node serving the data.</strong> That is per-node configuration, so this page cannot tell you
which mode a given node is in — check the node's own settings.
<br />
<strong>With it on:</strong> the scope is honoured, and the scoped decision <em>replaces</em> the
server-wide role rather than adding to it. <strong>Every data-plane operation is gated</strong> —
<span class="mono">Read</span>, <span class="mono">Browse</span>,
<span class="mono">Subscribe</span>, <span class="mono">WriteOperate</span>,
<span class="mono">HistoryRead</span> and the alarm methods, which separate into
<span class="mono">AlarmAcknowledge</span> / <span class="mono">AlarmConfirm</span> /
<span class="mono">AlarmShelve</span>. So a grant that omits <span class="mono">Read</span>
denies reads, and one that omits <span class="mono">Browse</span> hides the node from navigation.
Granting <span class="mono">Browse</span> on a node also makes the folders <em>above</em> it
browsable — otherwise the grant could never be navigated to — so it reveals their names.
Revoking this grant stops any live subscription it was authorizing, on the next deployment.
<br />
<strong>With it off (the default):</strong> only a <span class="mono">Cluster</span>-scoped grant
does anything, and it confers a <em>server-wide</em> role on its principal —
<span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods. Any narrower scope is
stored and shipped but <strong>confers nothing</strong>. That under-granting is deliberate:
honouring a grant's flags while its <em>scope</em> cannot be honoured would turn "WriteOperate on
one equipment" into server-wide write authority.
</div>
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit">
<DataAnnotationsValidator />
<ValidationSummary class="alert alert-danger" />
@@ -151,13 +125,12 @@ else
</div>
<div class="form-text mt-2">
Bundles:
<button type="button" class="btn btn-sm btn-link p-0 ms-1" @onclick="() => _form.PermissionFlags = NodePermissions.ReadOnly">ReadOnly</button>
&middot;
<button type="button" class="btn btn-sm btn-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Operator">Operator</button>
&middot;
<button type="button" class="btn btn-sm btn-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Engineer">Engineer</button>
&middot;
<button type="button" class="btn btn-sm btn-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Admin">Admin</button>
<div class="btn-group btn-group-sm ms-1" role="group">
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="() => _form.PermissionFlags = NodePermissions.ReadOnly">ReadOnly</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="() => _form.PermissionFlags = NodePermissions.Operator">Operator</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="() => _form.PermissionFlags = NodePermissions.Engineer">Engineer</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="() => _form.PermissionFlags = NodePermissions.Admin">Admin</button>
</div>
</div>
</div>
<div class="mb-3">
@@ -167,11 +140,13 @@ else
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div> }
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="panel notice notice-alert mt-3">@_error</div> }
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button>
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary">Cancel</a>
<div class="btn-group" role="group">
<button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button>
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary">Cancel</a>
</div>
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
</div>
</EditForm>
@@ -21,49 +21,7 @@
}
else
{
<div class="alert alert-info" role="alert">
<strong>What these grants do depends on
<span class="mono">Security:Acl:Enabled</span> on the node serving the data.</strong>
That is per-node configuration, so this page cannot tell you which mode a given node is in —
check the node's own settings.
<br />
<strong>With it on:</strong> every scope kind is honoured, and the scoped decision
<em>replaces</em> the server-wide role rather than adding to it — so a narrow grant works, and a
Cluster-scoped one no longer confers authority beyond that cluster.
<strong>Every data-plane operation is gated</strong> — <span class="mono">Read</span>,
<span class="mono">Browse</span>, <span class="mono">Subscribe</span>,
<span class="mono">WriteOperate</span>, <span class="mono">HistoryRead</span> and the alarm
methods, which separate into <span class="mono">AlarmAcknowledge</span> /
<span class="mono">AlarmConfirm</span> / <span class="mono">AlarmShelve</span>. A principal with
no grant at all can connect and see nothing.
<br />
<strong>With it off (the default):</strong> only <span class="mono">Cluster</span>-scoped grants
do anything, and they confer a <em>server-wide</em> role on their principal —
<span class="mono">WriteOperate</span> → writing any tag value;
<span class="mono">AlarmAcknowledge</span> → the Part 9 alarm methods
(acknowledge / confirm / shelve). Narrower grants are saved and shipped in every deployment
artifact but confer <strong>nothing</strong>. That under-granting is deliberate: honouring such
a grant's flags while its <em>scope</em> cannot be honoured would turn "WriteOperate on one
equipment" into server-wide write authority.
<br />
A node that merely <em>leads to</em> something you may browse stays browsable, or a narrow grant
could never be navigated to — so granting <span class="mono">Browse</span> deep in the tree also
reveals the names of the folders above it. Revoking a grant stops live subscriptions on the next
deployment, not just future requests.
<br />
Identity is an <strong>API key</strong>, not LDAP: a client presents an OPC UA UserName token
with username <span class="mono">apikey</span> and password
<span class="mono">otua_&lt;keyId&gt;_&lt;secret&gt;</span>, and the key resolves to the
principal named on these rows. Mint keys on the <span class="mono">/apikeys</span> page; LDAP
now covers AdminUI login only. See <span class="mono">docs/security.md</span>.
</div>
<section class="panel notice rise" style="animation-delay:.02s">
ACL rows grant principals specific <span class="mono">NodePermissions</span> on a scope
(the cluster, a namespace, an area, an equipment, a raw device, a tag). A principal is an API
key's principal, or the reserved <span class="mono">anonymous</span> principal that an
anonymous session authenticates as.
<br />
The <strong>Scope target</strong> column resolves each row against the current configuration. A
row marked <span class="chip chip-alert">unresolved</span> points at nothing — it is shipped in
every deployment artifact but authorizes nobody, and the deploy gate rejects it. Open it and
@@ -75,10 +33,7 @@ else
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">
No ACL rows for this cluster. With <span class="mono">Security:Acl:Enabled</span> off, that
means writes and alarm acknowledges are refused while reads, browses, subscriptions and
history reads stay open to any admitted session. With it <strong>on</strong>, it means
every data-plane operation is refused here — a client can connect and see nothing.
No ACL rows for this cluster.
</div>
}
else
@@ -20,9 +20,7 @@
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.
Latest @PageSize audit rows scoped to this cluster, newest first. Details JSON is shown verbatim.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
@@ -86,15 +86,17 @@ else
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
<div class="panel notice notice-alert mt-3">@_error</div>
}
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save changes
</button>
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary">Cancel</a>
<div class="btn-group" role="group">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save changes
</button>
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary">Cancel</a>
</div>
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">
Delete cluster
</button>
@@ -13,8 +13,7 @@
<div style="padding:1rem">
The <code>Namespace</code> entity has been removed. The v3 Raw/UNS split replaces it —
raw driver folders and the unified namespace are managed on the <strong>/raw</strong> and
<strong>/uns</strong> pages, arriving in v3 Batch 2/3. This page is a placeholder so existing
links do not 404.
<strong>/uns</strong> pages. This page is a placeholder so existing links do not 404.
</div>
</section>
@@ -26,7 +26,7 @@ else
<span class="mono text-muted">@_cluster.ClusterId</span>
@if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> }
</div>
<div class="d-flex gap-2">
<div class="btn-group btn-group-sm" role="group">
<a href="/clusters/@ClusterId/edit" class="btn btn-outline-secondary btn-sm">Edit cluster</a>
<a href="/deployments" class="btn btn-outline-primary btn-sm">Deployments</a>
</div>
@@ -34,15 +34,6 @@ else
<ClusterNav ClusterId="@ClusterId" ActiveTab="redundancy" />
<section class="panel notice rise" style="animation-delay:.02s">
v2 redundancy is computed at runtime by <span class="mono">RedundancyStateActor</span>
on each admin node. The values below are the static configuration; the resolved live
<span class="mono">ServiceLevel</span> for each peer is broadcast on the
<span class="mono">redundancy-state</span> DPS topic and consumed by the OPC UA host's
<span class="mono">ServerStatus</span> publisher. See
<a href="/docs/v2/Architecture-v2.md">docs/v2/Architecture-v2.md</a>.
</section>
<section class="card-grid rise mt-3" style="animation-delay:.08s">
<div class="metric-card">
<div class="panel-head">Cluster redundancy</div>
@@ -68,10 +59,7 @@ else
</div>
<p class="text-muted small mt-2 mb-0">
Read live from cluster state on this node. The Primary shown is
<strong>this application <span class="mono">Cluster</span>'s own Primary</strong> — since
the per-cluster mesh split, each cluster's redundant pair runs its own independent 2-node
Akka mesh and elects its own Primary. See <span class="mono">docs/Redundancy.md</span>.
Read live from cluster state on this node.
</p>
<AuthorizeView Policy="@ManualFailoverPageModel.RequiredPolicy">
@@ -98,8 +86,10 @@ else
<li>Its peer becomes Primary and advertises <span class="mono">ServiceLevel</span> 250.</li>
<li>Connected OPC UA clients re-select the new Primary.</li>
</ul>
<button class="btn btn-sm btn-danger" @onclick="ConfirmFailoverAsync">Confirm failover</button>
<button class="btn btn-sm btn-outline-secondary ms-2" @onclick="() => _failover.CancelFailover()">Cancel</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-sm btn-danger" @onclick="ConfirmFailoverAsync">Confirm failover</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => _failover.CancelFailover()">Cancel</button>
</div>
</div>
}
</Authorized>
@@ -15,7 +15,7 @@
<div style="padding:1rem">
The <code>Namespace</code> entity has been removed. The v3 Raw/UNS split replaces it —
raw driver folders and the unified namespace are managed on the <strong>/raw</strong> and
<strong>/uns</strong> pages, arriving in v3 Batch 2/3. This editor no longer applies.
<strong>/uns</strong> pages. This editor no longer applies.
</div>
</section>
@@ -69,11 +69,13 @@
}
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Create cluster
</button>
<a href="/clusters" class="btn btn-outline-secondary">Cancel</a>
<div class="btn-group" role="group">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Create cluster
</button>
<a href="/clusters" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</EditForm>
@@ -102,15 +102,17 @@ else
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
<div class="panel notice notice-alert mt-3">@_error</div>
}
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary">Cancel</a>
<div class="btn-group" role="group">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary">Cancel</a>
</div>
@if (!IsNew)
{
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">