Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor
T

181 lines
6.9 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.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 IDisposable
<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>@(r.FailureReason ?? "")</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;
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()
{
_refreshing = true;
StateHasChanged();
try
{
await using var db = await DbFactory.CreateDbContextAsync();
// 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();
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("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[] { "driver" } : Array.Empty<string>(),
Status: state?.Status,
StartedAtUtc: state?.StartedAtUtc,
AppliedAtUtc: state?.AppliedAtUtc,
FailureReason: state?.FailureReason);
}).ToList();
_lastRefreshUtc = DateTime.UtcNow;
}
finally
{
_refreshing = false;
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 void Dispose() => _timer?.Dispose();
private sealed record NodeRow(
string NodeId,
IReadOnlyCollection<string> Roles,
NodeDeploymentStatus? Status,
DateTime? StartedAtUtc,
DateTime? AppliedAtUtc,
string? FailureReason);
}