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

337 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `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):
```csharp
/// <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`):
```csharp
NativeSourceCanonicalName = _source.CanonicalName,
```
`sitestream.proto` — append to `message AlarmStateUpdate` (after line 86):
```proto
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`):
```csharp
NativeSourceCanonicalName = msg.NativeSourceCanonicalName ?? string.Empty,
IsConfiguredPlaceholder = msg.IsConfiguredPlaceholder,
```
`SiteStreamGrpcClient.cs` — in the `new AlarmStateChanged(...) { ... }` initializer (alongside `SourceReference`):
```csharp
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):
```csharp
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):
```csharp
_nativeAlarmKinds[nativeSource.CanonicalName] = <kind derived as NativeAlarmActor does>;
```
In `BuildAlarmStatesSnapshot()`, after the existing computed fallback loop and before `return states;`:
```csharp
// 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`:
```csharp
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.