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 d11cc27f..fdf1c147 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// Loads the structural portion of the unified-namespace (UNS) browse tree —
/// Enterprise → Cluster → Area → Line → Equipment — from the config database.
/// Equipment children (tags/virtual tags) are summarised by count only and loaded
-/// lazily by the renderer; this service never returns Tag/VirtualTag leaf nodes.
+/// lazily by the renderer via .
///
public interface IUnsTreeService
{
@@ -16,4 +16,14 @@ public interface IUnsTreeService
/// A token to cancel the load.
/// The enterprise root nodes, each populated down to equipment.
Task> LoadStructureAsync(CancellationToken ct = default);
+
+ ///
+ /// Lazily loads the Tag and VirtualTag leaf nodes for a single equipment node.
+ /// Tags are returned first (ordered by Name), followed by VirtualTags (ordered by Name).
+ /// Leaf nodes carry ChildCount = 0 and HasLazyChildren = false.
+ ///
+ /// The equipment whose children to load.
+ /// A token to cancel the load.
+ /// Tag nodes followed by VirtualTag nodes; empty if the equipment has none.
+ Task> LoadEquipmentChildrenAsync(string equipmentId, CancellationToken ct = default);
}
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 35913425..7096688b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
@@ -76,4 +76,49 @@ public sealed class UnsTreeService(IDbContextFactory dbF
return UnsTreeAssembly.Build(clusters, areas, lines, equipment);
}
+
+ ///
+ public async Task> LoadEquipmentChildrenAsync(
+ string equipmentId,
+ CancellationToken ct = default)
+ {
+ await using var db = await dbFactory.CreateDbContextAsync(ct);
+
+ var tagNodes = await db.Tags
+ .AsNoTracking()
+ .Where(t => t.EquipmentId == equipmentId)
+ .OrderBy(t => t.Name)
+ .Select(t => new UnsNode
+ {
+ Kind = UnsNodeKind.Tag,
+ Key = $"tag:{t.TagId}",
+ DisplayName = $"{t.Name} ({t.DataType})",
+ EntityId = t.TagId,
+ ClusterId = null,
+ ChildCount = 0,
+ HasLazyChildren = false,
+ })
+ .ToListAsync(ct);
+
+ var vtagNodes = await db.VirtualTags
+ .AsNoTracking()
+ .Where(v => v.EquipmentId == equipmentId)
+ .OrderBy(v => v.Name)
+ .Select(v => new UnsNode
+ {
+ Kind = UnsNodeKind.VirtualTag,
+ Key = $"vtag:{v.VirtualTagId}",
+ DisplayName = $"{v.Name} (VirtualTag)",
+ EntityId = v.VirtualTagId,
+ ClusterId = null,
+ ChildCount = 0,
+ HasLazyChildren = false,
+ })
+ .ToListAsync(ct);
+
+ var result = new List(tagNodes.Count + vtagNodes.Count);
+ result.AddRange(tagNodes);
+ result.AddRange(vtagNodes);
+ return result;
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs
new file mode 100644
index 00000000..f6403580
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLazyTests.cs
@@ -0,0 +1,82 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
+
+///
+/// Verifies returns Tag leaf nodes
+/// followed by VirtualTag leaf nodes, in Name order, with the correct keys and display names.
+///
+[Trait("Category", "Unit")]
+public sealed class UnsTreeServiceLazyTests
+{
+ private static UnsTreeService SeededService()
+ {
+ var dbName = $"uns-lazy-{Guid.NewGuid():N}";
+ UnsTreeTestDb.SeedNamed(dbName);
+ return new UnsTreeService(UnsTreeTestDb.Factory(dbName));
+ }
+
+ ///
+ /// Tags come first, ordered by Name, then VirtualTags; keys follow the tag:/vtag: scheme
+ /// and the Tag display name embeds the DataType.
+ ///
+ [Fact]
+ public async Task LoadEquipmentChildren_returns_tags_then_vtags()
+ {
+ var service = SeededService();
+
+ var children = await service.LoadEquipmentChildrenAsync(UnsTreeTestDb.SeededEquipmentId);
+
+ // Seed has 2 tags (speed=Float, running=Boolean) + 1 vtag (computed).
+ // Tags are ordered by Name: "running" < "speed".
+ children.Count.ShouldBe(3);
+
+ var running = children[0];
+ running.Kind.ShouldBe(UnsNodeKind.Tag);
+ running.Key.ShouldBe("tag:TAG-2");
+ running.EntityId.ShouldBe("TAG-2");
+ running.ClusterId.ShouldBeNull();
+ running.DisplayName.ShouldContain("running");
+ running.DisplayName.ShouldContain("Boolean");
+ running.ChildCount.ShouldBe(0);
+ running.HasLazyChildren.ShouldBeFalse();
+ running.Children.ShouldBeEmpty();
+
+ var speed = children[1];
+ speed.Kind.ShouldBe(UnsNodeKind.Tag);
+ speed.Key.ShouldBe("tag:TAG-1");
+ speed.EntityId.ShouldBe("TAG-1");
+ speed.DisplayName.ShouldContain("speed");
+ speed.DisplayName.ShouldContain("Float");
+
+ var vtag = children[2];
+ vtag.Kind.ShouldBe(UnsNodeKind.VirtualTag);
+ vtag.Key.ShouldBe("vtag:VTAG-1");
+ vtag.EntityId.ShouldBe("VTAG-1");
+ vtag.ClusterId.ShouldBeNull();
+ vtag.DisplayName.ShouldContain("computed");
+ vtag.DisplayName.ShouldContain("VirtualTag");
+ vtag.ChildCount.ShouldBe(0);
+ vtag.HasLazyChildren.ShouldBeFalse();
+ vtag.Children.ShouldBeEmpty();
+ }
+
+ /// An equipment with no tags or virtual tags returns an empty list.
+ [Fact]
+ public async Task LoadEquipmentChildren_empty_for_equipment_with_none()
+ {
+ // Use a fresh named store and add an equipment with no tags/vtags.
+ var dbName = $"uns-lazy-empty-{Guid.NewGuid():N}";
+ UnsTreeTestDb.SeedNamed(dbName);
+
+ // The orphan equipment id is not in the seeded fixture, so just use a novel id.
+ const string emptyEquipmentId = "EQ-NO-TAGS";
+ var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
+
+ var children = await service.LoadEquipmentChildrenAsync(emptyEquipmentId);
+
+ children.ShouldBeEmpty();
+ }
+}