feat(adminui): DriverStatusPanel + wire into 9 typed pages

Live panel subscribed to the /hubs/driverstatus SignalR feed —
renders state chip, last-success age, 5-min error count, last
error message. Auto-reconnect; dimmed when no push arrives for 30s.
Hidden for new instances (nothing deployed yet); shown read-only
on every edit-mode page. Reconnect/Restart buttons land in Phase 8.
This commit is contained in:
Joseph Doherty
2026-05-28 10:29:43 -04:00
parent 4203b84d51
commit 4584612a1a
10 changed files with 197 additions and 0 deletions
@@ -36,6 +36,11 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
@* Operation timeout *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div>
@@ -37,6 +37,11 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
@* Operation settings *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div>
@@ -36,6 +36,11 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
@@ -36,6 +36,11 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
@* mxaccessgw connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">mxaccessgw connection</div>
@@ -36,6 +36,11 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Connection</div>
@@ -36,6 +36,11 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
@* Transport *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Transport</div>
@@ -36,6 +36,11 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
@* Endpoint *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Endpoint</div>
@@ -36,6 +36,11 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
@@ -36,6 +36,11 @@ else
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
@* Options *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Options</div>
@@ -0,0 +1,152 @@
@* 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.
Reconnect/Restart buttons are Phase 8 (Task 8.3). *@
@implements IAsyncDisposable
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers
@inject NavigationManager Nav
<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>
}
}
</div>
</section>
@code {
[Parameter, EditorRequired] public string DriverInstanceId { get; set; } = "";
[Parameter] public bool Enabled { get; set; } = true;
private HubConnection? _hub;
private DriverHealthChanged? _snapshot;
private DateTime _lastUpdateUtc = DateTime.MinValue;
private bool _stale;
private bool _connecting;
private string? _error;
private System.Threading.Timer? _timer;
protected override async Task OnInitializedAsync()
{
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));
_hub = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri("/hubs/driverstatus"))
.WithAutomaticReconnect()
.Build();
_hub.On<DriverHealthChanged>("status", snap =>
{
_snapshot = snap;
_lastUpdateUtc = DateTime.UtcNow;
_stale = false;
InvokeAsync(StateHasChanged);
});
try
{
await _hub.StartAsync();
_connecting = false;
await _hub.InvokeAsync("JoinDriver", DriverInstanceId);
}
catch (Exception ex)
{
_connecting = false;
_error = ex.Message;
}
}
public async ValueTask DisposeAsync()
{
_timer?.Dispose();
if (_hub is not null)
await _hub.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";
}
}