173 lines
6.0 KiB
Plaintext
173 lines
6.0 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="mb-4">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)
|
|
{
|
|
<div class="alert alert-info">
|
|
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>
|
|
}
|
|
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>
|
|
</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>
|
|
<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>
|
|
<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>
|
|
</div>
|
|
|
|
<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>
|
|
}
|
|
|
|
@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>();
|
|
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)
|
|
.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" => "bg-success",
|
|
"Failed" => "bg-danger",
|
|
"Applying" => "bg-info",
|
|
_ => "bg-secondary",
|
|
};
|
|
|
|
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);
|
|
}
|