diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs index dc969979..cfaaab6d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs @@ -3,8 +3,10 @@ using System.Text; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; +using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Api; @@ -31,7 +33,8 @@ public static class DeployApiEndpoints /// Configuration key holding the deploy API secret. public const string ConfigKey = "Security:DeployApiKey"; - /// Maps POST /api/deployments — the headless deploy trigger. No-op-safe to call + /// Maps POST /api/deployments — the headless deploy trigger — and + /// GET /api/deployments/{id}, the status resource its 202 Location points at. No-op-safe to call /// on any admin-role node; the handler self-disables (503) until Security:DeployApiKey is set. /// The endpoint route builder. /// Application configuration (read for the deploy API key). @@ -62,9 +65,61 @@ public static class DeployApiEndpoints .DisableAntiforgery() // machine endpoint, not a browser form post .WithName("StartDeployment"); + // The status resource the POST's 202 Location points at (R04 S5). Same gate as the POST: the + // client that follows the Location is the automation holding the deploy key, not a browser + // session, so it is API-key gated (AllowAnonymous + the key check) rather than cookie-policy + // gated, and disabled (503) while the key is unconfigured. + app.MapGet("/api/deployments/{deploymentId:guid}", async ( + HttpContext http, + Guid deploymentId, + IDbContextFactory dbFactory, + CancellationToken ct) => + { + if (string.IsNullOrEmpty(configuredKey)) + return Results.Problem( + "Deploy API is disabled. Set Security:DeployApiKey to enable it.", + statusCode: StatusCodes.Status503ServiceUnavailable); + + if (!IsAuthorized(http.Request.Headers[ApiKeyHeader].ToString(), configuredKey)) + return Results.Unauthorized(); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + var status = await GetDeploymentStatusAsync(db, deploymentId, ct); + return status is null ? Results.NotFound() : Results.Ok(status); + }) + .AllowAnonymous() // gated by the API key, not the cookie auth fallback + .WithName("GetDeployment"); + return app; } + /// Reads one deployment and its per-node apply states, or null when the id is unknown. + /// The config DB context. + /// The deployment id from the POST's response / Location. + /// Cancellation token. + /// The status body, or null. + public static async Task GetDeploymentStatusAsync( + OtOpcUaConfigDbContext db, Guid deploymentId, CancellationToken ct = default) + { + // Projected: the deployment row also carries the (large) artifact blob, which a status poll never needs. + var deployment = await db.Deployments.AsNoTracking() + .Where(d => d.DeploymentId == deploymentId) + .Select(d => new { d.DeploymentId, d.RevisionHash, d.Status, d.CreatedBy, d.CreatedAtUtc, d.SealedAtUtc, d.FailureReason }) + .FirstOrDefaultAsync(ct); + if (deployment is null) return null; + + var nodes = await db.NodeDeploymentStates.AsNoTracking() + .Where(n => n.DeploymentId == deploymentId) + .OrderBy(n => n.NodeId) + .Select(n => new NodeDeploymentStatusResponse( + n.NodeId, n.Status.ToString(), n.StartedAtUtc, n.AppliedAtUtc, n.FailureReason)) + .ToListAsync(ct); + + return new DeploymentStatusResponse( + deployment.DeploymentId, deployment.RevisionHash, deployment.Status.ToString(), deployment.CreatedBy, + deployment.CreatedAtUtc, deployment.SealedAtUtc, deployment.FailureReason, nodes); + } + /// Fixed-time compare of the supplied key against the configured secret. False when /// either is empty (so an unconfigured key never authorizes). /// The caller-supplied key (from the X-Api-Key header). @@ -106,4 +161,26 @@ public static class DeployApiEndpoints /// The new deployment id when one was created; otherwise null. /// The config revision hash, when known. public sealed record DeployResponse(string Outcome, Guid? DeploymentId, string? RevisionHash); + + /// Response body for GET /api/deployments/{id}. + /// The deployment id. + /// The config revision hash the deployment carries. + /// The deployment's DeploymentStatus name. + /// Who/what initiated it. + /// When it was created. + /// When it sealed; null while still dispatching/applying. + /// The deployment-level failure reason, when any. + /// Per-node apply states, ordered by node id. + public sealed record DeploymentStatusResponse( + Guid DeploymentId, string RevisionHash, string Status, string CreatedBy, DateTime CreatedAtUtc, + DateTime? SealedAtUtc, string? FailureReason, IReadOnlyList Nodes); + + /// One node's apply state within a . + /// The cluster node id. + /// The node's NodeDeploymentStatus name. + /// When the node started applying. + /// When the node acked; null while applying. + /// The node's failure (or "Applied degraded: …") reason, when any. + public sealed record NodeDeploymentStatusResponse( + string NodeId, string Status, DateTime StartedAtUtc, DateTime? AppliedAtUtc, string? FailureReason); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseOpenGuard.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseOpenGuard.cs new file mode 100644 index 00000000..34c930f4 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseOpenGuard.cs @@ -0,0 +1,44 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; + +/// +/// Closes a browse session that finished opening after its host went away (R04 L4). Every picker and +/// RawBrowseModal awaited and then stored the token; +/// if the host was disposed (or the modal closed) meanwhile, its teardown had already run with no token, +/// so the session — an OPC UA client session, an MQTT connection, a SQL connection against the plant — +/// stayed open until the reaper's idle TTL (2 min + one 30 s tick). +/// +public static class BrowseOpenGuard +{ + /// + /// Call right after returns. When the host is gone it + /// closes the new session (fire-and-forget, like the hosts' own teardown) and returns + /// , telling the caller not to adopt the token. + /// + /// The open result. + /// Whether the host was disposed (or closed) while the open was in flight. + /// The browse service the session was opened on. + /// when the caller must drop the result. + public static bool CloseIfOrphaned(BrowseOpenResult result, bool hostGone, IBrowserSessionService service) + { + if (!hostGone) return false; + if (result.Ok && result.Token != Guid.Empty) + { + // Never block (or throw into) a dead host's continuation on a slow remote close. + _ = CloseQuietlyAsync(service, result.Token); + } + + return true; + } + + private static async Task CloseQuietlyAsync(IBrowserSessionService service, Guid token) + { + try + { + await service.CloseAsync(token).ConfigureAwait(false); + } + catch + { + // The reaper still bounds a session whose close failed. + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/FleetDiagnosticsLinks.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/FleetDiagnosticsLinks.cs new file mode 100644 index 00000000..243a7e69 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/FleetDiagnosticsLinks.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients; + +/// +/// Helpers for the per-node diagnostics page (A-F4 / A-F5). /fleet/diagnostics was reachable only +/// by URL; /fleet now links each driver node's row to it. The page also showed the driver's +/// string instance id where the operator expects its authored name — the diagnostics wire carries only +/// the id (DriverInstanceDiagnostics.Name), so the AdminUI joins the name from the ConfigDb. +/// +public static class FleetDiagnosticsLinks +{ + /// The diagnostics page route. + public const string PagePath = "/fleet/diagnostics"; + + /// The diagnostics URL for one node. + /// The node id as /fleet shows it (host:port). + /// A relative URL with the node escaped. + public static string For(string nodeId) => PagePath + "?node=" + Uri.EscapeDataString(nodeId); + + /// Loads the authored display names of the given driver instance ids. + /// The config DB context. + /// The string driver instance ids from the snapshot. + /// Cancellation token. + /// Id → authored name (ordinal-ignore-case keys); ids with no configured row are absent. + public static async Task> LoadDisplayNamesAsync( + OtOpcUaConfigDbContext db, IReadOnlyCollection driverInstanceIds, CancellationToken ct = default) + { + if (driverInstanceIds.Count == 0) return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var ids = driverInstanceIds.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var rows = await db.DriverInstances.AsNoTracking() + .Where(d => ids.Contains(d.DriverInstanceId)) + .Select(d => new { d.DriverInstanceId, d.Name }) + .ToListAsync(ct); + + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var r in rows) + { + if (!string.IsNullOrWhiteSpace(r.Name)) map[r.DriverInstanceId] = r.Name; + } + + return map; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/HistorianSinkStatusReader.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/HistorianSinkStatusReader.cs new file mode 100644 index 00000000..eb741d83 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/HistorianSinkStatusReader.cs @@ -0,0 +1,96 @@ +using Akka.Actor; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients; + +/// What one /alarms-historian poll learned. +public enum HistorianSinkAvailability +{ + /// The sink answered; is current. + Available, + + /// This node has no historian adapter actor registered — an admin-only node, or a driver node + /// that has not finished starting. Re-polled slowly (). + NotOnThisRole, + + /// The actor exists but did not answer (timeout or failure); the last good status, if any, is stale. + Unavailable, +} + +/// The outcome of one status poll. +/// What the poll learned. +/// The sink status when ; otherwise null. +/// Why the status is missing, for the page; null when available. +public sealed record HistorianSinkReading(HistorianSinkAvailability Availability, HistorianSinkStatus? Status, string? Message); + +/// +/// Reads the local historian sink status for /alarms-historian (R04 L3). The page used to wrap the +/// Ask in a bare catch {}, so every failure — including "this role has no historian actor" — left +/// it on "Loading…" forever. This classifies the failure so the page can say which it is. +/// +public static class HistorianSinkStatusReader +{ + /// The normal poll cadence. + public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5); + + /// The slow re-poll while the actor is not registered (admin-only node, or a driver node mid-startup). + public static readonly TimeSpan NotRegisteredPollInterval = TimeSpan.FromSeconds(30); + + /// The poll interval after a reading with . + /// The last reading's availability, or null before the first. + /// The interval until the next poll. + public static TimeSpan PollIntervalFor(HistorianSinkAvailability? availability) => + availability == HistorianSinkAvailability.NotOnThisRole ? NotRegisteredPollInterval : PollInterval; + + /// The per-poll Ask timeout. + public static readonly TimeSpan AskTimeout = TimeSpan.FromSeconds(2); + + /// Asks the historian adapter actor for its status. + /// Resolves the actor; throws when this node registers none. + /// The Ask timeout. + /// Cancellation (the page's lifetime); a cancellation propagates. + /// The classified reading. + public static async Task ReadAsync( + Func resolveActor, TimeSpan timeout, CancellationToken ct) + { + IActorRef actor; + try + { + actor = resolveActor(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Akka.Hosting throws when the registry has no entry for the key: the actor is spawned only on + // driver-role nodes, so an admin-only node never has one. + return new HistorianSinkReading(HistorianSinkAvailability.NotOnThisRole, null, + "This node does not run the alarm historian sink (it has no driver role). Open this page on a driver node."); + } + + if (actor.IsNobody()) + { + return new HistorianSinkReading(HistorianSinkAvailability.NotOnThisRole, null, + "This node does not run the alarm historian sink (it has no driver role). Open this page on a driver node."); + } + + try + { + var status = await actor.Ask(HistorianAdapterActor.GetStatus.Instance, timeout, ct); + return new HistorianSinkReading(HistorianSinkAvailability.Available, status, null); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (AskTimeoutException) + { + return new HistorianSinkReading(HistorianSinkAvailability.Unavailable, null, + $"The historian sink did not answer within {timeout.TotalSeconds:0} s."); + } + catch (Exception ex) + { + return new HistorianSinkReading(HistorianSinkAvailability.Unavailable, null, + $"The historian sink status could not be read: {ex.Message}"); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor index f1fcf87e..35bcdd04 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor @@ -5,25 +5,40 @@ @rendermode RenderMode.InteractiveServer @using Akka.Actor @using Akka.Hosting +@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients @using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian @using ZB.MOM.WW.OtOpcUa.Runtime @using ZB.MOM.WW.OtOpcUa.Runtime.Historian @inject IRequiredActor HistorianActor -@implements IDisposable +@implements IAsyncDisposable

Alarms historian sink

-
Polling every @PollSeconds s.
+
Polling every @((int)(_interval == TimeSpan.Zero ? PollSeconds : _interval.TotalSeconds)) s.
-@if (_status is null) +@if (_reading is { Availability: HistorianSinkAvailability.NotOnThisRole }) +{ +
@_reading.Message Checking again every @((int)HistorianSinkStatusReader.NotRegisteredPollInterval.TotalSeconds) s in case the node is still starting.
+} +else if (_status is null && _reading is { Availability: HistorianSinkAvailability.Unavailable }) +{ +
@_reading.Message Retrying every @PollSeconds s.
+} +else if (_status is null) {

Loading…

} else { + @if (_reading is { Availability: HistorianSinkAvailability.Unavailable }) + { +
+ @_reading.Message Showing the last status read at @_statusReadUtc?.ToString("u"). +
+ }
Queue
@@ -49,30 +64,58 @@ else @code { private const int PollSeconds = 5; + // The Fleet.razor lifecycle (R04 L3): IAsyncDisposable, a _disposed flag, an overlap guard and a + // page-lifetime CTS, so a queued tick after teardown neither Asks nor renders. private HistorianSinkStatus? _status; + private DateTime? _statusReadUtc; + private HistorianSinkReading? _reading; private Timer? _timer; + private int _tickRunning; + private TimeSpan _interval; + private readonly CancellationTokenSource _cts = new(); + private volatile bool _disposed; protected override async Task OnInitializedAsync() { await RefreshAsync(); - _timer = new Timer(_ => _ = InvokeAsync(RefreshAsync), null, - TimeSpan.FromSeconds(PollSeconds), TimeSpan.FromSeconds(PollSeconds)); + // "Not registered" is usually permanent (an admin-only node) but can also be a driver node still + // starting up, so it keeps a slow re-poll instead of stopping for good (review r0 M6). + _interval = HistorianSinkStatusReader.PollIntervalFor(_reading?.Availability); + _timer = new Timer(_ => _ = InvokeAsync(RefreshAsync), null, _interval, _interval); } private async Task RefreshAsync() { + if (_disposed || Interlocked.Exchange(ref _tickRunning, 1) == 1) return; try { - _status = await HistorianActor.ActorRef.Ask( - HistorianAdapterActor.GetStatus.Instance, TimeSpan.FromSeconds(2)); - StateHasChanged(); + _reading = await HistorianSinkStatusReader.ReadAsync( + () => HistorianActor.ActorRef, HistorianSinkStatusReader.AskTimeout, _cts.Token); + if (_reading.Status is not null) + { + _status = _reading.Status; + _statusReadUtc = DateTime.UtcNow; + } } - catch + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) { - // Actor unavailable (admin-only node, not driver-role) — leave _status null and let - // the page show "Loading…". A dedicated "this role doesn't run a historian" message - // would be nicer; lands when we add role gating to the UI. + return; // the page went away mid-Ask } + finally + { + Volatile.Write(ref _tickRunning, 0); + } + + if (_disposed) return; + var interval = HistorianSinkStatusReader.PollIntervalFor(_reading?.Availability); + if (_timer is not null && interval != _interval) + { + _interval = interval; + try { _timer.Change(interval, interval); } + catch (ObjectDisposedException) { return; } + } + + StateHasChanged(); } private static string StateChipClass(HistorianDrainState state) => state switch @@ -84,5 +127,13 @@ else _ => "chip chip-idle", }; - public void Dispose() => _timer?.Dispose(); + public async ValueTask DisposeAsync() + { + _disposed = true; + _cts.Cancel(); + // Drains an in-flight callback (System.Threading.Timer async dispose, .NET 6+). The callback only + // schedules the tick, so a tick may still hold _cts — skip the dispose then (see Hosts.razor). + if (_timer is not null) await _timer.DisposeAsync(); + if (Volatile.Read(ref _tickRunning) == 0) _cts.Dispose(); + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor index 7cee1860..3ead3ee0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor @@ -12,6 +12,7 @@ @inject AuthenticationStateProvider AuthState @inject IAuthorizationService AuthorizationService @inject IAdminOperationsClient AdminOps +@using ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail @implements IAsyncDisposable @@ -29,7 +30,7 @@ Shows the most-recent @Capacity entries since the page opened; reload for a fresh window.
-@if (_rows.Count == 0) +@if (_tail.Items.Count == 0) {
No alarms in the current window. The table will populate as soon as a @@ -39,7 +40,7 @@ else {
-
Recent transitions (@_rows.Count)
+
Recent transitions (@_tail.Items.Count)
@@ -58,7 +59,7 @@ else - @foreach (var e in _rows) + @foreach (var e in _tail.Items) { @@ -137,7 +138,9 @@ else @code { private const int Capacity = 200; - private readonly List _rows = new(); + // Newest-first ring, filled from the bridge thread and drained once per coalescing window (R04 P7). + private readonly LiveTailBuffer _tail = new(Capacity); + private CoalescingRenderScheduler? _render; private bool _connected; // Authorization — DriverOperator gates the per-row Ack/Shelve/Unshelve controls. @@ -172,6 +175,8 @@ else // Subscribe to the health signal BEFORE reading IsConnected so a state change between the // read and the subscribe can't be missed (TOCTOU) — mirrors DriverStatusPanel's // SnapshotChanged-then-TryGet ordering. + _render = new CoalescingRenderScheduler(CoalescingRenderScheduler.DefaultWindow, + () => InvokeAsync(FlushTail)); Alarms.ConnectionStateChanged += OnConnectionStateChanged; Alarms.Received += OnAlarm; _connected = Alarms.IsConnected; @@ -275,15 +280,18 @@ else null, TimeSpan.FromSeconds(8), Timeout.InfiniteTimeSpan); } - private void OnAlarm(AlarmTransitionEvent evt) => - // Marshal both the mutation and the re-render onto the circuit sync context so this can't - // race ClearAsync (which runs there) over the shared _rows list. - InvokeAsync(() => - { - _rows.Insert(0, evt); - if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1); - StateHasChanged(); - }); + // Bridge-actor thread: queue only. The first transition of a window arms one render ~200 ms + // later, so an alarm storm costs one render per window per tab instead of one per transition. + private void OnAlarm(AlarmTransitionEvent evt) + { + if (_tail.Add(evt)) _render?.Schedule(); + } + + // Render context: drain the queue into the ring and render once. + private void FlushTail() + { + if (_tail.Flush()) StateHasChanged(); + } // Raised by the broadcaster (on the bridge actor's thread) whenever the upstream DPS // subscription's health flips. Marshal onto the circuit sync context like OnAlarm so the live @@ -293,7 +301,7 @@ else private async Task ClearAsync() { - _rows.Clear(); + _tail.Clear(); await InvokeAsync(StateHasChanged); } @@ -314,5 +322,6 @@ else Alarms.ConnectionStateChanged -= OnConnectionStateChanged; Alarms.Received -= OnAlarm; if (_opResultClearTimer is not null) await _opResultClearTimer.DisposeAsync(); + if (_render is not null) await _render.DisposeAsync(); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor index d4180e38..177e358c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor @@ -1,4 +1,5 @@ -@page "/dev/context-menu-demo" +@* The /dev/context-menu-demo route lives in ContextMenuDemo.razor.cs behind #if DEBUG (R04 S7), so a + Release build ships the component but no route to it. *@ @* Dev-only host page to live-verify the reusable ContextMenu component. *@ @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode InteractiveServer diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor.cs new file mode 100644 index 00000000..8e5c60ec --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor.cs @@ -0,0 +1,16 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev; + +/// +/// Dev-only host page that live-verifies the reusable ContextMenu component. The route is +/// compiled only into DEBUG builds (R04 S7): a Release (production) build keeps the component type — +/// so nothing that references it breaks — but registers no /dev/* route, and the router never +/// serves it. +/// +#if DEBUG +[Microsoft.AspNetCore.Components.Route(RoutePath)] +#endif +public partial class ContextMenuDemo +{ + /// The DEBUG-only route. + public const string RoutePath = "/dev/context-menu-demo"; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor index 76cd5db1..dc34c5e6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor @@ -6,6 +6,7 @@ @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients @using ZB.MOM.WW.OtOpcUa.Cluster @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @using ZB.MOM.WW.OtOpcUa.Configuration @@ -81,7 +82,15 @@ else @foreach (var r in _rows) { - + - @foreach (var d in _snapshot.Drivers.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)) + @foreach (var d in _snapshot.Drivers.OrderBy(d => DisplayNameOf(d), StringComparer.OrdinalIgnoreCase)) { - + @@ -151,6 +163,7 @@ else private string _selectedNode = ""; private string _typedNode = ""; private NodeDiagnosticsSnapshot? _snapshot; + private IReadOnlyDictionary _displayNames = new Dictionary(); private bool _loading; private DateTime? _fetchedUtc; @@ -196,7 +209,7 @@ else private void Navigate(string node) => Nav.NavigateTo(string.IsNullOrEmpty(node) ? "/fleet/diagnostics" - : "/fleet/diagnostics?node=" + Uri.EscapeDataString(node)); + : FleetDiagnosticsLinks.For(node)); private async Task RefreshAsync() => await LoadAsync(); @@ -209,6 +222,7 @@ else // The client is fail-safe by contract: a node that does not answer yields an empty snapshot. _snapshot = await Diagnostics.GetDiagnosticsAsync(NodeId.Parse(_selectedNode), _cts.Token); _fetchedUtc = DateTime.UtcNow; + _displayNames = await LoadDisplayNamesAsync(_snapshot); } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) { @@ -221,6 +235,25 @@ else } } + // The authored name for a driver row, else its id (no ConfigDb row, or the name lookup failed). + private string DisplayNameOf(DriverInstanceDiagnostics d) => + _displayNames.TryGetValue(d.Name, out var name) ? name : d.Name; + + private async Task> LoadDisplayNamesAsync(NodeDiagnosticsSnapshot snapshot) + { + try + { + await using var db = await DbFactory.CreateDbContextAsync(_cts.Token); + return await FleetDiagnosticsLinks.LoadDisplayNamesAsync( + db, snapshot.Drivers.Select(d => d.Name).ToList(), _cts.Token); + } + catch (Exception ex) when (ex is not OperationCanceledException and not ObjectDisposedException) + { + // Names are enrichment only: a ConfigDb hiccup falls back to the ids, never fails the page. + return new Dictionary(); + } + } + private static string StateChipClass(string state) => state switch { "Healthy" => "chip-ok", diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor index 4c7849fd..119529cf 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor @@ -17,7 +17,9 @@ @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers @inject ActorSystem ActorSystem @inject IDriverStatusSnapshotStore DriverStore +@inject IDriverResilienceStatusStore ResilienceStore @inject IDbContextFactory DbFactory +@inject DeployedDriverSet Deployed @inject Microsoft.Extensions.Logging.ILogger Logger @implements IAsyncDisposable @@ -328,6 +330,12 @@ else @code { private const int RefreshIntervalSeconds = 5; + // Second guard only: a snapshot of a driver neither authored nor deployed must also be this old before it + // is evicted. NOT a liveness proof — DriverInstanceActor publishes on a 30 s tick and dedups on a health + // fingerprint, so a running Faulted/idle/tagless driver can go hours without publishing; the deployed + // set (DeployedDriverSet) is what keeps such a driver's row. + private static readonly TimeSpan EvictUnconfiguredAfter = TimeSpan.FromSeconds(60); + private List? _rows; private bool _refreshing; private DateTime? _lastRefreshUtc; @@ -346,6 +354,9 @@ else // next one. A manual Refresh landing on top of an in-flight tick is a no-op by the same guard. private int _tickRunning; + // Set by OnSnapshotChanged (bridge actor thread), consumed by TickAsync. + private readonly CoalescingDirtyFlag _driversDirty = new(); + // Page-lifetime cancellation: cancelled in DisposeAsync() so a tick in flight when the circuit // goes away aborts its queries instead of running them against a component that's gone. private readonly CancellationTokenSource _cts = new(); @@ -371,8 +382,11 @@ else try { Refresh(); - await LoadConfigAsync(); - RebuildDriverGroups(); + var configChanged = await LoadConfigAsync(); + // Re-project the driver rows only when a health snapshot or the config actually changed + // since the last tick (R04 P4). The snapshot handler just marks the flag, so N drivers + // publishing every 5 s cost one rebuild per tick per tab, not N rebuilds + N full renders. + if (_driversDirty.TryConsume() | configChanged) RebuildDriverGroups(); StateHasChanged(); } catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException) @@ -396,6 +410,8 @@ else await Task.Yield(); Refresh(); await LoadConfigAsync(); + // A manual refresh always rebuilds; clear the flag so the next tick does not rebuild again. + _driversDirty.TryConsume(); RebuildDriverGroups(); } catch (Exception ex) when (ex is ObjectDisposedException or OperationCanceledException) @@ -417,17 +433,48 @@ else // Load the per-cluster node + driver-instance config from the ConfigDB. Kept cheap and // swallow-on-failure so a transient DB hiccup dims the enrichment (rows fall back to id/—) // without crashing the page. The live health feed (the snapshot store) is independent of this. - private async Task LoadConfigAsync() + // Returns whether the loaded config differs from the previous load (records compare by value). + private async Task LoadConfigAsync() { + var previousNodes = _nodes; + var previousInstances = _instances; try { await using var db = await DbFactory.CreateDbContextAsync(_cts.Token); + // Ordered: the change test below compares with SequenceEqual (review r0 M9). _nodes = await db.ClusterNodes.AsNoTracking() + .OrderBy(n => n.ClusterId).ThenBy(n => n.NodeId) .Select(n => new HostsNodeInfo(n.ClusterId, n.NodeId, n.Host, n.OpcUaPort)) .ToListAsync(_cts.Token); _instances = await db.DriverInstances.AsNoTracking() + .OrderBy(d => d.DriverInstanceId) .Select(d => new HostsDriverInstanceInfo(d.DriverInstanceId, d.ClusterId, d.Name, d.DriverType, d.Enabled)) .ToListAsync(_cts.Token); + + // Evict snapshots of driver instances that are neither AUTHORED nor DEPLOYED, and stale. The + // stores have no other removal path, so without this a removed driver stays on this page as a + // frozen row until the admin node restarts. DriverInstances is the authored (draft) table: a + // driver deleted in /raw keeps running until the next deploy, and a quiet one (Faulted, idle, + // tagless) may not publish at all meanwhile, so the deployed set — not staleness — is what keeps + // it (review D-b r1, I1). Unknown deployed set ⇒ no eviction this sweep. + // Only a SUCCESSFUL load reaches here; the failure path below blanks _instances and must + // never sweep the stores. + var evicted = 0; + var deployed = await Deployed.LoadAsync(_cts.Token); + if (deployed is not null) + { + var configured = _instances + .Select(i => i.DriverInstanceId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + configured.UnionWith(deployed); + var staleBefore = DateTime.UtcNow - EvictUnconfiguredAfter; + evicted = DriverStore.EvictUnconfiguredStale(configured, staleBefore); + ResilienceStore.EvictUnconfiguredStale(configured, staleBefore); + } + + return evicted > 0 + || !_nodes.SequenceEqual(previousNodes) + || !_instances.SequenceEqual(previousInstances); } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) { @@ -445,6 +492,7 @@ else Logger.LogWarning(ex, "/hosts: failed to load driver-instance config; rows show ids only."); _nodes = new(); _instances = new(); + return true; } } @@ -472,11 +520,12 @@ else return (null, null); } - // Raised by the snapshot store on the bridge actor's thread for every driver instance. Rebuild - // from the cached config (cheap — no DB hit) and marshal onto the render sync context. A - // brand-new driver shows by id until the next config reload (timer/manual Refresh) enriches it. - private void OnSnapshotChanged(DriverHealthChanged _) - => InvokeAsync(() => { RebuildDriverGroups(); StateHasChanged(); }); + // Raised by the snapshot store on the bridge actor's thread for every driver instance. It only + // marks the rows dirty: the 5 s tick does the rebuild + render (R04 P4). Rendering here cost one + // full re-projection and full-page diff per driver publish per open tab (200 drivers ≈ 40/s). + // A health change therefore shows within one tick; the open Controls dialog's DriverStatusPanel + // subscribes to the store itself and stays live. + private void OnSnapshotChanged(DriverHealthChanged _) => _driversDirty.Mark(); private void Refresh() { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor index 20190df1..272df33f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor @@ -11,6 +11,7 @@ @using System.Text @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw @using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis @inject IDbContextFactory DbFactory @inject NavigationManager Nav @@ -74,9 +75,14 @@ else Cancel - @if (!IsNew) { } + @if (!IsNew) { } + + @* R04 H1: the delete used to run on the first click. *@ + } @@ -89,6 +95,7 @@ else private bool _loaded; private bool _busy; private string? _error; + private bool _confirmDeleteVisible; private MonacoEditor? _editor; private ScriptProblemReport _markers = ScriptProblemReport.Empty; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor index ff2893a1..65dd7551 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor @@ -6,13 +6,14 @@ @using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging @inject IInProcessBroadcaster ScriptLogs -@implements IDisposable +@using ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail +@implements IAsyncDisposable

Script log

- @@ -21,7 +22,7 @@ + placeholder="Filter script ID…" @bind="_scriptFilter" @bind:event="oninput" @bind:after="RecomputeVisible" /> @(_connected ? "live" : "disconnected") @@ -29,23 +30,23 @@
-@if (VisibleRows.Count == 0) +@if (_visible.Count == 0) {
- @if (_rows.Count == 0) + @if (_tail.Items.Count == 0) { No script-log entries yet. } else { - No entries match the current filter (@_rows.Count entries available). + No entries match the current filter (@_tail.Items.Count entries available). }
} else {
-
Showing @VisibleRows.Count of @_rows.Count
+
Showing @_visible.Count of @_tail.Items.Count
@e.TimestampUtc.ToString("HH:mm:ss.fff")
@r.NodeId + @r.NodeId + @* A-F4: the per-node diagnostics page was reachable only by URL. Driver nodes + answer it (the snapshot comes from their DriverHostActor). *@ + @if (r.Roles.Contains(RoleParser.Driver)) + { + diagnostics + } + @foreach (var role in r.Roles) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/FleetDiagnostics.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/FleetDiagnostics.razor index 1b958048..e55791b7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/FleetDiagnostics.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/FleetDiagnostics.razor @@ -7,11 +7,15 @@ a driver-role member from the selector. *@ @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients @using ZB.MOM.WW.OtOpcUa.Cluster @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @using ZB.MOM.WW.OtOpcUa.Commons.Types +@using ZB.MOM.WW.OtOpcUa.Configuration @inject IClusterRoleInfo Cluster @inject IFleetDiagnosticsClient Diagnostics +@inject IDbContextFactory DbFactory @inject NavigationManager Nav @implements IDisposable @@ -124,10 +128,18 @@ else
@d.Name + @* A-F5: the wire carries the string instance id; show the authored name + (joined from the ConfigDb) with the id beneath it. *@ + @DisplayNameOf(d) + @if (!string.Equals(DisplayNameOf(d), d.Name, StringComparison.Ordinal)) + { +
@d.Name
+ } +
@(d.DriverInstanceId == Guid.Empty ? "—" : DisplayText.Abbreviate(d.DriverInstanceId.ToString(), 13)) @d.State @d.ConnectedDevices
@@ -58,7 +59,7 @@ else - @foreach (var e in VisibleRows) + @foreach (var e in _visible) { @@ -82,7 +83,11 @@ else @code { private const int Capacity = 500; - private readonly List _rows = new(); + // Newest-first ring, filled from the bridge thread and drained once per coalescing window (R04 P7). + private readonly LiveTailBuffer _tail = new(Capacity); + private CoalescingRenderScheduler? _render; + // The filtered rows, recomputed on flush / filter change — not on every render. + private IReadOnlyList _visible = Array.Empty(); private bool _connected; private string _levelFilter = ""; private string _scriptFilter = ""; @@ -92,22 +97,19 @@ else ["Trace"] = 0, ["Debug"] = 1, ["Information"] = 2, ["Warning"] = 3, ["Error"] = 4, ["Critical"] = 5, }; - private List VisibleRows + private void RecomputeVisible() { - get + IEnumerable q = _tail.Items; + if (!string.IsNullOrWhiteSpace(_levelFilter) + && LevelRank.TryGetValue(_levelFilter, out var minRank)) { - IEnumerable q = _rows; - if (!string.IsNullOrWhiteSpace(_levelFilter) - && LevelRank.TryGetValue(_levelFilter, out var minRank)) - { - q = q.Where(e => LevelRank.TryGetValue(e.Level, out var r) && r >= minRank); - } - if (!string.IsNullOrWhiteSpace(_scriptFilter)) - { - q = q.Where(e => e.ScriptId.Contains(_scriptFilter, StringComparison.OrdinalIgnoreCase)); - } - return q.ToList(); + q = q.Where(e => LevelRank.TryGetValue(e.Level, out var r) && r >= minRank); } + if (!string.IsNullOrWhiteSpace(_scriptFilter)) + { + q = q.Where(e => e.ScriptId.Contains(_scriptFilter, StringComparison.OrdinalIgnoreCase)); + } + _visible = q.ToList(); } protected override void OnInitialized() @@ -116,6 +118,8 @@ else // 'script-logs' DPS topic). Blazor Server can't self-connect a SignalR HubConnection behind // a reverse proxy — see IInProcessBroadcaster — so we subscribe in-process instead. // Subscribe the state-change event BEFORE reading IsConnected so no transition is missed. + _render = new CoalescingRenderScheduler(CoalescingRenderScheduler.DefaultWindow, + () => InvokeAsync(FlushTail)); ScriptLogs.ConnectionStateChanged += OnConnectionStateChanged; ScriptLogs.Received += OnEntry; _connected = ScriptLogs.IsConnected; @@ -124,19 +128,25 @@ else private void OnConnectionStateChanged(bool connected) => InvokeAsync(() => { _connected = connected; StateHasChanged(); }); - private void OnEntry(ScriptLogEntry entry) => - // Marshal both the mutation and the re-render onto the circuit sync context so this can't - // race ClearAsync (which runs there) over the shared _rows list. - InvokeAsync(() => - { - _rows.Insert(0, entry); - if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1); - StateHasChanged(); - }); + // Bridge-actor thread: queue only. The first entry of a window arms one render ~200 ms later, so + // a chatty script costs one render per window per tab instead of one per log line. + private void OnEntry(ScriptLogEntry entry) + { + if (_tail.Add(entry)) _render?.Schedule(); + } + + // Render context: drain the queue into the ring and render once. + private void FlushTail() + { + if (!_tail.Flush()) return; + RecomputeVisible(); + StateHasChanged(); + } private async Task ClearAsync() { - _rows.Clear(); + _tail.Clear(); + RecomputeVisible(); await InvokeAsync(StateHasChanged); } @@ -148,9 +158,11 @@ else _ => "chip-idle", }; - public void Dispose() + public async ValueTask DisposeAsync() { ScriptLogs.ConnectionStateChanged -= OnConnectionStateChanged; ScriptLogs.Received -= OnEntry; + // Drains an in-flight window and suppresses later ones, so no flush renders a gone component. + if (_render is not null) await _render.DisposeAsync(); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor index a946dfec..38279f86 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor @@ -3,7 +3,7 @@ @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Configuration -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptList @inject IDbContextFactory DbFactory @@ -89,13 +89,21 @@ else
@e.TimestampUtc.ToString("HH:mm:ss.fff")