diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/FleetDiagnosticsClient.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/FleetDiagnosticsClient.cs
index 81a0b6cc..f7b5ef56 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/FleetDiagnosticsClient.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/FleetDiagnosticsClient.cs
@@ -1,4 +1,6 @@
using Akka.Actor;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
@@ -13,7 +15,9 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
/// Sends ; expects a reply.
///
/// On timeout or any other failure (peer down, GetDiagnostics handler missing) returns an
-/// empty snapshot so the UI degrades to "no data" instead of throwing.
+/// empty snapshot so the UI degrades to "no data" instead of throwing. The failure is logged at
+/// Warning, since the snapshot shape cannot carry it: an empty snapshot reads the same as a node with
+/// no drivers. Consumed by the /fleet/diagnostics page.
///
public sealed class FleetDiagnosticsClient : IFleetDiagnosticsClient
{
@@ -21,14 +25,18 @@ public sealed class FleetDiagnosticsClient : IFleetDiagnosticsClient
private readonly ActorSystem _system;
private readonly string _systemName;
+ private readonly ILogger _log;
/// Initializes a new FleetDiagnosticsClient with the given actor system and cluster options.
/// The Akka actor system.
/// Cluster configuration options.
- public FleetDiagnosticsClient(ActorSystem system, IOptions options)
+ /// Logger for the degraded-to-empty path; defaults to a no-op logger.
+ public FleetDiagnosticsClient(
+ ActorSystem system, IOptions options, ILogger? log = null)
{
_system = system;
_systemName = options.Value.SystemName;
+ _log = log ?? NullLogger.Instance;
}
///
@@ -42,8 +50,11 @@ public sealed class FleetDiagnosticsClient : IFleetDiagnosticsClient
return await selection.Ask(
new GetDiagnostics(CorrelationId.NewId()), AskTimeout, linked.Token);
}
- catch (Exception)
+ catch (Exception ex) when (!ct.IsCancellationRequested)
{
+ _log.LogWarning(ex,
+ "Fleet diagnostics for node {NodeId} unavailable (no answer within {Timeout}s); returning an empty snapshot",
+ nodeId.Value, AskTimeout.TotalSeconds);
return new NodeDiagnosticsSnapshot(
nodeId,
CurrentRevision: null,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/FleetDiagnostics.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/FleetDiagnostics.razor
new file mode 100644
index 00000000..60d2c993
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/FleetDiagnostics.razor
@@ -0,0 +1,238 @@
+@page "/fleet/diagnostics"
+@* Per-node driver diagnostics, fetched on demand from the selected driver node's DriverHostActor over
+ Akka (IFleetDiagnosticsClient → GetDiagnostics → NodeDiagnosticsSnapshot). The node answers from
+ its live child set + each child's last-published health; the "running from cache" flag is the one
+ fact /hosts cannot show (a node booted from its artifact cache looks healthy but is frozen).
+ Reach it as /fleet/diagnostics?node= (the NodeId shown on /fleet and /hosts), or pick
+ a driver-role member from the selector. *@
+@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
+@rendermode RenderMode.InteractiveServer
+@using ZB.MOM.WW.OtOpcUa.Cluster
+@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
+@using ZB.MOM.WW.OtOpcUa.Commons.Types
+@inject IClusterRoleInfo Cluster
+@inject IFleetDiagnosticsClient Diagnostics
+@inject NavigationManager Nav
+@implements IDisposable
+
+
+
+
+@if (string.IsNullOrEmpty(_selectedNode))
+{
+
+ Pick a driver-role node to fetch its driver diagnostics. The node answers from its own
+ DriverHostActor, so this works for any node reachable over Akka remoting from this admin node.
+
+}
+else if (_snapshot is null)
+{
+
+
+
+ @if (_snapshot.RunningFromCache)
+ {
+
+ This node booted its configuration from its local artifact cache because central was unreachable.
+ It serves its last-known-good address space, but no deployment can reach it until central is back.
+
+ }
+
+
+
+ Drivers
+ as of @_snapshot.AsOfUtc.ToString("u")
+
+ @if (_snapshot.Drivers.Count == 0)
+ {
+
+ Empty snapshot: the node has no driver children — or it did not answer within the client's
+ 3 s budget (an unreachable node degrades to an empty snapshot; see the AdminUI log for the
+ FleetDiagnosticsClient warning).
+
+ }
+ else
+ {
+
+
+
+
+
Driver
+
Row id
+
State
+
Connected devices
+
Faulted devices
+
Last change
+
+
+
+ @foreach (var d in _snapshot.Drivers.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase))
+ {
+
+ }
+
+}
+
+
+@code {
+ [SupplyParameterFromQuery(Name = "node")]
+ public string? Node { get; set; }
+
+ private IReadOnlyList _members = Array.Empty();
+ private string _selectedNode = "";
+ private string _typedNode = "";
+ private NodeDiagnosticsSnapshot? _snapshot;
+ private bool _loading;
+ private DateTime? _fetchedUtc;
+
+ // Page-lifetime cancellation: a fetch in flight when the circuit goes away is abandoned.
+ private readonly CancellationTokenSource _cts = new();
+ private volatile bool _disposed;
+
+ protected override async Task OnParametersSetAsync()
+ {
+ _members = Cluster.MembersWithRole(RoleParser.Driver)
+ .Select(n => n.Value)
+ .OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var requested = Node?.Trim() ?? "";
+ if (!string.Equals(requested, _selectedNode, StringComparison.Ordinal))
+ {
+ _selectedNode = requested;
+ _snapshot = null;
+ if (!string.IsNullOrEmpty(_selectedNode)) await LoadAsync();
+ }
+ }
+
+ private Task OnNodeSelected(ChangeEventArgs e)
+ {
+ var value = e.Value?.ToString() ?? "";
+ Navigate(value);
+ return Task.CompletedTask;
+ }
+
+ private Task UseTypedNodeAsync()
+ {
+ Navigate(_typedNode.Trim());
+ return Task.CompletedTask;
+ }
+
+ private Task OnTypedKeyDown(KeyboardEventArgs e)
+ {
+ if (e.Key == "Enter") Navigate(_typedNode.Trim());
+ return Task.CompletedTask;
+ }
+
+ // The node id lives in the URL so a diagnostics view is linkable from /fleet and survives a reload.
+ private void Navigate(string node) =>
+ Nav.NavigateTo(string.IsNullOrEmpty(node)
+ ? "/fleet/diagnostics"
+ : "/fleet/diagnostics?node=" + Uri.EscapeDataString(node));
+
+ private async Task RefreshAsync() => await LoadAsync();
+
+ private async Task LoadAsync()
+ {
+ if (_loading || string.IsNullOrEmpty(_selectedNode)) return;
+ _loading = true;
+ try
+ {
+ // 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;
+ }
+ catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
+ {
+ // Circuit gone mid-fetch — a clean exit, not a fault.
+ }
+ finally
+ {
+ _loading = false;
+ if (!_disposed) StateHasChanged();
+ }
+ }
+
+ private static string StateChipClass(string state) => state switch
+ {
+ "Healthy" => "chip-ok",
+ "Degraded" or "Connecting" or "Reconnecting" => "chip-caution",
+ "Faulted" => "chip-alert",
+ _ => "chip-idle",
+ };
+
+ public void Dispose()
+ {
+ _disposed = true;
+ _cts.Cancel();
+ _cts.Dispose();
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ChildHealthLedger.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ChildHealthLedger.cs
new file mode 100644
index 00000000..ec722a2c
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ChildHealthLedger.cs
@@ -0,0 +1,73 @@
+using System.Collections.Concurrent;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
+
+/// The last health snapshot a driver child published, as kept by .
+/// The driver's reported state.
+/// When the driver last read successfully, if ever.
+/// The driver's last error, if any.
+/// Per-host connectivity for a multi-host driver, or null when the driver has none.
+/// When the snapshot was published — the child's dedup means this is the last CHANGE.
+public sealed record ChildHealthRecord(
+ DriverState State,
+ DateTime? LastSuccessfulReadUtc,
+ string? LastError,
+ IReadOnlyList? HostStatuses,
+ DateTime PublishedUtc);
+
+///
+/// An decorator that remembers the last snapshot per driver instance
+/// and forwards every publish to the real publisher. wraps the publisher it
+/// hands its children in one of these so GetDiagnostics can answer from the children's own health
+/// (state, host counts, last change) instead of a placeholder — the children publish on their own actor
+/// threads, hence the concurrent map.
+///
+///
+/// Deliberately NOT a message to the host: the children already publish through this seam on every
+/// change, so a parallel Tell would double every health transition's traffic for one consumer.
+/// Entries are removed by the host when it stops a child; a late publish from a stopping child may re-add
+/// one, which the host filters against its live child set when it answers.
+///
+public sealed class ChildHealthLedger : IDriverHealthPublisher
+{
+ private readonly IDriverHealthPublisher _inner;
+ private readonly ConcurrentDictionary _last = new(StringComparer.Ordinal);
+
+ /// Wraps .
+ /// The publisher every snapshot is forwarded to.
+ public ChildHealthLedger(IDriverHealthPublisher inner)
+ {
+ ArgumentNullException.ThrowIfNull(inner);
+ _inner = inner;
+ }
+
+ ///
+ public void Publish(
+ string clusterId,
+ string driverInstanceId,
+ DriverHealth health,
+ int errorCount5Min,
+ DateTime? rediscoveryNeededUtc = null,
+ string? rediscoveryReason = null,
+ IReadOnlyList? hostStatuses = null,
+ SkippedTagReport? skippedTags = null,
+ IReadOnlyDictionary? diagnostics = null)
+ {
+ _last[driverInstanceId] = new ChildHealthRecord(
+ health.State, health.LastSuccessfulRead, health.LastError, hostStatuses, DateTime.UtcNow);
+ _inner.Publish(
+ clusterId, driverInstanceId, health, errorCount5Min, rediscoveryNeededUtc, rediscoveryReason,
+ hostStatuses, skippedTags, diagnostics);
+ }
+
+ /// The last snapshot published for , or null if none yet.
+ /// The driver instance.
+ /// The record, or null.
+ public ChildHealthRecord? TryGet(string driverInstanceId) =>
+ _last.TryGetValue(driverInstanceId, out var record) ? record : null;
+
+ /// Forgets 's snapshot (the child was stopped).
+ /// The driver instance.
+ public void Forget(string driverInstanceId) => _last.TryRemove(driverInstanceId, out _);
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
index 0405be1a..1e5be182 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -195,7 +195,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private readonly IReadOnlySet _localRoles;
private readonly IActorRef? _dependencyMux;
private readonly IActorRef? _opcUaPublishActor;
- private readonly IDriverHealthPublisher _healthPublisher;
+ /// The publisher handed to every child — a over the configured one, so
+ /// answers from the children's own last-published health.
+ private readonly ChildHealthLedger _healthPublisher;
private readonly IVirtualTagEvaluator _virtualTagEvaluator;
private readonly IHistoryWriter _historyWriter;
private readonly IActorRef? _virtualTagHostOverride;
@@ -594,6 +596,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private sealed record ChildEntry(IActorRef Actor, DriverInstanceSpec Spec, bool Stubbed)
{
+ /// When this child was spawned — the diagnostics' last-change time until it publishes health.
+ public DateTime SpawnedUtc { get; } = DateTime.UtcNow;
+
// Convenience accessors for sites that don't need the full spec.
/// Gets the driver type name from the child's spec.
public string DriverType => Spec.DriverType;
@@ -886,7 +891,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
_localRoles = localRoles ?? new HashSet(StringComparer.Ordinal);
_dependencyMux = dependencyMux;
_opcUaPublishActor = opcUaPublishActor;
- _healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
+ _healthPublisher = new ChildHealthLedger(healthPublisher ?? NullDriverHealthPublisher.Instance);
_virtualTagEvaluator = virtualTagEvaluator ?? NullVirtualTagEvaluator.Instance;
_historyWriter = historyWriter ?? NullHistoryWriter.Instance;
_virtualTagHostOverride = virtualTagHostOverride;
@@ -2514,16 +2519,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
}
+ ///
+ /// Answers from the live child set and each child's last-published health
+ /// (). Used to be a placeholder — Guid.Empty, "Spawned", zero
+ /// devices, "now" — that looked live in every behaviour and was read by nobody (02/U1); the AdminUI
+ /// fleet-diagnostics page is its consumer.
+ ///
private void HandleGetDiagnostics(GetDiagnostics msg)
{
var drivers = _children
- .Select(kv => new DriverInstanceDiagnostics(
- DriverInstanceId: Guid.Empty,
- Name: kv.Key,
- State: kv.Value.Stubbed ? "Stubbed" : "Spawned",
- ConnectedDevices: 0,
- FaultedDevices: 0,
- LastChangeUtc: DateTime.UtcNow))
+ .Select(kv => ToDiagnostics(kv.Key, kv.Value, _healthPublisher.TryGet(kv.Key)))
.ToArray();
var snapshot = new NodeDiagnosticsSnapshot(
NodeId: _localNode,
@@ -2534,6 +2539,37 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Sender.Tell(snapshot);
}
+ ///
+ /// One child's diagnostics row. Device counts come from the driver's host statuses when it is an
+ /// IHostConnectivityProbe (Running = connected; Stopped/Faulted = faulted; Unknown = neither);
+ /// a single-host driver counts as one device, connected while it reports Healthy or Degraded and
+ /// faulted while it reports Faulted. A child that has not published yet reports "Connecting" (or
+ /// "Stubbed") with its spawn time as the last change.
+ ///
+ private static DriverInstanceDiagnostics ToDiagnostics(string driverInstanceId, ChildEntry entry, ChildHealthRecord? health)
+ {
+ var name = string.IsNullOrEmpty(entry.Spec.Name) ? driverInstanceId : entry.Spec.Name;
+ if (entry.Stubbed)
+ return new DriverInstanceDiagnostics(entry.Spec.DriverInstanceRowId, name, "Stubbed", 0, 0, entry.SpawnedUtc);
+ if (health is null)
+ return new DriverInstanceDiagnostics(entry.Spec.DriverInstanceRowId, name, "Connecting", 0, 0, entry.SpawnedUtc);
+
+ int connected, faulted;
+ if (health.HostStatuses is { Count: > 0 } hosts)
+ {
+ connected = hosts.Count(h => h.State == HostState.Running);
+ faulted = hosts.Count(h => h.State is HostState.Stopped or HostState.Faulted);
+ }
+ else
+ {
+ connected = health.State is DriverState.Healthy or DriverState.Degraded ? 1 : 0;
+ faulted = health.State == DriverState.Faulted ? 1 : 0;
+ }
+
+ return new DriverInstanceDiagnostics(
+ entry.Spec.DriverInstanceRowId, name, health.State.ToString(), connected, faulted, health.PublishedUtc);
+ }
+
private void HandleDispatchFromSteady(DispatchDeployment msg)
{
// The short-circuit claims a CLEAN Applied, so it is only honest while the current revision's
@@ -3794,6 +3830,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// backoff it never earned (its first crash was deferred as a "crash loop").
_lastReprovisionUtc.Remove(driverInstanceId);
Timers.Cancel(ReprovisionTimerKey(driverInstanceId));
+ _healthPublisher.Forget(driverInstanceId);
_log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId);
}
@@ -3844,6 +3881,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
StopDependencyAdapter(msg.DriverInstanceId);
Context.Stop(entry.Actor);
_children.Remove(msg.DriverInstanceId);
+ // The old incarnation's health must not read as the new one's until it publishes its own.
+ _healthPublisher.Forget(msg.DriverInstanceId);
// Respawn from the same spec the last reconcile used — preserves RowId, Name, ClusterId.
SpawnChild(entry.Spec);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiagnosticsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiagnosticsTests.cs
new file mode 100644
index 00000000..4bd833cb
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiagnosticsTests.cs
@@ -0,0 +1,79 @@
+using Akka.Actor;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
+using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
+
+///
+/// Review 02 U1: GetDiagnostics answered a placeholder — Guid.Empty, "Spawned", zero devices and
+/// "now" for every child, in every behaviour — so the snapshot looked live and said nothing. It now carries
+/// each child's row id, authored name, last-published health state, device counts and last change time.
+///
+public sealed class DriverHostActorDiagnosticsTests : RuntimeActorTestBase
+{
+ private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
+ private static readonly NodeId TestNode = NodeId.Parse("node-1:4053");
+ private const string DriverId = "DI-1";
+
+ [Fact]
+ public async Task A_connected_child_reports_its_row_id_name_health_state_and_device_count()
+ {
+ var db = NewInMemoryDbFactory();
+ var rowId = Guid.NewGuid();
+ var before = DateTime.UtcNow.AddSeconds(-1);
+ var factory = new RecordingSubscribableDriverFactory("Modbus");
+ var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
+ db, RevA, new[] { (DriverId, "Line1-Modbus", "dev1", "speed", "Float", false) },
+ Array.Empty<(string, string, string, string, string?)>(),
+ enabled: true, driverRowIds: new Dictionary { [DriverId] = rowId });
+ var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory);
+
+ var snapshot = await AwaitDiagnosticsAsync(host, s => s.Drivers.Any(d => d.State == "Healthy"));
+
+ snapshot.NodeId.ShouldBe(TestNode);
+ snapshot.CurrentRevision.ShouldBe(RevA);
+ snapshot.RunningFromCache.ShouldBeFalse();
+ var driver = snapshot.Drivers.ShouldHaveSingleItem();
+ driver.DriverInstanceId.ShouldBe(rowId, "the real row id, not Guid.Empty");
+ driver.Name.ShouldBe("Line1-Modbus", "the authored name, not the instance id");
+ driver.State.ShouldBe("Healthy");
+ driver.ConnectedDevices.ShouldBe(1);
+ driver.FaultedDevices.ShouldBe(0);
+ driver.LastChangeUtc.ShouldBeGreaterThan(before);
+ driver.LastChangeUtc.ShouldBeLessThanOrEqualTo(DateTime.UtcNow);
+ }
+
+ [Fact]
+ public async Task A_stubbed_child_reports_Stubbed_with_no_devices()
+ {
+ var db = NewInMemoryDbFactory();
+ // The factory answers for a type the artifact does not use, so the host stubs the child.
+ var factory = new RecordingSubscribableDriverFactory("NotModbus");
+ var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
+ db, RevA, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) },
+ Array.Empty<(string, string, string, string, string?)>(), enabled: true);
+ var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory);
+
+ var snapshot = await AwaitDiagnosticsAsync(host, s => s.Drivers.Count == 1);
+
+ var driver = snapshot.Drivers.Single();
+ driver.State.ShouldBe("Stubbed");
+ driver.ConnectedDevices.ShouldBe(0);
+ driver.FaultedDevices.ShouldBe(0);
+ }
+
+ private async Task AwaitDiagnosticsAsync(IActorRef host, Func ready)
+ {
+ NodeDiagnosticsSnapshot? snapshot = null;
+ await AwaitAssertAsync(async () =>
+ {
+ snapshot = await host.Ask(new GetDiagnostics(CorrelationId.NewId()), PresenceBudget);
+ ready(snapshot).ShouldBeTrue();
+ }, PresenceBudget);
+ return snapshot!;
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs
index 20c2a62f..d5edc255 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/V3ArtifactSeeder.cs
@@ -47,6 +47,8 @@ public static class V3ArtifactSeeder
/// is parsed out of TagConfig.alarm by TagConfigIntent), which the alarm-ref half of the
/// restart suite needs — an alarm-bearing raw tag is excluded from the VALUE subscription set and carried
/// in the alarm one instead, so the two travel through different child code paths.
+ /// Optional DriverInstanceRowId (the DB row Guid) per driver id; absent
+ /// ids get , which is what an artifact without the property parses to.
/// Extra DriverInstance ids carrying NO devices and NO tags. The
/// only way to express a driver that is a running child yet absent from both the value- and alarm-ref
/// maps — the _children.Keys arm of ApplyDesiredSubscriptions's union, which must still
@@ -58,14 +60,16 @@ public static class V3ArtifactSeeder
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs,
bool enabled = false,
IReadOnlyDictionary? tagConfigByTag = null,
- string[]? taglessDrivers = null)
+ string[]? taglessDrivers = null,
+ IReadOnlyDictionary? driverRowIds = null)
{
+ Guid RowId(string id) => driverRowIds is not null && driverRowIds.TryGetValue(id, out var g) ? g : Guid.Empty;
var drivers = rawTags
.Select(t => (t.Driver, t.DriverName))
.Distinct()
- .Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled })
+ .Select(d => new { DriverInstanceRowId = RowId(d.Driver), DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled })
.Concat((taglessDrivers ?? Array.Empty())
- .Select(id => new { DriverInstanceId = id, RawFolderId = "rf-plant", Name = id, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled }))
+ .Select(id => new { DriverInstanceRowId = RowId(id), DriverInstanceId = id, RawFolderId = "rf-plant", Name = id, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled }))
.ToArray();
var devices = rawTags
.Select(t => (t.Driver, t.Device))