feat: restyle Admin UI with the technical-light design system

Adopt the technical-light design system across the Admin web UI:

- Vendor theme.css + IBM Plex woff2 fonts into wwwroot; include
  theme.css globally after Bootstrap.
- Rebuild MainLayout: top app-bar (brand mark, breadcrumb, connection
  pill) + hairline-ruled side rail with accent-bordered active link.
- Convert all 33 pages to the component catalog — tables to
  panel + data-table (num/mono columns), KPI cards to agg-grid,
  detail blocks to metric-card/kv rows, badges to chips, alerts to
  panel notice, headings to page-title/panel-head, .rise reveals.
- Buttons/forms stay on Bootstrap; theme.css restyles them via
  --bs-* overrides. View-specific layout lives in app.css; all
  colour/type comes from theme.css tokens.

Also fix a pre-existing /fleet 500: the node-state query ordered on
a property of a constructed FleetNodeRow record, which EF Core
cannot translate. Order the join's columns before projecting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 02:20:09 -04:00
parent 31b9468102
commit 482d5f5637
40 changed files with 1837 additions and 1206 deletions

View File

@@ -7,18 +7,18 @@
@inject NavigationManager Nav
@implements IAsyncDisposable
<h4>Redundancy topology</h4>
<h4 class="panel-head">Redundancy topology</h4>
@if (_roleChangedBanner is not null)
{
<div class="alert alert-info small mb-2">@_roleChangedBanner</div>
<section class="panel notice rise" style="animation-delay:.02s">@_roleChangedBanner</section>
}
<p class="text-muted small">
One row per <code>ClusterNode</code> in this cluster. Role, <code>ApplicationUri</code>,
and <code>ServiceLevelBase</code> are authored separately; the Admin UI shows them read-only
One row per <span class="mono">ClusterNode</span> in this cluster. Role, <span class="mono">ApplicationUri</span>,
and <span class="mono">ServiceLevelBase</span> are authored separately; the Admin UI shows them read-only
here so operators can confirm the published topology without touching it. LastSeen older than
@((int)ClusterNodeService.StaleThreshold.TotalSeconds)s is flagged Stale — the node has
stopped heart-beating and is likely down. Role swap goes through the server-side
<code>RedundancyCoordinator</code> apply-lease flow, not direct DB edits.
<span class="mono">RedundancyCoordinator</span> apply-lease flow, not direct DB edits.
</p>
@if (_nodes is null)
@@ -27,10 +27,10 @@
}
else if (_nodes.Count == 0)
{
<div class="alert alert-warning">
<section class="panel notice rise" style="animation-delay:.02s">
No ClusterNode rows for this cluster. The server process needs at least one entry
(with a non-blank <code>ApplicationUri</code>) before it can start up per OPC UA spec.
</div>
(with a non-blank <span class="mono">ApplicationUri</span>) before it can start up per OPC UA spec.
</section>
}
else
{
@@ -39,76 +39,80 @@ else
var standalone = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Standalone);
var staleCount = _nodes.Count(ClusterNodeService.IsStale);
<div class="row g-3 mb-4">
<div class="col-md-3"><div class="card"><div class="card-body">
<h6 class="text-muted mb-1">Nodes</h6>
<div class="fs-3">@_nodes.Count</div>
</div></div></div>
<div class="col-md-3"><div class="card border-success"><div class="card-body">
<h6 class="text-muted mb-1">Primary</h6>
<div class="fs-3 text-success">@primaries</div>
</div></div></div>
<div class="col-md-3"><div class="card border-info"><div class="card-body">
<h6 class="text-muted mb-1">Secondary</h6>
<div class="fs-3 text-info">@secondaries</div>
</div></div></div>
<div class="col-md-3"><div class="card @(staleCount > 0 ? "border-warning" : "")"><div class="card-body">
<h6 class="text-muted mb-1">Stale</h6>
<div class="fs-3 @(staleCount > 0 ? "text-warning" : "")">@staleCount</div>
</div></div></div>
</div>
<section class="agg-grid rise" style="animation-delay:.02s">
<div class="agg-card">
<div class="agg-label">Nodes</div>
<div class="agg-value numeric">@_nodes.Count</div>
</div>
<div class="agg-card">
<div class="agg-label">Primary</div>
<div class="agg-value numeric @(primaries > 0 ? "s-ok" : "")">@primaries</div>
</div>
<div class="agg-card">
<div class="agg-label">Secondary</div>
<div class="agg-value numeric">@secondaries</div>
</div>
<div class="agg-card">
<div class="agg-label">Stale</div>
<div class="agg-value numeric @(staleCount > 0 ? "s-warn" : "")">@staleCount</div>
</div>
</section>
@if (primaries == 0 && standalone == 0)
{
<div class="alert alert-danger small mb-3">
No Primary or Standalone node — the cluster has no authoritative write target. Secondaries
stay read-only until one of them gets promoted via <code>RedundancyCoordinator</code>.
</div>
<section class="panel notice rise" style="animation-delay:.08s">
<span class="s-bad">No Primary or Standalone node — the cluster has no authoritative write target. Secondaries
stay read-only until one of them gets promoted via <span class="mono">RedundancyCoordinator</span>.</span>
</section>
}
else if (primaries > 1)
{
<div class="alert alert-danger small mb-3">
<strong>Split-brain:</strong> @primaries nodes claim the Primary role. Apply-lease
<section class="panel notice rise" style="animation-delay:.08s">
<span class="s-bad"><strong>Split-brain:</strong> @primaries nodes claim the Primary role. Apply-lease
enforcement should have made this impossible at the coordinator level. Investigate
immediately — one of the rows was likely hand-edited.
</div>
immediately — one of the rows was likely hand-edited.</span>
</section>
}
<table class="table table-sm table-hover align-middle">
<thead>
<tr>
<th>Node</th>
<th>Role</th>
<th>Host</th>
<th class="text-end">OPC UA port</th>
<th class="text-end">ServiceLevel base</th>
<th>ApplicationUri</th>
<th>Enabled</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
@foreach (var n in _nodes)
{
<tr class="@RowClass(n)">
<td><code>@n.NodeId</code></td>
<td><span class="badge @RoleBadge(n.RedundancyRole)">@n.RedundancyRole</span></td>
<td>@n.Host</td>
<td class="text-end"><code>@n.OpcUaPort</code></td>
<td class="text-end">@n.ServiceLevelBase</td>
<td class="small text-break"><code>@n.ApplicationUri</code></td>
<td>
@if (n.Enabled) { <span class="badge bg-success">Enabled</span> }
else { <span class="badge bg-secondary">Disabled</span> }
</td>
<td class="small @(ClusterNodeService.IsStale(n) ? "text-warning fw-bold" : "")">
@(n.LastSeenAt is null ? "never" : FormatAge(n.LastSeenAt.Value))
@if (ClusterNodeService.IsStale(n)) { <span class="badge bg-warning text-dark ms-1">Stale</span> }
</td>
</tr>
}
</tbody>
</table>
<section class="panel rise" style="animation-delay:.14s">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Node</th>
<th>Role</th>
<th>Host</th>
<th class="num">OPC UA port</th>
<th class="num">ServiceLevel base</th>
<th>ApplicationUri</th>
<th>Enabled</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
@foreach (var n in _nodes)
{
<tr>
<td><span class="mono">@n.NodeId</span></td>
<td><span class="chip @RoleBadge(n.RedundancyRole)">@n.RedundancyRole</span></td>
<td>@n.Host</td>
<td class="num mono">@n.OpcUaPort</td>
<td class="num">@n.ServiceLevelBase</td>
<td class="mono">@n.ApplicationUri</td>
<td>
@if (n.Enabled) { <span class="chip chip-ok">Enabled</span> }
else { <span class="chip chip-idle">Disabled</span> }
</td>
<td class="@(ClusterNodeService.IsStale(n) ? "s-warn" : "")">
@(n.LastSeenAt is null ? "never" : FormatAge(n.LastSeenAt.Value))
@if (ClusterNodeService.IsStale(n)) { <span class="chip chip-warn ms-1">Stale</span> }
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@code {
@@ -158,10 +162,10 @@ else
private static string RoleBadge(RedundancyRole r) => r switch
{
RedundancyRole.Primary => "bg-success",
RedundancyRole.Secondary => "bg-info",
RedundancyRole.Standalone => "bg-primary",
_ => "bg-secondary",
RedundancyRole.Primary => "chip-ok",
RedundancyRole.Secondary => "chip-idle",
RedundancyRole.Standalone => "chip-idle",
_ => "chip-idle",
};
private static string FormatAge(DateTime t)