style(adminui): btn-groups + prose trim on top-level ops pages

Group related action buttons into btn-group/btn-group-sm on Alerts, Certificates,
ApiKeys and ScriptEdit; promote the RoleGrants Delete from btn-link to
btn-outline-danger. Replace the two inline style="border-color:var(--alert)"
no-ops with the .notice-alert class, and the undefined --surface-2 token in
Scripts with the theme's shipped --zebra-bg.

Delete unconditional documentation prose now covered by docs/ (Redundancy,
Telemetry, AlarmTracking, security, Reservations, AlarmHistorian, ScriptEditor),
keeping state banners and live captions. Correct the LDAP config key on
RoleGrants to Security:Ldap:GroupToRole (LdapOptions.SectionName) and drop the
same wrong key from Account with its deleted block; reword the stale
"Engine emit (F8/F9) is pending" script-log empty state.
This commit is contained in:
Joseph Doherty
2026-08-10 16:32:29 -04:00
parent 4d549571cc
commit 3d6d28e5a2
11 changed files with 60 additions and 109 deletions
@@ -69,10 +69,7 @@
</section>
<section class="panel notice rise" style="animation-delay:.08s">
Fleet roles come from LDAP group membership via the
<span class="mono">Authentication:Ldap:GroupToRole</span> mapping. To change them,
edit the LDAP group on the directory server; the next sign-in picks up the change.
Sign out + sign back in to refresh the cookie claim.
Sign out and sign back in to refresh the cookie claim after a role change.
</section>
</Authorized>
</AuthorizeView>
@@ -15,13 +15,7 @@
<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">LocalDbStoreAndForwardSink</span> — buffering into this node's LocalDb, and
draining to the HistorianGateway (<span class="mono">SendEvent</span>) only while this node holds
the Primary role. Polling every @PollSeconds s.
</section>
<div class="text-muted small mb-3">Polling every @PollSeconds s.</div>
@if (_status is null)
{
@@ -25,9 +25,7 @@
</div>
<section class="panel notice rise" style="animation-delay:.02s">
Live alarm transitions from the cluster's <span class="mono">alerts</span> DPS topic. Shows
the most-recent @Capacity entries since the page opened; reload for a fresh window. Sources:
ScriptedAlarmActor, native driver alarm bridges (AB CIP ALMD, Galaxy where wired).
Shows the most-recent @Capacity entries since the page opened; reload for a fresh window.
</section>
@if (_rows.Count == 0)
@@ -91,30 +89,34 @@ else
'alarm-commands' topic; the owning node applies it (ownership filter). *@
<td>
<div class="d-flex gap-1 align-items-center">
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@onclick="() => AcknowledgeAsync(e.AlarmId)"
title="Acknowledge this alarm">Ack</button>
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@onclick="() => ShelveAsync(e.AlarmId, ShelveKind.OneShot)"
title="Shelve this alarm until it next clears">Shelve</button>
<div class="btn-group btn-group-sm" role="group">
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@onclick="() => AcknowledgeAsync(e.AlarmId)"
title="Acknowledge this alarm">Ack</button>
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@onclick="() => ShelveAsync(e.AlarmId, ShelveKind.OneShot)"
title="Shelve this alarm until it next clears">Shelve</button>
</div>
<input type="number" min="1" class="form-control form-control-sm" style="width:4.5rem"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@bind:get="GetShelveMinutes(e.AlarmId)" @bind:set="m => SetShelveMinutes(e.AlarmId, m)"
title="Timed-shelve duration in minutes" />
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@onclick="() => ShelveTimedAsync(e.AlarmId)"
title="Shelve this alarm for the chosen number of minutes">Shelve (timed)</button>
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@onclick="() => ShelveAsync(e.AlarmId, ShelveKind.Unshelve)"
title="Remove an existing shelve">Unshelve</button>
<div class="btn-group btn-group-sm" role="group">
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@onclick="() => ShelveTimedAsync(e.AlarmId)"
title="Shelve this alarm for the chosen number of minutes">Shelve (timed)</button>
<button type="button"
class="btn btn-sm btn-outline-secondary"
disabled="@_busyAlarmId.Equals(e.AlarmId)"
@onclick="() => ShelveAsync(e.AlarmId, ShelveKind.Unshelve)"
title="Remove an existing shelve">Unshelve</button>
</div>
</div>
@if (_opResultAlarmId.Equals(e.AlarmId) && _opResultMessage is not null)
{
@@ -9,19 +9,6 @@
<h4 class="mb-0">OPC UA API keys</h4>
</div>
<div class="alert alert-info" role="alert">
The OPC UA data plane is machine-to-machine. A client connects with an OPC UA
<strong>UserName</strong> token whose username is the literal <span class="mono">apikey</span> and
whose password is the key below. The key resolves to a <strong>principal</strong>; ACL rows naming
that principal are what grant it permissions.
<br />
<strong>Revocation is not uniform across the fleet.</strong> A node with a config database (a fused
admin+driver node such as this one) applies it <em>immediately</em>, because it reads keys per
request. A <strong>driver-only</strong> node has no config database, so its keys travel inside the
deployment artifact and a revocation reaches it only on the <strong>next deployment</strong>.
Assume the slower behaviour when revoking a compromised key.
</div>
@if (_created is not null)
{
<div class="alert alert-success" role="alert">
@@ -111,12 +98,14 @@
<td>@k.CreatedUtc.ToString("yyyy-MM-dd HH:mm")</td>
<td>@(k.ExpiresUtc?.ToString("yyyy-MM-dd") ?? "—")</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => SetEnabledAsync(k.ApiKeyId, !k.Enabled)" disabled="@_busy">
@(k.Enabled ? "Revoke" : "Re-enable")
</button>
<button class="btn btn-sm btn-outline-danger"
@onclick="() => DeleteAsync(k.ApiKeyId)" disabled="@_busy">Delete</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => SetEnabledAsync(k.ApiKeyId, !k.Enabled)" disabled="@_busy">
@(k.Enabled ? "Revoke" : "Re-enable")
</button>
<button class="btn btn-sm btn-outline-danger"
@onclick="() => DeleteAsync(k.ApiKeyId)" disabled="@_busy">Delete</button>
</div>
</td>
</tr>
}
@@ -15,13 +15,6 @@
<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>
@@ -30,14 +23,16 @@ else
{
@if (_statusMsg is not null)
{
<section class="panel notice rise mt-3" style="@(_statusError ? "border-color:var(--alert)" : null)">@_statusMsg</section>
<section class="panel notice rise mt-3 @(_statusError ? "notice-alert" : null)">@_statusMsg</section>
}
@if (_pending is { } p)
{
<section class="panel notice rise mt-3">
Confirm <strong>@p.Verb</strong> of <span class="mono small">@p.Subject</span>?
<button class="btn btn-sm btn-primary" @onclick="ConfirmAction">Confirm</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAction">Cancel</button>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-sm btn-primary" @onclick="ConfirmAction">Confirm</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAction">Cancel</button>
</div>
</section>
}
@foreach (var store in _rows)
@@ -89,15 +84,17 @@ else
<td>
<AuthorizeView Policy="@AdminUiPolicies.FleetAdmin">
<Authorized>
@if (store.Kind == StoreKind.Rejected)
{
<button class="btn btn-sm btn-outline-primary" @onclick='() => RequestAction(store.Kind, c, "trust")'>Trust</button>
}
else
{
<button class="btn btn-sm btn-outline-secondary" @onclick='() => RequestAction(store.Kind, c, "untrust")'>Untrust</button>
}
<button class="btn btn-sm btn-outline-danger" @onclick='() => RequestAction(store.Kind, c, "delete")'>Delete</button>
<div class="btn-group btn-group-sm" role="group">
@if (store.Kind == StoreKind.Rejected)
{
<button class="btn btn-sm btn-outline-primary" @onclick='() => RequestAction(store.Kind, c, "trust")'>Trust</button>
}
else
{
<button class="btn btn-sm btn-outline-secondary" @onclick='() => RequestAction(store.Kind, c, "untrust")'>Untrust</button>
}
<button class="btn btn-sm btn-outline-danger" @onclick='() => RequestAction(store.Kind, c, "delete")'>Delete</button>
</div>
</Authorized>
</AuthorizeView>
</td>
@@ -35,13 +35,6 @@
</span>
</div>
<section class="panel notice rise" style="animation-delay:.02s">
Each row is one Akka cluster member identified by <span class="mono">host:port</span>. Roles
drive which actors run on which node — <span class="mono">admin</span> nodes host the
control-plane singletons, <span class="mono">driver</span> nodes host the per-node runtime
actors. The leader columns identify which member currently owns each role's singletons.
</section>
@if (_rows is null)
{
<p>Loading…</p>
@@ -125,12 +118,6 @@ else
</section>
}
<section class="panel notice rise" style="animation-delay:.16s">
Driver health is keyed per driver instance <em>across the cluster</em>, not per Akka member —
the health feed carries no per-member identity. These rows are therefore <strong>cluster-scoped</strong>:
one entry per configured driver instance, grouped under its cluster.
</section>
@if (_driverGroups is null)
{
<p>Loading…</p>
@@ -16,12 +16,6 @@
}
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)
@@ -64,7 +64,7 @@
<tr>
<td><span class="mono">@r.LdapGroup</span></td>
<td><span class="chip chip-idle">@r.Role</span></td>
<td><button class="btn btn-sm btn-link text-danger" @onclick="() => DeleteAsync(r.Id)" disabled="@_busy">Delete</button></td>
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(r.Id)" disabled="@_busy">Delete</button></td>
</tr>
}
}
@@ -78,7 +78,7 @@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Fallback (appsettings) (@(_options.GroupToRole?.Count ?? 0))</div>
<div style="padding:1rem 1rem 0" class="text-muted small">
These <span class="mono">Authentication:Ldap:GroupToRole</span> entries apply when a group has no database row above.
These <span class="mono">Security:Ldap:GroupToRole</span> entries apply when a group has no database row above.
</div>
@if (_options.GroupToRole is null || _options.GroupToRole.Count == 0)
{
@@ -60,11 +60,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="/scripts" 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="/scripts" 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>
@@ -28,17 +28,12 @@
</div>
</div>
<section class="panel notice rise" style="animation-delay:.02s">
Live tail of <span class="mono">script-logs</span> DPS topic, capped at @Capacity entries.
Filter by minimum level + script ID. Sources: VirtualTagActor (F8), ScriptedAlarmActor (F9).
</section>
@if (VisibleRows.Count == 0)
{
<section class="panel notice rise mt-3" style="animation-delay:.08s">
@if (_rows.Count == 0)
{
<span>No script-log entries yet. Engine emit (F8/F9) is pending.</span>
<span>No script-log entries yet.</span>
}
else
{
@@ -17,12 +17,6 @@
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
Scripts are fleet-wide C# expression compilations referenced by virtual tags and scripted
alarms. The editor provides Roslyn-backed IntelliSense — completions, diagnostics, hover,
and tag-path suggestions for <code>ctx.GetTag</code> / <code>ctx.SetVirtualTag</code> literals.
</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)
@@ -44,7 +38,7 @@ else
<div class="d-flex mb-2">
<a href="/scripts/@s.ScriptId" class="btn btn-sm btn-outline-primary">Edit</a>
</div>
<pre class="mono small" style="background:var(--surface-2);padding:1rem;border-radius:4px;overflow:auto">@s.SourceCode</pre>
<pre class="mono small" style="background:var(--zebra-bg);padding:1rem;border-radius:4px;overflow:auto">@s.SourceCode</pre>
</div>
</details>
}