@* Live driver-status panel — subscribes to /hubs/driverstatus and shows state chip, last-success age, 5-min error count, and last error message. Enabled=false renders a static "Disabled" notice and never opens the hub. DriverOperator-gated Reconnect/Restart buttons appear for authorised users. *@ @implements IAsyncDisposable @using Microsoft.AspNetCore.Authorization @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers @inject AuthenticationStateProvider AuthState @inject IAuthorizationService AuthorizationService @inject IAdminOperationsClient AdminOps @inject IDriverStatusSnapshotStore StatusStore @inject IDriverResilienceStatusStore ResilienceStore
Driver status @if (_snapshot is not null) { @_snapshot.State } else if (!Enabled) { Disabled } else if (_connecting) { Connecting… }
@if (!Enabled) {

Disabled — not deployed. Enable the driver and save to start receiving live status.

} else if (_error is not null) {

SignalR error: @_error

} else if (_snapshot is null) {

Awaiting first snapshot…

} else {
Last success: @if (_snapshot.LastSuccessfulReadUtc is { } t) { @HumanizeAge(t) ago } else { never } @if (_snapshot.ErrorCount5Min > 0) { @_snapshot.ErrorCount5Min error@(_snapshot.ErrorCount5Min == 1 ? "" : "s") / 5 min }
@if (_snapshot.LastError is { Length: > 0 } lastError) {
Last error
@lastError
} } @* --- Resilience (retry storm / circuit-breaker) — fed by the driver-resilience-status DPS store --- *@ @if (Enabled) { @if (_resilience.Count == 0) {

No resilience activity recorded yet.

} else { var active = _resilience.Values .Where(h => h.BreakerOpen || h.ConsecutiveFailures > 0 || h.CurrentInFlight > 0 || IsStale(h)) .OrderBy(h => h.HostName, StringComparer.Ordinal) .ToList();
Resilience
@if (active.Count == 0) {

All targets healthy.

} else { @foreach (var host in active) { var stale = IsStale(host);
@host.HostName @if (host.BreakerOpen) { Breaker OPEN@(host.LastBreakerOpenUtc is { } opened ? $" · opened {HumanizeAge(opened)} ago" : "") } @if (host.ConsecutiveFailures > 0) { @host.ConsecutiveFailures consecutive failure@(host.ConsecutiveFailures == 1 ? "" : "s") } @if (host.CurrentInFlight > 0) { in-flight @host.CurrentInFlight } @if (stale) { stale }
} }
} } @* --- Reconnect / Restart action buttons (DriverOperator-gated) --- *@ @if (_canOperate && Enabled) {
@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"; } }