Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
@if (_rows is null)
{
Loading…
}
else if (_rows.Count == 0)
{
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.
}
else
{
Nodes
@_rows.Count
Applied
@_rows.Count(r => r.Status == "Applied")
Stale
@_rows.Count(r => IsStale(r))
Failed
@_rows.Count(r => r.Status == "Failed")
Nodes
Node
Cluster
Generation
Status
Last applied
Last seen
Error
@foreach (var r in _rows)
{
@r.NodeId
@r.ClusterId
@(r.GenerationId?.ToString() ?? "—")
@(r.Status ?? "—")
@FormatAge(r.AppliedAt)
@FormatAge(r.SeenAt)
@r.Error
}
}
@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? _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();
// 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);
}