diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/AliasTagInput.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/AliasTagInput.cs new file mode 100644 index 00000000..d7bca06d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/AliasTagInput.cs @@ -0,0 +1,18 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// +/// Operator-editable fields for a Galaxy alias tag (an equipment Tag bound to the Galaxy gateway). +/// is the picked Galaxy reference (tag_name.AttributeName); it is stored +/// as the tag's TagConfig {"FullName":…}. AccessLevel defaults to ReadOnly at the call site. +/// +/// Stable unique tag id; only honoured on create. +/// Tag display name; unique within the owning equipment. +/// The bound Galaxy gateway driver instance. +/// OPC UA built-in type name (Boolean / Int32 / Float / etc.). +/// Tag-level OPC UA access baseline (default ReadOnly). +/// The Galaxy reference (tag_name.AttributeName) this alias surfaces. +public sealed record AliasTagInput( + string TagId, string Name, string DriverInstanceId, string DataType, + TagAccessLevel AccessLevel, string FullName); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs index dc589278..7e4d6c3d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs @@ -3,8 +3,13 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// A tag row for the equipment page's Tags tab table — display columns plus the id used to -/// open the edit modal. RowVersion is re-read fresh on delete (matching the tree's delete path). -public sealed record EquipmentTagRow(string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel); +/// open the edit modal. RowVersion is re-read fresh on delete (matching the tree's delete path). +/// Galaxy alias rows (tags bound to a GalaxyMxGateway driver) carry IsAlias = true and a +/// Source of "galaxy:<FullName>" taken from the tag's TagConfig; ordinary +/// equipment tags carry IsAlias = false and a null Source. +public sealed record EquipmentTagRow( + string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel, + bool IsAlias = false, string? Source = null); /// A virtual-tag row for the equipment page's Virtual Tags tab table. public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index 4f12c413..2b4d5dfb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -344,6 +344,11 @@ public interface IUnsTreeService /// where DriverType lets the TagModal dispatch to a per-driver-type typed config editor. Task> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default); + /// Galaxy gateway driver instances (DriverType "GalaxyMxGateway") in the equipment's + /// cluster, for the alias address picker. Tuple = (DriverInstanceId, Display, DriverConfig). + Task> + LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default); + /// /// Creates a new equipment-bound tag. FolderPath is always null (decision #110 — /// the tree only edits equipment-bound tags). Fails on a duplicate TagId, invalid diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index e0c0e656..df0d76cd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -86,11 +86,34 @@ public sealed class UnsTreeService(IDbContextFactory dbF public async Task> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); - return await db.Tags.AsNoTracking() + + // Left-join each tag to its driver so we can tell Galaxy aliases apart while still surfacing a + // tag whose driver row is missing (it is simply treated as a non-alias). EF can't parse the + // TagConfig JSON in-query, so we materialise then map IsAlias/Source in memory. + var rows = await db.Tags.AsNoTracking() .Where(t => t.EquipmentId == equipmentId) .OrderBy(t => t.Name) - .Select(t => new EquipmentTagRow(t.TagId, t.Name, t.DriverInstanceId, t.DataType, t.AccessLevel)) + .GroupJoin(db.DriverInstances.AsNoTracking(), t => t.DriverInstanceId, d => d.DriverInstanceId, + (t, ds) => new { Tag = t, Drivers = ds }) + .SelectMany(x => x.Drivers.DefaultIfEmpty(), + (x, d) => new + { + x.Tag.TagId, + x.Tag.Name, + x.Tag.DriverInstanceId, + x.Tag.DataType, + x.Tag.AccessLevel, + DriverType = d != null ? d.DriverType : null, + x.Tag.TagConfig, + }) .ToListAsync(ct); + + return rows.Select(r => + { + var isAlias = r.DriverType == "GalaxyMxGateway"; + var source = isAlias ? $"galaxy:{ExtractTagConfigFullName(r.TagConfig)}" : null; + return new EquipmentTagRow(r.TagId, r.Name, r.DriverInstanceId, r.DataType, r.AccessLevel, isAlias, source); + }).ToList(); } /// @@ -740,6 +763,29 @@ public sealed class UnsTreeService(IDbContextFactory dbF .ToList(); } + /// + public async Task> + LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var cluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct); + if (cluster is null) + { + return Array.Empty<(string, string, string)>(); + } + + var gateways = await db.DriverInstances + .Where(d => d.ClusterId == cluster && d.DriverType == "GalaxyMxGateway") + .OrderBy(d => d.DriverInstanceId) + .Select(d => new { d.DriverInstanceId, d.Name, d.DriverConfig }) + .ToListAsync(ct); + + return gateways + .Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverConfig)) + .ToList(); + } + /// public async Task CreateTagAsync( string equipmentId, @@ -1153,6 +1199,32 @@ public sealed class UnsTreeService(IDbContextFactory dbF } } + /// + /// Extracts the FullName string from a tag's TagConfig JSON (the Galaxy reference an + /// alias surfaces), or null when the config is empty, not a JSON object, lacks a string + /// FullName, or is malformed. A small local copy mirrors the composer's own extraction — + /// consistent with this codebase, where the composer and validator each keep their own. + /// + private static string? ExtractTagConfigFullName(string? tagConfig) + { + if (string.IsNullOrWhiteSpace(tagConfig)) + { + return null; + } + + try + { + using var doc = System.Text.Json.JsonDocument.Parse(tagConfig); + return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object + && doc.RootElement.TryGetProperty("FullName", out var fn) + && fn.ValueKind == System.Text.Json.JsonValueKind.String ? fn.GetString() : null; + } + catch (System.Text.Json.JsonException) + { + return null; + } + } + /// /// Resolves an equipment to its cluster via Equipment.UnsLineId → UnsLine.UnsAreaId → /// UnsArea.ClusterId. Returns null when the equipment, its line, or its area cannot be diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs new file mode 100644 index 00000000..c18333b8 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs @@ -0,0 +1,187 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Verifies the read-side surface for Galaxy alias tags: that +/// finds the +/// GalaxyMxGateway drivers in the equipment's cluster (and only those), and that +/// flags alias tags (those bound to a Galaxy +/// gateway) with IsAlias = true and a Source derived from the tag's TagConfig. +/// +[Trait("Category", "Unit")] +public sealed class UnsTreeServiceAliasTagTests +{ + private const string ClusterId = "MAIN"; + private const string EquipmentId = "EQ-ALIAS-1"; + private const string GatewayDriverId = "DRV-GALAXY"; + private const string ModbusDriverId = "DRV-MODBUS"; + + /// + /// Seeds a cluster with: a SystemPlatform namespace + GalaxyMxGateway driver, an Equipment-kind + /// namespace + Modbus driver, and an area→line→equipment path. Returns the InMemory db name. + /// + private static string SeedCluster(bool withGalaxyGateway = true) + { + var dbName = $"uns-alias-{Guid.NewGuid():N}"; + + using var db = UnsTreeTestDb.CreateNamed(dbName); + + db.ServerClusters.Add(new ServerCluster + { + ClusterId = ClusterId, + Name = "Main", + Enterprise = "zb", + Site = "warsaw-west", + RedundancyMode = RedundancyMode.None, + CreatedBy = "test", + }); + db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = ClusterId, Name = "a" }); + db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" }); + db.Equipment.Add(new Equipment + { + EquipmentId = EquipmentId, + EquipmentUuid = Guid.NewGuid(), + UnsLineId = "LINE-1", + Name = "machine-1", + MachineCode = "machine_001", + }); + + // SystemPlatform namespace hosting the Galaxy gateway. + db.Namespaces.Add(new Namespace + { + NamespaceId = "NS-SP", + ClusterId = ClusterId, + Kind = NamespaceKind.SystemPlatform, + NamespaceUri = "urn:zb:sp", + }); + if (withGalaxyGateway) + { + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = GatewayDriverId, + ClusterId = ClusterId, + NamespaceId = "NS-SP", + Name = "galaxy gateway", + DriverType = "GalaxyMxGateway", + DriverConfig = "{\"Galaxy\":{}}", + }); + } + + // Equipment-kind namespace hosting an ordinary (non-Galaxy) driver. + db.Namespaces.Add(new Namespace + { + NamespaceId = "NS-EQ", + ClusterId = ClusterId, + Kind = NamespaceKind.Equipment, + NamespaceUri = "urn:zb:eq", + }); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = ModbusDriverId, + ClusterId = ClusterId, + NamespaceId = "NS-EQ", + Name = "modbus driver", + DriverType = "Modbus", + DriverConfig = "{}", + }); + + db.SaveChanges(); + return dbName; + } + + /// + /// The gateway lookup returns exactly the GalaxyMxGateway driver (with its id, a display string, + /// and its DriverConfig) and never the cluster's Modbus driver. + /// + [Fact] + public async Task LoadGalaxyGatewaysForEquipment_returns_only_galaxy_gateway() + { + var dbName = SeedCluster(); + var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); + + var gateways = await service.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId); + + gateways.Count.ShouldBe(1); + gateways[0].DriverInstanceId.ShouldBe(GatewayDriverId); + gateways[0].Display.ShouldContain(GatewayDriverId); + gateways[0].Display.ShouldContain("galaxy gateway"); + gateways[0].DriverConfig.ShouldBe("{\"Galaxy\":{}}"); + gateways.ShouldNotContain(g => g.DriverInstanceId == ModbusDriverId); + } + + /// An equipment whose cluster has no GalaxyMxGateway driver yields an empty list. + [Fact] + public async Task LoadGalaxyGatewaysForEquipment_returns_empty_when_no_gateway() + { + var dbName = SeedCluster(withGalaxyGateway: false); + var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); + + var gateways = await service.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId); + + gateways.ShouldBeEmpty(); + } + + /// + /// A tag bound to the Galaxy gateway is an alias (IsAlias = true, Source derived from + /// its TagConfig FullName); a tag bound to the Modbus driver is not (IsAlias = false, + /// Source = null). + /// + [Fact] + public async Task LoadTagsForEquipment_flags_galaxy_alias_rows() + { + var dbName = SeedCluster(); + + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + db.Tags.Add(new Tag + { + TagId = "TAG-ALIAS", + DriverInstanceId = GatewayDriverId, + EquipmentId = EquipmentId, + Name = "aliased-speed", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"TestMachine_020.Speed\"}", + }); + db.Tags.Add(new Tag + { + TagId = "TAG-NORMAL", + DriverInstanceId = ModbusDriverId, + EquipmentId = EquipmentId, + Name = "raw-speed", + DataType = "Int32", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{}", + }); + db.SaveChanges(); + } + + var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); + + var rows = await service.LoadTagsForEquipmentAsync(EquipmentId); + + var alias = rows.ShouldHaveSingleItem(r => r.TagId == "TAG-ALIAS"); + alias.IsAlias.ShouldBeTrue(); + alias.Source.ShouldBe("galaxy:TestMachine_020.Speed"); + + var normal = rows.ShouldHaveSingleItem(r => r.TagId == "TAG-NORMAL"); + normal.IsAlias.ShouldBeFalse(); + normal.Source.ShouldBeNull(); + } +} + +/// Small Shouldly-style helper for "exactly one match" assertions used by these tests. +internal static class SingleItemAssertions +{ + public static T ShouldHaveSingleItem(this IEnumerable source, Func predicate) + { + var matches = source.Where(predicate).ToList(); + matches.Count.ShouldBe(1); + return matches[0]; + } +}