Compare commits

...

15 Commits

Author SHA1 Message Date
Joseph Doherty 59e094ada3 test(playwright): DebugViewTree — tolerate empty alarm forest (TreeView renders EmptyContent, not role=tree, when no alarms) 2026-06-17 16:08:37 -04:00
Joseph Doherty e7660134f2 fix(communication): drop IsConfiguredPlaceholder rows in StreamRelayActor before gRPC pack
Placeholder AlarmStateChanged rows are a DebugView snapshot-only concept emitted
by InstanceActor.BuildAlarmStatesSnapshot; they are never a real alarm transition.
Their timestamp may be DateTimeOffset.MinValue (the Protobuf Timestamp lower boundary),
which can throw when packed via Timestamp.FromDateTimeOffset.

Added early-return guard at the top of HandleAlarmStateChanged before any timestamp
pack or channel write. Updated the existing NativeBindingLinkage round-trip test to
use a real (non-placeholder) native alarm; added DropsAlarmStateChanged_WhenIsConfiguredPlaceholder
to assert placeholders are silently dropped (15/15 pass).
2026-06-17 15:44:28 -04:00
Joseph Doherty 7f59ae12cb test(playwright): DebugView tabs+trees — assert tabbed layout, tree panes, Alarms-tab switch on connected instance 2026-06-17 15:33:28 -04:00
Joseph Doherty ef86a2db28 refactor(debugview): cosmetic polish — test-seam comment, default-arm comment, tighten severity assertion 2026-06-17 15:30:18 -04:00
Joseph Doherty 50ce26f2e6 feat(centralui): DV-5 — Debug View tabbed composition trees (Attributes/Alarms)
Replace the two flat capped tables with a Bootstrap nav-tabs layout, each
tab hosting a TreeView<DebugTreeNode> built from the live latest-per-name
dictionaries via DebugTreeBuilder. Drop the MaxRows cap, auto-scroll locks,
and Clear buttons (change-feed affordances that don't fit a current-status
tree); HandleStreamEvent now does a plain dictionary upsert. Per-tab filters
ExpandAll on change so matches stay visible. Branch nodes surface roll-up
badges (active-count for alarms, bad-quality for attributes); native binding
nodes show active-count or 'no active conditions'. All existing badge helpers
and ValueFormatter reused. Marshalling/dispose/reconnect contract preserved
(SafeInvokeAsync/_disposed/Dispose unchanged; FilteredAttributeValues kept as
the render-thread dict reader the CentralUI-021 race test exercises).

Rework DebugViewAlarmTableTests for the tabbed-tree DOM: tab presence+default,
computed alarm grouped under its Motor1 branch with the active roll-up badge,
and a native condition nested under its source-binding node with the enriched
kind/severity/Unacked/Shelved badge set.
2026-06-17 15:23:49 -04:00
Joseph Doherty 59f135a4cf docs(DV-6): document Debug View tabbed-tree layout, native placeholders, and new AlarmStateChanged fields
- Component-CentralUI.md: replace flat-table Debug View section with tabbed
  tree layout (Attributes + Alarms tabs, TreeView<TItem> reuse, hierarchy from
  canonical names, branch roll-up, all-configured-alarms rule, native source
  binding nodes with quiet-binding placeholder rows, per-leaf rendering detail)
- Component-SiteRuntime.md (Instance Actor Wiring): add idle-binding placeholder
  emission via BuildAlarmStatesSnapshot(), _nativeAlarmKinds map, and
  NativeSourceCanonicalName stamping on live native events
- Component-SiteRuntime.md (Enriched AlarmStateChanged): document two new
  additive fields — NativeSourceCanonicalName (string?) and
  IsConfiguredPlaceholder (bool) — plus their gRPC proto fields 22/23 and
  StreamRelayActor/SiteStreamGrpcClient pack/unpack
- Component-Commons.md (Attribute Stream DTOs): extend AlarmStateChanged bullet
  with the same two additive fields and proto field numbers
2026-06-17 15:21:42 -04:00
Joseph Doherty c1e786e3fc refactor(DebugTreeBuilder): DRY BuildAttributeTree via WalkBranches; guard empty NativeSourceCanonicalName 2026-06-17 15:18:09 -04:00
Joseph Doherty 5f387ef3e3 feat(debugview): DV-4 implement BuildAlarmTree (computed leaves, native binding nodes, roll-up, filter)
Computed alarms place as leaves at their path-qualified AlarmName; native conditions group under a deduped IsNativeBinding branch keyed by NativeSourceCanonicalName with condition children keyed canonical::sourceRef. Configured-placeholder events materialise a childless binding node. Alarm roll-up (WorstState/ActiveCount) excludes placeholders. Filter matches AlarmName/SourceReference/NativeSourceCanonicalName (OrdinalIgnoreCase) and retains ancestor + binding branches. 20 new TDD cases; 18 attribute cases stay green. No DebugTreeNode model changes.
2026-06-17 15:12:57 -04:00
Joseph Doherty 69b83379d5 test(dv-3): add 4-level roll-up + deep-leaf filter tests; return AsReadOnly; add caller-contract remark
Fix 1 (Important): RollUp_FourLevelDeepBadQuality_ReachesRoot — proves bad quality at a
4-segment-deep leaf propagates HasBadQuality up every ancestor to the root.

Fix 2 (Important): Filter_DeepLeafMatch_RetainsAllAncestorBranches — proves filtering on
a terminal segment of a 3-level path retains all ancestor branches.

Fix 3 (Minor): BuildAttributeTree now returns roots.AsReadOnly() so the returned
IReadOnlyList<DebugTreeNode> reference is not a mutable list.

Fix 4 (Minor): Added <remarks> XML doc to BuildAttributeTree noting the caller-contract
that at most one AttributeValueChanged per AttributeName should be passed.

All 18 DebugTreeBuilder tests pass.
2026-06-17 15:09:01 -04:00
Joseph Doherty b5347faf44 fix(DV-2): clarify placeholder comment, stable MinValue timestamp, drain DCL probe in new tests
- Replace placeholder-loop comment with the double-render guard explanation
- Use _alarmTimestamps.GetValueOrDefault(binding, DateTimeOffset.MinValue) so the
  placeholder timestamp is stable/idempotent across snapshot calls (was UtcNow)
- Add dcl.ExpectMsg<SubscribeAlarmsRequest>() drain in Snapshot_QuietNativeBinding_EmitsPlaceholder
  and Snapshot_NativeBindingWithLiveCondition_NoPlaceholder to consume the DCL message
  the NativeAlarmActor sends at startup
2026-06-17 15:08:37 -04:00
Joseph Doherty cc017aabfc feat(debugview): DV-3 DebugTreeNode model + attribute tree builder
Pure path-split composition forest from streamed AttributeValueChanged: branch dedupe by accumulated prefix, ordinal child sort, post-order bad-quality roll-up, case-insensitive name-contains filter (keeps ancestors). BuildAlarmTree left as a NotImplementedException stub for DV-4. 16 unit tests cover structure + roll-up + filter.
2026-06-17 15:01:02 -04:00
Joseph Doherty 5d07ac24cb feat(debugview): DV-2 emit placeholder rows for quiet native alarm bindings
InstanceActor.BuildAlarmStatesSnapshot now adds an IsConfiguredPlaceholder
row per configured native source binding that currently has no live
condition, so the Debug View tree can show the binding node even when
quiet. A binding is "quiet" when no retained AlarmStateChanged carries its
NativeSourceCanonicalName (DV-1).

Kind derivation: reuses the exact nativeKind value already computed via
ResolveNativeKind(nativeSource.ConnectionName) at the NativeAlarmActor
creation site and stored in a new _nativeAlarmKinds dictionary -- the
accurate per-binding kind (NativeOpcUa vs NativeMxAccess), not the
NativeOpcUa default.

Tests: Snapshot_QuietNativeBinding_EmitsPlaceholder,
Snapshot_NativeBindingWithLiveCondition_NoPlaceholder.
2026-06-17 15:00:20 -04:00
Joseph Doherty 899ad6e106 feat(debugview): DV-1 native-binding linkage on AlarmStateChanged contract chain
Add two additive init-only fields to AlarmStateChanged so the Debug View can
nest live native conditions under their configured source-binding node:
  - NativeSourceCanonicalName (binding canonical name, e.g. "Motor1.MotorAlarms")
  - IsConfiguredPlaceholder (quiet-binding placeholder flag; default false)

Flow on BOTH cross-process paths:
  - Live: proto AlarmStateUpdate fields 22/23 -> StreamRelayActor packs ->
    SiteStreamGrpcClient unpacks (regenerated SiteStreamGrpc/Sitestream.cs).
  - Snapshot (Newtonsoft): record defaults carry through; no special handling.

NativeAlarmActor.Emit now stamps NativeSourceCanonicalName = _source.CanonicalName.
Additive-only: no existing positional constructor or wire frame changed.

Tests: StreamRelayActorTests round-trips both fields pack->unpack;
NativeAlarmActorTests asserts the emitted event carries the binding canonical name.
2026-06-17 14:52:03 -04:00
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
Joseph Doherty 811d72255c docs(plans): design for Debug View tabs + hierarchy trees
Tabbed Attributes/Alarms view with collapsible composition trees derived
from path-qualified canonical names; all configured alarms (computed +
native) shown with current status; branch-level status roll-up; native
source bindings as nodes with conditions nested. Site snapshot enriched
with placeholder rows for idle native sources via an additive
IsConfiguredPlaceholder field on AlarmStateChanged.
2026-06-17 14:06:08 -04:00
22 changed files with 2190 additions and 300 deletions
@@ -0,0 +1,98 @@
# Debug View — Tabs + Hierarchy Trees Design
**Date:** 2026-06-17
**Status:** Approved (brainstorming) — ready for implementation planning
**Component:** #9 Central UI (Debug View page) + #3 Site Runtime (snapshot enrichment) + #16 Commons (Streaming message contract)
**Base:** branch `debugview-tabs-trees` off `origin/main` (670b607)
## Goal
Restructure the Central UI **Debug View** page (`/deployment/debug-view`) from two side-by-side, change-driven flat tables into a **tabbed** layout — an **Attributes** tab and an **Alarms** tab — where each tab presents the instance's data as a **collapsible hierarchy tree**. Every configured alarm (computed *and* native) is shown with its current status, even when Normal/quiet. The tree hierarchy is derived from the instance's composition.
## Background — how the page works today
(Findings from code inspection; file:line references are against `origin/main` 670b607.)
- **Per-instance, change-driven.** The page connects to one selected instance via `DebugStreamService.StartStreamAsync` (`src/ZB.MOM.WW.ScadaBridge.Communication/DebugStreamService.cs`). It receives an **initial snapshot** (`DebugViewSnapshot`, via ClusterClient ask) plus **live events** (`AttributeValueChanged` / `AlarmStateChanged`, via gRPC stream). UI: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor`.
- **Two flat tables.** Attributes (left) and Alarm States (right), each a flat table filtered by name, **capped at 200 rows**, with auto-scroll and a "Clear" button — a *change-feed* model. Latest value per name held in `Dictionary<string, …>` keyed by `AttributeName` / `AlarmName`.
- **No runtime child instances.** Composition is **flattened at deploy time** (`src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs`). Composed members carry **path-qualified canonical names** of the form `[ModuleInstanceName].[MemberName]` (and nest, e.g. `Motor1.Compressor.Pump`). The hierarchy is fully recoverable by splitting these names on `.`.
- **Attributes are already "all configured."** The site seeds `InstanceActor._attributes` from `FlattenedConfiguration` defaults + static overrides, so the snapshot already carries every configured attribute (not just changed ones).
- **Computed alarms are already "all configured."** `InstanceActor.BuildAlarmStatesSnapshot()` (`src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs`) iterates `_alarmActors.Keys` and emits a bare `Normal` row for every computed alarm that hasn't fired.
- **The one gap: idle native sources.** Native alarm source bindings (`_nativeAlarmActors`, configured via `ResolvedNativeAlarmSource`) produce a row only once a live condition arrives. A configured-but-quiet native source currently shows nothing.
- **Message shapes.**
- `AttributeValueChanged(InstanceUniqueName, AttributePath, AttributeName, object? Value, string Quality, DateTimeOffset Timestamp)`.
- `AlarmStateChanged(InstanceUniqueName, AlarmName, AlarmState State, int Priority, DateTimeOffset Timestamp)` plus init-only enrichment: `Level`, `Message`, `Kind` (`Computed`/`NativeOpcUa`/`NativeMxAccess`), `Condition` (`AlarmConditionState`), and native-only `SourceReference`, `AlarmTypeName`, `Category`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime`, `CurrentValue`, `LimitValue`.
- **Neither carries a separate parent/child/path field** beyond the path-qualified name — the canonical name *is* the locator.
## Confirmed decisions
1. **Hierarchy from canonical names.** The tree is a presentation of the flattened names already in the snapshot: instance = root; root-level attributes/alarms are direct leaves; each composed module (nested as deep as composition goes) is a collapsible branch. No new "child instance" concept.
2. **Extend the site snapshot for native too.** A small site-side change emits a placeholder row for each configured native alarm source that is currently quiet, so **every** configured alarm/source appears as a tree node from the moment you connect.
3. **Roll up status to branch headers.** A collapsed module branch shows an aggregate badge — on the Alarms tree: worst descendant alarm state + active count; on the Attributes tree: a bad/uncertain-quality indicator if any descendant is off-Good.
4. **Native binding = node, conditions nest under it.** A native source binding is a tree node (placed by its canonical name); its current live conditions appear as child rows; a quiet binding shows a single "no active conditions" row; the binding header rolls up the worst condition.
5. **Replace the chronological change-feed.** The cap-200 / auto-scroll / Clear semantics are dropped in favour of a stable tree that updates values **in place**. A per-tab filter box is retained (prunes the tree to matching leaves + their ancestor branches, auto-expanding matches).
## Approach — hybrid reusable tree
A single recursive **`TreeView` shell** component owns the shared, harder parts — expand/collapse, branch roll-up, filter pruning — and accepts a **per-tab leaf render fragment** for the bespoke content (attribute leaf vs. alarm leaf). The flat-list → node-forest transform is a **pure static helper** so the gnarly logic is unit-testable without rendering.
*Alternatives rejected:*
- *Two fully-bespoke trees* — duplicates expand/filter/roll-up logic across both tabs.
- *Single indented table faking a tree* — cannot collapse or roll up; fails decisions 3 and 5.
## Design
### 1. Page restructure (`DebugView.razor`)
- The two side-by-side cards become a **tabbed layout** — custom Bootstrap `nav-tabs` (no third-party component libs, per project rule) — with an **Attributes** tab and an **Alarms** tab, each hosting a `TreeView`.
- Connection strip, site/instance selectors, live/snapshot badges, auto-reconnect-from-localStorage, and site-scope checks are **unchanged**.
- Drop cap-200 / auto-scroll / Clear. Keep a per-tab **filter box**.
- Values update **in place** on live events; the tree structure is stable for the life of the connection.
### 2. Tree model & building (pure helper + `TreeView.razor`)
- `DebugTreeNode { string Path; string Segment; List<DebugTreeNode> Children; LeafPayload? Leaf; }` — `Path` is the full canonical prefix (stable key for expand-state + diffing), `Segment` the display label.
- **Build:** split each item's `CanonicalName` on `.`; walk/create branch nodes; attach the leaf at the terminal segment. Children sorted by name; root = the instance.
- **Attributes tab:** leaf payload = `AttributeValueChanged` → value / quality badge / timestamp (today's columns rendered as a leaf row).
- **Alarms tab:**
- Computed alarm → leaf (state / kind / severity / level), reusing today's badge helpers (`GetAlarmStateBadge`, `GetAlarmLevelBadge`, `GetKindBadge`, `FormatKind`, `FormatLevel`, `BuildAlarmTooltip`).
- Native source binding → branch node (placed by canonical name) whose live conditions are child rows keyed by `SourceReference`; a quiet binding (placeholder row) renders "no active conditions".
- **Roll-up** (computed during build, stored on the branch node):
- Alarm branch → worst descendant `AlarmState` (Active dominates) + active-count badge.
- Attribute branch → bad/uncertain-quality indicator if any descendant attribute is off-Good.
- **Expand state:** the page holds a `HashSet<string>` of expanded `Path`s; default-expand the root + first level; an active filter auto-expands matching paths. State survives live re-renders (keyed by `Path`).
- **Threading:** the build + in-place merge run on the render thread via the existing `SafeInvokeAsync` marshalling (preserves CentralUI-009 dispose guard and CentralUI-021 dictionary single-thread access).
### 3. Site-side snapshot enrichment (native placeholders)
- Extend `InstanceActor.BuildAlarmStatesSnapshot()`: after the existing computed-alarm fallback loop, iterate the configured native source bindings (`_nativeAlarmActors.Keys`) and, for any binding with **no live condition** currently represented, emit a **placeholder** `AlarmStateChanged` (native `Kind`, `State = Normal`).
- Add an **additive** init-only field to `AlarmStateChanged`: `bool IsConfiguredPlaceholder { get; init; } = false;` — additive-only message-contract evolution; default `false` keeps every existing positional constructor call and serialized wire frame valid (Newtonsoft cross-process serializer). The UI uses it to render "binding present, no active conditions" distinctly from a real Normal condition.
- **Detail to verify during planning:** whether native live events key by binding canonical name or per-condition (`AlarmName` vs `SourceReference`). This decides exactly how a placeholder is suppressed once a live condition arrives, and how the frontend replaces a placeholder node with condition child-rows. Inspect `InstanceActor` native-event handling + `NativeAlarmActor` emission before writing the enrichment.
### 4. Data flow / real-time
- **Transport unchanged:** snapshot via ClusterClient ask, live events via gRPC. The frontend merges each event into the attribute/alarm forests in place. When the last condition for a native binding clears, the binding reverts to its "no active conditions" presentation (zero condition child-rows).
### 5. Error handling
- Disconnect / terminated / instance-not-found paths unchanged.
- Empty instance (no attributes/alarms) → tree shows just the root node with an empty-state hint.
## Testing
- **Site Runtime** (`tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests`): `BuildAlarmStatesSnapshot` emits a placeholder for a configured-but-quiet native source binding, and does **not** duplicate/placeholder a binding that already has a live condition.
- **Central UI** (`tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests`): pure tree-build helper tests — nesting from canonical names, worst-state roll-up, filter pruning to matches + ancestors; plus a leaf-render smoke test for each tab.
- **Playwright** (one): connect to an instance → switch to the Alarms tab → assert configured alarms are grouped under their module branch → expand a branch → assert the roll-up badge is visible.
## Docs to update (with the implementation)
- `docs/requirements/Component-CentralUI.md` — Debug View section (tabs + trees, roll-up, native placeholders).
- `docs/requirements/Component-SiteRuntime.md` — snapshot enrichment for idle native sources.
- Streaming message-contract note for the additive `IsConfiguredPlaceholder` field (additive-only evolution).
- No new component → `README.md` / `CLAUDE.md` component table unchanged (stays at 25).
## Out of scope / non-goals
- No new transport, no per-site (multi-instance) debug session — still one instance per connection.
- No chronological change-feed / history (replaced by the live tree). KPI/history trends are a separate effort (M6).
- No write-back / alarm acknowledgement from Debug View — it remains read-only.
@@ -0,0 +1,336 @@
# 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 ≤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.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.
@@ -0,0 +1,20 @@
{
"planPath": "docs/plans/2026-06-17-debugview-tabs-trees.md",
"tasks": [
{"id": 170, "subject": "DV-1: Native-binding linkage — additive AlarmStateChanged contract chain", "classification": "high-risk", "status": "pending"},
{"id": 171, "subject": "DV-2: Site snapshot — placeholder rows for idle native source bindings", "classification": "standard", "status": "pending", "blockedBy": [170]},
{"id": 172, "subject": "DV-3: DebugTreeNode model + attribute-tree builder", "classification": "standard", "status": "pending", "blockedBy": [170]},
{"id": 173, "subject": "DV-4: Alarm-tree builder — computed leaves + native binding grouping + roll-up", "classification": "standard", "status": "pending", "blockedBy": [172, 170]},
{"id": 174, "subject": "DV-5: DebugView page — tabs + two TreeViews + in-place updates", "classification": "standard", "status": "pending", "blockedBy": [173]},
{"id": 175, "subject": "DV-6: Documentation — CentralUI + SiteRuntime + streaming contract", "classification": "small", "status": "pending", "blockedBy": [173]},
{"id": 176, "subject": "DV-7: Integration — full build, docker rebuild, Playwright, smoke", "classification": "high-risk", "status": "pending", "blockedBy": [174, 175, 171]}
],
"waves": [
{"wave": 1, "tasks": [170]},
{"wave": 2, "tasks": [171, 172]},
{"wave": 3, "tasks": [173]},
{"wave": 4, "tasks": [174, 175]},
{"wave": 5, "tasks": [176]}
],
"lastUpdated": "2026-06-17"
}
+28 -10
View File
@@ -136,20 +136,38 @@ Central cluster only. Sites have no user interface.
- Ongoing events (`AttributeValueChanged`, `AlarmStateChanged`) flow via the gRPC stream directly to the bridge actor — they do not pass through ClusterClient.
- Events are delivered to the Blazor component via callbacks, which call `InvokeAsync(StateHasChanged)` to push UI updates through the built-in SignalR circuit.
- A pulsing "Live" indicator replaces the static "Connected" badge when streaming is active.
- Stream includes attribute values formatted as `[InstanceUniqueName].[AttributePath].[AttributeName]` and alarm states formatted as `[InstanceUniqueName].[AlarmName]`.
- Subscribe-on-demand — stream starts when opened, stops when closed.
- Read-only per-instance view (one instance per connection); no alarm acknowledgement is available from Debug View.
#### Alarm Table (Computed + Native)
#### Tabbed Layout
The DebugView alarm table is the **only** runtime surface for native OPC UA Alarms & Conditions and MxAccess Gateway alarms (no dedicated operator/alarm-summary page). Native alarms are a **read-only mirror** of source-reported state — the source system owns the alarm lifecycle (ack / shelve / suppress), so the table never offers ack-back or any command action. Both enriched `AlarmStateChanged` events (live, via the gRPC stream) and the initial `DebugViewSnapshot` (via ClusterClient) carry the unified alarm shape, so native alarms appear on the first paint and update in place. The table is a custom Blazor + Bootstrap component (no third-party grid).
The Debug View page uses a **two-tab layout** — an **Attributes** tab and an **Alarms** tab — replacing the earlier side-by-side flat tables. Each tab renders its data as a **collapsible hierarchy tree** using the existing generic `TreeView<TItem>` component.
- **Kind column** — a badge distinguishing **Computed** alarms from native ones (an **OPC UA** or **MxAccess** badge), driven by the event's `AlarmKind` discriminator.
- **Sev column** — the unified **0–1000 severity** (`AlarmConditionState.Severity`) shown for every row. Computed rows surface their integer priority on the same scale.
- **Source reference subtitle** — for native rows, the `SourceReference` (e.g. `Tank01.Level.HiHi`) renders as a **monospace subtitle under the alarm name**. Computed rows have no subtitle and render exactly as before this change.
- **State cell composite badges** — the orthogonal condition sub-states roll up into badges shown beside the active/normal state: **Unacked**, **Shelved**, and **Suppressed** appear only when the corresponding `AlarmConditionState` flag is set. Computed alarms are auto-acked and never shelved/suppressed, so they show none of these.
- **Row tooltip** — hovering a row surfaces the native metadata that does not warrant its own column: alarm type (`AlarmTypeName`), category, operator user and comment (source-supplied ack metadata, display-only), original raise time, and the current/limit value.
- **Filter** — the existing alarm filter additionally matches the native `SourceReference` (in addition to the alarm name), so operators can find a mirrored condition by its source path.
- **Computed alarms render unchanged** — no Kind badge styling change, no subtitle, no new state badges beyond what the unified model implies; the enrichment is purely additive for native rows.
**Tree hierarchy** — the hierarchy is derived from the path-qualified canonical names already present in the debug snapshot (e.g. `Motor1.Compressor.Pump`). The instance is the root node; composed modules are collapsible branch nodes; individual attributes (Attributes tab) or alarms/native-source bindings (Alarms tab) are leaf nodes.
**Branch-level status roll-up** — a collapsed branch shows a summary badge so the operator can assess health without expanding:
- *Alarms tab*: worst alarm state among descendants + count of active conditions.
- *Attributes tab*: a bad/uncertain-quality indicator when any descendant attribute has non-Good quality.
#### Attributes Tab
Displays all attribute values for the instance in the collapsible tree. Each leaf shows the attribute value, quality, and timestamp. The existing `[InstanceUniqueName].[AttributePath].[AttributeName]` canonical path is the basis for the tree structure.
#### Alarms Tab (Computed + Native)
The Alarms tab is the **only** runtime surface for native OPC UA Alarms & Conditions and MxAccess Gateway alarms (no dedicated operator/alarm-summary page). **All configured alarms are shown with current status, even when quiet/Normal** — no alarm is hidden simply because it has not fired.
Both enriched `AlarmStateChanged` events (live, via the gRPC stream) and the initial `DebugViewSnapshot` (via ClusterClient) carry the unified alarm shape, so all alarms appear on the first paint and update in place. Native alarms are a **read-only mirror** — the source system owns the alarm lifecycle (ack / shelve / suppress); the Debug View never offers ack-back or any command action.
**Native source binding nodes** — a configured native alarm source binding is itself a tree node, placed by its canonical name in the hierarchy. Its live mirrored conditions nest as child rows beneath it. A quiet binding (no currently active conditions) renders a "no active conditions" placeholder row — it is never hidden, so the operator can see every configured binding regardless of alarm state. This requires the backend to emit a placeholder `AlarmStateChanged` with `IsConfiguredPlaceholder = true` for each idle binding (see Component-SiteRuntime.md — Instance Actor Wiring). The `NativeSourceCanonicalName` field on `AlarmStateChanged` events identifies which binding node a live condition belongs to.
Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms, and the alarm itself for computed alarms):
- **Kind badge** — distinguishes **Computed** alarms from native ones (**OPC UA** or **MxAccess**), driven by the event's `AlarmKind` discriminator.
- **Sev** — the unified **0–1000 severity** (`AlarmConditionState.Severity`). Computed rows surface their integer priority on the same scale.
- **Source reference subtitle** — for native rows, the `SourceReference` (e.g. `Tank01.Level.HiHi`) renders as a monospace subtitle. Computed rows have no subtitle.
- **State badges** — orthogonal condition sub-states: **Unacked**, **Shelved**, and **Suppressed** appear only when the corresponding `AlarmConditionState` flag is set. Computed alarms are auto-acked and never shelved/suppressed.
- **Row tooltip** — surfaces native metadata not warranting its own column: `AlarmTypeName`, category, operator user and comment, original raise time, current/limit value.
- **Computed alarms render unchanged** from the prior flat-table style; the enrichment is purely additive for native rows.
### Parked Message Management (Deployment Role)
- Query sites for parked messages (external system calls, cached DB writes). (Parked notifications are managed centrally on the Notification Outbox page, not here.)
+1 -1
View File
@@ -140,7 +140,7 @@ Commons must define the shared DTOs and message contracts used for inter-compone
- **Instance Lifecycle DTOs**: Disable, enable, delete commands and responses.
- **Health DTOs**: Health check results, site status reports, heartbeat messages. Includes script error rates and alarm evaluation error rates.
- **Communication DTOs**: Site identity, connection state, routing metadata.
- **Attribute Stream DTOs**: Attribute value change messages (instance name, attribute path, value, quality, timestamp) and alarm state change messages (instance name, alarm name, state, priority, timestamp) for the site-wide Akka stream. The alarm state change message (`AlarmStateChanged`) is **additively enriched** to carry both computed and native alarms on one shape: an `AlarmKind` discriminator, the unified `AlarmConditionState`, and native metadata (`SourceReference`, `AlarmTypeName`, `Category`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime`, `CurrentValue`, `LimitValue`) — defaulted/empty for computed alarms. Subject to the additive-only evolution rules in REQ-COM-5a, since it crosses the site→central gRPC stream.
- **Attribute Stream DTOs**: Attribute value change messages (instance name, attribute path, value, quality, timestamp) and alarm state change messages (instance name, alarm name, state, priority, timestamp) for the site-wide Akka stream. The alarm state change message (`AlarmStateChanged`) is **additively enriched** to carry both computed and native alarms on one shape: an `AlarmKind` discriminator, the unified `AlarmConditionState`, and native metadata (`SourceReference`, `AlarmTypeName`, `Category`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime`, `CurrentValue`, `LimitValue`) — defaulted/empty for computed alarms. Two additional additive fields support the Debug View tabbed-tree layout: **`NativeSourceCanonicalName`** (`string?`, default `null`) — the canonical name of the native source binding a condition belongs to, stamped on every native-alarm event and on idle-binding placeholder rows; and **`IsConfiguredPlaceholder`** (`bool`, default `false`) — `true` only on synthetic placeholder rows emitted by the Instance Actor for quiet native source bindings, never forwarded as operational alarms. Both fields are mirrored additively on the gRPC `AlarmStateUpdate` proto as fields 22 and 23 respectively, packed/unpacked by `StreamRelayActor` and `SiteStreamGrpcClient`. Subject to the additive-only evolution rules in REQ-COM-5a, since `AlarmStateChanged` crosses the site→central gRPC stream.
- **Native Alarm DTOs** (`Messages/DataConnection/`): the read-only native alarm mirror messages between the Site Runtime and the Data Connection Layer — `SubscribeAlarmsRequest` / `SubscribeAlarmsResponse` (subscribe a source binding; response carries success + optional error), `UnsubscribeAlarmsRequest`, `NativeAlarmTransitionUpdate` (`ConnectionName`, `Transition` — one routed `NativeAlarmTransition`, including snapshot replay), and `NativeAlarmSourceUnavailable` (`ConnectionName`, `SourceReference`, `Timestamp` — the feed dropped on connection loss).
- **Debug View DTOs**: Subscribe/unsubscribe requests, one-shot snapshot request (`DebugSnapshotRequest`), initial snapshot, stream filter criteria.
- **Script Execution DTOs**: Script call requests (with recursion depth), return values, error results.
@@ -281,6 +281,8 @@ The Instance Actor owns native-alarm setup alongside its computed Script and Ala
- **Spawning**: for each entry in `_configuration.NativeAlarmSources`, the Instance Actor spawns a `NativeAlarmActor`. Spawning is **skipped when there is no DCL manager** (e.g., debug/test contexts with no data connections), since native alarms require a live source subscription.
- **Kind derivation**: the `AlarmKind` passed to each `NativeAlarmActor` is derived from the bound connection's protocol — `Mx*` protocols → `NativeMxAccess`, otherwise → `NativeOpcUa`.
- **Latest-event retention**: the Instance Actor retains the latest enriched `AlarmStateChanged` per alarm name in `_latestAlarmEvents`. The DebugView snapshot is built from this map so it carries the **unified condition view plus native metadata** for both computed and native alarms. Computed alarms that have not yet produced an event fall back to a **Normal projection** so the snapshot is complete.
- **Idle native source binding placeholders**: `BuildAlarmStatesSnapshot()` additionally emits one placeholder `AlarmStateChanged` for each configured native alarm source binding that currently has **no live conditions** in `_latestAlarmEvents`. The placeholder has `IsConfiguredPlaceholder = true` and carries the binding's canonical name in `NativeSourceCanonicalName`. The Instance Actor maintains a `_nativeAlarmKinds` map (`sourceCanonicalName → AlarmKind`) populated at spawn time to stamp the correct `Kind` on each placeholder. This ensures the Debug View Alarms tab shows every configured binding as a tree node even when quiet.
- **`NativeSourceCanonicalName` on live events**: every `AlarmStateChanged` emitted by a `NativeAlarmActor` stamps its source binding's canonical name in `NativeSourceCanonicalName`. The Debug View uses this field to place each live condition under the correct native-source binding node in the tree.
- **Reset semantics**: `_latestAlarmEvents` and the mirrored native state are cleared on redeploy/undeploy (same trigger as static-override reset) but rehydrate from SQLite on failover.
---
@@ -304,6 +306,10 @@ The `AlarmStateChanged` message published by both Alarm Actors and Native Alarm
- **`Condition`** (`AlarmConditionState`): the unified condition view. Computed alarms supply a computed default; native alarms carry the mirrored source condition.
- **Native metadata** (populated for native alarms; defaulted/empty for computed): `SourceReference`, `AlarmTypeName`, `Category`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime`, `CurrentValue`, `LimitValue`.
- **Computed-alarm projection**: computed alarms are surfaced as **auto-acknowledged** with `Severity = Priority`, so a single enriched shape carries both computed and native alarms onto the stream and into the DebugView snapshot.
- **`NativeSourceCanonicalName`** (`string?`): the canonical name of the native alarm source binding a native condition belongs to (e.g. `Motor1.NativeAlarms`). `null` for computed alarms. Stamped by the `NativeAlarmActor` on every emitted event; also carried on idle-binding placeholder rows emitted by the Instance Actor.
- **`IsConfiguredPlaceholder`** (`bool`): `true` for a synthetic row emitted by the Instance Actor to represent a configured native source binding that currently has no live conditions. These rows are never persisted or forwarded as operational alarms — they exist solely so the Debug View can render every configured binding as a tree node. `false` on all other rows.
Both fields are **additive-only** (init-only with defaults: `null` / `false`) and are mirrored additively on the gRPC `AlarmStateUpdate` proto as fields 22 (`native_source_canonical_name`, `string`) and 23 (`is_configured_placeholder`, `bool`). `StreamRelayActor` and `SiteStreamGrpcClient` pack/unpack these fields without touching existing fields, preserving backward compatibility across version-skew boundaries.
The enriched message flows Instance Actor → site-wide Akka stream → `SiteStreamManager` → `SiteStreamGrpcServer` and is streamed to central as the gRPC `AlarmStateUpdate` event (see [Component-Communication.md](Component-Communication.md)).
@@ -0,0 +1,282 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
/// <summary>
/// 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
/// <see cref="DebugTreeNode"/>. Path-qualified canonical names are split on
/// <c>'.'</c> to derive branch nodes; the terminal segment carries the payload.
/// </summary>
public static class DebugTreeBuilder
{
private const char Separator = '.';
/// <summary>
/// Build the attribute composition forest.
/// </summary>
/// <param name="attributes">Latest attribute value events (one per attribute).</param>
/// <param name="filter">
/// Optional name-contains filter (case-insensitive, matched against
/// <see cref="AttributeValueChanged.AttributeName"/>). Null/empty/whitespace
/// keeps everything; matching leaves carry along their ancestor branches.
/// </param>
/// <remarks>
/// The caller is expected to pass at most one <see cref="AttributeValueChanged"/>
/// per <see cref="AttributeValueChanged.AttributeName"/>. The DebugView page
/// enforces this by keying a dictionary on the attribute name before calling this
/// method. Passing duplicate names would produce sibling leaves with identical
/// keys under the same parent branch.
/// </remarks>
public static IReadOnlyList<DebugTreeNode> BuildAttributeTree(
IEnumerable<AttributeValueChanged> 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<DebugTreeNode>();
var branchByKey = new Dictionary<string, DebugTreeNode>(StringComparer.Ordinal);
foreach (var attr in attributes)
{
if (hasFilter &&
attr.AttributeName.IndexOf(filter!, StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
var segments = attr.AttributeName.Split(Separator);
var parent = WalkBranches(segments, segments.Length - 1, roots, branchByKey);
parent.Add(new DebugTreeNode
{
Key = attr.AttributeName,
Segment = segments[^1],
Attribute = attr,
});
}
SortAndRollUp(roots);
return roots.AsReadOnly();
}
/// <summary>
/// Build the alarm composition forest from a flat list of <see cref="AlarmStateChanged"/>.
/// </summary>
/// <param name="alarms">Latest alarm-state events (computed alarms, native conditions, placeholders).</param>
/// <param name="filter">
/// Optional name-contains filter (case-insensitive). An alarm is kept when the filter
/// is a substring of any of <see cref="AlarmStateChanged.AlarmName"/>,
/// <see cref="AlarmStateChanged.SourceReference"/>, or
/// <see cref="AlarmStateChanged.NativeSourceCanonicalName"/>. Null/empty/whitespace
/// keeps everything; kept items carry along their ancestor branches and, for native
/// conditions, their binding node.
/// </param>
/// <remarks>
/// Two flavours are placed differently:
/// <list type="bullet">
/// <item><b>Computed</b> (<see cref="AlarmKind.Computed"/>): <see cref="AlarmStateChanged.AlarmName"/>
/// is a path-qualified canonical name; the alarm becomes a LEAF at that path, exactly
/// like an attribute.</item>
/// <item><b>Native</b> (OPC UA / MxAccess): the condition belongs to a source BINDING
/// identified by <see cref="AlarmStateChanged.NativeSourceCanonicalName"/> — itself a
/// path-qualified canonical name placed as a branch with
/// <see cref="DebugTreeNode.IsNativeBinding"/> set. Non-placeholder events become
/// condition children (keyed <c>canonical::sourceReference</c>); a placeholder event
/// only materialises the (childless) binding node so the page can render
/// "no active conditions".</item>
/// </list>
/// </remarks>
public static IReadOnlyList<DebugTreeNode> BuildAlarmTree(
IEnumerable<AlarmStateChanged> alarms, string? filter)
{
ArgumentNullException.ThrowIfNull(alarms);
var hasFilter = !string.IsNullOrWhiteSpace(filter);
// Roots keyed by the first path segment; branch nodes (and native binding
// nodes) deduped by their full accumulated prefix / canonical name.
var roots = new List<DebugTreeNode>();
var branchByKey = new Dictionary<string, DebugTreeNode>(StringComparer.Ordinal);
foreach (var alarm in alarms)
{
if (hasFilter && !MatchesFilter(alarm, filter!))
{
continue;
}
if (alarm.Kind == AlarmKind.Computed)
{
AddComputedLeaf(alarm, roots, branchByKey);
}
else
{
AddNativeCondition(alarm, roots, branchByKey);
}
}
SortAndRollUpAlarms(roots);
return roots.AsReadOnly();
}
/// <summary>True when the filter is a case-insensitive substring of any of the alarm's name fields.</summary>
private static bool MatchesFilter(AlarmStateChanged alarm, string filter)
=> alarm.AlarmName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0
|| alarm.SourceReference.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0
|| alarm.NativeSourceCanonicalName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0;
/// <summary>Place a computed alarm as a leaf at its path-qualified canonical name.</summary>
private static void AddComputedLeaf(
AlarmStateChanged alarm,
List<DebugTreeNode> roots,
Dictionary<string, DebugTreeNode> branchByKey)
{
var segments = alarm.AlarmName.Split(Separator);
var parent = WalkBranches(segments, segments.Length - 1, roots, branchByKey);
parent.Add(new DebugTreeNode
{
Key = alarm.AlarmName,
Segment = segments[^1],
Alarm = alarm,
});
}
/// <summary>
/// Place a native condition under its source-binding branch node. A placeholder only
/// materialises the (childless) binding node; a real condition adds a child leaf keyed
/// <c>canonical::sourceReference</c>.
/// </summary>
private static void AddNativeCondition(
AlarmStateChanged alarm,
List<DebugTreeNode> roots,
Dictionary<string, DebugTreeNode> branchByKey)
{
if (string.IsNullOrEmpty(alarm.NativeSourceCanonicalName)) return;
var canonical = alarm.NativeSourceCanonicalName;
var segments = canonical.Split(Separator);
// Walk/create the ancestor branches above the binding node, then get-or-create
// the binding node itself (deduped by canonical name, flagged IsNativeBinding).
var parent = WalkBranches(segments, segments.Length - 1, roots, branchByKey);
if (!branchByKey.TryGetValue(canonical, out var binding))
{
binding = new DebugTreeNode
{
Key = canonical,
Segment = segments[^1],
IsNativeBinding = true,
};
branchByKey[canonical] = binding;
parent.Add(binding);
}
// Placeholder: leave the binding node childless ("no active conditions").
if (alarm.IsConfiguredPlaceholder)
{
return;
}
binding.Children.Add(new DebugTreeNode
{
Key = canonical + "::" + alarm.SourceReference,
Segment = alarm.SourceReference,
Alarm = alarm,
});
}
/// <summary>
/// Walk (creating as needed) the branch nodes for the first <paramref name="count"/>
/// segments, deduping by accumulated prefix, and return the child list at that depth.
/// </summary>
private static List<DebugTreeNode> WalkBranches(
string[] segments,
int count,
List<DebugTreeNode> roots,
Dictionary<string, DebugTreeNode> branchByKey)
{
var currentLevel = roots;
var prefix = string.Empty;
for (var i = 0; i < count; 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;
}
return currentLevel;
}
/// <summary>
/// Recursively sort each node's children by <see cref="DebugTreeNode.Segment"/>
/// (ordinal) and roll up alarm state post-order: <see cref="DebugTreeNode.WorstState"/>
/// is <see cref="AlarmState.Active"/> when any descendant alarm leaf/condition is Active
/// (placeholders excluded), and <see cref="DebugTreeNode.ActiveCount"/> tallies those
/// active descendants. A native binding node rolls up over its condition children the
/// same way. (Bad-quality is attribute-only and left default for alarm nodes.)
/// </summary>
private static void SortAndRollUpAlarms(List<DebugTreeNode> nodes)
{
nodes.Sort(static (a, b) => string.CompareOrdinal(a.Segment, b.Segment));
foreach (var node in nodes)
{
// A leaf's own contribution: Active and not a configured placeholder.
var ownActive = IsActiveAlarm(node);
var activeCount = ownActive ? 1 : 0;
if (node.Children.Count > 0)
{
SortAndRollUpAlarms(node.Children);
activeCount += node.Children.Sum(static c => c.ActiveCount);
}
node.ActiveCount = activeCount;
node.WorstState = activeCount > 0 ? AlarmState.Active : AlarmState.Normal;
}
}
private static bool IsActiveAlarm(DebugTreeNode node)
=> node.Alarm is { State: AlarmState.Active, IsConfiguredPlaceholder: false };
/// <summary>
/// Recursively sort each node's children by <see cref="DebugTreeNode.Segment"/>
/// (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.
/// </summary>
private static void SortAndRollUp(List<DebugTreeNode> 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);
}
@@ -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;
/// <summary>
/// A node in a Debug View composition tree. Attribute (and, in DV-4, alarm)
/// names are path-qualified canonical names (e.g. <c>Motor1.Compressor.Pump</c>);
/// the tree is derived by splitting those names on <c>'.'</c>. A node is either a
/// branch (composition member, no payload, has children) or a leaf (one attribute
/// or one alarm). Consumed by the generic <c>TreeView&lt;TItem&gt;</c> component.
/// </summary>
public sealed class DebugTreeNode
{
/// <summary>Full canonical path — stable TreeView key.</summary>
public required string Key { get; init; }
/// <summary>Display label (the last path segment).</summary>
public required string Segment { get; init; }
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 (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;
}
@@ -109,159 +109,163 @@
@if (_connected && _snapshot != null)
{
<div class="row">
@* Attribute Values *@
<div class="col-md-7">
<div class="card">
<div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<strong>Attribute Values</strong>
<small class="text-muted">@FilteredAttributeValues.Count latest (cap @MaxRows)</small>
</div>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 240px;"
placeholder="Filter by attribute…"
@bind="_attrFilter" @bind:event="oninput" aria-label="Filter attributes" />
<button class="btn btn-link btn-sm py-0" type="button"
@onclick="() => _attrScrollLocked = !_attrScrollLocked"
aria-pressed="@(_attrScrollLocked ? "true" : "false")"
aria-label="@(_attrScrollLocked ? "Scroll locked" : "Auto-scroll enabled")">
@(_attrScrollLocked ? "🔒 Locked" : "🔓 Auto-scroll")
</button>
<button class="btn btn-outline-secondary btn-sm" type="button"
@onclick="ClearAttributes" aria-label="Clear attribute table">Clear</button>
</div>
</div>
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<table class="table table-sm table-striped mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Attribute</th>
<th>Value</th>
<th>Quality</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody aria-live="polite" aria-atomic="false">
@foreach (var av in FilteredAttributeValues)
{
<tr>
<td class="small">@av.AttributeName</td>
<td class="small font-monospace"><strong>@ValueFormatter.FormatDisplayValue(av.Value)</strong></td>
<td>
<span class="badge @GetQualityBadge(av.Quality)"
aria-label="@($"Quality: {av.Quality}")">@av.Quality</span>
</td>
<td class="small text-muted"
title="@av.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
@av.Timestamp.LocalDateTime.ToString("HH:mm:ss")
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="card">
@* Tabbed layout — Attributes / Alarms, each a composition tree. *@
<div class="card-header py-0 px-0">
<ul class="nav nav-tabs card-header-tabs mx-2 mt-2" role="tablist">
<li class="nav-item" role="presentation">
<button type="button" role="tab"
class="nav-link @(_activeTab == "attributes" ? "active" : "")"
data-test="debug-tab-attributes"
aria-selected="@(_activeTab == "attributes" ? "true" : "false")"
@onclick='() => _activeTab = "attributes"'>
Attributes
</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" role="tab"
class="nav-link @(_activeTab == "alarms" ? "active" : "")"
data-test="debug-tab-alarms"
aria-selected="@(_activeTab == "alarms" ? "true" : "false")"
@onclick='() => _activeTab = "alarms"'>
Alarms
</button>
</li>
</ul>
</div>
@* Alarm States *@
<div class="col-md-5">
<div class="card">
<div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<strong>Alarm States</strong>
<small class="text-muted">@FilteredAlarmStates.Count latest (cap @MaxRows)</small>
</div>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 240px;"
placeholder="Filter by alarm…"
@bind="_alarmFilter" @bind:event="oninput" aria-label="Filter alarms" />
<button class="btn btn-link btn-sm py-0" type="button"
@onclick="() => _alarmScrollLocked = !_alarmScrollLocked"
aria-pressed="@(_alarmScrollLocked ? "true" : "false")"
aria-label="@(_alarmScrollLocked ? "Scroll locked" : "Auto-scroll enabled")">
@(_alarmScrollLocked ? "🔒 Locked" : "🔓 Auto-scroll")
</button>
<button class="btn btn-outline-secondary btn-sm" type="button"
@onclick="ClearAlarms" aria-label="Clear alarm table">Clear</button>
</div>
<div class="card-body">
@* ── Attributes tab ── *@
<div class="@(_activeTab == "attributes" ? "" : "d-none")"
data-test="debug-pane-attributes" role="tabpanel">
<div class="mb-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 280px;"
placeholder="Filter by attribute…"
@bind="_attrFilter" @bind:event="oninput" @bind:after="OnAttrFilterChanged"
aria-label="Filter attributes" />
</div>
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<table class="table table-sm table-striped mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Alarm</th>
<th>Kind</th>
<th>State</th>
<th>Sev</th>
<th>Level</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody aria-live="polite" aria-atomic="false">
@foreach (var alarm in FilteredAlarmStates)
<TreeView TItem="DebugTreeNode" @ref="_attrTree"
Items="AttributeForest"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.HasChildren"
KeySelector="n => n.Key"
InitiallyExpanded="n => true"
StorageKey="debugview.attrTree">
<NodeContent Context="node">
@if (node.Attribute is not null)
{
@* Leaf — one attribute value. *@
<span class="me-2">@node.Segment</span>
<span class="font-monospace me-2"><strong>@ValueFormatter.FormatDisplayValue(node.Attribute.Value)</strong></span>
<span class="badge @GetQualityBadge(node.Attribute.Quality) me-2"
aria-label="@($"Quality: {node.Attribute.Quality}")">@node.Attribute.Quality</span>
<span class="text-muted small"
title="@node.Attribute.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
@node.Attribute.Timestamp.LocalDateTime.ToString("HH:mm:ss")
</span>
}
else
{
@* Branch — composition member. *@
<span class="fw-semibold">@node.Segment</span>
@if (node.HasBadQuality)
{
<tr class="@GetAlarmRowClass(alarm.State)"
title="@BuildAlarmTooltip(alarm)">
<td class="small">
@alarm.AlarmName
@if (!string.IsNullOrEmpty(alarm.Message))
{
<span class="ms-1 text-info" aria-label="Has operator message">💬</span>
}
@if (!string.IsNullOrEmpty(alarm.SourceReference))
{
<div class="text-muted font-monospace text-truncate" style="font-size: .7rem; max-width: 180px;"
title="@alarm.SourceReference">@alarm.SourceReference</div>
}
</td>
<td>
<span class="badge @GetKindBadge(alarm.Kind)"
aria-label="@($"Alarm kind: {alarm.Kind}")">@FormatKind(alarm.Kind)</span>
</td>
<td>
<span class="badge @GetAlarmStateBadge(alarm.State)"
aria-label="@($"Alarm state: {alarm.State}")">@alarm.State</span>
@if (alarm.Kind != AlarmKind.Computed)
{
@if (alarm.Condition.Active && !alarm.Condition.Acknowledged)
{
<span class="badge bg-warning text-dark ms-1" aria-label="Unacknowledged">Unacked</span>
}
@if (alarm.Condition.Shelve != AlarmShelveState.Unshelved)
{
<span class="badge bg-info text-dark ms-1" title="@alarm.Condition.Shelve"
aria-label="@($"Shelved: {alarm.Condition.Shelve}")">Shelved</span>
}
@if (alarm.Condition.Suppressed)
{
<span class="badge bg-info text-dark ms-1" aria-label="Suppressed">Suppressed</span>
}
}
</td>
<td class="small font-monospace">@alarm.Condition.Severity</td>
<td>
@if (alarm.Level != AlarmLevel.None)
{
<span class="badge @GetAlarmLevelBadge(alarm.Level)"
aria-label="@($"Alarm level: {alarm.Level}")">@FormatLevel(alarm.Level)</span>
}
else
{
<span class="text-muted small">—</span>
}
</td>
<td class="small text-muted"
title="@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss")
</td>
</tr>
<span class="badge bg-warning text-dark ms-2"
aria-label="Contains bad-quality attributes"
title="One or more descendant attributes are off-Good quality">⚠ bad quality</span>
}
</tbody>
</table>
}
</NodeContent>
<EmptyContent>
<div class="text-muted small p-2">No attributes.</div>
</EmptyContent>
</TreeView>
</div>
@* ── Alarms tab ── *@
<div class="@(_activeTab == "alarms" ? "" : "d-none")"
data-test="debug-pane-alarms" role="tabpanel">
<div class="mb-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 280px;"
placeholder="Filter by alarm…"
@bind="_alarmFilter" @bind:event="oninput" @bind:after="OnAlarmFilterChanged"
aria-label="Filter alarms" />
</div>
<TreeView TItem="DebugTreeNode" @ref="_alarmTree"
Items="AlarmForest"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.HasChildren"
KeySelector="n => n.Key"
InitiallyExpanded="n => true"
StorageKey="debugview.alarmTree">
<NodeContent Context="node">
@if (node.Alarm is not null)
{
@* Leaf — computed alarm or native condition. *@
<span class="me-2"
title="@BuildAlarmTooltip(node.Alarm)">@node.Segment</span>
@if (!string.IsNullOrEmpty(node.Alarm.Message))
{
<span class="text-info me-1" aria-label="Has operator message">💬</span>
}
<span class="badge @GetAlarmStateBadge(node.Alarm.State) me-1"
aria-label="@($"Alarm state: {node.Alarm.State}")">@node.Alarm.State</span>
<span class="badge @GetKindBadge(node.Alarm.Kind) me-1"
aria-label="@($"Alarm kind: {node.Alarm.Kind}")">@FormatKind(node.Alarm.Kind)</span>
@if (node.Alarm.Kind != AlarmKind.Computed)
{
@if (node.Alarm.Condition.Active && !node.Alarm.Condition.Acknowledged)
{
<span class="badge bg-warning text-dark me-1" aria-label="Unacknowledged">Unacked</span>
}
@if (node.Alarm.Condition.Shelve != AlarmShelveState.Unshelved)
{
<span class="badge bg-info text-dark me-1" title="@node.Alarm.Condition.Shelve"
aria-label="@($"Shelved: {node.Alarm.Condition.Shelve}")">Shelved</span>
}
@if (node.Alarm.Condition.Suppressed)
{
<span class="badge bg-info text-dark me-1" aria-label="Suppressed">Suppressed</span>
}
}
<span class="font-monospace small text-muted me-1"
aria-label="@($"Severity: {node.Alarm.Condition.Severity}")">sev @node.Alarm.Condition.Severity</span>
@if (node.Alarm.Level != AlarmLevel.None)
{
<span class="badge @GetAlarmLevelBadge(node.Alarm.Level) me-1"
aria-label="@($"Alarm level: {node.Alarm.Level}")">@FormatLevel(node.Alarm.Level)</span>
}
}
else if (node.IsNativeBinding)
{
@* Native source binding branch. *@
<span class="fw-semibold me-2">@node.Segment</span>
@if (node.ActiveCount > 0)
{
<span class="badge @GetAlarmStateBadge(node.WorstState)"
aria-label="@($"{node.ActiveCount} active conditions")">@node.ActiveCount active</span>
}
else if (!node.HasChildren)
{
<span class="text-muted small">no active conditions</span>
}
}
else
{
@* Generic composition branch. *@
<span class="fw-semibold me-2">@node.Segment</span>
@if (node.WorstState == AlarmState.Active)
{
<span class="badge bg-danger"
aria-label="@($"{node.ActiveCount} active alarms")">@node.ActiveCount active</span>
}
}
</NodeContent>
<EmptyContent>
<div class="text-muted small p-2">No alarms.</div>
</EmptyContent>
</TreeView>
</div>
</div>
</div>
@@ -274,8 +278,6 @@
</div>
@code {
private const int MaxRows = 200;
[SupplyParameterFromQuery] public int? SiteId { get; set; }
[SupplyParameterFromQuery] public int? InstanceId { get; set; }
@@ -288,18 +290,25 @@
private bool _connecting;
private bool _connectedFromStorage;
// Which tab pane is visible — "attributes" (default) or "alarms".
private string _activeTab = "attributes";
private DebugViewSnapshot? _snapshot;
// Keyed dictionaries hold the latest value per attribute/alarm; insertion order
// is preserved so we can trim the oldest when the count exceeds MaxRows.
// Keyed dictionaries hold the latest value per attribute/alarm — the
// current-status source of truth the composition trees are built from.
private Dictionary<string, AttributeValueChanged> _attributeValues = new();
private Dictionary<string, AlarmStateChanged> _alarmStates = new();
// Filters and scroll-lock state per table.
// Per-tab name-contains filters.
private string _attrFilter = string.Empty;
private string _alarmFilter = string.Empty;
private bool _attrScrollLocked;
private bool _alarmScrollLocked;
private TreeView<DebugTreeNode>? _attrTree;
private TreeView<DebugTreeNode>? _alarmTree;
// Retained as a named enumeration target for DebugViewStreamRaceTests (reflection-based);
// not consumed in the Blazor markup — the trees render from AttributeForest/AlarmForest.
// Still enumerates the live _attributeValues dict on the render thread (CentralUI-021).
private IReadOnlyList<AttributeValueChanged> FilteredAttributeValues =>
string.IsNullOrWhiteSpace(_attrFilter)
? _attributeValues.Values.OrderBy(a => a.AttributeName).ToList()
@@ -308,14 +317,13 @@
.OrderBy(a => a.AttributeName)
.ToList();
private IReadOnlyList<AlarmStateChanged> FilteredAlarmStates =>
string.IsNullOrWhiteSpace(_alarmFilter)
? _alarmStates.Values.OrderBy(a => a.AlarmName).ToList()
: _alarmStates.Values
.Where(a => a.AlarmName.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase)
|| a.SourceReference.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase))
.OrderBy(a => a.AlarmName)
.ToList();
/// <summary>Attribute composition forest, rebuilt from the live latest-per-name dictionary.</summary>
private IReadOnlyList<DebugTreeNode> AttributeForest =>
DebugTreeBuilder.BuildAttributeTree(_attributeValues.Values, _attrFilter);
/// <summary>Alarm composition forest, rebuilt from the live latest-per-name dictionary.</summary>
private IReadOnlyList<DebugTreeNode> AlarmForest =>
DebugTreeBuilder.BuildAlarmTree(_alarmStates.Values, _alarmFilter);
private DebugStreamSession? _session;
private ToastNotification _toast = default!;
@@ -422,6 +430,15 @@
// No-op; selection is tracked via _selectedInstanceId binding
}
/// <summary>
/// When the attribute filter changes, expand the tree so any branches holding
/// freshly-matched leaves are visible (the builder already prunes non-matches).
/// </summary>
private void OnAttrFilterChanged() => _attrTree?.ExpandAll();
/// <summary>As <see cref="OnAttrFilterChanged"/>, for the alarm tree.</summary>
private void OnAlarmFilterChanged() => _alarmTree?.ExpandAll();
private async Task Connect()
{
if (_selectedInstanceId == 0 || _selectedSiteId == 0) return;
@@ -517,27 +534,16 @@
_toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000);
}
private void ClearAttributes()
{
_attributeValues.Clear();
}
private void ClearAlarms()
{
_alarmStates.Clear();
}
/// <summary>
/// Handles one debug-stream event. The callback is invoked on an Akka/gRPC
/// thread, but <see cref="_attributeValues"/>/<see cref="_alarmStates"/> are
/// <see cref="Dictionary{TKey,TValue}"/> instances also enumerated by the
/// render thread via <see cref="FilteredAttributeValues"/>/
/// <see cref="FilteredAlarmStates"/>. <c>Dictionary</c> is not thread-safe
/// (CentralUI-021): a write racing an enumeration can throw or corrupt the
/// buckets. The mutation (<see cref="UpsertWithCap"/>) is therefore
/// marshalled onto the renderer's dispatcher via <see cref="SafeInvokeAsync"/>
/// so every access to the dictionaries — read and write — happens on the
/// render thread.
/// render thread (the tree forests + <see cref="FilteredAttributeValues"/> are
/// built from them). <c>Dictionary</c> is not thread-safe (CentralUI-021): a
/// write racing an enumeration can throw or corrupt the buckets. The mutation
/// is therefore marshalled onto the renderer's dispatcher via
/// <see cref="SafeInvokeAsync"/> so every access to the dictionaries — read
/// and write — happens on the render thread.
/// </summary>
private void HandleStreamEvent(object evt)
{
@@ -550,39 +556,19 @@
switch (evt)
{
case AttributeValueChanged av:
UpsertWithCap(_attributeValues, av.AttributeName, av);
_attributeValues[av.AttributeName] = av;
break;
case AlarmStateChanged al:
UpsertWithCap(_alarmStates, al.AlarmName, al);
_alarmStates[al.AlarmName] = al;
break;
default:
// Unknown event type — no re-render needed.
return;
}
StateHasChanged();
});
}
/// <summary>
/// Replace or insert a value keyed by name, then trim the oldest entries
/// (queue-style) so the table size never exceeds MaxRows. Dictionary
/// preserves insertion order, so the first key is always the oldest.
/// <para>
/// Must be called on the render thread only (CentralUI-021) — see
/// <see cref="HandleStreamEvent"/>. The cap-trim loop is in the same
/// critical section as the upsert so the dictionary is never observed
/// over-capacity.
/// </para>
/// </summary>
private static void UpsertWithCap<T>(Dictionary<string, T> map, string key, T value)
{
map[key] = value;
while (map.Count > MaxRows)
{
var oldest = map.Keys.First();
map.Remove(oldest);
}
}
private static string GetQualityBadge(string quality) => quality switch
{
"Good" => "bg-success",
@@ -598,12 +584,6 @@
_ => "bg-secondary"
};
private static string GetAlarmRowClass(AlarmState state) => state switch
{
AlarmState.Active => "table-danger",
_ => ""
};
/// <summary>
/// Severity-tinted badge class for HiLo alarm levels. The critical bands
/// (HighHigh / LowLow) get the danger class; warning bands get amber.
@@ -71,4 +71,18 @@ public record AlarmStateChanged(
/// <summary>Limit/threshold value for native limit alarms (display-only); empty otherwise.</summary>
public string LimitValue { get; init; } = string.Empty;
/// <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; }
}
@@ -56,6 +56,15 @@ public class StreamRelayActor : ReceiveActor
private void HandleAlarmStateChanged(AlarmStateChanged msg)
{
// Placeholder rows (IsConfiguredPlaceholder) are a Debug View snapshot-only
// concept emitted by InstanceActor.BuildAlarmStatesSnapshot — they are never a
// real alarm transition and must not be relayed to the live gRPC stream (their
// timestamp may be DateTimeOffset.MinValue, the Protobuf Timestamp boundary).
if (msg.IsConfiguredPlaceholder)
{
return;
}
var protoEvent = new SiteStreamEvent
{
CorrelationId = _correlationId,
@@ -84,7 +93,9 @@ public class StreamRelayActor : ReceiveActor
? Timestamp.FromDateTimeOffset(msg.OriginalRaiseTime.Value)
: null,
CurrentValue = msg.CurrentValue ?? string.Empty,
LimitValue = msg.LimitValue ?? string.Empty
LimitValue = msg.LimitValue ?? string.Empty,
NativeSourceCanonicalName = msg.NativeSourceCanonicalName ?? string.Empty,
IsConfiguredPlaceholder = msg.IsConfiguredPlaceholder
}
};
@@ -253,7 +253,9 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
OperatorComment = evt.AlarmChanged.OperatorComment ?? string.Empty,
OriginalRaiseTime = evt.AlarmChanged.OriginalRaiseTime?.ToDateTimeOffset(),
CurrentValue = evt.AlarmChanged.CurrentValue ?? string.Empty,
LimitValue = evt.AlarmChanged.LimitValue ?? string.Empty
LimitValue = evt.AlarmChanged.LimitValue ?? string.Empty,
NativeSourceCanonicalName = evt.AlarmChanged.NativeSourceCanonicalName ?? string.Empty,
IsConfiguredPlaceholder = evt.AlarmChanged.IsConfiguredPlaceholder
},
_ => null
};
@@ -84,6 +84,8 @@ message AlarmStateUpdate {
google.protobuf.Timestamp original_raise_time = 19; // null when unknown
string current_value = 20;
string limit_value = 21;
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
}
// Audit Log (#23) telemetry: single lifecycle event ferried from a site SQLite
@@ -36,7 +36,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
"KAkSFgoOYXR0cmlidXRlX3BhdGgYAiABKAkSFgoOYXR0cmlidXRlX25hbWUY",
"AyABKAkSDQoFdmFsdWUYBCABKAkSJAoHcXVhbGl0eRgFIAEoDjITLnNpdGVz",
"dHJlYW0uUXVhbGl0eRItCgl0aW1lc3RhbXAYBiABKAsyGi5nb29nbGUucHJv",
"dG9idWYuVGltZXN0YW1wIrgEChBBbGFybVN0YXRlVXBkYXRlEhwKFGluc3Rh",
"dG9idWYuVGltZXN0YW1wIoEFChBBbGFybVN0YXRlVXBkYXRlEhwKFGluc3Rh",
"bmNlX3VuaXF1ZV9uYW1lGAEgASgJEhIKCmFsYXJtX25hbWUYAiABKAkSKQoF",
"c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy",
"aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90",
@@ -49,69 +49,70 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
"c2VyGBEgASgJEhgKEG9wZXJhdG9yX2NvbW1lbnQYEiABKAkSNwoTb3JpZ2lu",
"YWxfcmFpc2VfdGltZRgTIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3Rh",
"bXASFQoNY3VycmVudF92YWx1ZRgUIAEoCRITCgtsaW1pdF92YWx1ZRgVIAEo",
"CSK9BAoNQXVkaXRFdmVudER0bxIQCghldmVudF9pZBgBIAEoCRIzCg9vY2N1",
"cnJlZF9hdF91dGMYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w",
"Eg8KB2NoYW5uZWwYAyABKAkSDAoEa2luZBgEIAEoCRIWCg5jb3JyZWxhdGlv",
"bl9pZBgFIAEoCRIWCg5zb3VyY2Vfc2l0ZV9pZBgGIAEoCRIaChJzb3VyY2Vf",
"aW5zdGFuY2VfaWQYByABKAkSFQoNc291cmNlX3NjcmlwdBgIIAEoCRINCgVh",
"Y3RvchgJIAEoCRIOCgZ0YXJnZXQYCiABKAkSDgoGc3RhdHVzGAsgASgJEjAK",
"C2h0dHBfc3RhdHVzGAwgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFs",
"dWUSMAoLZHVyYXRpb25fbXMYDSABKAsyGy5nb29nbGUucHJvdG9idWYuSW50",
"MzJWYWx1ZRIVCg1lcnJvcl9tZXNzYWdlGA4gASgJEhQKDGVycm9yX2RldGFp",
"bBgPIAEoCRIXCg9yZXF1ZXN0X3N1bW1hcnkYECABKAkSGAoQcmVzcG9uc2Vf",
"c3VtbWFyeRgRIAEoCRIZChFwYXlsb2FkX3RydW5jYXRlZBgSIAEoCBINCgVl",
"eHRyYRgTIAEoCRIUCgxleGVjdXRpb25faWQYFCABKAkSGwoTcGFyZW50X2V4",
"ZWN1dGlvbl9pZBgVIAEoCRITCgtzb3VyY2Vfbm9kZRgWIAEoCSI8Cg9BdWRp",
"dEV2ZW50QmF0Y2gSKQoGZXZlbnRzGAEgAygLMhkuc2l0ZXN0cmVhbS5BdWRp",
"dEV2ZW50RHRvIicKCUluZ2VzdEFjaxIaChJhY2NlcHRlZF9ldmVudF9pZHMY",
"ASADKAkiiQMKFlNpdGVDYWxsT3BlcmF0aW9uYWxEdG8SHAoUdHJhY2tlZF9v",
"cGVyYXRpb25faWQYASABKAkSDwoHY2hhbm5lbBgCIAEoCRIOCgZ0YXJnZXQY",
"AyABKAkSEwoLc291cmNlX3NpdGUYBCABKAkSDgoGc3RhdHVzGAUgASgJEhMK",
"C3JldHJ5X2NvdW50GAYgASgFEhIKCmxhc3RfZXJyb3IYByABKAkSMAoLaHR0",
"cF9zdGF0dXMYCCABKAsyGy5nb29nbGUucHJvdG9idWYuSW50MzJWYWx1ZRIy",
"Cg5jcmVhdGVkX2F0X3V0YxgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1l",
"c3RhbXASMgoOdXBkYXRlZF9hdF91dGMYCiABKAsyGi5nb29nbGUucHJvdG9i",
"dWYuVGltZXN0YW1wEjMKD3Rlcm1pbmFsX2F0X3V0YxgLIAEoCzIaLmdvb2ds",
"ZS5wcm90b2J1Zi5UaW1lc3RhbXASEwoLc291cmNlX25vZGUYDCABKAkigAEK",
"FUNhY2hlZFRlbGVtZXRyeVBhY2tldBIuCgthdWRpdF9ldmVudBgBIAEoCzIZ",
"LnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxI3CgtvcGVyYXRpb25hbBgCIAEo",
"CzIiLnNpdGVzdHJlYW0uU2l0ZUNhbGxPcGVyYXRpb25hbER0byJKChRDYWNo",
"ZWRUZWxlbWV0cnlCYXRjaBIyCgdwYWNrZXRzGAEgAygLMiEuc2l0ZXN0cmVh",
"bS5DYWNoZWRUZWxlbWV0cnlQYWNrZXQiWwoWUHVsbEF1ZGl0RXZlbnRzUmVx",
"dWVzdBItCglzaW5jZV91dGMYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGlt",
"ZXN0YW1wEhIKCmJhdGNoX3NpemUYAiABKAUiXAoXUHVsbEF1ZGl0RXZlbnRz",
"UmVzcG9uc2USKQoGZXZlbnRzGAEgAygLMhkuc2l0ZXN0cmVhbS5BdWRpdEV2",
"ZW50RHRvEhYKDm1vcmVfYXZhaWxhYmxlGAIgASgIIlkKFFB1bGxTaXRlQ2Fs",
"bHNSZXF1ZXN0Ei0KCXNpbmNlX3V0YxgBIAEoCzIaLmdvb2dsZS5wcm90b2J1",
"Zi5UaW1lc3RhbXASEgoKYmF0Y2hfc2l6ZRgCIAEoBSJpChVQdWxsU2l0ZUNh",
"bGxzUmVzcG9uc2USOAoMb3BlcmF0aW9uYWxzGAEgAygLMiIuc2l0ZXN0cmVh",
"bS5TaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhYKDm1vcmVfYXZhaWxhYmxlGAIg",
"ASgIKlwKB1F1YWxpdHkSFwoTUVVBTElUWV9VTlNQRUNJRklFRBAAEhAKDFFV",
"QUxJVFlfR09PRBABEhUKEVFVQUxJVFlfVU5DRVJUQUlOEAISDwoLUVVBTElU",
"WV9CQUQQAypdCg5BbGFybVN0YXRlRW51bRIbChdBTEFSTV9TVEFURV9VTlNQ",
"RUNJRklFRBAAEhYKEkFMQVJNX1NUQVRFX05PUk1BTBABEhYKEkFMQVJNX1NU",
"QVRFX0FDVElWRRACKoUBCg5BbGFybUxldmVsRW51bRIUChBBTEFSTV9MRVZF",
"TF9OT05FEAASEwoPQUxBUk1fTEVWRUxfTE9XEAESFwoTQUxBUk1fTEVWRUxf",
"TE9XX0xPVxACEhQKEEFMQVJNX0xFVkVMX0hJR0gQAxIZChVBTEFSTV9MRVZF",
"TF9ISUdIX0hJR0gQBDK3AwoRU2l0ZVN0cmVhbVNlcnZpY2USVQoRU3Vic2Ny",
"aWJlSW5zdGFuY2USIS5zaXRlc3RyZWFtLkluc3RhbmNlU3RyZWFtUmVxdWVz",
"dBobLnNpdGVzdHJlYW0uU2l0ZVN0cmVhbUV2ZW50MAESRwoRSW5nZXN0QXVk",
"aXRFdmVudHMSGy5zaXRlc3RyZWFtLkF1ZGl0RXZlbnRCYXRjaBoVLnNpdGVz",
"dHJlYW0uSW5nZXN0QWNrElAKFUluZ2VzdENhY2hlZFRlbGVtZXRyeRIgLnNp",
"dGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gaFS5zaXRlc3RyZWFtLklu",
"Z2VzdEFjaxJaCg9QdWxsQXVkaXRFdmVudHMSIi5zaXRlc3RyZWFtLlB1bGxB",
"dWRpdEV2ZW50c1JlcXVlc3QaIy5zaXRlc3RyZWFtLlB1bGxBdWRpdEV2ZW50",
"c1Jlc3BvbnNlElQKDVB1bGxTaXRlQ2FsbHMSIC5zaXRlc3RyZWFtLlB1bGxT",
"aXRlQ2FsbHNSZXF1ZXN0GiEuc2l0ZXN0cmVhbS5QdWxsU2l0ZUNhbGxzUmVz",
"cG9uc2VCK6oCKFpCLk1PTS5XVy5TY2FkYUJyaWRnZS5Db21tdW5pY2F0aW9u",
"LkdycGNiBnByb3RvMw=="));
"CRIkChxuYXRpdmVfc291cmNlX2Nhbm9uaWNhbF9uYW1lGBYgASgJEiEKGWlz",
"X2NvbmZpZ3VyZWRfcGxhY2Vob2xkZXIYFyABKAgivQQKDUF1ZGl0RXZlbnRE",
"dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL",
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ",
"EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291",
"cmNlX3NpdGVfaWQYBiABKAkSGgoSc291cmNlX2luc3RhbmNlX2lkGAcgASgJ",
"EhUKDXNvdXJjZV9zY3JpcHQYCCABKAkSDQoFYWN0b3IYCSABKAkSDgoGdGFy",
"Z2V0GAogASgJEg4KBnN0YXR1cxgLIAEoCRIwCgtodHRwX3N0YXR1cxgMIAEo",
"CzIbLmdvb2dsZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjAKC2R1cmF0aW9uX21z",
"GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf",
"bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz",
"dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR",
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhl",
"Y3V0aW9uX2lkGBQgASgJEhsKE3BhcmVudF9leGVjdXRpb25faWQYFSABKAkS",
"EwoLc291cmNlX25vZGUYFiABKAkiPAoPQXVkaXRFdmVudEJhdGNoEikKBmV2",
"ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0byInCglJbmdl",
"c3RBY2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRzGAEgAygJIokDChZTaXRlQ2Fs",
"bE9wZXJhdGlvbmFsRHRvEhwKFHRyYWNrZWRfb3BlcmF0aW9uX2lkGAEgASgJ",
"Eg8KB2NoYW5uZWwYAiABKAkSDgoGdGFyZ2V0GAMgASgJEhMKC3NvdXJjZV9z",
"aXRlGAQgASgJEg4KBnN0YXR1cxgFIAEoCRITCgtyZXRyeV9jb3VudBgGIAEo",
"BRISCgpsYXN0X2Vycm9yGAcgASgJEjAKC2h0dHBfc3RhdHVzGAggASgLMhsu",
"Z29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSMgoOY3JlYXRlZF9hdF91dGMY",
"CSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjIKDnVwZGF0ZWRf",
"YXRfdXRjGAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIzCg90",
"ZXJtaW5hbF9hdF91dGMYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0",
"YW1wEhMKC3NvdXJjZV9ub2RlGAwgASgJIoABChVDYWNoZWRUZWxlbWV0cnlQ",
"YWNrZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1ZGl0",
"RXZlbnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFtLlNp",
"dGVDYWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gS",
"MgoHcGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5",
"UGFja2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2VfdXRj",
"GAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRjaF9z",
"aXplGAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2ZW50",
"cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3JlX2F2",
"YWlsYWJsZRgCIAEoCCJZChRQdWxsU2l0ZUNhbGxzUmVxdWVzdBItCglzaW5j",
"ZV91dGMYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhIKCmJh",
"dGNoX3NpemUYAiABKAUiaQoVUHVsbFNpdGVDYWxsc1Jlc3BvbnNlEjgKDG9w",
"ZXJhdGlvbmFscxgBIAMoCzIiLnNpdGVzdHJlYW0uU2l0ZUNhbGxPcGVyYXRp",
"b25hbER0bxIWCg5tb3JlX2F2YWlsYWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcK",
"E1FVQUxJVFlfVU5TUEVDSUZJRUQQABIQCgxRVUFMSVRZX0dPT0QQARIVChFR",
"VUFMSVRZX1VOQ0VSVEFJThACEg8KC1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1T",
"dGF0ZUVudW0SGwoXQUxBUk1fU1RBVEVfVU5TUEVDSUZJRUQQABIWChJBTEFS",
"TV9TVEFURV9OT1JNQUwQARIWChJBTEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoO",
"QWxhcm1MZXZlbEVudW0SFAoQQUxBUk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJN",
"X0xFVkVMX0xPVxABEhcKE0FMQVJNX0xFVkVMX0xPV19MT1cQAhIUChBBTEFS",
"TV9MRVZFTF9ISUdIEAMSGQoVQUxBUk1fTEVWRUxfSElHSF9ISUdIEAQytwMK",
"EVNpdGVTdHJlYW1TZXJ2aWNlElUKEVN1YnNjcmliZUluc3RhbmNlEiEuc2l0",
"ZXN0cmVhbS5JbnN0YW5jZVN0cmVhbVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNp",
"dGVTdHJlYW1FdmVudDABEkcKEUluZ2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0",
"cmVhbS5BdWRpdEV2ZW50QmF0Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQ",
"ChVJbmdlc3RDYWNoZWRUZWxlbWV0cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRl",
"bGVtZXRyeUJhdGNoGhUuc2l0ZXN0cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1",
"ZGl0RXZlbnRzEiIuc2l0ZXN0cmVhbS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0",
"GiMuc2l0ZXN0cmVhbS5QdWxsQXVkaXRFdmVudHNSZXNwb25zZRJUCg1QdWxs",
"U2l0ZUNhbGxzEiAuc2l0ZXN0cmVhbS5QdWxsU2l0ZUNhbGxzUmVxdWVzdBoh",
"LnNpdGVzdHJlYW0uUHVsbFNpdGVDYWxsc1Jlc3BvbnNlQiuqAihaQi5NT00u",
"V1cuU2NhZGFCcmlkZ2UuQ29tbXVuaWNhdGlvbi5HcnBjYgZwcm90bzM="));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.Quality), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateEnum), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.InstanceStreamRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.InstanceStreamRequest.Parser, new[]{ "CorrelationId", "InstanceUniqueName" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AttributeValueUpdate), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message", "Kind", "Active", "Acknowledged", "Confirmed", "ShelveState", "Suppressed", "SourceReference", "AlarmTypeName", "Category", "OperatorUser", "OperatorComment", "OriginalRaiseTime", "CurrentValue", "LimitValue" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message", "Kind", "Active", "Acknowledged", "Confirmed", "ShelveState", "Suppressed", "SourceReference", "AlarmTypeName", "Category", "OperatorUser", "OperatorComment", "OriginalRaiseTime", "CurrentValue", "LimitValue", "NativeSourceCanonicalName", "IsConfiguredPlaceholder" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId", "ParentExecutionId", "SourceNode" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null),
@@ -1171,6 +1172,8 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
originalRaiseTime_ = other.originalRaiseTime_ != null ? other.originalRaiseTime_.Clone() : null;
currentValue_ = other.currentValue_;
limitValue_ = other.limitValue_;
nativeSourceCanonicalName_ = other.nativeSourceCanonicalName_;
isConfiguredPlaceholder_ = other.isConfiguredPlaceholder_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
@@ -1460,6 +1463,36 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
}
}
/// <summary>Field number for the "native_source_canonical_name" field.</summary>
public const int NativeSourceCanonicalNameFieldNumber = 22;
private string nativeSourceCanonicalName_ = "";
/// <summary>
/// native binding canonical name; empty for computed
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string NativeSourceCanonicalName {
get { return nativeSourceCanonicalName_; }
set {
nativeSourceCanonicalName_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "is_configured_placeholder" field.</summary>
public const int IsConfiguredPlaceholderFieldNumber = 23;
private bool isConfiguredPlaceholder_;
/// <summary>
/// true for a quiet-binding placeholder row
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public bool IsConfiguredPlaceholder {
get { return isConfiguredPlaceholder_; }
set {
isConfiguredPlaceholder_ = value;
}
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
@@ -1496,6 +1529,8 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (!object.Equals(OriginalRaiseTime, other.OriginalRaiseTime)) return false;
if (CurrentValue != other.CurrentValue) return false;
if (LimitValue != other.LimitValue) return false;
if (NativeSourceCanonicalName != other.NativeSourceCanonicalName) return false;
if (IsConfiguredPlaceholder != other.IsConfiguredPlaceholder) return false;
return Equals(_unknownFields, other._unknownFields);
}
@@ -1524,6 +1559,8 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (originalRaiseTime_ != null) hash ^= OriginalRaiseTime.GetHashCode();
if (CurrentValue.Length != 0) hash ^= CurrentValue.GetHashCode();
if (LimitValue.Length != 0) hash ^= LimitValue.GetHashCode();
if (NativeSourceCanonicalName.Length != 0) hash ^= NativeSourceCanonicalName.GetHashCode();
if (IsConfiguredPlaceholder != false) hash ^= IsConfiguredPlaceholder.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
@@ -1626,6 +1663,14 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
output.WriteRawTag(170, 1);
output.WriteString(LimitValue);
}
if (NativeSourceCanonicalName.Length != 0) {
output.WriteRawTag(178, 1);
output.WriteString(NativeSourceCanonicalName);
}
if (IsConfiguredPlaceholder != false) {
output.WriteRawTag(184, 1);
output.WriteBool(IsConfiguredPlaceholder);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
@@ -1720,6 +1765,14 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
output.WriteRawTag(170, 1);
output.WriteString(LimitValue);
}
if (NativeSourceCanonicalName.Length != 0) {
output.WriteRawTag(178, 1);
output.WriteString(NativeSourceCanonicalName);
}
if (IsConfiguredPlaceholder != false) {
output.WriteRawTag(184, 1);
output.WriteBool(IsConfiguredPlaceholder);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
@@ -1793,6 +1846,12 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (LimitValue.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(LimitValue);
}
if (NativeSourceCanonicalName.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(NativeSourceCanonicalName);
}
if (IsConfiguredPlaceholder != false) {
size += 2 + 1;
}
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
@@ -1874,6 +1933,12 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (other.LimitValue.Length != 0) {
LimitValue = other.LimitValue;
}
if (other.NativeSourceCanonicalName.Length != 0) {
NativeSourceCanonicalName = other.NativeSourceCanonicalName;
}
if (other.IsConfiguredPlaceholder != false) {
IsConfiguredPlaceholder = other.IsConfiguredPlaceholder;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
@@ -1983,6 +2048,14 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
LimitValue = input.ReadString();
break;
}
case 178: {
NativeSourceCanonicalName = input.ReadString();
break;
}
case 184: {
IsConfiguredPlaceholder = input.ReadBool();
break;
}
}
}
#endif
@@ -2092,6 +2165,14 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
LimitValue = input.ReadString();
break;
}
case 178: {
NativeSourceCanonicalName = input.ReadString();
break;
}
case 184: {
IsConfiguredPlaceholder = input.ReadBool();
break;
}
}
}
}
@@ -53,6 +53,13 @@ public class InstanceActor : ReceiveActor
private readonly Dictionary<string, IActorRef> _alarmActors = new();
private readonly Dictionary<string, IActorRef> _nativeAlarmActors = new();
/// <summary>
/// Native alarm kind per source-binding canonical name (the same value handed
/// to each <see cref="NativeAlarmActor"/>'s <c>_nativeKind</c>). Used to stamp
/// the cosmetic kind badge on placeholder rows for quiet bindings in the
/// DebugView snapshot.
/// </summary>
private readonly Dictionary<string, AlarmKind> _nativeAlarmKinds = new();
/// <summary>
/// Latest enriched <see cref="AlarmStateChanged"/> per alarm name (computed and
/// native), so the DebugView snapshot carries the unified condition + native
/// metadata rather than a bare State/Priority projection.
@@ -1110,6 +1117,30 @@ public class InstanceActor : ReceiveActor
_alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow)));
}
// A native binding is "already represented" if any retained event carries its
// canonical name — it renders in the Debug View via those condition rows. The
// placeholder below covers ONLY bindings with zero retained events (truly quiet
// since startup). Do NOT narrow this to State==Active: a binding holding a
// retained Normal/resolved condition would then be rendered twice.
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,
_alarmTimestamps.GetValueOrDefault(binding, DateTimeOffset.MinValue))
{
Kind = _nativeAlarmKinds.GetValueOrDefault(binding, AlarmKind.NativeOpcUa),
NativeSourceCanonicalName = binding,
IsConfiguredPlaceholder = true
});
}
return states;
}
@@ -1468,6 +1499,9 @@ public class InstanceActor : ReceiveActor
var actorRef = Context.ActorOf(props, $"native-alarm-{nativeSource.CanonicalName}");
_nativeAlarmActors[nativeSource.CanonicalName] = actorRef;
// Same kind handed to the NativeAlarmActor — used to stamp the
// (cosmetic) kind badge on placeholder rows for quiet bindings.
_nativeAlarmKinds[nativeSource.CanonicalName] = nativeKind;
}
_logger.LogInformation(
@@ -330,7 +330,8 @@ public class NativeAlarmActor : ReceiveActor
OperatorComment = t.OperatorComment,
OriginalRaiseTime = t.OriginalRaiseTime,
CurrentValue = t.CurrentValue,
LimitValue = t.LimitValue
LimitValue = t.LimitValue,
NativeSourceCanonicalName = _source.CanonicalName,
};
_instanceActor.Tell(change);
@@ -0,0 +1,143 @@
using Microsoft.Playwright;
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
/// <summary>
/// E2E coverage for the tabbed, tree-rendered Debug View
/// (<c>/deployment/debug-view</c>) shipped by the debugview-tabs-trees feature.
/// Where <see cref="DebugViewTests"/> proves the controls render and Connect
/// resolves to a terminal state, this class proves the connected layout: a tab
/// strip (Attributes / Alarms), each tab a composition <see cref="TreeView{T}"/>.
///
/// <para>
/// <b>Seeding mirrors <see cref="DebugViewTests"/> exactly</b> — the Enabled-only
/// instance <c>&lt;select&gt;</c> (verified against
/// <c>DebugView.razor::LoadInstancesForSite</c>, <c>i.State == InstanceState.Enabled</c>)
/// lists only Enabled instances. A freshly-minted instance is <c>NotDeployed</c>
/// and would never appear, so the test mints an instance via the shared
/// <see cref="DeploymentFixture"/> and drives it to <c>Enabled</c> over the CLI
/// (<c>instance deploy</c>) before selecting it. The fixture provisions onto the
/// real, running <c>site-a</c> so the ClusterClient snapshot round-trip resolves
/// fast (an unknown site has no registered ClusterClient and only times out).
/// </para>
///
/// <para>
/// <b>Tolerant terminal-state wait, then the tabs/trees assertions</b> — Connect
/// awaits a ClusterClient snapshot round-trip; on success the page flips to the
/// Live badge and renders the tabbed card, on failure it surfaces a
/// <c>Connect failed: …</c> <c>.toast</c> (see <c>DebugView.razor::Connect</c>).
/// The test first waits on a TERMINAL state (Live badge OR error toast) within a
/// generous window so it never hangs, then asserts the Live badge directly
/// (OBSERVED reliable on this live cluster in <see cref="DebugViewTests"/>). Only
/// once connected does the card with the tab strip render
/// (<c>@if (_connected &amp;&amp; _snapshot != null)</c>), so the tabs/trees
/// assertions are gated behind the Live badge.
/// </para>
///
/// <para>
/// <b>Why the alarms-tab assertion is tolerant</b> — the
/// <see cref="TreeView{T}"/> renders its <c>&lt;ul role="tree"&gt;</c> root ONLY
/// when the forest is non-empty; when empty it renders the <c>EmptyContent</c>
/// slot ("No alarms.") with no <c>[role="tree"]</c> element. The seeded
/// <see cref="DeploymentFixture"/> instance has no configured alarms, so the
/// alarms pane shows the empty hint. The Attributes pane always has data (one
/// attribute is seeded), so its <c>[role="tree"]</c> assertion remains strict.
/// The populated alarm tree is covered by bUnit tests (DebugViewAlarmTableTests).
/// </para>
/// </summary>
[Collection("Playwright")]
public class DebugViewTreeTests : IClassFixture<DeploymentFixture>
{
private readonly PlaywrightFixture _pw;
private readonly DeploymentFixture _cluster;
public DebugViewTreeTests(PlaywrightFixture pw, DeploymentFixture cluster)
{
_pw = pw;
_cluster = cluster;
}
[SkippableFact]
public async Task DebugView_Connected_RendersTabbedTrees_AndAlarmsTabSwitches()
{
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
try
{
await CliRunner.DeployInstanceAsync(instanceId); // -> Enabled, so it lists in the dropdown
var page = await _pw.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/debug-view");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Select the seeded site + Enabled instance (same hooks as DebugViewTests).
await page.Locator("[data-test='debug-site-select']")
.SelectOptionAsync(new SelectOptionValue { Value = _cluster.SiteAId.ToString() });
var instanceOption = page.Locator("[data-test='debug-instance-select'] option",
new() { HasText = uniqueName });
await Assertions.Expect(instanceOption).ToHaveCountAsync(1, new() { Timeout = 10_000 });
await page.Locator("[data-test='debug-instance-select']")
.SelectOptionAsync(new SelectOptionValue { Value = instanceId.ToString() });
var connect = page.Locator("button.btn.btn-primary.btn-sm:has-text('Connect')");
await Assertions.Expect(connect).ToBeEnabledAsync(new() { Timeout = 10_000 });
await connect.ClickAsync();
// Outcome-tolerant terminal-state floor: Live badge OR an error toast within a
// generous window so the click never hangs (mirrors DebugViewTests). Then tighten
// to the observed-reliable Live outcome — the tabbed card only renders when
// connected, so the tabs/trees assertions below depend on reaching Live.
var terminal = page.Locator("span.badge[aria-label='Connection state: Live'], .toast");
await Assertions.Expect(terminal.First).ToBeVisibleAsync(new() { Timeout = 25_000 });
await Assertions.Expect(page.Locator("span.badge[aria-label='Connection state: Live']"))
.ToBeVisibleAsync(new() { Timeout = 2_000 });
// ── Tab strip renders with both tabs (the tabs+trees rework's structural hooks). ──
var attrTab = page.Locator("[data-test='debug-tab-attributes']");
var alarmTab = page.Locator("[data-test='debug-tab-alarms']");
await Assertions.Expect(attrTab).ToBeVisibleAsync(new() { Timeout = 10_000 });
await Assertions.Expect(alarmTab).ToBeVisibleAsync();
var attrPane = page.Locator("[data-test='debug-pane-attributes']");
var alarmPane = page.Locator("[data-test='debug-pane-alarms']");
// Attributes is the default tab: its pane is visible and hosts a tree;
// the Alarms pane is present in the DOM but hidden (d-none) until selected.
await Assertions.Expect(attrTab).ToHaveAttributeAsync("aria-selected", "true");
await Assertions.Expect(attrPane).ToBeVisibleAsync();
await Assertions.Expect(attrPane.Locator("[role='tree']")).ToHaveCountAsync(1);
await Assertions.Expect(alarmPane).ToBeHiddenAsync();
// ── Switch to the Alarms tab and assert the pane swaps + hosts its own tree. ──
await alarmTab.ClickAsync();
await Assertions.Expect(alarmTab).ToHaveAttributeAsync("aria-selected", "true");
await Assertions.Expect(alarmPane).ToBeVisibleAsync(new() { Timeout = 10_000 });
await Assertions.Expect(attrPane).ToBeHiddenAsync();
// TreeView renders <ul role="tree"> ONLY when the forest is non-empty; when empty it
// renders the EmptyContent slot ("No alarms.") instead. The seeded DeploymentFixture
// instance has no configured alarms, so the alarms forest is empty and the pane shows
// the empty hint rather than a tree. The populated alarm tree is covered by bUnit
// tests (DebugViewAlarmTableTests). Here we prove the tab switch works and the pane
// renders something valid — either a tree (if alarms exist) or the empty hint.
var alarmTreeCount = await alarmPane.Locator("[role='tree']").CountAsync();
if (alarmTreeCount == 0)
{
// No alarms configured on the seeded instance → TreeView shows EmptyContent.
await Assertions.Expect(alarmPane).ToContainTextAsync("No alarms.");
}
// Disconnect tears the card down and re-enables the selectors.
await page.Locator("button.btn-outline-danger.btn-sm:has-text('Disconnect')").ClickAsync();
await Assertions.Expect(page.Locator("span.badge[aria-label='Connection state: Disconnected']"))
.ToBeVisibleAsync(new() { Timeout = 10_000 });
await Assertions.Expect(page.Locator("[data-test='debug-site-select']")).ToBeEnabledAsync();
}
finally
{
await CliRunner.DeleteInstanceAsync(instanceId);
}
}
}
@@ -0,0 +1,567 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Pure unit tests for <see cref="DebugTreeBuilder.BuildAttributeTree"/> — 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.
/// </summary>
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<AttributeValueChanged>(), null);
Assert.Empty(tree);
}
[Fact]
public void RollUp_FourLevelDeepBadQuality_ReachesRoot()
{
var tree = DebugTreeBuilder.BuildAttributeTree(
new[] { Attr("A.B.C.D", quality: "Bad") }, null);
var a = Assert.Single(tree);
Assert.True(a.HasBadQuality);
var b = Assert.Single(a.Children);
Assert.True(b.HasBadQuality);
var c = Assert.Single(b.Children);
Assert.True(c.HasBadQuality);
var d = Assert.Single(c.Children);
Assert.True(d.HasBadQuality);
}
[Fact]
public void Filter_DeepLeafMatch_RetainsAllAncestorBranches()
{
var tree = DebugTreeBuilder.BuildAttributeTree(
new[] { Attr("Motor1.Compressor.Pump"), Attr("Motor1.Speed") }, "Pump");
var motor = Assert.Single(tree);
Assert.Equal("Motor1", motor.Key);
var compressor = Assert.Single(motor.Children);
Assert.Equal("Motor1.Compressor", compressor.Key);
Assert.Equal("Motor1.Compressor.Pump", Assert.Single(compressor.Children).Key);
}
// ---------------------------------------------------------------------
// DV-4: BuildAlarmTree — computed leaves, native condition groupings,
// configured-placeholder binding nodes, roll-up, filter.
// ---------------------------------------------------------------------
/// <summary>Computed alarm: <c>AlarmName</c> is the path-qualified canonical leaf path.</summary>
private static AlarmStateChanged Computed(string alarmName, AlarmState state = AlarmState.Active)
=> new("Inst", alarmName, state, Priority: 500, DateTimeOffset.UtcNow);
/// <summary>
/// Native condition (or placeholder) under a source binding identified by
/// <paramref name="bindingCanonical"/>.
/// </summary>
private static AlarmStateChanged Native(
string bindingCanonical,
string sourceReference,
AlarmState state = AlarmState.Active,
bool placeholder = false,
AlarmKind kind = AlarmKind.NativeOpcUa)
=> new("Inst", sourceReference, state, Priority: 500, DateTimeOffset.UtcNow)
{
Kind = kind,
SourceReference = sourceReference,
NativeSourceCanonicalName = bindingCanonical,
IsConfiguredPlaceholder = placeholder,
};
[Fact]
public void Computed_PathQualifiedName_BecomesLeafUnderBranch()
{
var tree = DebugTreeBuilder.BuildAlarmTree(
new[] { Computed("Motor1.HighTemp") }, null);
var motor = Assert.Single(tree);
Assert.Equal("Motor1", motor.Key);
Assert.Equal("Motor1", motor.Segment);
Assert.True(motor.HasChildren);
Assert.False(motor.IsNativeBinding);
Assert.Null(motor.Alarm);
var leaf = Assert.Single(motor.Children);
Assert.Equal("Motor1.HighTemp", leaf.Key);
Assert.Equal("HighTemp", leaf.Segment);
Assert.False(leaf.HasChildren);
Assert.NotNull(leaf.Alarm);
Assert.Equal("Motor1.HighTemp", leaf.Alarm!.AlarmName);
}
[Fact]
public void Computed_RootLevelName_BecomesSingleLeaf()
{
var tree = DebugTreeBuilder.BuildAlarmTree(new[] { Computed("Overheat") }, null);
var node = Assert.Single(tree);
Assert.Equal("Overheat", node.Key);
Assert.Equal("Overheat", node.Segment);
Assert.False(node.HasChildren);
Assert.NotNull(node.Alarm);
}
[Fact]
public void Native_Condition_NestsUnderBindingNode_WithSourceReferenceSegment()
{
var tree = DebugTreeBuilder.BuildAlarmTree(
new[] { Native("Tank1.Levels", "Tank1.Level.HiHi") }, null);
var tank = Assert.Single(tree);
Assert.Equal("Tank1", tank.Key);
Assert.False(tank.IsNativeBinding);
var binding = Assert.Single(tank.Children);
Assert.Equal("Tank1.Levels", binding.Key);
Assert.Equal("Levels", binding.Segment);
Assert.True(binding.IsNativeBinding);
Assert.Null(binding.Alarm);
var condition = Assert.Single(binding.Children);
Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", condition.Key);
Assert.Equal("Tank1.Level.HiHi", condition.Segment);
Assert.False(condition.HasChildren);
Assert.NotNull(condition.Alarm);
Assert.Equal("Tank1.Level.HiHi", condition.Alarm!.SourceReference);
}
[Fact]
public void Native_TwoConditions_ShareSameBindingNode()
{
var tree = DebugTreeBuilder.BuildAlarmTree(
new[]
{
Native("Tank1.Levels", "Tank1.Level.HiHi"),
Native("Tank1.Levels", "Tank1.Level.LoLo"),
},
null);
var tank = Assert.Single(tree);
var binding = Assert.Single(tank.Children);
Assert.Equal("Tank1.Levels", binding.Key);
Assert.True(binding.IsNativeBinding);
// ONE binding node carrying two condition children (sorted by SourceReference).
Assert.Equal(2, binding.Children.Count);
Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", binding.Children[0].Key);
Assert.Equal("Tank1.Levels::Tank1.Level.LoLo", binding.Children[1].Key);
}
[Fact]
public void Native_Placeholder_YieldsChildlessBindingNode()
{
var tree = DebugTreeBuilder.BuildAlarmTree(
new[] { Native("Tank1.Levels", "ignored", placeholder: true) }, null);
var tank = Assert.Single(tree);
var binding = Assert.Single(tank.Children);
Assert.Equal("Tank1.Levels", binding.Key);
Assert.True(binding.IsNativeBinding);
Assert.False(binding.HasChildren);
Assert.Empty(binding.Children);
}
[Fact]
public void Native_PlaceholderAndRealConditions_RealConditionsWin()
{
// DV-2 shouldn't emit both, but be safe: placeholder adds nothing.
var tree = DebugTreeBuilder.BuildAlarmTree(
new[]
{
Native("Tank1.Levels", "ignored", placeholder: true),
Native("Tank1.Levels", "Tank1.Level.HiHi"),
},
null);
var tank = Assert.Single(tree);
var binding = Assert.Single(tank.Children);
Assert.True(binding.IsNativeBinding);
var condition = Assert.Single(binding.Children);
Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", condition.Key);
}
[Fact]
public void RollUp_ActiveDescendant_SetsWorstStateActiveAndCounts()
{
var tree = DebugTreeBuilder.BuildAlarmTree(
new[]
{
Native("Tank1.Levels", "Tank1.Level.HiHi", AlarmState.Active),
Native("Tank1.Levels", "Tank1.Level.LoLo", AlarmState.Normal),
Computed("Tank1.Overflow", AlarmState.Active),
},
null);
var tank = Assert.Single(tree);
Assert.Equal(AlarmState.Active, tank.WorstState);
Assert.Equal(2, tank.ActiveCount); // one native HiHi + one computed Overflow
var binding = tank.Children.Single(c => c.IsNativeBinding);
Assert.Equal(AlarmState.Active, binding.WorstState);
Assert.Equal(1, binding.ActiveCount);
}
[Fact]
public void RollUp_AllNormal_WorstStateNormalAndZeroCount()
{
var tree = DebugTreeBuilder.BuildAlarmTree(
new[] { Computed("Tank1.Overflow", AlarmState.Normal) }, null);
var tank = Assert.Single(tree);
Assert.Equal(AlarmState.Normal, tank.WorstState);
Assert.Equal(0, tank.ActiveCount);
}
[Fact]
public void RollUp_Placeholder_NeverCountsAsActive()
{
// A placeholder must not contribute to ActiveCount or WorstState even
// though its underlying State is Active (the factory default).
var tree = DebugTreeBuilder.BuildAlarmTree(
new[] { Native("Tank1.Levels", "ignored", AlarmState.Active, placeholder: true) },
null);
var tank = Assert.Single(tree);
Assert.Equal(AlarmState.Normal, tank.WorstState);
Assert.Equal(0, tank.ActiveCount);
var binding = Assert.Single(tank.Children);
Assert.Equal(AlarmState.Normal, binding.WorstState);
Assert.Equal(0, binding.ActiveCount);
}
[Fact]
public void Filter_OnSourceReference_KeepsConditionUnderBindingAndAncestors()
{
var tree = DebugTreeBuilder.BuildAlarmTree(
new[]
{
Native("Tank1.Levels", "Tank1.Level.HiHi"),
Native("Tank1.Levels", "Tank1.Level.LoLo"),
},
"HiHi");
var tank = Assert.Single(tree);
Assert.Equal("Tank1", tank.Key);
var binding = Assert.Single(tank.Children);
Assert.Equal("Tank1.Levels", binding.Key);
Assert.True(binding.IsNativeBinding);
var condition = Assert.Single(binding.Children);
Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", condition.Key);
}
[Fact]
public void Filter_OnBindingCanonicalName_KeepsBindingNode()
{
var tree = DebugTreeBuilder.BuildAlarmTree(
new[]
{
Native("Tank1.Levels", "Tank1.Level.HiHi"),
Computed("Motor1.HighTemp"),
},
"Tank1.Levels");
// Only the native binding matches (via NativeSourceCanonicalName).
var tank = Assert.Single(tree);
Assert.Equal("Tank1", tank.Key);
var binding = Assert.Single(tank.Children);
Assert.Equal("Tank1.Levels", binding.Key);
Assert.True(binding.IsNativeBinding);
Assert.Single(binding.Children);
}
[Fact]
public void Filter_OnBindingName_MatchingPlaceholder_KeepsBindingNode()
{
var tree = DebugTreeBuilder.BuildAlarmTree(
new[] { Native("Tank1.Levels", "ignored", placeholder: true) },
"Levels");
var tank = Assert.Single(tree);
var binding = Assert.Single(tank.Children);
Assert.Equal("Tank1.Levels", binding.Key);
Assert.True(binding.IsNativeBinding);
Assert.False(binding.HasChildren);
}
[Fact]
public void Filter_OnComputedAlarmName_IsCaseInsensitive()
{
var tree = DebugTreeBuilder.BuildAlarmTree(
new[] { Computed("Motor1.HighTemp"), Computed("Motor1.LowPressure") },
"hightemp");
var motor = Assert.Single(tree);
Assert.Equal("Motor1.HighTemp", Assert.Single(motor.Children).Key);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Filter_EmptyOrWhitespace_ReturnsFullAlarmForest(string? filter)
{
var tree = DebugTreeBuilder.BuildAlarmTree(
new[] { Computed("Motor1.HighTemp"), Native("Tank1.Levels", "Tank1.Level.HiHi") },
filter);
Assert.Equal(2, tree.Count);
Assert.Equal("Motor1", tree[0].Key);
Assert.Equal("Tank1", tree[1].Key);
}
[Fact]
public void Filter_NoMatches_ReturnsEmptyAlarmForest()
{
var tree = DebugTreeBuilder.BuildAlarmTree(
new[] { Computed("Motor1.HighTemp") }, "nonexistent");
Assert.Empty(tree);
}
[Fact]
public void MixedComputedAndNative_ProduceBothSubtrees()
{
var tree = DebugTreeBuilder.BuildAlarmTree(
new[]
{
Computed("Motor1.HighTemp"),
Native("Tank1.Levels", "Tank1.Level.HiHi", kind: AlarmKind.NativeMxAccess),
},
null);
Assert.Equal(2, tree.Count);
var motor = tree.Single(n => n.Key == "Motor1");
var computedLeaf = Assert.Single(motor.Children);
Assert.Equal("Motor1.HighTemp", computedLeaf.Key);
Assert.False(computedLeaf.HasChildren);
var tank = tree.Single(n => n.Key == "Tank1");
var binding = Assert.Single(tank.Children);
Assert.True(binding.IsNativeBinding);
Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", Assert.Single(binding.Children).Key);
}
[Fact]
public void EmptyAlarmInput_ReturnsEmptyForest()
{
var tree = DebugTreeBuilder.BuildAlarmTree(Array.Empty<AlarmStateChanged>(), null);
Assert.Empty(tree);
}
[Fact]
public void Native_ComputedAndNativeSharePrefix_DistinctBindingAndLeaf()
{
// Tank1 has a computed alarm AND a native binding — both under one Tank1 branch.
var tree = DebugTreeBuilder.BuildAlarmTree(
new[]
{
Computed("Tank1.Overflow"),
Native("Tank1.Levels", "Tank1.Level.HiHi"),
},
null);
var tank = Assert.Single(tree);
Assert.Equal(2, tank.Children.Count);
var binding = tank.Children.Single(c => c.IsNativeBinding);
Assert.Equal("Tank1.Levels", binding.Key);
var computed = tank.Children.Single(c => !c.IsNativeBinding);
Assert.Equal("Tank1.Overflow", computed.Key);
Assert.NotNull(computed.Alarm);
}
}
@@ -20,9 +20,13 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Task 23: the DebugView alarm table surfaces enriched native alarm state —
/// kind, severity, source reference, and a composite condition badge set
/// (Unacked / Shelved / Suppressed).
/// DV-5: the Debug View renders a tabbed (Attributes / Alarms) layout, each tab
/// hosting a composition <c>TreeView</c>. Computed alarms and attributes nest as
/// leaves under their composition-member branches; native conditions nest under
/// their source-binding node. Branch nodes surface a roll-up (active-count /
/// bad-quality) badge. These tests assert the tree structure + roll-up, not just
/// that markup exists, and that the enriched native-alarm badge set
/// (kind / severity / Unacked / Shelved) still renders inside the tree leaf.
/// </summary>
public class DebugViewAlarmTableTests : BunitContext
{
@@ -69,35 +73,139 @@ public class DebugViewAlarmTableTests : BunitContext
(Dictionary<string, AlarmStateChanged>)typeof(DebugViewPage)
.GetField("_alarmStates", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
private static Dictionary<string, AttributeValueChanged> AttributeValues(DebugViewPage c) =>
(Dictionary<string, AttributeValueChanged>)typeof(DebugViewPage)
.GetField("_attributeValues", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
/// <summary>
/// Mark the page connected with the given snapshot and seed the latest-per-name
/// dictionaries, then re-render. Mirrors what <c>Connect()</c> does from a real
/// snapshot without driving the gRPC stream.
/// </summary>
private static void Connect(
IRenderedComponent<DebugViewPage> cut,
IReadOnlyList<AttributeValueChanged> attributes,
IReadOnlyList<AlarmStateChanged> alarms)
{
cut.InvokeAsync(() =>
{
SetField(cut.Instance, "_connected", true);
SetField(cut.Instance, "_snapshot",
new DebugViewSnapshot("inst", attributes.ToList(), alarms.ToList(), DateTimeOffset.UtcNow));
foreach (var a in attributes) AttributeValues(cut.Instance)[a.AttributeName] = a;
foreach (var a in alarms) AlarmStates(cut.Instance)[a.AlarmName] = a;
});
cut.Render();
}
[Fact]
public void AlarmTable_RendersNativeAlarm_WithSeverityKindAndShelvedBadge()
public void BothTabsPresent_AttributesTabIsDefault_AndShowsAttributeTree()
{
var cut = RenderPage();
var speed = new AttributeValueChanged(
"inst", "Motor1.Speed", "Motor1.Speed", 1450, "Good", DateTimeOffset.UtcNow);
var alarm = new AlarmStateChanged("inst", "Motor1.HighTemp", AlarmState.Active, 700, DateTimeOffset.UtcNow);
Connect(cut, new[] { speed }, new[] { alarm });
// Both tab hooks render.
var attrTab = cut.Find("[data-test='debug-tab-attributes']");
var alarmTab = cut.Find("[data-test='debug-tab-alarms']");
Assert.Contains("active", attrTab.GetAttribute("class")!); // Attributes is the default active tab
Assert.DoesNotContain("active", alarmTab.GetAttribute("class")!);
// The Attributes pane is visible (not d-none); the Alarms pane is hidden.
var attrPane = cut.Find("[data-test='debug-pane-attributes']");
var alarmPane = cut.Find("[data-test='debug-pane-alarms']");
Assert.DoesNotContain("d-none", attrPane.GetAttribute("class")!);
Assert.Contains("d-none", alarmPane.GetAttribute("class")!);
// The attribute tree shows the Motor1 branch and the Speed leaf nested under it.
var attrItems = attrPane.QuerySelectorAll("li[role='treeitem']");
Assert.Contains(attrItems, li => li.TextContent.Contains("Motor1"));
Assert.Contains(attrItems, li => li.TextContent.Contains("Speed") && li.TextContent.Contains("1450"));
// The leaf for Speed must be nested inside the Motor1 branch's group, not a root.
var motor1 = attrItems.Single(li =>
li.QuerySelector(".tv-content")!.TextContent.Trim().StartsWith("Motor1"));
Assert.NotNull(motor1.QuerySelector("ul[role='group'] li[role='treeitem']"));
}
[Fact]
public void AlarmsTab_GroupsComputedAlarmUnderBranch_WithActiveRollUpBadge()
{
var cut = RenderPage();
// Two computed alarms under the same Motor1 module — one active, one normal.
var high = new AlarmStateChanged("inst", "Motor1.HighTemp", AlarmState.Active, 700, DateTimeOffset.UtcNow);
var ok = new AlarmStateChanged("inst", "Motor1.Overload", AlarmState.Normal, 200, DateTimeOffset.UtcNow);
Connect(cut, Array.Empty<AttributeValueChanged>(), new[] { high, ok });
// Switch to the Alarms tab.
cut.Find("[data-test='debug-tab-alarms']").Click();
var alarmPane = cut.Find("[data-test='debug-pane-alarms']");
Assert.DoesNotContain("d-none", alarmPane.GetAttribute("class")!);
var items = alarmPane.QuerySelectorAll("li[role='treeitem']");
// Motor1 branch carries the roll-up "1 active" danger badge.
var motor1 = items.Single(li =>
li.QuerySelector(".tv-content")!.TextContent.Trim().StartsWith("Motor1"));
var rollup = motor1.QuerySelector(".tv-content .badge.bg-danger");
Assert.NotNull(rollup);
Assert.Contains("1 active", rollup!.TextContent);
// The active alarm leaf nests under Motor1 and shows the Active state badge.
var group = motor1.QuerySelector("ul[role='group']");
Assert.NotNull(group);
var leaves = group!.QuerySelectorAll("li[role='treeitem']");
Assert.Contains(leaves, li => li.TextContent.Contains("HighTemp"));
Assert.Contains(leaves, li => li.TextContent.Contains("Overload"));
var highTemp = leaves.Single(li => li.TextContent.Contains("HighTemp"));
Assert.NotNull(highTemp.QuerySelector(".badge.bg-danger")); // Active state badge
}
[Fact]
public void AlarmsTab_RendersNativeCondition_WithEnrichedBadgesUnderBindingNode()
{
var cut = RenderPage();
// A native OPC UA condition belonging to the Motor1.MotorAlarms binding.
var native = new AlarmStateChanged("inst", "T01.Hi", AlarmState.Active, 800, DateTimeOffset.UtcNow)
{
Kind = AlarmKind.NativeOpcUa,
NativeSourceCanonicalName = "Motor1.MotorAlarms",
SourceReference = "ns=2;s=Tank01.Level.HiHi",
Condition = new AlarmConditionState(
Active: true, Acknowledged: false, Confirmed: null,
Shelve: AlarmShelveState.OneShotShelved, Suppressed: false, Severity: 800)
};
cut.InvokeAsync(() =>
{
SetField(cut.Instance, "_connected", true);
SetField(cut.Instance, "_snapshot",
new DebugViewSnapshot("inst", new List<AttributeValueChanged>(),
new List<AlarmStateChanged> { native }, DateTimeOffset.UtcNow));
AlarmStates(cut.Instance)["T01.Hi"] = native;
});
cut.Render();
Connect(cut, Array.Empty<AttributeValueChanged>(), new[] { native });
var markup = cut.Markup;
Assert.Contains("800", markup); // severity surfaced
Assert.Contains("Shelved", markup); // composite condition badge
Assert.Contains("OPC UA", markup); // native kind badge
Assert.Contains("ns=2;s=Tank01.Level.HiHi", markup); // source reference
Assert.Contains("Unacked", markup); // active + unacknowledged
cut.Find("[data-test='debug-tab-alarms']").Click();
var alarmPane = cut.Find("[data-test='debug-pane-alarms']");
var items = alarmPane.QuerySelectorAll("li[role='treeitem']");
// The source-binding node renders with its roll-up active badge.
var binding = items.Single(li =>
li.QuerySelector(".tv-content")!.TextContent.Trim().StartsWith("MotorAlarms"));
var bindingBadge = binding.QuerySelector(".tv-content .badge");
Assert.NotNull(bindingBadge);
Assert.Contains("1 active", bindingBadge!.TextContent);
// The native condition leaf nests under the binding and surfaces the enriched badge set.
var leaf = binding.QuerySelector("ul[role='group'] li[role='treeitem']");
Assert.NotNull(leaf);
var leafMarkup = leaf!.OuterHtml;
Assert.Contains("sev 800", leafMarkup); // severity surfaced
Assert.Contains("Shelved", leafMarkup); // composite condition badge
Assert.Contains("OPC UA", leafMarkup); // native kind badge
Assert.Contains("Unacked", leafMarkup); // active + unacknowledged
Assert.Contains("ns=2;s=Tank01.Level.HiHi", leaf.TextContent); // source reference as the leaf label
}
}
@@ -113,6 +113,85 @@ public class StreamRelayActorTests : TestKit
Assert.Equal("90", alarm.LimitValue);
}
[Fact]
public void RelaysAlarmStateChanged_NativeBindingLinkage_SurvivesFullRoundTrip()
{
// DV-1: the native source-binding canonical name must pack into AlarmStateUpdate
// (StreamRelayActor) and unpack back out (SiteStreamGrpcClient.ConvertToDomainEvent)
// — the full cross-process round trip for a real (non-placeholder) native alarm.
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor("corr-native-binding", channel.Writer)));
var timestamp = new DateTimeOffset(2026, 3, 21, 11, 0, 0, TimeSpan.Zero);
var domainEvent = new AlarmStateChanged(
"Site1.Motor01", "Motor1.MotorAlarms.Hi", AlarmState.Active, 900, timestamp)
{
Kind = AlarmKind.NativeOpcUa,
SourceReference = "Motor1.MotorAlarms.Hi",
NativeSourceCanonicalName = "Motor1.MotorAlarms",
IsConfiguredPlaceholder = false,
Condition = new AlarmConditionState(
Active: true, Acknowledged: false, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 900)
};
actor.Tell(domainEvent);
var success = channel.Reader.TryRead(out var protoEvent);
if (!success)
{
Thread.Sleep(500);
success = channel.Reader.TryRead(out protoEvent);
}
Assert.True(success, "Expected a proto event on the channel");
Assert.NotNull(protoEvent);
// Packed onto the wire frame by StreamRelayActor.
Assert.Equal("Motor1.MotorAlarms", protoEvent.AlarmChanged.NativeSourceCanonicalName);
Assert.False(protoEvent.AlarmChanged.IsConfiguredPlaceholder);
// Unpacked back into the domain record by SiteStreamGrpcClient.
var roundTripped = Assert.IsType<AlarmStateChanged>(
SiteStreamGrpcClient.ConvertToDomainEvent(protoEvent));
Assert.Equal("Motor1.MotorAlarms", roundTripped.NativeSourceCanonicalName);
Assert.False(roundTripped.IsConfiguredPlaceholder);
}
[Fact]
public void DropsAlarmStateChanged_WhenIsConfiguredPlaceholder()
{
// Placeholder rows are a Debug View snapshot-only concept emitted by
// InstanceActor.BuildAlarmStatesSnapshot. They are never a real alarm
// transition; their timestamp may be DateTimeOffset.MinValue (the Protobuf
// Timestamp boundary). The relay must drop them without writing to the channel.
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor("corr-placeholder-drop", channel.Writer)));
var domainEvent = new AlarmStateChanged(
"Site1.Tank01", "Tank1.Levels.Hi", AlarmState.Normal, 500,
DateTimeOffset.MinValue)
{
Kind = AlarmKind.NativeOpcUa,
NativeSourceCanonicalName = "Tank1.Levels",
IsConfiguredPlaceholder = true,
Condition = new AlarmConditionState(
Active: false, Acknowledged: false, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 0)
};
actor.Tell(domainEvent);
// Allow time for the actor to process the message.
Thread.Sleep(500);
// Nothing must have been written — placeholder is silently dropped.
Assert.False(channel.Reader.TryRead(out _),
"Placeholder AlarmStateChanged must not be relayed to the gRPC stream");
}
[Fact]
public void SetsCorrelationId_OnAllEvents()
{
@@ -95,6 +95,58 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
a.SourceReference == "T01.Hi" && a.Kind == AlarmKind.NativeOpcUa && a.Condition.Severity == 800);
}
[Fact]
public void Snapshot_QuietNativeBinding_EmitsPlaceholder()
{
var dcl = CreateTestProbe();
var actor = CreateInstanceActorWithDcl("inst", ConfigWithNativeSource("inst"), dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
// No live condition is emitted: the configured "Pressure" binding is quiet.
actor.Tell(new SubscribeDebugViewRequest("inst", "c"));
var snap = ExpectMsg<DebugViewSnapshot>();
var placeholders = snap.AlarmStates
.Where(a => a.NativeSourceCanonicalName == "Pressure" && a.IsConfiguredPlaceholder)
.ToList();
Assert.Single(placeholders);
var placeholder = placeholders[0];
Assert.Equal(AlarmState.Normal, placeholder.State);
Assert.Equal("Pressure", placeholder.NativeSourceCanonicalName);
Assert.Equal(AlarmKind.NativeOpcUa, placeholder.Kind);
}
[Fact]
public void Snapshot_NativeBindingWithLiveCondition_NoPlaceholder()
{
var dcl = CreateTestProbe();
var actor = CreateInstanceActorWithDcl("inst", ConfigWithNativeSource("inst"), dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
// The NativeAlarmActor emits a live condition stamped with the binding's
// canonical name (DV-1: NativeSourceCanonicalName), so the binding is "active".
actor.Tell(new AlarmStateChanged("inst", "Pressure.Hi", AlarmState.Active, 800, DateTimeOffset.UtcNow)
{
Kind = AlarmKind.NativeOpcUa,
SourceReference = "ns=2;s=T01.Hi",
NativeSourceCanonicalName = "Pressure",
Condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800)
});
actor.Tell(new SubscribeDebugViewRequest("inst", "c"));
var snap = ExpectMsg<DebugViewSnapshot>();
// The live condition is present...
Assert.Contains(snap.AlarmStates, a =>
a.SourceReference == "ns=2;s=T01.Hi" && a.NativeSourceCanonicalName == "Pressure"
&& a.Kind == AlarmKind.NativeOpcUa && a.Condition.Severity == 800);
// ...and NO placeholder row is emitted for that binding.
Assert.DoesNotContain(snap.AlarmStates, a =>
a.NativeSourceCanonicalName == "Pressure" && a.IsConfiguredPlaceholder);
}
void IDisposable.Dispose()
{
Shutdown();
@@ -79,6 +79,27 @@ public class NativeAlarmActorTests : TestKit, IDisposable
Assert.Equal(800, emitted.Condition.Severity);
}
[Fact]
public void Raise_StampsNativeSourceCanonicalName_FromConfiguredBinding()
{
// DV-1: every emitted condition carries the canonical name of the source
// BINDING it belongs to, so the DebugView can nest live native conditions
// under their configured binding node. It must equal the binding's
// CanonicalName used to construct the actor (Source().CanonicalName).
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
var emitted = instance.ExpectMsg<AlarmStateChanged>();
Assert.Equal(Source().CanonicalName, emitted.NativeSourceCanonicalName);
Assert.Equal("Pressure", emitted.NativeSourceCanonicalName);
}
[Fact]
public void SnapshotComplete_WithMissingPriorAlarm_EmitsReturnToNormal()
{