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 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"> <EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<ValidationSummary class="alert alert-danger" /> <ValidationSummary class="alert alert-danger" />
@@ -151,13 +125,12 @@ else
</div> </div>
<div class="form-text mt-2"> <div class="form-text mt-2">
Bundles: Bundles:
<button type="button" class="btn btn-sm btn-link p-0 ms-1" @onclick="() => _form.PermissionFlags = NodePermissions.ReadOnly">ReadOnly</button> <div class="btn-group btn-group-sm ms-1" role="group">
&middot; <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-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Operator">Operator</button> <button type="button" class="btn btn-sm btn-outline-secondary" @onclick="() => _form.PermissionFlags = NodePermissions.Operator">Operator</button>
&middot; <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-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Engineer">Engineer</button> <button type="button" class="btn btn-sm btn-outline-secondary" @onclick="() => _form.PermissionFlags = NodePermissions.Admin">Admin</button>
&middot; </div>
<button type="button" class="btn btn-sm btn-link p-0" @onclick="() => _form.PermissionFlags = NodePermissions.Admin">Admin</button>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@@ -167,11 +140,13 @@ else
</div> </div>
</section> </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"> <div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button> <div class="btn-group" role="group">
<a href="/clusters/@ClusterId/acls" class="btn btn-outline-secondary">Cancel</a> <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> } @if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
</div> </div>
</EditForm> </EditForm>
@@ -21,49 +21,7 @@
} }
else 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"> <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 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 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 every deployment artifact but authorizes nobody, and the deploy gate rejects it. Open it and
@@ -75,10 +33,7 @@ else
@if (_rows.Count == 0) @if (_rows.Count == 0)
{ {
<div style="padding:1rem" class="text-muted"> <div style="padding:1rem" class="text-muted">
No ACL rows for this cluster. With <span class="mono">Security:Acl:Enabled</span> off, that No ACL rows for this cluster.
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.
</div> </div>
} }
else else
@@ -20,9 +20,7 @@
else else
{ {
<section class="panel notice rise" style="animation-delay:.02s"> <section class="panel notice rise" style="animation-delay:.02s">
Latest @PageSize audit rows scoped to this cluster, newest first. EventId/CorrelationId Latest @PageSize audit rows scoped to this cluster, newest first. Details JSON is shown verbatim.
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>
<section class="panel rise mt-3" style="animation-delay:.08s"> <section class="panel rise mt-3" style="animation-delay:.08s">
@@ -86,15 +86,17 @@ else
@if (!string.IsNullOrWhiteSpace(_error)) @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"> <div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy"> <div class="btn-group" role="group">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> } <button type="submit" class="btn btn-primary" disabled="@_busy">
Save changes @if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
</button> Save changes
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary">Cancel</a> </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"> <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">
Delete cluster Delete cluster
</button> </button>
@@ -13,8 +13,7 @@
<div style="padding:1rem"> <div style="padding:1rem">
The <code>Namespace</code> entity has been removed. The v3 Raw/UNS split replaces it — 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 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 <strong>/uns</strong> pages. This page is a placeholder so existing links do not 404.
links do not 404.
</div> </div>
</section> </section>
@@ -26,7 +26,7 @@ else
<span class="mono text-muted">@_cluster.ClusterId</span> <span class="mono text-muted">@_cluster.ClusterId</span>
@if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> } @if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> }
</div> </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="/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> <a href="/deployments" class="btn btn-outline-primary btn-sm">Deployments</a>
</div> </div>
@@ -34,15 +34,6 @@ else
<ClusterNav ClusterId="@ClusterId" ActiveTab="redundancy" /> <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"> <section class="card-grid rise mt-3" style="animation-delay:.08s">
<div class="metric-card"> <div class="metric-card">
<div class="panel-head">Cluster redundancy</div> <div class="panel-head">Cluster redundancy</div>
@@ -68,10 +59,7 @@ else
</div> </div>
<p class="text-muted small mt-2 mb-0"> <p class="text-muted small mt-2 mb-0">
Read live from cluster state on this node. The Primary shown is Read live from cluster state on this node.
<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>.
</p> </p>
<AuthorizeView Policy="@ManualFailoverPageModel.RequiredPolicy"> <AuthorizeView Policy="@ManualFailoverPageModel.RequiredPolicy">
@@ -98,8 +86,10 @@ else
<li>Its peer becomes Primary and advertises <span class="mono">ServiceLevel</span> 250.</li> <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> <li>Connected OPC UA clients re-select the new Primary.</li>
</ul> </ul>
<button class="btn btn-sm btn-danger" @onclick="ConfirmFailoverAsync">Confirm failover</button> <div class="btn-group btn-group-sm" role="group">
<button class="btn btn-sm btn-outline-secondary ms-2" @onclick="() => _failover.CancelFailover()">Cancel</button> <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> </div>
} }
</Authorized> </Authorized>
@@ -15,7 +15,7 @@
<div style="padding:1rem"> <div style="padding:1rem">
The <code>Namespace</code> entity has been removed. The v3 Raw/UNS split replaces it — 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 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> </div>
</section> </section>
@@ -69,11 +69,13 @@
} }
<div class="mt-3 d-flex gap-2"> <div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy"> <div class="btn-group" role="group">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> } <button type="submit" class="btn btn-primary" disabled="@_busy">
Create cluster @if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
</button> Create cluster
<a href="/clusters" class="btn btn-outline-secondary">Cancel</a> </button>
<a href="/clusters" class="btn btn-outline-secondary">Cancel</a>
</div>
</div> </div>
</EditForm> </EditForm>
@@ -102,15 +102,17 @@ else
@if (!string.IsNullOrWhiteSpace(_error)) @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"> <div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy"> <div class="btn-group" role="group">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> } <button type="submit" class="btn btn-primary" disabled="@_busy">
@(IsNew ? "Create" : "Save changes") @if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
</button> @(IsNew ? "Create" : "Save changes")
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary">Cancel</a> </button>
<a href="/clusters/@ClusterId" class="btn btn-outline-secondary">Cancel</a>
</div>
@if (!IsNew) @if (!IsNew)
{ {
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy"> <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">