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];
+ }
+}