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
@@ -5,7 +5,7 @@
@inject IServiceScopeFactory ScopeFactory
@implements IDisposable
<h1 class="mb-4">Fleet status</h1>
<h1 class="page-title">Fleet status</h1>
<div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
@@ -23,69 +23,66 @@
}
else if (_rows.Count == 0)
{
<div class="alert alert-info">
<section class="panel notice" style="animation-delay:.02s">
No node state recorded yet. Nodes publish their state to the central DB on each poll; if
this list is empty, either no nodes have been registered or the poller hasn't run yet.
</div>
</section>
}
else
{
<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">@_rows.Count</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">@_rows.Count</div>
</div>
<div class="col-md-3">
<div class="card border-success"><div class="card-body">
<h6 class="text-muted mb-1">Applied</h6>
<div class="fs-3 text-success">@_rows.Count(r => r.Status == "Applied")</div>
</div></div>
<div class="agg-card">
<div class="agg-label">Applied</div>
<div class="agg-value numeric">@_rows.Count(r => r.Status == "Applied")</div>
</div>
<div class="col-md-3">
<div class="card border-warning"><div class="card-body">
<h6 class="text-muted mb-1">Stale</h6>
<div class="fs-3 text-warning">@_rows.Count(r => IsStale(r))</div>
</div></div>
<div class="agg-card caution">
<div class="agg-label">Stale</div>
<div class="agg-value numeric">@_rows.Count(r => IsStale(r))</div>
</div>
<div class="col-md-3">
<div class="card border-danger"><div class="card-body">
<h6 class="text-muted mb-1">Failed</h6>
<div class="fs-3 text-danger">@_rows.Count(r => r.Status == "Failed")</div>
</div></div>
<div class="agg-card alert">
<div class="agg-label">Failed</div>
<div class="agg-value numeric">@_rows.Count(r => r.Status == "Failed")</div>
</div>
</div>
</section>
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Node</th>
<th>Cluster</th>
<th>Generation</th>
<th>Status</th>
<th>Last applied</th>
<th>Last seen</th>
<th>Error</th>
</tr>
</thead>
<tbody>
@foreach (var r in _rows)
{
<tr class="@RowClass(r)">
<td><code>@r.NodeId</code></td>
<td>@r.ClusterId</td>
<td>@(r.GenerationId?.ToString() ?? "—")</td>
<td>
<span class="badge @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
</td>
<td>@FormatAge(r.AppliedAt)</td>
<td class="@(IsStale(r) ? "text-warning" : "")">@FormatAge(r.SeenAt)</td>
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
</tr>
}
</tbody>
</table>
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Nodes</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Node</th>
<th>Cluster</th>
<th class="num">Generation</th>
<th>Status</th>
<th>Last applied</th>
<th>Last seen</th>
<th>Error</th>
</tr>
</thead>
<tbody>
@foreach (var r in _rows)
{
<tr>
<td><span class="mono">@r.NodeId</span></td>
<td>@r.ClusterId</td>
<td class="num">@(r.GenerationId?.ToString() ?? "—")</td>
<td>
<span class="chip @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
</td>
<td>@FormatAge(r.AppliedAt)</td>
<td>@FormatAge(r.SeenAt)</td>
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@code {
@@ -115,13 +112,16 @@ else
{
using var scope = ScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
// Order on the join's plain columns before projecting into FleetNodeRow —
// EF Core cannot translate OrderBy over a property of a constructed record.
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow(
s.NodeId, n.ClusterId, s.CurrentGenerationId,
s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null,
s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt))
.OrderBy(r => r.ClusterId)
.ThenBy(r => r.NodeId)
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new { s, n })
.OrderBy(x => x.n.ClusterId)
.ThenBy(x => x.s.NodeId)
.Select(x => new FleetNodeRow(
x.s.NodeId, x.n.ClusterId, x.s.CurrentGenerationId,
x.s.LastAppliedStatus != null ? x.s.LastAppliedStatus.ToString() : null,
x.s.LastAppliedError, x.s.LastAppliedAt, x.s.LastSeenAt))
.ToListAsync();
_rows = rows;
_lastRefreshUtc = DateTime.UtcNow;
@@ -148,10 +148,10 @@ else
private static string StatusBadge(string? status) => status switch
{
"Applied" => "bg-success",
"Failed" => "bg-danger",
"Applying" => "bg-info",
_ => "bg-secondary",
"Applied" => "chip-ok",
"Failed" => "chip-bad",
"Applying" => "chip-idle",
_ => "chip-idle",
};
private static string FormatAge(DateTime? t)