2e64af26ef
Staleness is not a liveness proof: DriverInstanceActor publishes on a 30 s tick and dedups on a health fingerprint, so a running Faulted, idle or tagless driver deleted in /raw (undeployed) went stale and was evicted. DeployedDriverSet reads, per node, the latest and latest-Applied NodeDeploymentState deployments plus the latest Sealed one, parses each artifact's DriverInstances once (cached by deployment id, pruned to the live set, only the blob column of an uncached row is read), and /hosts unions it with the authored set. The staleness clause stays as a second guard. An unreadable artifact or a failed read means 'unknown' and skips eviction. The Hosts.razor comment claiming '~12 health publishes' is corrected. N-new-2: the CSV download's Content-Disposition carries an RFC 5987 filename* via ContentDispositionHeaderValue.SetHttpFileName. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
616 lines
32 KiB
Plaintext
616 lines
32 KiB
Plaintext
@page "/hosts"
|
|
@* Two views, top to bottom. (1) Akka cluster topology: each member's NodeId (host:port), roles,
|
|
leader status — the cluster-membership view. (2) Driver instances: live driver-health grouped by
|
|
cluster. The health feed (DriverHealthChanged) carries no per-Akka-member identity, so the rows
|
|
are cluster-scoped (keyed per driver instance across the cluster, not per member). The section
|
|
reads the in-process driver-health snapshot store directly + reloads its config from the ConfigDB. *@
|
|
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
|
@rendermode RenderMode.InteractiveServer
|
|
@using Akka.Actor
|
|
@using Akka.Cluster
|
|
@using Microsoft.EntityFrameworkCore
|
|
@using Microsoft.Extensions.Logging
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration
|
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts
|
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
|
|
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers
|
|
@inject ActorSystem ActorSystem
|
|
@inject IDriverStatusSnapshotStore DriverStore
|
|
@inject IDriverResilienceStatusStore ResilienceStore
|
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
@inject DeployedDriverSet Deployed
|
|
@inject Microsoft.Extensions.Logging.ILogger<Hosts> Logger
|
|
@implements IAsyncDisposable
|
|
|
|
<PageErrorBoundary>
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h4 class="mb-0">Cluster hosts</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:.08s">
|
|
No cluster members visible. The local node may still be joining.
|
|
</section>
|
|
}
|
|
else
|
|
{
|
|
<section class="agg-grid rise" style="animation-delay:.08s">
|
|
<div class="agg-card">
|
|
<div class="agg-label">Members</div>
|
|
<div class="agg-value numeric">@_rows.Count</div>
|
|
</div>
|
|
<div class="agg-card">
|
|
<div class="agg-label">Up</div>
|
|
<div class="agg-value numeric">@_rows.Count(r => r.Status == "Up")</div>
|
|
</div>
|
|
<div class="agg-card caution">
|
|
<div class="agg-label">Joining/Leaving</div>
|
|
<div class="agg-value numeric">@_rows.Count(r => r.Status is "Joining" or "Leaving" or "Exiting")</div>
|
|
</div>
|
|
<div class="agg-card alert">
|
|
<div class="agg-label">Unreachable</div>
|
|
<div class="agg-value numeric">@_rows.Count(r => r.Unreachable)</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel rise" style="animation-delay:.14s">
|
|
<div class="panel-head">Members</div>
|
|
<div class="table-wrap">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Address</th>
|
|
<th>Status</th>
|
|
<th>Roles</th>
|
|
<th>Leader for</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var r in _rows)
|
|
{
|
|
<tr>
|
|
<td>
|
|
<span class="mono">@r.Address</span>
|
|
@if (r.IsSelf) { <span class="chip chip-idle ms-2">self</span> }
|
|
</td>
|
|
<td>
|
|
<span class="chip @StatusChipClass(r.Status, r.Unreachable)">
|
|
@(r.Unreachable ? $"{r.Status} (unreachable)" : r.Status)
|
|
</span>
|
|
</td>
|
|
<td>
|
|
@foreach (var role in r.Roles)
|
|
{
|
|
<span class="chip chip-idle me-1">@role</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
@if (r.LeaderRoles.Count == 0)
|
|
{
|
|
<span class="text-muted">—</span>
|
|
}
|
|
else
|
|
{
|
|
@foreach (var role in r.LeaderRoles)
|
|
{
|
|
<span class="chip chip-ok me-1">@role</span>
|
|
}
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
@if (_driverGroups is null)
|
|
{
|
|
<p>Loading…</p>
|
|
}
|
|
else if (_driverGroups.Count == 0)
|
|
{
|
|
<section class="panel notice rise" style="animation-delay:.2s">
|
|
No driver instances reporting yet.
|
|
</section>
|
|
}
|
|
else
|
|
{
|
|
@* One panel per cluster group (was: one panel nesting every group) so the sections read as
|
|
separate cards instead of a continuous run of headings. *@
|
|
@foreach (var g in _driverGroups)
|
|
{
|
|
<section class="panel rise mt-3" style="animation-delay:.2s" @key="@($"group-{g.ClusterId}")">
|
|
<div class="panel-head">
|
|
Driver instances · Cluster <span class="mono">@g.ClusterId</span>
|
|
<span class="ms-2">
|
|
@foreach (var n in g.Nodes)
|
|
{
|
|
<span class="chip chip-idle me-1">@n.NodeId (@n.Host:@n.OpcUaPort)</span>
|
|
}
|
|
</span>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Driver</th>
|
|
<th>Type</th>
|
|
<th>Status</th>
|
|
<th>Hosts</th>
|
|
<th>Tags</th>
|
|
<th>Last read</th>
|
|
<th>Errors/5 min</th>
|
|
<th>Last error</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@if (g.Drivers.Count == 0)
|
|
{
|
|
<tr>
|
|
<td colspan="9"><span class="text-muted">No drivers.</span></td>
|
|
</tr>
|
|
}
|
|
else
|
|
{
|
|
@foreach (var d in g.Drivers)
|
|
{
|
|
<tr @key="@($"row-{d.DriverInstanceId}")">
|
|
<td>
|
|
@(d.Name ?? d.DriverInstanceId)
|
|
@if (d.RediscoveryNeededUtc is not null)
|
|
{
|
|
@* The driver reported its remote's tag set may have changed. Advisory
|
|
only — v3 authors raw tags via /raw browse-commit, so nothing is
|
|
grafted at runtime and an operator must re-browse to pick it up. *@
|
|
<span class="chip chip-warn ms-1"
|
|
title="@($"Reported {d.RediscoveryNeededUtc:u}: {d.RediscoveryReason ?? "tag set may have changed"}. The served address space is unchanged — re-browse this device under /raw and commit anything new.")">
|
|
re-browse
|
|
</span>
|
|
}
|
|
@if (d.Name is not null)
|
|
{
|
|
@* Own line — inline after the name the two identifiers ran together. *@
|
|
<div class="mono small text-muted">@d.DriverInstanceId</div>
|
|
}
|
|
@if (d.DiagnosticItems.Count > 0)
|
|
{
|
|
@* Driver-reported diagnostics (Gitea #640), e.g. S7 negotiated PDU size.
|
|
Renders nothing for a driver with none. *@
|
|
<div class="small text-muted" data-testid="driver-diagnostics">
|
|
@foreach (var kv in d.DiagnosticItems)
|
|
{
|
|
<div class="mono">@kv.Key: @kv.Value</div>
|
|
}
|
|
</div>
|
|
}
|
|
</td>
|
|
<td>@(d.DriverType ?? "—")</td>
|
|
<td><span class="chip @DriverChipClass(d.State)">@d.State</span></td>
|
|
<td>
|
|
@* Per-host connectivity (Gitea #521). The point of this column is the case
|
|
the driver-level Status chip cannot express: a multi-device driver stays
|
|
Healthy in aggregate while ONE of its PLCs is unreachable. A driver with
|
|
no probe shows "—" — deliberately distinct from a probe reporting zero
|
|
hosts, which shows "0 hosts". *@
|
|
@if (d.HostStatuses is null)
|
|
{
|
|
<span class="text-muted">—</span>
|
|
}
|
|
else if (d.DegradedHosts.Count == 0)
|
|
{
|
|
<span class="text-muted small">@d.HostStatuses.Count @(d.HostStatuses.Count == 1 ? "host" : "hosts")</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="chip chip-warn"
|
|
title="@DegradedHostTitle(d)">
|
|
@d.DegradedHosts.Count / @d.HostStatuses.Count down
|
|
</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
@* Authored tags the driver is NOT polling (Gitea #541). This column
|
|
exists because a skip is not an error: the Status chip stays
|
|
Healthy, Errors/5 min stays 0, Last error stays empty, and Last
|
|
read keeps advancing off the tags that DID map. On the Makino in
|
|
#540 that read as a perfectly healthy driver ignoring 20 of its 22
|
|
tags. "—" means NO ANSWER — either the driver cannot report skips,
|
|
or it faulted before building a tag table — which is distinct from
|
|
the positive claim "this driver skipped nothing". A pre-fix build
|
|
rendered "0 tags" for two faulted drivers on the docker-dev rig
|
|
that had two authored tags apiece. *@
|
|
@if (d.SkippedTags is null)
|
|
{
|
|
<span class="text-muted">—</span>
|
|
}
|
|
else if (!d.HasSkippedTags)
|
|
{
|
|
<span class="text-muted small">@d.SkippedTags.AuthoredCount @(d.SkippedTags.AuthoredCount == 1 ? "tag" : "tags")</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="chip @(d.SkippedTags.SkippedEverything ? "chip-bad" : "chip-warn")"
|
|
title="@d.SkippedTagsTitle()">
|
|
@d.SkippedTags.SkippedCount / @d.SkippedTags.AuthoredCount skipped
|
|
</span>
|
|
}
|
|
</td>
|
|
<td>@(d.LastSuccessfulReadUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")</td>
|
|
<td class="numeric">@d.ErrorCount5Min</td>
|
|
<td>
|
|
@* Abbreviated so one long driver exception can't blow the table out
|
|
sideways (never slice a DB/driver string in Razor — #504); the full
|
|
text rides the tooltip and the Controls dialog. *@
|
|
<span class="text-muted small" title="@d.LastError">@(DisplayText.Abbreviate(d.LastError, 60) is { Length: > 0 } err ? err : "—")</span>
|
|
</td>
|
|
<td>
|
|
@* Opens the driver's status panel in a dialog (was an inline full-width
|
|
expander row — #546). Shown to every AuthenticatedRead viewer — the
|
|
panel carries read-only detail (resilience/breaker state, last error)
|
|
as well as the operator actions, and self-gates only the
|
|
Reconnect/Restart buttons on DriverOperator. *@
|
|
<button type="button"
|
|
class="btn btn-sm btn-outline-secondary"
|
|
@onclick="() => OpenControls(d.DriverInstanceId)"
|
|
title="Show live status, resilience and driver controls">
|
|
Controls
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
@* Controls dialog (#546, reworked from an inline expander row — the full-width panel inside the
|
|
table made an already-dense page unreadable). Only one dialog at a time; the id survives the
|
|
5 s RebuildDriverGroups because it is held on the component, and the row is re-resolved from
|
|
the CURRENT groups each render so the header/Enabled always track live state. A driver that
|
|
vanishes from the snapshot (undeployed) closes the dialog by rendering nothing.
|
|
|
|
Enabled participates in the panel's key on purpose. The panel decides whether to subscribe
|
|
ONCE, in OnInitializedAsync (`if (!Enabled) return;`), and has no OnParametersSet hook — so a
|
|
live config edit that flips Enabled (surfaced by the 5 s LoadConfigAsync refresh) would
|
|
re-render the panel's "Disabled — not deployed" markup while its two store subscriptions and
|
|
its staleness timer stayed wired underneath. Keying on the flag recycles the component:
|
|
the old instance is disposed (unsubscribing both stores, draining both timers) and a fresh
|
|
one mounts against the new value. Closing the dialog disposes the panel the same way. *@
|
|
@if (FindControlsDriver() is var (cg, cd) && cd is not null && cg is not null)
|
|
{
|
|
<div class="modal-backdrop fade show" style="display:block"></div>
|
|
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
|
<div class="modal-dialog modal-lg" role="document">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
@(cd.Name ?? cd.DriverInstanceId)
|
|
<span class="mono small text-muted ms-2">@cd.DriverInstanceId</span>
|
|
<span class="chip chip-idle ms-2">@cg.ClusterId</span>
|
|
</h5>
|
|
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseControls"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<DriverStatusPanel @key="@($"detail-{cd.DriverInstanceId}-{cd.Enabled}")"
|
|
DriverInstanceId="@cd.DriverInstanceId"
|
|
ClusterId="@cg.ClusterId"
|
|
Enabled="@cd.Enabled" />
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" @onclick="CloseControls">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
</PageErrorBoundary>
|
|
|
|
@code {
|
|
private const int RefreshIntervalSeconds = 5;
|
|
|
|
// Second guard only: a snapshot of a driver neither authored nor deployed must also be this old before it
|
|
// is evicted. NOT a liveness proof — DriverInstanceActor publishes on a 30 s tick and dedups on a health
|
|
// fingerprint, so a running Faulted/idle/tagless driver can go hours without publishing; the deployed
|
|
// set (DeployedDriverSet) is what keeps such a driver's row.
|
|
private static readonly TimeSpan EvictUnconfiguredAfter = TimeSpan.FromSeconds(60);
|
|
|
|
private List<MemberRow>? _rows;
|
|
private bool _refreshing;
|
|
private DateTime? _lastRefreshUtc;
|
|
private Timer? _timer;
|
|
|
|
private IReadOnlyList<HostsClusterGroup>? _driverGroups;
|
|
private List<HostsNodeInfo> _nodes = new();
|
|
private List<HostsDriverInstanceInfo> _instances = new();
|
|
|
|
// The driver instance whose Controls dialog is open (Gitea #546). Held on the component, NOT on
|
|
// the row records, so it survives the 5 s timer's RebuildDriverGroups — which replaces every
|
|
// HostsDriverRow instance and would otherwise close a dialog an operator just opened.
|
|
private string? _controlsDriverId;
|
|
|
|
// Overlap guard: a tick whose config load outruns the 5 s 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;
|
|
|
|
// Set by OnSnapshotChanged (bridge actor thread), consumed by TickAsync.
|
|
private readonly CoalescingDirtyFlag _driversDirty = new();
|
|
|
|
// Page-lifetime cancellation: cancelled in DisposeAsync() so a tick in flight when the circuit
|
|
// goes away aborts its queries instead of running them 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()
|
|
{
|
|
Refresh();
|
|
await LoadConfigAsync();
|
|
RebuildDriverGroups();
|
|
DriverStore.SnapshotChanged += OnSnapshotChanged;
|
|
_timer = new Timer(_ => _ = InvokeAsync(TickAsync), null,
|
|
TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
|
TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
|
}
|
|
|
|
private async Task TickAsync()
|
|
{
|
|
if (Interlocked.Exchange(ref _tickRunning, 1) == 1) return;
|
|
try
|
|
{
|
|
Refresh();
|
|
var configChanged = await LoadConfigAsync();
|
|
// Re-project the driver rows only when a health snapshot or the config actually changed
|
|
// since the last tick (R04 P4). The snapshot handler just marks the flag, so N drivers
|
|
// publishing every 5 s cost one rebuild per tick per tab, not N rebuilds + N full renders.
|
|
if (_driversDirty.TryConsume() | configChanged) RebuildDriverGroups();
|
|
StateHasChanged();
|
|
}
|
|
catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException)
|
|
{
|
|
// Circuit disposed while a tick was in flight — ignore (the discarded task would
|
|
// otherwise swallow this silently). Mirrors DriverStatusPanel's drain-on-dispose.
|
|
}
|
|
finally
|
|
{
|
|
Volatile.Write(ref _tickRunning, 0);
|
|
}
|
|
}
|
|
|
|
private async Task RefreshAsync()
|
|
{
|
|
if (Interlocked.Exchange(ref _tickRunning, 1) == 1) return;
|
|
try
|
|
{
|
|
_refreshing = true;
|
|
StateHasChanged();
|
|
await Task.Yield();
|
|
Refresh();
|
|
await LoadConfigAsync();
|
|
// A manual refresh always rebuilds; clear the flag so the next tick does not rebuild again.
|
|
_driversDirty.TryConsume();
|
|
RebuildDriverGroups();
|
|
}
|
|
catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException)
|
|
{
|
|
// Same clean-exit tolerance as the timer path.
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
|
|
// Load the per-cluster node + driver-instance config from the ConfigDB. Kept cheap and
|
|
// swallow-on-failure so a transient DB hiccup dims the enrichment (rows fall back to id/—)
|
|
// without crashing the page. The live health feed (the snapshot store) is independent of this.
|
|
// Returns whether the loaded config differs from the previous load (records compare by value).
|
|
private async Task<bool> LoadConfigAsync()
|
|
{
|
|
var previousNodes = _nodes;
|
|
var previousInstances = _instances;
|
|
try
|
|
{
|
|
await using var db = await DbFactory.CreateDbContextAsync(_cts.Token);
|
|
// Ordered: the change test below compares with SequenceEqual (review r0 M9).
|
|
_nodes = await db.ClusterNodes.AsNoTracking()
|
|
.OrderBy(n => n.ClusterId).ThenBy(n => n.NodeId)
|
|
.Select(n => new HostsNodeInfo(n.ClusterId, n.NodeId, n.Host, n.OpcUaPort))
|
|
.ToListAsync(_cts.Token);
|
|
_instances = await db.DriverInstances.AsNoTracking()
|
|
.OrderBy(d => d.DriverInstanceId)
|
|
.Select(d => new HostsDriverInstanceInfo(d.DriverInstanceId, d.ClusterId, d.Name, d.DriverType, d.Enabled))
|
|
.ToListAsync(_cts.Token);
|
|
|
|
// Evict snapshots of driver instances that are neither AUTHORED nor DEPLOYED, and stale. The
|
|
// stores have no other removal path, so without this a removed driver stays on this page as a
|
|
// frozen row until the admin node restarts. DriverInstances is the authored (draft) table: a
|
|
// driver deleted in /raw keeps running until the next deploy, and a quiet one (Faulted, idle,
|
|
// tagless) may not publish at all meanwhile, so the deployed set — not staleness — is what keeps
|
|
// it (review D-b r1, I1). Unknown deployed set ⇒ no eviction this sweep.
|
|
// Only a SUCCESSFUL load reaches here; the failure path below blanks _instances and must
|
|
// never sweep the stores.
|
|
var evicted = 0;
|
|
var deployed = await Deployed.LoadAsync(_cts.Token);
|
|
if (deployed is not null)
|
|
{
|
|
var configured = _instances
|
|
.Select(i => i.DriverInstanceId)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
configured.UnionWith(deployed);
|
|
var staleBefore = DateTime.UtcNow - EvictUnconfiguredAfter;
|
|
evicted = DriverStore.EvictUnconfiguredStale(configured, staleBefore);
|
|
ResilienceStore.EvictUnconfiguredStale(configured, staleBefore);
|
|
}
|
|
|
|
return evicted > 0
|
|
|| !_nodes.SequenceEqual(previousNodes)
|
|
|| !_instances.SequenceEqual(previousInstances);
|
|
}
|
|
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
|
|
{
|
|
// The circuit went away mid-load. Rethrow so the caller treats it as a clean exit — it
|
|
// must NOT fall into the swallow-and-clear below, which would blank the cached config.
|
|
// ObjectDisposedException belongs here for the same reason: teardown disposes _cts (and
|
|
// with it the DbContext's token registration) while a query may still be running, and
|
|
// that shape reaches this method as ODE rather than OCE. Sending it to the generic catch
|
|
// blanked _nodes/_instances on the way out — the exact swallow-and-clear this rethrow
|
|
// exists to prevent, and permanent, because a torn-down page never ticks again.
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "/hosts: failed to load driver-instance config; rows show ids only.");
|
|
_nodes = new();
|
|
_instances = new();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Re-project the cluster-grouped driver rows from the latest live snapshots + cached config.
|
|
private void RebuildDriverGroups()
|
|
=> _driverGroups = HostsDriverView.Build(DriverStore.GetAll(), _nodes, _instances);
|
|
|
|
// Open/close the Controls dialog. Ids are globally unique, so no cluster qualifier is needed.
|
|
private void OpenControls(string driverInstanceId) => _controlsDriverId = driverInstanceId;
|
|
|
|
private void CloseControls() => _controlsDriverId = null;
|
|
|
|
// Resolve the open dialog's driver from the CURRENT groups (the row records are replaced on
|
|
// every rebuild, so the dialog must never cache one). Null row ⇒ the driver vanished from the
|
|
// snapshot ⇒ the dialog renders nothing, i.e. self-closes.
|
|
private (HostsClusterGroup? Group, HostsDriverRow? Driver) FindControlsDriver()
|
|
{
|
|
if (_controlsDriverId is null || _driverGroups is null) return (null, null);
|
|
foreach (var g in _driverGroups)
|
|
{
|
|
var d = g.Drivers.FirstOrDefault(x =>
|
|
string.Equals(x.DriverInstanceId, _controlsDriverId, StringComparison.OrdinalIgnoreCase));
|
|
if (d is not null) return (g, d);
|
|
}
|
|
return (null, null);
|
|
}
|
|
|
|
// Raised by the snapshot store on the bridge actor's thread for every driver instance. It only
|
|
// marks the rows dirty: the 5 s tick does the rebuild + render (R04 P4). Rendering here cost one
|
|
// full re-projection and full-page diff per driver publish per open tab (200 drivers ≈ 40/s).
|
|
// A health change therefore shows within one tick; the open Controls dialog's DriverStatusPanel
|
|
// subscribes to the store itself and stays live.
|
|
private void OnSnapshotChanged(DriverHealthChanged _) => _driversDirty.Mark();
|
|
|
|
private void Refresh()
|
|
{
|
|
var cluster = Akka.Cluster.Cluster.Get(ActorSystem);
|
|
var state = cluster.State;
|
|
var unreachable = state.Unreachable
|
|
.Select(m => m.Address.ToString()).ToHashSet();
|
|
var selfAddress = cluster.SelfAddress.ToString();
|
|
|
|
_rows = state.Members.Select(m =>
|
|
{
|
|
var address = m.Address.ToString();
|
|
var hostPort = $"{m.Address.Host ?? "?"}:{m.Address.Port ?? 0}";
|
|
var leaderRoles = m.Roles
|
|
.Where(role => cluster.State.RoleLeader(role)?.ToString() == address)
|
|
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
return new MemberRow(
|
|
Address: hostPort,
|
|
Status: m.Status.ToString(),
|
|
Roles: m.Roles.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(),
|
|
LeaderRoles: leaderRoles,
|
|
Unreachable: unreachable.Contains(address),
|
|
IsSelf: address == selfAddress);
|
|
})
|
|
.OrderBy(r => r.Address, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
_lastRefreshUtc = DateTime.UtcNow;
|
|
}
|
|
|
|
private static string StatusChipClass(string status, bool unreachable) => (status, unreachable) switch
|
|
{
|
|
(_, true) => "chip-alert",
|
|
("Up", _) => "chip-ok",
|
|
("Joining", _) or ("Leaving", _) or ("Exiting", _) or ("WeaklyUp", _) => "chip-caution",
|
|
("Down", _) or ("Removed", _) => "chip-alert",
|
|
_ => "chip-idle",
|
|
};
|
|
|
|
// Map DriverState string → chip CSS class (mirrors DriverStatusPanel.ChipClass). Since #546 that
|
|
// panel renders inside this page's expanded rows, so the two mappings are visible side by side —
|
|
// they must stay in step or one driver shows two different colours for one state.
|
|
private static string DriverChipClass(string? state) => state switch
|
|
{
|
|
"Healthy" => "chip-ok",
|
|
"Degraded" => "chip-warn",
|
|
"Connecting" => "chip-warn",
|
|
"Reconnecting" => "chip-warn",
|
|
"Faulted" => "chip-bad",
|
|
_ => "chip-idle",
|
|
};
|
|
|
|
// Tooltip listing each host that is not Running, with its state and when it last changed. Built in
|
|
// C# rather than inline in the markup so the string is composed once per render, and so the row can
|
|
// never be the thing that 500s the page — string.Join over an already-materialised list does no
|
|
// indexing (cf. #504, where slicing a short DB string in Razor took down the whole page).
|
|
private static string DegradedHostTitle(HostsDriverRow d) =>
|
|
string.Join(" · ", d.DegradedHosts.Select(h => $"{h.HostName}: {h.State} since {h.LastChangedUtc:u}"));
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
_disposed = true;
|
|
// Unsubscribe first so the singleton store can't invoke a handler on a disposed component.
|
|
DriverStore.SnapshotChanged -= OnSnapshotChanged;
|
|
// 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(TickAsync)`, so that await covered only the
|
|
// SCHEDULING of the tick, never the tick itself: LoadConfigAsync 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 MemberRow(
|
|
string Address,
|
|
string Status,
|
|
IReadOnlyCollection<string> Roles,
|
|
IReadOnlyCollection<string> LeaderRoles,
|
|
bool Unreachable,
|
|
bool IsSelf);
|
|
}
|