Compare commits
15 Commits
670b607acb
...
59e094ada3
| Author | SHA1 | Date | |
|---|---|---|---|
| 59e094ada3 | |||
| e7660134f2 | |||
| 7f59ae12cb | |||
| ef86a2db28 | |||
| 50ce26f2e6 | |||
| 59f135a4cf | |||
| c1e786e3fc | |||
| 5f387ef3e3 | |||
| 69b83379d5 | |||
| b5347faf44 | |||
| cc017aabfc | |||
| 5d07ac24cb | |||
| 899ad6e106 | |||
| 1045e7966d | |||
| 811d72255c |
@@ -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"
|
||||
}
|
||||
@@ -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.)
|
||||
|
||||
@@ -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<TItem></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);
|
||||
|
||||
+143
@@ -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><select></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 && _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><ul role="tree"></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);
|
||||
}
|
||||
}
|
||||
+127
-19
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user