Files
ScadaBridge/docs/plans/2026-06-17-debugview-tabs-trees.md
T
Joseph Doherty 1045e7966d docs(plans): implementation plan for Debug View tabs + hierarchy trees
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).
2026-06-17 14:14:58 -04:00

22 KiB
Raw Blame History

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-trees worktree. Implementer subagents must NOT create their own worktree.
  • Pathspec commits only. Commit with git commit -- <explicit paths> (never git add -A/-a). Retry once on index.lock.
  • Concurrency. Keep ≤23 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.proto regenerates Sitestream.cs on build of ZB.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 (set NativeSourceCanonicalName in the Emit initializer)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto:86 (add fields 22 + 23 to AlarmStateUpdate)
  • 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 (or ProtoRoundtripTests.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 AlarmStateChangedAlarmStateUpdate → 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 _nativeAlarmKinds dict), :1447-1470 (record kind at native-actor creation), :1094-1114 (BuildAlarmStatesSnapshot native 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:

  1. 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 with IsConfiguredPlaceholder == true, State == Normal, NativeSourceCanonicalName == <binding>.
  2. BuildAlarmStatesSnapshot_NativeBindingWithLiveCondition_NoPlaceholder — after the InstanceActor receives an AlarmStateChanged (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 a Motor1 branch; "Motor1.Compressor.Pump" nests two deep.
  • Branch roll-up: a branch with any descendant whose Quality != "Good" has HasBadQuality == 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 AttributeName contains filter (OrdinalIgnoreCase).
  • Split each AttributeName on '.'; 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: HasBadQuality true if the node's own attribute is off-Good or any child has HasBadQuality.
  • (Leave BuildAlarmTree as a // DV-4 stub 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 (add BuildAlarmTree)
  • Test: tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugTreeBuilderTests.cs (add alarm cases)

Step 1: Write failing tests:

  • Computed alarm "Motor1.HighTemp" → leaf under Motor1.
  • Native condition (Kind = NativeOpcUa, NativeSourceCanonicalName = "Tank1.Levels", SourceReference = "Tank1.Level.HiHi") → nests as a condition child under a Tank1.Levels binding branch (binding placed by its canonical path under Tank1). 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 marks IsNativeBinding and adds no condition child).
  • Roll-up: a branch with any descendant Alarm.State == Active has WorstState == Active and ActiveCount = 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 from AlarmName.
  • Native: group by NativeSourceCanonicalName. For each binding, create/walk a branch node at the binding's canonical path with IsNativeBinding = 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 with State == 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 Bootstrap nav-tabs header + 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 an EmptyContent hint for an empty instance.
  • NodeContent fragments: attribute leaf → name / ValueFormatter.FormatDisplayValue / quality badge (GetQualityBadge) / timestamp; attribute branch → segment + bad-quality indicator when HasBadQuality. Alarm computed leaf / native condition → reuse GetAlarmStateBadge, 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 / _alarmStates dictionaries as the latest-per-name source of truth but remove the MaxRows cap, auto-scroll, and Clear controls. Replace FilteredAttributeValues/FilteredAlarmStates with computed forests: DebugTreeBuilder.BuildAttributeTree(_attributeValues.Values, _attrFilter) and DebugTreeBuilder.BuildAlarmTree(_alarmStates.Values, _alarmFilter). Keep the per-tab filter <input>; when a filter is non-empty, call the TreeView's ExpandAll() via @ref so matches are visible.
  • Live-event handling: keep HandleStreamEvent + SafeInvokeAsync marshalling and UpsertWithCap → rename to a plain upsert (no cap). Rebuilding the forest in the computed property on each StateHasChanged is 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: additive NativeSourceCanonicalName + IsConfiguredPlaceholder on AlarmStateChanged (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:

  1. Full-solution build: dotnet build ZB.MOM.WW.ScadaBridge.slnx → 0 errors.
  2. Run the affected unit suites green: Communication.Tests (Grpc filter), SiteRuntime.Tests (InstanceActorNativeAlarm), CentralUI.Tests (DebugView + DebugTreeBuilder + TreeView).
  3. Rebuild the local cluster: bash docker/deploy.sh.
  4. One Playwright test (DebugViewTreeTests): log in (multi-role/password via localhost:9000), navigate to /deployment/debug-view, select a site + instance with composition + a configured alarm (seed via CLI in the fixture, mirroring TemplateCrudTests/InstanceConfigure* fixtures), Connect, switch to the Alarms tab, assert the configured alarm appears under its module branch and a branch roll-up badge renders. Reuse PlaywrightFixture.
  5. 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.
  6. 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 original SafeInvokeAsync/dispose-guard contract — fix the merge, don't weaken the tests.
  • Native kind for placeholders (DV-2): derive it the same way NativeAlarmActor does; if the derivation is awkward to reach from InstanceActor, 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.