412 lines
18 KiB
Plaintext
412 lines
18 KiB
Plaintext
@* 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
|
||
|
||
<section class="panel rise mt-3" style="animation-delay:.04s; @(_stale ? "opacity:0.5;" : "")">
|
||
<div class="panel-head d-flex align-items-center gap-2">
|
||
<span>Driver status</span>
|
||
@if (_snapshot is not null)
|
||
{
|
||
<span class="chip @ChipClass(_snapshot.State)">@_snapshot.State</span>
|
||
}
|
||
else if (!Enabled)
|
||
{
|
||
<span class="chip chip-idle">Disabled</span>
|
||
}
|
||
else if (_connecting)
|
||
{
|
||
<span class="chip chip-idle">Connecting…</span>
|
||
}
|
||
</div>
|
||
|
||
<div style="padding:1rem">
|
||
@if (!Enabled)
|
||
{
|
||
<p class="mb-0" style="color:var(--ink-soft)">Disabled — not deployed. Enable the driver and save to start receiving live status.</p>
|
||
}
|
||
else if (_error is not null)
|
||
{
|
||
<p class="mb-0" style="color:var(--bad)">SignalR error: @_error</p>
|
||
}
|
||
else if (_snapshot is null)
|
||
{
|
||
<p class="mb-0" style="color:var(--ink-faint)">Awaiting first snapshot…</p>
|
||
}
|
||
else
|
||
{
|
||
<div class="d-flex flex-wrap gap-3 align-items-baseline">
|
||
<span style="color:var(--ink-soft)">
|
||
Last success:
|
||
@if (_snapshot.LastSuccessfulReadUtc is { } t)
|
||
{
|
||
<strong>@HumanizeAge(t) ago</strong>
|
||
}
|
||
else
|
||
{
|
||
<strong>never</strong>
|
||
}
|
||
</span>
|
||
|
||
@if (_snapshot.ErrorCount5Min > 0)
|
||
{
|
||
<span class="chip chip-bad">@_snapshot.ErrorCount5Min error@(_snapshot.ErrorCount5Min == 1 ? "" : "s") / 5 min</span>
|
||
}
|
||
</div>
|
||
|
||
@if (_snapshot.LastError is { Length: > 0 } lastError)
|
||
{
|
||
<details class="mt-2" style="font-size:0.85rem">
|
||
<summary style="cursor:pointer; color:var(--ink-soft)">Last error</summary>
|
||
<pre class="mt-1 mb-0" style="white-space:pre-wrap; word-break:break-word; color:var(--bad); font-size:0.8rem">@lastError</pre>
|
||
</details>
|
||
}
|
||
}
|
||
|
||
@* --- Resilience (retry storm / circuit-breaker) — fed by the driver-resilience-status DPS store --- *@
|
||
@if (Enabled)
|
||
{
|
||
@if (_resilience.Count == 0)
|
||
{
|
||
<p class="mb-0 mt-3" style="color:var(--ink-faint); font-size:0.85rem">No resilience activity recorded yet.</p>
|
||
}
|
||
else
|
||
{
|
||
var active = _resilience.Values
|
||
.Where(h => h.BreakerOpen || h.ConsecutiveFailures > 0 || h.CurrentInFlight > 0 || IsStale(h))
|
||
.OrderBy(h => h.HostName, StringComparer.Ordinal)
|
||
.ToList();
|
||
|
||
<div class="mt-3">
|
||
<div style="color:var(--ink-soft); font-size:0.8rem; font-weight:600; text-transform:uppercase; letter-spacing:.03em">Resilience</div>
|
||
@if (active.Count == 0)
|
||
{
|
||
<p class="mb-0 mt-1" style="color:var(--ink-faint); font-size:0.85rem">All targets healthy.</p>
|
||
}
|
||
else
|
||
{
|
||
@foreach (var host in active)
|
||
{
|
||
var stale = IsStale(host);
|
||
<div class="d-flex flex-wrap gap-2 align-items-baseline mt-1" style="@(stale ? "opacity:0.5;" : "")">
|
||
<span style="color:var(--ink-soft); font-size:0.85rem"><strong>@host.HostName</strong></span>
|
||
@if (host.BreakerOpen)
|
||
{
|
||
<span class="chip chip-bad">
|
||
Breaker OPEN@(host.LastBreakerOpenUtc is { } opened ? $" · opened {HumanizeAge(opened)} ago" : "")
|
||
</span>
|
||
}
|
||
@if (host.ConsecutiveFailures > 0)
|
||
{
|
||
<span class="chip chip-warn">@host.ConsecutiveFailures consecutive failure@(host.ConsecutiveFailures == 1 ? "" : "s")</span>
|
||
}
|
||
@if (host.CurrentInFlight > 0)
|
||
{
|
||
<span class="chip chip-idle">in-flight @host.CurrentInFlight</span>
|
||
}
|
||
@if (stale)
|
||
{
|
||
<span class="chip chip-idle">stale</span>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
|
||
@* --- Reconnect / Restart action buttons (DriverOperator-gated) --- *@
|
||
@if (_canOperate && Enabled)
|
||
{
|
||
<div class="d-flex gap-2 align-items-center mt-3">
|
||
<button type="button"
|
||
class="btn btn-sm btn-outline-secondary"
|
||
disabled="@_busyReconnect"
|
||
@onclick="ReconnectAsync"
|
||
title="Re-establish driver transport without restarting the actor">
|
||
@if (_busyReconnect)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
<span>Reconnecting…</span>
|
||
}
|
||
else
|
||
{
|
||
<span>Reconnect</span>
|
||
}
|
||
</button>
|
||
|
||
<button type="button"
|
||
class="btn btn-sm btn-outline-danger"
|
||
disabled="@_busyRestart"
|
||
@onclick="() => _showRestartConfirm = true"
|
||
title="Stop and respawn the driver actor — briefly interrupts active subscriptions">
|
||
@if (_busyRestart)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
<span>Restarting…</span>
|
||
}
|
||
else
|
||
{
|
||
<span>Restart</span>
|
||
}
|
||
</button>
|
||
|
||
@if (_opResultMessage is not null)
|
||
{
|
||
<span class="chip @(_opResultOk ? "chip-ok" : "chip-bad")" style="font-size:0.8rem">@_opResultMessage</span>
|
||
}
|
||
</div>
|
||
|
||
@* Inline confirm dialog for Restart (no JS confirm — keeps SignalR event loop unblocked) *@
|
||
@if (_showRestartConfirm)
|
||
{
|
||
<div class="mt-2 p-2 border rounded" style="background:var(--surface-raised,#fff); border-color:var(--bad,#dc3545)!important; max-width:420px; font-size:0.9rem">
|
||
<p class="mb-2" style="color:var(--ink)">
|
||
Restart driver <code>@DriverInstanceId</code>?<br />
|
||
<span style="color:var(--ink-soft)">This briefly interrupts active subscriptions and clears in-memory state.</span>
|
||
</p>
|
||
<div class="d-flex gap-2">
|
||
<button type="button" class="btn btn-sm btn-danger" @onclick="RestartConfirmedAsync">Confirm restart</button>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="() => _showRestartConfirm = false">Cancel</button>
|
||
</div>
|
||
</div>
|
||
}
|
||
}
|
||
</div>
|
||
</section>
|
||
|
||
@code {
|
||
[Parameter, EditorRequired] public string DriverInstanceId { get; set; } = "";
|
||
/// <summary>Cluster identifier forwarded in Reconnect/Restart messages for audit.</summary>
|
||
[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<string, DriverResilienceStatusChanged> _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<ReconnectDriverResult>(
|
||
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<RestartDriverResult>(
|
||
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();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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).
|
||
/// </summary>
|
||
private async Task<string> 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";
|
||
}
|
||
}
|