diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor
index 90a3fedf1..b3ff3b55d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor
@@ -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)
{
@@ -22,61 +24,93 @@
- @if (_error is not null)
+ @if (_model.ScanError is not null)
{
-
@_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.
+
}
-
- @foreach (var f in _findings.Take(MaxRows))
- {
-
…and @(_model.RunningFindings.Count - MaxRows) more running.
+ }
}
}
@code {
private const int MaxRows = 25;
- private IReadOnlyList _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)) as IDbContextFactory;
- 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)) as IDbContextFactory;
+ 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;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs
index b553bce25..169377ad5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs
@@ -113,9 +113,47 @@ public sealed class CredentialReferencePreflight(IDbContextFactory 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();
}
+///
+/// What the /raw 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
+/// credential-compat:* 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.
+///
+/// Draft / Deployed findings from the last successful scan (empty when it failed).
+/// Running-driver findings from the live health feed.
+/// The scan failure note, or null when the scan succeeded (or has not run).
+/// When the scan last ran.
+public sealed record CredentialReferenceBannerModel(
+ IReadOnlyList ScanFindings,
+ IReadOnlyList RunningFindings,
+ string? ScanError,
+ DateTime? ScannedAtUtc)
+{
+ /// Whether the banner renders at all.
+ public bool Visible => ScanFindings.Count > 0 || RunningFindings.Count > 0 || ScanError is not null;
+
+ /// Composes the banner from the last scan result and the current driver-health snapshots.
+ /// The last preflight result.
+ /// The live driver-health snapshots.
+ /// The banner model.
+ public static CredentialReferenceBannerModel Compose(
+ CredentialReferencePreflightState.Snapshot scan,
+ IEnumerable 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);
+ }
+}
+
/// The last preflight result, shown by the AdminUI /raw credential banner.
public sealed class CredentialReferencePreflightState
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs
index 553f9b17f..8eeb5e81e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs
@@ -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
+ {
+ [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();
+ }
}