@if (_opResultMessage is not null)
{
@_opResultMessage
}
@* Inline confirm dialog for Restart (no JS confirm — keeps SignalR event loop unblocked) *@
@if (_showRestartConfirm)
{
Restart driver @DriverInstanceId? This briefly interrupts active subscriptions and clears in-memory state.
}
}
@code {
[Parameter, EditorRequired] public string DriverInstanceId { get; set; } = "";
/// Cluster identifier forwarded in Reconnect/Restart messages for audit.
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public bool Enabled { get; set; } = true;
private DriverHealthChanged? _snapshot;
private DateTime _lastUpdateUtc = DateTime.MinValue;
private bool _stale;
// Per-host resilience snapshots for THIS instance, keyed by HostName. Fed by the
// driver-resilience-status DPS store (read in-process, same house pattern as the health store).
private readonly Dictionary _resilience = new(StringComparer.Ordinal);
private bool _connecting;
private string? _error;
private System.Threading.Timer? _timer;
// Authorization
private bool _canOperate;
// Action state
private bool _busyReconnect;
private bool _busyRestart;
private bool _showRestartConfirm;
private string? _opResultMessage;
private bool _opResultOk;
private System.Threading.Timer? _opResultClearTimer;
protected override async Task OnInitializedAsync()
{
// Check DriverOperator authorization so buttons only render for permitted users.
// The username for audit logging is re-read at button-click time (not captured here)
// so token-refreshes mid-session land in audit entries accurately.
var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = authResult.Succeeded;
if (!Enabled)
return;
_connecting = true;
// Tick every 5 s to refresh the stale-dimming check and humanized ages.
_timer = new System.Threading.Timer(_ =>
{
_stale = _snapshot is not null &&
(DateTime.UtcNow - _lastUpdateUtc).TotalSeconds > 30;
InvokeAsync(StateHasChanged);
}, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
// Read live status straight from the in-process snapshot store rather than opening a
// self-targeted SignalR connection. This component runs server-side (Blazor
// InteractiveServer), so a HubConnection to the browser's public URL (e.g.
// http://localhost:9200 behind Traefik) would dial that port from *inside* the container —
// where only Kestrel's :9000 listens — and fail with "Connection refused". The store is fed
// on every admin node by DriverStatusSignalRBridge (a per-node DistributedPubSub
// subscriber), so the local singleton is always current regardless of which replica serves
// this circuit.
try
{
StatusStore.SnapshotChanged += OnSnapshotChanged;
if (StatusStore.TryGet(DriverInstanceId, out var snap))
{
_snapshot = snap;
_lastUpdateUtc = DateTime.UtcNow;
}
// Prime + subscribe the resilience store (same in-process read pattern as the health store).
ResilienceStore.SnapshotChanged += OnResilienceChanged;
foreach (var r in ResilienceStore.GetForInstance(DriverInstanceId))
_resilience[r.HostName] = r;
}
catch (Exception ex)
{
_error = ex.Message;
}
finally
{
_connecting = false;
}
}
// Invoked by the snapshot store (on the bridge actor's thread) for every driver instance;
// ignore snapshots for other instances and marshal onto the render sync context.
private void OnSnapshotChanged(DriverHealthChanged snap)
{
if (!string.Equals(snap.DriverInstanceId, DriverInstanceId, StringComparison.Ordinal))
return;
_snapshot = snap;
_lastUpdateUtc = DateTime.UtcNow;
_stale = false;
InvokeAsync(StateHasChanged);
}
// Invoked by the resilience store (on the bridge actor's thread) for every (instance, host);
// ignore snapshots for other instances and marshal onto the render sync context.
private void OnResilienceChanged(DriverResilienceStatusChanged msg)
{
if (!string.Equals(msg.DriverInstanceId, DriverInstanceId, StringComparison.Ordinal))
return;
_resilience[msg.HostName] = msg;
InvokeAsync(StateHasChanged);
}
// A host row is stale once its last publish is older than 15 s (3× the 5 s publish interval) —
// covers driver-node death, where entries freeze. Re-evaluated by the existing 5 s render timer.
private static bool IsStale(DriverResilienceStatusChanged h) =>
(DateTime.UtcNow - h.PublishedUtc).TotalSeconds > 15;
private async Task ReconnectAsync()
{
_busyReconnect = true;
_opResultMessage = null;
StateHasChanged();
try
{
var userName = await GetCurrentUserNameAsync();
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15));
var result = await AdminOps.AskAsync(
new ReconnectDriver(ClusterId, DriverInstanceId, userName, Guid.NewGuid()),
cts.Token);
ShowOpResult(result.Ok, result.Ok ? "Reconnect dispatched" : (result.Message ?? "Failed"));
}
catch (Exception ex)
{
ShowOpResult(false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message);
}
finally
{
_busyReconnect = false;
StateHasChanged();
}
}
private async Task RestartConfirmedAsync()
{
_showRestartConfirm = false;
_busyRestart = true;
_opResultMessage = null;
StateHasChanged();
try
{
var userName = await GetCurrentUserNameAsync();
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15));
var result = await AdminOps.AskAsync(
new RestartDriver(ClusterId, DriverInstanceId, userName, Guid.NewGuid()),
cts.Token);
ShowOpResult(result.Ok, result.Ok ? "Restart dispatched" : (result.Message ?? "Failed"));
}
catch (Exception ex)
{
ShowOpResult(false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message);
}
finally
{
_busyRestart = false;
StateHasChanged();
}
}
///
/// Re-reads the AuthenticationState at call time so the username forwarded to the
/// audit log reflects the current claims-principal — survives token refresh / role
/// change during a long-lived Blazor circuit. Returns "unknown" if no Name claim is
/// present (auth requirements upstream should normally prevent this).
///
private async Task GetCurrentUserNameAsync()
{
var auth = await AuthState.GetAuthenticationStateAsync();
return auth.User.Identity?.Name
?? auth.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? "unknown";
}
private void ShowOpResult(bool ok, string message)
{
_opResultOk = ok;
_opResultMessage = message;
// Auto-clear the result chip after 8 s. System.Threading.Timer is used (not
// System.Timers.Timer) so DisposeAsync can drain any in-flight callback.
_opResultClearTimer?.Dispose();
_opResultClearTimer = new System.Threading.Timer(_ =>
{
_opResultMessage = null;
InvokeAsync(StateHasChanged);
}, null, TimeSpan.FromSeconds(8), Timeout.InfiniteTimeSpan);
}
public async ValueTask DisposeAsync()
{
// Unsubscribe first so the singleton stores can't invoke a handler on a disposed component.
StatusStore.SnapshotChanged -= OnSnapshotChanged;
ResilienceStore.SnapshotChanged -= OnResilienceChanged;
// Drain BOTH timers so an in-flight callback can't invoke StateHasChanged on 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();
if (_opResultClearTimer is not null) await _opResultClearTimer.DisposeAsync();
}
// Map DriverState string → chip CSS class using the 4 defined theme variants.
private static string ChipClass(string? state) => state switch
{
"Healthy" => "chip-ok",
"Degraded" => "chip-warn",
"Connecting" => "chip-warn",
"Reconnecting" => "chip-warn",
"Faulted" => "chip-bad",
_ => "chip-idle", // Unknown, Initializing, null
};
private static string HumanizeAge(DateTime utc)
{
var age = DateTime.UtcNow - utc;
if (age.TotalSeconds < 2) return "just now";
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s";
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m {age.Seconds}s";
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h {age.Minutes}m";
return $"{(int)age.TotalDays}d {age.Hours}h";
}
}