diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeBuilder.cs
new file mode 100644
index 00000000..e746d423
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeBuilder.cs
@@ -0,0 +1,113 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
+
+///
+/// Pure (no Blazor/DI) builder that turns a flat list of streamed attribute (and,
+/// in DV-4, alarm) events into a collapsible composition forest of
+/// . Path-qualified canonical names are split on
+/// '.' to derive branch nodes; the terminal segment carries the payload.
+///
+public static class DebugTreeBuilder
+{
+ private const char Separator = '.';
+
+ ///
+ /// Build the attribute composition forest.
+ ///
+ /// Latest attribute value events (one per attribute).
+ ///
+ /// Optional name-contains filter (case-insensitive, matched against
+ /// ). Null/empty/whitespace
+ /// keeps everything; matching leaves carry along their ancestor branches.
+ ///
+ public static IReadOnlyList BuildAttributeTree(
+ IEnumerable attributes, string? filter)
+ {
+ ArgumentNullException.ThrowIfNull(attributes);
+
+ var hasFilter = !string.IsNullOrWhiteSpace(filter);
+
+ // Roots keyed by the first path segment; branch nodes deduped by full prefix.
+ var roots = new List();
+ var branchByKey = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var attr in attributes)
+ {
+ if (hasFilter &&
+ attr.AttributeName.IndexOf(filter!, StringComparison.OrdinalIgnoreCase) < 0)
+ {
+ continue;
+ }
+
+ var segments = attr.AttributeName.Split(Separator);
+
+ // Walk/create branch nodes for every segment except the last; the last
+ // segment becomes the leaf carrying the attribute event.
+ List currentLevel = roots;
+ string prefix = string.Empty;
+
+ for (var i = 0; i < segments.Length - 1; i++)
+ {
+ prefix = prefix.Length == 0 ? segments[i] : prefix + Separator + segments[i];
+
+ if (!branchByKey.TryGetValue(prefix, out var branch))
+ {
+ branch = new DebugTreeNode { Key = prefix, Segment = segments[i] };
+ branchByKey[prefix] = branch;
+ currentLevel.Add(branch);
+ }
+
+ currentLevel = branch.Children;
+ }
+
+ var leaf = new DebugTreeNode
+ {
+ Key = attr.AttributeName,
+ Segment = segments[^1],
+ Attribute = attr,
+ };
+ currentLevel.Add(leaf);
+ }
+
+ SortAndRollUp(roots);
+ return roots;
+ }
+
+ ///
+ /// DV-4 will implement the alarm composition tree (computed leaves, native
+ /// condition groupings, configured-placeholder branches). Intentionally not
+ /// implemented in DV-3 — do not add alarm logic here.
+ ///
+ public static IReadOnlyList BuildAlarmTree(
+ IEnumerable alarms, string? filter)
+ => throw new NotImplementedException("BuildAlarmTree is implemented in DV-4.");
+
+ ///
+ /// Recursively sort each node's children by
+ /// (ordinal) and roll up bad-quality: a node has bad quality when its own
+ /// attribute is off-Good or any descendant leaf is off-Good. Post-order so a
+ /// child's roll-up is settled before its parent reads it.
+ ///
+ private static void SortAndRollUp(List nodes)
+ {
+ nodes.Sort(static (a, b) => string.CompareOrdinal(a.Segment, b.Segment));
+
+ foreach (var node in nodes)
+ {
+ // Leaf's own quality (branches normally have no attribute).
+ var bad = node.Attribute is not null && !IsGood(node.Attribute.Quality);
+
+ if (node.Children.Count > 0)
+ {
+ SortAndRollUp(node.Children);
+ bad = bad || node.Children.Any(static c => c.HasBadQuality);
+ }
+
+ node.HasBadQuality = bad;
+ }
+ }
+
+ private static bool IsGood(string quality)
+ => string.Equals(quality, "Good", StringComparison.Ordinal);
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeNode.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeNode.cs
new file mode 100644
index 00000000..157361ed
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeNode.cs
@@ -0,0 +1,35 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; // AlarmState
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
+
+///
+/// A node in a Debug View composition tree. Attribute (and, in DV-4, alarm)
+/// names are path-qualified canonical names (e.g. Motor1.Compressor.Pump);
+/// the tree is derived by splitting those names on '.'. A node is either a
+/// branch (composition member, no payload, has children) or a leaf (one attribute
+/// or one alarm). Consumed by the generic TreeView<TItem> component.
+///
+public sealed class DebugTreeNode
+{
+ /// Full canonical path — stable TreeView key.
+ public required string Key { get; init; }
+
+ /// Display label (the last path segment).
+ public required string Segment { get; init; }
+
+ public List Children { get; } = new();
+
+ // Leaf payloads — exactly one is set on a leaf; both null on a pure branch.
+ public AttributeValueChanged? Attribute { get; init; }
+ public AlarmStateChanged? Alarm { get; init; } // computed leaf, native condition, or placeholder (DV-4)
+
+ public bool IsNativeBinding { get; init; } // branch grouping native conditions (DV-4)
+
+ // Roll-up (set by the builder for branch nodes).
+ public AlarmState WorstState { get; set; } = AlarmState.Normal;
+ public int ActiveCount { get; set; }
+ public bool HasBadQuality { get; set; }
+
+ public bool HasChildren => Children.Count > 0;
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs
new file mode 100644
index 00000000..9ee4ce24
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs
@@ -0,0 +1,206 @@
+using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
+
+///
+/// Pure unit tests for — node
+/// model derivation, branch dedupe, child sort, bad-quality roll-up, and the
+/// name-contains filter. No bUnit / DI required; the builder is a pure function.
+///
+public class DebugTreeBuilderTests
+{
+ private static AttributeValueChanged Attr(string name, object? value = null, string quality = "Good")
+ => new("Inst", name, name, value, quality, DateTimeOffset.UtcNow);
+
+ [Fact]
+ public void RootLevelAttribute_BecomesSingleLeaf()
+ {
+ var tree = DebugTreeBuilder.BuildAttributeTree(new[] { Attr("Speed") }, null);
+
+ var node = Assert.Single(tree);
+ Assert.Equal("Speed", node.Key);
+ Assert.Equal("Speed", node.Segment);
+ Assert.False(node.HasChildren);
+ Assert.NotNull(node.Attribute);
+ Assert.Equal("Speed", node.Attribute!.AttributeName);
+ }
+
+ [Fact]
+ public void TwoSiblingLeaves_ShareSingleBranch_SortedBySegment()
+ {
+ // Provided out of order to prove children are sorted ordinally.
+ var tree = DebugTreeBuilder.BuildAttributeTree(
+ new[] { Attr("Motor1.Temp"), Attr("Motor1.Speed") }, null);
+
+ var branch = Assert.Single(tree);
+ Assert.Equal("Motor1", branch.Key);
+ Assert.Equal("Motor1", branch.Segment);
+ Assert.True(branch.HasChildren);
+ Assert.Null(branch.Attribute);
+
+ Assert.Equal(2, branch.Children.Count);
+ Assert.Equal("Motor1.Speed", branch.Children[0].Key);
+ Assert.Equal("Speed", branch.Children[0].Segment);
+ Assert.Equal("Motor1.Temp", branch.Children[1].Key);
+ Assert.Equal("Temp", branch.Children[1].Segment);
+ }
+
+ [Fact]
+ public void DeepPath_NestsBranchesByAccumulatedPrefix()
+ {
+ var tree = DebugTreeBuilder.BuildAttributeTree(
+ new[] { Attr("Motor1.Compressor.Pump") }, null);
+
+ var motor = Assert.Single(tree);
+ Assert.Equal("Motor1", motor.Key);
+ Assert.True(motor.HasChildren);
+
+ var compressor = Assert.Single(motor.Children);
+ Assert.Equal("Motor1.Compressor", compressor.Key);
+ Assert.Equal("Compressor", compressor.Segment);
+ Assert.True(compressor.HasChildren);
+ Assert.Null(compressor.Attribute);
+
+ var pump = Assert.Single(compressor.Children);
+ Assert.Equal("Motor1.Compressor.Pump", pump.Key);
+ Assert.Equal("Pump", pump.Segment);
+ Assert.False(pump.HasChildren);
+ Assert.NotNull(pump.Attribute);
+ }
+
+ [Fact]
+ public void BranchDedupe_SharedPrefixAcrossDeepPaths()
+ {
+ var tree = DebugTreeBuilder.BuildAttributeTree(
+ new[]
+ {
+ Attr("Motor1.Compressor.Pump"),
+ Attr("Motor1.Compressor.Valve"),
+ Attr("Motor1.Speed"),
+ },
+ null);
+
+ var motor = Assert.Single(tree);
+ Assert.Equal("Motor1", motor.Key);
+ // Two direct children: "Compressor" (branch) and "Speed" (leaf), sorted.
+ Assert.Equal(2, motor.Children.Count);
+ Assert.Equal("Motor1.Compressor", motor.Children[0].Key);
+ Assert.Equal("Motor1.Speed", motor.Children[1].Key);
+
+ var compressor = motor.Children[0];
+ Assert.Equal(2, compressor.Children.Count);
+ Assert.Equal("Motor1.Compressor.Pump", compressor.Children[0].Key);
+ Assert.Equal("Motor1.Compressor.Valve", compressor.Children[1].Key);
+ }
+
+ [Fact]
+ public void RollUp_BadQualityDescendant_MarksAllAncestorBranches()
+ {
+ var tree = DebugTreeBuilder.BuildAttributeTree(
+ new[]
+ {
+ Attr("Motor1.Compressor.Pump", quality: "Bad"),
+ Attr("Motor1.Speed", quality: "Good"),
+ },
+ null);
+
+ var motor = Assert.Single(tree);
+ Assert.True(motor.HasBadQuality);
+
+ var compressor = motor.Children.Single(c => c.Key == "Motor1.Compressor");
+ Assert.True(compressor.HasBadQuality);
+ }
+
+ [Fact]
+ public void RollUp_AllGood_LeavesBranchHasBadQualityFalse()
+ {
+ var tree = DebugTreeBuilder.BuildAttributeTree(
+ new[] { Attr("Motor1.Speed"), Attr("Motor1.Temp") }, null);
+
+ var motor = Assert.Single(tree);
+ Assert.False(motor.HasBadQuality);
+ }
+
+ [Theory]
+ [InlineData("Uncertain")]
+ [InlineData("Bad")]
+ public void RollUp_NonGoodQuality_CountsAsBad(string quality)
+ {
+ var tree = DebugTreeBuilder.BuildAttributeTree(
+ new[] { Attr("Motor1.Speed", quality: quality) }, null);
+
+ Assert.True(Assert.Single(tree).HasBadQuality);
+ }
+
+ [Fact]
+ public void Filter_KeepsOnlyMatchingLeaves_AndTheirAncestors()
+ {
+ var tree = DebugTreeBuilder.BuildAttributeTree(
+ new[]
+ {
+ Attr("Motor1.Temp"),
+ Attr("Motor1.Speed"),
+ Attr("Motor2.Pressure"),
+ },
+ "temp");
+
+ // Only Motor1.Temp matches → just the Motor1 branch with one Temp leaf.
+ var motor = Assert.Single(tree);
+ Assert.Equal("Motor1", motor.Key);
+ var leaf = Assert.Single(motor.Children);
+ Assert.Equal("Motor1.Temp", leaf.Key);
+ }
+
+ [Fact]
+ public void Filter_IsCaseInsensitive()
+ {
+ var tree = DebugTreeBuilder.BuildAttributeTree(
+ new[] { Attr("Motor1.Temp"), Attr("Motor1.Speed") }, "TEMP");
+
+ var motor = Assert.Single(tree);
+ Assert.Equal("Motor1.Temp", Assert.Single(motor.Children).Key);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Filter_EmptyOrWhitespace_ReturnsFullForest(string? filter)
+ {
+ var tree = DebugTreeBuilder.BuildAttributeTree(
+ new[] { Attr("Motor1.Temp"), Attr("Motor2.Pressure") }, filter);
+
+ Assert.Equal(2, tree.Count);
+ Assert.Equal("Motor1", tree[0].Key);
+ Assert.Equal("Motor2", tree[1].Key);
+ }
+
+ [Fact]
+ public void Filter_NoMatches_ReturnsEmptyForest()
+ {
+ var tree = DebugTreeBuilder.BuildAttributeTree(
+ new[] { Attr("Motor1.Temp") }, "nonexistent");
+
+ Assert.Empty(tree);
+ }
+
+ [Fact]
+ public void TopLevelForest_SortedBySegment()
+ {
+ var tree = DebugTreeBuilder.BuildAttributeTree(
+ new[] { Attr("Zeta"), Attr("Alpha"), Attr("Motor1.Speed") }, null);
+
+ Assert.Equal(3, tree.Count);
+ Assert.Equal("Alpha", tree[0].Key);
+ Assert.Equal("Motor1", tree[1].Key);
+ Assert.Equal("Zeta", tree[2].Key);
+ }
+
+ [Fact]
+ public void EmptyInput_ReturnsEmptyForest()
+ {
+ var tree = DebugTreeBuilder.BuildAttributeTree(Array.Empty(), null);
+ Assert.Empty(tree);
+ }
+}