7 tasks (DV-1..DV-7): additive AlarmStateChanged native-binding contract chain, site snapshot native placeholders, DebugTreeNode + pure builder (attribute + alarm trees with roll-up/filter), DebugView tabs reusing TreeView<TItem>, docs, and integration (build + docker + Playwright).
22 KiB
Debug View — Tabs + Hierarchy Trees Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Turn the Central UI Debug View into a tabbed page (Attributes / Alarms) where each tab is a collapsible composition tree showing every configured attribute and alarm with current status, including idle native alarm sources.
Architecture: Reuse the existing generic TreeView<TItem> Blazor component. A pure DebugTreeBuilder transforms the flat snapshot/stream lists (whose path-qualified canonical names encode the composition hierarchy) into node forests with branch-level status roll-up. A small additive change to the AlarmStateChanged contract carries the native source-binding canonical name so live native conditions can nest under their binding, and the site snapshot emits placeholder rows for configured-but-quiet native sources.
Tech Stack: C#/.NET 10, Blazor Server, Akka.NET (InstanceActor, StreamRelayActor), gRPC (sitestream.proto), Newtonsoft cross-process serializer, xUnit + bUnit, Playwright.
Design doc: docs/plans/2026-06-17-debugview-tabs-trees-design.md (committed 811d722).
Base: branch debugview-tabs-trees off origin/main (670b607).
Execution notes (read before starting)
- No worktrees in implementers. This plan already runs in the
debugview-tabs-treesworktree. Implementer subagents must NOT create their own worktree. - Pathspec commits only. Commit with
git commit -- <explicit paths>(nevergit add -A/-a). Retry once onindex.lock. - Concurrency. Keep ≤2–3 concurrent committers per wave; after each parallel wave, verify every task's commit is on
HEAD(git log --oneline -<n>), recover any orphan via cherry-pick. - Targeted builds/tests per task (
dotnet build <project.csproj>,dotnet test <testproject.csproj> --filter ...). Full-solution build + docker rebuild only in the final integration task. - Proto codegen:
sitestream.protoregeneratesSitestream.cson build ofZB.MOM.WW.ScadaBridge.Communication— no manual codegen step.
Suggested waves (for subagent-driven execution)
- Wave 1: DV-1 (high-risk; serial spec→code review).
- Wave 2: DV-2 ∥ DV-3 (disjoint files; both need DV-1's new fields).
- Wave 3: DV-4 (alarm-tree builder; needs DV-3's model).
- Wave 4: DV-5 ∥ DV-6 (DebugView page ∥ docs; disjoint).
- Wave 5: DV-7 (integration).
Task DV-1: Native-binding linkage — additive AlarmStateChanged contract chain
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none
Why high-risk: touches a cross-process message contract (Newtonsoft + gRPC proto), the native alarm actor, and both relay directions. Additive-only, but verify nothing existing breaks.
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs:73(add two init properties before the closing brace) - Modify:
src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs:316-334(setNativeSourceCanonicalNamein theEmitinitializer) - Modify:
src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto:86(add fields 22 + 23 toAlarmStateUpdate) - Modify:
src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs:62-78(pack the two fields) - Modify:
src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs:231-249(unpack the two fields) - Test:
tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs(orProtoRoundtripTests.cs/SiteStreamGrpcClientTests.cs— match where alarm round-trip is already asserted) - Test:
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs
Step 1: Write failing tests
In the Communication tests, extend the existing alarm round-trip test (find the test that packs an AlarmStateChanged → AlarmStateUpdate → back) to set NativeSourceCanonicalName = "Motor1.MotorAlarms" and IsConfiguredPlaceholder = true on the input and assert both survive the round-trip. In NativeAlarmActorTests, assert that an emitted AlarmStateChanged (the one told to the parent on a live transition) has NativeSourceCanonicalName == _source.CanonicalName.
Step 2: Run, expect fail (NativeSourceCanonicalName/IsConfiguredPlaceholder don't exist yet → compile error).
Step 3: Implement
AlarmStateChanged.cs — add after LimitValue (line 73):
/// <summary>
/// Canonical name of the native alarm SOURCE BINDING this condition belongs to
/// (e.g. "Motor1.MotorAlarms"). Lets the Debug View nest live native conditions
/// under their configured binding node. Empty for computed alarms. Additive.
/// </summary>
public string NativeSourceCanonicalName { get; init; } = string.Empty;
/// <summary>
/// True when this row is a placeholder emitted for a CONFIGURED native source
/// binding that currently has no active conditions, so the Debug View tree can
/// show the binding node as "no active conditions". Additive; default false.
/// </summary>
public bool IsConfiguredPlaceholder { get; init; }
NativeAlarmActor.cs — in the Emit object initializer (after LimitValue = t.LimitValue):
NativeSourceCanonicalName = _source.CanonicalName,
sitestream.proto — append to message AlarmStateUpdate (after line 86):
string native_source_canonical_name = 22; // native binding canonical name; empty for computed
bool is_configured_placeholder = 23; // true for a quiet-binding placeholder row
StreamRelayActor.cs — in the AlarmStateUpdate initializer (alongside SourceReference):
NativeSourceCanonicalName = msg.NativeSourceCanonicalName ?? string.Empty,
IsConfiguredPlaceholder = msg.IsConfiguredPlaceholder,
SiteStreamGrpcClient.cs — in the new AlarmStateChanged(...) { ... } initializer (alongside SourceReference):
NativeSourceCanonicalName = evt.AlarmChanged.NativeSourceCanonicalName ?? string.Empty,
IsConfiguredPlaceholder = evt.AlarmChanged.IsConfiguredPlaceholder,
Step 4: Run tests
dotnet build src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj
dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/ZB.MOM.WW.ScadaBridge.Communication.Tests.csproj --filter "FullyQualifiedName~StreamRelayActor|FullyQualifiedName~ProtoRoundtrip|FullyQualifiedName~SiteStreamGrpcClient"
dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.csproj --filter "FullyQualifiedName~NativeAlarmActor"
Expected: PASS.
Step 5: Commit git commit -- src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs tests/...
Task DV-2: Site snapshot — placeholder rows for idle native source bindings
Classification: standard Estimated implement time: ~4 min Parallelizable with: DV-3 Depends on: DV-1
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:53-54(add_nativeAlarmKindsdict),:1447-1470(record kind at native-actor creation),:1094-1114(BuildAlarmStatesSnapshotnative placeholder loop) - Test:
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs
Step 1: Write failing tests
Two tests against an InstanceActor configured with a native alarm source binding:
BuildAlarmStatesSnapshot_QuietNativeBinding_EmitsPlaceholder— with a configured native binding and NO live condition for it, the debug snapshot contains exactly one alarm row for that binding canonical name withIsConfiguredPlaceholder == true,State == Normal,NativeSourceCanonicalName == <binding>.BuildAlarmStatesSnapshot_NativeBindingWithLiveCondition_NoPlaceholder— after the InstanceActor receives anAlarmStateChanged(Kind native,NativeSourceCanonicalName == <binding>), the snapshot contains the live condition and no placeholder row for that binding.
Match the existing construction pattern in InstanceActorNativeAlarmTests.cs (TestKit + a FlattenedConfiguration with NativeAlarmSources). Reuse the debug-snapshot ask already exercised there if present (SubscribeDebugViewRequest / DebugSnapshotRequest).
Step 2: Run, expect fail (no placeholder emitted today).
Step 3: Implement
Add field near the other actor dictionaries (line ~54):
private readonly Dictionary<string, AlarmKind> _nativeAlarmKinds = new();
At native-actor creation (~1447-1470), record the kind per binding using the SAME derivation NativeAlarmActor uses for its _nativeKind (read NativeAlarmActor ctor to see how kind is derived from the source/connection):
_nativeAlarmKinds[nativeSource.CanonicalName] = <kind derived as NativeAlarmActor does>;
In BuildAlarmStatesSnapshot(), after the existing computed fallback loop and before return states;:
// Native source bindings with no live condition: emit a placeholder so the
// Debug View tree shows the configured binding node even when quiet.
var liveBindings = _latestAlarmEvents.Values
.Where(e => !string.IsNullOrEmpty(e.NativeSourceCanonicalName))
.Select(e => e.NativeSourceCanonicalName)
.ToHashSet();
foreach (var binding in _nativeAlarmActors.Keys)
{
if (liveBindings.Contains(binding)) continue;
states.Add(new AlarmStateChanged(
_instanceUniqueName, binding, AlarmState.Normal, 0, DateTimeOffset.UtcNow)
{
Kind = _nativeAlarmKinds.GetValueOrDefault(binding, AlarmKind.NativeOpcUa),
NativeSourceCanonicalName = binding,
IsConfiguredPlaceholder = true
});
}
Step 4: Run dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.csproj --filter "FullyQualifiedName~InstanceActorNativeAlarm" → PASS.
Step 5: Commit pathspec (InstanceActor.cs + the test).
Task DV-3: DebugTreeNode model + attribute-tree builder (pure, filter, roll-up)
Classification: standard
Estimated implement time: ~5 min
Parallelizable with: DV-2
Depends on: DV-1 (uses the new AlarmStateChanged fields in the shared model file)
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeNode.cs - Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeBuilder.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs
Step 1: Write failing tests (attribute tree only in this task):
- Root-level attributes (
"Speed") become direct leaves;"Motor1.Speed"+"Motor1.Temp"nest under aMotor1branch;"Motor1.Compressor.Pump"nests two deep. - Branch roll-up: a branch with any descendant whose
Quality != "Good"hasHasBadQuality == true. - Filter prune:
BuildAttributeTree(attrs, "temp")keeps only matching leaves plus their ancestor branches; empty/whitespace filter returns the full forest. - Leaf key is the full canonical name; children sorted by segment.
Step 2: Run, expect fail (types don't exist).
Step 3: Implement
DebugTreeNode.cs:
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
public sealed class DebugTreeNode
{
public required string Key { get; init; } // full canonical path — stable TreeView key
public required string Segment { get; init; } // display label (last path segment)
public List<DebugTreeNode> 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
public bool IsNativeBinding { get; init; } // branch grouping native conditions
// 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;
}
DebugTreeBuilder.cs — implement BuildAttributeTree(IEnumerable<AttributeValueChanged> attrs, string? filter):
- Optionally filter the input: keep an attribute if
AttributeNamecontainsfilter(OrdinalIgnoreCase). - Split each
AttributeNameon'.'; walk/create branch nodes (Key = accumulated prefix), attach the attribute as a leaf at the terminal segment. - Sort children by
Segment. - Post-order roll-up:
HasBadQualitytrue if the node's own attribute is off-Good or any child hasHasBadQuality. - (Leave
BuildAlarmTreeas a// DV-4stub or omit; DV-4 adds it to the same class.)
Step 4: Run dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests.csproj --filter "FullyQualifiedName~DebugTreeBuilder" → PASS.
Step 5: Commit pathspec (the two new src files + the test).
Task DV-4: Alarm-tree builder — computed leaves + native binding grouping + roll-up
Classification: standard
Estimated implement time: ~5 min
Parallelizable with: none
Depends on: DV-3 (shares DebugTreeNode + DebugTreeBuilder), DV-1 (native fields)
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugTreeBuilder.cs(addBuildAlarmTree) - Test:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs(add alarm cases)
Step 1: Write failing tests:
- Computed alarm
"Motor1.HighTemp"→ leaf underMotor1. - Native condition (
Kind = NativeOpcUa,NativeSourceCanonicalName = "Tank1.Levels",SourceReference = "Tank1.Level.HiHi") → nests as a condition child under aTank1.Levelsbinding branch (binding placed by its canonical path underTank1). Two conditions for the same binding → two children of that binding node. - Placeholder row (
IsConfiguredPlaceholder = true,NativeSourceCanonicalName = "Tank1.Levels") → the binding node renders as present with zero condition children (builder marksIsNativeBindingand adds no condition child). - Roll-up: a branch with any descendant
Alarm.State == ActivehasWorstState == ActiveandActiveCount= number of active descendants; placeholder rows never count as active. - Filter prune keeps native bindings whose name OR a condition matches.
Step 2: Run, expect fail.
Step 3: Implement BuildAlarmTree:
- Partition input by
Kind. Computed: leaf at the canonical path fromAlarmName. - Native: group by
NativeSourceCanonicalName. For each binding, create/walk a branch node at the binding's canonical path withIsNativeBinding = true. For each non-placeholder condition under it, add a child node (Key =binding + "::" + SourceReference, Segment =SourceReference,Alarm = the event). Placeholder rows add no child (the empty binding node renders "no active conditions" in the page). - Sort; post-order roll-up of
WorstState/ActiveCount(Active dominates; count leaves/conditions withState == Active && !IsConfiguredPlaceholder).
Step 4: Run --filter "FullyQualifiedName~DebugTreeBuilder" → PASS.
Step 5: Commit pathspec.
Task DV-5: DebugView page — tabs + two TreeViews + in-place updates
Classification: standard Estimated implement time: ~5 min Parallelizable with: DV-6 Depends on: DV-4 (builder), DV-2 (placeholders flow through the snapshot at runtime; not a compile dep)
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor - Modify:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewAlarmTableTests.cs(rework table assertions → tree assertions)
Step 1: Write/adjust failing tests — render the connected page (reuse the harness in DebugViewAlarmTableTests) and assert: an Attributes tab and an Alarms tab exist (custom nav-tabs); switching to Alarms shows configured alarms grouped under their module branch (a Motor1 branch containing HighTemp); a branch header shows the roll-up badge when a descendant alarm is Active. Preserve the dispose/stream-race coverage in DebugViewDisposalTests.cs / DebugViewStreamRaceTests.cs (those exercise CentralUI-009/021 — the marshalling is unchanged, so they should keep passing; run them, don't rewrite).
Step 2: Run, expect fail.
Step 3: Implement
- Replace the two side-by-side
<div class="col-md-*">cards with a Bootstrapnav-tabsheader + tab panes (custom markup, no third-party lib). Keep the status strip, site/instance selectors, live/snapshot badges,Connect/Disconnect, localStorage auto-reconnect, and site-scope checks exactly as-is. - Each tab body hosts
<TreeView TItem="DebugTreeNode" Items="..." ChildrenSelector="n => n.Children" HasChildrenSelector="n => n.HasChildren" KeySelector="n => n.Key" NodeContent="..." StorageKey="debugview.attrTree"/...>with anEmptyContenthint for an empty instance. NodeContentfragments: attribute leaf → name /ValueFormatter.FormatDisplayValue/ quality badge (GetQualityBadge) / timestamp; attribute branch → segment + bad-quality indicator whenHasBadQuality. Alarm computed leaf / native condition → reuseGetAlarmStateBadge,GetKindBadge,FormatKind,GetAlarmLevelBadge,FormatLevel,BuildAlarmTooltip; native binding branch → segment + roll-up badge (worst state +ActiveCount); a binding with no children renders "no active conditions"; alarm branch → segment + worst-state/active-count roll-up badge.- Keep
_attributeValues/_alarmStatesdictionaries as the latest-per-name source of truth but remove theMaxRowscap, auto-scroll, and Clear controls. ReplaceFilteredAttributeValues/FilteredAlarmStateswith computed forests:DebugTreeBuilder.BuildAttributeTree(_attributeValues.Values, _attrFilter)andDebugTreeBuilder.BuildAlarmTree(_alarmStates.Values, _alarmFilter). Keep the per-tab filter<input>; when a filter is non-empty, call the TreeView'sExpandAll()via@refso matches are visible. - Live-event handling: keep
HandleStreamEvent+SafeInvokeAsyncmarshalling andUpsertWithCap→ rename to a plain upsert (no cap). Rebuilding the forest in the computed property on eachStateHasChangedis sufficient (pure + cheap for typical instance sizes).
Step 4: Run dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests.csproj --filter "FullyQualifiedName~DebugView" → PASS (DebugViewAlarmTable reworked; Disposal + StreamRace still green).
Step 5: Commit pathspec.
Task DV-6: Documentation
Classification: small Estimated implement time: ~3 min Parallelizable with: DV-5 Depends on: DV-4
Files:
- Modify:
docs/requirements/Component-CentralUI.md(Debug View section — tabs + trees, branch roll-up, native binding nodes, idle-source placeholders) - Modify:
docs/requirements/Component-SiteRuntime.md(snapshot enrichment: placeholder rows for quiet native bindings;_nativeAlarmKinds) - Modify:
docs/requirements/Component-Commons.md(if present; else note in CentralUI doc) — streaming contract note: additiveNativeSourceCanonicalName+IsConfiguredPlaceholderonAlarmStateChanged(and proto fields 22/23), additive-only evolution.
Steps: Update the prose to match the shipped behaviour; no README/CLAUDE component-table change (no new component, stays at 25). Commit pathspec. (No tests.)
Task DV-7: Integration — full build, docker rebuild, Playwright, smoke
Classification: high-risk Estimated implement time: ~5 min (+ build/deploy wall-time) Parallelizable with: none Depends on: DV-5, DV-6, DV-2
Files:
- Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DebugViewTreeTests.cs
Steps:
- Full-solution build:
dotnet build ZB.MOM.WW.ScadaBridge.slnx→ 0 errors. - Run the affected unit suites green: Communication.Tests (Grpc filter), SiteRuntime.Tests (InstanceActorNativeAlarm), CentralUI.Tests (DebugView + DebugTreeBuilder + TreeView).
- Rebuild the local cluster:
bash docker/deploy.sh. - One Playwright test (
DebugViewTreeTests): log in (multi-role/passwordvialocalhost:9000), navigate to/deployment/debug-view, select a site + instance with composition + a configured alarm (seed via CLI in the fixture, mirroringTemplateCrudTests/InstanceConfigure*fixtures), Connect, switch to the Alarms tab, assert the configured alarm appears under its module branch and a branch roll-up badge renders. ReusePlaywrightFixture. - Manual smoke (note results in the task comment): connect to a real instance, confirm Attributes + Alarms tabs render trees, idle native source shows a "no active conditions" binding node, a fired alarm rolls up to its branch.
- Commit pathspec (the Playwright test). Then hand off to
finishing-a-development-branch.
Risks / watch-items
- Existing DebugView tests (
DebugViewAlarmTableTests) assert the flat-table DOM and WILL break — DV-5 reworks them. The concurrency tests (DebugViewDisposalTests,DebugViewStreamRaceTests) should stay green because the stream-callback marshalling is unchanged; if they fail, the merge logic diverged from the originalSafeInvokeAsync/dispose-guard contract — fix the merge, don't weaken the tests. - Native kind for placeholders (DV-2): derive it the same way
NativeAlarmActordoes; if the derivation is awkward to reach fromInstanceActor, defaulting the placeholder badge to a generic native kind is acceptable (cosmetic only) — note the choice in the commit. - Forest rebuild on every event (DV-5) is intentional (pure + cheap). If profiling ever shows it hot for very large instances, switch to incremental node patching — out of scope here.