feat(ui): admin manual-failover control on the health page
CentralFailoverControl lives in Components/Health/ (matching AuditKpiTiles / SiteCallKpiTiles) rather than inline in Health.razor, so it is testable without standing up the whole dashboard's DI graph. - Admin-gated via AuthorizeView + RequireAdmin. The Health page itself is intentionally all-roles, so the gate belongs on the control, not the page. - Disabled with an explanatory title when the pair has no online standby; the authoritative guard remains server-side against live cluster membership. - Confirmation dialog (IDialogService, the page idiom) warns that singletons hand over, in-flight work on the active node is interrupted, and THIS PAGE will disconnect and reconnect against the new active node -- Traefik routes the UI to the node being restarted, so a working failover otherwise reads as a crash the admin caused. - A refused failover (service returns null) surfaces the refusal; the UI never reports a failover that did not happen. 7 bUnit tests. Two harness requirements that bit first: AuthorizeView needs a cascading AuthenticationState (the app supplies it from the layout), and BunitContext pre-registers a placeholder IAuthorizationService that throws on policy evaluation -- both handled the same way NavMenuTests documents. Runbook paragraphs added to Component-ClusterInfrastructure.md (new Manual Failover section) and docker/README.md.
This commit is contained in:
@@ -218,11 +218,19 @@
|
||||
<small class="text-muted ms-2">offline since @changedAt.ToString("u")</small>
|
||||
}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" NullText="awaiting first report" />
|
||||
| Last heartbeat: <TimestampDisplay Value="@state.LastHeartbeatAt" Format="HH:mm:ss" />
|
||||
| Seq: @state.LastSequenceNumber
|
||||
</small>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
@if (isCentral)
|
||||
{
|
||||
@* Admin-only; the control gates itself, and disables when the
|
||||
central pair has no online standby to take over. *@
|
||||
<CentralFailoverControl OnlineCentralNodeCount="@OnlineNodeCount(state)" />
|
||||
}
|
||||
<small class="text-muted">
|
||||
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" NullText="awaiting first report" />
|
||||
| Last heartbeat: <TimestampDisplay Value="@state.LastHeartbeatAt" Format="HH:mm:ss" />
|
||||
| Seq: @state.LastSequenceNumber
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
@if (state.LatestReport != null)
|
||||
@@ -442,6 +450,15 @@
|
||||
private string StaleTimeoutDisplay =>
|
||||
FormatDuration(HealthOptions.Value.MetricsStaleTimeout);
|
||||
|
||||
// Online central nodes, from the same ClusterNodes list the Nodes column renders.
|
||||
// Fewer than two means no standby, which disables the manual-failover control. This is
|
||||
// a display-side guard only — AkkaManualFailoverService re-checks against live cluster
|
||||
// membership, which is the authoritative answer.
|
||||
private static int OnlineNodeCount(SiteHealthState state) =>
|
||||
state.LatestReport?.ClusterNodes is { Count: > 0 } nodes
|
||||
? nodes.Count(n => n.IsOnline)
|
||||
: (state.IsOnline ? 1 : 0);
|
||||
|
||||
private static string FormatDuration(TimeSpan span) =>
|
||||
span.TotalMinutes >= 1 && span == TimeSpan.FromMinutes(Math.Round(span.TotalMinutes))
|
||||
? $"{span.TotalMinutes:0} minute{(span.TotalMinutes == 1 ? "" : "s")}"
|
||||
|
||||
Reference in New Issue
Block a user