25 Commits

Author SHA1 Message Date
Joseph Doherty b3076e18db docs(lmxproxy): add stale session subscription fix plan 2026-03-24 16:19:39 -04:00
Joseph Doherty de7c4067e4 feat(dcl): add debug-level logging for heartbeat subscription callbacks 2026-03-24 16:19:39 -04:00
Joseph Doherty 5fdeaf613f feat(dcl): failover on repeated unstable connections (connect-then-stale pattern)
Previously, failover only triggered when ConnectAsync failed consecutively.
If a connection succeeded but went stale quickly (e.g., heartbeat timeout),
the failure counter reset on each successful connect and failover never
triggered.

Added a separate _consecutiveUnstableDisconnects counter that increments
when a connection lasts less than StableConnectionThreshold (60s) before
disconnecting. When this counter reaches failoverRetryCount, the actor
fails over to the backup endpoint. Stable connections (lasting >60s)
reset this counter.

The original connection-failure failover path is unchanged.
2026-03-24 16:19:39 -04:00
Joseph Doherty ff2784b862 fix(site-runtime): add SQLite schema migration for backup_configuration column
Existing site databases created before the primary/backup data connections
feature lack the backup_configuration and failover_retry_count columns.
Added TryAddColumnAsync migration that runs on startup after table creation.
2026-03-24 16:19:39 -04:00
Joseph Doherty 0d03aec4f2 feat(dcl): log connection disconnect events to site event log 2026-03-24 16:19:39 -04:00
Joseph Doherty d4397910f0 feat(dcl): add StaleTagMonitor for heartbeat-based disconnect detection
Composable StaleTagMonitor class in Commons fires a Stale event when no
value is received within a configurable max silence period. Integrated
into both LmxProxyDataConnection and OpcUaDataConnection adapters via
optional HeartbeatTagPath/HeartbeatMaxSilence connection config keys.
When stale, the adapter fires Disconnected triggering the standard
reconnect cycle. 10 unit tests cover timer behavior.
2026-03-24 16:19:39 -04:00
Joseph Doherty 02a7e8abc6 feat(health): show all cluster nodes (online/offline, primary/standby) in health dashboard
Add NodeStatus record, IClusterNodeProvider interface, and AkkaClusterNodeProvider
that queries Akka cluster membership for all site-role nodes. HealthReportSender
populates ClusterNodes before each report. UI shows a row per node with
hostname, Online/Offline badge, and Primary/Standby badge. Falls back to
single-node display if ClusterNodes is not populated.
2026-03-24 16:19:39 -04:00
Joseph Doherty 65cc7b69cd feat(health): wire up NodeHostname, ConnectionEndpoint, TagQuality, ParkedMessageCount collectors
- AkkaHostedService: SetNodeHostname from NodeOptions
- DataConnectionActor: UpdateConnectionEndpoint on state transitions,
  track per-tag quality counts and UpdateTagQuality on value changes
- HealthReportSender: query StoreAndForwardStorage for parked message count
- StoreAndForwardStorage: add GetParkedMessageCountAsync()
2026-03-24 16:19:39 -04:00
Joseph Doherty e84a831a02 feat(health): redesign health dashboard with 4-column layout and new metrics
New fields in SiteHealthReport: NodeHostname, DataConnectionEndpoints
(primary/secondary), DataConnectionTagQuality (good/bad/uncertain),
ParkedMessageCount. New collector methods to populate them.

Health dashboard redesigned to match mockup: Nodes | Data Connections
(with per-connection tag quality) | Instances + S&F Buffers | Error
Counts + Parked Messages. Site names resolved from repository.
2026-03-24 16:19:39 -04:00
Joseph Doherty 5e2a4c9080 fix(ui): align TreeView node text by giving toggle and spacer equal fixed width 2026-03-24 16:19:39 -04:00
Joseph Doherty 0abaa47de2 fix(ui): normalize TreeView expanded keys to strings for sessionStorage compatibility
Keys from KeySelector (e.g. boxed int) were compared against string keys
restored from sessionStorage, causing expansion state to be lost on
navigation. All keys are now normalized to strings internally.
2026-03-24 16:19:39 -04:00
Joseph Doherty a0a6bb4986 refactor(ui): replace manual template inheritance tree with TreeView component 2026-03-24 16:19:39 -04:00
Joseph Doherty 2b5dabb336 refactor(ui): redesign Areas page with TreeView and dedicated Add/Edit/Delete pages
Areas page now shows a single TreeView with sites as roots and areas as
children. Context menus: sites get "Add Area", areas get "Add Child Area",
"Edit Area", "Delete Area" — each navigating to a dedicated page.

The Delete Area page shows a TreeView of the area and all recursive children
with assigned instances. Deletion is blocked if any instances are assigned
to the area or its descendants.
2026-03-24 16:19:39 -04:00
Joseph Doherty 968fc4adc7 fix(ui): disable site and instance dropdowns while debug view is connected 2026-03-24 16:19:39 -04:00
Joseph Doherty 4c7fa03c07 fix(ui): remove default list-style bullets from TreeView ul elements 2026-03-24 16:19:39 -04:00
Joseph Doherty addbb6ffeb fix(ui): move treeview-storage.js to Host wwwroot where static files are served 2026-03-24 16:19:39 -04:00
Joseph Doherty f1537b62ca refactor(ui): replace instances table with hierarchical TreeView (Site → Area → Instance) 2026-03-24 16:19:39 -04:00
Joseph Doherty 71894f4ba9 refactor(ui): replace manual area tree rendering with TreeView component 2026-03-24 16:19:39 -04:00
Joseph Doherty 4426f3e928 refactor(ui): replace data connections table with TreeView grouped by site 2026-03-24 16:19:39 -04:00
Joseph Doherty 08d511f609 test(ui): add external filtering tests for TreeView (R8) 2026-03-24 16:19:39 -04:00
Joseph Doherty 4e5b5facec feat(ui): add right-click context menu to TreeView (R15) 2026-03-24 16:19:39 -04:00
Joseph Doherty f127efe6ea feat(ui): add ExpandAll, CollapseAll, RevealNode to TreeView (R12, R13) 2026-03-24 16:19:39 -04:00
Joseph Doherty d3a6ed5f68 feat(ui): add sessionStorage persistence for TreeView expansion state (R11) 2026-03-24 16:19:39 -04:00
Joseph Doherty da4f29f6ee feat(ui): add selection support to TreeView (R5) 2026-03-24 16:19:39 -04:00
Joseph Doherty 75648c0c76 feat(ui): add TreeView<TItem> component with core rendering, expand/collapse, ARIA (R1-R4, R14) 2026-03-24 16:19:39 -04:00
36 changed files with 4360 additions and 476 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
{
"planPath": "docs/plans/2026-03-23-treeview-component.md",
"tasks": [
{"id": 22, "subject": "Task 1: Create TreeView.razor — Core Rendering (R1-R4, R14)", "status": "pending"},
{"id": 23, "subject": "Task 2: Add Selection Support (R5)", "status": "pending", "blockedBy": [22]},
{"id": 24, "subject": "Task 3: Add Session Storage Persistence (R11)", "status": "pending", "blockedBy": [23]},
{"id": 25, "subject": "Task 4: Add ExpandAll, CollapseAll, RevealNode (R12, R13)", "status": "pending", "blockedBy": [24]},
{"id": 26, "subject": "Task 5: Add Context Menu (R15)", "status": "pending", "blockedBy": [25]},
{"id": 27, "subject": "Task 6: Add External Filtering Tests (R8)", "status": "pending", "blockedBy": [26]},
{"id": 28, "subject": "Task 7: Integrate TreeView into Data Connections Page", "status": "pending", "blockedBy": [27]},
{"id": 29, "subject": "Task 8: Integrate TreeView into Areas Page", "status": "pending", "blockedBy": [27]},
{"id": 30, "subject": "Task 9: Integrate TreeView into Instances Page", "status": "pending", "blockedBy": [27]},
{"id": 31, "subject": "Task 10: Full Build Verification", "status": "pending", "blockedBy": [28, 29, 30]}
],
"lastUpdated": "2026-03-23T00:00:00Z"
}
+626
View File
@@ -0,0 +1,626 @@
# TreeView Component
## Purpose
A reusable, generic Blazor Server component that renders hierarchical data as an expandable/collapsible tree. The component is data-agnostic — it accepts any tree-shaped data via type parameters and render fragments, following the same pattern as the existing `DataTable<TItem>` shared component.
## Location
`src/ScadaLink.CentralUI/Components/Shared/TreeView.razor`
## Primary Use Case: Instance Hierarchy
The motivating use case is displaying instances organized by site and area:
```
- Site A
+ Area 1
- Sub Area 1
Instance 1
Instance 2
+ Area 2
+ Site B
+ Site C
```
**Hierarchy**: Site → Area → Sub Area (recursive) → Instance (leaf)
Nodes at each level may be expandable (branches) or plain items (leaves). Leaf nodes have no expand/collapse toggle.
## Requirements
### R1 — Generic Type Parameter
The component accepts a single type parameter `TItem` representing any node in the tree. The consumer provides:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `Items` | `IReadOnlyList<TItem>` | Yes | Root-level items |
| `ChildrenSelector` | `Func<TItem, IReadOnlyList<TItem>>` | Yes | Returns children for a given node |
| `HasChildrenSelector` | `Func<TItem, bool>` | Yes | Whether the node can be expanded (branch vs. leaf) |
| `KeySelector` | `Func<TItem, object>` | Yes | Unique key per node (for state tracking) |
### R2 — Render Fragments
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `NodeContent` | `RenderFragment<TItem>` | Yes | Renders the label/content for each node |
| `EmptyContent` | `RenderFragment?` | No | Shown when `Items` is empty |
The `NodeContent` fragment receives the `TItem` and is responsible for rendering the node's display (text, icons, badges, action buttons, etc.). The tree component only renders the structural chrome (indentation, expand/collapse toggle, vertical guide lines).
### R3 — Expand/Collapse Behavior
- Each branch node displays a toggle indicator: `+` when collapsed, `` when expanded.
- Clicking the **toggle icon** expands/collapses the node. Clicking the **content area** does **not** toggle expansion (it is reserved for selection — see R5).
- Leaf nodes (where `HasChildrenSelector` returns `false`) display no toggle — they are indented inline with sibling branch nodes.
- Expand/collapse state is tracked internally by the component using `KeySelector` for identity.
- All nodes start collapsed by default unless `InitiallyExpanded` is set.
- **Session persistence**: When the user navigates away and returns, previously expanded nodes are restored (see R11).
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `InitiallyExpanded` | `Func<TItem, bool>?` | No | Predicate — nodes matching this start expanded (first load only, before any persisted state exists) |
### R4 — Indentation and Visual Structure
- Each depth level is indented by a fixed amount (default 24px, configurable via `IndentPx` parameter).
- Vertical guide lines connect parent to children at each depth level (thin left-border or CSS pseudo-element).
- The toggle icon is inline with the node content, left-aligned at the current depth.
- Leaf nodes align with sibling branch labels (the content starts at the same horizontal position, with empty space where the toggle would be).
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `IndentPx` | `int` | No | Pixels per indent level. Default: 24 |
| `ShowGuideLines` | `bool` | No | Show vertical connector lines. Default: true |
### R5 — Selection
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `Selectable` | `bool` | No | Enable click-to-select. Default: false |
| `SelectedKey` | `object?` | No | Currently selected node key (two-way binding) |
| `SelectedKeyChanged` | `EventCallback<object?>` | No | Fires when selection changes |
| `SelectedCssClass` | `string` | No | CSS class for selected node. Default: `"bg-primary bg-opacity-10"` |
When `Selectable` is true, clicking a node row selects it (highlighted). Clicking the expand/collapse toggle does **not** change selection — only clicking the content area does.
### R6 — Lazy Loading (Deferred)
Future enhancement. For now, all children are provided synchronously via `ChildrenSelector`. A future version may support `Func<TItem, Task<IReadOnlyList<TItem>>>` for on-demand loading with a spinner placeholder.
### R7 — Keyboard Navigation (Deferred)
Future enhancement. Arrow keys for navigation, Enter/Space for expand/collapse, Home/End for first/last.
### R8 — External Filtering
The tree component itself does **not** implement filter UI. Filtering is driven externally by the consuming page — for example, a site dropdown that filters the tree to show only the selected site's hierarchy.
**How it works:**
- The consumer filters `Items` (and/or adjusts `ChildrenSelector` results) and passes the filtered list to the component.
- When `Items` changes (Blazor re-render), the component re-renders the tree with the new data.
- **Expansion state is preserved across filter changes.** Nodes that were expanded before filtering remain expanded if they reappear after the filter changes. The component tracks expanded keys independently of the current `Items` — keys are never purged when items disappear, so re-adding a previously expanded node restores its expanded state.
- Selection is cleared if the selected node is no longer present after filtering.
**Example — site filter on the instances page:**
```razor
<select class="form-select form-select-sm" @bind="_selectedSiteId">
<option value="">All Sites</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name</option>
}
</select>
<TreeView TItem="TreeNode" Items="GetFilteredRoots()" ...>
...
</TreeView>
@code {
private int? _selectedSiteId;
private List<TreeNode> GetFilteredRoots()
{
if (_selectedSiteId == null) return _allRoots;
return _allRoots.Where(r => r.SiteId == _selectedSiteId).ToList();
}
}
```
This keeps filter logic in the page (domain-specific) while the component handles rendering whatever it receives.
### R9 — Styling
- Uses Bootstrap 5 utility classes only (no third-party frameworks).
- No hardcoded colors — uses standard Bootstrap text/background utilities.
- Toggle icons: Unicode characters (`+` / ``) in a `<span>` with `cursor: pointer`, or a small SVG chevron. No icon library dependency.
- Compact row height for dense data (matching `table-sm` density).
- Hover effect on rows: subtle background highlight (`bg-light` or similar).
- CSS scoped to the component via Blazor CSS isolation (`TreeView.razor.css`).
### R10 — No Internal Scrolling
The tree renders inline in the page flow. The consuming page is responsible for placing it in a scrollable container if needed (e.g., `overflow-auto` with `max-height`).
### R11 — Session-Persistent Expansion State
When a user expands nodes, navigates away (e.g., clicks an instance link to the configure page), and returns to the page, the tree must restore the same expansion state.
**Mechanism:**
- The component requires a `StorageKey` parameter — a unique string identifying this tree instance (e.g., `"instances-tree"`, `"data-connections-tree"`).
- Expanded node keys are stored in browser `sessionStorage` under the key `treeview:{StorageKey}`.
- On mount (`OnAfterRenderAsync` first render), the component reads `sessionStorage` and expands any nodes whose keys are present. This takes precedence over `InitiallyExpanded`.
- On every expand/collapse toggle, the component writes the updated set of expanded keys to `sessionStorage`.
- `sessionStorage` is scoped to the browser tab — each tab has independent state. State is cleared when the tab is closed.
**Implementation note:** Blazor Server requires `IJSRuntime` to access `sessionStorage`. The component injects `IJSRuntime` and uses a small JS interop helper (inline or in a shared `.js` file) for `getItem`/`setItem`.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `StorageKey` | `string?` | No | Key for sessionStorage persistence. If null, expansion state is not persisted (in-memory only). |
### R12 — Expand All / Collapse All
The component exposes methods that the consumer can call via `@ref`:
```csharp
/// Expands all branch nodes in the tree (recursive).
public void ExpandAll();
/// Collapses all branch nodes in the tree.
public void CollapseAll();
```
**Usage:**
```razor
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _tree.ExpandAll()">Expand All</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _tree.CollapseAll()">Collapse All</button>
<TreeView @ref="_tree" TItem="TreeNode" ... />
@code {
private TreeView<TreeNode> _tree = default!;
}
```
Both methods update sessionStorage if `StorageKey` is set. `ExpandAll` requires walking the full tree via `ChildrenSelector` to collect all branch node keys.
### R13 — Programmatic Expand-to-Node
The component exposes a method to reveal a specific node by expanding all of its ancestors:
```csharp
/// Expands all ancestor nodes so that the node with the given key becomes visible.
/// Optionally selects the node and scrolls it into view.
public void RevealNode(object key, bool select = false);
```
This requires the component to build a parent lookup (key → parent key) from the tree data. When called:
1. Walk from the target node's key up to the root, collecting ancestor keys.
2. Expand all ancestors.
3. If `select` is true, set the node as selected and fire `SelectedKeyChanged`.
4. After rendering, scroll the node element into view via JS interop (`element.scrollIntoView({ block: 'nearest' })`).
**Use case:** Search box on the instances page — user types "Motor-1", results list shows matching instances. Clicking a result calls `_tree.RevealNode(instanceKey, select: true)` to expand the Site → Area path and highlight the instance.
### R14 — Accessibility (ARIA)
The component renders semantic ARIA attributes for screen reader support:
- The root `<ul>` has `role="tree"`.
- Each node `<li>` has `role="treeitem"`.
- Branch nodes have `aria-expanded="true"` or `aria-expanded="false"`.
- Child `<ul>` containers have `role="group"`.
- When `Selectable` is true, the selected node has `aria-selected="true"`.
- Each node row has a unique `id` derived from `KeySelector` for anchor targeting.
This is baseline accessibility — no keyboard navigation yet (deferred in R7), but screen readers can understand the tree structure.
### R15 — Context Menu
The component supports an optional right-click context menu on nodes, defined by the consumer via a render fragment.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `ContextMenu` | `RenderFragment<TItem>?` | No | Menu content rendered when a node is right-clicked. Receives the right-clicked `TItem`. |
**Behavior:**
- Right-clicking a node renders the `ContextMenu` fragment for that node. The component checks whether the fragment produces any content — **if the fragment renders nothing (empty markup), no menu is shown and the browser default context menu is used.** This is how per-node-type menus work: the consumer uses `@if` blocks in the fragment, and nodes that don't match any condition simply produce no output.
- When content is produced, the browser's default context menu is suppressed (`@oncontextmenu:preventDefault`) and a floating menu is shown at the cursor.
- The menu is rendered as a Bootstrap dropdown: `<div class="dropdown-menu show">` containing `<button class="dropdown-item">` elements.
- Clicking a menu item or clicking anywhere outside the menu dismisses it.
- Pressing Escape dismisses the menu.
- Only one context menu is visible at a time — right-clicking another node replaces the current menu.
- If the `ContextMenu` parameter itself is null (not provided), right-click always uses the browser default for all nodes.
**The consumer controls which items appear and what they do:**
```razor
<TreeView TItem="TreeNode" Items="_roots" ... >
<NodeContent Context="node">
<span>@node.Label</span>
</NodeContent>
<ContextMenu Context="node">
@if (node.Kind == NodeKind.Instance)
{
<button class="dropdown-item" @onclick="() => DeployInstance(node)">
Deploy
</button>
@if (node.State == InstanceState.Enabled)
{
<button class="dropdown-item" @onclick="() => DisableInstance(node)">
Disable
</button>
}
else if (node.State == InstanceState.Disabled)
{
<button class="dropdown-item" @onclick="() => EnableInstance(node)">
Enable
</button>
}
<button class="dropdown-item" @onclick="() => NavigateToConfigure(node)">
Configure
</button>
<button class="dropdown-item" @onclick="() => ShowDiff(node)">
Diff
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteInstance(node)">
Delete
</button>
}
else if (node.Kind == NodeKind.Site)
{
<button class="dropdown-item" @onclick="() => DeployAllInSite(node)">
Deploy All
</button>
}
</ContextMenu>
</TreeView>
```
This keeps the tree clean — no inline action buttons cluttering leaf nodes. Different node types can show different menu items (instances get full CRUD actions, sites might get bulk operations, areas might have no menu at all).
**Positioning:**
- The menu is absolutely positioned relative to the viewport using the mouse event's `clientX`/`clientY`.
- If the menu would overflow the viewport bottom or right edge, it flips direction (opens upward or leftward).
- The component handles positioning internally — no JS interop needed (CSS `position: fixed` with `top`/`left` from the mouse event).
### R16 — Multi-Selection (Deferred)
Future enhancement. Single selection (R5) covers current needs. A future version may add:
- `MultiSelect` bool parameter
- `SelectedKeys` / `SelectedKeysChanged` for set-based selection
- Shift+click for range select, Ctrl+click for toggle
- Use case: bulk operations (select multiple instances → deploy/disable all)
## Component API Summary
```csharp
@typeparam TItem
// Data
[Parameter] public IReadOnlyList<TItem> Items { get; set; }
[Parameter] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; }
[Parameter] public Func<TItem, bool> HasChildrenSelector { get; set; }
[Parameter] public Func<TItem, object> KeySelector { get; set; }
// Rendering
[Parameter] public RenderFragment<TItem> NodeContent { get; set; }
[Parameter] public RenderFragment? EmptyContent { get; set; }
[Parameter] public RenderFragment<TItem>? ContextMenu { get; set; }
// Layout
[Parameter] public int IndentPx { get; set; } = 24;
[Parameter] public bool ShowGuideLines { get; set; } = true;
// Expand/Collapse
[Parameter] public Func<TItem, bool>? InitiallyExpanded { get; set; }
[Parameter] public string? StorageKey { get; set; } // sessionStorage persistence key
// Selection
[Parameter] public bool Selectable { get; set; }
[Parameter] public object? SelectedKey { get; set; }
[Parameter] public EventCallback<object?> SelectedKeyChanged { get; set; }
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";
// Public methods (called via @ref)
public void ExpandAll();
public void CollapseAll();
public void RevealNode(object key, bool select = false);
```
## Usage Example: Instance Hierarchy
```razor
@* Build a unified tree model from sites, areas, and instances *@
<TreeView TItem="TreeNode" Items="_roots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => n.Key"
Selectable="true"
SelectedKey="_selectedKey"
SelectedKeyChanged="key => { _selectedKey = key; StateHasChanged(); }">
<NodeContent Context="node">
@switch (node.Kind)
{
case NodeKind.Site:
<span class="fw-semibold">@node.Label</span>
break;
case NodeKind.Area:
<span class="text-secondary">@node.Label</span>
break;
case NodeKind.Instance:
<span>@node.Label</span>
<span class="badge bg-success ms-2">Enabled</span>
break;
}
</NodeContent>
<EmptyContent>
<span class="text-muted fst-italic">No items to display.</span>
</EmptyContent>
</TreeView>
@code {
private object? _selectedKey;
private List<TreeNode> _roots = new();
record TreeNode(string Key, string Label, NodeKind Kind, List<TreeNode> Children);
enum NodeKind { Site, Area, Instance }
}
```
## Usage Example: Data Connections by Site
A simpler two-level tree — Site → Data Connections (leaves):
```
- Site A
Data Connection 1
Data Connection 2
+ Site B
+ Site C
```
```razor
<TreeView TItem="TreeNode" Items="_roots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => n.Key">
<NodeContent Context="node">
@if (node.Kind == NodeKind.Site)
{
<span class="fw-semibold">@node.Label</span>
}
else
{
<span>@node.Label</span>
<span class="badge bg-info ms-2">@node.Protocol</span>
}
</NodeContent>
</TreeView>
@code {
private List<TreeNode> _roots = new();
record TreeNode(string Key, string Label, NodeKind Kind, List<TreeNode> Children, string? Protocol = null);
enum NodeKind { Site, DataConnection }
// Build: group data connections by SiteId, wrap each site as a branch
// with its connections as leaf children
}
```
This demonstrates the component working with a flat two-level grouping — no recursive hierarchy needed. The consumer simply groups data connections by site and builds one level of children per site node.
## Tree Model Construction Pattern
The consuming page is responsible for building the tree model. The component only knows about `TItem`.
**Instance hierarchy** (deep, recursive):
1. Load sites, areas (with `ParentAreaId` hierarchy), and instances.
2. Build `Area` subtree per site using recursive `ParentAreaId` traversal.
3. Attach instances as leaf children of their assigned area (or directly under the site if `AreaId` is null).
4. Wrap each entity in a uniform `TreeNode`.
**Data connections by site** (flat, two-level):
1. Load sites and data connections.
2. Group connections by `SiteId`.
3. Each site becomes a branch node with its connections as leaf children.
## Other Potential Uses
The component is generic enough for:
- **Template inheritance tree**: Template → child templates (via `ParentTemplateId`)
- **Area management**: Site → Area hierarchy (replace current flat indentation in Areas.razor)
- **Data connections**: Site → connections (flat grouping, as shown above)
- **Navigation sidebar**: Hierarchical menu structure
- **File/folder browser**: Any nested structure
## Testing
Unit tests use the existing bUnit + xUnit + NSubstitute setup in `tests/ScadaLink.CentralUI.Tests/`. Tests live in a dedicated file: `TreeViewTests.cs`.
All tests use a simple test model:
```csharp
record TestNode(string Key, string Label, List<TestNode> Children);
```
### Test Categories
**Rendering:**
- Renders root-level items with correct labels
- Renders `EmptyContent` when `Items` is empty
- Does not render `EmptyContent` when items exist
- Leaf nodes have no expand/collapse toggle
- Branch nodes show `+` toggle when collapsed
**Expand/Collapse:**
- Clicking toggle expands node and shows children
- Clicking expanded toggle collapses node and hides children
- Children of collapsed nodes are not in the DOM
- Deep nesting: expand parent, then expand child — grandchildren visible
- `InitiallyExpanded` predicate expands matching nodes on first render
**Indentation:**
- Root nodes have zero indentation
- Child nodes are indented by `IndentPx` pixels per depth level
- Custom `IndentPx` value is applied correctly
**Selection:**
- When `Selectable` is false (default), clicking a node does not fire `SelectedKeyChanged`
- When `Selectable` is true, clicking node content fires `SelectedKeyChanged` with correct key
- Clicking expand toggle does **not** change selection
- Selected node has `SelectedCssClass` applied
- Custom `SelectedCssClass` is used when provided
**External Filtering:**
- Re-rendering with a filtered `Items` list removes hidden root nodes
- Expansion state is preserved after filter changes — expanding Site A, filtering to Site A only, then removing filter still shows Site A expanded
- Selection is cleared when the selected node disappears from filtered results
**Session Persistence (R11):**
- When `StorageKey` is null, no JS interop calls are made
- When `StorageKey` is set, expanding a node writes to sessionStorage via JS interop
- On mount with a `StorageKey`, reads sessionStorage and restores expanded nodes
- Persisted state takes precedence over `InitiallyExpanded`
*Note: sessionStorage tests mock `IJSRuntime` (already available via bUnit's `JSInterop`).*
**Expand All / Collapse All (R12):**
- `ExpandAll()` expands all branch nodes — all descendants visible
- `CollapseAll()` collapses all branch nodes — only roots visible
- `ExpandAll()` updates sessionStorage when `StorageKey` is set
- `CollapseAll()` clears sessionStorage expanded set when `StorageKey` is set
**RevealNode (R13):**
- `RevealNode(key)` expands all ancestors of the target node
- Target node's content is present in the DOM after reveal
- `RevealNode(key, select: true)` selects the node and fires `SelectedKeyChanged`
- `RevealNode` with unknown key is a no-op (does not throw)
- Deeply nested node (3+ levels) — all intermediate ancestors expanded
**Accessibility (R14):**
- Root `<ul>` has `role="tree"`
- Node `<li>` elements have `role="treeitem"`
- Expanded branch has `aria-expanded="true"`
- Collapsed branch has `aria-expanded="false"`
- Child container `<ul>` has `role="group"`
- Selected node has `aria-selected="true"` when `Selectable` is true
**Context Menu (R15):**
- Right-clicking a node shows the context menu with consumer-defined content
- Context menu is positioned at cursor coordinates
- When `ContextMenu` parameter is null, right-click does not render a menu
- When `ContextMenu` fragment renders empty content for a node type, no menu appears and browser default is used
- Right-clicking a node type with menu items shows the menu; right-clicking a node type without menu items does not
- Clicking a menu item dismisses the menu
- Clicking outside the menu dismisses it
- Right-clicking a different node replaces the current menu
### Test File Location
`tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs`
## Dependencies
- Bootstrap 5 (already included in CentralUI)
- No additional packages
- bUnit 2.0.33-preview (already in test project)
## Page Integration Notes
### 1. Instances Page (`/deployment/instances` — Instances.razor)
**Current state:** Flat table with filters (Site, Template, Status, Search), pagination, and 6 inline action buttons per row (Deploy, Disable/Enable, Configure, Diff, Delete). ~490 lines.
**Change to:**
- Replace the `<table>` with a `<TreeView>` showing Site → Area → Sub Area → Instance hierarchy.
- **Keep the existing filter bar** (Site, Template, Status, Search). Filters control which tree roots and leaves are shown:
- Site filter: pass only the matching site root to `Items`.
- Template/Status/Search filters: filter at the instance (leaf) level. Branch nodes with no matching descendants should be pruned from the tree. Build a helper method (`BuildFilteredTree()`) that walks the hierarchy bottom-up, keeping only branches that contain at least one matching instance.
- **Remove the table, pagination, and Actions column.** The tree replaces all of this.
- **Move all 6 action buttons into the `ContextMenu` fragment**, shown only for instance nodes:
- Deploy/Redeploy, Disable/Enable (conditional on state), Configure, Diff, Delete (with divider).
- Site and Area nodes get no context menu (browser default).
- **Node content per type:**
- Site nodes: `<span class="fw-semibold">SiteName</span>`
- Area nodes: `<span class="text-secondary">AreaName</span>`
- Instance nodes: `<span>UniqueName</span>` + status badge + staleness badge
- **Tree model:** Build in `LoadDataAsync` — load sites, areas (recursive via `ParentAreaId`), instances. Group instances by `SiteId` + `AreaId`. Instances with `AreaId == null` attach directly under their site. Wrap in a uniform `TreeNode` record.
- **StorageKey:** `"instances-tree"`
- **Selection:** Enable selection. Clicking an instance could show a detail panel or simply highlight it for context menu use.
**Files to modify:**
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor` — replace table with TreeView, add tree model building, move actions to context menu, keep filter bar.
**Removed code:**
- Pagination logic (`_currentPage`, `_totalPages`, `_pagedInstances`, `GoToPage`)
- Actions column markup
- `<table>` / `<thead>` / `<tbody>` structure
---
### 2. Data Connections Page (`/admin/data-connections` — DataConnections.razor)
**Current state:** Flat table listing all data connections across all sites. Columns: ID, Name, Protocol, Site, Primary Config, Backup Config, Actions (Edit, Delete). No filters. ~119 lines.
**Change to:**
- Replace the `<table>` with a `<TreeView>` showing Site → Data Connection hierarchy (two levels, no recursion).
- **No filter bar needed initially** — the tree naturally groups by site. If the number of sites grows, a site filter dropdown can be added later using the external filtering pattern.
- **Move Edit and Delete into the `ContextMenu` fragment**, shown only for data connection nodes:
- Edit → navigates to `/admin/data-connections/{id}/edit`
- Delete → shows confirm dialog, then deletes
- Site nodes get no context menu.
- **Node content per type:**
- Site nodes: `<span class="fw-semibold">SiteName</span>` + child count badge (e.g., `<span class="badge bg-secondary ms-1">3</span>`)
- Data Connection nodes: `<span>Name</span>` + protocol badge (e.g., `<span class="badge bg-info ms-2">OPC UA</span>`)
- **Tree model:** Group data connections by `SiteId`. Each site becomes a branch, its connections become leaves. Sites with no connections still appear as empty branches (expandable but no children).
- **StorageKey:** `"data-connections-tree"`
**Files to modify:**
- `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor` — replace table with TreeView, add tree model building, move actions to context menu.
**Removed code:**
- `<table>` / `<thead>` / `<tbody>` structure
- Inline Edit/Delete buttons
---
### 3. Areas Page (`/admin/areas` — Areas.razor)
**Current state:** Two-panel layout. Left panel: site list (`list-group`). Right panel: manually indented flat tree of areas for the selected site, with `[+]`/`-` indicators, inline Edit/Delete buttons, and an add/edit form. Custom `BuildFlatTree()` / `AddChildren()` methods, `AreaTreeNode` record, manual `padding-left` indentation. ~293 lines.
**Change to:**
- **Keep the two-panel layout** (site list on left, area tree on right).
- Replace the custom flat-tree rendering in the right panel with a `<TreeView>` component.
- **Site selection stays as-is** (left panel `list-group` click sets `_selectedSiteId`). This acts as the external filter — the TreeView receives only the selected site's areas as `Items`.
- **Move Edit and Delete into the `ContextMenu` fragment** for area nodes:
- Edit → loads area into the add/edit form (same as current behavior)
- Delete → shows confirm dialog (with child check, same as current)
- **Node content:** `<span>AreaName</span>` — optionally show instance count if available.
- **Tree model:** For the selected site, load root areas (`ParentAreaId == null`), use `ChildrenSelector` to return child areas. The `Area` entity already has `Children` collection, so it can be used directly as `TItem` without a wrapper record — `ChildrenSelector = a => a.Children.ToList()`, `HasChildrenSelector = a => a.Children.Any()`, `KeySelector = a => a.Id`.
- **Keep the add/edit form** at the top of the right panel (above the tree). The "Parent Area" dropdown stays.
- **StorageKey:** `"areas-tree"`
**Files to modify:**
- `src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor` — replace custom flat-tree rendering with TreeView, remove `BuildFlatTree()`, `AddChildren()`, `AreaTreeNode` record, manual indentation CSS.
**Removed code:**
- `BuildFlatTree()` method
- `AddChildren()` recursive helper
- `AreaTreeNode` record
- Manual `padding-left` indentation
- Custom `[+]`/`-` toggle rendering
- Inline Edit/Delete buttons in the tree rows
## Interactions
- **DataTable**: The tree replaces flat tables on the three pages listed above. Other pages that don't need hierarchy continue using DataTable.
- **InstanceConfigure.razor**: Right-click → Configure on an instance node navigates to `/deployment/instances/{Id}/configure`.
- **Areas.razor**: The simplest integration — `Area` entity used directly as `TItem`, no wrapper needed.
@@ -0,0 +1,185 @@
# LmxProxy Stale Session Subscription Leak Fix
## Problem
When a gRPC client disconnects abruptly, Grpc.Core (the C-core library used by the .NET Framework 4.8 server) does not reliably fire the `ServerCallContext.CancellationToken`. This means:
1. The `Subscribe` RPC in `ScadaGrpcService` blocks forever on `reader.WaitToReadAsync(context.CancellationToken)` (line 368)
2. The `finally` block with `_subscriptionManager.UnsubscribeClient(request.SessionId)` never runs
3. The `ct.Register(() => UnsubscribeClient(clientId))` in `SubscriptionManager.SubscribeAsync` also never fires (same token)
4. The old session's subscriptions leak in `SubscriptionManager._clientSubscriptions` and `_tagSubscriptions`
When the client reconnects with a new session ID, it creates duplicate subscriptions. Tags aren't cleaned up because they still have a ref-count from the leaked old session. Over time, client count grows and tag subscriptions accumulate.
The `SessionManager` does scavenge inactive sessions after 5 minutes, but it only removes the session from its own dictionary — it doesn't notify `SubscriptionManager` to clean up subscriptions.
## Fix
Bridge `SessionManager` scavenging to `SubscriptionManager` cleanup. When a session is scavenged due to inactivity, also call `SubscriptionManager.UnsubscribeClient()`.
### Step 1: Add cleanup callback to SessionManager
File: `src/ZB.MOM.WW.LmxProxy.Host/Sessions/SessionManager.cs`
Add a callback field and expose it:
```csharp
// Add after the _inactivityTimeout field (line 22)
private Action<string>? _onSessionScavenged;
/// <summary>
/// Register a callback invoked when a session is scavenged due to inactivity.
/// The callback receives the session ID.
/// </summary>
public void OnSessionScavenged(Action<string> callback)
{
_onSessionScavenged = callback;
}
```
Then in `ScavengeInactiveSessions`, invoke the callback for each scavenged session:
```csharp
// In ScavengeInactiveSessions (line 103-118), change the foreach to:
foreach (var kvp in expired)
{
if (_sessions.TryRemove(kvp.Key, out _))
{
Log.Information("Session {SessionId} scavenged (inactive since {LastActivity})",
kvp.Key, kvp.Value.LastActivity);
// Notify subscriber cleanup
try
{
_onSessionScavenged?.Invoke(kvp.Key);
}
catch (Exception ex)
{
Log.Warning(ex, "Error in session scavenge callback for {SessionId}", kvp.Key);
}
}
}
```
### Step 2: Wire up the callback in LmxProxyService
File: `src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs`
After both `SessionManager` and `SubscriptionManager` are created, register the callback:
```csharp
// Add after SubscriptionManager creation:
_sessionManager.OnSessionScavenged(sessionId =>
{
Log.Information("Cleaning up subscriptions for scavenged session {SessionId}", sessionId);
_subscriptionManager.UnsubscribeClient(sessionId);
});
```
Find where `_sessionManager` and `_subscriptionManager` are both initialized and add this line right after.
### Step 3: Also clean up on explicit Disconnect
This is already handled — `ScadaGrpcService.Disconnect()` (line 86) calls `_subscriptionManager.UnsubscribeClient(request.SessionId)` before terminating the session. No change needed.
### Step 4: Add proactive stream timeout (belt-and-suspenders)
The scavenger runs every 60 seconds with a 5-minute timeout. This means a leaked session could take up to 6 minutes to clean up. For faster detection, add a secondary timeout in the Subscribe RPC itself.
File: `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Services/ScadaGrpcService.cs`
In the `Subscribe` method, replace the simple `context.CancellationToken` with a combined token that also expires if the session becomes invalid:
```csharp
// Replace the Subscribe method (lines 353-390) with:
public override async Task Subscribe(
Scada.SubscribeRequest request,
IServerStreamWriter<Scada.VtqMessage> responseStream,
ServerCallContext context)
{
if (!_sessionManager.ValidateSession(request.SessionId))
{
throw new RpcException(new GrpcStatus(StatusCode.Unauthenticated, "Invalid session"));
}
var reader = await _subscriptionManager.SubscribeAsync(
request.SessionId, request.Tags, context.CancellationToken);
try
{
// Use a combined approach: check both the gRPC cancellation token AND
// periodic session validity. This works around Grpc.Core not reliably
// firing CancellationToken on client disconnect.
while (true)
{
// Wait for data with a timeout so we can periodically check session validity
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
context.CancellationToken, timeoutCts.Token);
bool hasData;
try
{
hasData = await reader.WaitToReadAsync(linkedCts.Token);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested
&& !context.CancellationToken.IsCancellationRequested)
{
// Timeout expired, not a client disconnect — check if session is still valid
if (!_sessionManager.ValidateSession(request.SessionId))
{
Log.Information("Subscribe stream ending — session {SessionId} no longer valid",
request.SessionId);
break;
}
continue; // Session still valid, keep waiting
}
if (!hasData) break; // Channel completed
while (reader.TryRead(out var item))
{
var protoVtq = ConvertToProtoVtq(item.address, item.vtq);
await responseStream.WriteAsync(protoVtq);
}
}
}
catch (OperationCanceledException)
{
// Client disconnected -- normal
}
catch (Exception ex)
{
Log.Error(ex, "Subscribe stream error for session {SessionId}", request.SessionId);
throw new RpcException(new GrpcStatus(StatusCode.Internal, ex.Message));
}
finally
{
_subscriptionManager.UnsubscribeClient(request.SessionId);
}
}
```
This adds a 30-second periodic check: if no data arrives for 30 seconds, it checks whether the session is still valid. If the session was scavenged (client disconnected, 5-min timeout), the stream exits cleanly and runs the `finally` cleanup.
## Summary of Changes
| File | Change |
|------|--------|
| `Sessions/SessionManager.cs` | Add `_onSessionScavenged` callback, invoke during `ScavengeInactiveSessions` |
| `LmxProxyService.cs` | Wire `_sessionManager.OnSessionScavenged` to `_subscriptionManager.UnsubscribeClient` |
| `Grpc/Services/ScadaGrpcService.cs` | Add 30-second periodic session validity check in `Subscribe` loop |
## Testing
1. Start LmxProxy server
2. Connect a client and subscribe to tags
3. Kill the client process abruptly (not a clean disconnect)
4. Check status page — client count should still show the old session
5. Wait up to 5 minutes — session should be scavenged, subscription count should drop
6. Reconnect client — should get a clean new session, no duplicate subscriptions
7. Verify tag subscription counts match expected (no leaked refs)
## Optional: Reduce scavenge timeout for faster cleanup
In `LmxProxyService.cs` where `SessionManager` is constructed, consider reducing `inactivityTimeoutMinutes` from 5 to 2, since the Subscribe RPC now has its own 30-second validity check. The 5-minute timeout was the only cleanup path before; now it's belt-and-suspenders.
@@ -0,0 +1,127 @@
@page "/admin/areas/add"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Instances
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">&larr; Back</a>
<h4 class="mb-0">Add Area</h4>
</div>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="card" style="max-width: 500px;">
<div class="card-body">
<div class="mb-3">
<label class="form-label small">Site</label>
<input type="text" class="form-control form-control-sm" value="@_siteName" readonly />
</div>
<div class="mb-3">
<label class="form-label small">Parent Area</label>
<select class="form-select form-select-sm" @bind="_parentAreaId">
<option value="0">(Root level)</option>
@foreach (var area in _areas)
{
<option value="@area.Id">@GetAreaPath(area)</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_name" placeholder="Area name" />
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<button class="btn btn-success btn-sm" @onclick="Save" disabled="@_saving">Save</button>
</div>
</div>
}
</div>
@code {
[SupplyParameterFromQuery] public int SiteId { get; set; }
[SupplyParameterFromQuery] public int ParentAreaId { get; set; }
private string _siteName = string.Empty;
private List<Area> _areas = new();
private int _parentAreaId;
private string _name = string.Empty;
private string? _formError;
private string? _errorMessage;
private bool _loading = true;
private bool _saving;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
try
{
var site = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.Id == SiteId);
_siteName = site?.Name ?? $"Site #{SiteId}";
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(SiteId)).ToList();
_parentAreaId = ParentAreaId;
}
catch (Exception ex)
{
_errorMessage = $"Failed to load: {ex.Message}";
}
_loading = false;
}
private string GetAreaPath(Area area)
{
var parts = new List<string>();
var current = area;
while (current != null)
{
parts.Insert(0, current.Name);
current = current.ParentAreaId.HasValue
? _areas.FirstOrDefault(a => a.Id == current.ParentAreaId.Value)
: null;
}
return string.Join(" / ", parts);
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name)) { _formError = "Name is required."; return; }
_saving = true;
try
{
var area = new Area(_name.Trim())
{
SiteId = SiteId,
ParentAreaId = _parentAreaId == 0 ? null : _parentAreaId
};
await TemplateEngineRepository.AddAreaAsync(area);
await TemplateEngineRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/areas");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
_saving = false;
}
}
@@ -0,0 +1,199 @@
@page "/admin/areas/{Id:int}/delete"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Instances
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">&larr; Back</a>
<h4 class="mb-0">Delete Area</h4>
</div>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="card mb-3" style="max-width: 700px;">
<div class="card-body">
<p>
You are about to delete area <strong>@_area!.Name</strong>.
@if (_hasBlockingInstances)
{
<span class="text-danger">This area (or its children) has instances assigned. Remove or reassign instances before deleting.</span>
}
</p>
<h6 class="mt-3">Area hierarchy with assigned instances:</h6>
<TreeView TItem="DeleteTreeNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => n.Key"
InitiallyExpanded="_ => true">
<NodeContent Context="node">
@if (node.Kind == DeleteNodeKind.Area)
{
<span class="@(node.HasInstances ? "text-danger fw-semibold" : "")">@node.Label</span>
@if (node.HasInstances)
{
<span class="badge bg-danger ms-1">@node.InstanceCount instance(s)</span>
}
}
else
{
<span class="text-muted small">@node.Label</span>
}
</NodeContent>
<EmptyContent>
<span class="text-muted fst-italic">No child areas.</span>
</EmptyContent>
</TreeView>
<div class="mt-3">
@if (_hasBlockingInstances)
{
<button class="btn btn-danger btn-sm" disabled>Delete (blocked)</button>
}
else
{
<button class="btn btn-danger btn-sm" @onclick="Delete" disabled="@_deleting">Delete Area</button>
}
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm ms-2">Cancel</a>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int Id { get; set; }
record DeleteTreeNode(string Key, string Label, DeleteNodeKind Kind, List<DeleteTreeNode> Children,
bool HasInstances = false, int InstanceCount = 0);
enum DeleteNodeKind { Area, Instance }
private Area? _area;
private List<DeleteTreeNode> _treeRoots = new();
private bool _hasBlockingInstances;
private bool _loading = true;
private bool _deleting;
private string? _errorMessage;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
try
{
_area = await TemplateEngineRepository.GetAreaByIdAsync(Id);
if (_area == null)
{
_errorMessage = $"Area #{Id} not found.";
_loading = false;
return;
}
// Load all areas for this site to build hierarchy
var allAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_area.SiteId)).ToList();
var allInstances = (await TemplateEngineRepository.GetAllInstancesAsync())
.Where(i => i.SiteId == _area.SiteId)
.ToList();
var rootNode = BuildDeleteTree(_area, allAreas, allInstances);
_treeRoots = new List<DeleteTreeNode> { rootNode };
_hasBlockingInstances = HasAnyInstances(rootNode);
}
catch (Exception ex)
{
_errorMessage = $"Failed to load: {ex.Message}";
}
_loading = false;
}
private DeleteTreeNode BuildDeleteTree(Area area, List<Area> allAreas, List<Instance> allInstances)
{
var children = new List<DeleteTreeNode>();
// Add child areas recursively
var childAreas = allAreas.Where(a => a.ParentAreaId == area.Id).OrderBy(a => a.Name);
foreach (var child in childAreas)
{
children.Add(BuildDeleteTree(child, allAreas, allInstances));
}
// Add instances assigned to this area
var areaInstances = allInstances.Where(i => i.AreaId == area.Id).OrderBy(i => i.UniqueName);
foreach (var inst in areaInstances)
{
children.Add(new DeleteTreeNode(
Key: $"inst-{inst.Id}",
Label: inst.UniqueName,
Kind: DeleteNodeKind.Instance,
Children: new()));
}
var instanceCount = areaInstances.Count();
return new DeleteTreeNode(
Key: $"area-{area.Id}",
Label: area.Name,
Kind: DeleteNodeKind.Area,
Children: children,
HasInstances: instanceCount > 0,
InstanceCount: instanceCount);
}
private bool HasAnyInstances(DeleteTreeNode node)
{
if (node.Kind == DeleteNodeKind.Instance) return true;
return node.Children.Any(HasAnyInstances);
}
private async Task Delete()
{
var confirmed = await _confirmDialog.ShowAsync(
$"Permanently delete area '{_area!.Name}' and all its child areas?",
"Confirm Delete");
if (!confirmed) return;
_deleting = true;
try
{
// Delete child areas bottom-up (deepest first)
await DeleteAreaRecursive(_area!);
await TemplateEngineRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/areas");
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
_deleting = false;
}
private async Task DeleteAreaRecursive(Area area)
{
// Load fresh children in case the collection wasn't populated
var allAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(area.SiteId)).ToList();
var children = allAreas.Where(a => a.ParentAreaId == area.Id).ToList();
foreach (var child in children)
{
await DeleteAreaRecursive(child);
}
await TemplateEngineRepository.DeleteAreaAsync(area.Id);
}
}
@@ -0,0 +1,95 @@
@page "/admin/areas/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Instances
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<a href="/admin/areas" class="btn btn-outline-secondary btn-sm me-3">&larr; Back</a>
<h4 class="mb-0">Edit Area</h4>
</div>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="card" style="max-width: 500px;">
<div class="card-body">
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_name" />
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<button class="btn btn-success btn-sm" @onclick="Save" disabled="@_saving">Save</button>
</div>
</div>
}
</div>
@code {
[Parameter] public int Id { get; set; }
private Area? _area;
private string _name = string.Empty;
private string? _formError;
private string? _errorMessage;
private bool _loading = true;
private bool _saving;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
try
{
_area = await TemplateEngineRepository.GetAreaByIdAsync(Id);
if (_area == null)
{
_errorMessage = $"Area #{Id} not found.";
}
else
{
_name = _area.Name;
}
}
catch (Exception ex)
{
_errorMessage = $"Failed to load area: {ex.Message}";
}
_loading = false;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name)) { _formError = "Name is required."; return; }
_saving = true;
try
{
_area!.Name = _name.Trim();
await TemplateEngineRepository.UpdateAreaAsync(_area);
await TemplateEngineRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/areas");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
_saving = false;
}
}
@@ -6,14 +6,12 @@
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ISiteRepository SiteRepository
@inject ITemplateEngineRepository TemplateEngineRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Area Management</h4>
</div>
<h4 class="mb-3">Area Management</h4>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" />
@if (_loading)
{
@@ -25,268 +23,111 @@
}
else
{
<div class="row">
<div class="col-md-3">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Sites</h6>
</div>
<div class="list-group list-group-flush">
@if (_sites.Count == 0)
<TreeView TItem="AreaTreeNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => n.Key"
StorageKey="areas-tree">
<NodeContent Context="node">
@if (node.Kind == AreaNodeKind.Site)
{
<div class="list-group-item text-muted small">No sites configured.</div>
<span class="fw-semibold">@node.Label</span>
<span class="badge bg-secondary ms-1">@node.AreaCount</span>
}
@foreach (var site in _sites)
else
{
<button type="button"
class="list-group-item list-group-item-action @(site.Id == _selectedSiteId ? "active" : "")"
@onclick="() => SelectSite(site.Id)">
@site.Name
<span>@node.Label</span>
}
</NodeContent>
<ContextMenu Context="node">
@if (node.Kind == AreaNodeKind.Site)
{
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/add?siteId={node.SiteId}")'>
Add Area
</button>
}
</div>
</div>
</div>
<div class="col-md-9">
@if (_selectedSiteId == 0)
{
<div class="text-muted">Select a site to manage its areas.</div>
}
else
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Areas for @(_sites.FirstOrDefault(s => s.Id == _selectedSiteId)?.Name)</h5>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Area</button>
</div>
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingArea == null ? "Add New Area" : "Edit Area")</h6>
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
@if (_editingArea == null)
{
<div class="col-md-4">
<label class="form-label small">Parent Area</label>
<select class="form-select form-select-sm" @bind="_formParentAreaId">
<option value="0">(Root level)</option>
@foreach (var area in _areas)
{
<option value="@area.Id">@GetAreaPath(area)</option>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/add?siteId={node.SiteId}&parentAreaId={node.Area!.Id}")'>
Add Child Area
</button>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/{node.Area!.Id}/edit")'>
Edit Area
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger"
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/{node.Area!.Id}/delete")'>
Delete Area
</button>
}
</select>
</div>
}
<div class="col-md-4">
<button class="btn btn-success btn-sm me-1" @onclick="SaveArea">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
</div>
</div>
}
@if (_areas.Count == 0)
{
<div class="text-muted">No areas configured for this site.</div>
}
else
{
<div class="card">
<div class="card-body p-2">
@foreach (var node in BuildFlatTree())
{
<div class="d-flex align-items-center py-1 border-bottom"
style="padding-left: @(node.Depth * 24 + 8)px;">
<span class="me-2 text-muted small">
@(node.HasChildren ? "[+]" : " -")
</span>
<span class="flex-grow-1">@node.Area.Name</span>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditArea(node.Area)">Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteArea(node.Area)">Delete</button>
</div>
}
</div>
</div>
}
}
</div>
</div>
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No sites configured.</span>
</EmptyContent>
</TreeView>
}
</div>
@code {
record AreaTreeNode(string Key, string Label, AreaNodeKind Kind, List<AreaTreeNode> Children,
int SiteId, Area? Area = null, int AreaCount = 0);
enum AreaNodeKind { Site, Area }
private List<Site> _sites = new();
private List<Area> _areas = new();
private int _selectedSiteId;
private List<AreaTreeNode> _treeRoots = new();
private bool _loading = true;
private string? _errorMessage;
private bool _showForm;
private Area? _editingArea;
private string _formName = string.Empty;
private int _formParentAreaId;
private string? _formError;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
_treeRoots = new();
foreach (var site in _sites)
{
var areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id)).ToList();
var rootAreas = areas.Where(a => a.ParentAreaId == null).OrderBy(a => a.Name);
var children = rootAreas.Select(a => BuildAreaNode(a, site.Id)).ToList();
_treeRoots.Add(new AreaTreeNode(
Key: $"site-{site.Id}",
Label: site.Name,
Kind: AreaNodeKind.Site,
Children: children,
SiteId: site.Id,
AreaCount: areas.Count));
}
}
catch (Exception ex)
{
_errorMessage = $"Failed to load sites: {ex.Message}";
_errorMessage = $"Failed to load data: {ex.Message}";
}
_loading = false;
}
private async Task SelectSite(int siteId)
private AreaTreeNode BuildAreaNode(Area area, int siteId)
{
_selectedSiteId = siteId;
_showForm = false;
await LoadAreasAsync();
}
private async Task LoadAreasAsync()
{
try
{
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load areas: {ex.Message}";
}
}
private record AreaTreeNode(Area Area, int Depth, bool HasChildren);
private List<AreaTreeNode> BuildFlatTree()
{
var result = new List<AreaTreeNode>();
AddChildren(null, 0, result);
return result;
}
private void AddChildren(int? parentId, int depth, List<AreaTreeNode> result)
{
var children = _areas.Where(a => a.ParentAreaId == parentId).OrderBy(a => a.Name);
foreach (var child in children)
{
var hasChildren = _areas.Any(a => a.ParentAreaId == child.Id);
result.Add(new AreaTreeNode(child, depth, hasChildren));
AddChildren(child.Id, depth + 1, result);
}
}
private string GetAreaPath(Area area)
{
var parts = new List<string>();
var current = area;
while (current != null)
{
parts.Insert(0, current.Name);
current = current.ParentAreaId.HasValue
? _areas.FirstOrDefault(a => a.Id == current.ParentAreaId.Value)
: null;
}
return string.Join(" / ", parts);
}
private void ShowAddForm()
{
_editingArea = null;
_formName = string.Empty;
_formParentAreaId = 0;
_formError = null;
_showForm = true;
}
private void EditArea(Area area)
{
_editingArea = area;
_formName = area.Name;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingArea = null;
_formError = null;
}
private async Task SaveArea()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
try
{
if (_editingArea != null)
{
_editingArea.Name = _formName.Trim();
await TemplateEngineRepository.UpdateAreaAsync(_editingArea);
}
else
{
var area = new Area(_formName.Trim())
{
SiteId = _selectedSiteId,
ParentAreaId = _formParentAreaId == 0 ? null : _formParentAreaId
};
await TemplateEngineRepository.AddAreaAsync(area);
}
await TemplateEngineRepository.SaveChangesAsync();
_showForm = false;
_editingArea = null;
_toast.ShowSuccess("Area saved.");
await LoadAreasAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteArea(Area area)
{
var hasChildren = _areas.Any(a => a.ParentAreaId == area.Id);
var message = hasChildren
? $"Area '{area.Name}' has child areas. Delete child areas first."
: $"Delete area '{area.Name}'?";
var confirmed = await _confirmDialog.ShowAsync(message, "Delete Area");
if (!confirmed) return;
try
{
await TemplateEngineRepository.DeleteAreaAsync(area.Id);
await TemplateEngineRepository.SaveChangesAsync();
_toast.ShowSuccess($"Area '{area.Name}' deleted.");
await LoadAreasAsync();
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
var children = area.Children
.OrderBy(c => c.Name)
.Select(c => BuildAreaNode(c, siteId))
.ToList();
return new AreaTreeNode(
Key: $"area-{area.Id}",
Label: area.Name,
Kind: AreaNodeKind.Area,
Children: children,
SiteId: siteId,
Area: area);
}
}
@@ -25,50 +25,51 @@
}
else
{
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Name</th>
<th>Protocol</th>
<th>Site</th>
<th>Primary Config</th>
<th>Backup Config</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@if (_connections.Count == 0)
<TreeView TItem="DcTreeNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => n.Key"
StorageKey="data-connections-tree">
<NodeContent Context="node">
@if (node.Kind == DcNodeKind.Site)
{
<tr>
<td colspan="7" class="text-muted text-center">No data connections configured.</td>
</tr>
<span class="fw-semibold">@node.Label</span>
<span class="badge bg-secondary ms-1">@node.Children.Count</span>
}
@foreach (var conn in _connections)
else
{
<tr>
<td>@conn.Id</td>
<td>@conn.Name</td>
<td><span class="badge bg-secondary">@conn.Protocol</span></td>
<td>@(_siteLookup.GetValueOrDefault(conn.SiteId)?.Name ?? $"Site {conn.SiteId}")</td>
<td class="text-muted small text-truncate" style="max-width: 300px;">@(conn.PrimaryConfiguration ?? "—")</td>
<td class="text-muted small text-truncate" style="max-width: 300px;">@(conn.BackupConfiguration ?? "—")</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick='() => NavigationManager.NavigateTo($"/admin/data-connections/{conn.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteConnection(conn)">Delete</button>
</td>
</tr>
<span>@node.Label</span>
<span class="badge bg-info ms-2">@node.Connection!.Protocol</span>
}
</tbody>
</table>
</NodeContent>
<ContextMenu Context="node">
@if (node.Kind == DcNodeKind.DataConnection)
{
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/admin/data-connections/{node.Connection!.Id}/edit")'>
Edit
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger"
@onclick="() => DeleteConnection(node.Connection!)">
Delete
</button>
}
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No data connections configured.</span>
</EmptyContent>
</TreeView>
}
</div>
@code {
record DcTreeNode(string Key, string Label, DcNodeKind Kind, List<DcTreeNode> Children,
DataConnection? Connection = null);
enum DcNodeKind { Site, DataConnection }
private List<DcTreeNode> _treeRoots = new();
private List<DataConnection> _connections = new();
private Dictionary<int, Site> _siteLookup = new();
private bool _loading = true;
private string? _errorMessage;
@@ -87,8 +88,22 @@
try
{
var sites = await SiteRepository.GetAllSitesAsync();
_siteLookup = sites.ToDictionary(s => s.Id);
_connections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList();
var connBySite = _connections.GroupBy(c => c.SiteId).ToDictionary(g => g.Key, g => g.ToList());
_treeRoots = sites.Select(site => new DcTreeNode(
Key: $"site-{site.Id}",
Label: site.Name,
Kind: DcNodeKind.Site,
Children: (connBySite.GetValueOrDefault(site.Id) ?? new())
.Select(c => new DcTreeNode(
Key: $"conn-{c.Id}",
Label: c.Name,
Kind: DcNodeKind.DataConnection,
Children: new(),
Connection: c))
.ToList()
)).ToList();
}
catch (Exception ex)
{
@@ -29,7 +29,7 @@
<div class="row mb-3 g-2">
<div class="col-md-3">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_selectedSiteId" @bind:after="LoadInstancesForSite">
<select class="form-select form-select-sm" @bind="_selectedSiteId" @bind:after="LoadInstancesForSite" disabled="@_connected">
<option value="0">Select site...</option>
@foreach (var site in _sites)
{
@@ -39,7 +39,7 @@
</div>
<div class="col-md-4">
<label class="form-label small">Instance</label>
<select class="form-select form-select-sm" @bind="_selectedInstanceId" @bind:after="OnInstanceSelectionChanged">
<select class="form-select form-select-sm" @bind="_selectedInstanceId" @bind:after="OnInstanceSelectionChanged" disabled="@_connected">
<option value="0">Select instance...</option>
@foreach (var inst in _siteInstances)
{
@@ -75,100 +75,69 @@
</div>
</div>
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Instance Name</th>
<th>Template</th>
<th>Site</th>
<th>Area</th>
<th>Status</th>
<th>Staleness</th>
<th style="width: 240px;">Actions</th>
</tr>
</thead>
<tbody>
@if (_filteredInstances.Count == 0)
<TreeView @ref="_instanceTree" TItem="InstanceTreeNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => n.Key"
StorageKey="instances-tree"
Selectable="true"
SelectedKey="_selectedKey"
SelectedKeyChanged="key => { _selectedKey = key; }">
<NodeContent Context="node">
@switch (node.Kind)
{
<tr>
<td colspan="7" class="text-muted text-center">No instances match the current filters.</td>
</tr>
}
@foreach (var inst in _pagedInstances)
case InstanceNodeKind.Site:
<span class="fw-semibold">@node.Label</span>
break;
case InstanceNodeKind.Area:
<span class="text-secondary">@node.Label</span>
break;
case InstanceNodeKind.Instance:
<span>@node.Label</span>
<span class="badge @GetStateBadge(node.Instance!.State) ms-1">@node.Instance!.State</span>
@if (node.Instance!.State != InstanceState.NotDeployed)
{
<tr>
<td><strong>@inst.UniqueName</strong></td>
<td>@GetTemplateName(inst.TemplateId)</td>
<td>@GetSiteName(inst.SiteId)</td>
<td>@(inst.AreaId.HasValue ? GetAreaName(inst.AreaId.Value) : "—")</td>
<td>
<span class="badge @GetStateBadge(inst.State)">@inst.State</span>
</td>
<td>
@{
var isStale = _stalenessMap.GetValueOrDefault(inst.Id);
<span class="badge @(node.IsStale ? "bg-warning text-dark" : "bg-light text-dark") ms-1">
@(node.IsStale ? "Stale" : "Current")
</span>
}
@if (inst.State == InstanceState.NotDeployed)
break;
}
</NodeContent>
<ContextMenu Context="node">
@if (node.Kind == InstanceNodeKind.Instance)
{
<span class="text-muted small">—</span>
}
else if (isStale)
{
<span class="badge bg-warning text-dark" title="Template changes pending">Stale</span>
}
else
{
<span class="badge bg-light text-dark">Current</span>
}
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => DeployInstance(inst)" disabled="@_actionInProgress"
title="Flatten template and send config to site">@(isStale ? "Redeploy" : "Deploy")</button>
var inst = node.Instance!;
var isStale = node.IsStale;
<button class="dropdown-item" @onclick="() => DeployInstance(inst)"
disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
@if (inst.State == InstanceState.Enabled)
{
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
@onclick="() => DisableInstance(inst)" disabled="@_actionInProgress">Disable</button>
<button class="dropdown-item" @onclick="() => DisableInstance(inst)"
disabled="@_actionInProgress">Disable</button>
}
else if (inst.State == InstanceState.Disabled)
{
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
@onclick="() => EnableInstance(inst)" disabled="@_actionInProgress">Enable</button>
<button class="dropdown-item" @onclick="() => EnableInstance(inst)"
disabled="@_actionInProgress">Enable</button>
}
<button class="btn btn-outline-info btn-sm py-0 px-1 me-1"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>Configure</button>
<button class="btn btn-outline-info btn-sm py-0 px-1 me-1"
@onclick="() => ShowDiff(inst)" disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteInstance(inst)" disabled="@_actionInProgress">Delete</button>
</td>
</tr>
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>
Configure
</button>
<button class="dropdown-item" @onclick="() => ShowDiff(inst)"
disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteInstance(inst)"
disabled="@_actionInProgress">Delete</button>
}
</tbody>
</table>
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No instances match the current filters.</span>
</EmptyContent>
</TreeView>
@* Pagination *@
@if (_totalPages > 1)
{
<nav>
<ul class="pagination pagination-sm justify-content-end">
<li class="page-item @(_currentPage <= 1 ? "disabled" : "")">
<button class="page-link" @onclick="() => GoToPage(_currentPage - 1)">Previous</button>
</li>
@for (int i = 1; i <= _totalPages; i++)
{
var page = i;
<li class="page-item @(page == _currentPage ? "active" : "")">
<button class="page-link" @onclick="() => GoToPage(page)">@(page)</button>
</li>
}
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
<button class="page-link" @onclick="() => GoToPage(_currentPage + 1)">Next</button>
</li>
</ul>
</nav>
}
<div class="text-muted small">
<div class="text-muted small mt-2">
@_filteredInstances.Count instance(s) total
</div>
@@ -230,9 +199,13 @@
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
record InstanceTreeNode(string Key, string Label, InstanceNodeKind Kind,
List<InstanceTreeNode> Children, Instance? Instance = null,
bool IsStale = false);
enum InstanceNodeKind { Site, Area, Instance }
private List<Instance> _allInstances = new();
private List<Instance> _filteredInstances = new();
private List<Instance> _pagedInstances = new();
private List<Site> _sites = new();
private List<Template> _templates = new();
private List<Area> _allAreas = new();
@@ -246,9 +219,9 @@
private string _filterStatus = string.Empty;
private string _filterSearch = string.Empty;
private int _currentPage = 1;
private int _totalPages;
private const int PageSize = 25;
private List<InstanceTreeNode> _treeRoots = new();
private TreeView<InstanceTreeNode> _instanceTree = default!;
private object? _selectedKey;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
@@ -312,26 +285,66 @@
return true;
}).OrderBy(i => i.UniqueName).ToList();
_totalPages = Math.Max(1, (int)Math.Ceiling(_filteredInstances.Count / (double)PageSize));
if (_currentPage > _totalPages) _currentPage = 1;
UpdatePage();
BuildTree();
}
private void GoToPage(int page)
private void BuildTree()
{
if (page < 1 || page > _totalPages) return;
_currentPage = page;
UpdatePage();
}
_treeRoots = _sites.Select(site =>
{
var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
var siteInstances = _filteredInstances.Where(i => i.SiteId == site.Id).ToList();
private void UpdatePage()
{
_pagedInstances = _filteredInstances
.Skip((_currentPage - 1) * PageSize)
.Take(PageSize)
var areaNodes = BuildAreaNodes(siteAreas, siteInstances, parentId: null);
var unassigned = siteInstances
.Where(i => i.AreaId == null)
.Select(MakeInstanceNode)
.ToList();
var children = areaNodes.Concat(unassigned).ToList();
return new InstanceTreeNode(
Key: $"site-{site.Id}",
Label: site.Name,
Kind: InstanceNodeKind.Site,
Children: children);
})
.Where(s => s.Children.Count > 0)
.ToList();
}
private List<InstanceTreeNode> BuildAreaNodes(List<Area> allAreas,
List<Instance> instances, int? parentId)
{
return allAreas
.Where(a => a.ParentAreaId == parentId)
.Select(area =>
{
var childAreas = BuildAreaNodes(allAreas, instances, area.Id);
var areaInstances = instances
.Where(i => i.AreaId == area.Id)
.Select(MakeInstanceNode)
.ToList();
var children = childAreas.Concat(areaInstances).ToList();
return new InstanceTreeNode(
Key: $"area-{area.Id}",
Label: area.Name,
Kind: InstanceNodeKind.Area,
Children: children);
})
.Where(a => a.Children.Count > 0)
.ToList();
}
private InstanceTreeNode MakeInstanceNode(Instance inst) => new(
Key: $"inst-{inst.Id}",
Label: inst.UniqueName,
Kind: InstanceNodeKind.Instance,
Children: new(),
Instance: inst,
IsStale: _stalenessMap.GetValueOrDefault(inst.Id));
private string GetTemplateName(int templateId) =>
_templates.FirstOrDefault(t => t.Id == templateId)?.Name ?? $"#{templateId}";
@@ -33,16 +33,14 @@
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>New Template</button>
</div>
@* Inheritance tree visualization *@
<div class="card">
<div class="card-body p-2">
@foreach (var node in BuildTemplateTree())
{
<div class="d-flex align-items-center py-1 border-bottom"
style="padding-left: @(node.Depth * 24 + 8)px; cursor: pointer;"
@onclick="() => SelectTemplate(node.Template.Id)">
<span class="me-2 text-muted small">@(node.HasChildren ? "[+]" : " -")</span>
<span class="flex-grow-1">
<TreeView TItem="TmplTreeNode" Items="_templateTreeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => (object)n.Template.Id"
StorageKey="templates-tree"
Selectable="true"
SelectedKeyChanged="key => { if (key is int id) _ = SelectTemplate(id); }">
<NodeContent Context="node">
<strong>@node.Template.Name</strong>
@if (node.Template.ParentTemplateId.HasValue)
{
@@ -52,20 +50,27 @@
{
<span class="text-muted small ms-2">@node.Template.Description</span>
}
</span>
<span class="badge bg-light text-dark me-2">
<span class="badge bg-light text-dark ms-2">
@node.Template.Attributes.Count attr, @node.Template.Alarms.Count alm, @node.Template.Scripts.Count scr
</span>
@if (node.Template.Compositions.Count > 0)
{
<span class="badge bg-info text-dark me-2">@node.Template.Compositions.Count comp</span>
<span class="badge bg-info text-dark ms-1">@node.Template.Compositions.Count comp</span>
}
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteTemplate(node.Template)" @onclick:stopPropagation="true">Delete</button>
</div>
}
</div>
</div>
</NodeContent>
<ContextMenu Context="node">
<button class="dropdown-item" @onclick="() => SelectTemplate(node.Template.Id)">
Edit
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(node.Template)">
Delete
</button>
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No templates. Create one to get started.</span>
</EmptyContent>
</TreeView>
}
else
{
@@ -275,6 +280,7 @@
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
BuildTemplateTree();
}
catch (Exception ex)
{
@@ -283,24 +289,22 @@
_loading = false;
}
private record TemplateTreeNode(Template Template, int Depth, bool HasChildren);
private record TmplTreeNode(Template Template, List<TmplTreeNode> Children);
private List<TemplateTreeNode> BuildTemplateTree()
private List<TmplTreeNode> _templateTreeRoots = new();
private void BuildTemplateTree()
{
var result = new List<TemplateTreeNode>();
AddTemplateChildren(null, 0, result);
return result;
_templateTreeRoots = BuildTmplChildren(null);
}
private void AddTemplateChildren(int? parentId, int depth, List<TemplateTreeNode> result)
private List<TmplTreeNode> BuildTmplChildren(int? parentId)
{
var children = _templates.Where(t => t.ParentTemplateId == parentId).OrderBy(t => t.Name);
foreach (var child in children)
{
var hasChildren = _templates.Any(t => t.ParentTemplateId == child.Id);
result.Add(new TemplateTreeNode(child, depth, hasChildren));
AddTemplateChildren(child.Id, depth + 1, result);
}
return _templates
.Where(t => t.ParentTemplateId == parentId)
.OrderBy(t => t.Name)
.Select(t => new TmplTreeNode(t, BuildTmplChildren(t.Id)))
.ToList();
}
private async Task SelectTemplate(int templateId)
@@ -1,9 +1,12 @@
@page "/monitoring/health"
@attribute [Authorize]
@using ScadaLink.Commons.Types.Enums
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.HealthMonitoring
@implements IDisposable
@inject ICentralHealthAggregator HealthAggregator
@inject ISiteRepository SiteRepository
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -56,11 +59,12 @@
</div>
</div>
@* Per-site detail *@
@* Per-site detail cards *@
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key))
{
var siteName = GetSiteName(siteId);
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<div>
@if (state.IsOnline)
{
@@ -70,24 +74,48 @@
{
<span class="badge bg-danger me-2">Offline</span>
}
<strong>@siteId</strong>
@if (state.LatestReport?.NodeRole != null)
{
<span class="badge @(state.LatestReport.NodeRole == "Active" ? "bg-primary" : "bg-secondary") ms-2">@state.LatestReport.NodeRole</span>
}
<strong class="fs-5">@siteName (@siteId)</strong>
</div>
<small class="text-muted">
Last report: @state.LastReportReceivedAt.LocalDateTime.ToString("HH:mm:ss") | Seq: @state.LastSequenceNumber
</small>
</div>
<div class="card-body">
<div class="card-body p-3">
@if (state.LatestReport != null)
{
var report = state.LatestReport;
<div class="row">
@* Connection Health *@
<div class="col-md-4">
<h6 class="text-muted mb-2">Data Connections</h6>
<div class="row g-3">
@* Column 1: Nodes *@
<div class="col-md-3">
<h6 class="text-muted mb-2 border-bottom pb-1">Nodes</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
@if (report.ClusterNodes is { Count: > 0 })
{
@foreach (var node in report.ClusterNodes)
{
<tr>
<td class="small">@node.Hostname</td>
<td><span class="badge @(node.IsOnline ? "bg-success" : "bg-danger")">@(node.IsOnline ? "Online" : "Offline")</span></td>
<td><span class="badge @(node.Role == "Primary" ? "bg-primary" : "bg-secondary")">@node.Role</span></td>
</tr>
}
}
else
{
<tr>
<td class="small">@(report.NodeHostname != "" ? report.NodeHostname : "Node")</td>
<td><span class="badge @(state.IsOnline ? "bg-success" : "bg-danger")">@(state.IsOnline ? "Online" : "Offline")</span></td>
<td><span class="badge @(report.NodeRole == "Active" ? "bg-primary" : "bg-secondary")">@(report.NodeRole == "Active" ? "Primary" : "Standby")</span></td>
</tr>
}
</tbody>
</table>
</div>
@* Column 2: Data Connections *@
<div class="col-md-3">
<h6 class="text-muted mb-2 border-bottom pb-1">Data Connections</h6>
@if (report.DataConnectionStatuses.Count == 0)
{
<span class="text-muted small">None</span>
@@ -96,34 +124,77 @@
{
@foreach (var (connName, health) in report.DataConnectionStatuses)
{
<div class="d-flex justify-content-between mb-1">
<span class="small">@connName</span>
<span class="badge @GetConnectionHealthBadge(health)">@health</span>
var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName);
var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName);
<div class="mb-2">
<div class="d-flex justify-content-between">
<strong class="small">@connName</strong>
<span class="small">@(endpoint ?? health.ToString())</span>
</div>
@if (quality != null)
{
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small text-muted py-0">Tags good</td>
<td class="small text-end py-0">@quality.Good.ToString("N0")</td>
</tr>
<tr>
<td class="small text-muted py-0">Tags bad</td>
<td class="small text-end py-0">@quality.Bad.ToString("N0")</td>
</tr>
<tr>
<td class="small text-muted py-0">Tags uncertain</td>
<td class="small text-end py-0">@quality.Uncertain.ToString("N0")</td>
</tr>
</tbody>
</table>
}
</div>
}
}
</div>
@* Instances *@
<div class="col-md-4">
<h6 class="text-muted small mb-2">Instances</h6>
@* Column 3: Instances + Store-and-Forward *@
<div class="col-md-3">
<h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small">Deployed</td>
<td class="text-end">@report.DeployedInstanceCount</td>
</tr>
<tr>
<td class="small">Enabled</td>
<td class="text-end text-success">@report.EnabledInstanceCount</td>
</tr>
<tr>
<td class="small">Disabled</td>
<td class="text-end">@report.DisabledInstanceCount</td>
</tr>
</tbody>
</table>
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Store-and-Forward Buffers</h6>
@if (report.StoreAndForwardBufferDepths.Count == 0)
{
<span class="text-muted small">Empty</span>
}
else
{
@foreach (var (category, depth) in report.StoreAndForwardBufferDepths)
{
<div class="d-flex justify-content-between mb-1">
<span class="small">Deployed</span>
<span>@report.DeployedInstanceCount</span>
</div>
<div class="d-flex justify-content-between mb-1">
<span class="small">Enabled</span>
<span class="text-success">@report.EnabledInstanceCount</span>
</div>
<div class="d-flex justify-content-between mb-1">
<span class="small">Disabled</span>
<span class="text-warning">@report.DisabledInstanceCount</span>
<span class="small">@category</span>
<span class="badge @(depth > 0 ? "bg-warning text-dark" : "bg-light text-dark")">@depth</span>
</div>
}
}
</div>
@* Error Counts *@
<div class="col-md-4">
<h6 class="text-muted mb-2">Error Counts</h6>
@* Column 4: Error Counts + Parked Messages *@
<div class="col-md-3">
<h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
@@ -146,24 +217,15 @@
</tr>
</tbody>
</table>
</div>
@* S&F Buffer Depths *@
<div class="col-md-4">
<h6 class="text-muted mb-2">Store-and-Forward Buffers</h6>
@if (report.StoreAndForwardBufferDepths.Count == 0)
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Parked Messages</h6>
@if (report.ParkedMessageCount == 0)
{
<span class="text-muted small">Empty</span>
}
else
{
@foreach (var (category, depth) in report.StoreAndForwardBufferDepths)
{
<div class="d-flex justify-content-between mb-1">
<span class="small">@category</span>
<span class="badge @(depth > 0 ? "bg-warning text-dark" : "bg-light text-dark")">@depth</span>
</div>
}
<span class="badge bg-warning text-dark">@report.ParkedMessageCount</span>
}
</div>
</div>
@@ -180,11 +242,23 @@
@code {
private IReadOnlyDictionary<string, SiteHealthState> _siteStates = new Dictionary<string, SiteHealthState>();
private Dictionary<string, string> _siteNames = new();
private Timer? _refreshTimer;
private int _autoRefreshSeconds = 10;
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
// Load site names for display
try
{
var sites = await SiteRepository.GetAllSitesAsync();
_siteNames = sites.ToDictionary(s => s.SiteIdentifier, s => s.Name);
}
catch
{
// Non-fatal — fall back to showing siteId only
}
RefreshNow();
_refreshTimer = new Timer(_ =>
{
@@ -201,6 +275,11 @@
_siteStates = HealthAggregator.GetAllSiteStates();
}
private string GetSiteName(string siteId)
{
return _siteNames.GetValueOrDefault(siteId, siteId);
}
private static string GetConnectionHealthBadge(ConnectionHealth health) => health switch
{
ConnectionHealth.Connected => "bg-success",
@@ -0,0 +1,311 @@
@* Reusable hierarchical tree view with expand/collapse, ARIA roles, and guide lines *@
@typeparam TItem
@inject IJSRuntime JSRuntime
@if (_items is null || _items.Count == 0)
{
if (EmptyContent != null)
{
@EmptyContent
}
}
else
{
<ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")" style="list-style:none;padding-left:0;margin:0;">
@foreach (var item in _items)
{
RenderNode(item, 0);
}
</ul>
}
@if (_showContextMenu && _contextMenuItem != null && ContextMenu != null)
{
<div class="tv-ctx-overlay" @onclick="DismissContextMenu" style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div>
<div class="dropdown-menu show" style="position:fixed;top:@(_contextMenuY)px;left:@(_contextMenuX)px;z-index:1050;">
@ContextMenu(_contextMenuItem)
</div>
}
@{ void RenderNode(TItem item, int depth)
{
var key = KeySelector(item);
var children = ChildrenSelector(item);
var isBranch = HasChildrenSelector(item);
var isExpanded = _expandedKeys.Contains(KeyStr(key));
<li role="treeitem" @key="key"
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
aria-selected="@(Selectable && SelectedKey != null && SelectedKey.Equals(key) ? "true" : null)">
<div class="tv-row @(Selectable && SelectedKey != null && SelectedKey.Equals(key) ? SelectedCssClass : "")" style="padding-left: @(depth * IndentPx)px"
@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)">
@if (isBranch)
{
<span class="tv-toggle" style="display:inline-block;width:1.2em;text-align:center;cursor:pointer;" @onclick="() => ToggleExpand(key)" @onclick:stopPropagation>@(isExpanded ? "\u2212" : "+")</span>
}
else
{
<span class="tv-spacer" style="display:inline-block;width:1.2em;"></span>
}
<span class="tv-content" @onclick="() => OnContentClick(key)" @onclick:stopPropagation>
@NodeContent(item)
</span>
</div>
@if (isBranch && isExpanded && children is { Count: > 0 })
{
<ul role="group" style="list-style:none;padding-left:0;margin:0;">
@foreach (var child in children)
{
RenderNode(child, depth + 1);
}
</ul>
}
</li>
}
}
@code {
private IReadOnlyList<TItem>? _items;
private HashSet<string> _expandedKeys = new();
/// <summary>Normalize any key object to a string for consistent comparison with sessionStorage.</summary>
private string KeyStr(object key) => key.ToString()!;
private bool _initialExpansionApplied;
private bool _storageLoaded;
private TItem? _contextMenuItem;
private double _contextMenuX;
private double _contextMenuY;
private bool _showContextMenu;
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
[Parameter, EditorRequired] public Func<TItem, bool> HasChildrenSelector { get; set; } = default!;
[Parameter, EditorRequired] public Func<TItem, object> KeySelector { get; set; } = default!;
[Parameter, EditorRequired] public RenderFragment<TItem> NodeContent { get; set; } = default!;
[Parameter] public RenderFragment? EmptyContent { get; set; }
[Parameter] public RenderFragment<TItem>? ContextMenu { get; set; }
[Parameter] public int IndentPx { get; set; } = 24;
[Parameter] public bool ShowGuideLines { get; set; } = true;
[Parameter] public Func<TItem, bool>? InitiallyExpanded { get; set; }
[Parameter] public bool Selectable { get; set; }
[Parameter] public object? SelectedKey { get; set; }
[Parameter] public EventCallback<object?> SelectedKeyChanged { get; set; }
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";
[Parameter] public string? StorageKey { get; set; }
protected override void OnParametersSet()
{
_items = Items;
if (!_initialExpansionApplied && InitiallyExpanded != null && _items is { Count: > 0 })
{
// Only apply InitiallyExpanded when there is no StorageKey, or storage
// has already been checked and returned nothing (no prior state).
if (StorageKey == null || (_storageLoaded && _expandedKeys.Count == 0))
{
_initialExpansionApplied = true;
ApplyInitialExpansion(_items);
}
}
// Clear selection if the selected key no longer exists in the current items tree
if (Selectable && SelectedKey != null && _items is not null && !KeyExistsInTree(_items, SelectedKey))
{
_ = SelectedKeyChanged.InvokeAsync(null);
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && StorageKey != null)
{
var json = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", StorageKey);
_storageLoaded = true;
if (json != null)
{
var keys = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
if (keys != null)
{
_expandedKeys = new HashSet<string>(keys);
_initialExpansionApplied = true;
}
}
else if (InitiallyExpanded != null && _items is { Count: > 0 } && !_initialExpansionApplied)
{
// Storage returned null (no prior state) — fall back to InitiallyExpanded
_initialExpansionApplied = true;
ApplyInitialExpansion(_items);
}
StateHasChanged();
}
}
private bool KeyExistsInTree(IReadOnlyList<TItem> items, object key)
{
foreach (var item in items)
{
if (key.Equals(KeySelector(item)))
return true;
var children = ChildrenSelector(item);
if (children is { Count: > 0 } && KeyExistsInTree(children, key))
return true;
}
return false;
}
private void ApplyInitialExpansion(IReadOnlyList<TItem> items)
{
foreach (var item in items)
{
if (InitiallyExpanded!(item))
{
_expandedKeys.Add(KeyStr(KeySelector(item)));
}
var children = ChildrenSelector(item);
if (children is { Count: > 0 })
{
ApplyInitialExpansion(children);
}
}
}
private void ToggleExpand(object key)
{
var k = KeyStr(key);
if (!_expandedKeys.Remove(k))
{
_expandedKeys.Add(k);
}
PersistExpandedState();
}
private void PersistExpandedState()
{
if (StorageKey != null)
{
var json = System.Text.Json.JsonSerializer.Serialize(_expandedKeys.ToList());
_ = JSRuntime.InvokeVoidAsync("treeviewStorage.save", StorageKey, json);
}
}
private async Task OnContentClick(object key)
{
if (Selectable)
{
await SelectedKeyChanged.InvokeAsync(key);
}
}
private void OnContextMenu(MouseEventArgs e, TItem item)
{
if (ContextMenu == null) return;
_contextMenuItem = item;
_contextMenuX = e.ClientX;
_contextMenuY = e.ClientY;
_showContextMenu = true;
}
private void DismissContextMenu()
{
_showContextMenu = false;
_contextMenuItem = default;
}
/// <summary>Expand every branch node in the tree.</summary>
public void ExpandAll()
{
if (_items is { Count: > 0 })
{
ExpandAllRecursive(_items);
}
PersistExpandedState();
StateHasChanged();
}
private void ExpandAllRecursive(IReadOnlyList<TItem> items)
{
foreach (var item in items)
{
if (HasChildrenSelector(item))
{
_expandedKeys.Add(KeyStr(KeySelector(item)));
}
var children = ChildrenSelector(item);
if (children is { Count: > 0 })
{
ExpandAllRecursive(children);
}
}
}
/// <summary>Collapse every node in the tree.</summary>
public void CollapseAll()
{
_expandedKeys.Clear();
PersistExpandedState();
StateHasChanged();
}
/// <summary>
/// Expand all ancestors of the given key so it becomes visible.
/// Optionally select the node.
/// </summary>
public async Task RevealNode(object key, bool select = false)
{
var parentLookup = BuildParentLookup();
var k = KeyStr(key);
// If key is not in the tree at all, no-op
if (!parentLookup.ContainsKey(k))
return;
// Walk up through ancestors
var current = k;
while (parentLookup.TryGetValue(current, out var parentKey) && parentKey != null)
{
_expandedKeys.Add(parentKey);
current = parentKey;
}
if (select && Selectable)
{
await SelectedKeyChanged.InvokeAsync(key);
}
PersistExpandedState();
StateHasChanged();
}
private Dictionary<string, string?> BuildParentLookup()
{
var lookup = new Dictionary<string, string?>();
if (_items is { Count: > 0 })
{
BuildParentLookupRecursive(_items, null, lookup);
}
return lookup;
}
private void BuildParentLookupRecursive(IReadOnlyList<TItem> items, string? parentKey, Dictionary<string, string?> lookup)
{
foreach (var item in items)
{
var key = KeyStr(KeySelector(item));
lookup[key] = parentKey;
var children = ChildrenSelector(item);
if (children is { Count: > 0 })
{
BuildParentLookupRecursive(children, key, lookup);
}
}
}
}
@@ -0,0 +1,3 @@
namespace ScadaLink.Commons.Messages.Health;
public record NodeStatus(string Hostname, bool IsOnline, string Role);
@@ -15,4 +15,9 @@ public record SiteHealthReport(
int DeployedInstanceCount,
int EnabledInstanceCount,
int DisabledInstanceCount,
string NodeRole = "Unknown");
string NodeRole = "Unknown",
string NodeHostname = "",
IReadOnlyDictionary<string, string>? DataConnectionEndpoints = null,
IReadOnlyDictionary<string, TagQualityCounts>? DataConnectionTagQuality = null,
int ParkedMessageCount = 0,
IReadOnlyList<NodeStatus>? ClusterNodes = null);
@@ -0,0 +1,3 @@
namespace ScadaLink.Commons.Messages.Health;
public record TagQualityCounts(int Good, int Bad, int Uncertain);
@@ -0,0 +1,68 @@
namespace ScadaLink.Commons.Types;
/// <summary>
/// Monitors a heartbeat tag subscription for staleness. If no value is received
/// within <see cref="MaxSilence"/>, the <see cref="Stale"/> event fires.
/// Composable into any IDataConnection adapter.
/// </summary>
public sealed class StaleTagMonitor : IDisposable
{
private readonly TimeSpan _maxSilence;
private Timer? _timer;
private volatile bool _staleFired;
public StaleTagMonitor(TimeSpan maxSilence)
{
if (maxSilence <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(maxSilence), "MaxSilence must be positive.");
_maxSilence = maxSilence;
}
/// <summary>
/// Fires when no value has been received within <see cref="MaxSilence"/>.
/// Fires once per stale period — resets after <see cref="OnValueReceived"/> is called.
/// </summary>
public event Action? Stale;
public TimeSpan MaxSilence => _maxSilence;
/// <summary>
/// Start monitoring. The timer begins counting from now.
/// </summary>
public void Start()
{
_staleFired = false;
_timer?.Dispose();
_timer = new Timer(OnTimerElapsed, null, _maxSilence, Timeout.InfiniteTimeSpan);
}
/// <summary>
/// Signal that a value was received. Resets the stale timer.
/// </summary>
public void OnValueReceived()
{
_staleFired = false;
_timer?.Change(_maxSilence, Timeout.InfiniteTimeSpan);
}
/// <summary>
/// Stop monitoring and dispose the timer.
/// </summary>
public void Stop()
{
_timer?.Dispose();
_timer = null;
}
public void Dispose()
{
Stop();
}
private void OnTimerElapsed(object? state)
{
if (_staleFired) return;
_staleFired = true;
Stale?.Invoke();
}
}
@@ -66,12 +66,19 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
private int _totalSubscribed;
private int _resolvedTags;
private int _tagsGoodQuality;
private int _tagsBadQuality;
private int _tagsUncertainQuality;
private readonly Dictionary<string, QualityCode> _lastTagQuality = new();
private IDictionary<string, string> _connectionDetails;
private readonly IDictionary<string, string> _primaryConfig;
private readonly IDictionary<string, string>? _backupConfig;
private readonly int _failoverRetryCount;
private ActiveEndpoint _activeEndpoint = ActiveEndpoint.Primary;
private int _consecutiveFailures;
private int _consecutiveUnstableDisconnects;
private DateTimeOffset _lastConnectedAt;
/// <summary>
/// Captured Self reference for use from non-actor threads (event handlers, callbacks).
@@ -144,6 +151,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
{
_log.Info("[{0}] Entering Connecting state", _connectionName);
_healthCollector.UpdateConnectionHealth(_connectionName, ConnectionHealth.Connecting);
_healthCollector.UpdateConnectionEndpoint(_connectionName, "Connecting");
Become(Connecting);
Self.Tell(new AttemptConnect());
}
@@ -174,11 +182,20 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// ── Connected State ──
/// <summary>
/// Minimum time connected before we consider the connection stable.
/// If we disconnect before this, it counts as an unstable connection toward failover.
/// </summary>
private static readonly TimeSpan StableConnectionThreshold = TimeSpan.FromSeconds(60);
private void BecomeConnected()
{
_log.Info("[{0}] Entering Connected state", _connectionName);
_lastConnectedAt = DateTimeOffset.UtcNow;
_healthCollector.UpdateConnectionHealth(_connectionName, ConnectionHealth.Connected);
_healthCollector.UpdateTagResolution(_connectionName, _totalSubscribed, _resolvedTags);
var endpointLabel = _backupConfig == null ? "Connected" : $"Connected to {_activeEndpoint.ToString().ToLower()}";
_healthCollector.UpdateConnectionEndpoint(_connectionName, endpointLabel);
Become(Connected);
Stash.UnstashAll();
}
@@ -226,6 +243,67 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
{
_log.Warning("[{0}] Entering Reconnecting state", _connectionName);
_healthCollector.UpdateConnectionHealth(_connectionName, ConnectionHealth.Disconnected);
_healthCollector.UpdateConnectionEndpoint(_connectionName, "Disconnected");
// Track unstable connections toward failover.
// If we were connected for less than the stability threshold, this counts
// as an unstable cycle (e.g., connect succeeded but heartbeat went stale).
var connectionDuration = DateTimeOffset.UtcNow - _lastConnectedAt;
if (_lastConnectedAt != default && connectionDuration < StableConnectionThreshold)
{
_consecutiveUnstableDisconnects++;
_log.Warning("[{0}] Unstable connection (lasted {1:F0}s) — consecutive unstable disconnects: {2}/{3}",
_connectionName, connectionDuration.TotalSeconds, _consecutiveUnstableDisconnects,
_backupConfig != null ? _failoverRetryCount : 0);
}
else
{
_consecutiveUnstableDisconnects = 0;
}
// Failover if we keep connecting and going stale repeatedly
if (_backupConfig != null && _consecutiveUnstableDisconnects >= _failoverRetryCount)
{
var previousEndpoint = _activeEndpoint;
_activeEndpoint = _activeEndpoint == ActiveEndpoint.Primary
? ActiveEndpoint.Backup
: ActiveEndpoint.Primary;
_consecutiveUnstableDisconnects = 0;
_consecutiveFailures = 0;
var newConfig = _activeEndpoint == ActiveEndpoint.Primary
? _primaryConfig
: _backupConfig;
// Dispose old adapter
_adapter.Disconnected -= OnAdapterDisconnected;
_ = _adapter.DisposeAsync().AsTask();
// Create new adapter for the target endpoint
_adapter = _factory.Create(_protocolType, newConfig);
_connectionDetails = newConfig;
_adapter.Disconnected += OnAdapterDisconnected;
_log.Warning("[{0}] Failing over from {1} to {2} (unstable connection pattern)",
_connectionName, previousEndpoint, _activeEndpoint);
if (_siteEventLogger != null)
{
_ = _siteEventLogger.LogEventAsync(
"connection", "Warning", null, _connectionName,
$"Failover from {previousEndpoint} to {_activeEndpoint} (unstable connection)",
$"Connection lasted {connectionDuration.TotalSeconds:F0}s, threshold {StableConnectionThreshold.TotalSeconds:F0}s");
}
}
// Log disconnect to site event log
if (_siteEventLogger != null)
{
_ = _siteEventLogger.LogEventAsync(
"connection", "Warning", null, _connectionName,
$"Connection lost — entering reconnect cycle", null);
}
Become(Reconnecting);
// WP-9: Push bad quality for all subscribed tags on disconnect
@@ -552,6 +630,14 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
subscriber.Tell(new ConnectionQualityChanged(_connectionName, QualityCode.Bad, now));
}
// All tags now bad quality
_tagsGoodQuality = 0;
_tagsUncertainQuality = 0;
_tagsBadQuality = _lastTagQuality.Count;
foreach (var key in _lastTagQuality.Keys.ToList())
_lastTagQuality[key] = QualityCode.Bad;
_healthCollector.UpdateTagQuality(_connectionName, _tagsGoodQuality, _tagsBadQuality, _tagsUncertainQuality);
}
// ── Re-subscribe (WP-10) ──
@@ -646,6 +732,27 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
_connectionName, msg.TagPath, msg.Value.Value, msg.Value.Quality, msg.Value.Timestamp));
}
}
// Track quality transitions
if (_lastTagQuality.TryGetValue(msg.TagPath, out var prevQuality))
{
// Decrement old quality bucket
switch (prevQuality)
{
case QualityCode.Good: _tagsGoodQuality--; break;
case QualityCode.Bad: _tagsBadQuality--; break;
case QualityCode.Uncertain: _tagsUncertainQuality--; break;
}
}
// Increment new quality bucket
switch (msg.Value.Quality)
{
case QualityCode.Good: _tagsGoodQuality++; break;
case QualityCode.Bad: _tagsBadQuality++; break;
case QualityCode.Uncertain: _tagsUncertainQuality++; break;
}
_lastTagQuality[msg.TagPath] = msg.Value.Quality;
_healthCollector.UpdateTagQuality(_connectionName, _tagsGoodQuality, _tagsBadQuality, _tagsUncertainQuality);
}
// ── Internal messages ──
@@ -30,6 +30,8 @@ public class LmxProxyDataConnection : IDataConnection
private readonly Dictionary<string, ILmxSubscription> _subscriptions = new();
private volatile bool _disconnectFired;
private StaleTagMonitor? _staleMonitor;
private string? _heartbeatSubscriptionId;
public LmxProxyDataConnection(ILmxProxyClientFactory clientFactory, ILogger<LmxProxyDataConnection> logger)
{
@@ -57,10 +59,48 @@ public class LmxProxyDataConnection : IDataConnection
_disconnectFired = false;
_logger.LogInformation("LmxProxy connected to {Host}:{Port}", _host, _port);
// Heartbeat stale tag monitoring (optional)
await StartHeartbeatMonitorAsync(connectionDetails, cancellationToken);
}
private async Task StartHeartbeatMonitorAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken)
{
if (!connectionDetails.TryGetValue("HeartbeatTagPath", out var heartbeatTag) || string.IsNullOrWhiteSpace(heartbeatTag))
return;
var maxSilenceSeconds = connectionDetails.TryGetValue("HeartbeatMaxSilence", out var silenceStr)
&& int.TryParse(silenceStr, out var sec) ? sec : 30;
_staleMonitor?.Dispose();
_staleMonitor = new StaleTagMonitor(TimeSpan.FromSeconds(maxSilenceSeconds));
_staleMonitor.Stale += () =>
{
_logger.LogWarning("LmxProxy heartbeat tag '{Tag}' stale — no update in {Seconds}s", heartbeatTag, maxSilenceSeconds);
RaiseDisconnected();
};
try
{
_heartbeatSubscriptionId = await SubscribeAsync(heartbeatTag, (tag, value) =>
{
_logger.LogDebug("LmxProxy heartbeat received: {Tag} = {Value} (quality={Quality})", tag, value.Value, value.Quality);
_staleMonitor.OnValueReceived();
}, cancellationToken);
_staleMonitor.Start();
_logger.LogInformation("LmxProxy heartbeat monitor started for '{Tag}' with {Seconds}s max silence", heartbeatTag, maxSilenceSeconds);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to subscribe to heartbeat tag '{Tag}' — stale monitor not active", heartbeatTag);
_staleMonitor.Dispose();
_staleMonitor = null;
}
}
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
StopHeartbeatMonitor();
if (_client != null)
{
await _client.DisconnectAsync();
@@ -200,8 +240,16 @@ public class LmxProxyDataConnection : IDataConnection
}
}
private void StopHeartbeatMonitor()
{
_staleMonitor?.Dispose();
_staleMonitor = null;
_heartbeatSubscriptionId = null;
}
public async ValueTask DisposeAsync()
{
StopHeartbeatMonitor();
foreach (var subscription in _subscriptions.Values)
{
try { await subscription.DisposeAsync(); }
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.DataConnectionLayer.Adapters;
@@ -26,6 +27,8 @@ public class OpcUaDataConnection : IDataConnection
/// Maps subscription IDs to their tag paths for cleanup.
/// </summary>
private readonly Dictionary<string, string> _subscriptionHandles = new();
private StaleTagMonitor? _staleMonitor;
private string? _heartbeatSubscriptionId;
public OpcUaDataConnection(IOpcUaClientFactory clientFactory, ILogger<OpcUaDataConnection> logger)
{
@@ -67,6 +70,38 @@ public class OpcUaDataConnection : IDataConnection
_status = ConnectionHealth.Connected;
_disconnectFired = false;
_logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl);
// Heartbeat stale tag monitoring (optional)
await StartHeartbeatMonitorAsync(connectionDetails, cancellationToken);
}
private async Task StartHeartbeatMonitorAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken)
{
if (!connectionDetails.TryGetValue("HeartbeatTagPath", out var heartbeatTag) || string.IsNullOrWhiteSpace(heartbeatTag))
return;
var maxSilenceSeconds = ParseInt(connectionDetails, "HeartbeatMaxSilence", 30);
_staleMonitor?.Dispose();
_staleMonitor = new StaleTagMonitor(TimeSpan.FromSeconds(maxSilenceSeconds));
_staleMonitor.Stale += () =>
{
_logger.LogWarning("OPC UA heartbeat tag '{Tag}' stale — no update in {Seconds}s", heartbeatTag, maxSilenceSeconds);
RaiseDisconnected();
};
try
{
_heartbeatSubscriptionId = await SubscribeAsync(heartbeatTag, (_, _) => _staleMonitor.OnValueReceived(), cancellationToken);
_staleMonitor.Start();
_logger.LogInformation("OPC UA heartbeat monitor started for '{Tag}' with {Seconds}s max silence", heartbeatTag, maxSilenceSeconds);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to subscribe to heartbeat tag '{Tag}' — stale monitor not active", heartbeatTag);
_staleMonitor.Dispose();
_staleMonitor = null;
}
}
internal static int ParseInt(IDictionary<string, string> d, string key, int defaultValue)
@@ -86,6 +121,7 @@ public class OpcUaDataConnection : IDataConnection
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
StopHeartbeatMonitor();
if (_client != null)
{
_client.ConnectionLost -= OnClientConnectionLost;
@@ -201,8 +237,16 @@ public class OpcUaDataConnection : IDataConnection
return false;
}
private void StopHeartbeatMonitor()
{
_staleMonitor?.Dispose();
_staleMonitor = null;
_heartbeatSubscriptionId = null;
}
public async ValueTask DisposeAsync()
{
StopHeartbeatMonitor();
if (_client != null)
{
_client.ConnectionLost -= OnClientConnectionLost;
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Messages.Health;
using ScadaLink.StoreAndForward;
namespace ScadaLink.HealthMonitoring;
@@ -16,6 +17,8 @@ public class HealthReportSender : BackgroundService
private readonly HealthMonitoringOptions _options;
private readonly ILogger<HealthReportSender> _logger;
private readonly string _siteId;
private readonly StoreAndForwardStorage? _sfStorage;
private readonly IClusterNodeProvider? _clusterNodeProvider;
private long _sequenceNumber;
public HealthReportSender(
@@ -23,13 +26,17 @@ public class HealthReportSender : BackgroundService
IHealthReportTransport transport,
IOptions<HealthMonitoringOptions> options,
ILogger<HealthReportSender> logger,
ISiteIdentityProvider siteIdentityProvider)
ISiteIdentityProvider siteIdentityProvider,
StoreAndForwardStorage? sfStorage = null,
IClusterNodeProvider? clusterNodeProvider = null)
{
_collector = collector;
_transport = transport;
_options = options.Value;
_logger = logger;
_siteId = siteIdentityProvider.SiteId;
_sfStorage = sfStorage;
_clusterNodeProvider = clusterNodeProvider;
}
/// <summary>
@@ -54,6 +61,25 @@ public class HealthReportSender : BackgroundService
if (!_collector.IsActiveNode)
continue;
if (_clusterNodeProvider != null)
{
try
{
_collector.SetClusterNodes(_clusterNodeProvider.GetClusterNodes());
}
catch { /* Non-fatal */ }
}
if (_sfStorage != null)
{
try
{
var parkedCount = await _sfStorage.GetParkedMessageCountAsync();
_collector.SetParkedMessageCount(parkedCount);
}
catch { /* Non-fatal — parked count will be 0 */ }
}
var seq = Interlocked.Increment(ref _sequenceNumber);
var report = _collector.CollectReport(_siteId);
@@ -0,0 +1,12 @@
using ScadaLink.Commons.Messages.Health;
namespace ScadaLink.HealthMonitoring;
/// <summary>
/// Provides cluster node status information for health reporting.
/// Implemented by the Host project which has access to the Akka.NET actor system.
/// </summary>
public interface IClusterNodeProvider
{
IReadOnlyList<NodeStatus> GetClusterNodes();
}
@@ -15,8 +15,13 @@ public interface ISiteHealthCollector
void UpdateConnectionHealth(string connectionName, ConnectionHealth health);
void RemoveConnection(string connectionName);
void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved);
void UpdateConnectionEndpoint(string connectionName, string endpoint);
void UpdateTagQuality(string connectionName, int good, int bad, int uncertain);
void SetStoreAndForwardDepths(IReadOnlyDictionary<string, int> depths);
void SetInstanceCounts(int deployed, int enabled, int disabled);
void SetParkedMessageCount(int count);
void SetNodeHostname(string hostname);
void SetClusterNodes(IReadOnlyList<Commons.Messages.Health.NodeStatus> nodes);
void SetActiveNode(bool isActive);
bool IsActiveNode { get; }
SiteHealthReport CollectReport(string siteId);
@@ -16,6 +16,7 @@
<ItemGroup>
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
<ProjectReference Include="../ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" />
</ItemGroup>
<ItemGroup>
@@ -15,8 +15,13 @@ public class SiteHealthCollector : ISiteHealthCollector
private int _deadLetterCount;
private readonly ConcurrentDictionary<string, ConnectionHealth> _connectionStatuses = new();
private readonly ConcurrentDictionary<string, TagResolutionStatus> _tagResolutionCounts = new();
private readonly ConcurrentDictionary<string, string> _connectionEndpoints = new();
private readonly ConcurrentDictionary<string, TagQualityCounts> _tagQualityCounts = new();
private IReadOnlyDictionary<string, int> _sfBufferDepths = new Dictionary<string, int>();
private int _deployedInstanceCount, _enabledInstanceCount, _disabledInstanceCount;
private int _parkedMessageCount;
private volatile string _nodeHostname = "";
private volatile IReadOnlyList<Commons.Messages.Health.NodeStatus>? _clusterNodes;
private volatile bool _isActiveNode;
/// <summary>
@@ -60,6 +65,8 @@ public class SiteHealthCollector : ISiteHealthCollector
{
_connectionStatuses.TryRemove(connectionName, out _);
_tagResolutionCounts.TryRemove(connectionName, out _);
_connectionEndpoints.TryRemove(connectionName, out _);
_tagQualityCounts.TryRemove(connectionName, out _);
}
/// <summary>
@@ -71,6 +78,25 @@ public class SiteHealthCollector : ISiteHealthCollector
_tagResolutionCounts[connectionName] = new TagResolutionStatus(totalSubscribed, successfullyResolved);
}
public void UpdateConnectionEndpoint(string connectionName, string endpoint)
{
_connectionEndpoints[connectionName] = endpoint;
}
public void UpdateTagQuality(string connectionName, int good, int bad, int uncertain)
{
_tagQualityCounts[connectionName] = new TagQualityCounts(good, bad, uncertain);
}
public void SetParkedMessageCount(int count)
{
Interlocked.Exchange(ref _parkedMessageCount, count);
}
public void SetNodeHostname(string hostname) => _nodeHostname = hostname;
public void SetClusterNodes(IReadOnlyList<Commons.Messages.Health.NodeStatus> nodes) => _clusterNodes = nodes;
/// <summary>
/// Set the current store-and-forward buffer depths snapshot.
/// Called before report collection with data from the S&amp;F service.
@@ -110,6 +136,8 @@ public class SiteHealthCollector : ISiteHealthCollector
// Snapshot current connection and tag resolution state
var connectionStatuses = new Dictionary<string, ConnectionHealth>(_connectionStatuses);
var tagResolution = new Dictionary<string, TagResolutionStatus>(_tagResolutionCounts);
var connectionEndpoints = new Dictionary<string, string>(_connectionEndpoints);
var tagQuality = new Dictionary<string, TagQualityCounts>(_tagQualityCounts);
// Snapshot current S&F buffer depths
var sfBufferDepths = new Dictionary<string, int>(_sfBufferDepths);
@@ -130,6 +158,11 @@ public class SiteHealthCollector : ISiteHealthCollector
DeployedInstanceCount: _deployedInstanceCount,
EnabledInstanceCount: _enabledInstanceCount,
DisabledInstanceCount: _disabledInstanceCount,
NodeRole: nodeRole);
NodeRole: nodeRole,
NodeHostname: _nodeHostname,
DataConnectionEndpoints: connectionEndpoints,
DataConnectionTagQuality: tagQuality,
ParkedMessageCount: Interlocked.CompareExchange(ref _parkedMessageCount, 0, 0),
ClusterNodes: _clusterNodes?.ToList());
}
}
@@ -241,6 +241,7 @@ akka {{
// Resolve the health collector for the Deployment Manager
var siteHealthCollector = _serviceProvider.GetService<ScadaLink.HealthMonitoring.ISiteHealthCollector>();
siteHealthCollector?.SetNodeHostname(_nodeOptions.NodeHostname);
// Create SiteReplicationActor on every node (not a singleton)
var sfStorage = _serviceProvider.GetRequiredService<StoreAndForwardStorage>();
+1
View File
@@ -92,6 +92,7 @@
}
});
</script>
<script src="/js/treeview-storage.js"></script>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
</body>
</html>
@@ -0,0 +1,62 @@
using Akka.Actor;
using Akka.Cluster;
using ScadaLink.Commons.Messages.Health;
using ScadaLink.HealthMonitoring;
using ScadaLink.Host.Actors;
namespace ScadaLink.Host.Health;
/// <summary>
/// Provides cluster node statuses from Akka.NET cluster membership for health reporting.
/// </summary>
public class AkkaClusterNodeProvider : IClusterNodeProvider
{
private readonly AkkaHostedService _akkaService;
private readonly string _siteRole;
public AkkaClusterNodeProvider(AkkaHostedService akkaService, string siteRole)
{
_akkaService = akkaService;
_siteRole = siteRole;
}
public IReadOnlyList<NodeStatus> GetClusterNodes()
{
var system = _akkaService.ActorSystem;
if (system == null) return [];
var cluster = Cluster.Get(system);
var selfAddress = cluster.SelfAddress;
var leader = cluster.State.Leader;
var nodes = new List<NodeStatus>();
foreach (var member in cluster.State.Members)
{
if (!member.HasRole(_siteRole))
continue;
var hostname = member.Address.Host ?? member.Address.ToString();
var isOnline = member.Status == MemberStatus.Up;
var isLeader = member.Address.Equals(leader);
var role = isLeader ? "Primary" : "Standby";
nodes.Add(new NodeStatus(hostname, isOnline, role));
}
// If we have unreachable members, add them as offline
foreach (var unreachable in cluster.State.Unreachable)
{
if (!unreachable.HasRole(_siteRole))
continue;
// Don't duplicate if already in members list
if (nodes.Any(n => n.Hostname == (unreachable.Address.Host ?? unreachable.Address.ToString())))
continue;
var hostname = unreachable.Address.Host ?? unreachable.Address.ToString();
nodes.Add(new NodeStatus(hostname, false, "Standby"));
}
return nodes;
}
}
@@ -4,6 +4,7 @@ using ScadaLink.DataConnectionLayer;
using ScadaLink.ExternalSystemGateway;
using ScadaLink.HealthMonitoring;
using ScadaLink.Host.Actors;
using ScadaLink.Host.Health;
using ScadaLink.NotificationService;
using ScadaLink.SiteEventLogging;
using ScadaLink.SiteRuntime;
@@ -42,6 +43,15 @@ public static class SiteServiceRegistration
services.AddSingleton<AkkaHostedService>();
services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
// Cluster node status provider for health reports
services.AddSingleton<IClusterNodeProvider>(sp =>
{
var akkaService = sp.GetRequiredService<AkkaHostedService>();
var nodeOptions = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<NodeOptions>>().Value;
var siteRole = $"site-{nodeOptions.SiteId}";
return new AkkaClusterNodeProvider(akkaService, siteRole);
});
// Options binding
BindSharedOptions(services, config);
services.Configure<SiteRuntimeOptions>(config.GetSection("ScadaLink:SiteRuntime"));
@@ -0,0 +1,8 @@
window.treeviewStorage = {
save: function (storageKey, keysJson) {
sessionStorage.setItem("treeview:" + storageKey, keysJson);
},
load: function (storageKey) {
return sessionStorage.getItem("treeview:" + storageKey);
}
};
@@ -101,9 +101,35 @@ public class SiteStorageService
";
await command.ExecuteNonQueryAsync();
// Schema migrations — add columns that may not exist on older databases
await MigrateSchemaAsync(connection);
_logger.LogInformation("Site SQLite storage initialized at {ConnectionString}", _connectionString);
}
private async Task MigrateSchemaAsync(SqliteConnection connection)
{
// Add backup_configuration and failover_retry_count to data_connection_definitions
// (added in primary/backup data connections feature)
await TryAddColumnAsync(connection, "data_connection_definitions", "backup_configuration", "TEXT");
await TryAddColumnAsync(connection, "data_connection_definitions", "failover_retry_count", "INTEGER NOT NULL DEFAULT 3");
}
private async Task TryAddColumnAsync(SqliteConnection connection, string table, string column, string type)
{
try
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
await cmd.ExecuteNonQueryAsync();
_logger.LogInformation("Migrated: added column {Column} to {Table}", column, table);
}
catch (SqliteException ex) when (ex.Message.Contains("duplicate column"))
{
// Column already exists — no action needed
}
}
// ── Deployed Configuration CRUD ──
/// <summary>
@@ -297,6 +297,20 @@ public class StoreAndForwardStorage
return messages.FirstOrDefault();
}
/// <summary>
/// Gets the count of parked messages (for health reporting).
/// </summary>
public async Task<int> GetParkedMessageCountAsync()
{
await using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM sf_messages WHERE status = @parked";
cmd.Parameters.AddWithValue("@parked", (int)StoreAndForwardMessageStatus.Parked);
var result = await cmd.ExecuteScalarAsync();
return Convert.ToInt32(result);
}
/// <summary>
/// Gets total message count by status.
/// </summary>
@@ -0,0 +1,638 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using ScadaLink.CentralUI.Components.Shared;
namespace ScadaLink.CentralUI.Tests;
/// <summary>
/// bUnit tests for the TreeView component covering core rendering,
/// expand/collapse behavior, ARIA attributes, and indentation.
/// </summary>
public class TreeViewTests : BunitContext
{
private record TestNode(string Key, string Label, List<TestNode> Children);
private static List<TestNode> SimpleRoots() => new()
{
new("a", "Alpha", new()
{
new("a1", "Alpha-1", new()),
new("a2", "Alpha-2", new()
{
new("a2x", "Alpha-2-X", new())
})
}),
new("b", "Beta", new()),
};
private IRenderedComponent<TreeView<TestNode>> RenderTreeView(
List<TestNode>? items = null,
RenderFragment? emptyContent = null,
int indentPx = 24,
Func<TestNode, bool>? initiallyExpanded = null,
bool selectable = false,
object? selectedKey = null,
Action<object?>? onSelectedKeyChanged = null,
string? selectedCssClass = null,
string? storageKey = null,
RenderFragment<TestNode>? contextMenu = null)
{
return Render<TreeView<TestNode>>(parameters =>
{
parameters
.Add(p => p.Items, items ?? SimpleRoots())
.Add(p => p.ChildrenSelector, n => n.Children)
.Add(p => p.HasChildrenSelector, n => n.Children.Count > 0)
.Add(p => p.KeySelector, n => n.Key)
.Add(p => p.NodeContent, node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
})
.Add(p => p.IndentPx, indentPx)
.Add(p => p.EmptyContent, emptyContent)
.Add(p => p.InitiallyExpanded, initiallyExpanded)
.Add(p => p.Selectable, selectable)
.Add(p => p.SelectedKey, selectedKey);
if (onSelectedKeyChanged != null)
{
parameters.Add(p => p.SelectedKeyChanged, onSelectedKeyChanged);
}
if (selectedCssClass != null)
{
parameters.Add(p => p.SelectedCssClass, selectedCssClass);
}
if (storageKey != null)
{
parameters.Add(p => p.StorageKey, storageKey);
}
if (contextMenu != null)
{
parameters.Add(p => p.ContextMenu, contextMenu);
}
});
}
[Fact]
public void RendersRootLevelItems_WithCorrectLabels()
{
var cut = RenderTreeView();
var labels = cut.FindAll(".node-label");
// Only root-level items visible (children collapsed)
Assert.Equal(2, labels.Count);
Assert.Equal("Alpha", labels[0].TextContent);
Assert.Equal("Beta", labels[1].TextContent);
}
[Fact]
public void RendersEmptyContent_WhenItemsEmpty()
{
var cut = RenderTreeView(
items: new List<TestNode>(),
emptyContent: builder =>
{
builder.AddMarkupContent(0, "<p class=\"empty-msg\">Nothing here</p>");
});
var msg = cut.Find(".empty-msg");
Assert.Equal("Nothing here", msg.TextContent);
Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find("ul[role='tree']"));
}
[Fact]
public void LeafNodes_HaveNoToggle()
{
var cut = RenderTreeView();
// Beta is a leaf (index 1 in the li list)
var treeItems = cut.FindAll("li[role='treeitem']");
var betaLi = treeItems[1]; // Beta is second root
Assert.Throws<Bunit.ElementNotFoundException>(() => betaLi.QuerySelector(".tv-toggle")
?? throw new Bunit.ElementNotFoundException(".tv-toggle"));
// Should have spacer instead
Assert.NotNull(betaLi.QuerySelector(".tv-spacer"));
}
[Fact]
public void BranchNodes_ShowCollapsedToggle()
{
var cut = RenderTreeView();
var alphaLi = cut.FindAll("li[role='treeitem']")[0];
Assert.Equal("false", alphaLi.GetAttribute("aria-expanded"));
var toggle = alphaLi.QuerySelector(".tv-toggle");
Assert.NotNull(toggle);
Assert.Equal("+", toggle!.TextContent);
}
[Fact]
public void CollapsedBranch_ChildrenNotInDom()
{
var cut = RenderTreeView();
// Alpha is collapsed by default, children should not be in DOM
var groups = cut.FindAll("ul[role='group']");
Assert.Empty(groups);
}
[Fact]
public void ClickToggle_ExpandsNode_ShowsChildren()
{
var cut = RenderTreeView();
// Click Alpha's toggle
var toggle = cut.Find(".tv-toggle");
toggle.Click();
// Alpha should now be expanded
var alphaLi = cut.FindAll("li[role='treeitem']")[0];
Assert.Equal("true", alphaLi.GetAttribute("aria-expanded"));
// Children should appear
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-1");
Assert.Contains(labels, l => l.TextContent == "Alpha-2");
}
[Fact]
public void ClickExpandedToggle_Collapses_HidesChildren()
{
var cut = RenderTreeView();
// Expand Alpha
var toggle = cut.Find(".tv-toggle");
toggle.Click();
// Verify children visible
Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1");
// Collapse Alpha - find the toggle again (DOM changed)
var toggleAgain = cut.Find(".tv-toggle");
toggleAgain.Click();
// Children gone
var labels = cut.FindAll(".node-label");
Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1");
Assert.Empty(cut.FindAll("ul[role='group']"));
}
[Fact]
public void DeepNesting_ExpandParentThenChild_ShowsGrandchildren()
{
var cut = RenderTreeView();
// Expand Alpha
cut.Find(".tv-toggle").Click();
// Now find Alpha-2's toggle (Alpha-2 is a branch)
var toggles = cut.FindAll(".tv-toggle");
// toggles[0] = Alpha (now expanded, shows minus), toggles[1] = Alpha-2
Assert.True(toggles.Count >= 2);
toggles[1].Click();
// Alpha-2-X should be visible
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-2-X");
}
[Fact]
public void InitiallyExpanded_ExpandsMatchingNodes()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2");
// Alpha and Alpha-2 should be expanded, so Alpha-2-X should be visible
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-1");
Assert.Contains(labels, l => l.TextContent == "Alpha-2");
Assert.Contains(labels, l => l.TextContent == "Alpha-2-X");
}
[Fact]
public void RootUl_HasRoleTree()
{
var cut = RenderTreeView();
var rootUl = cut.Find("ul[role='tree']");
Assert.NotNull(rootUl);
}
[Fact]
public void NodeLi_HasRoleTreeitem()
{
var cut = RenderTreeView();
var items = cut.FindAll("li[role='treeitem']");
Assert.Equal(2, items.Count); // Two root nodes
}
[Fact]
public void ExpandedBranch_HasAriaExpandedTrue()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
var alphaLi = cut.FindAll("li[role='treeitem']")[0];
Assert.Equal("true", alphaLi.GetAttribute("aria-expanded"));
}
[Fact]
public void ChildGroup_HasRoleGroup()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
var groups = cut.FindAll("ul[role='group']");
Assert.Single(groups);
}
[Fact]
public void Children_IndentedByIndentPxPerDepth()
{
var cut = RenderTreeView(indentPx: 30, initiallyExpanded: n => n.Key == "a" || n.Key == "a2");
var rows = cut.FindAll(".tv-row");
// Root nodes at depth 0: padding-left: 0px
// Children at depth 1: padding-left: 30px
// Grandchildren at depth 2: padding-left: 60px
// Find Alpha row (depth 0)
var alphaRow = rows[0];
Assert.Contains("padding-left: 0px", alphaRow.GetAttribute("style"));
// Find Alpha-1 row (depth 1)
var alpha1Row = rows[1];
Assert.Contains("padding-left: 30px", alpha1Row.GetAttribute("style"));
// Find Alpha-2-X row (depth 2) - it's after Alpha-2 at index 3
var alpha2xRow = rows[3];
Assert.Contains("padding-left: 60px", alpha2xRow.GetAttribute("style"));
}
[Fact]
public void Selection_Disabled_ClickDoesNotFireCallback()
{
object? selected = null;
var cut = RenderTreeView(selectable: false, onSelectedKeyChanged: k => selected = k);
cut.Find(".tv-content").Click();
Assert.Null(selected);
}
[Fact]
public void Selection_Enabled_ClickContentFiresCallback()
{
object? selected = null;
var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k);
cut.Find(".tv-content").Click();
Assert.Equal("a", selected);
}
[Fact]
public void Selection_ClickToggle_DoesNotChangeSelection()
{
object? selected = null;
var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k);
cut.Find(".tv-toggle").Click();
Assert.Null(selected);
}
[Fact]
public void Selection_SelectedNode_HasCssClass()
{
var cut = RenderTreeView(selectable: true, selectedKey: "a");
var alphaRow = cut.FindAll(".tv-row")[0];
Assert.Contains("bg-primary", alphaRow.GetAttribute("class"));
}
[Fact]
public void Selection_CustomCssClass_Applied()
{
var cut = RenderTreeView(selectable: true, selectedKey: "a", selectedCssClass: "my-highlight");
var alphaRow = cut.FindAll(".tv-row")[0];
Assert.Contains("my-highlight", alphaRow.GetAttribute("class"));
}
[Fact]
public void Selection_AriaSelected_SetOnSelectedNode()
{
var cut = RenderTreeView(selectable: true, selectedKey: "a");
var alphaLi = cut.FindAll("li[role='treeitem']")[0];
Assert.Equal("true", alphaLi.GetAttribute("aria-selected"));
}
[Fact]
public void SessionStorage_NullKey_NoJsInteropCalls()
{
var cut = RenderTreeView();
// Expand Alpha
cut.Find(".tv-toggle").Click();
// No JS interop calls should have been made
Assert.Empty(JSInterop.Invocations);
}
[Fact]
public void SessionStorage_Set_ExpandWritesToStorage()
{
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
var cut = RenderTreeView(storageKey: "test-tree");
// Expand Alpha
cut.Find(".tv-toggle").Click();
// Verify save was called
var saveInvocations = JSInterop.Invocations
.Where(i => i.Identifier == "treeviewStorage.save")
.ToList();
Assert.Single(saveInvocations);
}
[Fact]
public void SessionStorage_RestoresExpandedOnMount()
{
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[\"a\"]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
var cut = RenderTreeView(storageKey: "test-tree");
// Alpha's children should be visible because "a" was restored from storage
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-1");
Assert.Contains(labels, l => l.TextContent == "Alpha-2");
}
[Fact]
public void SessionStorage_TakesPrecedenceOverInitiallyExpanded()
{
// Storage returns empty array — meaning user explicitly collapsed everything
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
var cut = RenderTreeView(
storageKey: "test-tree",
initiallyExpanded: n => n.Key == "a");
// Alpha should NOT be expanded — storage (empty) wins over InitiallyExpanded
var labels = cut.FindAll(".node-label");
Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1");
}
[Fact]
public void ExpandAll_ExpandsAllBranches()
{
var cut = RenderTreeView();
// Everything collapsed initially
Assert.Equal(2, cut.FindAll(".node-label").Count);
cut.InvokeAsync(() => cut.Instance.ExpandAll());
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-1");
Assert.Contains(labels, l => l.TextContent == "Alpha-2");
Assert.Contains(labels, l => l.TextContent == "Alpha-2-X");
}
[Fact]
public void CollapseAll_CollapsesAllBranches()
{
var cut = RenderTreeView(initiallyExpanded: _ => true);
// Verify deep content is visible
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-2-X");
cut.InvokeAsync(() => cut.Instance.CollapseAll());
// Only roots should be visible
labels = cut.FindAll(".node-label");
Assert.Equal(2, labels.Count);
Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1");
}
[Fact]
public void RevealNode_ExpandsAncestors()
{
var cut = RenderTreeView();
// Everything collapsed initially
Assert.Equal(2, cut.FindAll(".node-label").Count);
cut.InvokeAsync(() => cut.Instance.RevealNode("a2x"));
// Alpha-2-X should now be visible (Alpha and Alpha-2 expanded)
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-2-X");
Assert.Contains(labels, l => l.TextContent == "Alpha-1"); // sibling also visible since Alpha is expanded
Assert.Contains(labels, l => l.TextContent == "Alpha-2");
}
[Fact]
public void RevealNode_WithSelect_SelectsNode()
{
object? selected = null;
var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k);
cut.InvokeAsync(() => cut.Instance.RevealNode("a2x", select: true));
Assert.Equal("a2x", selected);
}
[Fact]
public void RevealNode_UnknownKey_NoOp()
{
var cut = RenderTreeView();
cut.InvokeAsync(() => cut.Instance.RevealNode("nonexistent"));
// Alpha should still be collapsed
var labels = cut.FindAll(".node-label");
Assert.Equal(2, labels.Count);
}
// ── External filtering tests (R8) ──────────────────────────────────
[Fact]
public void Filtering_ReducedItems_HidesRemovedRoots()
{
var fullItems = SimpleRoots();
var cut = RenderTreeView(items: fullItems);
// Both roots visible
var labels = cut.FindAll(".node-label");
Assert.Equal(2, labels.Count);
// Re-render with only Alpha (Beta removed)
var alphaOnly = new List<TestNode> { fullItems[0] };
cut.Render(parameters =>
{
parameters
.Add(p => p.Items, alphaOnly)
.Add(p => p.ChildrenSelector, (Func<TestNode, IReadOnlyList<TestNode>>)(n => n.Children))
.Add(p => p.HasChildrenSelector, (Func<TestNode, bool>)(n => n.Children.Count > 0))
.Add(p => p.KeySelector, (Func<TestNode, object>)(n => n.Key))
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
}));
});
labels = cut.FindAll(".node-label");
Assert.Single(labels);
Assert.Equal("Alpha", labels[0].TextContent);
}
[Fact]
public void Filtering_ExpansionStatePreserved()
{
var fullItems = SimpleRoots();
var cut = RenderTreeView(items: fullItems);
// Expand Alpha
cut.Find(".tv-toggle").Click();
Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1");
// Re-render with only Alpha
var alphaOnly = new List<TestNode> { fullItems[0] };
cut.Render(parameters =>
{
parameters
.Add(p => p.Items, alphaOnly)
.Add(p => p.ChildrenSelector, (Func<TestNode, IReadOnlyList<TestNode>>)(n => n.Children))
.Add(p => p.HasChildrenSelector, (Func<TestNode, bool>)(n => n.Children.Count > 0))
.Add(p => p.KeySelector, (Func<TestNode, object>)(n => n.Key))
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
}));
});
// Alpha-1 still visible (expansion state preserved)
Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1");
// Re-render with full list again
cut.Render(parameters =>
{
parameters
.Add(p => p.Items, fullItems)
.Add(p => p.ChildrenSelector, (Func<TestNode, IReadOnlyList<TestNode>>)(n => n.Children))
.Add(p => p.HasChildrenSelector, (Func<TestNode, bool>)(n => n.Children.Count > 0))
.Add(p => p.KeySelector, (Func<TestNode, object>)(n => n.Key))
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
}));
});
// Alpha-1 still visible after restoration
Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1");
}
[Fact]
public void Filtering_SelectionCleared_WhenNodeDisappears()
{
var fullItems = SimpleRoots();
object? lastSelected = "b"; // track the last value passed to callback
var cut = RenderTreeView(
items: fullItems,
selectable: true,
selectedKey: "b",
onSelectedKeyChanged: k => lastSelected = k);
// Re-render with only Alpha (Beta disappears)
var alphaOnly = new List<TestNode> { fullItems[0] };
cut.Render(parameters =>
{
parameters
.Add(p => p.Items, alphaOnly)
.Add(p => p.ChildrenSelector, (Func<TestNode, IReadOnlyList<TestNode>>)(n => n.Children))
.Add(p => p.HasChildrenSelector, (Func<TestNode, bool>)(n => n.Children.Count > 0))
.Add(p => p.KeySelector, (Func<TestNode, object>)(n => n.Key))
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
}))
.Add(p => p.Selectable, true)
.Add(p => p.SelectedKey, (object?)"b")
.Add(p => p.SelectedKeyChanged, (Action<object?>)(k => lastSelected = k));
});
// SelectedKeyChanged should have been called with null
Assert.Null(lastSelected);
}
// ── Context menu tests ──────────────────────────────────────────────
[Fact]
public void ContextMenu_Null_NoMenuRendered()
{
var cut = RenderTreeView();
// Right-click Alpha
var row = cut.Find(".tv-row");
row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
// No dropdown-menu should appear
Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find(".dropdown-menu"));
}
[Fact]
public void ContextMenu_RightClickShowsMenu()
{
var cut = RenderTreeView(contextMenu: node => builder =>
{
builder.AddMarkupContent(0, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
// Right-click Alpha
var row = cut.Find(".tv-row");
row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
// Dropdown menu should contain the button for Alpha
var menu = cut.Find(".dropdown-menu");
Assert.NotNull(menu);
var btn = menu.QuerySelector(".ctx-btn");
Assert.NotNull(btn);
Assert.Equal("Alpha", btn!.TextContent);
}
[Fact]
public void ContextMenu_RightClickDifferentNode_ReplacesMenu()
{
var cut = RenderTreeView(
initiallyExpanded: n => n.Key == "a",
contextMenu: node => builder =>
{
builder.AddMarkupContent(0, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
// Right-click Alpha
var rows = cut.FindAll(".tv-row");
rows[0].TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
// Now right-click Alpha-1
rows = cut.FindAll(".tv-row");
rows[1].TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 150, ClientY = 250 });
// Should be only one dropdown-menu, showing Alpha-1
var menus = cut.FindAll(".dropdown-menu");
Assert.Single(menus);
var btn = menus[0].QuerySelector(".ctx-btn");
Assert.NotNull(btn);
Assert.Equal("Alpha-1", btn!.TextContent);
}
}
@@ -0,0 +1,129 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Tests.Types;
public class StaleTagMonitorTests
{
[Fact]
public void Constructor_ZeroTimeSpan_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.Zero));
}
[Fact]
public void Constructor_NegativeTimeSpan_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.FromSeconds(-1)));
}
[Fact]
public async Task Stale_FiresAfterMaxSilence()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
await Task.Delay(300);
Assert.Equal(1, staleCount);
}
[Fact]
public async Task Stale_FiresOnlyOnce()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
await Task.Delay(300);
Assert.Equal(1, staleCount);
}
[Fact]
public async Task OnValueReceived_ResetsTimer()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(200));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
// Keep resetting before the 200ms deadline
for (int i = 0; i < 5; i++)
{
await Task.Delay(100);
monitor.OnValueReceived();
}
// Should not have gone stale
Assert.Equal(0, staleCount);
}
[Fact]
public async Task OnValueReceived_AllowsStaleAfterSilence()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
// Reset once
await Task.Delay(50);
monitor.OnValueReceived();
// Then go silent
await Task.Delay(250);
Assert.Equal(1, staleCount);
}
[Fact]
public async Task OnValueReceived_ResetsStaleFlag_AllowsSecondFire()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
// Wait for first stale
await Task.Delay(250);
Assert.Equal(1, staleCount);
// Reset — should allow second stale fire
monitor.OnValueReceived();
await Task.Delay(250);
Assert.Equal(2, staleCount);
}
[Fact]
public async Task Stop_PreventsStale()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
monitor.Stop();
await Task.Delay(200);
Assert.Equal(0, staleCount);
}
[Fact]
public async Task Dispose_PreventsStale()
{
var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
var staleCount = 0;
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
monitor.Dispose();
await Task.Delay(200);
Assert.Equal(0, staleCount);
}
[Fact]
public void MaxSilence_ReturnsConfiguredValue()
{
using var monitor = new StaleTagMonitor(TimeSpan.FromSeconds(42));
Assert.Equal(TimeSpan.FromSeconds(42), monitor.MaxSilence);
}
}
@@ -347,7 +347,7 @@ public class DataConnectionActorTests : TestKit
var count = Interlocked.Increment(ref connectCount);
// count 1: initial connect → success
// count 2,3: reconnect failures
// count 4: reconnect success (resets counter)
// count 4: reconnect success
// count 5,6: reconnect failures again
// count 7: reconnect success again
return count switch
@@ -366,7 +366,7 @@ public class DataConnectionActorTests : TestKit
AwaitCondition(() => connectCount >= 1, TimeSpan.FromSeconds(2));
await Task.Delay(200);
// Disconnect: triggers 2 failures then success (count 2,3,4)
// Disconnect: triggers 1 unstable disconnect + 2 failures then success (count 2,3,4)
RaiseDisconnected(primaryAdapter);
// Wait for successful reconnect (count 4)
@@ -380,7 +380,8 @@ public class DataConnectionActorTests : TestKit
AwaitCondition(() => connectCount >= 7, TimeSpan.FromSeconds(5));
await Task.Delay(200);
// Factory should never be called — counter reset each time before reaching 3
// Factory should never be called — connection failures counter resets on each
// successful reconnect, and unstable disconnect counter is separate
_mockFactory.DidNotReceive().Create(Arg.Any<string>(), Arg.Any<IDictionary<string, string>>());
}