482d5f5637
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>
173 lines
6.2 KiB
Plaintext
173 lines
6.2 KiB
Plaintext
@page "/fleet"
|
|
@using Microsoft.EntityFrameworkCore
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
@inject IServiceScopeFactory ScopeFactory
|
|
@implements IDisposable
|
|
|
|
<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">
|
|
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
|
|
Refresh
|
|
</button>
|
|
<span class="text-muted small">
|
|
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
|
|
</span>
|
|
</div>
|
|
|
|
@if (_rows is null)
|
|
{
|
|
<p>Loading…</p>
|
|
}
|
|
else if (_rows.Count == 0)
|
|
{
|
|
<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.
|
|
</section>
|
|
}
|
|
else
|
|
{
|
|
<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="agg-card">
|
|
<div class="agg-label">Applied</div>
|
|
<div class="agg-value numeric">@_rows.Count(r => r.Status == "Applied")</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="agg-card alert">
|
|
<div class="agg-label">Failed</div>
|
|
<div class="agg-value numeric">@_rows.Count(r => r.Status == "Failed")</div>
|
|
</div>
|
|
</section>
|
|
|
|
<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 {
|
|
// Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees
|
|
// the most recent published state without polling ahead of the broadcaster.
|
|
private const int RefreshIntervalSeconds = 5;
|
|
|
|
private List<FleetNodeRow>? _rows;
|
|
private bool _refreshing;
|
|
private DateTime? _lastRefreshUtc;
|
|
private Timer? _timer;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await RefreshAsync();
|
|
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
|
|
state: null,
|
|
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
|
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
|
}
|
|
|
|
private async Task RefreshAsync()
|
|
{
|
|
if (_refreshing) return;
|
|
_refreshing = true;
|
|
try
|
|
{
|
|
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 { 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;
|
|
}
|
|
finally
|
|
{
|
|
_refreshing = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private static bool IsStale(FleetNodeRow r)
|
|
{
|
|
if (r.SeenAt is null) return true;
|
|
return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30);
|
|
}
|
|
|
|
private static string RowClass(FleetNodeRow r) => r.Status switch
|
|
{
|
|
"Failed" => "table-danger",
|
|
_ when IsStale(r) => "table-warning",
|
|
_ => "",
|
|
};
|
|
|
|
private static string StatusBadge(string? status) => status switch
|
|
{
|
|
"Applied" => "chip-ok",
|
|
"Failed" => "chip-bad",
|
|
"Applying" => "chip-idle",
|
|
_ => "chip-idle",
|
|
};
|
|
|
|
private static string FormatAge(DateTime? t)
|
|
{
|
|
if (t is null) return "—";
|
|
var age = DateTime.UtcNow - t.Value;
|
|
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
|
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
|
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
|
return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
|
}
|
|
|
|
public void Dispose() => _timer?.Dispose();
|
|
|
|
internal sealed record FleetNodeRow(
|
|
string NodeId, string ClusterId, long? GenerationId,
|
|
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
|
|
}
|