diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs
new file mode 100644
index 00000000..fcb16e5d
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsNode.cs
@@ -0,0 +1,242 @@
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+
+///
+/// The kind of node within the unified-namespace (UNS) browse tree. Determines
+/// how the renderer styles the row and which entity (if any) it links to.
+///
+public enum UnsNodeKind
+{
+ Enterprise,
+ Cluster,
+ Area,
+ Line,
+ Equipment,
+ Tag,
+ VirtualTag,
+}
+
+///
+/// View-model for a single node in the UNS browse tree. Carries the stable
+/// identity, display text and lazy-load metadata the renderer needs, plus
+/// transient UI state (expansion/loading) that is never persisted.
+///
+public sealed class UnsNode
+{
+ /// The kind of node — drives styling and entity linking.
+ public required UnsNodeKind Kind { get; init; }
+
+ /// Stable per-node identifier, unique within the tree (e.g. eq:{equipmentId}).
+ public required string Key { get; init; }
+
+ /// Human-readable label shown in the tree.
+ public required string DisplayName { get; init; }
+
+ /// Owning cluster id for Area/Line/Equipment (and the cluster itself); null for Enterprise.
+ public string? ClusterId { get; init; }
+
+ /// This node's own logical entity id (UnsAreaId/UnsLineId/EquipmentId/TagId/VirtualTagId); null for Enterprise.
+ public string? EntityId { get; init; }
+
+ /// Badge count. For equipment this is tag + virtual-tag count; for container nodes it is the direct child count.
+ public int ChildCount { get; set; }
+
+ /// True when this node has children that are loaded lazily (equipment with > 0).
+ public bool HasLazyChildren { get; init; }
+
+ /// Eagerly-materialised children. Empty for lazy-loaded equipment until expanded.
+ public List Children { get; } = new();
+
+ // --- Runtime UI state (not persisted) ---
+
+ /// Whether the node is currently expanded in the UI.
+ public bool Expanded { get; set; }
+
+ /// Whether the node's lazy children have been loaded.
+ public bool Loaded { get; set; }
+
+ /// Whether a lazy-load is currently in flight for this node.
+ public bool Loading { get; set; }
+
+ /// Last load error message, if any.
+ public string? Error { get; set; }
+}
+
+/// Flat structural row describing a cluster and its enterprise/site placement.
+public readonly record struct ClusterRow(string ClusterId, string Enterprise, string Site, string Name);
+
+/// Flat structural row describing a UNS area and its owning cluster.
+public readonly record struct AreaRow(string UnsAreaId, string ClusterId, string Name);
+
+/// Flat structural row describing a UNS line and its owning area.
+public readonly record struct LineRow(string UnsLineId, string UnsAreaId, string Name);
+
+/// Flat structural row describing an equipment node, its owning line and its tag/virtual-tag counts.
+public readonly record struct EquipmentRow(
+ string EquipmentId,
+ string UnsLineId,
+ string MachineCode,
+ string Name,
+ int TagCount,
+ int VirtualTagCount);
+
+///
+/// Pure, EF-free assembly of the UNS browse tree from flat structural rows.
+/// Builds Enterprise → Cluster → Area → Line → Equipment with deterministic
+/// ordinal ordering and equipment lazy-load metadata.
+///
+public static class UnsTreeAssembly
+{
+ ///
+ /// Builds the Enterprise→Cluster→Area→Line→Equipment tree. Empty clusters
+ /// (and enterprises whose clusters have no areas) are retained. Ordering is
+ /// deterministic and ordinal at every level.
+ ///
+ public static IReadOnlyList Build(
+ IReadOnlyList clusters,
+ IReadOnlyList areas,
+ IReadOnlyList lines,
+ IReadOnlyList equipment)
+ {
+ // Index children by their parent key for O(1) lookup during nesting.
+ var areasByCluster = areas
+ .GroupBy(a => a.ClusterId, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
+
+ var linesByArea = lines
+ .GroupBy(l => l.UnsAreaId, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
+
+ var equipmentByLine = equipment
+ .GroupBy(e => e.UnsLineId, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
+
+ // Top level: distinct enterprises, ordered ordinally.
+ var enterprises = clusters
+ .GroupBy(c => c.Enterprise, StringComparer.Ordinal)
+ .OrderBy(g => g.Key, StringComparer.Ordinal)
+ .Select(entGroup =>
+ {
+ var clusterNodes = entGroup
+ .OrderBy(c => c.Name, StringComparer.Ordinal)
+ .ThenBy(c => c.ClusterId, StringComparer.Ordinal)
+ .Select(c => BuildCluster(c, areasByCluster, linesByArea, equipmentByLine))
+ .ToList();
+
+ var ent = new UnsNode
+ {
+ Kind = UnsNodeKind.Enterprise,
+ Key = $"ent:{entGroup.Key}",
+ DisplayName = entGroup.Key,
+ ClusterId = null,
+ EntityId = null,
+ HasLazyChildren = false,
+ };
+ ent.Children.AddRange(clusterNodes);
+ ent.ChildCount = ent.Children.Count;
+ return ent;
+ })
+ .ToList();
+
+ return enterprises;
+ }
+
+ private static UnsNode BuildCluster(
+ ClusterRow cluster,
+ IReadOnlyDictionary> areasByCluster,
+ IReadOnlyDictionary> linesByArea,
+ IReadOnlyDictionary> equipmentByLine)
+ {
+ var areaNodes = (areasByCluster.TryGetValue(cluster.ClusterId, out var clusterAreas)
+ ? clusterAreas
+ : Enumerable.Empty())
+ .OrderBy(a => a.Name, StringComparer.Ordinal)
+ .ThenBy(a => a.UnsAreaId, StringComparer.Ordinal)
+ .Select(a => BuildArea(a, cluster.ClusterId, linesByArea, equipmentByLine))
+ .ToList();
+
+ var node = new UnsNode
+ {
+ Kind = UnsNodeKind.Cluster,
+ Key = $"clu:{cluster.ClusterId}",
+ DisplayName = string.IsNullOrEmpty(cluster.Site)
+ ? cluster.Name
+ : $"{cluster.Site} ({cluster.Name})",
+ ClusterId = cluster.ClusterId,
+ EntityId = cluster.ClusterId,
+ HasLazyChildren = false,
+ };
+ node.Children.AddRange(areaNodes);
+ node.ChildCount = node.Children.Count;
+ return node;
+ }
+
+ private static UnsNode BuildArea(
+ AreaRow area,
+ string clusterId,
+ IReadOnlyDictionary> linesByArea,
+ IReadOnlyDictionary> equipmentByLine)
+ {
+ var lineNodes = (linesByArea.TryGetValue(area.UnsAreaId, out var areaLines)
+ ? areaLines
+ : Enumerable.Empty())
+ .OrderBy(l => l.Name, StringComparer.Ordinal)
+ .ThenBy(l => l.UnsLineId, StringComparer.Ordinal)
+ .Select(l => BuildLine(l, clusterId, equipmentByLine))
+ .ToList();
+
+ var node = new UnsNode
+ {
+ Kind = UnsNodeKind.Area,
+ Key = $"area:{area.UnsAreaId}",
+ DisplayName = area.Name,
+ ClusterId = clusterId,
+ EntityId = area.UnsAreaId,
+ HasLazyChildren = false,
+ };
+ node.Children.AddRange(lineNodes);
+ node.ChildCount = node.Children.Count;
+ return node;
+ }
+
+ private static UnsNode BuildLine(
+ LineRow line,
+ string clusterId,
+ IReadOnlyDictionary> equipmentByLine)
+ {
+ var equipmentNodes = (equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment)
+ ? lineEquipment
+ : Enumerable.Empty())
+ .OrderBy(e => e.Name, StringComparer.Ordinal)
+ .ThenBy(e => e.EquipmentId, StringComparer.Ordinal)
+ .Select(e => BuildEquipment(e, clusterId))
+ .ToList();
+
+ var node = new UnsNode
+ {
+ Kind = UnsNodeKind.Line,
+ Key = $"line:{line.UnsLineId}",
+ DisplayName = line.Name,
+ ClusterId = clusterId,
+ EntityId = line.UnsLineId,
+ HasLazyChildren = false,
+ };
+ node.Children.AddRange(equipmentNodes);
+ node.ChildCount = node.Children.Count;
+ return node;
+ }
+
+ private static UnsNode BuildEquipment(EquipmentRow equipment, string clusterId)
+ {
+ var childCount = equipment.TagCount + equipment.VirtualTagCount;
+ return new UnsNode
+ {
+ Kind = UnsNodeKind.Equipment,
+ Key = $"eq:{equipment.EquipmentId}",
+ DisplayName = equipment.Name,
+ ClusterId = clusterId,
+ EntityId = equipment.EquipmentId,
+ ChildCount = childCount,
+ HasLazyChildren = childCount > 0,
+ };
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs
new file mode 100644
index 00000000..6df18be8
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeAssemblyTests.cs
@@ -0,0 +1,166 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
+
+public sealed class UnsTreeAssemblyTests
+{
+ [Fact]
+ public void Build_groups_clusters_under_enterprise()
+ {
+ var clusters = new[]
+ {
+ new ClusterRow("c1", "zb", "SiteA", "Alpha"),
+ new ClusterRow("c2", "zb", "SiteB", "Bravo"),
+ };
+
+ var tree = UnsTreeAssembly.Build(
+ clusters,
+ areas: System.Array.Empty(),
+ lines: System.Array.Empty(),
+ equipment: System.Array.Empty());
+
+ tree.Count.ShouldBe(1);
+ var ent = tree[0];
+ ent.Kind.ShouldBe(UnsNodeKind.Enterprise);
+ ent.Key.ShouldBe("ent:zb");
+ ent.DisplayName.ShouldBe("zb");
+ ent.Children.Count.ShouldBe(2);
+ ent.Children.All(c => c.Kind == UnsNodeKind.Cluster).ShouldBeTrue();
+ ent.Children.Select(c => c.Key).ShouldBe(new[] { "clu:c1", "clu:c2" });
+ }
+
+ [Fact]
+ public void Build_nests_area_line_equipment_under_owning_cluster()
+ {
+ var clusters = new[] { new ClusterRow("c1", "zb", "SiteA", "Alpha") };
+ var areas = new[] { new AreaRow("a1", "c1", "AreaOne") };
+ var lines = new[] { new LineRow("l1", "a1", "LineOne") };
+ var equipment = new[] { new EquipmentRow("e1", "l1", "MC-1", "EquipOne", 0, 0) };
+
+ var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment);
+
+ var cluster = tree.Single().Children.Single();
+ cluster.Key.ShouldBe("clu:c1");
+
+ var area = cluster.Children.Single();
+ area.Kind.ShouldBe(UnsNodeKind.Area);
+ area.Key.ShouldBe("area:a1");
+ area.DisplayName.ShouldBe("AreaOne");
+ area.ClusterId.ShouldBe("c1");
+ area.EntityId.ShouldBe("a1");
+
+ var line = area.Children.Single();
+ line.Kind.ShouldBe(UnsNodeKind.Line);
+ line.Key.ShouldBe("line:l1");
+ line.DisplayName.ShouldBe("LineOne");
+ line.ClusterId.ShouldBe("c1");
+ line.EntityId.ShouldBe("l1");
+
+ var eq = line.Children.Single();
+ eq.Kind.ShouldBe(UnsNodeKind.Equipment);
+ eq.Key.ShouldBe("eq:e1");
+ eq.DisplayName.ShouldBe("EquipOne");
+ eq.ClusterId.ShouldBe("c1");
+ eq.EntityId.ShouldBe("e1");
+ }
+
+ [Fact]
+ public void Build_sets_equipment_child_count_and_lazy_flag()
+ {
+ var clusters = new[] { new ClusterRow("c1", "zb", "SiteA", "Alpha") };
+ var areas = new[] { new AreaRow("a1", "c1", "AreaOne") };
+ var lines = new[] { new LineRow("l1", "a1", "LineOne") };
+ var equipment = new[]
+ {
+ new EquipmentRow("e1", "l1", "MC-1", "WithChildren", 2, 1),
+ new EquipmentRow("e2", "l1", "MC-2", "Empty", 0, 0),
+ };
+
+ var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment);
+ var line = tree.Single().Children.Single().Children.Single().Children.Single();
+
+ var withChildren = line.Children.Single(e => e.Key == "eq:e1");
+ withChildren.ChildCount.ShouldBe(3);
+ withChildren.HasLazyChildren.ShouldBeTrue();
+
+ var empty = line.Children.Single(e => e.Key == "eq:e2");
+ empty.ChildCount.ShouldBe(0);
+ empty.HasLazyChildren.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Build_includes_clusters_with_no_areas()
+ {
+ var clusters = new[]
+ {
+ new ClusterRow("c1", "zb", "SiteA", "Alpha"),
+ new ClusterRow("c2", "zb", "SiteB", "Bravo"),
+ };
+ var areas = new[] { new AreaRow("a1", "c1", "AreaOne") };
+
+ var tree = UnsTreeAssembly.Build(
+ clusters,
+ areas,
+ lines: System.Array.Empty(),
+ equipment: System.Array.Empty());
+
+ var ent = tree.Single();
+ ent.Children.Count.ShouldBe(2);
+
+ var emptyCluster = ent.Children.Single(c => c.Key == "clu:c2");
+ emptyCluster.Children.ShouldBeEmpty();
+
+ var populatedCluster = ent.Children.Single(c => c.Key == "clu:c1");
+ populatedCluster.Children.Single().Key.ShouldBe("area:a1");
+ }
+
+ [Fact]
+ public void Build_orders_deterministically()
+ {
+ // Two enterprises out of order; clusters/areas/lines/equipment scrambled.
+ var clusters = new[]
+ {
+ new ClusterRow("cZ", "zeta", "SiteZ", "Zeta"),
+ new ClusterRow("c2", "alpha", "Site2", "Bravo"),
+ new ClusterRow("c1", "alpha", "Site1", "Alpha"),
+ };
+ var areas = new[]
+ {
+ new AreaRow("a2", "c1", "Beta"),
+ new AreaRow("a1", "c1", "Alpha"),
+ };
+ var lines = new[]
+ {
+ new LineRow("l2", "a1", "LineB"),
+ new LineRow("l1", "a1", "LineA"),
+ };
+ var equipment = new[]
+ {
+ new EquipmentRow("e2", "l1", "MC-2", "EquipB", 0, 0),
+ new EquipmentRow("e1", "l1", "MC-1", "EquipA", 0, 0),
+ };
+
+ var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment);
+
+ // Enterprises ordered by Enterprise (ordinal): "alpha" < "zeta".
+ tree.Select(e => e.Key).ShouldBe(new[] { "ent:alpha", "ent:zeta" });
+
+ // Clusters under "alpha" ordered by Name then ClusterId: "Alpha"(c1) < "Bravo"(c2).
+ var alphaEnt = tree.Single(e => e.Key == "ent:alpha");
+ alphaEnt.Children.Select(c => c.Key).ShouldBe(new[] { "clu:c1", "clu:c2" });
+
+ // Areas ordered by Name then UnsAreaId: "Alpha"(a1) < "Beta"(a2).
+ var c1 = alphaEnt.Children.Single(c => c.Key == "clu:c1");
+ c1.Children.Select(a => a.Key).ShouldBe(new[] { "area:a1", "area:a2" });
+
+ // Lines under a1 ordered by Name then UnsLineId: "LineA"(l1) < "LineB"(l2).
+ var a1 = c1.Children.Single(a => a.Key == "area:a1");
+ a1.Children.Select(l => l.Key).ShouldBe(new[] { "line:l1", "line:l2" });
+
+ // Equipment under l1 ordered by Name then EquipmentId: "EquipA"(e1) < "EquipB"(e2).
+ var l1 = a1.Children.Single(l => l.Key == "line:l1");
+ l1.Children.Select(eq => eq.Key).ShouldBe(new[] { "eq:e1", "eq:e2" });
+ }
+}