221 lines
9.6 KiB
Plaintext
221 lines
9.6 KiB
Plaintext
@page "/fleet"
|
|
@* Per-node deployment status. v2 reads NodeDeploymentState (the per-(node, deployment) apply
|
|
progress row owned by each DriverHostActor) and projects the most-recent row per node. The
|
|
Akka cluster topology comes from IClusterRoleInfo so we can show nodes that haven't applied
|
|
anything yet alongside nodes that have. *@
|
|
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
|
@rendermode RenderMode.InteractiveServer
|
|
@using Microsoft.EntityFrameworkCore
|
|
@using ZB.MOM.WW.OtOpcUa.Cluster
|
|
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
|
@inject IClusterRoleInfo Cluster
|
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
@implements IAsyncDisposable
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h4 class="mb-0">Fleet status</h4>
|
|
</div>
|
|
|
|
<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 rise" style="animation-delay:.02s">
|
|
No driver-role nodes are currently Up in the Akka cluster, and no NodeDeploymentState
|
|
rows have been recorded yet. Either no driver nodes have joined or the cluster is still
|
|
forming.
|
|
</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 == NodeDeploymentStatus.Applied)</div>
|
|
</div>
|
|
<div class="agg-card caution">
|
|
<div class="agg-label">Applying</div>
|
|
<div class="agg-value numeric">@_rows.Count(r => r.Status == NodeDeploymentStatus.Applying)</div>
|
|
</div>
|
|
<div class="agg-card alert">
|
|
<div class="agg-label">Failed</div>
|
|
<div class="agg-value numeric">@_rows.Count(r => r.Status == NodeDeploymentStatus.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>Roles</th>
|
|
<th>Status</th>
|
|
<th>Applied at</th>
|
|
<th>Started at</th>
|
|
<th>Failure reason</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var r in _rows)
|
|
{
|
|
<tr>
|
|
<td><span class="mono">@r.NodeId</span></td>
|
|
<td>
|
|
@foreach (var role in r.Roles)
|
|
{
|
|
<span class="chip chip-idle me-1">@role</span>
|
|
}
|
|
</td>
|
|
<td><span class="chip @StatusChipClass(r.Status)">@StatusLabel(r.Status)</span></td>
|
|
<td>@(r.AppliedAtUtc?.ToString("u") ?? "—")</td>
|
|
<td>@(r.StartedAtUtc?.ToString("u") ?? "—")</td>
|
|
<td title="@(r.FailureReason ?? "")">@(string.IsNullOrEmpty(r.FailureReason) ? "" : DisplayText.Abbreviate(r.FailureReason, 80))</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
@code {
|
|
private const int RefreshIntervalSeconds = 10;
|
|
|
|
private List<NodeRow>? _rows;
|
|
private bool _refreshing;
|
|
private DateTime? _lastRefreshUtc;
|
|
private Timer? _timer;
|
|
|
|
// Overlap guard: a tick whose DB query outruns the refresh interval must not stack behind the
|
|
// next one. A manual Refresh landing on top of an in-flight tick is a no-op by the same guard.
|
|
private int _tickRunning;
|
|
|
|
// Page-lifetime cancellation: cancelled in DisposeAsync() so a tick in flight when the circuit
|
|
// goes away aborts its query instead of running it to completion against a component that's gone.
|
|
private readonly CancellationTokenSource _cts = new();
|
|
|
|
// Set first thing in DisposeAsync. A tick that resumes after teardown must not call
|
|
// StateHasChanged — the renderer is gone and it throws, inside a task nobody awaits.
|
|
private volatile bool _disposed;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await LoadAsync();
|
|
_timer = new Timer(_ => _ = InvokeAsync(LoadAsync), null,
|
|
TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
|
TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
|
}
|
|
|
|
private async Task RefreshAsync() => await LoadAsync();
|
|
|
|
private async Task LoadAsync()
|
|
{
|
|
if (Interlocked.Exchange(ref _tickRunning, 1) == 1) return;
|
|
try
|
|
{
|
|
_refreshing = true;
|
|
StateHasChanged();
|
|
await using var db = await DbFactory.CreateDbContextAsync(_cts.Token);
|
|
// Project the most-recent NodeDeploymentState per node — that's the row the
|
|
// DriverHostActor most recently touched, regardless of which deployment it was for.
|
|
var states = await db.NodeDeploymentStates.AsNoTracking()
|
|
.GroupBy(s => s.NodeId)
|
|
.Select(g => g.OrderByDescending(s => s.StartedAtUtc).First())
|
|
.ToListAsync(_cts.Token);
|
|
var byNode = states.ToDictionary(s => s.NodeId);
|
|
|
|
// Union with current Akka driver members so a freshly-joined node that has no
|
|
// NodeDeploymentState row yet still appears as "waiting".
|
|
var akkaDrivers = Cluster.MembersWithRole(RoleParser.Driver)
|
|
.Select(n => n.Value).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var allNodes = byNode.Keys.Union(akkaDrivers, StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList();
|
|
|
|
_rows = allNodes.Select(nodeId =>
|
|
{
|
|
byNode.TryGetValue(nodeId, out var state);
|
|
return new NodeRow(
|
|
NodeId: nodeId,
|
|
Roles: akkaDrivers.Contains(nodeId) ? new[] { RoleParser.Driver } : Array.Empty<string>(),
|
|
Status: state?.Status,
|
|
StartedAtUtc: state?.StartedAtUtc,
|
|
AppliedAtUtc: state?.AppliedAtUtc,
|
|
FailureReason: state?.FailureReason);
|
|
}).ToList();
|
|
_lastRefreshUtc = DateTime.UtcNow;
|
|
}
|
|
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
|
|
{
|
|
// Circuit disposed while a tick was in flight — a cancelled tick is a clean exit, not a
|
|
// fault (the discarded timer task would otherwise swallow it silently).
|
|
}
|
|
finally
|
|
{
|
|
// Release first: a StateHasChanged on a torn-down circuit must never leave the guard set.
|
|
Volatile.Write(ref _tickRunning, 0);
|
|
_refreshing = false;
|
|
// This one sits OUTSIDE the try, so it has no catch to fall into — and a render
|
|
// request against a disposed renderer throws. Skip it once the page is gone; there
|
|
// is nothing left to render to.
|
|
if (!_disposed) StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private static string StatusChipClass(NodeDeploymentStatus? status) => status switch
|
|
{
|
|
NodeDeploymentStatus.Applied => "chip-ok",
|
|
NodeDeploymentStatus.Applying => "chip-caution",
|
|
NodeDeploymentStatus.Failed => "chip-alert",
|
|
_ => "chip-idle",
|
|
};
|
|
|
|
private static string StatusLabel(NodeDeploymentStatus? status) => status?.ToString() ?? "waiting";
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
_disposed = true;
|
|
// Cancel before draining so a tick already in flight aborts its queries rather than running
|
|
// them to completion against a component that's gone.
|
|
_cts.Cancel();
|
|
// Drain the timer so an in-flight callback can't touch a component that's already gone
|
|
// (System.Threading.Timer's async dispose awaits any in-flight callback — .NET 6+).
|
|
if (_timer is not null) await _timer.DisposeAsync();
|
|
// ...but the callback body is `_ = InvokeAsync(LoadAsync)`, so that await covered only the
|
|
// SCHEDULING of the tick, never the tick itself: a query can still be running against
|
|
// _cts.Token right now. Disposing the source out from under it turns the clean
|
|
// cancellation the line above arranged into an ObjectDisposedException mid-query, so skip
|
|
// the dispose while a tick holds it. An undisposed CancellationTokenSource with no timer
|
|
// and no materialised WaitHandle costs nothing and is collected with the component.
|
|
if (Volatile.Read(ref _tickRunning) == 0) _cts.Dispose();
|
|
}
|
|
|
|
private sealed record NodeRow(
|
|
string NodeId,
|
|
IReadOnlyCollection<string> Roles,
|
|
NodeDeploymentStatus? Status,
|
|
DateTime? StartedAtUtc,
|
|
DateTime? AppliedAtUtc,
|
|
string? FailureReason);
|
|
}
|