Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor
T

412 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@* 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&hellip;</span>
}
</div>
<div style="padding:1rem">
@if (!Enabled)
{
<p class="mb-0" style="color:var(--ink-soft)">Disabled &mdash; 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&hellip;</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&hellip;</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&hellip;</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";
}
}