Merge #847 AdminUI polish
This commit is contained in:
@@ -65,6 +65,13 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||
/// capability. Additive, never a state change. Same by-reference value-equality caveat as
|
||||
/// <paramref name="HostStatuses"/>.
|
||||
/// </param>
|
||||
/// <param name="ReportingNodeId">
|
||||
/// The <c>ClusterNode.NodeId</c> of the node whose driver actor produced this snapshot (lmxopcua#847), or
|
||||
/// null from a node that predates the field / a harness with no role info. Both nodes of a redundant pair
|
||||
/// run the same driver instance, so <c>DriverInstanceId</c> alone is NOT a unique key: keyed by it, a driver
|
||||
/// Faulted on central-1 and Healthy on central-2 flipped between the two on every publish. Consumers that keep
|
||||
/// a last-value cache key by (DriverInstanceId, ReportingNodeId). Additive — an old central ignores it.
|
||||
/// </param>
|
||||
public sealed record DriverHealthChanged(
|
||||
string ClusterId,
|
||||
string DriverInstanceId,
|
||||
@@ -77,7 +84,8 @@ public sealed record DriverHealthChanged(
|
||||
string? RediscoveryReason = null,
|
||||
IReadOnlyList<HostConnectivityStatus>? HostStatuses = null,
|
||||
SkippedTagReport? SkippedTags = null,
|
||||
IReadOnlyDictionary<string, string>? Diagnostics = null)
|
||||
IReadOnlyDictionary<string, string>? Diagnostics = null,
|
||||
string? ReportingNodeId = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// DPS topic name. Both the runtime <c>AkkaDriverHealthPublisher</c> and the AdminUI
|
||||
|
||||
@@ -88,6 +88,10 @@ message DriverHealth {
|
||||
// flag carries that bit - the same shape as has_host_statuses.
|
||||
bool has_diagnostics = 13;
|
||||
map<string, string> diagnostics = 14;
|
||||
// The ClusterNode.NodeId of the reporting node (lmxopcua#847). Both nodes of a redundant pair run the same
|
||||
// driver instance, so central keys its last-value cache by (driver_instance_id, reporting_node_id). Additive:
|
||||
// absent from an old node (central then falls back to the driver id alone).
|
||||
optional string reporting_node_id = 15;
|
||||
}
|
||||
|
||||
// Mirrors ZB.MOM.WW.OtOpcUa.Core.Abstractions.SkippedTagReport.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
@page "/hosts"
|
||||
@* Two views, top to bottom. (1) Akka cluster topology: each member's NodeId (host:port), roles,
|
||||
leader status — the cluster-membership view. (2) Driver instances: live driver-health grouped by
|
||||
cluster. The health feed (DriverHealthChanged) carries no per-Akka-member identity, so the rows
|
||||
are cluster-scoped (keyed per driver instance across the cluster, not per member). The section
|
||||
cluster. Each health snapshot carries its reporting node (DriverHealthChanged.ReportingNodeId, #847), so the
|
||||
rows are one per (driver instance, node) — a driver Faulted on one node and Healthy on its peer shows both. The section
|
||||
reads the in-process driver-health snapshot store directly + reloads its config from the ConfigDB. *@
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@@ -152,6 +152,7 @@ else
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Driver</th>
|
||||
<th title="The node reporting this row — each node of a redundant pair runs the driver, so a driver has one row per node">Node</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Hosts</th>
|
||||
@@ -166,14 +167,14 @@ else
|
||||
@if (g.Drivers.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="9"><span class="text-muted">No drivers.</span></td>
|
||||
<td colspan="10"><span class="text-muted">No drivers.</span></td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var d in g.Drivers)
|
||||
{
|
||||
<tr @key="@($"row-{d.DriverInstanceId}")">
|
||||
<tr @key="@($"row-{d.RowKey}")">
|
||||
<td>
|
||||
@(d.Name ?? d.DriverInstanceId)
|
||||
@if (d.RediscoveryNeededUtc is not null)
|
||||
@@ -203,6 +204,16 @@ else
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (d.NodeId is null)
|
||||
{
|
||||
<span class="text-muted" title="Reported by a node that predates per-node health (#847)">—</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="mono">@d.NodeId</span>
|
||||
}
|
||||
</td>
|
||||
<td>@(d.DriverType ?? "—")</td>
|
||||
<td><span class="chip @DriverChipClass(d.State)">@d.State</span></td>
|
||||
<td>
|
||||
|
||||
@@ -62,14 +62,7 @@
|
||||
<DriverTestConnectButton DriverType="@_driverType" GetConfigJson="@BuildMergedProbeConfig" />
|
||||
</div>
|
||||
|
||||
@if (DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson) is { } gateError)
|
||||
{
|
||||
<div class="panel notice notice-alert mt-3">@gateError</div>
|
||||
}
|
||||
@if (_saveError is not null)
|
||||
{
|
||||
<div class="panel notice notice-alert mt-3">@_saveError</div>
|
||||
}
|
||||
<SaveRefusalNotice GateError="@DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson)" SaveError="@_saveError" />
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -122,7 +115,7 @@
|
||||
{
|
||||
["DeviceConfigJson"] = _deviceConfigJson,
|
||||
["DeviceConfigJsonChanged"] = _deviceConfigJsonChanged ??=
|
||||
EventCallback.Factory.Create<string>(this, v => _deviceConfigJson = v),
|
||||
EventCallback.Factory.Create<string>(this, v => { _deviceConfigJson = v; _saveError = null; }),
|
||||
});
|
||||
private readonly ParameterDictionaryCache _deviceFormParameterCache = new();
|
||||
private EventCallback<string>? _deviceConfigJsonChanged;
|
||||
@@ -200,8 +193,10 @@
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_saveError = DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson);
|
||||
if (_saveError is not null) return;
|
||||
// A resubmit REPLACES the previous refusal; a gate refusal is already rendered live by
|
||||
// SaveRefusalNotice, so it is not copied into _saveError (that stacked two boxes — lmxopcua#847).
|
||||
_saveError = null;
|
||||
if (DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson) is not null) return;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
|
||||
+6
-11
@@ -63,14 +63,7 @@
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (DriverConfigSaveGate.Validate(_driverType, _driverConfigJson) is { } gateError)
|
||||
{
|
||||
<div class="panel notice notice-alert mt-3">@gateError</div>
|
||||
}
|
||||
@if (_saveError is not null)
|
||||
{
|
||||
<div class="panel notice notice-alert mt-3">@_saveError</div>
|
||||
}
|
||||
<SaveRefusalNotice GateError="@DriverConfigSaveGate.Validate(_driverType, _driverConfigJson)" SaveError="@_saveError" />
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -126,7 +119,7 @@
|
||||
{
|
||||
["DriverConfigJson"] = _driverConfigJson,
|
||||
["DriverConfigJsonChanged"] = _driverConfigJsonChanged ??=
|
||||
EventCallback.Factory.Create<string>(this, v => _driverConfigJson = v),
|
||||
EventCallback.Factory.Create<string>(this, v => { _driverConfigJson = v; _saveError = null; }),
|
||||
["ResilienceConfig"] = _resilienceConfig!,
|
||||
["ResilienceConfigChanged"] = _resilienceConfigChanged ??=
|
||||
EventCallback.Factory.Create<string?>(this, v => _resilienceConfig = v),
|
||||
@@ -190,8 +183,10 @@
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_saveError = DriverConfigSaveGate.Validate(_driverType, _driverConfigJson);
|
||||
if (_saveError is not null) return;
|
||||
// A resubmit REPLACES the previous refusal; a gate refusal is already rendered live by
|
||||
// SaveRefusalNotice, so it is not copied into _saveError (that stacked two boxes — lmxopcua#847).
|
||||
_saveError = null;
|
||||
if (DriverConfigSaveGate.Validate(_driverType, _driverConfigJson) is not null) return;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
|
||||
+1
-3
@@ -29,9 +29,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="chip chip-bad" title="@_result.Message">
|
||||
Failed · @DisplayText.Abbreviate(_result.Message, 60)
|
||||
</span>
|
||||
<RefusalChip Message="@_result.Message" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
+1
-3
@@ -57,7 +57,7 @@
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
|
||||
@if (_openError is not null) { <RefusalChip Message="@_openError" /> }
|
||||
</div>
|
||||
|
||||
@if (_token != Guid.Empty)
|
||||
@@ -154,8 +154,6 @@
|
||||
return _useIndex ? $"{_tagName}[{_elementIndex}]" : _tagName;
|
||||
}
|
||||
|
||||
private string TruncatedError() =>
|
||||
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
+1
-3
@@ -121,7 +121,7 @@
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
|
||||
@if (_openError is not null) { <RefusalChip Message="@_openError" /> }
|
||||
</div>
|
||||
|
||||
@if (_token != Guid.Empty)
|
||||
@@ -228,8 +228,6 @@
|
||||
? FocasAddressBuilder.BuildAlarm(_alarmScope, _alarmLow, _alarmHigh)
|
||||
: FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
||||
|
||||
private string TruncatedError() =>
|
||||
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
+1
-3
@@ -42,7 +42,7 @@
|
||||
<span class="chip chip-ok">Browser open</span>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">Close</button>
|
||||
}
|
||||
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
|
||||
@if (_openError is not null) { <RefusalChip Message="@_openError" /> }
|
||||
</div>
|
||||
|
||||
@if (_token != Guid.Empty)
|
||||
@@ -215,8 +215,6 @@
|
||||
return $"{_tagName}.{_attributeName}";
|
||||
}
|
||||
|
||||
private string TruncatedError() =>
|
||||
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
+1
-3
@@ -40,7 +40,7 @@
|
||||
Close
|
||||
</button>
|
||||
}
|
||||
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
|
||||
@if (_openError is not null) { <RefusalChip Message="@_openError" /> }
|
||||
</div>
|
||||
|
||||
@if (_token != Guid.Empty)
|
||||
@@ -113,8 +113,6 @@
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
|
||||
private string TruncatedError() =>
|
||||
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
+1
-1
@@ -156,7 +156,7 @@ else
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">Close</button>
|
||||
}
|
||||
@* Same open-error chip the five sibling pickers use: abbreviated inline, full text on title. *@
|
||||
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@DisplayText.Abbreviate(_openError, 60)</span> }
|
||||
@if (_openError is not null) { <RefusalChip Message="@_openError" /> }
|
||||
</div>
|
||||
|
||||
@if (_token != Guid.Empty)
|
||||
|
||||
+1
-3
@@ -46,7 +46,7 @@
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (_openError is not null) { <span class="chip chip-bad" title="@_openError">@TruncatedError()</span> }
|
||||
@if (_openError is not null) { <RefusalChip Message="@_openError" /> }
|
||||
</div>
|
||||
|
||||
@if (_token != Guid.Empty)
|
||||
@@ -133,8 +133,6 @@
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
|
||||
private string TruncatedError() =>
|
||||
_openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@* A failed Test Connect / browse-open outcome (lmxopcua#847). The chip carries only a short status word; the
|
||||
full refusal text is rendered READABLY under it — wrapping, normal case, selectable — instead of being cut
|
||||
to a few characters inside the (uppercase, inline) chip with the real reason only in a hover tooltip. A
|
||||
secret-reference refusal ("SEC…") was unreadable that way, and a tooltip cannot be copied from.
|
||||
Renders as an inline column, so it drops into the existing d-flex rows beside the button unchanged. *@
|
||||
<span class="d-inline-flex flex-column align-items-start" style="min-width:0;max-width:100%">
|
||||
<span class="chip chip-bad">@Label</span>
|
||||
@if (!string.IsNullOrWhiteSpace(Message))
|
||||
{
|
||||
<span class="small text-danger text-break mt-1" style="white-space:pre-wrap;user-select:text">@Message</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
@code {
|
||||
/// <summary>The short chip text (default <c>Failed</c>).</summary>
|
||||
[Parameter] public string Label { get; set; } = "Failed";
|
||||
|
||||
/// <summary>The full refusal / failure message, shown untruncated under the chip.</summary>
|
||||
[Parameter] public string? Message { get; set; }
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
@* The single save-error area under a driver/device form (lmxopcua#847) — see SaveRefusalNotices. *@
|
||||
@foreach (var message in SaveRefusalNotices.Messages(GateError, SaveError))
|
||||
{
|
||||
<div class="panel notice notice-alert mt-3" role="alert">@message</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The live save-gate refusal for the current form state, or null.</summary>
|
||||
[Parameter] public string? GateError { get; set; }
|
||||
|
||||
/// <summary>The last save attempt's error, or null.</summary>
|
||||
[Parameter] public string? SaveError { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// The one error area a driver/device modal shows under its form (lmxopcua#847). The modals used to render the
|
||||
/// live <see cref="Uns.DriverConfigSaveGate"/> refusal AND the last save's error as two independent boxes, and
|
||||
/// a Save that the gate refused copied the gate text into the save error — so a refused save (e.g. a Galaxy
|
||||
/// credential reference outside its namespace) stacked two identical boxes, and a server refusal from an
|
||||
/// earlier save could stay standing beside a newer one.
|
||||
/// </summary>
|
||||
public static class SaveRefusalNotices
|
||||
{
|
||||
/// <summary>The distinct messages to render, the live gate refusal first.</summary>
|
||||
/// <param name="gateError">The live save-gate refusal for the current form state, or null.</param>
|
||||
/// <param name="saveError">The last save attempt's error, or null.</param>
|
||||
/// <returns>Zero, one or two messages — never the same text twice, and never a save error that merely
|
||||
/// repeats (contains, or is contained by) the gate refusal already shown.</returns>
|
||||
public static IReadOnlyList<string> Messages(string? gateError, string? saveError)
|
||||
{
|
||||
var gate = string.IsNullOrWhiteSpace(gateError) ? null : gateError.Trim();
|
||||
var save = string.IsNullOrWhiteSpace(saveError) ? null : saveError.Trim();
|
||||
|
||||
if (gate is null)
|
||||
{
|
||||
return save is null ? [] : [save];
|
||||
}
|
||||
|
||||
if (save is null
|
||||
|| save.Contains(gate, StringComparison.Ordinal)
|
||||
|| gate.Contains(save, StringComparison.Ordinal))
|
||||
{
|
||||
return [gate];
|
||||
}
|
||||
|
||||
return [gate, save];
|
||||
}
|
||||
}
|
||||
+69
-35
@@ -8,9 +8,11 @@
|
||||
|
||||
@* lmxopcua#815 upgrade preflight surface: stored / deployed configs whose credential references sit outside their
|
||||
driver's namespace (the boot scan, re-runnable here) plus running drivers that reported resolving one through the
|
||||
deprecated warn-only compatibility path (driver-health diagnostics, fleet-wide). Never shows a value. *@
|
||||
deprecated warn-only compatibility path (driver-health diagnostics, fleet-wide). Never shows a value.
|
||||
The two sources are independent (lmxopcua#847): a failed ConfigDb scan shows a note, and the running findings
|
||||
— read from the in-process health store — still render. See CredentialReferenceBannerModel. *@
|
||||
|
||||
@if (_findings.Count > 0 || _error is not null)
|
||||
@if (_model.Visible)
|
||||
{
|
||||
<div class="alert alert-warning small mb-3" role="alert">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
@@ -22,61 +24,93 @@
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@_busy" @onclick="RecheckAsync">Re-check</button>
|
||||
</div>
|
||||
@if (_error is not null)
|
||||
@if (_model.ScanError is not null)
|
||||
{
|
||||
<div class="text-danger mt-1">@_error</div>
|
||||
<div class="text-danger mt-1" data-testid="credential-scan-error">
|
||||
@_model.ScanError Stored and deployed configs were not checked
|
||||
@(_model.ScanFindings.Count > 0 ? "(the list below is from the last successful scan)" : "")
|
||||
— running-driver findings below are still live.
|
||||
</div>
|
||||
}
|
||||
<ul class="mb-0 mt-2">
|
||||
@foreach (var f in _findings.Take(MaxRows))
|
||||
{
|
||||
<li>
|
||||
<span class="badge bg-secondary me-1">@f.Source</span>
|
||||
@if (!string.IsNullOrEmpty(f.DriverType)) { <span>@f.DriverType </span> }
|
||||
driver <span class="mono">@f.DriverName</span>
|
||||
@if (f.DeviceName is not null) { <span> device <span class="mono">@f.DeviceName</span></span> }
|
||||
— @f.Problem
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (_findings.Count > MaxRows)
|
||||
@if (_model.ScanFindings.Count > 0)
|
||||
{
|
||||
<div class="mt-1 text-muted">…and @(_findings.Count - MaxRows) more (see the node log).</div>
|
||||
<ul class="mb-0 mt-2">
|
||||
@foreach (var f in _model.ScanFindings.Take(MaxRows))
|
||||
{
|
||||
<li>
|
||||
<span class="badge bg-secondary me-1">@f.Source</span>
|
||||
@if (!string.IsNullOrEmpty(f.DriverType)) { <span>@f.DriverType </span> }
|
||||
driver <span class="mono">@f.DriverName</span>
|
||||
@if (f.DeviceName is not null) { <span> device <span class="mono">@f.DeviceName</span></span> }
|
||||
— @f.Problem
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (_model.ScanFindings.Count > MaxRows)
|
||||
{
|
||||
<div class="mt-1 text-muted">…and @(_model.ScanFindings.Count - MaxRows) more (see the node log).</div>
|
||||
}
|
||||
}
|
||||
@if (_model.RunningFindings.Count > 0)
|
||||
{
|
||||
<ul class="mb-0 mt-2" data-testid="credential-running-findings">
|
||||
@foreach (var f in _model.RunningFindings.Take(MaxRows))
|
||||
{
|
||||
<li>
|
||||
<span class="badge bg-secondary me-1">@f.Source</span>
|
||||
driver <span class="mono">@f.DriverName</span>
|
||||
— @f.Problem
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (_model.RunningFindings.Count > MaxRows)
|
||||
{
|
||||
<div class="mt-1 text-muted">…and @(_model.RunningFindings.Count - MaxRows) more running.</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const int MaxRows = 25;
|
||||
private IReadOnlyList<CredentialReferenceFinding> _findings = [];
|
||||
private string? _error;
|
||||
private CredentialReferenceBannerModel _model = new([], [], null, null);
|
||||
private bool _busy;
|
||||
|
||||
protected override void OnInitialized() => Compose(State.Current);
|
||||
|
||||
private void Compose(CredentialReferencePreflightState.Snapshot snapshot)
|
||||
{
|
||||
_error = snapshot.Error;
|
||||
_findings = snapshot.Findings
|
||||
.Concat(CredentialReferencePreflight.RunningFindings(DriverStatus.GetAll()))
|
||||
.ToList();
|
||||
}
|
||||
=> _model = CredentialReferenceBannerModel.Compose(snapshot, DriverStatus.GetAll());
|
||||
|
||||
private async Task RecheckAsync()
|
||||
{
|
||||
var factory = Services.GetService(typeof(IDbContextFactory<OtOpcUaConfigDbContext>)) as IDbContextFactory<OtOpcUaConfigDbContext>;
|
||||
if (factory is null) return;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None);
|
||||
var snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null);
|
||||
State.Set(snapshot);
|
||||
var factory = Services.GetService(typeof(IDbContextFactory<OtOpcUaConfigDbContext>)) as IDbContextFactory<OtOpcUaConfigDbContext>;
|
||||
CredentialReferencePreflightState.Snapshot snapshot;
|
||||
if (factory is null)
|
||||
{
|
||||
snapshot = State.Current with { Error = "No config DB on this node." };
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None);
|
||||
snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null);
|
||||
State.Set(snapshot);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Keep the last successful scan's findings (flagged as such by the note) — and, above all,
|
||||
// recompose the RUNNING findings from the live health store rather than leaving whatever the
|
||||
// last render had (lmxopcua#847).
|
||||
snapshot = State.Current with { Error = "The credential-reference preflight could not read the config DB." };
|
||||
}
|
||||
}
|
||||
|
||||
Compose(snapshot);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_error = "The credential-reference preflight could not read the config DB.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
</button>
|
||||
@if (_openError is not null)
|
||||
{
|
||||
<span class="chip chip-bad" title="@_openError">@Truncate(_openError, 70)</span>
|
||||
<RefusalChip Message="@_openError" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -845,7 +845,6 @@
|
||||
private string TargetLabel()
|
||||
=> string.IsNullOrEmpty(_groupPrefix) ? "(device root)" : _groupPrefix!;
|
||||
|
||||
private static string Truncate(string s, int max) => s.Length > max ? s[..max] + "…" : s;
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<thead class="sticky-top bg-body">
|
||||
<tr>
|
||||
<th style="width:2.5rem"></th>
|
||||
<th>Row</th>
|
||||
<th title="Data-row number in the file (the header is row 0)">Row</th>
|
||||
<th>Name</th>
|
||||
<th>Group</th>
|
||||
<th>Verdict</th>
|
||||
@@ -89,7 +89,16 @@
|
||||
<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>
|
||||
<td class="mono">
|
||||
@if (row.Name.Length == 0)
|
||||
{
|
||||
<span class="text-muted fst-italic">(blank name)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@row.Name
|
||||
}
|
||||
</td>
|
||||
<td class="mono small">@(row.EffectiveGroupPath.Length == 0 ? "(device root)" : row.EffectiveGroupPath)</td>
|
||||
<td>
|
||||
@if (row.Ok)
|
||||
|
||||
@@ -113,9 +113,47 @@ public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfig
|
||||
.Select(kv => new CredentialReferenceFinding(
|
||||
CredentialFindingSource.Running, "", s.DriverInstanceId, s.DriverInstanceId, null,
|
||||
$"{kv.Key[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix.Length..]}: {kv.Value}")))
|
||||
// Both nodes of a redundant pair run the same driver and report the same finding; the health store
|
||||
// keeps one snapshot per (driver, node) since lmxopcua#847, so collapse the identical pair here.
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the <c>/raw</c> credential banner renders (lmxopcua#847), composed from two INDEPENDENT sources: the
|
||||
/// ConfigDb scan (draft + deployed rows) and the live driver-health feed (running drivers' warn-only
|
||||
/// <c>credential-compat:*</c> resolutions). A failed ConfigDb scan must never hide the running findings — they
|
||||
/// come from the in-process health store and are exactly what is still true while the database is unreachable.
|
||||
/// </summary>
|
||||
/// <param name="ScanFindings">Draft / Deployed findings from the last successful scan (empty when it failed).</param>
|
||||
/// <param name="RunningFindings">Running-driver findings from the live health feed.</param>
|
||||
/// <param name="ScanError">The scan failure note, or null when the scan succeeded (or has not run).</param>
|
||||
/// <param name="ScannedAtUtc">When the scan last ran.</param>
|
||||
public sealed record CredentialReferenceBannerModel(
|
||||
IReadOnlyList<CredentialReferenceFinding> ScanFindings,
|
||||
IReadOnlyList<CredentialReferenceFinding> RunningFindings,
|
||||
string? ScanError,
|
||||
DateTime? ScannedAtUtc)
|
||||
{
|
||||
/// <summary>Whether the banner renders at all.</summary>
|
||||
public bool Visible => ScanFindings.Count > 0 || RunningFindings.Count > 0 || ScanError is not null;
|
||||
|
||||
/// <summary>Composes the banner from the last scan result and the current driver-health snapshots.</summary>
|
||||
/// <param name="scan">The last preflight result.</param>
|
||||
/// <param name="healthSnapshots">The live driver-health snapshots.</param>
|
||||
/// <returns>The banner model.</returns>
|
||||
public static CredentialReferenceBannerModel Compose(
|
||||
CredentialReferencePreflightState.Snapshot scan,
|
||||
IEnumerable<Commons.Messages.Drivers.DriverHealthChanged> healthSnapshots)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scan);
|
||||
var running = CredentialReferencePreflight.RunningFindings(healthSnapshots ?? []);
|
||||
// Running-source rows only ever come from the health feed; a scan result never carries them.
|
||||
var scanned = scan.Findings.Where(f => f.Source != CredentialFindingSource.Running).ToList();
|
||||
return new(scanned, running, scan.Error, scan.ScannedAtUtc);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The last preflight result, shown by the AdminUI <c>/raw</c> credential banner.</summary>
|
||||
public sealed class CredentialReferencePreflightState
|
||||
{
|
||||
|
||||
@@ -58,6 +58,9 @@ public sealed record HostsDriverInstanceInfo(
|
||||
/// when no instance matched. Fed to the row's expandable <c>DriverStatusPanel</c>. An
|
||||
/// unmatched row defaults to enabled on purpose: the row only exists because a live snapshot arrived,
|
||||
/// so telling the operator it is "not deployed" would contradict the evidence in front of them.</param>
|
||||
/// <param name="NodeId">The cluster node that reported this snapshot (lmxopcua#847); null from a node that predates
|
||||
/// the field. Both nodes of a redundant pair run the same driver instance, so the page shows one row per
|
||||
/// (driver, node) — a driver Faulted on one node and Healthy on the other used to flip between the two.</param>
|
||||
public sealed record HostsDriverRow(
|
||||
string DriverInstanceId, string? Name, string? DriverType, string State,
|
||||
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc,
|
||||
@@ -65,8 +68,12 @@ public sealed record HostsDriverRow(
|
||||
IReadOnlyList<HostConnectivityStatus>? HostStatuses = null,
|
||||
SkippedTagReport? SkippedTags = null,
|
||||
bool Enabled = true,
|
||||
IReadOnlyDictionary<string, string>? Diagnostics = null)
|
||||
IReadOnlyDictionary<string, string>? Diagnostics = null,
|
||||
string? NodeId = null)
|
||||
{
|
||||
/// <summary>The row's render key: unique per (driver instance, reporting node).</summary>
|
||||
public string RowKey => $"{DriverInstanceId}@{NodeId}";
|
||||
|
||||
/// <summary>Diagnostics as ordered key/value pairs for rendering; empty when there are none, in which
|
||||
/// case the page renders nothing.</summary>
|
||||
public IReadOnlyList<KeyValuePair<string, string>> DiagnosticItems { get; } =
|
||||
@@ -117,7 +124,8 @@ public sealed record HostsDriverRow(
|
||||
/// </summary>
|
||||
/// <param name="ClusterId">Cluster identifier.</param>
|
||||
/// <param name="Nodes">Configured nodes in this cluster, ordered by node id.</param>
|
||||
/// <param name="Drivers">Driver rows in this cluster, ordered by name then instance id.</param>
|
||||
/// <param name="Drivers">Driver rows in this cluster — one per (driver instance, reporting node) — ordered by
|
||||
/// name, then instance id, then node id.</param>
|
||||
public sealed record HostsClusterGroup(string ClusterId, IReadOnlyList<HostsNodeInfo> Nodes, IReadOnlyList<HostsDriverRow> Drivers);
|
||||
|
||||
/// <summary>
|
||||
@@ -188,10 +196,12 @@ public static class HostsDriverView
|
||||
s.HostStatuses,
|
||||
s.SkippedTags,
|
||||
instance?.Enabled ?? true,
|
||||
s.Diagnostics);
|
||||
s.Diagnostics,
|
||||
s.ReportingNodeId);
|
||||
})
|
||||
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(d => d.NodeId ?? "", StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
groups.Add(new HostsClusterGroup(clusterId, clusterNodes, clusterDrivers));
|
||||
|
||||
@@ -15,14 +15,16 @@ public interface IDriverStatusSnapshotStore
|
||||
/// <param name="snapshot">The driver health snapshot to store.</param>
|
||||
void Upsert(DriverHealthChanged snapshot);
|
||||
|
||||
/// <summary>Attempts to retrieve the last-known health snapshot for a driver instance.</summary>
|
||||
/// <summary>Attempts to retrieve the most recently published health snapshot for a driver instance, across
|
||||
/// every node reporting it (both nodes of a redundant pair run the same instance — lmxopcua#847; use
|
||||
/// <see cref="GetAll"/> for the per-node view).</summary>
|
||||
/// <param name="driverInstanceId">The driver instance's stable logical ID.</param>
|
||||
/// <param name="snapshot">The stored snapshot, if found.</param>
|
||||
/// <returns><see langword="true"/> if a snapshot was found; otherwise <see langword="false"/>.</returns>
|
||||
bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot);
|
||||
|
||||
/// <summary>Returns a point-in-time snapshot of every driver instance's last-known health.</summary>
|
||||
/// <returns>A read-only collection of the last-known health snapshot for each driver instance.</returns>
|
||||
/// <summary>Returns a point-in-time snapshot of every (driver instance, reporting node)'s last-known health.</summary>
|
||||
/// <returns>One last-known snapshot per driver instance PER reporting node (lmxopcua#847).</returns>
|
||||
IReadOnlyCollection<DriverHealthChanged> GetAll();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,11 +6,17 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe in-memory implementation of <see cref="IDriverStatusSnapshotStore"/>.
|
||||
/// Keyed by <see cref="DriverHealthChanged.DriverInstanceId"/>; last write wins.
|
||||
/// Keyed by (<see cref="DriverHealthChanged.DriverInstanceId"/>, <see cref="DriverHealthChanged.ReportingNodeId"/>);
|
||||
/// last write wins per key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keyed per reporting node since lmxopcua#847: both nodes of a redundant pair run the same driver instance,
|
||||
/// so a driver-id-only key made a driver Faulted on central-1 and Healthy on central-2 flip between the two on
|
||||
/// every publish. A snapshot from a node that predates <c>ReportingNodeId</c> keys under the empty node.
|
||||
/// </remarks>
|
||||
public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DriverHealthChanged> _byInstance = new();
|
||||
private readonly ConcurrentDictionary<(string DriverInstanceId, string NodeId), DriverHealthChanged> _byInstance = new();
|
||||
private readonly ILogger<InMemoryDriverStatusSnapshotStore>? _logger;
|
||||
private readonly FailureLogThrottle _failureLog = new();
|
||||
|
||||
@@ -22,10 +28,12 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
|
||||
/// <summary>Raised whenever a driver status snapshot is stored or updated.</summary>
|
||||
public event Action<DriverHealthChanged>? SnapshotChanged;
|
||||
|
||||
private static (string, string) KeyOf(DriverHealthChanged s) => (s.DriverInstanceId, s.ReportingNodeId ?? "");
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Upsert(DriverHealthChanged snapshot)
|
||||
{
|
||||
_byInstance[snapshot.DriverInstanceId] = snapshot;
|
||||
_byInstance[KeyOf(snapshot)] = 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,
|
||||
@@ -36,11 +44,11 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
|
||||
public int EvictUnconfiguredStale(IReadOnlySet<string> configuredInstanceIds, DateTime olderThanUtc)
|
||||
{
|
||||
var removed = 0;
|
||||
foreach (var (id, snapshot) in _byInstance)
|
||||
foreach (var (key, snapshot) in _byInstance)
|
||||
{
|
||||
if (configuredInstanceIds.Contains(id) || snapshot.PublishedUtc >= olderThanUtc) continue;
|
||||
if (configuredInstanceIds.Contains(key.DriverInstanceId) || 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++;
|
||||
if (_byInstance.TryRemove(new KeyValuePair<(string, string), DriverHealthChanged>(key, snapshot))) removed++;
|
||||
}
|
||||
|
||||
return removed;
|
||||
@@ -48,7 +56,17 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot)
|
||||
=> _byInstance.TryGetValue(driverInstanceId, out snapshot!);
|
||||
{
|
||||
DriverHealthChanged? latest = null;
|
||||
foreach (var (key, s) in _byInstance)
|
||||
{
|
||||
if (!string.Equals(key.DriverInstanceId, driverInstanceId, StringComparison.Ordinal)) continue;
|
||||
if (latest is null || s.PublishedUtc > latest.PublishedUtc) latest = s;
|
||||
}
|
||||
|
||||
snapshot = latest!;
|
||||
return latest is not null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyCollection<DriverHealthChanged> GetAll() => _byInstance.Values.ToArray();
|
||||
|
||||
@@ -128,19 +128,24 @@ public static class RawTagCsvMapper
|
||||
var typedUsed = new List<string>();
|
||||
var overrodeFallback = new List<string>();
|
||||
|
||||
// Echoed onto an INVALID row too (lmxopcua#847): the grid's Name/Group columns used to render blank for
|
||||
// a rejected row, so the operator could not tell which tag failed. The raw cell is shown as typed.
|
||||
var rawName = Cell("Name");
|
||||
var rawGroup = CombineGroupPath(groupPathPrefix, Cell("TagGroupPath")) ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
// --- Common identity/access fields ---
|
||||
var name = Cell("Name");
|
||||
if (RawPaths.ValidateSegment(name) is { } nameErr)
|
||||
{
|
||||
return Invalid(rowNumber, cells, $"Name: {nameErr}");
|
||||
return Invalid(rowNumber, cells, rawName, rawGroup, $"Name: {nameErr}");
|
||||
}
|
||||
|
||||
var dataType = Cell("DataType");
|
||||
if (dataType.Length == 0)
|
||||
{
|
||||
return Invalid(rowNumber, cells, "DataType is required.");
|
||||
return Invalid(rowNumber, cells, rawName, rawGroup, "DataType is required.");
|
||||
}
|
||||
|
||||
var accessRaw = Cell("AccessLevel");
|
||||
@@ -161,7 +166,7 @@ public static class RawTagCsvMapper
|
||||
var baseKeys = ReadRootKeys(baseJson.Length == 0 ? "{}" : baseJson, out var baseValid);
|
||||
if (!baseValid)
|
||||
{
|
||||
return Invalid(rowNumber, cells, "TagConfigJson is not well-formed JSON.");
|
||||
return Invalid(rowNumber, cells, rawName, rawGroup, "TagConfigJson is not well-formed JSON.");
|
||||
}
|
||||
|
||||
if (map is not null)
|
||||
@@ -219,7 +224,7 @@ public static class RawTagCsvMapper
|
||||
var n = CsvValues.ParseInt("ArrayLength", arrayLenRaw);
|
||||
if (n < 0)
|
||||
{
|
||||
return Invalid(rowNumber, cells, "ArrayLength must not be negative.");
|
||||
return Invalid(rowNumber, cells, rawName, rawGroup, "ArrayLength must not be negative.");
|
||||
}
|
||||
|
||||
arrayLength = (uint)n;
|
||||
@@ -243,14 +248,14 @@ public static class RawTagCsvMapper
|
||||
// claim the tag is already present.
|
||||
if (TagConfigValidator.Validate(driverType, json) is { } configError)
|
||||
{
|
||||
return Invalid(rowNumber, cells, $"TagConfig: {configError}");
|
||||
return Invalid(rowNumber, cells, rawName, rawGroup, $"TagConfig: {configError}");
|
||||
}
|
||||
|
||||
// The driver's Tag-ROW rules (access level / data type — lmxopcua#830): the SAME authority the commit
|
||||
// (RawTreeService.ImportTagsAsync) applies, so a row the commit would refuse is never previewed as OK.
|
||||
if (RawTagRowRules.Problem(driverType, access, dataType, json) is { } rowError)
|
||||
{
|
||||
return Invalid(rowNumber, cells, rowError);
|
||||
return Invalid(rowNumber, cells, rawName, rawGroup, rowError);
|
||||
}
|
||||
|
||||
var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json));
|
||||
@@ -271,13 +276,15 @@ public static class RawTagCsvMapper
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return Invalid(rowNumber, cells, ex.Message);
|
||||
return Invalid(rowNumber, cells, rawName, rawGroup, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static RawCsvReviewRow Invalid(int rowNumber, string[] cells, string error) => new()
|
||||
private static RawCsvReviewRow Invalid(int rowNumber, string[] cells, string rawName, string rawGroup, string error) => new()
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Name = rawName,
|
||||
EffectiveGroupPath = rawGroup,
|
||||
Cells = cells,
|
||||
Headers = Array.Empty<string>(),
|
||||
Error = error,
|
||||
|
||||
@@ -131,7 +131,8 @@ public static class TelemetryProtoMapCentral
|
||||
RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null,
|
||||
HostStatuses: ToHostStatuses(msg),
|
||||
SkippedTags: ToSkippedTags(msg),
|
||||
Diagnostics: msg.HasDiagnostics ? new Dictionary<string, string>(msg.Diagnostics) : null);
|
||||
Diagnostics: msg.HasDiagnostics ? new Dictionary<string, string>(msg.Diagnostics) : null,
|
||||
ReportingNodeId: msg.HasReportingNodeId ? msg.ReportingNodeId : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -132,6 +132,8 @@ public static class TelemetryProtoMapNode
|
||||
msg.RediscoveryNeededUtc = ToUtcTimestamp(e.RediscoveryNeededUtc.Value);
|
||||
if (e.RediscoveryReason is not null)
|
||||
msg.RediscoveryReason = e.RediscoveryReason;
|
||||
if (e.ReportingNodeId is not null)
|
||||
msg.ReportingNodeId = e.ReportingNodeId;
|
||||
|
||||
// Presence flag first — an empty repeated field cannot say whether the driver has a probe at all.
|
||||
if (e.HostStatuses is not null)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
|
||||
@@ -19,14 +20,19 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
|
||||
|
||||
private readonly ActorSystem _system;
|
||||
private readonly ITelemetryLocalHub _hub;
|
||||
private readonly string? _localNodeId;
|
||||
|
||||
/// <summary>Initializes a new instance of <see cref="AkkaDriverHealthPublisher"/>.</summary>
|
||||
/// <param name="system">The Akka actor system used to resolve the DPS mediator.</param>
|
||||
/// <param name="hub">The node-local live-telemetry hub each snapshot is also emitted into (Phase 5).</param>
|
||||
public AkkaDriverHealthPublisher(ActorSystem system, ITelemetryLocalHub hub)
|
||||
/// <param name="roleInfo">This node's role info; its <c>LocalNode</c> stamps every snapshot's
|
||||
/// <see cref="DriverHealthChanged.ReportingNodeId"/> (lmxopcua#847). Optional for harnesses.</param>
|
||||
public AkkaDriverHealthPublisher(ActorSystem system, ITelemetryLocalHub hub, IClusterRoleInfo? roleInfo = null)
|
||||
{
|
||||
_system = system;
|
||||
_hub = hub;
|
||||
var local = roleInfo?.LocalNode.Value;
|
||||
_localNodeId = string.IsNullOrWhiteSpace(local) ? null : local;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -53,7 +59,8 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
|
||||
rediscoveryReason,
|
||||
hostStatuses,
|
||||
skippedTags,
|
||||
diagnostics);
|
||||
diagnostics,
|
||||
_localNodeId);
|
||||
DistributedPubSub.Get(_system).Mediator.Tell(new Publish(TopicName, msg));
|
||||
// Phase 5: fan the same snapshot into the node-local live-telemetry hub (no-op until a gRPC
|
||||
// client subscribes). The DPS publish above is unchanged — the hub is a strictly additive tap.
|
||||
|
||||
+44
@@ -184,4 +184,48 @@ public sealed class CredentialReferencePreflightTests
|
||||
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
|
||||
private static DriverHealthChanged LegacyRunning(string node) =>
|
||||
new("c", "gx-old", "Healthy", null, null, 0, DateTime.UtcNow,
|
||||
Diagnostics: new Dictionary<string, string>
|
||||
{
|
||||
[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef"] = "deprecated",
|
||||
},
|
||||
ReportingNodeId: node);
|
||||
|
||||
[Fact]
|
||||
public void Banner_keeps_running_findings_when_the_config_db_scan_failed_847()
|
||||
{
|
||||
// lmxopcua#847: a failed ConfigDb scan must not hide what the live health feed says is still running.
|
||||
var failed = new CredentialReferencePreflightState.Snapshot([], DateTime.UtcNow, "could not read the config DB.");
|
||||
|
||||
var model = CredentialReferenceBannerModel.Compose(failed, [LegacyRunning("central-1")]);
|
||||
|
||||
model.Visible.ShouldBeTrue();
|
||||
model.ScanError.ShouldNotBeNull();
|
||||
model.ScanFindings.ShouldBeEmpty();
|
||||
model.RunningFindings.ShouldHaveSingleItem().DriverInstanceId.ShouldBe("gx-old");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Banner_shows_a_scan_error_alone_and_hides_when_nothing_to_say_847()
|
||||
{
|
||||
CredentialReferenceBannerModel.Compose(new([], DateTime.UtcNow, "db down"), []).ShouldSatisfyAllConditions(
|
||||
m => m.Visible.ShouldBeTrue(),
|
||||
m => m.RunningFindings.ShouldBeEmpty());
|
||||
CredentialReferenceBannerModel.Compose(new([], DateTime.UtcNow, null), []).Visible.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Banner_separates_scan_from_running_findings_and_dedups_the_redundant_pair_847()
|
||||
{
|
||||
var draft = new CredentialReferenceFinding(CredentialFindingSource.Draft, "Galaxy", "gx", "gx", null, "p");
|
||||
var model = CredentialReferenceBannerModel.Compose(
|
||||
new([draft], DateTime.UtcNow, null), [LegacyRunning("central-1"), LegacyRunning("central-2")]);
|
||||
|
||||
model.ScanFindings.ShouldHaveSingleItem().ShouldBe(draft);
|
||||
// Both nodes run the driver and report the same thing — one line, not two.
|
||||
model.RunningFindings.ShouldHaveSingleItem();
|
||||
model.ScanError.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,4 +56,39 @@ public sealed class DriverStatusSnapshotStoreTests
|
||||
|
||||
count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshots_from_two_nodes_for_one_driver_are_kept_side_by_side_847()
|
||||
{
|
||||
var store = new InMemoryDriverStatusSnapshotStore();
|
||||
var t0 = new DateTime(2026, 11, 11, 0, 0, 0, DateTimeKind.Utc);
|
||||
store.Upsert(new DriverHealthChanged("MAIN", "drv", "Faulted", null, "x", 1, t0, ReportingNodeId: "central-1"));
|
||||
store.Upsert(new DriverHealthChanged("MAIN", "drv", "Healthy", null, null, 0, t0.AddSeconds(1), ReportingNodeId: "central-2"));
|
||||
// A re-publish from central-1 replaces only central-1's entry.
|
||||
store.Upsert(new DriverHealthChanged("MAIN", "drv", "Faulted", null, "y", 2, t0.AddSeconds(2), ReportingNodeId: "central-1"));
|
||||
|
||||
var all = store.GetAll();
|
||||
all.Count.ShouldBe(2);
|
||||
all.Single(s => s.ReportingNodeId == "central-1").LastError.ShouldBe("y");
|
||||
all.Single(s => s.ReportingNodeId == "central-2").State.ShouldBe("Healthy");
|
||||
|
||||
// TryGet (the per-driver panel / hub) answers the most recently published across nodes.
|
||||
store.TryGet("drv", out var latest).ShouldBeTrue();
|
||||
latest.ReportingNodeId.ShouldBe("central-1");
|
||||
latest.LastError.ShouldBe("y");
|
||||
store.TryGet("other", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eviction_removes_every_node_entry_of_an_unconfigured_stale_driver_847()
|
||||
{
|
||||
var store = new InMemoryDriverStatusSnapshotStore();
|
||||
var old = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
store.Upsert(new DriverHealthChanged("MAIN", "gone", "Healthy", null, null, 0, old, ReportingNodeId: "a"));
|
||||
store.Upsert(new DriverHealthChanged("MAIN", "gone", "Healthy", null, null, 0, old, ReportingNodeId: "b"));
|
||||
store.Upsert(new DriverHealthChanged("MAIN", "kept", "Healthy", null, null, 0, old, ReportingNodeId: "a"));
|
||||
|
||||
store.EvictUnconfiguredStale(new HashSet<string> { "kept" }, old.AddMinutes(1)).ShouldBe(2);
|
||||
store.GetAll().ShouldHaveSingleItem().DriverInstanceId.ShouldBe("kept");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#847: a refused driver/device save stacked two copies of the same error box (the live gate box plus
|
||||
/// the gate text copied into the save error), and a resubmit left the previous refusal standing.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SaveRefusalNoticesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Nothing_to_show_renders_nothing()
|
||||
=> SaveRefusalNotices.Messages(null, " ").ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void The_same_refusal_from_gate_and_save_renders_once()
|
||||
=> SaveRefusalNotices.Messages("Credential refused.", "Credential refused.").ShouldBe(["Credential refused."]);
|
||||
|
||||
[Fact]
|
||||
public void A_save_error_that_wraps_the_gate_text_renders_once()
|
||||
=> SaveRefusalNotices.Messages("Credential refused.", "Save failed: Credential refused.")
|
||||
.ShouldBe(["Credential refused."]);
|
||||
|
||||
[Fact]
|
||||
public void Distinct_gate_and_server_errors_both_render_gate_first()
|
||||
=> SaveRefusalNotices.Messages("Credential refused.", "Row was modified by someone else.")
|
||||
.ShouldBe(["Credential refused.", "Row was modified by someone else."]);
|
||||
|
||||
[Fact]
|
||||
public void A_server_error_alone_renders()
|
||||
=> SaveRefusalNotices.Messages(null, "Duplicate name.").ShouldBe(["Duplicate name."]);
|
||||
|
||||
[Theory]
|
||||
[InlineData("DriverConfigModal.razor", "_driverConfigJson")]
|
||||
[InlineData("DeviceModal.razor", "_deviceConfigJson")]
|
||||
public void Both_modals_render_one_shared_error_area_and_reset_it_on_resubmit_and_edit(string file, string jsonField)
|
||||
{
|
||||
var src = AdminUiSource.Read("Components", "Shared", "Drivers", file);
|
||||
|
||||
src.ShouldContain("<SaveRefusalNotice ");
|
||||
src.ShouldNotContain("<div class=\"panel notice notice-alert mt-3\">@_saveError</div>");
|
||||
// The gate refusal is no longer COPIED into _saveError (the source of the duplicate box).
|
||||
src.ShouldNotContain($"_saveError = DriverConfigSaveGate.Validate(_driverType, {jsonField});");
|
||||
src.ShouldContain("_saveError = null;\n if (DriverConfigSaveGate.Validate(");
|
||||
// An edit clears a stale server refusal.
|
||||
src.ShouldContain($"v => {{ {jsonField} = v; _saveError = null; }}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#847: Test Connect / browse-open refusals were cut down inside an uppercase chip ("SEC…") with the
|
||||
/// real reason only in a hover tooltip. Every site now renders the shared <c>RefusalChip</c>, which prints the
|
||||
/// full message under a short chip.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RefusalChipWiringTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Drivers/DriverTestConnectButton.razor")]
|
||||
[InlineData("Raw/RawBrowseModal.razor")]
|
||||
[InlineData("Drivers/Pickers/AbCipAddressPickerBody.razor")]
|
||||
[InlineData("Drivers/Pickers/FOCASAddressPickerBody.razor")]
|
||||
[InlineData("Drivers/Pickers/GalaxyAddressPickerBody.razor")]
|
||||
[InlineData("Drivers/Pickers/OpcUaClientAddressPickerBody.razor")]
|
||||
[InlineData("Drivers/Pickers/SqlAddressPickerBody.razor")]
|
||||
[InlineData("Drivers/Pickers/TwinCATAddressPickerBody.razor")]
|
||||
public void Refusal_sites_render_the_full_message_via_RefusalChip(string relative)
|
||||
{
|
||||
var src = AdminUiSource.Read(["Components", "Shared", .. relative.Split('/')]);
|
||||
src.ShouldContain("<RefusalChip Message=");
|
||||
src.ShouldNotContain("TruncatedError()");
|
||||
src.ShouldNotContain("<span class=\"chip chip-bad\" title=");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusalChip_prints_the_message_untruncated_and_wrapping()
|
||||
{
|
||||
var src = AdminUiSource.Read("Components", "Shared", "Drivers", "RefusalChip.razor");
|
||||
src.ShouldContain("text-break");
|
||||
src.ShouldContain(">@Message</span>");
|
||||
src.ShouldNotContain("Abbreviate");
|
||||
}
|
||||
}
|
||||
@@ -356,4 +356,35 @@ public sealed class HostsDriverViewTests
|
||||
row.HasSkippedTags.ShouldBeFalse();
|
||||
row.SkippedTagsTitle().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_driver_reported_by_two_nodes_yields_one_row_per_node_847()
|
||||
{
|
||||
// lmxopcua#847: both nodes of a redundant pair run the same driver; keyed by driver id alone the row
|
||||
// flipped between central-1's Faulted and central-2's Healthy on every publish.
|
||||
var snaps = new[]
|
||||
{
|
||||
new DriverHealthChanged("c", "drv", "Healthy", null, null, 0, When, ReportingNodeId: "central-2"),
|
||||
new DriverHealthChanged("c", "drv", "Faulted", null, "boom", 1, When, ReportingNodeId: "central-1"),
|
||||
};
|
||||
|
||||
var rows = HostsDriverView.Build(snaps, null, [Instance("drv", "c", "Line 1", "Modbus")])
|
||||
.ShouldHaveSingleItem().Drivers;
|
||||
|
||||
rows.Count.ShouldBe(2);
|
||||
rows.Select(r => r.NodeId).ShouldBe(["central-1", "central-2"]);
|
||||
rows.Select(r => r.State).ShouldBe(["Faulted", "Healthy"]);
|
||||
rows.Select(r => r.RowKey).Distinct().Count().ShouldBe(2);
|
||||
rows.ShouldAllBe(r => r.Name == "Line 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hosts_page_renders_a_node_column_and_keys_rows_by_driver_and_node_847()
|
||||
{
|
||||
var src = AdminUiSource.Read("Components", "Pages", "Hosts.razor");
|
||||
src.ShouldContain("@key=\"@($\"row-{d.RowKey}\")\"");
|
||||
src.ShouldNotContain("@key=\"@($\"row-{d.DriverInstanceId}\")\"");
|
||||
src.ShouldContain(">Node</th>");
|
||||
src.ShouldContain("@d.NodeId");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,30 @@ public sealed class HeidenhainAuthoringUxTests
|
||||
result.CanCommit.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Csv_preview_invalid_rows_echo_the_raw_name_and_group_847()
|
||||
{
|
||||
// lmxopcua#847: an invalid row rendered a BLANK Name in the review grid, so the operator could not tell
|
||||
// which tag failed. Every rejection path must carry the raw Name/group cell as typed.
|
||||
const string header = "Name,DataType,AccessLevel,TagGroupPath,TagConfigJson";
|
||||
var exec = Json("""{"address":"Status/ExecutionState"}""");
|
||||
var result = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain,
|
||||
header + "\n" + string.Join("\n",
|
||||
$"rw,Int32,ReadWrite,Spindle,{exec}", // row rule refusal
|
||||
$"nodt,,Read,,{exec}", // DataType required
|
||||
$"badjson,Int32,Read,,{Json("{not json")}",
|
||||
$"bad/name,Int32,Read,,{exec}", // name segment refusal
|
||||
$"badacc,Int32,Bogus,,{exec}"), // FormatException path
|
||||
groupPathPrefix: "Axes");
|
||||
|
||||
result.Rows.Count.ShouldBe(5);
|
||||
result.Rows.ShouldAllBe(r => !r.Ok);
|
||||
result.Rows.Select(r => r.Name).ShouldBe(new[] { "rw", "nodt", "badjson", "bad/name", "badacc" });
|
||||
result.Rows[0].EffectiveGroupPath.ShouldBe("Axes/Spindle");
|
||||
result.Rows[1].EffectiveGroupPath.ShouldBe("Axes");
|
||||
result.Rows.Select(r => r.RowNumber).ShouldBe(new[] { 1, 2, 3, 4, 5 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Csv_preview_and_ImportTags_agree_row_for_row()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user