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 + + +
+

Node diagnostics

+ Back to fleet status +
+ +
+ + + + + + + Last fetched: @(_fetchedUtc?.ToString("HH:mm:ss 'UTC'") ?? "—") + +
+ +@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) +{ +

@(_loading ? "Fetching…" : "No snapshot yet.")

+} +else +{ +
+
+
Node
+
@_snapshot.NodeId.Value
+
+
+
Revision
+
+ @(_snapshot.CurrentRevision is { } rev ? DisplayText.Abbreviate(rev.Value, 12) : "—") +
+
+
+
Config source
+
@(_snapshot.RunningFromCache ? "cache (frozen)" : "live")
+
+
+
Drivers
+
@_snapshot.Drivers.Count
+
+
+
Faulted devices
+
@_snapshot.Drivers.Sum(d => d.FaultedDevices)
+
+
+ + @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 + { +
+ + + + + + + + + + + + + @foreach (var d in _snapshot.Drivers.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)) + { + + + + + + + + + } + +
DriverRow idStateConnected devicesFaulted devicesLast change
@d.Name@(d.DriverInstanceId == Guid.Empty ? "—" : DisplayText.Abbreviate(d.DriverInstanceId.ToString(), 13))@d.State@d.ConnectedDevices@d.FaultedDevices@d.LastChangeUtc.ToString("u")
+
+ } +
+} +
+ +@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))