Merge lane D-b: AdminUI rendering, leaks, stubs, deployed-set /hosts eviction (#745)

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 18:34:51 -04:00
87 changed files with 4004 additions and 372 deletions
@@ -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
/// <summary>Configuration key holding the deploy API secret.</summary>
public const string ConfigKey = "Security:DeployApiKey";
/// <summary>Maps <c>POST /api/deployments</c> — the headless deploy trigger. No-op-safe to call
/// <summary>Maps <c>POST /api/deployments</c> — the headless deploy trigger — and
/// <c>GET /api/deployments/{id}</c>, the status resource its 202 <c>Location</c> points at. No-op-safe to call
/// on any admin-role node; the handler self-disables (503) until <c>Security:DeployApiKey</c> is set.</summary>
/// <param name="app">The endpoint route builder.</param>
/// <param name="config">Application configuration (read for the deploy API key).</param>
@@ -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<OtOpcUaConfigDbContext> 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;
}
/// <summary>Reads one deployment and its per-node apply states, or null when the id is unknown.</summary>
/// <param name="db">The config DB context.</param>
/// <param name="deploymentId">The deployment id from the POST's response / Location.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The status body, or null.</returns>
public static async Task<DeploymentStatusResponse?> 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);
}
/// <summary>Fixed-time compare of the supplied key against the configured secret. False when
/// either is empty (so an unconfigured key never authorizes).</summary>
/// <param name="provided">The caller-supplied key (from the <c>X-Api-Key</c> header).</param>
@@ -106,4 +161,26 @@ public static class DeployApiEndpoints
/// <param name="DeploymentId">The new deployment id when one was created; otherwise null.</param>
/// <param name="RevisionHash">The config revision hash, when known.</param>
public sealed record DeployResponse(string Outcome, Guid? DeploymentId, string? RevisionHash);
/// <summary>Response body for <c>GET /api/deployments/{id}</c>.</summary>
/// <param name="DeploymentId">The deployment id.</param>
/// <param name="RevisionHash">The config revision hash the deployment carries.</param>
/// <param name="Status">The deployment's <c>DeploymentStatus</c> name.</param>
/// <param name="CreatedBy">Who/what initiated it.</param>
/// <param name="CreatedAtUtc">When it was created.</param>
/// <param name="SealedAtUtc">When it sealed; null while still dispatching/applying.</param>
/// <param name="FailureReason">The deployment-level failure reason, when any.</param>
/// <param name="Nodes">Per-node apply states, ordered by node id.</param>
public sealed record DeploymentStatusResponse(
Guid DeploymentId, string RevisionHash, string Status, string CreatedBy, DateTime CreatedAtUtc,
DateTime? SealedAtUtc, string? FailureReason, IReadOnlyList<NodeDeploymentStatusResponse> Nodes);
/// <summary>One node's apply state within a <see cref="DeploymentStatusResponse"/>.</summary>
/// <param name="NodeId">The cluster node id.</param>
/// <param name="Status">The node's <c>NodeDeploymentStatus</c> name.</param>
/// <param name="StartedAtUtc">When the node started applying.</param>
/// <param name="AppliedAtUtc">When the node acked; null while applying.</param>
/// <param name="FailureReason">The node's failure (or "Applied degraded: …") reason, when any.</param>
public sealed record NodeDeploymentStatusResponse(
string NodeId, string Status, DateTime StartedAtUtc, DateTime? AppliedAtUtc, string? FailureReason);
}
@@ -0,0 +1,44 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Closes a browse session that finished opening after its host went away (R04 L4). Every picker and
/// <c>RawBrowseModal</c> awaited <see cref="IBrowserSessionService.OpenAsync"/> 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).
/// </summary>
public static class BrowseOpenGuard
{
/// <summary>
/// Call right after <see cref="IBrowserSessionService.OpenAsync"/> returns. When the host is gone it
/// closes the new session (fire-and-forget, like the hosts' own teardown) and returns
/// <see langword="true"/>, telling the caller not to adopt the token.
/// </summary>
/// <param name="result">The open result.</param>
/// <param name="hostGone">Whether the host was disposed (or closed) while the open was in flight.</param>
/// <param name="service">The browse service the session was opened on.</param>
/// <returns><see langword="true"/> when the caller must drop the result.</returns>
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.
}
}
}
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
/// <summary>
/// Helpers for the per-node diagnostics page (A-F4 / A-F5). <c>/fleet/diagnostics</c> was reachable only
/// by URL; <c>/fleet</c> 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 (<c>DriverInstanceDiagnostics.Name</c>), so the AdminUI joins the name from the ConfigDb.
/// </summary>
public static class FleetDiagnosticsLinks
{
/// <summary>The diagnostics page route.</summary>
public const string PagePath = "/fleet/diagnostics";
/// <summary>The diagnostics URL for one node.</summary>
/// <param name="nodeId">The node id as <c>/fleet</c> shows it (<c>host:port</c>).</param>
/// <returns>A relative URL with the node escaped.</returns>
public static string For(string nodeId) => PagePath + "?node=" + Uri.EscapeDataString(nodeId);
/// <summary>Loads the authored display names of the given driver instance ids.</summary>
/// <param name="db">The config DB context.</param>
/// <param name="driverInstanceIds">The string driver instance ids from the snapshot.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Id → authored name (ordinal-ignore-case keys); ids with no configured row are absent.</returns>
public static async Task<IReadOnlyDictionary<string, string>> LoadDisplayNamesAsync(
OtOpcUaConfigDbContext db, IReadOnlyCollection<string> driverInstanceIds, CancellationToken ct = default)
{
if (driverInstanceIds.Count == 0) return new Dictionary<string, string>(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<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var r in rows)
{
if (!string.IsNullOrWhiteSpace(r.Name)) map[r.DriverInstanceId] = r.Name;
}
return map;
}
}
@@ -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;
/// <summary>What one <c>/alarms-historian</c> poll learned.</summary>
public enum HistorianSinkAvailability
{
/// <summary>The sink answered; <see cref="HistorianSinkReading.Status"/> is current.</summary>
Available,
/// <summary>This node has no historian adapter actor registered — an admin-only node, or a driver node
/// that has not finished starting. Re-polled slowly (<see cref="HistorianSinkStatusReader.NotRegisteredPollInterval"/>).</summary>
NotOnThisRole,
/// <summary>The actor exists but did not answer (timeout or failure); the last good status, if any, is stale.</summary>
Unavailable,
}
/// <summary>The outcome of one status poll.</summary>
/// <param name="Availability">What the poll learned.</param>
/// <param name="Status">The sink status when <see cref="HistorianSinkAvailability.Available"/>; otherwise null.</param>
/// <param name="Message">Why the status is missing, for the page; null when available.</param>
public sealed record HistorianSinkReading(HistorianSinkAvailability Availability, HistorianSinkStatus? Status, string? Message);
/// <summary>
/// Reads the local historian sink status for <c>/alarms-historian</c> (R04 L3). The page used to wrap the
/// Ask in a bare <c>catch {}</c>, 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.
/// </summary>
public static class HistorianSinkStatusReader
{
/// <summary>The normal poll cadence.</summary>
public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5);
/// <summary>The slow re-poll while the actor is not registered (admin-only node, or a driver node mid-startup).</summary>
public static readonly TimeSpan NotRegisteredPollInterval = TimeSpan.FromSeconds(30);
/// <summary>The poll interval after a reading with <paramref name="availability"/>.</summary>
/// <param name="availability">The last reading's availability, or null before the first.</param>
/// <returns>The interval until the next poll.</returns>
public static TimeSpan PollIntervalFor(HistorianSinkAvailability? availability) =>
availability == HistorianSinkAvailability.NotOnThisRole ? NotRegisteredPollInterval : PollInterval;
/// <summary>The per-poll Ask timeout.</summary>
public static readonly TimeSpan AskTimeout = TimeSpan.FromSeconds(2);
/// <summary>Asks the historian adapter actor for its status.</summary>
/// <param name="resolveActor">Resolves the actor; throws when this node registers none.</param>
/// <param name="timeout">The Ask timeout.</param>
/// <param name="ct">Cancellation (the page's lifetime); a cancellation propagates.</param>
/// <returns>The classified reading.</returns>
public static async Task<HistorianSinkReading> ReadAsync(
Func<IActorRef> 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<HistorianSinkStatus>(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}");
}
}
}
@@ -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<HistorianAdapterActorKey> HistorianActor
@implements IDisposable
@implements IAsyncDisposable
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Alarms historian sink</h4>
</div>
<div class="text-muted small mb-3">Polling every @PollSeconds s.</div>
<div class="text-muted small mb-3">Polling every @((int)(_interval == TimeSpan.Zero ? PollSeconds : _interval.TotalSeconds)) s.</div>
@if (_status is null)
@if (_reading is { Availability: HistorianSinkAvailability.NotOnThisRole })
{
<section class="panel notice rise mt-3" style="animation-delay:.04s">@_reading.Message Checking again every @((int)HistorianSinkStatusReader.NotRegisteredPollInterval.TotalSeconds) s in case the node is still starting.</section>
}
else if (_status is null && _reading is { Availability: HistorianSinkAvailability.Unavailable })
{
<section class="panel notice notice-alert rise mt-3" style="animation-delay:.04s">@_reading.Message Retrying every @PollSeconds s.</section>
}
else if (_status is null)
{
<p class="mt-3">Loading…</p>
}
else
{
@if (_reading is { Availability: HistorianSinkAvailability.Unavailable })
{
<section class="panel notice notice-alert rise mt-3" style="animation-delay:.04s">
@_reading.Message Showing the last status read at @_statusReadUtc?.ToString("u").
</section>
}
<section class="card-grid rise mt-3" style="animation-delay:.08s">
<div class="metric-card">
<div class="panel-head">Queue</div>
@@ -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<HistorianSinkStatus>(
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();
}
}
@@ -12,6 +12,7 @@
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
@inject IAdminOperationsClient AdminOps
@using ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail
@implements IAsyncDisposable
<PageErrorBoundary>
@@ -29,7 +30,7 @@
Shows the most-recent @Capacity entries since the page opened; reload for a fresh window.
</section>
@if (_rows.Count == 0)
@if (_tail.Items.Count == 0)
{
<section class="panel notice rise mt-3" style="animation-delay:.08s">
No alarms in the current window. The table will populate as soon as a
@@ -39,7 +40,7 @@
else
{
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Recent transitions (@_rows.Count)</div>
<div class="panel-head">Recent transitions (@_tail.Items.Count)</div>
<div class="table-wrap">
<table class="data-table">
<thead>
@@ -58,7 +59,7 @@ else
</tr>
</thead>
<tbody>
@foreach (var e in _rows)
@foreach (var e in _tail.Items)
{
<tr>
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
@@ -137,7 +138,9 @@ else
@code {
private const int Capacity = 200;
private readonly List<AlarmTransitionEvent> _rows = new();
// Newest-first ring, filled from the bridge thread and drained once per coalescing window (R04 P7).
private readonly LiveTailBuffer<AlarmTransitionEvent> _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();
}
}
@@ -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
@@ -0,0 +1,16 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev;
/// <summary>
/// Dev-only host page that live-verifies the reusable <c>ContextMenu</c> 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 <c>/dev/*</c> route, and the router never
/// serves it.
/// </summary>
#if DEBUG
[Microsoft.AspNetCore.Components.Route(RoutePath)]
#endif
public partial class ContextMenuDemo
{
/// <summary>The DEBUG-only route.</summary>
public const string RoutePath = "/dev/context-menu-demo";
}
@@ -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)
{
<tr>
<td><span class="mono">@r.NodeId</span></td>
<td>
<span class="mono">@r.NodeId</span>
@* 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))
{
<a class="small ms-2" href="@FleetDiagnosticsLinks.For(r.NodeId)">diagnostics</a>
}
</td>
<td>
@foreach (var role in r.Roles)
{
@@ -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<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
@implements IDisposable
@@ -124,10 +128,18 @@ else
</tr>
</thead>
<tbody>
@foreach (var d in _snapshot.Drivers.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase))
@foreach (var d in _snapshot.Drivers.OrderBy(d => DisplayNameOf(d), StringComparer.OrdinalIgnoreCase))
{
<tr>
<td>@d.Name</td>
<td>
@* 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))
{
<div class="mono small text-muted">@d.Name</div>
}
</td>
<td><span class="mono" title="@d.DriverInstanceId">@(d.DriverInstanceId == Guid.Empty ? "—" : DisplayText.Abbreviate(d.DriverInstanceId.ToString(), 13))</span></td>
<td><span class="chip @StateChipClass(d.State)">@d.State</span></td>
<td class="numeric">@d.ConnectedDevices</td>
@@ -151,6 +163,7 @@ else
private string _selectedNode = "";
private string _typedNode = "";
private NodeDiagnosticsSnapshot? _snapshot;
private IReadOnlyDictionary<string, string> _displayNames = new Dictionary<string, string>();
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<IReadOnlyDictionary<string, string>> 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<string, string>();
}
}
private static string StateChipClass(string state) => state switch
{
"Healthy" => "chip-ok",
@@ -17,7 +17,9 @@
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers
@inject ActorSystem ActorSystem
@inject IDriverStatusSnapshotStore DriverStore
@inject IDriverResilienceStatusStore ResilienceStore
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject DeployedDriverSet Deployed
@inject Microsoft.Extensions.Logging.ILogger<Hosts> 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<MemberRow>? _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<bool> 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()
{
@@ -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<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
@@ -74,9 +75,14 @@ else
<button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button>
<a href="/scripts" class="btn btn-outline-secondary">Cancel</a>
</div>
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="() => _confirmDeleteVisible = true" disabled="@_busy">Delete</button> }
</div>
</EditForm>
@* R04 H1: the delete used to run on the first click. *@
<RawConfirmDialog @bind-Visible="_confirmDeleteVisible" Title="Delete script"
Message="@($"Delete the script '{_existing?.Name ?? _form.Name}'? This cannot be undone.")"
OnConfirm="DeleteAsync" />
}
</PageErrorBoundary>
@@ -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;
@@ -6,13 +6,14 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging
@inject IInProcessBroadcaster<ScriptLogEntry> ScriptLogs
@implements IDisposable
@using ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail
@implements IAsyncDisposable
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Script log</h4>
<div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm" style="width:auto" @bind="_levelFilter">
<select class="form-select form-select-sm" style="width:auto" @bind="_levelFilter" @bind:after="RecomputeVisible">
<option value="">All levels</option>
<option value="Trace">Trace+</option>
<option value="Debug">Debug+</option>
@@ -21,7 +22,7 @@
<option value="Error">Error+</option>
</select>
<input type="text" class="form-control form-control-sm" style="width:200px"
placeholder="Filter script ID…" @bind="_scriptFilter" @bind:event="oninput" />
placeholder="Filter script ID…" @bind="_scriptFilter" @bind:event="oninput" @bind:after="RecomputeVisible" />
<span class="conn-pill" data-state="@(_connected ? "connected" : "disconnected")">
<span class="dot"></span><span>@(_connected ? "live" : "disconnected")</span>
</span>
@@ -29,23 +30,23 @@
</div>
</div>
@if (VisibleRows.Count == 0)
@if (_visible.Count == 0)
{
<section class="panel notice rise mt-3" style="animation-delay:.08s">
@if (_rows.Count == 0)
@if (_tail.Items.Count == 0)
{
<span>No script-log entries yet.</span>
}
else
{
<span>No entries match the current filter (@_rows.Count entries available).</span>
<span>No entries match the current filter (@_tail.Items.Count entries available).</span>
}
</section>
}
else
{
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Showing @VisibleRows.Count of @_rows.Count</div>
<div class="panel-head">Showing @_visible.Count of @_tail.Items.Count</div>
<div class="table-wrap">
<table class="data-table">
<thead>
@@ -58,7 +59,7 @@ else
</tr>
</thead>
<tbody>
@foreach (var e in VisibleRows)
@foreach (var e in _visible)
{
<tr>
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
@@ -82,7 +83,11 @@ else
@code {
private const int Capacity = 500;
private readonly List<ScriptLogEntry> _rows = new();
// Newest-first ring, filled from the bridge thread and drained once per coalescing window (R04 P7).
private readonly LiveTailBuffer<ScriptLogEntry> _tail = new(Capacity);
private CoalescingRenderScheduler? _render;
// The filtered rows, recomputed on flush / filter change — not on every render.
private IReadOnlyList<ScriptLogEntry> _visible = Array.Empty<ScriptLogEntry>();
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<ScriptLogEntry> VisibleRows
private void RecomputeVisible()
{
get
IEnumerable<ScriptLogEntry> q = _tail.Items;
if (!string.IsNullOrWhiteSpace(_levelFilter)
&& LevelRank.TryGetValue(_levelFilter, out var minRank))
{
IEnumerable<ScriptLogEntry> 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();
}
}
@@ -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<OtOpcUaConfigDbContext> DbFactory
<PageErrorBoundary>
@@ -89,13 +89,21 @@ else
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseSource"></button>
</div>
<div class="modal-body">
@if (src is not null)
@if (_sourceError is not null)
{
<pre class="mono small mb-0" style="background:var(--zebra-bg);padding:1rem;border-radius:4px;overflow:auto">@src.SourceCode</pre>
<div class="panel notice notice-alert mb-0">@_sourceError</div>
}
else if (src is null || (_sourceLoaded && _sourceCode is null))
{
<p class="text-muted mb-0">This script is no longer loaded.</p>
}
else if (!_sourceLoaded)
{
<p class="text-muted mb-0">Loading source…</p>
}
else
{
<p class="text-muted mb-0">This script is no longer loaded.</p>
<pre class="mono small mb-0" style="background:var(--zebra-bg);padding:1rem;border-radius:4px;overflow:auto">@_sourceCode</pre>
}
</div>
<div class="modal-footer">
@@ -108,30 +116,69 @@ else
</PageErrorBoundary>
@code {
private List<Script>? _rows;
// A projection without SourceCode (R04 P8); the source loads per dialog open.
private List<ScriptListRow>? _rows;
// Only the id of the script whose source dialog is open — never the record itself, so a reload
// that drops or replaces the row cannot leave a stale object on screen.
private string? _sourceScriptId;
// The open dialog's source, fetched on open. _sourceGeneration discards a superseded fetch (the
// operator opened another row, or closed the dialog, while the first one was in flight).
private string? _sourceCode;
private bool _sourceLoaded;
private string? _sourceError;
private int _sourceGeneration;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_rows = await db.Scripts.AsNoTracking()
.OrderBy(s => s.Name)
.ToListAsync();
_rows = await ScriptListQueries.LoadRowsAsync(db);
}
/// <summary>Re-resolves the open dialog's script from the current list on every render. Drives
/// the dialog's CONTENT only — never its existence (see the comment at the markup): a script
/// that is no longer loaded renders a notice with the frame and Close still mounted.</summary>
private Script? FindSourceScript()
private ScriptListRow? FindSourceScript()
=> _sourceScriptId is null
? null
: _rows?.FirstOrDefault(s => s.ScriptId == _sourceScriptId);
// One dialog at a time: opening another row replaces the id.
private void ShowSource(string scriptId) => _sourceScriptId = scriptId;
private async Task ShowSource(string scriptId)
{
var generation = ++_sourceGeneration;
_sourceScriptId = scriptId;
_sourceCode = null;
_sourceLoaded = false;
private void CloseSource() => _sourceScriptId = null;
_sourceError = null;
string? source;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
source = await ScriptListQueries.LoadSourceAsync(db, scriptId);
}
catch (Exception ex)
{
// A ConfigDb hiccup must not escape the click handler into the page's error boundary (review r0 M7).
if (generation != _sourceGeneration) return;
_sourceError = $"The script source could not be loaded: {ex.Message}";
_sourceLoaded = true;
return;
}
if (generation != _sourceGeneration) return;
_sourceCode = source;
_sourceLoaded = true;
}
private void CloseSource()
{
_sourceGeneration++;
_sourceScriptId = null;
_sourceCode = null;
_sourceLoaded = false;
_sourceError = null;
}
}
@@ -11,6 +11,7 @@
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@inject IUnsTreeService Svc
@inject NavigationManager Nav
@@ -187,7 +188,7 @@ else
<td class="text-end text-nowrap">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="() => SaveOverride(r)">Save name</button>
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => RemoveReference(r)">Remove</button>
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmRemoveReference(r)">Remove</button>
</div>
</td>
</tr>
@@ -240,7 +241,7 @@ else
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditVirtualTag(v.VirtualTagId)">Edit</button>
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteVirtualTag(v.VirtualTagId)">Delete</button>
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDeleteVirtualTag(v.VirtualTagId, v.Name)">Delete</button>
</div>
</td>
</tr>
@@ -295,7 +296,7 @@ else
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditAlarm(a.ScriptedAlarmId)">Edit</button>
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteAlarm(a.ScriptedAlarmId)">Delete</button>
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDeleteAlarm(a.ScriptedAlarmId, a.Name)">Delete</button>
</div>
</td>
</tr>
@@ -310,6 +311,11 @@ else
<ScriptedAlarmModal Visible="_alarmModalVisible" IsNew="_alarmModalIsNew" EquipmentId="@EquipmentId"
Existing="_alarmModalExisting" Scripts="_alarmScriptOptions"
OnSaved="OnAlarmSavedAsync" OnCancel="@(() => { _alarmModalVisible = false; })" />
@* One confirm for every destructive row action on this page (R04 H1) — the same RawConfirmDialog
/raw routes its deletes through. A mis-click used to delete immediately. *@
<RawConfirmDialog @bind-Visible="_confirmVisible" Title="@_confirmTitle" Message="@_confirmMessage"
ConfirmLabel="@_confirmLabel" OnConfirm="RunConfirmedAsync" />
}
}
</PageErrorBoundary>
@@ -403,6 +409,47 @@ else
else { _refError = res.Error; }
}
// --- Delete confirmation (R04 H1) ---
private bool _confirmVisible;
private string _confirmTitle = "";
private string _confirmMessage = "";
private string _confirmLabel = "Delete";
private Func<Task>? _confirmAction;
private void AskConfirm(string title, string message, string label, Func<Task> action)
{
_confirmTitle = title;
_confirmMessage = message;
_confirmLabel = label;
_confirmAction = action;
_confirmVisible = true;
}
private async Task RunConfirmedAsync()
{
var action = _confirmAction;
_confirmAction = null;
if (action is not null) await action();
}
private void ConfirmRemoveReference(EquipmentReferenceRow r) => AskConfirm(
"Remove reference",
$"Remove the reference '{r.EffectiveName}'? After the next deploy its UNS node is gone, and any client subscribed to it loses it. The raw tag is not affected.",
"Remove",
() => RemoveReference(r));
private void ConfirmDeleteVirtualTag(string vtagId, string name) => AskConfirm(
"Delete virtual tag",
$"Delete the virtual tag '{name}'? After the next deploy its OPC UA node is gone, and any client subscribed to it loses it.",
"Delete",
() => DeleteVirtualTag(vtagId));
private void ConfirmDeleteAlarm(string scriptedAlarmId, string name) => AskConfirm(
"Delete scripted alarm",
$"Delete the scripted alarm '{name}'? After the next deploy its condition is gone, and any client subscribed to it loses it.",
"Delete",
() => DeleteAlarm(scriptedAlarmId));
private async Task RemoveReference(EquipmentReferenceRow r)
{
_refError = null;
@@ -108,13 +108,19 @@
private bool _enabled = true;
private string _deviceConfigJson = "{}";
/// <summary>Parameters handed to the DynamicComponent-rendered device form. Rebuilt on every render —
/// see DriverConfigModal for why this must not be cached.</summary>
private Dictionary<string, object> _deviceFormParameters => new()
{
["DeviceConfigJson"] = _deviceConfigJson,
["DeviceConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _deviceConfigJson = v),
};
/// <summary>Parameters handed to the DynamicComponent-rendered device form. Cached on the value it
/// carries and rebuilt when that changes (never build-once: see DriverConfigModal), with the callback
/// created once (R04 H2).</summary>
private Dictionary<string, object> _deviceFormParameters => _deviceFormParameterCache.Get(
[_deviceConfigJson],
() => new()
{
["DeviceConfigJson"] = _deviceConfigJson,
["DeviceConfigJsonChanged"] = _deviceConfigJsonChanged ??=
EventCallback.Factory.Create<string>(this, v => _deviceConfigJson = v),
});
private readonly ParameterDictionaryCache _deviceFormParameterCache = new();
private EventCallback<string>? _deviceConfigJsonChanged;
private byte[] _rowVersion = [];
private string _parentDriverConfig = "{}";
@@ -111,16 +111,24 @@
private string _name = "";
private string _driverConfigJson = "{}";
/// <summary>Parameters handed to the DynamicComponent-rendered driver form. Rebuilt on every render so
/// the child receives the CURRENT json — a cached dictionary would pin the value captured at first
/// render and silently strand every subsequent edit.</summary>
private Dictionary<string, object> _formParameters => new()
{
["DriverConfigJson"] = _driverConfigJson,
["DriverConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _driverConfigJson = v),
["ResilienceConfig"] = _resilienceConfig!,
["ResilienceConfigChanged"] = EventCallback.Factory.Create<string?>(this, v => _resilienceConfig = v),
};
/// <summary>Parameters handed to the DynamicComponent-rendered driver form. The child must receive the
/// CURRENT json — a build-once dictionary would pin the value captured at first render and silently
/// strand every subsequent edit — so the cache is keyed on the values it carries and rebuilt only when
/// one changes; the callbacks are created once and reused (R04 H2).</summary>
private Dictionary<string, object> _formParameters => _formParameterCache.Get(
[_driverConfigJson, _resilienceConfig],
() => new()
{
["DriverConfigJson"] = _driverConfigJson,
["DriverConfigJsonChanged"] = _driverConfigJsonChanged ??=
EventCallback.Factory.Create<string>(this, v => _driverConfigJson = v),
["ResilienceConfig"] = _resilienceConfig!,
["ResilienceConfigChanged"] = _resilienceConfigChanged ??=
EventCallback.Factory.Create<string?>(this, v => _resilienceConfig = v),
});
private readonly ParameterDictionaryCache _formParameterCache = new();
private EventCallback<string>? _driverConfigJsonChanged;
private EventCallback<string?>? _resilienceConfigChanged;
private string? _resilienceConfig;
private byte[] _rowVersion = [];
@@ -1,10 +1,11 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
@inject AdminProbeService Probe
@implements IDisposable
@implements IAsyncDisposable
<div class="d-inline-flex align-items-center gap-2">
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@_busy" @onclick="OnClickAsync">
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@_busy" @onclick="OnClickAsync"
title="@(_lastBudgetSeconds is { } b ? $"Probe budget: {b} s" : "Probe budget: the driver's configured probe timeout")">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Test Connect
</button>
@@ -20,7 +21,7 @@
else
{
<span class="chip chip-bad" title="@_result.Message">
Failed &middot; @TruncatedMessage()
Failed &middot; @DisplayText.Abbreviate(_result.Message, 60)
</span>
}
}
@@ -33,12 +34,25 @@
/// <summary>Callback that returns the current form config as JSON. Called at click time.</summary>
[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";
/// <summary>Per-probe timeout forwarded to the actor (actor clamps to [1, 60] s). Default 10 s.</summary>
[Parameter] public int TimeoutSeconds { get; set; } = 10;
/// <summary>
/// Fallback probe budget, used only when the config JSON carries no positive
/// <c>ProbeTimeoutSeconds</c> and the driver has no documented default (<c>TestConnectBudget.DriverDefaults</c>). The configured value wins (it is every driver's documented "Test Connect
/// probe timeout"), so it can lengthen the budget as well as shorten it. Clamped to [1, 60] s.
/// </summary>
[Parameter] public int TimeoutSeconds { get; set; } = TestConnectBudget.DefaultSeconds;
private const int ClearAfterMs = 30_000;
private bool _busy;
private int? _lastBudgetSeconds;
private TestDriverConnectResult? _result;
private System.Timers.Timer? _clearTimer;
// System.Threading.Timer (it replaced an Elapsed-event timer with an async void lambda): DisposeAsync awaits an
// in-flight callback, and the callback body never throws onto the thread pool (R04 L2). The generation
// makes a superseded timer's late fire a no-op, so it cannot clear a newer result.
private System.Threading.Timer? _clearTimer;
private int _resultGeneration;
private volatile bool _disposed;
private async Task OnClickAsync()
{
@@ -48,7 +62,9 @@
try
{
var json = GetConfigJson() ?? "{}";
_result = await Probe.TestAsync(DriverType, json, TimeoutSeconds);
var budget = TestConnectBudget.Resolve(json, TimeoutSeconds, DriverType);
_lastBudgetSeconds = budget;
_result = await Probe.TestAsync(DriverType, json, budget);
}
catch (Exception ex)
{
@@ -58,25 +74,39 @@
{
_busy = false;
ScheduleClear();
StateHasChanged();
if (!_disposed) StateHasChanged();
}
}
private void ScheduleClear()
{
if (_disposed) return;
var generation = ++_resultGeneration;
_clearTimer?.Dispose();
_clearTimer = new System.Timers.Timer(30_000) { AutoReset = false };
_clearTimer.Elapsed += async (_, _) =>
{
_result = null;
await InvokeAsync(StateHasChanged);
};
_clearTimer.Start();
_clearTimer = new System.Threading.Timer(_ => _ = ClearAsync(generation), null, ClearAfterMs, Timeout.Infinite);
}
private string TruncatedMessage()
=> _result?.Message is null ? "" :
(_result.Message.Length > 60 ? _result.Message[..60] + "…" : _result.Message);
private async Task ClearAsync(int generation)
{
try
{
await InvokeAsync(() =>
{
if (_disposed || generation != _resultGeneration) return;
_result = null;
StateHasChanged();
});
}
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException)
{
// The circuit went away between the fire and the render — nothing to clear.
}
}
public void Dispose() => _clearTimer?.Dispose();
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
_disposed = true;
if (_clearTimer is not null) await _clearTimer.DisposeAsync();
}
}
@@ -138,7 +138,7 @@
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.Galaxy.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Caps the Test Connect probe deadline (the probe's own budget still applies, so this only shortens it). Max 60. Default 30.</div>
<div class="form-text">The Test Connect probe budget (1–60 s). Default 30.</div>
</div>
</div>
</div>
@@ -219,7 +219,7 @@
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.OpcUa.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Caps Test Connect: it can only shorten the 10 s Test Connect budget, never extend it.</div>
<div class="form-text">The Test Connect probe budget (1–60 s). Default 15.</div>
</div>
</div>
</div>
@@ -89,6 +89,8 @@
private string _built = "";
private Guid _token = Guid.Empty;
// Set first thing in DisposeAsync; an open that lands afterwards closes its own session.
private volatile bool _disposed;
private bool _opening;
private bool _canBrowse;
private string? _openError;
@@ -110,6 +112,8 @@
{
var json = GetConfigJson() ?? "{}";
var result = await BrowserService.OpenAsync(DriverType, json, default);
// Host disposed mid-open: close the session nobody will (R04 L4).
if (BrowseOpenGuard.CloseIfOrphaned(result, _disposed, BrowserService)) { return; }
if (result.Ok) _token = result.Token;
else _openError = result.Message;
}
@@ -155,6 +159,7 @@
public ValueTask DisposeAsync()
{
_disposed = true;
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow capture.
@@ -164,6 +164,8 @@
private string _built = "";
private Guid _token = Guid.Empty;
// Set first thing in DisposeAsync; an open that lands afterwards closes its own session.
private volatile bool _disposed;
private bool _opening;
private bool _canBrowse;
private string? _openError;
@@ -185,6 +187,8 @@
{
var json = GetConfigJson() ?? "{}";
var result = await BrowserService.OpenAsync(DriverType, json, default);
// Host disposed mid-open: close the session nobody will (R04 L4).
if (BrowseOpenGuard.CloseIfOrphaned(result, _disposed, BrowserService)) { return; }
if (result.Ok) _token = result.Token;
else _openError = result.Message;
}
@@ -229,6 +233,7 @@
public ValueTask DisposeAsync()
{
_disposed = true;
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow capture.
@@ -111,6 +111,8 @@
private string _attributeName = "";
private string _built = "";
private Guid _token = Guid.Empty;
// Set first thing in DisposeAsync; an open that lands afterwards closes its own session.
private volatile bool _disposed;
private bool _opening;
private bool _attrsLoading;
private bool _canOperate;
@@ -134,6 +136,8 @@
{
var json = GetConfigJson() ?? "{}";
var result = await BrowserService.OpenAsync("GalaxyMxGateway", json, default);
// Host disposed mid-open: close the session nobody will (R04 L4).
if (BrowseOpenGuard.CloseIfOrphaned(result, _disposed, BrowserService)) { return; }
if (result.Ok) _token = result.Token;
else _openError = result.Message;
}
@@ -216,6 +220,7 @@
public ValueTask DisposeAsync()
{
_disposed = true;
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow remote.
@@ -65,6 +65,8 @@
private string _nodeId = "";
private string _built = "";
private Guid _token = Guid.Empty;
// Set first thing in DisposeAsync; an open that lands afterwards closes its own session.
private volatile bool _disposed;
private bool _opening;
private bool _canOperate;
private string? _openError;
@@ -85,6 +87,8 @@
{
var json = GetConfigJson() ?? "{}";
var result = await BrowserService.OpenAsync("OpcUaClient", json, default);
// Host disposed mid-open: close the session nobody will (R04 L4).
if (BrowseOpenGuard.CloseIfOrphaned(result, _disposed, BrowserService)) { return; }
if (result.Ok) _token = result.Token;
else _openError = result.Message;
}
@@ -114,6 +118,7 @@
public ValueTask DisposeAsync()
{
_disposed = true;
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow remote.
@@ -240,6 +240,8 @@ else
private string _built = "";
private string _selectedColumnNodeId = "";
private Guid _token = Guid.Empty;
// Set first thing in DisposeAsync; an open that lands afterwards closes its own session.
private volatile bool _disposed;
private bool _opening;
private bool _canOperate;
private string? _openError;
@@ -268,6 +270,8 @@ else
// sole place it is read, and it dies with the local variable.
var json = BuildBrowseConfig();
var result = await BrowserService.OpenAsync(DriverTypeNames.Sql, json, default);
// Host disposed mid-open: close the session nobody will (R04 L4).
if (BrowseOpenGuard.CloseIfOrphaned(result, _disposed, BrowserService)) { return; }
if (result.Ok) { _token = result.Token; }
else { _openError = result.Message; }
}
@@ -429,6 +433,7 @@ else
public ValueTask DisposeAsync()
{
_disposed = true;
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow database.
@@ -76,6 +76,8 @@
private string _built = "";
private Guid _token = Guid.Empty;
// Set first thing in DisposeAsync; an open that lands afterwards closes its own session.
private volatile bool _disposed;
private bool _opening;
private bool _canBrowse;
private string? _openError;
@@ -97,6 +99,8 @@
{
var json = GetConfigJson() ?? "{}";
var result = await BrowserService.OpenAsync(DriverType, json, default);
// Host disposed mid-open: close the session nobody will (R04 L4).
if (BrowseOpenGuard.CloseIfOrphaned(result, _disposed, BrowserService)) { return; }
if (result.Ok) _token = result.Token;
else _openError = result.Message;
}
@@ -134,6 +138,7 @@
public ValueTask DisposeAsync()
{
_disposed = true;
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow capture.
@@ -0,0 +1,111 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
/// <summary>
/// Resolves the Test Connect probe budget (G1-F5 / F-FU2). Every driver's options document
/// <c>ProbeTimeoutSeconds</c> as "Timeout for the AdminUI Test Connect probe", but the button always sent
/// a fixed 10 s, and the Galaxy and OpcUaClient probes take <c>min(caller budget, configured)</c> — so the
/// knob could shorten a probe but never lengthen it (Galaxy's default of 30 s was silently 10 s). The
/// button now sends the configured value, read from the root of the config JSON it is about to probe,
/// clamped to the probe actor's own [1, 60] s range.
/// </summary>
public static class TestConnectBudget
{
/// <summary>The budget used when the config carries no (valid) <c>ProbeTimeoutSeconds</c>.</summary>
public const int DefaultSeconds = 10;
/// <summary>The probe actor's lower clamp.</summary>
public const int MinSeconds = 1;
/// <summary>The probe actor's upper clamp.</summary>
public const int MaxSeconds = 60;
/// <summary>The config property that carries the budget (matched case-insensitively at the JSON root).</summary>
public const string PropertyName = "ProbeTimeoutSeconds";
/// <summary>The root per-request timeout some probes (Modbus) use as their own deadline, in ms.</summary>
public const string RequestTimeoutPropertyName = "timeoutMs";
/// <summary>
/// Each driver's documented <c>ProbeTimeoutSeconds</c> default (its options type's initializer), used
/// when the config carries no value — legacy rows and raw-JSON/seeded configs omit the key, and the
/// form hints promise these defaults (review r0 M1). Drivers not listed fall back to the caller's value.
/// </summary>
public static readonly IReadOnlyDictionary<string, int> DriverDefaults =
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
[DriverTypeNames.Galaxy] = 30,
[DriverTypeNames.OpcUaClient] = 15,
[DriverTypeNames.TwinCAT] = 10,
[DriverTypeNames.FOCAS] = 10,
[DriverTypeNames.Modbus] = 5,
[DriverTypeNames.AbCip] = 5,
[DriverTypeNames.AbLegacy] = 5,
[DriverTypeNames.S7] = 5,
};
/// <summary>Returns the configured budget, else the driver's documented default, else <paramref name="fallbackSeconds"/>.</summary>
/// <param name="configJson">The (merged) config JSON the probe will run against.</param>
/// <param name="fallbackSeconds">The budget when neither the config nor <see cref="DriverDefaults"/> supplies one.</param>
/// <param name="driverType">The driver type, selecting its documented default; null skips that step.</param>
/// <returns>The budget in seconds, within [<see cref="MinSeconds"/>, <see cref="MaxSeconds"/>].</returns>
/// <remarks>
/// A probe that runs under the config's own <c>timeoutMs</c> (Modbus, factory parity) is hard-cancelled
/// at this budget by the probe actor, so the budget is never set below <c>ceil(timeoutMs / 1000) + 1</c>
/// (review r0 M2): a slow gateway that the deployed driver reaches within its 8 s request timeout must
/// not fail Test Connect at a 5 s budget.
/// </remarks>
public static int Resolve(string? configJson, int fallbackSeconds = DefaultSeconds, string? driverType = null)
{
var (configured, requestTimeoutMs) = TryRead(configJson);
var seconds = configured is > 0
? configured.Value
: driverType is not null && DriverDefaults.TryGetValue(driverType, out var d) ? d : fallbackSeconds;
if (requestTimeoutMs is > 0)
{
var floor = (int)Math.Min(Math.Ceiling(requestTimeoutMs.Value / 1000.0) + 1, int.MaxValue);
seconds = Math.Max(seconds, floor);
}
return Math.Clamp(seconds, MinSeconds, MaxSeconds);
}
private static (int? ProbeTimeoutSeconds, double? RequestTimeoutMs) TryRead(string? configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return (null, null);
int? probe = null;
double? request = null;
try
{
using var doc = JsonDocument.Parse(configJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (null, null);
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (string.Equals(prop.Name, PropertyName, StringComparison.OrdinalIgnoreCase))
{
probe = prop.Value.ValueKind switch
{
JsonValueKind.Number when prop.Value.TryGetInt32(out var i) => i,
JsonValueKind.Number when prop.Value.TryGetDouble(out var dd) => (int)Math.Min(Math.Ceiling(dd), int.MaxValue),
JsonValueKind.String when int.TryParse(prop.Value.GetString(), out var s) => s,
_ => null,
};
}
else if (string.Equals(prop.Name, RequestTimeoutPropertyName, StringComparison.OrdinalIgnoreCase)
&& prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetDouble(out var ms))
{
request = ms;
}
}
}
catch (JsonException)
{
// An unparseable config is the probe's problem to report; the budget just falls back.
}
return (probe, request);
}
}
@@ -225,6 +225,10 @@
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle minimap", _id, "minimap", new { enabled = _minimap });
}
// R04 H3 (accepted, cosmetic): Monaco themes are page-global — the JS side calls
// monaco.editor.setTheme, which re-themes EVERY editor on the page (e.g. a VirtualTagModal open over
// ScriptEdit), not just this one, and the other instance's _dark flag then no longer matches what it shows.
// Monaco has no per-editor theme; a fix would need a page-level theme service shared by all instances.
private async Task ToggleTheme()
{
_dark = !_dark;
@@ -0,0 +1,50 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
/// <summary>
/// Caches a <c>DynamicComponent</c> parameter dictionary (R04 H2) and rebuilds it only when one of the
/// values it carries changed. The modals used to build a new dictionary, and new
/// <c>EventCallback</c>s, on every render. A plain build-once cache is wrong: it would pin the JSON
/// captured at first render and silently strand every later edit — so the cache is keyed on the
/// current values, and the callbacks it hands out are created once by the caller and reused.
/// <para>Note the limit: Blazor's change detection treats any <c>EventCallback</c> parameter as
/// possibly changed, so a hosted form still sees <c>SetParametersAsync</c> on each parent render. The
/// per-form <c>_lastParsed</c> guards remain load-bearing; this removes the per-render allocations and
/// gives the child a stable dictionary and callback identity.</para>
/// </summary>
public sealed class ParameterDictionaryCache
{
private object?[]? _keyValues;
private Dictionary<string, object>? _cached;
/// <summary>Returns the cached dictionary when <paramref name="keyValues"/> equal the previous call's,
/// otherwise builds, caches and returns a new one.</summary>
/// <param name="keyValues">The values the dictionary carries (compared with <see cref="object.Equals(object, object)"/>).</param>
/// <param name="build">Builds the dictionary from the current state.</param>
/// <returns>The parameter dictionary.</returns>
public Dictionary<string, object> Get(object?[] keyValues, Func<Dictionary<string, object>> build)
{
if (_cached is not null && _keyValues is not null && Same(_keyValues, keyValues)) return _cached;
_cached = build();
_keyValues = (object?[])keyValues.Clone();
return _cached;
}
/// <summary>Drops the cached dictionary (e.g. on modal close) so the next <see cref="Get"/> rebuilds.</summary>
public void Reset()
{
_cached = null;
_keyValues = null;
}
private static bool Same(object?[] a, object?[] b)
{
if (a.Length != b.Length) return false;
for (var i = 0; i < a.Length; i++)
{
if (!Equals(a[i], b[i])) return false;
}
return true;
}
}
@@ -300,6 +300,8 @@
private string? _groupPrefix;
private Guid _token = Guid.Empty;
// Set first thing in DisposeAsync; an open that lands afterwards closes its own session.
private volatile bool _disposed;
private bool _opening;
private string? _openError;
@@ -455,10 +457,15 @@
{
_opening = true;
_openError = null;
// Captured BEFORE the await: a close-then-reopen while this open is in flight leaves Visible true
// again, so only the generation tells this open's result apart from the new modal's (review r0 M5).
var generation = _openGeneration;
StateHasChanged();
try
{
var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default);
// Host disposed, modal closed, or modal re-opened mid-open: close the session nobody will (R04 L4).
if (BrowseOpenGuard.CloseIfOrphaned(result, _disposed || IsStale(generation), BrowserService)) { return; }
if (result.Ok)
{
_token = result.Token;
@@ -499,6 +506,7 @@
string name = sel.Leaf.DisplayName;
string? driverDataType = null;
IReadOnlyDictionary<string, string>? addressFields = null;
string? descriptor = null;
try
{
var attrs = await BrowserService.AttributesAsync(_token, nodeId, default);
@@ -510,6 +518,8 @@
// reconstructed from the node id: a metric name may contain '/', so the id cannot be
// split back into (group, edgeNode, device?, metric) — see RawBrowseCommitMapper.
addressFields = attrs[0].AddressFields;
// The descriptor line, so a commit refusal can say WHY a leaf is unbindable (G2-F2).
descriptor = attrs[0].SecurityClass;
}
}
catch
@@ -519,7 +529,7 @@
}
_selectedIds.Add(nodeId);
_selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath, addressFields);
_selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath, addressFields, descriptor);
// A metric is also a legal rebirth scope (it resolves up to its owning edge node), so a toggle
// moves the scope the same way a folder click does.
@@ -676,13 +686,29 @@
_rebirthOutcome = null;
_rebirthError = null;
StateHasChanged();
var token = _token;
var target = _rebirthTarget;
try
{
var published = await BrowserService.RequestRebirthAsync(_token, _rebirthTarget.Scope, default);
var published = await BrowserService.RequestRebirthAsync(token, target.Scope, default);
// The session's RequestRebirthAsync returns only once the births it asked for have arrived and
// gone quiet (bounded by the window), so the tree can be re-read now instead of telling the
// operator to re-expand it later (G2-F3). Skipped if the session or modal went away meanwhile.
var refreshed = token == _token && Visible && !_disposed;
if (refreshed)
{
// Re-key the tree against the SAME session (as the Refresh button does). The armed scope is
// dropped — its node may not be in the rebuilt tree — but the outcome line is kept.
_treeGeneration++;
_rebirthTarget = null;
}
_rebirthOutcome =
$"Rebirth requested for {_rebirthTarget.Kind} — {published} command"
$"Rebirth requested for {target.Kind} — {published} command"
+ (published == 1 ? "" : "s")
+ " published. Re-expand the tree in a few seconds to see the republished metrics.";
+ (refreshed
? " published. The tree was refreshed with the births that arrived; press Refresh if more appear later."
: " published.");
_rebirthConfirming = false;
}
catch (Exception ex)
@@ -764,7 +790,7 @@
// committing it would produce a tag that deploys clean and then reports BadNodeIdUnknown
// forever with nothing to point at.
_commitErrors = _selected.Values
.Select(s => RawBrowseCommitMapper.DescribeUncommittableLeaf(_driverType, s.Name, s.AddressFields))
.Select(s => RawBrowseCommitMapper.DescribeUncommittableLeaf(_driverType, s.Name, s.AddressFields, s.Descriptor))
.Where(e => e is not null)
.Select(e => e!)
.ToList();
@@ -823,6 +849,7 @@
public ValueTask DisposeAsync()
{
_disposed = true;
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow remote.
@@ -838,7 +865,8 @@
string Name,
string? DriverDataType,
IReadOnlyList<string> FolderPath,
IReadOnlyDictionary<string, string>? AddressFields = null);
IReadOnlyDictionary<string, string>? AddressFields = null,
string? Descriptor = null);
/// <summary>A described Request-rebirth scope: what gets published, and to what.</summary>
/// <param name="Scope">The browse node id handed to the session verbatim.</param>
@@ -2,15 +2,15 @@
Reads the device's tags (via the WP5-owned RawTagCsvExportReader — no IRawTreeService extension) and
renders them in the same column shape RawCsvImportModal consumes (T0-2 CsvWriter, per-driver
CsvColumnMap). The CSV is offered as a self-contained data-URI download anchor (no JS interop, no
external host — matches the app's CSP posture) plus a preview textarea.
CsvColumnMap). The download is a plain same-origin link to GET /api/raw/tags.csv (R04 P6) — the
file used to be embedded in the circuit twice (textarea + base64 data: URI); the modal now shows only
a bounded preview of the first RawTagCsvExport.PreviewLines lines.
Modal contract (the RawTree coordinator wires this in place of the OnExportCsv stub):
<RawCsvExportModal @bind-Visible="_exportVisible"
DeviceId="@_exportDeviceId" (may be blank if TagGroupId is supplied)
TagGroupId="@_exportTagGroupId" (when set, only that group's subtree is exported)
DriverType="@_exportDriverType" /> *@
@using System.Text
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject RawTagCsvExportReader ExportReader
@@ -43,7 +43,11 @@
<span>, group <span class="mono">@_groupFilter</span></span>
}
</p>
<textarea class="form-control mono small" rows="10" readonly>@_csv</textarea>
@if (_truncated)
{
<p class="small text-muted mb-1">Preview — first @RawTagCsvExport.PreviewLines lines. Download for the full file.</p>
}
<textarea class="form-control mono small" rows="10" readonly>@_preview</textarea>
}
</div>
<div class="modal-footer">
@@ -51,7 +55,7 @@
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Close</button>
@if (!_loading && _error is null)
{
<a class="btn btn-primary" download="@_fileName" href="@_dataUri">Download CSV</a>
<a class="btn btn-primary" download="@_fileName" href="@_downloadUrl">Download CSV</a>
}
</div>
</div>
@@ -79,8 +83,9 @@
private bool _loading;
private bool _built;
private string? _error;
private string _csv = "";
private string _dataUri = "";
private string _preview = "";
private bool _truncated;
private string _downloadUrl = "";
private string _fileName = "tags.csv";
private int _rowCount;
private string? _groupFilter;
@@ -110,48 +115,25 @@
_error = null;
try
{
var deviceId = DeviceId;
_groupFilter = null;
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
if (IsStale(generation)) { return; }
if (ctx is { } c)
{
_groupFilter = c.GroupPath;
if (string.IsNullOrEmpty(deviceId))
{
deviceId = c.DeviceId;
}
}
}
if (string.IsNullOrEmpty(deviceId))
// The same builder the download endpoint runs, so the preview matches the file — but mapping only
// the preview's rows (the header takes the first line), never the whole export (review r0 M8).
var export = await RawTagCsvExport.BuildAsync(ExportReader, DeviceId, TagGroupId, DriverType,
maxRows: RawTagCsvExport.PreviewLines - 1);
if (IsStale(generation)) { return; }
if (export is null)
{
_error = "Could not resolve the target device.";
return;
}
var tags = await ExportReader.ReadDeviceTagsAsync(deviceId);
if (IsStale(generation)) { return; }
// When exporting a single group, keep only that group's subtree (its path + descendants).
if (!string.IsNullOrEmpty(_groupFilter))
{
var prefix = _groupFilter;
tags = tags
.Where(t => t.TagGroupPath is not null &&
(t.TagGroupPath == prefix || t.TagGroupPath.StartsWith(prefix + "/", StringComparison.Ordinal)))
.ToList();
}
_rowCount = tags.Count;
_csv = RawTagCsvMapper.Export(DriverType, tags);
_fileName = BuildFileName(deviceId);
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(_csv));
_dataUri = $"data:text/csv;charset=utf-8;base64,{base64}";
_groupFilter = export.GroupPath;
_rowCount = export.RowCount;
_fileName = export.FileName;
// Only the bounded preview is kept on the component; the full CSV is not held by the circuit.
(_preview, var cut) = RawTagCsvExport.Preview(export.Csv);
_truncated = cut || export.RowCount > RawTagCsvExport.PreviewLines - 1;
_downloadUrl = RawTagCsvExport.DownloadUrl(DeviceId, TagGroupId, DriverType);
}
catch (Exception ex)
{
@@ -163,18 +145,13 @@
}
}
private static string BuildFileName(string deviceId)
{
var safe = new string(deviceId.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
return $"tags-{safe}.csv";
}
private async Task Close()
{
_built = false;
_openGeneration++;
_csv = "";
_dataUri = "";
_preview = "";
_truncated = false;
_downloadUrl = "";
Visible = false;
await VisibleChanged.InvokeAsync(false);
}
@@ -14,6 +14,7 @@
For a Device node: DeviceId = node.EntityId, TagGroupId = null.
For a TagGroup node: TagGroupId = node.EntityId (DeviceId is resolved from it if not passed). *@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IRawTreeService Svc
@inject RawTagCsvExportReader ExportReader
@@ -82,10 +83,10 @@
</tr>
</thead>
<tbody>
@foreach (var row in _result.Rows)
{
if (_removed.Contains(row.RowNumber)) { continue; }
<tr class="@(row.Ok ? "" : "table-danger")">
@* Virtualized (R04 P6): only the rows in the scrolled view render. *@
<Virtualize Items="@_view.VisibleRows" ItemSize="34"
SpacerElement="tr" Context="row">
<tr @key="row.RowNumber" class="@(row.Ok ? "" : "table-danger")">
<td>@(row.Ok ? "✓" : "✕")</td>
<td class="mono">@row.RowNumber</td>
<td class="mono">@row.Name</td>
@@ -124,7 +125,7 @@
@onclick="() => RemoveRow(row.RowNumber)">✕</button>
</td>
</tr>
}
</Virtualize>
</tbody>
</table>
</div>
@@ -184,7 +185,8 @@
[Parameter] public EventCallback OnSaved { get; set; }
private RawCsvParseResult? _result;
private readonly HashSet<int> _removed = new();
// The grid's filtered rows + counts, recomputed per change rather than per render (R04 P6).
private readonly RawCsvReviewView _view = new();
private List<string> _commitErrors = new();
private string? _fatal;
private bool _parsing;
@@ -202,9 +204,9 @@
private string TargetLabel =>
string.IsNullOrEmpty(_groupPrefix) ? _effectiveDeviceId : $"{_effectiveDeviceId} / {_groupPrefix}";
private int OkCount => _result?.Rows.Count(r => r.Ok && !_removed.Contains(r.RowNumber)) ?? 0;
private int ErrorCount => _result?.Rows.Count(r => !r.Ok && !_removed.Contains(r.RowNumber)) ?? 0;
private int RemainingCount => _result?.Rows.Count(r => !_removed.Contains(r.RowNumber)) ?? 0;
private int OkCount => _view.OkCount;
private int ErrorCount => _view.ErrorCount;
private int RemainingCount => _view.RemainingCount;
// Commit is unblocked only when every remaining row is valid AND at least one row remains.
private bool CanCommit =>
@@ -252,7 +254,7 @@
{
_fatal = null;
_commitErrors = new();
_removed.Clear();
_view.Set(null);
_parsing = true;
var generation = _openGeneration;
@@ -263,12 +265,14 @@
var text = await reader.ReadToEndAsync();
if (IsStale(generation)) { return; }
_result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix);
_view.Set(_result);
_fatal = _result.FatalError;
}
catch (Exception ex)
{
_fatal = $"Could not read the file: {ex.Message}";
_result = null;
_view.Set(null);
}
finally
{
@@ -276,14 +280,14 @@
}
}
private void RemoveRow(int rowNumber) => _removed.Add(rowNumber);
private void RemoveRow(int rowNumber) => _view.Remove(rowNumber);
private void Reset()
{
_result = null;
_fatal = null;
_commitErrors = new();
_removed.Clear();
_view.Set(null);
}
private async Task Commit()
@@ -299,10 +303,7 @@
var targetDeviceId = _effectiveDeviceId;
try
{
var rows = _result.Rows
.Where(r => r is { Ok: true, ImportRow: not null } && !_removed.Contains(r.RowNumber))
.Select(r => r.ImportRow!)
.ToList();
var rows = _view.CommitRows();
var outcome = await Svc.ImportTagsAsync(targetDeviceId, rows);
// The write has landed whether or not the modal was closed/reopened meanwhile, so the parent must
@@ -217,15 +217,23 @@
}
}
/// <summary>Parameters handed to the typed TagConfig editor. Internal so tests can assert them.</summary>
internal IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
{
["ConfigJson"] = _form.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
["DriverType"] = DriverType ?? "",
// Live accessor, not a snapshot: the picker reads it when the operator clicks Browse.
["GetDriverConfigJson"] = (Func<string>)(() => _mergedConfigJson ?? "{}"),
};
/// <summary>Parameters handed to the typed TagConfig editor. Internal so tests can assert them. Cached
/// on the values it carries and rebuilt when one changes (never build-once, which would pin the first
/// render's TagConfig); the callback and accessor are created once (R04 H2).</summary>
internal IDictionary<string, object> BuildEditorParameters() => _editorParameterCache.Get(
[_form.TagConfig, DriverType ?? ""],
() => new Dictionary<string, object>
{
["ConfigJson"] = _form.TagConfig,
["ConfigJsonChanged"] = _configJsonChanged ??=
EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
["DriverType"] = DriverType ?? "",
// Live accessor, not a snapshot: the picker reads it when the operator clicks Browse.
["GetDriverConfigJson"] = _getDriverConfigJson ??= () => _mergedConfigJson ?? "{}",
});
private readonly ParameterDictionaryCache _editorParameterCache = new();
private EventCallback<string>? _configJsonChanged;
private Func<string>? _getDriverConfigJson;
private async Task SaveAsync()
{
@@ -17,15 +17,19 @@
surface for delete-blockers and rename warnings. This
component is a single instance that recurses via the RenderNode RenderFragment, so it
owns one set of modals + one injected service for the whole tree. *@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@inject IRawTreeService Svc
@foreach (var root in Roots)
{
@RenderNode(root, 0)
}
@* One flat, virtualized row list (R04 P5): only the rows in view render, and each expanded level
shows RawTreeRows.DefaultPageSize children before a "Show more" row. Flatten runs per render; it is
O(visible rows) with no allocation per node beyond one row struct, so every mutation path below
(reload, toggle, load) is picked up without separate invalidation. *@
<Virtualize Items="@RawTreeRows.Flatten(Roots)" ItemSize="30" OverscanCount="20" Context="row">
@RenderRow(row)
</Virtualize>
@* --- Real modals + dialogs (one instance each for the whole recursive tree) --- *@
<DriverConfigModal @bind-Visible="_driverCfgVisible" DriverInstanceId="@_driverCfgId" OnSaved="DriverCfgSaved" />
@@ -163,12 +167,45 @@
// Row rendering
// ---------------------------------------------------------------------------
// Per-node menus are built once and reused across renders (R04 P5); see RawNodeMenuCache.
private readonly RawNodeMenuCache _menus = new();
private RenderFragment RenderRow(RawTreeRow row) => __builder =>
{
switch (row.Kind)
{
case RawTreeRowKind.Node:
@RenderNode(row.Node, row.Depth)
break;
case RawTreeRowKind.Loading:
<div @key="row.Key" class="small text-muted py-1" style="@($"padding-left:{row.Depth * 18}px")">
<span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
</div>
break;
case RawTreeRowKind.Error:
<div @key="row.Key" class="small text-danger py-1" style="@($"padding-left:{row.Depth * 18}px")">
@row.Node.Error
</div>
break;
default:
<div @key="row.Key" class="small py-1" style="@($"padding-left:{row.Depth * 18}px")">
<button type="button" class="btn btn-sm btn-link p-0" @onclick="@(() => ShowMore(row.Node))">
Show @Math.Min(row.Remaining, RawTreeRows.DefaultPageSize) more
(@row.Remaining not shown)
</button>
</div>
break;
}
};
private void ShowMore(RawNode node) => RawTreeRows.ShowMore(node);
private RenderFragment RenderNode(RawNode node, int depth) => __builder =>
{
var indent = $"padding-left:{depth * 18}px";
var hasExpander = node.HasLazyChildren || node.Children.Count > 0;
var muted = node.Enabled == false && node.Kind is RawNodeKind.Driver or RawNodeKind.Device;
var items = MenuFor(node);
var items = _menus.GetOrBuild(node, PickerMode, MenuFor);
<div @key="node.Key" class="d-flex align-items-center gap-1 py-1" style="@indent">
@if (hasExpander)
@@ -194,26 +231,6 @@
@RenderRowBody(node, muted)
}
</div>
@if (node.Expanded && node.Loading)
{
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
<span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
</div>
}
else if (node.Expanded && node.Error is not null)
{
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">
@node.Error
</div>
}
else if (node.Expanded)
{
@foreach (var child in node.Children)
{
@RenderNode(child, depth + 1)
}
}
};
// The icon + label + badge shown for a node — the ContextMenu right-click surface.
@@ -31,7 +31,7 @@
<textarea class="form-control form-control-sm mono" rows="12"
@bind="_csv" @bind:event="oninput" disabled="@_busy"
placeholder="Name,MachineCode,UnsLineId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,LINE-1,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
<div class="form-text">Simple comma-separated values only — fields containing commas are not supported.</div>
<div class="form-text">RFC 4180 CSV: wrap a field containing commas, quotes or line breaks in double quotes (a quote inside is doubled: <span class="mono">""</span>). The opening quote must come straight after the comma — <span class="mono">a, "b"</span> is rejected.</div>
@if (!string.IsNullOrWhiteSpace(_parseError))
{
@@ -75,8 +75,6 @@
}
@code {
// Required, in order. v3: equipment no longer binds a driver, so DriverInstanceId is gone.
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId"];
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
@@ -134,60 +132,12 @@
private Task CloseAsync() =>
_result is not null ? OnImported.InvokeAsync() : OnCancel.InvokeAsync();
/// <summary>
/// Parses the pasted CSV into <see cref="EquipmentInput"/> rows. Requires a header row whose first
/// three columns are Name, MachineCode, UnsLineId (case-insensitive); the optional
/// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as <c>null</c>. Returns
/// <c>null</c> (and sets <see cref="_parseError"/>) when the CSV is empty or the header is wrong.
/// </summary>
/// <summary>Parses the pasted CSV via <see cref="EquipmentCsvParser"/> (RFC 4180, R04 S6); sets
/// <see cref="_parseError"/> and returns null on an empty/malformed CSV or a wrong header.</summary>
private List<EquipmentInput>? ParseCsv(string csv)
{
if (string.IsNullOrWhiteSpace(csv)) { _parseError = "CSV is empty."; return null; }
var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length < 2) { _parseError = "Need a header row and at least one data row."; return null; }
var header = lines[0].Split(',').Select(c => c.Trim()).ToArray();
for (var i = 0; i < RequiredColumns.Length; i++)
{
if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
{
_parseError = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').";
return null;
}
}
var rows = new List<EquipmentInput>();
for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++)
{
// NOTE: simple comma split — no RFC4180 quoting; values with commas are not supported.
var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray();
if (parts.Length < RequiredColumns.Length)
{
_parseError = $"Row {lineIdx}: too few columns (got {parts.Length}, need {RequiredColumns.Length}).";
return null;
}
rows.Add(new EquipmentInput(
Name: parts[0],
MachineCode: parts[1],
UnsLineId: parts[2],
ZTag: NullIfEmpty(parts, 3),
SAPID: NullIfEmpty(parts, 4),
Manufacturer: NullIfEmpty(parts, 5),
Model: NullIfEmpty(parts, 6),
SerialNumber: null,
HardwareRevision: null,
SoftwareRevision: null,
YearOfConstruction: null,
AssetLocation: null,
ManufacturerUri: null,
DeviceManualUri: null,
Enabled: true));
}
var (rows, error) = EquipmentCsvParser.Parse(csv);
_parseError = error;
return rows;
}
private static string? NullIfEmpty(string[] parts, int idx) =>
idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
}
@@ -43,6 +43,10 @@ public static class EndpointRouteBuilderExtensions
// ambiguous.
app.MapRazorComponents<TApp>()
.AddInteractiveServerRenderMode();
// The /raw CSV export modal's download link (R04 P6): the file goes over plain HTTP instead of
// being embedded in the circuit. ConfigEditor, like the /raw page itself.
app.MapRawTagCsvExport();
return app;
}
@@ -81,6 +85,8 @@ public static class EndpointRouteBuilderExtensions
services.AddScoped<Acls.IAclScopeService, Acls.AclScopeService>();
// CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
services.AddScoped<RawTagCsvExportReader>();
// /hosts eviction: what the fleet is running (cached per deployment id), unioned with the authored set.
services.AddSingleton<Hosts.DeployedDriverSet>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
// Bespoke Sql schema browser (server → schema → table → column). Constructible bare — every
@@ -0,0 +1,20 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hosts;
/// <summary>
/// A thread-safe "something changed since you last looked" flag. A high-rate producer (the driver
/// health store raises once per driver per publish, on the bridge actor's thread) calls
/// <see cref="Mark"/>, which is cheap and never renders; the page's own periodic tick calls
/// <see cref="TryConsume"/> and does the expensive re-projection + render at most once per tick,
/// however many marks arrived in between.
/// </summary>
public sealed class CoalescingDirtyFlag
{
private int _dirty;
/// <summary>Records that the underlying data changed. Safe from any thread.</summary>
public void Mark() => Volatile.Write(ref _dirty, 1);
/// <summary>Returns whether anything was marked since the last call, and clears the flag.</summary>
/// <returns><see langword="true"/> when at least one <see cref="Mark"/> happened since the last consume.</returns>
public bool TryConsume() => Interlocked.Exchange(ref _dirty, 0) == 1;
}
@@ -0,0 +1,118 @@
using System.Collections.Concurrent;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hosts;
/// <summary>
/// The driver instance ids the fleet is (or may still be) RUNNING, read from deployment state rather than
/// from the authored <c>DriverInstances</c> table (review D-b r1, I1). <c>/hosts</c> unions this with the
/// authored set before evicting snapshots, so a driver deleted in <c>/raw</c> but not yet deployed keeps
/// its row however quiet it is — <c>DriverInstanceActor</c> dedups its health publishes on a fingerprint,
/// so a Faulted, idle or tagless driver that is still running may not publish for hours, and staleness
/// alone cannot prove it stopped.
/// </summary>
/// <remarks>
/// <para>"Running" deployments are, per node, its latest <c>NodeDeploymentState</c> row (whatever its
/// status — an Applying/Failed apply may already be running part of it) and its latest Applied row (what
/// it ran before a newer one failed), plus the latest Sealed deployment. The union over-approximates on
/// purpose: keeping a ghost row a little longer is harmless, hiding a running driver is the defect.</para>
/// <para>Each artifact is parsed once and cached by deployment id — artifacts are immutable — and only
/// the one <c>ArtifactBlob</c> column of an uncached id is ever read. The cache is pruned to the current
/// id set every call, so it stays the size of the fleet's live deployments.</para>
/// <para>Fail-safe: any id whose artifact cannot be read (missing, retention-emptied, query failure)
/// makes <see cref="LoadAsync"/> return null, and the caller then skips eviction for that sweep.</para>
/// </remarks>
public sealed class DeployedDriverSet
{
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly Func<byte[], IEnumerable<string>> _parseDriverIds;
private readonly ILogger<DeployedDriverSet>? _logger;
private readonly ConcurrentDictionary<Guid, IReadOnlySet<string>> _byDeployment = new();
/// <summary>Creates the set.</summary>
/// <param name="dbFactory">The config DB factory.</param>
/// <param name="logger">Optional logger.</param>
/// <param name="parseDriverIds">Artifact → driver instance ids; defaults to <see cref="DeploymentArtifact.ParseDriverInstances(ReadOnlySpan{byte})"/>.</param>
public DeployedDriverSet(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
ILogger<DeployedDriverSet>? logger = null,
Func<byte[], IEnumerable<string>>? parseDriverIds = null)
{
_dbFactory = dbFactory;
_logger = logger;
_parseDriverIds = parseDriverIds
?? (blob => DeploymentArtifact.ParseDriverInstances(blob).Select(s => s.DriverInstanceId));
}
/// <summary>The number of cached artifacts (for tests).</summary>
internal int CachedDeployments => _byDeployment.Count;
/// <summary>Loads the deployed driver instance ids, or null when they cannot be determined this time.</summary>
/// <param name="ct">Cancellation token; a cancellation propagates.</param>
/// <returns>The ids (ordinal-ignore-case), or null ⇒ do not evict.</returns>
public async Task<IReadOnlySet<string>?> LoadAsync(CancellationToken ct = default)
{
try
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
var latestPerNode = await db.NodeDeploymentStates.AsNoTracking()
.GroupBy(n => n.NodeId)
.Select(g => g.OrderByDescending(x => x.StartedAtUtc).Select(x => x.DeploymentId).First())
.ToListAsync(ct);
var latestAppliedPerNode = await db.NodeDeploymentStates.AsNoTracking()
.Where(n => n.Status == NodeDeploymentStatus.Applied)
.GroupBy(n => n.NodeId)
.Select(g => g.OrderByDescending(x => x.StartedAtUtc).Select(x => x.DeploymentId).First())
.ToListAsync(ct);
var latestSealed = await db.Deployments.AsNoTracking()
.Where(d => d.Status == DeploymentStatus.Sealed)
.OrderByDescending(d => d.CreatedAtUtc)
.Select(d => (Guid?)d.DeploymentId)
.FirstOrDefaultAsync(ct);
var ids = latestPerNode.Concat(latestAppliedPerNode).ToHashSet();
if (latestSealed is { } s) ids.Add(s);
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var id in ids)
{
if (!_byDeployment.TryGetValue(id, out var drivers))
{
// Only the blob column of this one row — never the whole entity, never every row.
var blob = await db.Deployments.AsNoTracking()
.Where(d => d.DeploymentId == id)
.Select(d => d.ArtifactBlob)
.FirstOrDefaultAsync(ct);
if (blob is null || blob.Length == 0)
{
// Unknown, not empty: a running node may still hold drivers this artifact named.
_logger?.LogDebug("/hosts: artifact of deployment {DeploymentId} is unavailable; skipping eviction", id);
return null;
}
drivers = _parseDriverIds(blob).ToHashSet(StringComparer.OrdinalIgnoreCase);
_byDeployment[id] = drivers;
}
result.UnionWith(drivers);
}
foreach (var cached in _byDeployment.Keys)
{
if (!ids.Contains(cached)) _byDeployment.TryRemove(cached, out _);
}
return result;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger?.LogWarning(ex, "/hosts: could not read the deployed driver set; skipping eviction this sweep.");
return null;
}
}
}
@@ -57,15 +57,16 @@ public sealed class AlertSignalRBridge : ReceiveActor
private async Task ForwardAsync(AlarmTransitionEvent msg)
{
// In-process fan-out first — this is what the Blazor Server Alerts page reads. The hub push
// is kept for any out-of-process (e.g. WASM) SignalR client.
_broadcaster.Publish(msg);
// is kept for any out-of-process (e.g. WASM) SignalR client. Both sit inside the try so a
// broadcast failure cannot restart the bridge (and drop its DPS subscription).
try
{
_broadcaster.Publish(msg);
await _hub.Clients.All.SendAsync(AlertHub.MethodName, msg);
}
catch (Exception ex)
{
_log.Warning(ex, "AlertSignalRBridge: SignalR push failed for {AlarmId}", msg.AlarmId);
_log.Warning(ex, "AlertSignalRBridge: forward failed for {AlarmId}", msg.AlarmId);
}
}
}
@@ -1,5 +1,6 @@
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
@@ -17,6 +18,7 @@ public sealed class DriverResilienceStatusBridge : ReceiveActor
public const string TopicName = DriverResilienceStatusChanged.TopicName;
private readonly IDriverResilienceStatusStore _store;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>Creates actor props for a <see cref="DriverResilienceStatusBridge"/>.</summary>
/// <param name="store">Resilience status store updated on every forwarded snapshot.</param>
@@ -29,7 +31,12 @@ public sealed class DriverResilienceStatusBridge : ReceiveActor
public DriverResilienceStatusBridge(IDriverResilienceStatusStore store)
{
_store = store;
Receive<DriverResilienceStatusChanged>(msg => _store.Upsert(msg));
Receive<DriverResilienceStatusChanged>(msg =>
{
// A store failure must not restart the bridge (a restart re-subscribes DPS and drops messages).
try { _store.Upsert(msg); }
catch (Exception ex) { _log.Warning(ex, "DriverResilienceStatusBridge: upsert failed for {0}", msg.DriverInstanceId); }
});
Receive<SubscribeAck>(_ => { /* DPS confirmation */ });
}
@@ -27,6 +27,22 @@ public interface IDriverResilienceStatusStore
/// <returns>A read-only collection of the last-known resilience snapshot for each pair.</returns>
IReadOnlyCollection<DriverResilienceStatusChanged> GetAll();
/// <summary>
/// Evicts every snapshot whose driver instance is not in <paramref name="configuredInstanceIds"/>
/// <b>and</b> whose last publish is older than <paramref name="olderThanUtc"/>. The stores have no other
/// removal path, so a deleted (or re-created under a new id) driver would otherwise keep its last
/// snapshot until the process restarts. Both conditions are required: the configured set comes from
/// the AUTHORED (draft) table, so a driver deleted in <c>/raw</c> but not yet deployed is still
/// running and still publishing — it must stay visible until a deploy actually stops it and its
/// snapshot goes stale. The <c>/hosts</c> page calls this after each successful config load; a failed
/// load must never call it.
/// </summary>
/// <param name="configuredInstanceIds">The configured driver-instance ids to keep. The set's own
/// comparer decides matching.</param>
/// <param name="olderThanUtc">Only an unconfigured entry published before this instant is evicted.</param>
/// <returns>The number of entries removed.</returns>
int EvictUnconfiguredStale(IReadOnlySet<string> configuredInstanceIds, DateTime olderThanUtc);
/// <summary>
/// Raised after every <see cref="Upsert"/> with the just-stored snapshot. Lets the Blazor Server
/// driver status panel receive live updates by reading this singleton directly. Handlers run on
@@ -25,6 +25,22 @@ public interface IDriverStatusSnapshotStore
/// <returns>A read-only collection of the last-known health snapshot for each driver instance.</returns>
IReadOnlyCollection<DriverHealthChanged> GetAll();
/// <summary>
/// Evicts every snapshot whose driver instance is not in <paramref name="configuredInstanceIds"/>
/// <b>and</b> whose last publish is older than <paramref name="olderThanUtc"/>. The stores have no other
/// removal path, so a deleted (or re-created under a new id) driver would otherwise keep its last
/// snapshot until the process restarts. Both conditions are required: the configured set comes from
/// the AUTHORED (draft) table, so a driver deleted in <c>/raw</c> but not yet deployed is still
/// running and still publishing — it must stay visible until a deploy actually stops it and its
/// snapshot goes stale. The <c>/hosts</c> page calls this after each successful config load; a failed
/// load must never call it.
/// </summary>
/// <param name="configuredInstanceIds">The configured driver-instance ids to keep. The set's own
/// comparer decides matching.</param>
/// <param name="olderThanUtc">Only an unconfigured entry published before this instant is evicted.</param>
/// <returns>The number of entries removed.</returns>
int EvictUnconfiguredStale(IReadOnlySet<string> configuredInstanceIds, DateTime olderThanUtc);
/// <summary>
/// Raised after every <see cref="Upsert"/> with the just-stored snapshot. Lets in-process
/// consumers (the Blazor Server <c>DriverStatusPanel</c>) receive live updates by reading
@@ -1,3 +1,5 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
/// <summary>
@@ -59,6 +61,12 @@ public sealed class InProcessBroadcaster<T> : IInProcessBroadcaster<T>
// render thread, so access must be serialised.
private readonly object _connectionLock = new();
private bool _isConnected;
private readonly ILogger<InProcessBroadcaster<T>>? _logger;
private readonly FailureLogThrottle _failureLog = new();
/// <summary>Creates the broadcaster.</summary>
/// <param name="logger">Optional logger for a subscriber that throws during a raise.</param>
public InProcessBroadcaster(ILogger<InProcessBroadcaster<T>>? logger = null) => _logger = logger;
/// <summary>Raised once per <see cref="Publish"/> with the published item, on the caller's thread.</summary>
public event Action<T>? Received;
@@ -67,8 +75,10 @@ public sealed class InProcessBroadcaster<T> : IInProcessBroadcaster<T>
public event Action<bool>? ConnectionStateChanged;
/// <inheritdoc />
// Capture-then-invoke (via ?.) so a concurrent unsubscribe can't null the delegate mid-raise.
public void Publish(T item) => Received?.Invoke(item);
// Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise; one subscriber
// at a time so a throwing one cannot starve the rest or escape onto the bridge actor.
public void Publish(T item) =>
SafeMulticast.Invoke(Received, item, ex => _logger?.Log(_failureLog.NextLevel(), ex, "In-process broadcast subscriber threw"));
/// <inheritdoc />
public bool IsConnected
@@ -99,6 +109,7 @@ public sealed class InProcessBroadcaster<T> : IInProcessBroadcaster<T>
handler = ConnectionStateChanged;
}
handler?.Invoke(connected);
SafeMulticast.Invoke(handler, connected,
ex => _logger?.Log(_failureLog.NextLevel(), ex, "In-process connection-state subscriber threw"));
}
}
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
@@ -10,6 +11,13 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
public sealed class InMemoryDriverResilienceStatusStore : IDriverResilienceStatusStore
{
private readonly ConcurrentDictionary<(string Instance, string Host), DriverResilienceStatusChanged> _byPair = new();
private readonly ILogger<InMemoryDriverResilienceStatusStore>? _logger;
private readonly FailureLogThrottle _failureLog = new();
/// <summary>Creates the store.</summary>
/// <param name="logger">Optional logger for a subscriber that throws during a raise.</param>
public InMemoryDriverResilienceStatusStore(ILogger<InMemoryDriverResilienceStatusStore>? logger = null)
=> _logger = logger;
/// <summary>Raised after every <see cref="Upsert"/> with the just-stored snapshot.</summary>
public event Action<DriverResilienceStatusChanged>? SnapshotChanged;
@@ -18,8 +26,24 @@ public sealed class InMemoryDriverResilienceStatusStore : IDriverResilienceStatu
public void Upsert(DriverResilienceStatusChanged snapshot)
{
_byPair[(snapshot.DriverInstanceId, snapshot.HostName)] = snapshot;
// Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise.
SnapshotChanged?.Invoke(snapshot);
// Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise; one
// subscriber at a time so a throwing one cannot starve the rest (or escape onto the bridge actor).
SafeMulticast.Invoke(SnapshotChanged, snapshot,
ex => _logger?.Log(_failureLog.NextLevel(), ex, "Driver resilience subscriber threw for {DriverInstanceId}", snapshot.DriverInstanceId));
}
/// <inheritdoc />
public int EvictUnconfiguredStale(IReadOnlySet<string> configuredInstanceIds, DateTime olderThanUtc)
{
var removed = 0;
foreach (var (key, snapshot) in _byPair)
{
if (configuredInstanceIds.Contains(key.Instance) || snapshot.PublishedUtc >= olderThanUtc) continue;
// Remove only the exact snapshot inspected: a fresh publish that raced in is kept.
if (_byPair.TryRemove(new KeyValuePair<(string Instance, string Host), DriverResilienceStatusChanged>(key, snapshot))) removed++;
}
return removed;
}
/// <inheritdoc />
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
@@ -10,6 +11,13 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotStore
{
private readonly ConcurrentDictionary<string, DriverHealthChanged> _byInstance = new();
private readonly ILogger<InMemoryDriverStatusSnapshotStore>? _logger;
private readonly FailureLogThrottle _failureLog = new();
/// <summary>Creates the store.</summary>
/// <param name="logger">Optional logger for a subscriber that throws during a raise.</param>
public InMemoryDriverStatusSnapshotStore(ILogger<InMemoryDriverStatusSnapshotStore>? logger = null)
=> _logger = logger;
/// <summary>Raised whenever a driver status snapshot is stored or updated.</summary>
public event Action<DriverHealthChanged>? SnapshotChanged;
@@ -18,8 +26,24 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
public void Upsert(DriverHealthChanged snapshot)
{
_byInstance[snapshot.DriverInstanceId] = snapshot;
// Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise.
SnapshotChanged?.Invoke(snapshot);
// Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise; one
// subscriber at a time so a throwing one cannot starve the rest (or escape onto the bridge actor).
SafeMulticast.Invoke(SnapshotChanged, snapshot,
ex => _logger?.Log(_failureLog.NextLevel(), ex, "Driver status subscriber threw for {DriverInstanceId}", snapshot.DriverInstanceId));
}
/// <inheritdoc />
public int EvictUnconfiguredStale(IReadOnlySet<string> configuredInstanceIds, DateTime olderThanUtc)
{
var removed = 0;
foreach (var (id, snapshot) in _byInstance)
{
if (configuredInstanceIds.Contains(id) || snapshot.PublishedUtc >= olderThanUtc) continue;
// Remove only the exact snapshot inspected: a fresh publish that raced in is kept.
if (_byInstance.TryRemove(new KeyValuePair<string, DriverHealthChanged>(id, snapshot))) removed++;
}
return removed;
}
/// <inheritdoc />
@@ -0,0 +1,68 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
/// <summary>
/// Logs a subscriber failure at Warning at most once per <see cref="Interval"/>, and at Debug otherwise,
/// so a subscriber that throws on every publish is visible at the default level without flooding it.
/// </summary>
internal sealed class FailureLogThrottle
{
/// <summary>The minimum gap between Warning-level entries.</summary>
public static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
private readonly Func<long> _nowMs;
private long _lastWarnMs = long.MinValue;
/// <summary>Creates the throttle.</summary>
/// <param name="nowMs">Monotonic milliseconds; <see cref="Environment.TickCount64"/> when null.</param>
public FailureLogThrottle(Func<long>? nowMs = null) => _nowMs = nowMs ?? (() => Environment.TickCount64);
/// <summary>The level the next failure should log at; claims the Warning slot when it is free.</summary>
/// <returns><see cref="LogLevel.Warning"/> at most once per interval, else <see cref="LogLevel.Debug"/>.</returns>
public LogLevel NextLevel()
{
var now = _nowMs();
var last = Interlocked.Read(ref _lastWarnMs);
if (last != long.MinValue && now - last < (long)Interval.TotalMilliseconds) return LogLevel.Debug;
return Interlocked.CompareExchange(ref _lastWarnMs, now, last) == last ? LogLevel.Warning : LogLevel.Debug;
}
}
/// <summary>
/// Raises a multicast delegate one subscriber at a time, so a subscriber that throws cannot stop
/// delivery to the subscribers after it. The in-process stores and broadcasters raise their events on
/// the bridge actor's thread; a plain <c>handler?.Invoke(x)</c> stops at the first throwing handler (for
/// example a Blazor circuit whose renderer is mid-teardown), so the remaining panels miss the update and
/// the exception escapes into the bridge actor, which restarts it.
/// </summary>
internal static class SafeMulticast
{
/// <summary>Invokes every subscriber of <paramref name="handler"/> with <paramref name="arg"/>, isolating failures.</summary>
/// <typeparam name="T">The event payload type.</typeparam>
/// <param name="handler">The captured multicast delegate; null means no subscribers.</param>
/// <param name="arg">The payload passed to each subscriber.</param>
/// <param name="onError">Optional sink for a subscriber's exception; it must not throw.</param>
/// <returns>The number of subscribers that threw.</returns>
public static int Invoke<T>(Action<T>? handler, T arg, Action<Exception>? onError = null)
{
if (handler is null) return 0;
var failures = 0;
foreach (var subscriber in handler.GetInvocationList())
{
try
{
((Action<T>)subscriber)(arg);
}
catch (Exception ex)
{
failures++;
try { onError?.Invoke(ex); }
catch { /* the error sink must never take delivery down with it */ }
}
}
return failures;
}
}
@@ -52,10 +52,11 @@ public sealed class ScriptLogSignalRBridge : ReceiveActor
private async Task ForwardAsync(ScriptLogEntry msg)
{
// In-process fan-out first — this is what the Blazor Server Script log page reads. The hub
// push is kept for any out-of-process (e.g. WASM) SignalR client.
_broadcaster.Publish(msg);
// push is kept for any out-of-process (e.g. WASM) SignalR client. Both sit inside the try so a
// broadcast failure cannot restart the bridge (and drop its DPS subscription).
try
{
_broadcaster.Publish(msg);
await _hub.Clients.All.SendAsync(ScriptLogHub.MethodName, msg);
}
catch (Exception ex)
@@ -0,0 +1,79 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail;
/// <summary>
/// Coalesces bursts of "please re-render" requests into at most one flush per window. The first
/// <see cref="Schedule"/> after a flush arms a one-shot timer; further calls inside the window are
/// no-ops; when the timer fires the flush callback runs once (the page marshals it onto its render
/// context with <c>InvokeAsync</c>). Disposal drains an in-flight callback and suppresses later ones.
/// </summary>
public sealed class CoalescingRenderScheduler : IAsyncDisposable
{
/// <summary>The default coalescing window for live-tail pages.</summary>
public static readonly TimeSpan DefaultWindow = TimeSpan.FromMilliseconds(200);
private readonly TimeSpan _window;
private readonly Func<Task> _flush;
private readonly ITimer _timer;
private int _armed;
private volatile bool _disposed;
/// <summary>Creates the scheduler.</summary>
/// <param name="window">The coalescing window.</param>
/// <param name="flush">The callback run once per window; exceptions are swallowed (the page is the
/// only consumer, and a throw here would surface as an unobserved timer-thread exception).</param>
/// <param name="timeProvider">The time source; <see cref="TimeProvider.System"/> when null.</param>
public CoalescingRenderScheduler(TimeSpan window, Func<Task> flush, TimeProvider? timeProvider = null)
{
_window = window;
_flush = flush;
_timer = (timeProvider ?? TimeProvider.System)
.CreateTimer(OnFire, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
}
/// <summary>Requests a flush within one window. Safe from any thread.</summary>
public void Schedule()
{
if (_disposed) return;
if (Interlocked.CompareExchange(ref _armed, 1, 0) != 0) return;
try
{
_timer.Change(_window, Timeout.InfiniteTimeSpan);
}
catch (ObjectDisposedException)
{
// Raced DisposeAsync — nothing left to render to.
}
}
private void OnFire(object? state)
{
// Disarm BEFORE flushing: an item arriving during the flush schedules the next window rather
// than being stranded until some later item happens to arrive.
Volatile.Write(ref _armed, 0);
if (_disposed) return;
// Not async void: a fault is observed on the returned task, never thrown onto the timer thread.
// The circuit going away mid-flush (ObjectDisposedException and friends) is the expected fault,
// and a live-tail render failure is never worth an unhandled exception.
Task task;
try
{
task = _flush();
}
catch
{
return;
}
_ = task.ContinueWith(static t => _ = t.Exception, CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
_disposed = true;
await _timer.DisposeAsync().ConfigureAwait(false);
}
}
@@ -0,0 +1,94 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail;
/// <summary>
/// A bounded, newest-first buffer for a live event tail (the <c>/script-log</c> and <c>/alerts</c>
/// pages). Producers call <see cref="Add"/> from the bridge actor's thread; it only queues the item.
/// The page drains the queue into a fixed-capacity ring on its render context (<see cref="Flush"/>),
/// once per coalescing window, so a chatty feed costs one render per window instead of one per event,
/// and no <c>List.Insert(0, …)</c> memmove per event.
/// </summary>
/// <typeparam name="T">The event type.</typeparam>
public sealed class LiveTailBuffer<T>
{
private readonly object _gate = new();
private readonly T[] _ring;
private readonly List<T> _pending = new();
private int _head; // index the next item is written to
private int _count;
private IReadOnlyList<T> _snapshot = Array.Empty<T>();
/// <summary>Creates a buffer that keeps the most recent <paramref name="capacity"/> items.</summary>
/// <param name="capacity">Maximum retained items; must be positive.</param>
public LiveTailBuffer(int capacity)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
_ring = new T[capacity];
}
/// <summary>The maximum number of retained items.</summary>
public int Capacity => _ring.Length;
/// <summary>Retained items, newest first, as of the last <see cref="Flush"/> or <see cref="Clear"/>.</summary>
public IReadOnlyList<T> Items => Volatile.Read(ref _snapshot);
/// <summary>Queues an item. Safe from any thread; does not change <see cref="Items"/> until the next flush.</summary>
/// <param name="item">The item to queue.</param>
/// <returns><see langword="true"/> when the queue was empty before this call, i.e. the caller should
/// schedule a flush.</returns>
public bool Add(T item)
{
lock (_gate)
{
// A burst larger than the ring would be evicted at flush anyway; dropping the oldest pending
// now keeps the queue bounded when no flush runs (e.g. a stalled circuit).
if (_pending.Count >= _ring.Length) _pending.RemoveAt(0);
_pending.Add(item);
return _pending.Count == 1;
}
}
/// <summary>Moves the queued items into the ring (evicting the oldest past capacity) and refreshes <see cref="Items"/>.</summary>
/// <returns><see langword="true"/> when anything was queued, i.e. a render is worthwhile.</returns>
public bool Flush()
{
lock (_gate)
{
if (_pending.Count == 0) return false;
foreach (var item in _pending)
{
_ring[_head] = item;
_head = (_head + 1) % _ring.Length;
if (_count < _ring.Length) _count++;
}
_pending.Clear();
Volatile.Write(ref _snapshot, BuildNewestFirst());
return true;
}
}
/// <summary>Drops every retained and queued item.</summary>
public void Clear()
{
lock (_gate)
{
_pending.Clear();
Array.Clear(_ring);
_head = 0;
_count = 0;
Volatile.Write(ref _snapshot, Array.Empty<T>());
}
}
private T[] BuildNewestFirst()
{
var result = new T[_count];
for (var i = 0; i < _count; i++)
{
var idx = (_head - 1 - i + _ring.Length) % _ring.Length;
result[i] = _ring[idx];
}
return result;
}
}
@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptList;
/// <summary>One row of the <c>/scripts</c> list: everything the list renders, and nothing else.</summary>
/// <param name="ScriptId">The script's logical id.</param>
/// <param name="Name">The authored display name.</param>
/// <param name="Language">The script language (e.g. <c>CSharp</c>).</param>
/// <param name="SourceHash">The source hash shown abbreviated on the row.</param>
public sealed record ScriptListRow(string ScriptId, string Name, string Language, string SourceHash);
/// <summary>
/// Queries behind the <c>/scripts</c> page. The list is a projection that leaves out
/// <c>SourceCode</c> (R04 P8): loading every script's full source to render names and hashes made
/// the page cost proportional to the total size of every script in the fleet. The source is fetched
/// on demand, for one script, when its "Source" dialog opens.
/// </summary>
public static class ScriptListQueries
{
/// <summary>Loads the list rows, ordered by name, without any script's source.</summary>
/// <param name="db">The config DB context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The list rows.</returns>
public static Task<List<ScriptListRow>> LoadRowsAsync(OtOpcUaConfigDbContext db, CancellationToken ct = default) =>
db.Scripts.AsNoTracking()
.OrderBy(s => s.Name)
.Select(s => new ScriptListRow(s.ScriptId, s.Name, s.Language, s.SourceHash))
.ToListAsync(ct);
/// <summary>Loads one script's source, or null when the script no longer exists.</summary>
/// <param name="db">The config DB context.</param>
/// <param name="scriptId">The script's logical id.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The source code, or null.</returns>
public static Task<string?> LoadSourceAsync(OtOpcUaConfigDbContext db, string scriptId, CancellationToken ct = default) =>
db.Scripts.AsNoTracking()
.Where(s => s.ScriptId == scriptId)
.Select(s => (string?)s.SourceCode)
.FirstOrDefaultAsync(ct);
}
@@ -2,11 +2,6 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>A tag row for the equipment page's Tags tab table — display columns plus the id used to
/// open the edit modal. RowVersion is re-read fresh on delete (matching the tree's delete path).</summary>
public sealed record EquipmentTagRow(
string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel);
/// <summary>A virtual-tag row for the equipment page's Virtual Tags tab table.</summary>
public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled);
@@ -0,0 +1,85 @@
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Parses the equipment-import CSV (R04 S6). It used to be a naive comma split, so a quoted
/// <c>Manufacturer</c> containing a comma silently shifted every later column with no error. It now
/// reads through the RFC 4180 <see cref="CsvParser"/> — the same authority the raw-tag importer uses —
/// so quoted fields, embedded commas/newlines and doubled quotes parse correctly, and malformed quoting
/// is reported instead of guessed at.
/// </summary>
public static class EquipmentCsvParser
{
/// <summary>The leading header columns the import requires, in order (case-insensitive).</summary>
public static readonly IReadOnlyList<string> RequiredColumns = ["Name", "MachineCode", "UnsLineId"];
/// <summary>
/// Parses the CSV. Requires a header row whose first three columns are Name, MachineCode, UnsLineId;
/// the optional ZTag, SAPID, Manufacturer, Model follow. Cells are trimmed; blank optional cells parse
/// as null; blank lines are skipped.
/// </summary>
/// <param name="csv">The pasted CSV text.</param>
/// <returns>The rows, or a null list with an operator-facing error.</returns>
public static (List<EquipmentInput>? Rows, string? Error) Parse(string? csv)
{
if (string.IsNullOrWhiteSpace(csv)) return (null, "CSV is empty.");
IReadOnlyList<string[]> records;
try
{
records = CsvParser.Parse(csv);
}
catch (FormatException ex)
{
return (null, $"The CSV is malformed: {ex.Message}");
}
var lines = records
.Where(r => r.Length > 1 || r[0].Trim().Length > 0)
.Select(r => r.Select(c => c.Trim()).ToArray())
.ToList();
if (lines.Count < 2) return (null, "Need a header row and at least one data row.");
var header = lines[0];
for (var i = 0; i < RequiredColumns.Count; i++)
{
if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
{
return (null, $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').");
}
}
var rows = new List<EquipmentInput>(lines.Count - 1);
for (var lineIdx = 1; lineIdx < lines.Count; lineIdx++)
{
var parts = lines[lineIdx];
if (parts.Length < RequiredColumns.Count)
{
return (null, $"Row {lineIdx}: too few columns (got {parts.Length}, need {RequiredColumns.Count}).");
}
rows.Add(new EquipmentInput(
Name: parts[0],
MachineCode: parts[1],
UnsLineId: parts[2],
ZTag: NullIfEmpty(parts, 3),
SAPID: NullIfEmpty(parts, 4),
Manufacturer: NullIfEmpty(parts, 5),
Model: NullIfEmpty(parts, 6),
SerialNumber: null,
HardwareRevision: null,
SoftwareRevision: null,
YearOfConstruction: null,
AssetLocation: null,
ManufacturerUri: null,
DeviceManualUri: null,
Enabled: true));
}
return (rows, null);
}
private static string? NullIfEmpty(string[] parts, int idx) =>
idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
}
@@ -117,17 +117,6 @@ public interface IUnsTreeService
/// <returns>The enterprise root nodes, each populated down to equipment.</returns>
Task<IReadOnlyList<UnsNode>> LoadStructureAsync(CancellationToken ct = default);
/// <summary>
/// Loads the driver tags bound to a single equipment as flat row projections for the equipment
/// page's Tags tab table, ordered by Name. Each row carries the display columns plus the
/// <c>TagId</c> the table uses to open the edit modal. Reads untracked. Returns an empty list when
/// the equipment has no tags.
/// </summary>
/// <param name="equipmentId">The equipment whose tags to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The equipment's tag rows ordered by Name; empty if it has none.</returns>
Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Loads the virtual tags scoped to a single equipment as flat row projections for the equipment
/// page's Virtual Tags tab table, ordered by Name. Each row carries the display columns plus the
@@ -361,6 +361,10 @@ public static class RawBrowseCommitMapper
TryGetField(addressFields, MqttTagConfigKeys.Topic) ?? fullName);
}
/// <summary>The phrase the MQTT/Sparkplug browser puts on the descriptor of a metric it refuses to
/// bind (<c>MqttBrowseSession.DescribeMetric</c>); matched case-insensitively.</summary>
public const string NotBindableMarker = "not bindable";
/// <summary>
/// Describes why a selected browse leaf cannot be committed as a working tag, or <c>null</c> when it
/// can. The AdminUI calls this before mapping so an unbindable selection fails <b>at commit, in
@@ -369,6 +373,9 @@ public static class RawBrowseCommitMapper
/// <param name="driverType">The owning device's driver type.</param>
/// <param name="browseName">The leaf's browse name, for the message.</param>
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <param name="descriptor">The leaf attribute's descriptor line (<c>AttributeInfo.SecurityClass</c>),
/// when the lookup returned one. MQTT uses it to tell a deliberately unbindable Sparkplug metric
/// (<see cref="NotBindableMarker"/>) from a lookup that returned nothing.</param>
/// <returns>The operator-facing reason, or <c>null</c> when the leaf is committable.</returns>
/// <remarks>
/// <para>
@@ -394,9 +401,10 @@ public static class RawBrowseCommitMapper
public static string? DescribeUncommittableLeaf(
string driverType,
string browseName,
IReadOnlyDictionary<string, string>? addressFields)
IReadOnlyDictionary<string, string>? addressFields,
string? descriptor = null)
{
if (Is(driverType, DriverTypeNames.Mqtt)) return DescribeUncommittableMqttLeaf(browseName, addressFields);
if (Is(driverType, DriverTypeNames.Mqtt)) return DescribeUncommittableMqttLeaf(browseName, addressFields, descriptor);
if (Is(driverType, DriverTypeNames.Sql)) return DescribeUncommittableSqlLeaf(browseName, addressFields);
return null;
}
@@ -404,13 +412,25 @@ public static class RawBrowseCommitMapper
/// <summary>Describes an MQTT leaf that states no address at all, or <c>null</c> when it states one.</summary>
/// <param name="browseName">The leaf's browse name, for the message.</param>
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <param name="descriptor">The attribute's descriptor line (<c>AttributeInfo.SecurityClass</c>), if the
/// lookup returned one.</param>
/// <returns>The operator-facing reason, or <c>null</c>.</returns>
private static string? DescribeUncommittableMqttLeaf(
string browseName, IReadOnlyDictionary<string, string>? addressFields)
string browseName, IReadOnlyDictionary<string, string>? addressFields, string? descriptor)
{
if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is not null) return null;
if (TryGetField(addressFields, MqttTagConfigKeys.Topic) is not null) return null;
// G2-F2: the Sparkplug browser deliberately states no address for a metric the ingestor will not
// read (an *Array type, or a type with no driver mapping — R06:M-5), and says so on its descriptor
// line. That is a "not bindable in this version" refusal, not a failed lookup, so say which.
if (descriptor is not null && descriptor.Contains(NotBindableMarker, StringComparison.OrdinalIgnoreCase))
{
return $"'{browseName}' cannot be bound in this version: the Sparkplug browser reports it as "
+ $"\"{descriptor}\". Array metrics and metrics of an unsupported type are not read yet; "
+ "remove it from the selection.";
}
return $"'{browseName}' could not be committed: the browse session reported no MQTT address for it "
+ "(its attribute lookup returned nothing). Re-open the browser and re-select it, or author "
+ "the tag manually.";
@@ -0,0 +1,65 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// The CSV import review grid's view state (R04 P6): the parsed rows minus the ones the operator removed,
/// plus the OK / invalid / remaining counts. The modal used to re-scan every parsed row three times per
/// render for the counts and filter removals inside the row loop; this recomputes once per change and
/// hands <c>Virtualize</c> a ready list, so a 100k-row file renders only the rows in view.
/// </summary>
public sealed class RawCsvReviewView
{
private readonly HashSet<int> _removed = new();
private System.Collections.ObjectModel.ReadOnlyCollection<RawCsvReviewRow> _visible = new(new List<RawCsvReviewRow>());
/// <summary>The rows still in the import, in file order.</summary>
public System.Collections.ObjectModel.ReadOnlyCollection<RawCsvReviewRow> VisibleRows => _visible;
/// <summary>Remaining valid rows.</summary>
public int OkCount { get; private set; }
/// <summary>Remaining invalid rows.</summary>
public int ErrorCount { get; private set; }
/// <summary>Remaining rows (valid + invalid).</summary>
public int RemainingCount => _visible.Count;
/// <summary>The valid remaining rows' import rows, in file order.</summary>
/// <returns>The rows to commit.</returns>
public IReadOnlyList<RawTagImportRow> CommitRows() =>
_visible.Where(r => r is { Ok: true, ImportRow: not null }).Select(r => r.ImportRow!).ToList();
/// <summary>Replaces the parsed result (null clears) and forgets any removals.</summary>
/// <param name="result">The parse result.</param>
public void Set(RawCsvParseResult? result)
{
_removed.Clear();
_source = result?.Rows ?? Array.Empty<RawCsvReviewRow>();
Recompute();
}
/// <summary>Removes one row (by its file row number) from the import.</summary>
/// <param name="rowNumber">The row's 1-based data-row number.</param>
public void Remove(int rowNumber)
{
if (_removed.Add(rowNumber)) Recompute();
}
private IReadOnlyList<RawCsvReviewRow> _source = Array.Empty<RawCsvReviewRow>();
private void Recompute()
{
var visible = new List<RawCsvReviewRow>(_source.Count);
int ok = 0, bad = 0;
foreach (var row in _source)
{
if (_removed.Contains(row.RowNumber)) continue;
visible.Add(row);
if (row.Ok) ok++;
else bad++;
}
_visible = visible.AsReadOnly();
OkCount = ok;
ErrorCount = bad;
}
}
@@ -98,4 +98,8 @@ public sealed class RawNode
/// <summary>Last load error message, if any.</summary>
public string? Error { get; set; }
/// <summary>How many of <see cref="Children"/> the tree shows before a "Show more" row
/// (<see cref="RawTreeRows.DefaultPageSize"/> per page).</summary>
public int VisibleChildLimit { get; set; } = RawTreeRows.DefaultPageSize;
}
@@ -0,0 +1,38 @@
using System.Runtime.CompilerServices;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Caches each <see cref="RawNode"/>'s context-menu item list (R04 P5). <c>RawTree</c> used to build a
/// fresh list (and fresh closures) per node on every render, and hand it to that row's
/// <c>ContextMenu</c> as a new reference each time. The cache is keyed weakly on the node instance, so
/// a reload (which replaces the child nodes) drops the stale entries with the nodes, and it rebuilds
/// when anything the menu depends on (picker mode, the node's enabled state) differs.
/// </summary>
public sealed class RawNodeMenuCache
{
private readonly ConditionalWeakTable<RawNode, Entry> _entries = new();
/// <summary>Returns the cached menu for <paramref name="node"/>, building it on first use or after a dependency changed.</summary>
/// <param name="node">The node.</param>
/// <param name="pickerMode">Whether the tree is in picker mode (picker menus differ entirely).</param>
/// <param name="build">Builds the menu for the node.</param>
/// <returns>The menu items.</returns>
public IReadOnlyList<ContextMenuItem> GetOrBuild(
RawNode node, bool pickerMode, Func<RawNode, IReadOnlyList<ContextMenuItem>> build)
{
if (_entries.TryGetValue(node, out var entry)
&& entry.PickerMode == pickerMode
&& entry.Enabled == node.Enabled)
{
return entry.Items;
}
var items = build(node);
_entries.AddOrUpdate(node, new Entry(pickerMode, node.Enabled, items));
return items;
}
private sealed record Entry(bool PickerMode, bool Enabled, IReadOnlyList<ContextMenuItem> Items);
}
@@ -0,0 +1,153 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>A built raw-tag CSV export.</summary>
/// <param name="Csv">The CSV text.</param>
/// <param name="FileName">The suggested download file name.</param>
/// <param name="RowCount">The number of exported tags.</param>
/// <param name="GroupPath">The exported group's device-relative path, or null for a whole-device export.</param>
public sealed record RawTagCsvExportResult(string Csv, string FileName, int RowCount, string? GroupPath);
/// <summary>
/// Builds raw-tag CSV exports, and serves them as a download (R04 P6). The export modal used to embed
/// the whole CSV in the circuit twice (a textarea plus a base64 <c>data:</c> URI on the anchor), so a
/// multi-MB export pushed ≈2.3× its size over SignalR into the DOM. The modal now shows a bounded
/// preview and links to <see cref="DownloadPath"/>, which streams the file over plain HTTP.
/// </summary>
public static class RawTagCsvExport
{
/// <summary>The download endpoint's route.</summary>
public const string DownloadPath = "/api/raw/tags.csv";
/// <summary>The number of CSV lines the modal previews.</summary>
public const int PreviewLines = 50;
/// <summary>Builds the export for a device, or for one tag group's subtree.</summary>
/// <param name="reader">The export reader.</param>
/// <param name="deviceId">The device; may be blank when <paramref name="tagGroupId"/> is given.</param>
/// <param name="tagGroupId">When set, only this group's subtree is exported.</param>
/// <param name="driverType">The driver type (selects the typed columns); resolved from the device when blank.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="maxRows">When set, only the first <paramref name="maxRows"/> tags are mapped into
/// <see cref="RawTagCsvExportResult.Csv"/> (the modal's preview — review r0 M8: it used to map the whole
/// export only to keep 50 lines); <see cref="RawTagCsvExportResult.RowCount"/> is always the full count.</param>
/// <returns>The export, or null when the target device cannot be resolved.</returns>
public static async Task<RawTagCsvExportResult?> BuildAsync(
RawTagCsvExportReader reader, string? deviceId, string? tagGroupId, string? driverType,
CancellationToken ct = default, int? maxRows = null)
{
string? groupPath = null;
if (!string.IsNullOrEmpty(tagGroupId))
{
var ctx = await reader.ResolveGroupContextAsync(tagGroupId, ct);
if (ctx is { } c)
{
groupPath = c.GroupPath;
if (string.IsNullOrEmpty(deviceId)) deviceId = c.DeviceId;
}
}
if (string.IsNullOrEmpty(deviceId)) return null;
if (string.IsNullOrEmpty(driverType))
{
driverType = await reader.ResolveDriverTypeAsync(deviceId, ct);
if (driverType is null) return null;
}
IEnumerable<RawCsvExportTag> tags = await reader.ReadDeviceTagsAsync(deviceId, ct);
// When exporting a single group, keep only that group's subtree (its path + descendants).
if (!string.IsNullOrEmpty(groupPath))
{
var prefix = groupPath;
tags = tags.Where(t => t.TagGroupPath is not null &&
(t.TagGroupPath == prefix || t.TagGroupPath.StartsWith(prefix + "/", StringComparison.Ordinal)));
}
var list = tags.ToList();
var mapped = maxRows is { } max && max >= 0 && max < list.Count ? list.Take(max) : list;
return new RawTagCsvExportResult(RawTagCsvMapper.Export(driverType, mapped), BuildFileName(deviceId), list.Count, groupPath);
}
/// <summary>The first <paramref name="maxLines"/> lines of <paramref name="csv"/>, without copying the rest.</summary>
/// <param name="csv">The CSV text.</param>
/// <param name="maxLines">The maximum number of lines.</param>
/// <returns>The preview, and whether lines were cut.</returns>
public static (string Preview, bool Truncated) Preview(string csv, int maxLines = PreviewLines)
{
var idx = 0;
for (var line = 0; line < maxLines; line++)
{
var next = csv.IndexOf('\n', idx);
if (next < 0) return (csv, false);
idx = next + 1;
}
return idx >= csv.Length ? (csv, false) : (csv[..idx], true);
}
/// <summary>The download URL for an export.</summary>
/// <param name="deviceId">The device id (may be blank when a group is given).</param>
/// <param name="tagGroupId">The optional tag-group id.</param>
/// <param name="driverType">The optional driver type.</param>
/// <returns>A relative URL with escaped query values.</returns>
public static string DownloadUrl(string? deviceId, string? tagGroupId, string? driverType)
{
var query = new List<string>();
if (!string.IsNullOrEmpty(deviceId)) query.Add("deviceId=" + Uri.EscapeDataString(deviceId));
if (!string.IsNullOrEmpty(tagGroupId)) query.Add("tagGroupId=" + Uri.EscapeDataString(tagGroupId));
if (!string.IsNullOrEmpty(driverType)) query.Add("driverType=" + Uri.EscapeDataString(driverType));
return query.Count == 0 ? DownloadPath : DownloadPath + "?" + string.Join("&", query);
}
/// <summary>
/// The attachment <c>Content-Disposition</c> value. Emits both <c>filename</c> and the RFC 5987
/// <c>filename*</c> (review r1 N-new-2): a non-ASCII device id such as <c>Prüfstand</c> keeps its
/// name in every browser, which an RFC 2047 encoded word (System.Net.Mime) does not.
/// </summary>
/// <param name="fileName">The download file name.</param>
/// <returns>The header value.</returns>
public static string AttachmentHeader(string fileName)
{
var header = new Microsoft.Net.Http.Headers.ContentDispositionHeaderValue("attachment");
header.SetHttpFileName(fileName);
return header.ToString();
}
/// <summary>The download file name for a device.</summary>
/// <param name="deviceId">The device id.</param>
/// <returns>A file-system-safe name.</returns>
public static string BuildFileName(string deviceId)
{
var safe = new string(deviceId.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
return $"tags-{safe}.csv";
}
/// <summary>
/// Maps <c>GET /api/raw/tags.csv</c>. It needs the same <c>ConfigEditor</c> policy as the <c>/raw</c>
/// page whose export modal links to it — the CSV carries every tag's config, which is exactly what
/// that page shows. Browser-cookie authenticated (the anchor is a same-origin navigation).
/// </summary>
/// <param name="app">The endpoint route builder.</param>
/// <returns>The route handler builder.</returns>
public static RouteHandlerBuilder MapRawTagCsvExport(this IEndpointRouteBuilder app) =>
app.MapGet(DownloadPath, async (
HttpContext http, string? deviceId, string? tagGroupId, string? driverType,
RawTagCsvExportReader reader, CancellationToken ct) =>
{
var export = await BuildAsync(reader, deviceId, tagGroupId, driverType, ct);
if (export is null) return Results.NotFound();
// Attachment, so the browser saves rather than renders. Results.Text writes the string
// through the response encoder, so no second whole-file byte[] copy is held (review r0 M8).
http.Response.Headers.ContentDisposition = AttachmentHeader(export.FileName);
return Results.Text(export.Csv, "text/csv; charset=utf-8", System.Text.Encoding.UTF8);
})
.RequireAuthorization(AdminUiPolicies.ConfigEditor)
.WithName("RawTagCsvExport");
}
@@ -0,0 +1,96 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>What a flattened <c>RawTree</c> row renders.</summary>
public enum RawTreeRowKind
{
/// <summary>A tree node row (expander + label + menu).</summary>
Node,
/// <summary>The "Loading…" placeholder under an expanded container whose children are in flight.</summary>
Loading,
/// <summary>The error line under an expanded container whose load failed.</summary>
Error,
/// <summary>The "Show more" row under an expanded container with more children than its visible limit.</summary>
ShowMore,
}
/// <summary>One row of the flattened, visible Raw tree.</summary>
/// <param name="Kind">What the row renders.</param>
/// <param name="Node">The node the row belongs to (for non-<see cref="RawTreeRowKind.Node"/> rows, the container).</param>
/// <param name="Depth">Indent depth; a placeholder row sits one level below its container.</param>
/// <param name="Remaining">For <see cref="RawTreeRowKind.ShowMore"/>, how many children are still hidden; otherwise 0.</param>
public readonly record struct RawTreeRow(RawTreeRowKind Kind, RawNode Node, int Depth, int Remaining = 0)
{
/// <summary>A key unique within the flattened list, stable across renders (for <c>@key</c>).</summary>
public string Key => Kind switch
{
RawTreeRowKind.Node => Node.Key,
RawTreeRowKind.Loading => "loading:" + Node.Key,
RawTreeRowKind.Error => "error:" + Node.Key,
_ => "more:" + Node.Key,
};
}
/// <summary>
/// Flattens the expanded part of the Raw tree into the rows the <c>RawTree</c> component renders
/// through one <c>Virtualize</c> (R04 P5). A recursive render of an expanded 5k-tag device rendered
/// every row (and a <c>ContextMenu</c> per row) on every state change; a flat list lets the browser
/// render only the rows in view. Each level also shows at most <see cref="RawNode.VisibleChildLimit"/>
/// children (default <see cref="DefaultPageSize"/>), followed by a "Show more" row.
/// </summary>
public static class RawTreeRows
{
/// <summary>The number of children an expanded level shows before a "Show more" row.</summary>
public const int DefaultPageSize = 500;
/// <summary>Flattens the visible tree, depth-first, in display order.</summary>
/// <param name="roots">The top-level nodes.</param>
/// <returns>The visible rows.</returns>
public static List<RawTreeRow> Flatten(IReadOnlyList<RawNode> roots)
{
var rows = new List<RawTreeRow>();
// An explicit stack instead of recursion: tag-group nesting depth is author-controlled.
var stack = new Stack<RawTreeRow>();
for (var i = roots.Count - 1; i >= 0; i--) stack.Push(new RawTreeRow(RawTreeRowKind.Node, roots[i], 0));
while (stack.Count > 0)
{
var row = stack.Pop();
rows.Add(row);
if (row.Kind != RawTreeRowKind.Node) continue;
var n = row.Node;
if (!n.Expanded) continue;
var childDepth = row.Depth + 1;
if (n.Loading)
{
rows.Add(new RawTreeRow(RawTreeRowKind.Loading, n, childDepth));
continue;
}
if (n.Error is not null)
{
rows.Add(new RawTreeRow(RawTreeRowKind.Error, n, childDepth));
continue;
}
var shown = Math.Min(Math.Max(n.VisibleChildLimit, 0), n.Children.Count);
var hidden = n.Children.Count - shown;
// Pushed in reverse so they pop in display order; the ShowMore row is pushed first so it pops
// after the last shown child's whole (possibly expanded) subtree.
if (hidden > 0) stack.Push(new RawTreeRow(RawTreeRowKind.ShowMore, n, childDepth, hidden));
for (var i = shown - 1; i >= 0; i--) stack.Push(new RawTreeRow(RawTreeRowKind.Node, n.Children[i], childDepth));
}
return rows;
}
/// <summary>Raises a container's visible-children limit by one page.</summary>
/// <param name="node">The container whose "Show more" row was clicked.</param>
public static void ShowMore(RawNode node) =>
node.VisibleChildLimit = Math.Max(node.VisibleChildLimit, 0) + DefaultPageSize;
}
@@ -75,13 +75,13 @@ public readonly record struct AreaRow(string UnsAreaId, string ClusterId, string
/// <summary>Flat structural row describing a UNS line and its owning area.</summary>
public readonly record struct LineRow(string UnsLineId, string UnsAreaId, string Name);
/// <summary>Flat structural row describing an equipment node, its owning line and its tag/virtual-tag counts.</summary>
/// <summary>Flat structural row describing an equipment node, its owning line and its virtual-tag count.
/// (The driver-tag count was retired with the v3 equipment↔Tag binding; it was always zero.)</summary>
public readonly record struct EquipmentRow(
string EquipmentId,
string UnsLineId,
string MachineCode,
string Name,
int TagCount,
int VirtualTagCount);
/// <summary>
@@ -237,7 +237,7 @@ public static class UnsTreeAssembly
private static UnsNode BuildEquipment(EquipmentRow equipment, string clusterId)
{
var childCount = equipment.TagCount + equipment.VirtualTagCount;
var childCount = equipment.VirtualTagCount;
return new UnsNode
{
Kind = UnsNodeKind.Equipment,
@@ -61,11 +61,6 @@ public sealed class UnsTreeService(
})
.ToListAsync(ct);
// Per-equipment driver-tag counts: the equipment↔Tag binding was retired in v3
// (Tag is now Raw-only; authoring moved to the Raw tree in Batch 2/3). No binding
// exists to count against, so every equipment reports zero driver tags.
var tagCounts = new Dictionary<string, int>(StringComparer.Ordinal);
// Per-equipment virtual-tag counts (virtual tags with no equipment are excluded).
var vtagCounts = (await db.VirtualTags
.AsNoTracking()
@@ -81,21 +76,12 @@ public sealed class UnsTreeService(
e.UnsLineId,
e.MachineCode,
e.Name,
tagCounts.GetValueOrDefault(e.EquipmentId),
vtagCounts.GetValueOrDefault(e.EquipmentId)))
.ToList();
return UnsTreeAssembly.Build(clusters, areas, lines, equipment);
}
/// <inheritdoc />
public Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
// The equipment↔Tag binding was retired in v3 (Tag is now Raw-only; authoring moved to
// the Raw tree in Batch 2/3). No equipment-bound tags exist to list.
return Task.FromResult<IReadOnlyList<EquipmentTagRow>>(Array.Empty<EquipmentTagRow>());
}
/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
@@ -0,0 +1,25 @@
using Shouldly;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>
/// Reads an AdminUI source file for a source-scan pin. The repo has no bUnit, so Razor wiring that a
/// unit test cannot render is pinned by scanning the markup; it proves the wiring exists, not that it
/// renders correctly (the docker-dev live pass covers that).
/// </summary>
internal static class AdminUiSource
{
/// <summary>Reads a file under <c>src/Server/ZB.MOM.WW.OtOpcUa.AdminUI</c>.</summary>
/// <param name="relativeSegments">Path segments below the AdminUI project directory.</param>
/// <returns>The file text.</returns>
public static string Read(params string[] relativeSegments)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
dir = dir.Parent;
dir.ShouldNotBeNull("could not locate the repository root (no ZB.MOM.WW.OtOpcUa.slnx above the test binary)");
var parts = new[] { dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI" }.Concat(relativeSegments).ToArray();
return File.ReadAllText(Path.Combine(parts));
}
}
@@ -0,0 +1,100 @@
using Akka.Actor;
using Akka.Hosting;
using Akka.TestKit.Xunit2;
using Shouldly;
using Xunit;
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;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>
/// R04 L3: <c>/alarms-historian</c> distinguishes "this role runs no historian" (a permanent state, shown
/// once, not polled) from a sink that did not answer, instead of swallowing both into "Loading…" forever.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AlarmsHistorianReaderTests : TestKit
{
private static readonly HistorianSinkStatus Status = new(3, 1, null, null, null, HistorianDrainState.Idle);
private sealed class StatusActor : ReceiveActor
{
public StatusActor() => Receive<HistorianAdapterActor.GetStatus>(_ => Sender.Tell(Status));
}
[Fact]
public async Task An_answering_sink_is_available()
{
var actor = Sys.ActorOf(Props.Create(() => new StatusActor()));
var reading = await HistorianSinkStatusReader.ReadAsync(() => actor, TimeSpan.FromSeconds(5), CancellationToken.None);
reading.Availability.ShouldBe(HistorianSinkAvailability.Available);
reading.Status.ShouldBe(Status);
}
[Fact]
public async Task A_node_without_the_registry_entry_reports_not_on_this_role()
{
// The real Akka.Hosting registry: an admin-only node never registers the historian key.
var registry = new ActorRegistry();
var reading = await HistorianSinkStatusReader.ReadAsync(
() => registry.Get<HistorianAdapterActorKey>(), TimeSpan.FromSeconds(5), CancellationToken.None);
reading.Availability.ShouldBe(HistorianSinkAvailability.NotOnThisRole);
reading.Status.ShouldBeNull();
reading.Message!.ShouldContain("driver role");
}
[Fact]
public async Task A_silent_sink_is_unavailable_not_loading()
{
var silent = CreateTestProbe(); // receives the Ask, never replies
var reading = await HistorianSinkStatusReader.ReadAsync(() => silent.Ref, TimeSpan.FromMilliseconds(200), CancellationToken.None);
reading.Availability.ShouldBe(HistorianSinkAvailability.Unavailable);
reading.Message!.ShouldContain("did not answer");
// Positive control: the Ask really was delivered — the classification is about the missing reply.
silent.ExpectMsg<HistorianAdapterActor.GetStatus>();
}
[Fact]
public async Task Page_teardown_cancellation_propagates()
{
var silent = CreateTestProbe();
using var cts = new CancellationTokenSource();
cts.Cancel();
await Should.ThrowAsync<OperationCanceledException>(() =>
HistorianSinkStatusReader.ReadAsync(() => silent.Ref, TimeSpan.FromSeconds(30), cts.Token));
}
[Fact]
public void Not_registered_keeps_a_slow_repoll_instead_of_stopping()
{
// Review r0 M6: a driver node opened mid-startup is "not registered" for a moment — never permanent.
HistorianSinkStatusReader.PollIntervalFor(HistorianSinkAvailability.NotOnThisRole)
.ShouldBe(HistorianSinkStatusReader.NotRegisteredPollInterval);
HistorianSinkStatusReader.PollIntervalFor(HistorianSinkAvailability.Available)
.ShouldBe(HistorianSinkStatusReader.PollInterval);
HistorianSinkStatusReader.PollIntervalFor(null).ShouldBe(HistorianSinkStatusReader.PollInterval);
var src = AdminUiSource.Read("Components", "Pages", "AlarmsHistorian.razor");
src.ShouldContain("_timer.Change(interval, interval)");
src.ShouldNotContain("if (_reading?.Availability == HistorianSinkAvailability.NotOnThisRole) return;");
}
[Fact]
public void Page_uses_the_reader_and_the_async_dispose_lifecycle()
{
var src = AdminUiSource.Read("Components", "Pages", "AlarmsHistorian.razor");
src.ShouldContain("HistorianSinkStatusReader.ReadAsync(");
src.ShouldContain("@implements IAsyncDisposable");
src.ShouldContain("HistorianSinkAvailability.NotOnThisRole");
System.Text.RegularExpressions.Regex.IsMatch(src, @"catch\s*\{").ShouldBeFalse("a bare catch {} swallows every failure");
}
}
@@ -0,0 +1,116 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Api;
/// <summary>
/// R04 S5: the POST's 202 <c>Location</c> points at <c>GET /api/deployments/{id}</c>, which now exists,
/// carries the same API-key gate as the POST, and reports the deployment plus its per-node states.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DeploymentStatusEndpointTests
{
private static OtOpcUaConfigDbContext NewDb(string name) =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
[Fact]
public async Task GetDeploymentStatusAsync_returns_the_deployment_and_its_node_states()
{
var name = Guid.NewGuid().ToString("N");
var id = Guid.NewGuid();
await using (var seed = NewDb(name))
{
seed.Deployments.Add(new Deployment
{
DeploymentId = id, RevisionHash = "rev1", CreatedBy = "ci", Status = DeploymentStatus.Dispatching,
ArtifactBlob = new byte[1024],
});
seed.Deployments.Add(new Deployment { DeploymentId = Guid.NewGuid(), RevisionHash = "other", CreatedBy = "x" });
seed.NodeDeploymentStates.Add(new NodeDeploymentState { NodeId = "node-b", DeploymentId = id, Status = NodeDeploymentStatus.Applying });
seed.NodeDeploymentStates.Add(new NodeDeploymentState { NodeId = "node-a", DeploymentId = id, Status = NodeDeploymentStatus.Applied, AppliedAtUtc = DateTime.UtcNow });
await seed.SaveChangesAsync();
}
await using var db = NewDb(name);
var status = await DeployApiEndpoints.GetDeploymentStatusAsync(db, id);
status.ShouldNotBeNull();
status!.DeploymentId.ShouldBe(id);
status.RevisionHash.ShouldBe("rev1");
status.Status.ShouldBe(nameof(DeploymentStatus.Dispatching));
status.Nodes.Select(n => (n.NodeId, n.Status)).ShouldBe(
[("node-a", nameof(NodeDeploymentStatus.Applied)), ("node-b", nameof(NodeDeploymentStatus.Applying))]);
}
[Fact]
public async Task GetDeploymentStatusAsync_returns_null_for_an_unknown_id()
{
await using var db = NewDb(Guid.NewGuid().ToString("N"));
(await DeployApiEndpoints.GetDeploymentStatusAsync(db, Guid.NewGuid())).ShouldBeNull();
}
[Fact]
public void Get_route_exists_and_is_gated_like_the_post()
{
var endpoints = MapDeployApi();
var get = endpoints.Single(e => NameOf(e) == "GetDeployment");
get.RoutePattern.RawText.ShouldBe("/api/deployments/{deploymentId:guid}");
get.Metadata.GetMetadata<IHttpMethodMetadata>()!.HttpMethods.ShouldBe(["GET"]);
var post = endpoints.Single(e => NameOf(e) == "StartDeployment");
// Both are API-key gated in the handler: AllowAnonymous so the cookie fallback policy does not 401
// the automation client, and no cookie policy of their own.
foreach (var e in new[] { get, post })
{
e.Metadata.GetMetadata<IAllowAnonymous>().ShouldNotBeNull(NameOf(e));
e.Metadata.GetOrderedMetadata<IAuthorizeData>().Where(a => !string.IsNullOrEmpty(a.Policy)).ShouldBeEmpty(NameOf(e));
}
}
[Fact]
public void Accepted_location_matches_the_get_route()
{
var id = Guid.NewGuid();
var result = DeployApiEndpoints.ToResult(new StartDeploymentResult(
StartDeploymentOutcome.Accepted, new DeploymentId(id), new RevisionHash("rev"), null, CorrelationId.NewId()));
var accepted = result.ShouldBeOfType<Microsoft.AspNetCore.Http.HttpResults.Accepted<DeployApiEndpoints.DeployResponse>>();
accepted.Location.ShouldBe($"/api/deployments/{id}");
Guid.TryParse(accepted.Location!["/api/deployments/".Length..], out _).ShouldBeTrue();
}
private static string? NameOf(Endpoint e) => e.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName;
private static IReadOnlyList<RouteEndpoint> MapDeployApi()
{
var builder = WebApplication.CreateBuilder();
// Registered only so the handlers' service parameters are inferred as services; the metadata
// scan below never invokes a handler, so nothing is resolved.
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Commons.Interfaces.IAdminOperationsClient>(_ => null!);
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("deploy-api-metadata"));
var app = builder.Build();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { [DeployApiEndpoints.ConfigKey] = "k" })
.Build();
app.MapOtOpcUaDeployApi(config);
return ((IEndpointRouteBuilder)app).DataSources
.SelectMany(d => d.Endpoints)
.OfType<RouteEndpoint>()
.ToList();
}
}
@@ -0,0 +1,43 @@
using System.Reflection;
using Microsoft.AspNetCore.Components;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Authorization;
/// <summary>
/// R04 S7: <c>/dev/*</c> routes exist only in DEBUG builds. The test project builds in the same
/// configuration as the AdminUI assembly, so each configuration asserts its own half — run
/// <c>dotnet test -c Release</c> to exercise the production half.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DevRouteGatingTests
{
private static IReadOnlyList<string> DevRoutes() =>
typeof(Routes).Assembly.GetTypes()
.SelectMany(t => t.GetCustomAttributes<RouteAttribute>(inherit: false))
.Select(r => r.Template)
.Where(t => t.StartsWith("/dev/", StringComparison.OrdinalIgnoreCase))
.ToList();
[Fact]
public void Dev_routes_follow_the_build_configuration()
{
#if DEBUG
DevRoutes().ShouldBe([ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo.RoutePath]);
#else
DevRoutes().ShouldBeEmpty();
#endif
}
[Fact]
public void Production_routes_are_present_in_every_configuration()
{
// Positive control: the scan sees routes at all (an empty assembly scan would pass the Release half).
typeof(Routes).Assembly.GetTypes()
.SelectMany(t => t.GetCustomAttributes<RouteAttribute>(inherit: false))
.Select(r => r.Template)
.ShouldContain("/hosts");
}
}
@@ -59,7 +59,10 @@ public class PageAuthorizationGuardTests
// ── AuthenticatedRead (17): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ──
[typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
#if DEBUG
// Routable only in DEBUG builds (R04 S7) — a Release build registers no /dev/* route.
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = AdminUiPolicies.AuthenticatedRead,
#endif
[typeof(Fleet)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.FleetDiagnostics)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Hosts)] = AdminUiPolicies.AuthenticatedRead,
@@ -0,0 +1,74 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Drivers;
/// <summary>
/// G1-F5 / F-FU2: Test Connect honours the driver's configured <c>ProbeTimeoutSeconds</c> (so it can
/// lengthen the budget, not only shorten it), aligned across Galaxy and OpcUaClient, and the button
/// drains its clear timer (R04 L2).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TestConnectBudgetTests
{
[Theory]
[InlineData("{\"ProbeTimeoutSeconds\":30}", 30)] // Galaxy default — was silently 10 s
[InlineData("{\"probeTimeoutSeconds\":15}", 15)] // OpcUaClient default, camelCase
[InlineData("{\"ProbeTimeoutSeconds\":\"25\"}", 25)]
[InlineData("{\"ProbeTimeoutSeconds\":2.5}", 3)]
[InlineData("{\"ProbeTimeoutSeconds\":600}", 60)] // clamped to the actor's max
public void A_configured_timeout_is_the_budget(string json, int expected)
=> TestConnectBudget.Resolve(json).ShouldBe(expected);
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("{}")]
[InlineData("{\"ProbeTimeoutSeconds\":0}")]
[InlineData("{\"ProbeTimeoutSeconds\":-5}")]
[InlineData("{\"ProbeTimeoutSeconds\":null}")]
[InlineData("not json")]
[InlineData("[1,2]")]
[InlineData("{\"Nested\":{\"ProbeTimeoutSeconds\":40}}")] // only the root key counts
public void No_valid_configured_timeout_falls_back(string? json)
=> TestConnectBudget.Resolve(json, fallbackSeconds: 10).ShouldBe(10);
[Theory]
[InlineData("GalaxyMxGateway", 30)]
[InlineData("OpcUaClient", 15)]
[InlineData("Modbus", 5)]
[InlineData("Sql", 10)] // no documented default ⇒ the caller's fallback
public void An_absent_key_uses_the_drivers_documented_default(string driverType, int expected)
=> TestConnectBudget.Resolve("{}", fallbackSeconds: 10, driverType).ShouldBe(expected);
[Fact]
public void A_configured_value_beats_the_driver_default()
=> TestConnectBudget.Resolve("{\"ProbeTimeoutSeconds\":7}", 10, "GalaxyMxGateway").ShouldBe(7);
[Theory]
[InlineData("{\"ProbeTimeoutSeconds\":5,\"timeoutMs\":8000}", 9)] // M2: 8 s request + 1 s, never cut short
[InlineData("{\"ProbeTimeoutSeconds\":5,\"timeoutMs\":2000}", 5)] // positive control: a short request timeout changes nothing
[InlineData("{\"timeoutMs\":120000}", 60)] // still clamped
public void The_budget_never_undercuts_the_configs_request_timeout(string json, int expected)
=> TestConnectBudget.Resolve(json, 10, "Modbus").ShouldBe(expected);
[Fact]
public void The_fallback_is_clamped_too()
{
TestConnectBudget.Resolve("{}", fallbackSeconds: 0).ShouldBe(TestConnectBudget.MinSeconds);
TestConnectBudget.Resolve("{}", fallbackSeconds: 999).ShouldBe(TestConnectBudget.MaxSeconds);
}
[Fact]
public void Button_resolves_its_budget_from_the_config_and_drains_a_threading_timer()
{
var src = AdminUiSource.Read("Components", "Shared", "Drivers", "DriverTestConnectButton.razor");
src.ShouldContain("TestConnectBudget.Resolve(json, TimeoutSeconds, DriverType)");
src.ShouldContain("@implements IAsyncDisposable");
src.ShouldContain("System.Threading.Timer");
src.ShouldNotContain("System.Timers.Timer");
src.ShouldNotContain("async (_, _)");
}
}
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>A-F4 / A-F5: <c>/fleet</c> links to the per-node diagnostics page, which shows authored names.</summary>
[Trait("Category", "Unit")]
public sealed class FleetDiagnosticsLinksTests
{
[Fact]
public void Link_escapes_the_node_id()
=> FleetDiagnosticsLinks.For("host-a:4053").ShouldBe("/fleet/diagnostics?node=host-a%3A4053");
[Fact]
public async Task Display_names_join_from_the_config_db_and_unknown_ids_are_absent()
{
var name = Guid.NewGuid().ToString("N");
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options;
await using (var seed = new OtOpcUaConfigDbContext(options))
{
seed.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "drv-press-1", ClusterId = "MAIN", Name = "Press line Modbus",
DriverType = "Modbus", DriverConfig = "{}",
});
await seed.SaveChangesAsync();
}
await using var db = new OtOpcUaConfigDbContext(options);
var names = await FleetDiagnosticsLinks.LoadDisplayNamesAsync(db, ["drv-press-1", "drv-unknown"]);
names["DRV-PRESS-1"].ShouldBe("Press line Modbus"); // the returned map is case-insensitive
names.ContainsKey("drv-unknown").ShouldBeFalse();
(await FleetDiagnosticsLinks.LoadDisplayNamesAsync(db, [])).ShouldBeEmpty();
}
[Fact]
public void Fleet_rows_link_driver_nodes_and_the_page_uses_the_names()
{
AdminUiSource.Read("Components", "Pages", "Fleet.razor")
.ShouldContain("href=\"@FleetDiagnosticsLinks.For(r.NodeId)\"");
var diag = AdminUiSource.Read("Components", "Pages", "FleetDiagnostics.razor");
diag.ShouldContain("FleetDiagnosticsLinks.LoadDisplayNamesAsync(");
diag.ShouldContain("@DisplayNameOf(d)");
}
}
@@ -0,0 +1,157 @@
using System.Text;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hosts;
/// <summary>
/// Review D-b r1, I1: <c>/hosts</c> keys eviction on authored ∪ DEPLOYED drivers, because a running driver
/// whose health is unchanged never re-publishes (fingerprint dedup) — staleness is not a liveness proof.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DeployedDriverSetTests
{
private static readonly DateTime Now = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
// Test artifacts are a plain comma list; the real parser is DeploymentArtifact.ParseDriverInstances.
private static IEnumerable<string> Parse(byte[] blob) =>
Encoding.UTF8.GetString(blob).Split(',', StringSplitOptions.RemoveEmptyEntries);
private static byte[] Artifact(params string[] driverIds) => Encoding.UTF8.GetBytes(string.Join(",", driverIds));
private sealed class Factory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public int Created;
public OtOpcUaConfigDbContext CreateDbContext()
{
Created++;
return new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
}
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default) => Task.FromResult(CreateDbContext());
}
private static Deployment Dep(Guid id, DeploymentStatus status, DateTime created, byte[] blob) =>
new() { DeploymentId = id, RevisionHash = id.ToString("N"), CreatedBy = "t", Status = status, CreatedAtUtc = created, ArtifactBlob = blob };
private static NodeDeploymentState Node(string node, Guid dep, NodeDeploymentStatus status, DateTime started) =>
new() { NodeId = node, DeploymentId = dep, Status = status, StartedAtUtc = started };
[Fact]
public async Task Quiet_running_driver_deleted_in_raw_survives_the_sweep()
{
// drv-quiet is running (in the deployed artifact), deleted in /raw (not authored), and published
// once 5 minutes ago — a Faulted driver with a steady error never re-publishes.
var factory = new Factory(Guid.NewGuid().ToString("N"));
var d1 = Guid.NewGuid();
await using (var db = factory.CreateDbContext())
{
db.Deployments.Add(Dep(d1, DeploymentStatus.Sealed, Now.AddHours(-1), Artifact("drv-quiet", "drv-kept")));
db.NodeDeploymentStates.Add(Node("n1", d1, NodeDeploymentStatus.Applied, Now.AddHours(-1)));
await db.SaveChangesAsync();
}
var deployed = await new DeployedDriverSet(factory, parseDriverIds: Parse).LoadAsync();
deployed.ShouldNotBeNull();
var store = new InMemoryDriverStatusSnapshotStore();
store.Upsert(new DriverHealthChanged("MAIN", "drv-quiet", "Faulted", null, "refused", 3, Now.AddMinutes(-5)));
store.Upsert(new DriverHealthChanged("MAIN", "drv-gone", "Faulted", null, "refused", 3, Now.AddMinutes(-5)));
var configured = new HashSet<string>(["drv-kept"], StringComparer.OrdinalIgnoreCase); // authored
configured.UnionWith(deployed!);
var removed = store.EvictUnconfiguredStale(configured, Now.AddSeconds(-60));
store.TryGet("drv-quiet", out _).ShouldBeTrue();
// Positive control: an undeployed, unconfigured, stale driver is still evicted.
removed.ShouldBe(1);
store.TryGet("drv-gone", out _).ShouldBeFalse();
}
[Fact]
public async Task Union_covers_each_nodes_latest_and_latest_applied_and_the_latest_sealed()
{
var factory = new Factory(Guid.NewGuid().ToString("N"));
Guid old = Guid.NewGuid(), failed = Guid.NewGuid(), sealedOther = Guid.NewGuid(), stale = Guid.NewGuid();
await using (var db = factory.CreateDbContext())
{
db.Deployments.Add(Dep(stale, DeploymentStatus.Sealed, Now.AddDays(-9), Artifact("drv-ancient")));
db.Deployments.Add(Dep(old, DeploymentStatus.Sealed, Now.AddHours(-3), Artifact("drv-old")));
db.Deployments.Add(Dep(failed, DeploymentStatus.PartiallyFailed, Now.AddHours(-1), Artifact("drv-new")));
db.Deployments.Add(Dep(sealedOther, DeploymentStatus.Sealed, Now.AddHours(-2), Artifact("drv-sealed")));
db.NodeDeploymentStates.Add(Node("n1", stale, NodeDeploymentStatus.Applied, Now.AddDays(-9)));
db.NodeDeploymentStates.Add(Node("n1", old, NodeDeploymentStatus.Applied, Now.AddHours(-3)));
db.NodeDeploymentStates.Add(Node("n1", failed, NodeDeploymentStatus.Failed, Now.AddHours(-1)));
await db.SaveChangesAsync();
}
var deployed = await new DeployedDriverSet(factory, parseDriverIds: Parse).LoadAsync();
// n1's latest (failed apply, may be partly running), its latest Applied (what it ran before), and the
// latest Sealed deployment — but not a superseded one nobody runs.
deployed!.ShouldBe(["drv-new", "drv-old", "drv-sealed"], ignoreOrder: true);
deployed!.ShouldNotContain("drv-ancient");
}
[Fact]
public async Task Artifacts_are_parsed_once_per_deployment_and_pruned()
{
var factory = new Factory(Guid.NewGuid().ToString("N"));
var d1 = Guid.NewGuid();
await using (var db = factory.CreateDbContext())
{
db.Deployments.Add(Dep(d1, DeploymentStatus.Sealed, Now, Artifact("drv-a")));
await db.SaveChangesAsync();
}
var parses = 0;
var set = new DeployedDriverSet(factory, parseDriverIds: b => { parses++; return Parse(b); });
await set.LoadAsync();
await set.LoadAsync();
parses.ShouldBe(1);
set.CachedDeployments.ShouldBe(1);
var d2 = Guid.NewGuid();
await using (var db = factory.CreateDbContext())
{
db.Deployments.Add(Dep(d2, DeploymentStatus.Sealed, Now.AddMinutes(1), Artifact("drv-b")));
await db.SaveChangesAsync();
}
(await set.LoadAsync())!.ShouldBe(["drv-b"]);
set.CachedDeployments.ShouldBe(1); // d1 is no longer live, so it is pruned
parses.ShouldBe(2);
}
[Fact]
public async Task An_unreadable_artifact_means_unknown_so_nothing_is_evicted()
{
var factory = new Factory(Guid.NewGuid().ToString("N"));
var d1 = Guid.NewGuid();
await using (var db = factory.CreateDbContext())
{
db.Deployments.Add(Dep(d1, DeploymentStatus.Sealed, Now, Array.Empty<byte>())); // retention-emptied
await db.SaveChangesAsync();
}
(await new DeployedDriverSet(factory, parseDriverIds: Parse).LoadAsync()).ShouldBeNull();
}
[Fact]
public void Hosts_skips_eviction_when_the_deployed_set_is_unknown()
{
var src = AdminUiSource.Read("Components", "Pages", "Hosts.razor");
src.ShouldContain("var deployed = await Deployed.LoadAsync(_cts.Token);");
src.ShouldContain("configured.UnionWith(deployed);");
src.IndexOf("if (deployed is not null)", StringComparison.Ordinal)
.ShouldBeLessThan(src.IndexOf("DriverStore.EvictUnconfiguredStale(", StringComparison.Ordinal));
src.ShouldNotContain("≈ 12 health publishes");
}
}
@@ -0,0 +1,53 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hosts;
/// <summary>
/// R04 P4: a driver health publish only marks <c>/hosts</c> dirty; the page's 5 s tick rebuilds and
/// renders at most once, however many publishes arrived.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HostsRenderCoalescingTests
{
[Fact]
public void Many_marks_coalesce_into_one_consume()
{
var flag = new CoalescingDirtyFlag();
for (var i = 0; i < 200; i++) flag.Mark();
flag.TryConsume().ShouldBeTrue();
flag.TryConsume().ShouldBeFalse();
}
[Fact]
public void A_fresh_flag_is_clean_and_a_mark_after_consume_is_seen()
{
var flag = new CoalescingDirtyFlag();
flag.TryConsume().ShouldBeFalse();
// Positive control: the flag is not stuck clean.
flag.Mark();
flag.TryConsume().ShouldBeTrue();
}
[Fact]
public void Concurrent_marks_are_never_lost()
{
var flag = new CoalescingDirtyFlag();
Parallel.For(0, 1000, _ => flag.Mark());
flag.TryConsume().ShouldBeTrue();
}
/// <summary>Source pin (no bUnit): the snapshot handler must not render or rebuild.</summary>
[Fact]
public void Snapshot_handler_only_marks_and_the_tick_consumes()
{
var src = AdminUiSource.Read("Components", "Pages", "Hosts.razor");
src.ShouldContain("private void OnSnapshotChanged(DriverHealthChanged _) => _driversDirty.Mark();");
src.ShouldContain("if (_driversDirty.TryConsume() | configChanged) RebuildDriverGroups();");
}
}
@@ -0,0 +1,178 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hubs;
/// <summary>
/// Review R04 L1 + L5: the in-process stores evict driver instances that are no longer configured
/// (so <c>/hosts</c> stops rendering ghost rows), and a throwing subscriber no longer aborts the
/// multicast for the subscribers after it (nor escapes onto the bridge actor).
/// </summary>
public sealed class StoreEvictionAndIsolationTests
{
private static readonly DateTime Now = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Stale = Now.AddMinutes(-5); // stopped publishing
private static readonly DateTime Fresh = Now.AddSeconds(-3); // still publishing
private static readonly DateTime Cutoff = Now.AddSeconds(-60); // what /hosts passes
private static DriverHealthChanged Health(string instance, DateTime? publishedUtc = null) =>
new("MAIN", instance, "Healthy", null, null, 0, publishedUtc ?? Now);
private static DriverResilienceStatusChanged Res(string instance, string host, DateTime? publishedUtc = null) =>
new(instance, host, false, 0, 0, null, publishedUtc ?? Now, publishedUtc ?? Now);
private static HashSet<string> Configured(params string[] ids) => new(ids, StringComparer.OrdinalIgnoreCase);
[Fact]
public void HealthStore_evicts_only_unconfigured_and_stale()
{
var store = new InMemoryDriverStatusSnapshotStore();
store.Upsert(Health("drv-deleted-stopped", Stale)); // unconfigured + stale ⇒ evicted
store.Upsert(Health("drv-deleted-running", Fresh)); // unconfigured + fresh ⇒ kept (I1: deleted in /raw, not yet deployed)
store.Upsert(Health("drv-configured-quiet", Stale)); // configured + stale ⇒ kept
store.Upsert(Health("drv-configured-live", Fresh)); // configured + fresh ⇒ kept
var removed = store.EvictUnconfiguredStale(Configured("drv-configured-quiet", "drv-configured-live"), Cutoff);
removed.ShouldBe(1);
store.TryGet("drv-deleted-stopped", out _).ShouldBeFalse();
// Positive controls: a still-publishing driver survives even though the draft no longer has it,
// and a configured driver survives whatever its age.
store.TryGet("drv-deleted-running", out _).ShouldBeTrue();
store.TryGet("drv-configured-quiet", out _).ShouldBeTrue();
store.TryGet("drv-configured-live", out _).ShouldBeTrue();
}
[Fact]
public void HealthStore_a_running_undeployed_delete_goes_once_it_stops_publishing()
{
var store = new InMemoryDriverStatusSnapshotStore();
store.Upsert(Health("drv-x", Fresh));
store.EvictUnconfiguredStale(Configured(), Cutoff).ShouldBe(0);
// The deploy stops it: its last snapshot ages past the cutoff, and the next sweep evicts it.
store.Upsert(Health("drv-x", Stale));
store.EvictUnconfiguredStale(Configured(), Cutoff).ShouldBe(1);
store.TryGet("drv-x", out _).ShouldBeFalse();
}
[Fact]
public void HealthStore_matches_ids_case_insensitively()
{
var store = new InMemoryDriverStatusSnapshotStore();
store.Upsert(Health("DRV-Live", Stale));
store.EvictUnconfiguredStale(Configured("drv-live"), Cutoff).ShouldBe(0);
store.TryGet("DRV-Live", out _).ShouldBeTrue();
}
[Fact]
public void ResilienceStore_evicts_only_unconfigured_and_stale_hosts()
{
var store = new InMemoryDriverResilienceStatusStore();
store.Upsert(Res("drv-live", "h1", Stale));
store.Upsert(Res("drv-gone", "h1", Stale));
store.Upsert(Res("drv-gone", "h2", Stale));
store.Upsert(Res("drv-running", "h1", Fresh));
var removed = store.EvictUnconfiguredStale(Configured("drv-live"), Cutoff);
removed.ShouldBe(2);
store.GetForInstance("drv-gone").ShouldBeEmpty();
store.GetForInstance("drv-live").Count.ShouldBe(1);
store.GetForInstance("drv-running").Count.ShouldBe(1);
}
[Fact]
public void Failure_log_throttle_warns_at_most_once_per_interval()
{
long now = 1_000;
var throttle = new FailureLogThrottle(() => now);
throttle.NextLevel().ShouldBe(Microsoft.Extensions.Logging.LogLevel.Warning);
throttle.NextLevel().ShouldBe(Microsoft.Extensions.Logging.LogLevel.Debug);
now += (long)FailureLogThrottle.Interval.TotalMilliseconds - 1;
throttle.NextLevel().ShouldBe(Microsoft.Extensions.Logging.LogLevel.Debug);
// Positive control: once the interval has passed, the next failure is visible again.
now += 1;
throttle.NextLevel().ShouldBe(Microsoft.Extensions.Logging.LogLevel.Warning);
}
[Fact]
public void HealthStore_throwing_subscriber_does_not_stop_later_subscribers()
{
var store = new InMemoryDriverStatusSnapshotStore();
var received = new List<string>();
store.SnapshotChanged += _ => throw new InvalidOperationException("renderer torn down");
store.SnapshotChanged += s => received.Add(s.DriverInstanceId);
Should.NotThrow(() => store.Upsert(Health("drv-1")));
received.ShouldBe(["drv-1"]);
store.TryGet("drv-1", out _).ShouldBeTrue();
}
[Fact]
public void ResilienceStore_throwing_subscriber_does_not_stop_later_subscribers()
{
var store = new InMemoryDriverResilienceStatusStore();
var received = 0;
store.SnapshotChanged += _ => throw new InvalidOperationException("boom");
store.SnapshotChanged += _ => received++;
Should.NotThrow(() => store.Upsert(Res("drv-1", "h1")));
received.ShouldBe(1);
}
[Fact]
public void Broadcaster_throwing_subscriber_does_not_stop_later_subscribers()
{
var broadcaster = new InProcessBroadcaster<string>();
var received = new List<string>();
broadcaster.Received += _ => throw new InvalidOperationException("boom");
broadcaster.Received += received.Add;
Should.NotThrow(() => broadcaster.Publish("evt"));
received.ShouldBe(["evt"]);
}
[Fact]
public void Broadcaster_throwing_connection_subscriber_does_not_stop_later_subscribers()
{
var broadcaster = new InProcessBroadcaster<string>();
var raised = new List<bool>();
broadcaster.ConnectionStateChanged += _ => throw new InvalidOperationException("boom");
broadcaster.ConnectionStateChanged += raised.Add;
Should.NotThrow(() => broadcaster.SetConnected(true));
raised.ShouldBe([true]);
broadcaster.IsConnected.ShouldBeTrue();
}
}
/// <summary>
/// Source pin (no bUnit): <c>/hosts</c> sweeps both stores after a successful config load, and the
/// sweep sits before the failure catch, so a failed load (which blanks the config) never evicts.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HostsEvictionWiringTests
{
[Fact]
public void Hosts_page_evicts_both_stores_inside_the_successful_load_only()
{
var src = AdminUiSource.Read("Components", "Pages", "Hosts.razor");
var load = src.IndexOf("private async Task<bool> LoadConfigAsync()", StringComparison.Ordinal);
load.ShouldBeGreaterThan(0);
var firstCatch = src.IndexOf("catch (Exception ex) when (ex is OperationCanceledException", load, StringComparison.Ordinal);
firstCatch.ShouldBeGreaterThan(load);
src.IndexOf("DriverStore.EvictUnconfiguredStale(", load, StringComparison.Ordinal).ShouldBeInRange(load, firstCatch);
src.IndexOf("ResilienceStore.EvictUnconfiguredStale(", load, StringComparison.Ordinal).ShouldBeInRange(load, firstCatch);
}
}
@@ -0,0 +1,185 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.LiveTail;
/// <summary>
/// R04 P7: the live-tail pages coalesce event bursts into one render per window and keep their rows
/// in a bounded ring. All timing here is driven by a manual <see cref="TimeProvider"/>, so nothing
/// depends on wall-clock scheduling.
/// </summary>
[Trait("Category", "Unit")]
public sealed class LiveTailTests
{
[Fact]
public void Buffer_items_are_newest_first_and_only_change_on_flush()
{
var buffer = new LiveTailBuffer<int>(capacity: 3);
buffer.Add(1).ShouldBeTrue(); // first pending ⇒ caller schedules
buffer.Add(2).ShouldBeFalse(); // already pending ⇒ no second schedule
buffer.Items.ShouldBeEmpty(); // not visible until flush
buffer.Flush().ShouldBeTrue();
buffer.Items.ShouldBe([2, 1]);
buffer.Flush().ShouldBeFalse(); // nothing queued ⇒ no render worth doing
}
[Fact]
public void Buffer_evicts_the_oldest_past_capacity()
{
var buffer = new LiveTailBuffer<int>(capacity: 3);
for (var i = 1; i <= 5; i++) buffer.Add(i);
buffer.Flush();
buffer.Items.ShouldBe([5, 4, 3]);
buffer.Add(6);
buffer.Flush();
buffer.Items.ShouldBe([6, 5, 4]);
}
[Fact]
public void Buffer_Clear_drops_retained_and_pending_items()
{
var buffer = new LiveTailBuffer<int>(capacity: 3);
buffer.Add(1);
buffer.Flush();
buffer.Add(2);
buffer.Clear();
buffer.Items.ShouldBeEmpty();
buffer.Flush().ShouldBeFalse();
// Positive control: the buffer still works after a clear, and the next add re-arms scheduling.
buffer.Add(3).ShouldBeTrue();
buffer.Flush();
buffer.Items.ShouldBe([3]);
}
[Fact]
public async Task Scheduler_runs_one_flush_per_window_for_a_burst()
{
var time = new ManualTimeProvider();
var flushes = 0;
await using var scheduler = new CoalescingRenderScheduler(
TimeSpan.FromMilliseconds(200), () => { flushes++; return Task.CompletedTask; }, time);
for (var i = 0; i < 100; i++) scheduler.Schedule();
flushes.ShouldBe(0);
time.Advance(TimeSpan.FromMilliseconds(199));
flushes.ShouldBe(0);
time.Advance(TimeSpan.FromMilliseconds(1));
flushes.ShouldBe(1);
// The window re-arms after a fire: a later burst gets exactly one more flush.
scheduler.Schedule();
scheduler.Schedule();
time.Advance(TimeSpan.FromMilliseconds(200));
flushes.ShouldBe(2);
}
[Fact]
public async Task Scheduler_does_not_flush_after_dispose()
{
var time = new ManualTimeProvider();
var flushes = 0;
var scheduler = new CoalescingRenderScheduler(
TimeSpan.FromMilliseconds(200), () => { flushes++; return Task.CompletedTask; }, time);
// Positive control: the scheduler does flush before dispose.
scheduler.Schedule();
time.Advance(TimeSpan.FromMilliseconds(200));
flushes.ShouldBe(1);
scheduler.Schedule();
await scheduler.DisposeAsync();
time.Advance(TimeSpan.FromMilliseconds(500));
flushes.ShouldBe(1);
Should.NotThrow(scheduler.Schedule);
}
[Fact]
public async Task Scheduler_swallows_a_faulting_flush_and_keeps_working()
{
var time = new ManualTimeProvider();
var calls = 0;
await using var scheduler = new CoalescingRenderScheduler(TimeSpan.FromMilliseconds(200), () =>
{
calls++;
if (calls == 1) throw new ObjectDisposedException("renderer");
return Task.FromException(new InvalidOperationException("late"));
}, time);
scheduler.Schedule();
Should.NotThrow(() => time.Advance(TimeSpan.FromMilliseconds(200)));
scheduler.Schedule();
Should.NotThrow(() => time.Advance(TimeSpan.FromMilliseconds(200)));
calls.ShouldBe(2);
}
[Fact]
public void Pages_use_the_coalescing_tail_instead_of_a_render_per_event()
{
foreach (var page in new[] { "ScriptLog.razor", "Alerts.razor" })
{
var src = AdminUiSource.Read("Components", "Pages", page);
src.ShouldContain("CoalescingRenderScheduler", customMessage: page);
src.ShouldContain("LiveTailBuffer<", customMessage: page);
src.ShouldNotContain("_rows.Insert(0,", customMessage: page);
}
}
/// <summary>A deterministic <see cref="TimeProvider"/>: timers fire only when <see cref="Advance"/> passes their due time.</summary>
private sealed class ManualTimeProvider : TimeProvider
{
private readonly List<ManualTimer> _timers = new();
private DateTimeOffset _now = new(2026, 10, 22, 0, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var timer = new ManualTimer(this, callback, state);
timer.Change(dueTime, period);
_timers.Add(timer);
return timer;
}
public void Advance(TimeSpan by)
{
_now += by;
foreach (var t in _timers.ToArray()) t.FireIfDue(_now);
}
private sealed class ManualTimer(ManualTimeProvider owner, TimerCallback callback, object? state) : ITimer
{
private DateTimeOffset? _due;
private bool _disposed;
public bool Change(TimeSpan dueTime, TimeSpan period)
{
if (_disposed) throw new ObjectDisposedException(nameof(ManualTimer));
_due = dueTime == Timeout.InfiniteTimeSpan ? null : owner._now + dueTime;
return true;
}
public void FireIfDue(DateTimeOffset now)
{
if (_disposed || _due is null || _due > now) return;
_due = null;
callback(state);
}
public void Dispose() => _disposed = true;
public ValueTask DisposeAsync()
{
_disposed = true;
return ValueTask.CompletedTask;
}
}
}
}
@@ -0,0 +1,169 @@
using System.Reflection;
using System.Runtime.ExceptionServices;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
// BL0006: the test renderer needs RenderTree types (same suppression as RawBrowseModalStaleOpenTests).
#pragma warning disable BL0006
/// <summary>
/// R04 L4: a browse session that finishes opening after its picker (or the browse modal) went away is
/// closed at once instead of living until the reaper's idle TTL.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BrowseOpenGuardTests : IDisposable
{
private readonly List<Renderer> _renderers = new();
public void Dispose()
{
foreach (var r in _renderers) r.Dispose();
}
[Fact]
public async Task Guard_closes_an_orphaned_session_and_leaves_a_live_one()
{
var svc = new RecordingBrowserService();
var token = Guid.NewGuid();
BrowseOpenGuard.CloseIfOrphaned(new BrowseOpenResult(true, null, token), hostGone: false, svc).ShouldBeFalse();
svc.Closed.ShouldBeEmpty();
BrowseOpenGuard.CloseIfOrphaned(new BrowseOpenResult(true, null, token), hostGone: true, svc).ShouldBeTrue();
await svc.WaitForCloseAsync();
svc.Closed.ShouldBe([token]);
}
[Fact]
public void Guard_never_closes_a_failed_open_and_swallows_a_failing_close()
{
var svc = new RecordingBrowserService();
BrowseOpenGuard.CloseIfOrphaned(new BrowseOpenResult(false, "refused", Guid.Empty), hostGone: true, svc).ShouldBeTrue();
svc.Closed.ShouldBeEmpty();
var throwing = new RecordingBrowserService { ThrowOnClose = true };
Should.NotThrow(() => BrowseOpenGuard.CloseIfOrphaned(new BrowseOpenResult(true, null, Guid.NewGuid()), true, throwing));
}
[Fact]
public async Task Picker_disposed_mid_open_closes_the_session_it_was_handed()
{
var gate = new TaskCompletionSource<BrowseOpenResult>();
var svc = new RecordingBrowserService { Open = () => gate.Task };
var picker = NewPicker(svc);
var open = InvokeOpen(picker);
await picker.DisposeAsync();
var token = Guid.NewGuid();
gate.SetResult(new BrowseOpenResult(true, null, token));
await open;
await svc.WaitForCloseAsync();
svc.Closed.ShouldBe([token]);
Token(picker).ShouldBe(Guid.Empty);
}
[Fact]
public async Task Picker_still_open_adopts_the_session()
{
// Positive control for the test above: without the dispose the token is adopted, not closed.
var token = Guid.NewGuid();
var svc = new RecordingBrowserService { Open = () => Task.FromResult(new BrowseOpenResult(true, null, token)) };
var picker = NewPicker(svc);
await InvokeOpen(picker);
Token(picker).ShouldBe(token);
svc.Closed.ShouldBeEmpty();
}
[Fact]
public void Every_browse_host_guards_its_open()
{
foreach (var path in new[]
{
"AbCipAddressPickerBody.razor", "FOCASAddressPickerBody.razor", "GalaxyAddressPickerBody.razor",
"OpcUaClientAddressPickerBody.razor", "TwinCATAddressPickerBody.razor", "SqlAddressPickerBody.razor",
})
{
var src = AdminUiSource.Read("Components", "Shared", "Drivers", "Pickers", path);
src.ShouldContain("BrowseOpenGuard.CloseIfOrphaned(result, _disposed, BrowserService)", customMessage: path);
src.ShouldContain("_disposed = true;", customMessage: path);
}
var modal = AdminUiSource.Read("Components", "Shared", "Raw", "RawBrowseModal.razor");
modal.ShouldContain("BrowseOpenGuard.CloseIfOrphaned(result, _disposed || IsStale(generation), BrowserService)");
}
private OpcUaClientAddressPickerBody NewPicker(IBrowserSessionService svc)
{
var picker = new OpcUaClientAddressPickerBody();
typeof(OpcUaClientAddressPickerBody).GetProperty("BrowserService", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(picker, svc);
var renderer = new InertRenderer();
_renderers.Add(renderer);
renderer.Attach(picker);
return picker;
}
private static Task InvokeOpen(OpcUaClientAddressPickerBody picker) =>
(Task)typeof(OpcUaClientAddressPickerBody).GetMethod("OpenBrowseAsync", BindingFlags.NonPublic | BindingFlags.Instance)!
.Invoke(picker, null)!;
private static Guid Token(OpcUaClientAddressPickerBody picker) =>
(Guid)typeof(OpcUaClientAddressPickerBody).GetField("_token", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(picker)!;
private sealed class RecordingBrowserService : IBrowserSessionService
{
private readonly TaskCompletionSource _closed = new(TaskCreationOptions.RunContinuationsAsynchronously);
public List<Guid> Closed { get; } = new();
public bool ThrowOnClose { get; init; }
public Func<Task<BrowseOpenResult>> Open { get; init; } = () => Task.FromResult(new BrowseOpenResult(false, "n/a", Guid.Empty));
public Task WaitForCloseAsync() => _closed.Task.WaitAsync(TimeSpan.FromSeconds(10));
public bool CanBrowse(string driverType, string configJson) => true;
public Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct) => Open();
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct) => throw new NotImplementedException();
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct) => throw new NotImplementedException();
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct) => throw new NotImplementedException();
public Task<int> RequestRebirthAsync(Guid token, string scope, CancellationToken ct) => throw new NotImplementedException();
public bool CanRequestRebirth(Guid token) => false;
public Task CloseAsync(Guid token)
{
if (ThrowOnClose) throw new InvalidOperationException("remote gone");
lock (Closed) Closed.Add(token);
_closed.TrySetResult();
return Task.CompletedTask;
}
}
private sealed class InertRenderer()
: Renderer(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance)
{
public override Dispatcher Dispatcher { get; } = new InlineDispatcher();
public void Attach(IComponent component) => AssignRootComponentId(component);
protected override void HandleException(Exception exception) => ExceptionDispatchInfo.Capture(exception).Throw();
protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask;
}
private sealed class InlineDispatcher : Dispatcher
{
public override bool CheckAccess() => true;
public override Task InvokeAsync(Action workItem) { workItem(); return Task.CompletedTask; }
public override Task InvokeAsync(Func<Task> workItem) => workItem();
public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem) => Task.FromResult(workItem());
public override Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> workItem) => workItem();
}
}
@@ -87,8 +87,12 @@ public sealed class ModalStaleOpenGuardTests : IDisposable
("OnSaved", EventCallback.Factory.Create(this, () => { saved++; })));
await Open(modal);
modal.GetType().GetField("_result", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(
modal, RawTagCsvMapper.Parse("Modbus", "Name,DataType,TagConfigJson\nok,Float,\"{\"\"region\"\":\"\"HoldingRegisters\"\",\"\"address\"\":1}\""));
var parsed = RawTagCsvMapper.Parse("Modbus", "Name,DataType,TagConfigJson\nok,Float,\"{\"\"region\"\":\"\"HoldingRegisters\"\",\"\"address\"\":1}\"");
modal.GetType().GetField("_result", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(modal, parsed);
// The review grid's view (R04 P6) is what Commit reads its rows and counts from; OnFileSelected
// sets both, so a test that bypasses the upload must too.
((RawCsvReviewView)modal.GetType().GetField("_view", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(modal)!).Set(parsed);
var commit = (Task)modal.GetType().GetMethod("Commit", BindingFlags.NonPublic | BindingFlags.Instance)!
.Invoke(modal, null)!;
@@ -122,7 +126,7 @@ public sealed class ModalStaleOpenGuardTests : IDisposable
Field<string?>(modal, "_groupFilter").ShouldBeNull();
Field<int>(modal, "_rowCount").ShouldBe(0);
Field<string>(modal, "_dataUri").ShouldBe("");
Field<string>(modal, "_downloadUrl").ShouldBe("");
}
[Fact]
@@ -0,0 +1,87 @@
using Microsoft.AspNetCore.Components;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
/// <summary>
/// R04 H2: the modals' DynamicComponent parameter dictionaries are reused across renders while their
/// values are unchanged, and rebuilt — carrying the current value — the moment one changes. The second
/// half is the regression a naive build-once cache would introduce (stale JSON stranding every edit).
/// </summary>
[Trait("Category", "Unit")]
public sealed class ParameterDictionaryCacheTests
{
[Fact]
public void Unchanged_values_return_the_same_dictionary()
{
var cache = new ParameterDictionaryCache();
var builds = 0;
var json = "{}";
Dictionary<string, object> Build() { builds++; return new() { ["Json"] = json }; }
var first = cache.Get([json], Build);
cache.Get([json], Build).ShouldBeSameAs(first);
builds.ShouldBe(1);
}
[Fact]
public void A_changed_value_rebuilds_with_the_current_value()
{
var cache = new ParameterDictionaryCache();
var json = "{}";
Dictionary<string, object> Build() => new() { ["Json"] = json };
cache.Get([json], Build)["Json"].ShouldBe("{}");
json = "{\"a\":1}";
cache.Get([json], Build)["Json"].ShouldBe("{\"a\":1}");
}
[Fact]
public void Null_values_compare_and_reset_forces_a_rebuild()
{
var cache = new ParameterDictionaryCache();
var builds = 0;
Dictionary<string, object> Build() { builds++; return new(); }
cache.Get(["x", null], Build);
cache.Get(["x", null], Build);
builds.ShouldBe(1);
cache.Reset();
cache.Get(["x", null], Build);
builds.ShouldBe(2);
}
[Fact]
public void Modals_build_their_parameters_through_the_cache_with_reused_callbacks()
{
foreach (var (path, cacheField) in new[]
{
(new[] { "Components", "Shared", "Drivers", "DriverConfigModal.razor" }, "_formParameterCache.Get("),
(new[] { "Components", "Shared", "Drivers", "DeviceModal.razor" }, "_deviceFormParameterCache.Get("),
(new[] { "Components", "Shared", "Raw", "RawTagModal.razor" }, "_editorParameterCache.Get("),
})
{
var src = AdminUiSource.Read(path);
src.ShouldContain(cacheField, customMessage: path[^1]);
// Every EventCallback is created behind a ??= so it is made once per component.
var all = System.Text.RegularExpressions.Regex.Matches(src, @"EventCallback\.Factory\.Create").Count;
var once = System.Text.RegularExpressions.Regex.Matches(src, @"\?\?=\s*EventCallback\.Factory\.Create").Count;
all.ShouldBeGreaterThan(0, path[^1]);
once.ShouldBe(all, path[^1]);
}
}
[Fact]
public void RawTagModal_reuses_its_callback_across_renders()
{
var modal = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw.RawTagModal();
var first = modal.BuildEditorParameters();
var second = modal.BuildEditorParameters();
second.ShouldBeSameAs(first);
((EventCallback<string>)first["ConfigJsonChanged"]).HasDelegate.ShouldBeTrue();
}
}
@@ -0,0 +1,178 @@
using System.Reflection;
using System.Runtime.ExceptionServices;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
// BL0006: the test renderer needs RenderTree types (same suppression as RawBrowseModalStaleOpenTests).
#pragma warning disable BL0006
/// <summary>
/// G2-F3: after a rebirth request the browse modal re-reads the tree itself (the session's
/// <c>RequestRebirthAsync</c> now waits for the births), instead of telling the operator to re-expand it.
/// </summary>
[Trait("Category", "Unit")]
public sealed class RawBrowseRebirthRefreshTests : IDisposable
{
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
private readonly List<Renderer> _renderers = new();
public void Dispose()
{
foreach (var r in _renderers) r.Dispose();
}
[Fact]
public async Task A_completed_rebirth_rekeys_the_tree_and_keeps_the_outcome()
{
var svc = new RebirthBrowserService { Result = Task.FromResult(2) };
var modal = NewOpenModal(svc);
var before = Get<int>(modal, "_treeGeneration");
await ConfirmAsync(modal);
Get<int>(modal, "_treeGeneration").ShouldBe(before + 1);
Get<string?>(modal, "_rebirthOutcome")!.ShouldContain("The tree was refreshed");
Get<object?>(modal, "_rebirthTarget").ShouldBeNull();
svc.Scopes.ShouldBe(["Grp/EdgeA"]);
}
[Fact]
public async Task A_modal_closed_during_the_wait_is_not_refreshed()
{
var gate = new TaskCompletionSource<int>();
var svc = new RebirthBrowserService { Result = gate.Task };
var modal = NewOpenModal(svc);
var before = Get<int>(modal, "_treeGeneration");
var confirm = ConfirmAsync(modal);
typeof(RawBrowseModal).GetProperty("Visible")!.SetValue(modal, false);
gate.SetResult(1);
await confirm;
Get<int>(modal, "_treeGeneration").ShouldBe(before);
Get<string?>(modal, "_rebirthOutcome")!.ShouldNotContain("refreshed");
// Positive control: the publish itself was still made and reported.
svc.Scopes.ShouldBe(["Grp/EdgeA"]);
}
[Fact]
public async Task An_open_superseded_by_a_close_and_reopen_closes_its_own_session()
{
// Review r0 M5: Visible is true again after a reopen, so only the generation identifies the stale open.
var gate = new TaskCompletionSource<BrowseOpenResult>();
var svc = new RebirthBrowserService { Result = Task.FromResult(0), Open = () => gate.Task };
var modal = NewOpenModal(svc);
Set(modal, "_token", Guid.Empty);
var open = (Task)typeof(RawBrowseModal).GetMethod("OpenBrowseAsync", Private)!.Invoke(modal, null)!;
Set(modal, "_openGeneration", Get<int>(modal, "_openGeneration") + 1); // closed + reopened meanwhile
var stale = Guid.NewGuid();
gate.SetResult(new BrowseOpenResult(true, null, stale));
await open;
await svc.Closed.Task.WaitAsync(TimeSpan.FromSeconds(10));
svc.ClosedTokens.ShouldBe([stale]);
Get<Guid>(modal, "_token").ShouldBe(Guid.Empty);
}
[Fact]
public async Task A_current_open_adopts_its_session()
{
// Positive control for the test above.
var token = Guid.NewGuid();
var svc = new RebirthBrowserService { Result = Task.FromResult(0), Open = () => Task.FromResult(new BrowseOpenResult(true, null, token)) };
var modal = NewOpenModal(svc);
Set(modal, "_token", Guid.Empty);
await (Task)typeof(RawBrowseModal).GetMethod("OpenBrowseAsync", Private)!.Invoke(modal, null)!;
Get<Guid>(modal, "_token").ShouldBe(token);
svc.ClosedTokens.ShouldBeEmpty();
}
private RawBrowseModal NewOpenModal(IBrowserSessionService svc)
{
var modal = new RawBrowseModal();
typeof(RawBrowseModal).GetProperty("BrowserService", Private)!.SetValue(modal, svc);
typeof(RawBrowseModal).GetProperty("Visible")!.SetValue(modal, true);
Set(modal, "_token", Guid.NewGuid());
Set(modal, "_canRebirth", true);
var targetType = typeof(RawBrowseModal).GetNestedType("RebirthTarget", BindingFlags.NonPublic)!;
Set(modal, "_rebirthTarget", Activator.CreateInstance(targetType, "Grp/EdgeA", "edge node EdgeA", "detail", false));
Set(modal, "_rebirthConfirming", true);
var renderer = new InertRenderer();
_renderers.Add(renderer);
renderer.Attach(modal);
return modal;
}
private static Task ConfirmAsync(RawBrowseModal modal) =>
(Task)typeof(RawBrowseModal).GetMethod("ConfirmRebirthAsync", Private)!.Invoke(modal, null)!;
private static void Set(RawBrowseModal modal, string field, object? value)
{
var f = typeof(RawBrowseModal).GetField(field, Private);
f.ShouldNotBeNull($"{field} was renamed");
f!.SetValue(modal, value);
}
private static T Get<T>(RawBrowseModal modal, string field) =>
(T)typeof(RawBrowseModal).GetField(field, Private)!.GetValue(modal)!;
private sealed class RebirthBrowserService : IBrowserSessionService
{
public required Task<int> Result { get; init; }
public Func<Task<BrowseOpenResult>> Open { get; init; } = () => throw new NotImplementedException();
public List<string> Scopes { get; } = new();
public List<Guid> ClosedTokens { get; } = new();
public TaskCompletionSource Closed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public bool CanBrowse(string driverType, string configJson) => true;
public Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct) => Open();
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct) => throw new NotImplementedException();
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct) => throw new NotImplementedException();
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct) => throw new NotImplementedException();
public bool CanRequestRebirth(Guid token) => true;
public Task CloseAsync(Guid token)
{
lock (ClosedTokens) ClosedTokens.Add(token);
Closed.TrySetResult();
return Task.CompletedTask;
}
public Task<int> RequestRebirthAsync(Guid token, string scope, CancellationToken ct)
{
Scopes.Add(scope);
return Result;
}
}
private sealed class InertRenderer()
: Renderer(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance)
{
public override Dispatcher Dispatcher { get; } = new InlineDispatcher();
public void Attach(IComponent component) => AssignRootComponentId(component);
protected override void HandleException(Exception exception) => ExceptionDispatchInfo.Capture(exception).Throw();
protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask;
}
private sealed class InlineDispatcher : Dispatcher
{
public override bool CheckAccess() => true;
public override Task InvokeAsync(Action workItem) { workItem(); return Task.CompletedTask; }
public override Task InvokeAsync(Func<Task> workItem) => workItem();
public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem) => Task.FromResult(workItem());
public override Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> workItem) => workItem();
}
}
@@ -0,0 +1,133 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
/// <summary>
/// R04 P6: the CSV import grid renders through Virtualize from a precomputed view, and the export is
/// downloaded from an endpoint (bounded preview in the modal) instead of being embedded in the circuit.
/// </summary>
[Trait("Category", "Unit")]
public sealed class RawCsvRenderingTests
{
private static RawCsvParseResult Result(params (int Row, bool Ok)[] rows) =>
new(["Name"], rows.Select(r => new RawCsvReviewRow
{
RowNumber = r.Row, Name = $"t{r.Row}", Error = r.Ok ? null : "bad",
ImportRow = r.Ok ? new RawTagImportRow(null, new RawTagInput($"t{r.Row}", "Int32", Configuration.Enums.TagAccessLevel.Read, false, null, "{}")) : null,
}).ToList(), null);
[Fact]
public void Review_view_counts_and_filters_once_per_change()
{
var view = new RawCsvReviewView();
view.Set(Result((1, true), (2, false), (3, true)));
view.OkCount.ShouldBe(2);
view.ErrorCount.ShouldBe(1);
view.RemainingCount.ShouldBe(3);
view.Remove(2);
view.ErrorCount.ShouldBe(0);
view.VisibleRows.Select(r => r.RowNumber).ShouldBe([1, 3]);
view.CommitRows().Select(r => r.Tag.Name).ShouldBe(["t1", "t3"]);
view.Set(null);
view.RemainingCount.ShouldBe(0);
// Positive control: a fresh set after a clear shows every row again (removals are forgotten).
view.Set(Result((1, true), (2, false)));
view.RemainingCount.ShouldBe(2);
}
[Fact]
public void Preview_is_bounded_and_flags_truncation()
{
var csv = string.Concat(Enumerable.Range(0, 200).Select(i => $"row{i}\n"));
var (preview, truncated) = RawTagCsvExport.Preview(csv, maxLines: 50);
truncated.ShouldBeTrue();
preview.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length.ShouldBe(50);
var (all, cut) = RawTagCsvExport.Preview("a\nb\n", maxLines: 50);
cut.ShouldBeFalse();
all.ShouldBe("a\nb\n");
}
[Fact]
public async Task Preview_build_maps_only_the_first_rows_but_counts_all()
{
// Review r0 M8: the modal maps only its preview's rows; the full count still comes back.
var dbName = Uns.RawTreeTestDb.SeedFresh();
var reader = new RawTagCsvExportReader(Uns.RawTreeTestDb.Factory(dbName));
var full = await RawTagCsvExport.BuildAsync(reader, Uns.RawTreeTestDb.FolderedDeviceId, null, "Modbus");
var preview = await RawTagCsvExport.BuildAsync(reader, Uns.RawTreeTestDb.FolderedDeviceId, null, "Modbus", maxRows: 1);
full!.RowCount.ShouldBe(2);
Commons.Csv.CsvParser.Parse(full.Csv).Count.ShouldBe(3); // header + 2 (positive control)
preview!.RowCount.ShouldBe(2);
Commons.Csv.CsvParser.Parse(preview.Csv).Count.ShouldBe(2); // header + 1
}
[Fact]
public void Attachment_header_carries_an_rfc5987_name_for_non_ascii_ids()
{
var header = RawTagCsvExport.AttachmentHeader(RawTagCsvExport.BuildFileName("Prüfstand"));
header.ShouldStartWith("attachment");
header.ShouldContain("filename*=UTF-8''tags-Pr%C3%BCfstand.csv");
// Positive control: an ASCII name is still a plain filename.
RawTagCsvExport.AttachmentHeader("tags-dev1.csv").ShouldContain("filename=tags-dev1.csv");
}
[Fact]
public void Download_url_escapes_its_query_values()
{
RawTagCsvExport.DownloadUrl("dev 1&x", null, "Modbus")
.ShouldBe("/api/raw/tags.csv?deviceId=dev%201%26x&driverType=Modbus");
RawTagCsvExport.DownloadUrl("", "TG/1", null).ShouldBe("/api/raw/tags.csv?tagGroupId=TG%2F1");
}
[Fact]
public void Download_endpoint_requires_the_same_ConfigEditor_policy_as_the_raw_page()
{
var builder = WebApplication.CreateBuilder();
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("csv-export-metadata"));
builder.Services.AddScoped<RawTagCsvExportReader>();
var app = builder.Build();
app.MapRawTagCsvExport();
var endpoint = ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType<RouteEndpoint>()
.Single(e => e.RoutePattern.RawText == RawTagCsvExport.DownloadPath);
endpoint.Metadata.GetMetadata<IAllowAnonymous>().ShouldBeNull();
endpoint.Metadata.GetOrderedMetadata<IAuthorizeData>().Select(a => a.Policy)
.ShouldContain(AdminUiPolicies.ConfigEditor);
// Parity with the page that links to it.
typeof(ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Raw.GlobalRaw)
.GetCustomAttributes(typeof(AuthorizeAttribute), false).Cast<AuthorizeAttribute>()
.Single().Policy.ShouldBe(AdminUiPolicies.ConfigEditor);
}
[Fact]
public void Modals_no_longer_embed_the_csv_or_render_every_row()
{
var export = AdminUiSource.Read("Components", "Shared", "Raw", "RawCsvExportModal.razor");
export.ShouldNotContain("data:text/csv");
export.ShouldContain("RawTagCsvExport.Preview(");
var import = AdminUiSource.Read("Components", "Shared", "Raw", "RawCsvImportModal.razor");
import.ShouldContain("<Virtualize Items=\"@_view.VisibleRows\"");
import.ShouldNotContain("@foreach (var row in _result.Rows)");
AdminUiSource.Read("EndpointRouteBuilderExtensions.cs").ShouldContain("app.MapRawTagCsvExport();");
}
}
@@ -0,0 +1,140 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
/// <summary>R04 P5: the Raw tree renders a flat, paged row list through Virtualize, with cached menus.</summary>
[Trait("Category", "Unit")]
public sealed class RawTreeRowsTests
{
private static RawNode Node(string key, RawNodeKind kind = RawNodeKind.Tag, bool lazy = false, bool enabled = true) =>
new() { Kind = kind, Key = key, DisplayName = key, HasLazyChildren = lazy, Enabled = enabled };
private static RawNode Device(string key, int tags)
{
var d = Node(key, RawNodeKind.Device, lazy: true);
for (var i = 0; i < tags; i++) d.Children.Add(Node($"{key}/t{i}"));
d.Loaded = true;
d.Expanded = true;
return d;
}
[Fact]
public void Collapsed_nodes_show_no_children()
{
var dev = Device("dev", 3);
dev.Expanded = false;
RawTreeRows.Flatten([dev]).Select(r => r.Key).ShouldBe(["dev"]);
}
[Fact]
public void Expanded_nodes_flatten_depth_first_in_display_order_with_depth()
{
var root = Node("cluster", RawNodeKind.Cluster, lazy: true);
root.Expanded = true;
var drv = Node("drv", RawNodeKind.Driver, lazy: true);
drv.Expanded = true;
drv.Children.Add(Device("devA", 2));
root.Children.Add(drv);
root.Children.Add(Node("folderB", RawNodeKind.Folder, lazy: true));
var rows = RawTreeRows.Flatten([root]);
rows.Select(r => (r.Key, r.Depth)).ShouldBe(
[
("cluster", 0), ("drv", 1), ("devA", 2), ("devA/t0", 3), ("devA/t1", 3), ("folderB", 1),
]);
}
[Fact]
public void A_5k_tag_device_shows_one_page_then_a_show_more_row()
{
var dev = Device("dev", 5000);
var rows = RawTreeRows.Flatten([dev]);
rows.Count.ShouldBe(1 + RawTreeRows.DefaultPageSize + 1);
var more = rows[^1];
more.Kind.ShouldBe(RawTreeRowKind.ShowMore);
more.Remaining.ShouldBe(5000 - RawTreeRows.DefaultPageSize);
more.Depth.ShouldBe(1);
RawTreeRows.ShowMore(dev);
var after = RawTreeRows.Flatten([dev]);
after.Count(r => r.Kind == RawTreeRowKind.Node).ShouldBe(1 + 2 * RawTreeRows.DefaultPageSize);
after[^1].Remaining.ShouldBe(5000 - 2 * RawTreeRows.DefaultPageSize);
}
[Fact]
public void A_level_at_or_under_the_page_size_has_no_show_more_row()
{
var rows = RawTreeRows.Flatten([Device("dev", RawTreeRows.DefaultPageSize)]);
rows.ShouldNotContain(r => r.Kind == RawTreeRowKind.ShowMore);
// Positive control: one more child does produce one.
RawTreeRows.Flatten([Device("dev", RawTreeRows.DefaultPageSize + 1)])
.Count(r => r.Kind == RawTreeRowKind.ShowMore).ShouldBe(1);
}
[Fact]
public void Show_more_row_follows_the_whole_subtree_of_the_last_shown_child()
{
var dev = Node("dev", RawNodeKind.Device, lazy: true);
dev.Expanded = true;
dev.VisibleChildLimit = 1;
var grp = Node("grp", RawNodeKind.TagGroup, lazy: true);
grp.Expanded = true;
grp.Children.Add(Node("grp/t"));
dev.Children.Add(grp);
dev.Children.Add(Node("dev/hidden"));
RawTreeRows.Flatten([dev]).Select(r => r.Key).ShouldBe(["dev", "grp", "grp/t", "more:dev"]);
}
[Fact]
public void Loading_and_error_rows_replace_children()
{
var loading = Device("a", 2);
loading.Loading = true;
var failed = Device("b", 2);
failed.Error = "boom";
RawTreeRows.Flatten([loading, failed]).Select(r => r.Kind).ShouldBe(
[
RawTreeRowKind.Node, RawTreeRowKind.Loading, RawTreeRowKind.Node, RawTreeRowKind.Error,
]);
}
[Fact]
public void Menu_cache_reuses_the_list_and_rebuilds_when_a_dependency_changes()
{
var cache = new RawNodeMenuCache();
var builds = 0;
IReadOnlyList<ContextMenuItem> Build(RawNode _) { builds++; return [new ContextMenuItem { Label = "x" }]; }
var node = Node("drv", RawNodeKind.Driver);
var first = cache.GetOrBuild(node, pickerMode: false, Build);
cache.GetOrBuild(node, pickerMode: false, Build).ShouldBeSameAs(first);
builds.ShouldBe(1);
// Picker mode is a different menu entirely.
cache.GetOrBuild(node, pickerMode: true, Build).ShouldNotBeSameAs(first);
builds.ShouldBe(2);
// A reload replaces the node instance (Enabled is init-only), so the new node gets its own menu.
var reloaded = Node("drv", RawNodeKind.Driver, enabled: false);
cache.GetOrBuild(reloaded, pickerMode: true, Build);
builds.ShouldBe(3);
}
[Fact]
public void RawTree_renders_through_Virtualize_and_the_menu_cache()
{
var src = AdminUiSource.Read("Components", "Shared", "Raw", "RawTree.razor");
src.ShouldContain("<Virtualize Items=\"@RawTreeRows.Flatten(Roots)\"");
src.ShouldContain("_menus.GetOrBuild(node, PickerMode, MenuFor)");
src.ShouldNotContain("@RenderNode(child, depth + 1)");
}
}
@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptList;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptList;
/// <summary>R04 P8: the <c>/scripts</c> list is a projection without source; source loads per dialog.</summary>
[Trait("Category", "Unit")]
public sealed class ScriptListQueriesTests
{
private static OtOpcUaConfigDbContext NewDb(string name) =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
private static async Task<string> SeedAsync()
{
var name = Guid.NewGuid().ToString("N");
await using var db = NewDb(name);
db.Scripts.AddRange(
new Script { ScriptId = "s-b", Name = "Beta", SourceCode = "return 2;", SourceHash = "hb" },
new Script { ScriptId = "s-a", Name = "Alpha", SourceCode = "return 1;", SourceHash = "ha", Language = "CSharp" });
await db.SaveChangesAsync();
return name;
}
[Fact]
public async Task LoadRowsAsync_returns_name_ordered_rows_without_source()
{
var name = await SeedAsync();
await using var db = NewDb(name);
var rows = await ScriptListQueries.LoadRowsAsync(db);
rows.Select(r => r.ScriptId).ShouldBe(["s-a", "s-b"]);
rows[0].ShouldBe(new ScriptListRow("s-a", "Alpha", "CSharp", "ha"));
// The row type has no source member at all — the list cannot load it by accident.
typeof(ScriptListRow).GetProperty("SourceCode").ShouldBeNull();
}
[Fact]
public async Task LoadSourceAsync_returns_one_scripts_source_or_null_when_gone()
{
var name = await SeedAsync();
await using var db = NewDb(name);
(await ScriptListQueries.LoadSourceAsync(db, "s-b")).ShouldBe("return 2;");
(await ScriptListQueries.LoadSourceAsync(db, "missing")).ShouldBeNull();
}
[Fact]
public void Scripts_page_uses_the_projection()
{
var src = AdminUiSource.Read("Components", "Pages", "Scripts.razor");
src.ShouldContain("ScriptListQueries.LoadRowsAsync(");
src.ShouldNotContain("List<Script>");
}
[Fact]
public void Source_fetch_failure_is_shown_in_the_dialog_not_thrown()
{
// Review r0 M7 source pin: the fetch sits inside a try whose catch sets _sourceError.
var src = AdminUiSource.Read("Components", "Pages", "Scripts.razor");
var fetch = src.IndexOf("ScriptListQueries.LoadSourceAsync(", StringComparison.Ordinal);
var tryAt = src.LastIndexOf("try", fetch, StringComparison.Ordinal);
var catchAt = src.IndexOf("catch (Exception ex)", fetch, StringComparison.Ordinal);
tryAt.ShouldBeGreaterThan(src.IndexOf("private async Task ShowSource", StringComparison.Ordinal));
catchAt.ShouldBeGreaterThan(fetch);
src.IndexOf("_sourceError = ", catchAt, StringComparison.Ordinal).ShouldBeGreaterThan(catchAt);
src.ShouldContain("@_sourceError");
}
}
@@ -141,6 +141,7 @@ public sealed class TelemetryDialDeadlineTests : TestKit
public void Upsert(DriverHealthChanged snapshot) { Upserts.Enqueue(snapshot); SnapshotChanged?.Invoke(snapshot); }
public bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot) { snapshot = null!; return false; }
public IReadOnlyCollection<DriverHealthChanged> GetAll() => Upserts.ToArray();
public int EvictUnconfiguredStale(IReadOnlySet<string> configuredInstanceIds, DateTime olderThanUtc) => 0;
}
private sealed class NullResilienceStore : IDriverResilienceStatusStore
@@ -149,5 +150,6 @@ public sealed class TelemetryDialDeadlineTests : TestKit
public void Upsert(DriverResilienceStatusChanged snapshot) => SnapshotChanged?.Invoke(snapshot);
public IReadOnlyList<DriverResilienceStatusChanged> GetForInstance(string driverInstanceId) => Array.Empty<DriverResilienceStatusChanged>();
public IReadOnlyCollection<DriverResilienceStatusChanged> GetAll() => Array.Empty<DriverResilienceStatusChanged>();
public int EvictUnconfiguredStale(IReadOnlySet<string> configuredInstanceIds, DateTime olderThanUtc) => 0;
}
}
@@ -438,6 +438,7 @@ public sealed class TelemetryDialSupervisorTests : TestKit
}
public IReadOnlyCollection<DriverHealthChanged> GetAll() => Upserts.ToArray();
public int EvictUnconfiguredStale(IReadOnlySet<string> configuredInstanceIds, DateTime olderThanUtc) => 0;
}
private sealed class RecordingResilienceStore : IDriverResilienceStatusStore
@@ -456,6 +457,7 @@ public sealed class TelemetryDialSupervisorTests : TestKit
Upserts.ToArray();
public IReadOnlyCollection<DriverResilienceStatusChanged> GetAll() => Upserts.ToArray();
public int EvictUnconfiguredStale(IReadOnlySet<string> configuredInstanceIds, DateTime olderThanUtc) => 0;
}
private sealed class InMemoryFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
@@ -0,0 +1,40 @@
using System.Text.RegularExpressions;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// R04 H1 source pin (no bUnit): every destructive button on the equipment page and the script editor
/// opens a confirm dialog instead of deleting on the first click. The live check (each delete confirms)
/// is in the Wave 4 docker-dev pass.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DeleteConfirmationWiringTests
{
[Fact]
public void Equipment_page_routes_every_destructive_row_action_through_the_confirm()
{
var src = AdminUiSource.Read("Components", "Pages", "Uns", "EquipmentPage.razor");
src.ShouldContain("<RawConfirmDialog @bind-Visible=\"_confirmVisible\"");
src.ShouldContain("@onclick=\"() => ConfirmRemoveReference(r)\"");
src.ShouldContain("@onclick=\"() => ConfirmDeleteVirtualTag(v.VirtualTagId, v.Name)\"");
src.ShouldContain("@onclick=\"() => ConfirmDeleteAlarm(a.ScriptedAlarmId, a.Name)\"");
// No danger button may call a delete handler directly.
Regex.Matches(src, @"btn-outline-danger[^>]*@onclick=""\(\) => (Delete|Remove)\w*\(").Count.ShouldBe(0);
// Positive control: the page still has its three danger buttons.
Regex.Matches(src, "btn-outline-danger").Count.ShouldBe(3);
}
[Fact]
public void Script_editor_delete_confirms()
{
var src = AdminUiSource.Read("Components", "Pages", "ScriptEdit.razor");
src.ShouldContain("@onclick=\"() => _confirmDeleteVisible = true\"");
src.ShouldContain("OnConfirm=\"DeleteAsync\"");
src.ShouldNotContain("@onclick=\"DeleteAsync\"");
}
}
@@ -0,0 +1,62 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>R04 S6: the equipment CSV import reads RFC 4180 through the shared CsvParser.</summary>
[Trait("Category", "Unit")]
public sealed class EquipmentCsvParserTests
{
private const string Header = "Name,MachineCode,UnsLineId,ZTag,SAPID,Manufacturer,Model";
[Fact]
public void A_quoted_comma_stays_in_its_column()
{
var (rows, error) = EquipmentCsvParser.Parse(
Header + "\nPress 1,MC-1,LINE-1,Z1,S1,\"Acme, Inc.\",X200\n");
error.ShouldBeNull();
rows!.Single().Manufacturer.ShouldBe("Acme, Inc.");
// The column after the quoted comma did not shift.
rows!.Single().Model.ShouldBe("X200");
}
[Fact]
public void Doubled_quotes_crlf_and_blank_lines_parse()
{
var (rows, error) = EquipmentCsvParser.Parse(
Header + "\r\n\r\n\"Press \"\"A\"\"\",MC-1,LINE-1,,,,\r\nPress B,MC-2,LINE-1\r\n");
error.ShouldBeNull();
rows!.Select(r => r.Name).ShouldBe(["Press \"A\"", "Press B"]);
rows![0].ZTag.ShouldBeNull();
rows[1].Manufacturer.ShouldBeNull();
}
[Fact]
public void Header_and_shape_errors_are_reported()
{
EquipmentCsvParser.Parse("").Error.ShouldBe("CSV is empty.");
EquipmentCsvParser.Parse(Header).Error.ShouldBe("Need a header row and at least one data row.");
EquipmentCsvParser.Parse("Name,Code,UnsLineId\na,b,c").Error!.ShouldContain("Header column #2 must be 'MachineCode'");
EquipmentCsvParser.Parse(Header + "\nonly,two").Error!.ShouldContain("Row 1: too few columns");
}
[Fact]
public void Malformed_quoting_is_an_error_not_a_guess()
{
var (rows, error) = EquipmentCsvParser.Parse(Header + "\nPress,MC-1,LINE-1,\"unterminated\n");
rows.ShouldBeNull();
error!.ShouldStartWith("The CSV is malformed:");
}
[Fact]
public void Modal_delegates_to_the_parser()
{
var src = AdminUiSource.Read("Components", "Shared", "Uns", "ImportEquipmentModal.razor");
src.ShouldContain("EquipmentCsvParser.Parse(csv)");
src.ShouldNotContain(".Split(',')");
}
}
@@ -0,0 +1,58 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// G2-F2: a Sparkplug array / unsupported-type metric is refused at commit with a "not bindable in this
/// version" message that echoes the browser's descriptor — not the "attribute lookup returned nothing"
/// text, which is reserved for a lookup that really did return nothing.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MqttUnbindableLeafMessageTests
{
[Fact]
public void An_array_metric_says_it_is_not_bindable_and_echoes_the_descriptor()
{
const string descriptor = "Metric · Int32Array · alias 7 · array metrics are not read in v1 — not bindable";
var error = RawBrowseCommitMapper.DescribeUncommittableLeaf("Mqtt", "Pressures", addressFields: null, descriptor);
error.ShouldNotBeNull();
error!.ShouldContain("'Pressures' cannot be bound in this version");
error.ShouldContain(descriptor);
error.ShouldNotContain("attribute lookup returned nothing");
}
[Fact]
public void A_lookup_that_returned_nothing_keeps_the_original_message()
{
// Positive control: the new branch is selected by the descriptor, not by MQTT in general.
RawBrowseCommitMapper.DescribeUncommittableLeaf("Mqtt", "Temperature", addressFields: null, descriptor: null)!
.ShouldContain("attribute lookup returned nothing");
RawBrowseCommitMapper.DescribeUncommittableLeaf("Mqtt", "Temperature", null, "Metric · Double")!
.ShouldContain("attribute lookup returned nothing");
}
[Fact]
public void A_bindable_leaf_is_never_refused_whatever_its_descriptor_says()
=> RawBrowseCommitMapper.DescribeUncommittableLeaf(
"Mqtt", "Temperature",
new Dictionary<string, string> { [ZB.MOM.WW.OtOpcUa.Driver.Mqtt.MqttTagConfigKeys.MetricName] = "Temperature" },
"anything · not bindable")
.ShouldBeNull();
[Fact]
public void The_marker_matches_what_the_sparkplug_browser_writes()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) dir = dir.Parent;
var browser = File.ReadAllText(Path.Combine(dir!.FullName, "src", "Drivers",
"ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser", "MqttBrowseSession.cs"));
browser.ShouldContain("— " + RawBrowseCommitMapper.NotBindableMarker + "\")");
AdminUiSource.Read("Components", "Shared", "Raw", "RawBrowseModal.razor")
.ShouldContain("DescribeUncommittableLeaf(_driverType, s.Name, s.AddressFields, s.Descriptor)");
}
}
@@ -37,7 +37,7 @@ public sealed class UnsTreeAssemblyTests
var clusters = new[] { new ClusterRow("c1", "zb", "SiteA", "Alpha") };
var areas = new[] { new AreaRow("a1", "c1", "AreaOne") };
var lines = new[] { new LineRow("l1", "a1", "LineOne") };
var equipment = new[] { new EquipmentRow("e1", "l1", "MC-1", "EquipOne", 0, 0) };
var equipment = new[] { new EquipmentRow("e1", "l1", "MC-1", "EquipOne", 0) };
var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment);
@@ -75,8 +75,8 @@ public sealed class UnsTreeAssemblyTests
var lines = new[] { new LineRow("l1", "a1", "LineOne") };
var equipment = new[]
{
new EquipmentRow("e1", "l1", "MC-1", "WithChildren", 2, 1),
new EquipmentRow("e2", "l1", "MC-2", "Empty", 0, 0),
new EquipmentRow("e1", "l1", "MC-1", "WithChildren", 3),
new EquipmentRow("e2", "l1", "MC-2", "Empty", 0),
};
var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment);
@@ -84,7 +84,7 @@ public sealed class UnsTreeAssemblyTests
// Equipment is a tree leaf that links to its own detail page, so it never carries an
// expander — HasLazyChildren is always false regardless of its tag/virtual-tag count.
// The ChildCount badge still reflects tags + virtual tags.
// The ChildCount badge reflects its virtual tags.
var withChildren = line.Children.Single(e => e.Key == "eq:e1");
withChildren.ChildCount.ShouldBe(3);
withChildren.HasLazyChildren.ShouldBeFalse();
@@ -145,8 +145,8 @@ public sealed class UnsTreeAssemblyTests
};
var equipment = new[]
{
new EquipmentRow("e2", "l1", "MC-2", "EquipB", 0, 0),
new EquipmentRow("e1", "l1", "MC-1", "EquipA", 0, 0),
new EquipmentRow("e2", "l1", "MC-2", "EquipB", 0),
new EquipmentRow("e1", "l1", "MC-1", "EquipA", 0),
};
var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment);
@@ -14,16 +14,8 @@ public sealed class UnsTreeServiceEquipmentChildRowsTests
return new UnsTreeService(UnsTreeTestDb.Factory(dbName));
}
// v3 Batch-1: the equipment↔Tag binding was retired (Tag is Raw-only; authoring moved to the Raw
// tree in Batch 2). LoadTagsForEquipmentAsync is a stub that always returns empty — no equipment
// has bound driver tags to list. The former in-name-order / EquipmentId-scoping assertions moved
// with the authoring surface to the Batch-2 Raw-tree tests.
[Fact]
public async Task LoadTagsForEquipment_returns_empty_stub()
{
var rows = await SeededService().LoadTagsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId);
rows.ShouldBeEmpty();
}
// v3 Batch-1 retired the equipment↔Tag binding; the always-empty LoadTagsForEquipmentAsync stub and
// its EquipmentTagRow were deleted (R04 S4). Equipment tags are UnsTagReference rows now.
[Fact]
public async Task LoadVirtualTagsForEquipment_returns_vtags_in_name_order()
@@ -34,8 +26,4 @@ public sealed class UnsTreeServiceEquipmentChildRowsTests
rows[0].Name.ShouldBe("computed");
rows[0].DataType.ShouldBe("Double");
}
[Fact]
public async Task LoadTagsForEquipment_empty_for_unknown_equipment()
=> (await SeededService().LoadTagsForEquipmentAsync("EQ-NONE")).ShouldBeEmpty();
}
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <c>FolderPath</c>), the <c>Namespace</c>/<c>NamespaceKind</c> entities were deleted, and tag
/// authoring moved to the Raw tree (<c>/raw</c>) in Batch 2. So <see cref="UnsTreeService"/>'s
/// <c>CreateTagAsync</c> / <c>UpdateTagAsync</c> now return a uniform Batch-1 refusal, and
/// <c>LoadTagsForEquipmentAsync</c> / <c>LoadTagDriversForEquipmentAsync</c> return empty. The tests
/// <c>LoadTagDriversForEquipmentAsync</c> returns empty. The tests
/// below assert that stub contract (preserving the intent of the old CRUD-guard / driver-loader
/// tests) plus the still-live <c>DeleteTagAsync</c> path (which continues to remove a raw Tag row).
/// </summary>
@@ -153,16 +153,6 @@ public sealed class UnsTreeServiceTagTests
// ----- Per-equipment loaders: retired → empty -----
/// <summary>The per-equipment tag list is retired (Tag is Raw-only) → always empty.</summary>
[Fact]
public async Task LoadTagsForEquipment_returns_empty()
{
var (service, dbName) = Fresh();
SeedHierarchy(dbName);
(await service.LoadTagsForEquipmentAsync("EQ-1")).ShouldBeEmpty();
}
/// <summary>The per-equipment candidate-driver loader is retired (equipment↔driver binding gone) → always empty.</summary>
[Fact]
public async Task LoadTagDriversForEquipment_returns_empty()