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:
@@ -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 = "{}";
|
||||
|
||||
|
||||
+18
-10
@@ -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 = [];
|
||||
|
||||
|
||||
+49
-19
@@ -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 · @TruncatedMessage()
|
||||
Failed · @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();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+5
@@ -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.
|
||||
|
||||
+5
@@ -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.
|
||||
|
||||
+5
@@ -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.
|
||||
|
||||
+5
@@ -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.
|
||||
|
||||
+5
@@ -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.
|
||||
|
||||
+5
@@ -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>
|
||||
|
||||
+28
-51
@@ -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);
|
||||
}
|
||||
|
||||
+17
-16
@@ -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…
|
||||
</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…
|
||||
</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.
|
||||
|
||||
+5
-55
@@ -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 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");
|
||||
}
|
||||
}
|
||||
+3
@@ -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);
|
||||
|
||||
+2
-14
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user