Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3076e18db | |||
| de7c4067e4 | |||
| 5fdeaf613f | |||
| ff2784b862 | |||
| 0d03aec4f2 | |||
| d4397910f0 | |||
| 02a7e8abc6 | |||
| 65cc7b69cd | |||
| e84a831a02 | |||
| 5e2a4c9080 | |||
| 0abaa47de2 | |||
| a0a6bb4986 | |||
| 2b5dabb336 | |||
| 968fc4adc7 | |||
| 4c7fa03c07 | |||
| addbb6ffeb | |||
| f1537b62ca | |||
| 71894f4ba9 | |||
| 4426f3e928 | |||
| 08d511f609 | |||
| 4e5b5facec | |||
| f127efe6ea | |||
| d3a6ed5f68 | |||
| da4f29f6ee | |||
| 75648c0c76 |
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"
|
||||||
|
}
|
||||||
@@ -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">← 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">← 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">← 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)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<h4 class="mb-3">Area Management</h4>
|
||||||
<h4 class="mb-0">Area Management</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ToastNotification @ref="_toast" />
|
<ToastNotification @ref="_toast" />
|
||||||
<ConfirmDialog @ref="_confirmDialog" />
|
|
||||||
|
|
||||||
@if (_loading)
|
@if (_loading)
|
||||||
{
|
{
|
||||||
@@ -25,268 +23,111 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="row">
|
<TreeView TItem="AreaTreeNode" Items="_treeRoots"
|
||||||
<div class="col-md-3">
|
ChildrenSelector="n => n.Children"
|
||||||
<div class="card">
|
HasChildrenSelector="n => n.Children.Count > 0"
|
||||||
<div class="card-header">
|
KeySelector="n => n.Key"
|
||||||
<h6 class="mb-0">Sites</h6>
|
StorageKey="areas-tree">
|
||||||
</div>
|
<NodeContent Context="node">
|
||||||
<div class="list-group list-group-flush">
|
@if (node.Kind == AreaNodeKind.Site)
|
||||||
@if (_sites.Count == 0)
|
|
||||||
{
|
|
||||||
<div class="list-group-item text-muted small">No sites configured.</div>
|
|
||||||
}
|
|
||||||
@foreach (var site in _sites)
|
|
||||||
{
|
|
||||||
<button type="button"
|
|
||||||
class="list-group-item list-group-item-action @(site.Id == _selectedSiteId ? "active" : "")"
|
|
||||||
@onclick="() => SelectSite(site.Id)">
|
|
||||||
@site.Name
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-9">
|
|
||||||
@if (_selectedSiteId == 0)
|
|
||||||
{
|
{
|
||||||
<div class="text-muted">Select a site to manage its areas.</div>
|
<span class="fw-semibold">@node.Label</span>
|
||||||
|
<span class="badge bg-secondary ms-1">@node.AreaCount</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<span>@node.Label</span>
|
||||||
<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>
|
|
||||||
}
|
|
||||||
</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>
|
</NodeContent>
|
||||||
</div>
|
<ContextMenu Context="node">
|
||||||
|
@if (node.Kind == AreaNodeKind.Site)
|
||||||
|
{
|
||||||
|
<button class="dropdown-item"
|
||||||
|
@onclick='() => NavigationManager.NavigateTo($"/admin/areas/add?siteId={node.SiteId}")'>
|
||||||
|
Add Area
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</ContextMenu>
|
||||||
|
<EmptyContent>
|
||||||
|
<span class="text-muted fst-italic">No sites configured.</span>
|
||||||
|
</EmptyContent>
|
||||||
|
</TreeView>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@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<Site> _sites = new();
|
||||||
private List<Area> _areas = new();
|
private List<AreaTreeNode> _treeRoots = new();
|
||||||
private int _selectedSiteId;
|
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private string? _errorMessage;
|
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 ToastNotification _toast = default!;
|
||||||
private ConfirmDialog _confirmDialog = default!;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadDataAsync()
|
||||||
{
|
{
|
||||||
_loading = true;
|
_loading = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_errorMessage = $"Failed to load sites: {ex.Message}";
|
_errorMessage = $"Failed to load data: {ex.Message}";
|
||||||
}
|
}
|
||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SelectSite(int siteId)
|
private AreaTreeNode BuildAreaNode(Area area, int siteId)
|
||||||
{
|
{
|
||||||
_selectedSiteId = siteId;
|
var children = area.Children
|
||||||
_showForm = false;
|
.OrderBy(c => c.Name)
|
||||||
await LoadAreasAsync();
|
.Select(c => BuildAreaNode(c, siteId))
|
||||||
}
|
.ToList();
|
||||||
|
return new AreaTreeNode(
|
||||||
private async Task LoadAreasAsync()
|
Key: $"area-{area.Id}",
|
||||||
{
|
Label: area.Name,
|
||||||
try
|
Kind: AreaNodeKind.Area,
|
||||||
{
|
Children: children,
|
||||||
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
|
SiteId: siteId,
|
||||||
}
|
Area: area);
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,50 +25,51 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<table class="table table-sm table-striped table-hover">
|
<TreeView TItem="DcTreeNode" Items="_treeRoots"
|
||||||
<thead class="table-dark">
|
ChildrenSelector="n => n.Children"
|
||||||
<tr>
|
HasChildrenSelector="n => n.Children.Count > 0"
|
||||||
<th>ID</th>
|
KeySelector="n => n.Key"
|
||||||
<th>Name</th>
|
StorageKey="data-connections-tree">
|
||||||
<th>Protocol</th>
|
<NodeContent Context="node">
|
||||||
<th>Site</th>
|
@if (node.Kind == DcNodeKind.Site)
|
||||||
<th>Primary Config</th>
|
|
||||||
<th>Backup Config</th>
|
|
||||||
<th style="width: 160px;">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@if (_connections.Count == 0)
|
|
||||||
{
|
{
|
||||||
<tr>
|
<span class="fw-semibold">@node.Label</span>
|
||||||
<td colspan="7" class="text-muted text-center">No data connections configured.</td>
|
<span class="badge bg-secondary ms-1">@node.Children.Count</span>
|
||||||
</tr>
|
|
||||||
}
|
}
|
||||||
@foreach (var conn in _connections)
|
else
|
||||||
{
|
{
|
||||||
<tr>
|
<span>@node.Label</span>
|
||||||
<td>@conn.Id</td>
|
<span class="badge bg-info ms-2">@node.Connection!.Protocol</span>
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
</tbody>
|
</NodeContent>
|
||||||
</table>
|
<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>
|
</div>
|
||||||
|
|
||||||
@code {
|
@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 List<DataConnection> _connections = new();
|
||||||
private Dictionary<int, Site> _siteLookup = new();
|
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
|
|
||||||
@@ -87,8 +88,22 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var sites = await SiteRepository.GetAllSitesAsync();
|
var sites = await SiteRepository.GetAllSitesAsync();
|
||||||
_siteLookup = sites.ToDictionary(s => s.Id);
|
|
||||||
_connections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList();
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<div class="row mb-3 g-2">
|
<div class="row mb-3 g-2">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label small">Site</label>
|
<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>
|
<option value="0">Select site...</option>
|
||||||
@foreach (var site in _sites)
|
@foreach (var site in _sites)
|
||||||
{
|
{
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label small">Instance</label>
|
<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>
|
<option value="0">Select instance...</option>
|
||||||
@foreach (var inst in _siteInstances)
|
@foreach (var inst in _siteInstances)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -75,100 +75,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="table table-sm table-striped table-hover">
|
<TreeView @ref="_instanceTree" TItem="InstanceTreeNode" Items="_treeRoots"
|
||||||
<thead class="table-dark">
|
ChildrenSelector="n => n.Children"
|
||||||
<tr>
|
HasChildrenSelector="n => n.Children.Count > 0"
|
||||||
<th>Instance Name</th>
|
KeySelector="n => n.Key"
|
||||||
<th>Template</th>
|
StorageKey="instances-tree"
|
||||||
<th>Site</th>
|
Selectable="true"
|
||||||
<th>Area</th>
|
SelectedKey="_selectedKey"
|
||||||
<th>Status</th>
|
SelectedKeyChanged="key => { _selectedKey = key; }">
|
||||||
<th>Staleness</th>
|
<NodeContent Context="node">
|
||||||
<th style="width: 240px;">Actions</th>
|
@switch (node.Kind)
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@if (_filteredInstances.Count == 0)
|
|
||||||
{
|
{
|
||||||
<tr>
|
case InstanceNodeKind.Site:
|
||||||
<td colspan="7" class="text-muted text-center">No instances match the current filters.</td>
|
<span class="fw-semibold">@node.Label</span>
|
||||||
</tr>
|
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)
|
||||||
|
{
|
||||||
|
<span class="badge @(node.IsStale ? "bg-warning text-dark" : "bg-light text-dark") ms-1">
|
||||||
|
@(node.IsStale ? "Stale" : "Current")
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
@foreach (var inst in _pagedInstances)
|
</NodeContent>
|
||||||
|
<ContextMenu Context="node">
|
||||||
|
@if (node.Kind == InstanceNodeKind.Instance)
|
||||||
{
|
{
|
||||||
<tr>
|
var inst = node.Instance!;
|
||||||
<td><strong>@inst.UniqueName</strong></td>
|
var isStale = node.IsStale;
|
||||||
<td>@GetTemplateName(inst.TemplateId)</td>
|
<button class="dropdown-item" @onclick="() => DeployInstance(inst)"
|
||||||
<td>@GetSiteName(inst.SiteId)</td>
|
disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
|
||||||
<td>@(inst.AreaId.HasValue ? GetAreaName(inst.AreaId.Value) : "—")</td>
|
@if (inst.State == InstanceState.Enabled)
|
||||||
<td>
|
|
||||||
<span class="badge @GetStateBadge(inst.State)">@inst.State</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
@{
|
|
||||||
var isStale = _stalenessMap.GetValueOrDefault(inst.Id);
|
|
||||||
}
|
|
||||||
@if (inst.State == InstanceState.NotDeployed)
|
|
||||||
{
|
|
||||||
<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>
|
|
||||||
@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>
|
|
||||||
}
|
|
||||||
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="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>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
@* 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;
|
<button class="dropdown-item" @onclick="() => DisableInstance(inst)"
|
||||||
<li class="page-item @(page == _currentPage ? "active" : "")">
|
disabled="@_actionInProgress">Disable</button>
|
||||||
<button class="page-link" @onclick="() => GoToPage(page)">@(page)</button>
|
|
||||||
</li>
|
|
||||||
}
|
}
|
||||||
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
|
else if (inst.State == InstanceState.Disabled)
|
||||||
<button class="page-link" @onclick="() => GoToPage(_currentPage + 1)">Next</button>
|
{
|
||||||
</li>
|
<button class="dropdown-item" @onclick="() => EnableInstance(inst)"
|
||||||
</ul>
|
disabled="@_actionInProgress">Enable</button>
|
||||||
</nav>
|
}
|
||||||
}
|
<button class="dropdown-item"
|
||||||
<div class="text-muted small">
|
@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>
|
||||||
|
}
|
||||||
|
</ContextMenu>
|
||||||
|
<EmptyContent>
|
||||||
|
<span class="text-muted fst-italic">No instances match the current filters.</span>
|
||||||
|
</EmptyContent>
|
||||||
|
</TreeView>
|
||||||
|
|
||||||
|
<div class="text-muted small mt-2">
|
||||||
@_filteredInstances.Count instance(s) total
|
@_filteredInstances.Count instance(s) total
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,9 +199,13 @@
|
|||||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
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> _allInstances = new();
|
||||||
private List<Instance> _filteredInstances = new();
|
private List<Instance> _filteredInstances = new();
|
||||||
private List<Instance> _pagedInstances = new();
|
|
||||||
private List<Site> _sites = new();
|
private List<Site> _sites = new();
|
||||||
private List<Template> _templates = new();
|
private List<Template> _templates = new();
|
||||||
private List<Area> _allAreas = new();
|
private List<Area> _allAreas = new();
|
||||||
@@ -246,9 +219,9 @@
|
|||||||
private string _filterStatus = string.Empty;
|
private string _filterStatus = string.Empty;
|
||||||
private string _filterSearch = string.Empty;
|
private string _filterSearch = string.Empty;
|
||||||
|
|
||||||
private int _currentPage = 1;
|
private List<InstanceTreeNode> _treeRoots = new();
|
||||||
private int _totalPages;
|
private TreeView<InstanceTreeNode> _instanceTree = default!;
|
||||||
private const int PageSize = 25;
|
private object? _selectedKey;
|
||||||
|
|
||||||
private ToastNotification _toast = default!;
|
private ToastNotification _toast = default!;
|
||||||
private ConfirmDialog _confirmDialog = default!;
|
private ConfirmDialog _confirmDialog = default!;
|
||||||
@@ -312,26 +285,66 @@
|
|||||||
return true;
|
return true;
|
||||||
}).OrderBy(i => i.UniqueName).ToList();
|
}).OrderBy(i => i.UniqueName).ToList();
|
||||||
|
|
||||||
_totalPages = Math.Max(1, (int)Math.Ceiling(_filteredInstances.Count / (double)PageSize));
|
BuildTree();
|
||||||
if (_currentPage > _totalPages) _currentPage = 1;
|
|
||||||
UpdatePage();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void GoToPage(int page)
|
private void BuildTree()
|
||||||
{
|
{
|
||||||
if (page < 1 || page > _totalPages) return;
|
_treeRoots = _sites.Select(site =>
|
||||||
_currentPage = page;
|
{
|
||||||
UpdatePage();
|
var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
|
||||||
|
var siteInstances = _filteredInstances.Where(i => i.SiteId == site.Id).ToList();
|
||||||
|
|
||||||
|
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 void UpdatePage()
|
private List<InstanceTreeNode> BuildAreaNodes(List<Area> allAreas,
|
||||||
|
List<Instance> instances, int? parentId)
|
||||||
{
|
{
|
||||||
_pagedInstances = _filteredInstances
|
return allAreas
|
||||||
.Skip((_currentPage - 1) * PageSize)
|
.Where(a => a.ParentAreaId == parentId)
|
||||||
.Take(PageSize)
|
.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();
|
.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) =>
|
private string GetTemplateName(int templateId) =>
|
||||||
_templates.FirstOrDefault(t => t.Id == templateId)?.Name ?? $"#{templateId}";
|
_templates.FirstOrDefault(t => t.Id == templateId)?.Name ?? $"#{templateId}";
|
||||||
|
|
||||||
|
|||||||
@@ -33,39 +33,44 @@
|
|||||||
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>New Template</button>
|
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>New Template</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Inheritance tree visualization *@
|
<TreeView TItem="TmplTreeNode" Items="_templateTreeRoots"
|
||||||
<div class="card">
|
ChildrenSelector="n => n.Children"
|
||||||
<div class="card-body p-2">
|
HasChildrenSelector="n => n.Children.Count > 0"
|
||||||
@foreach (var node in BuildTemplateTree())
|
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)
|
||||||
{
|
{
|
||||||
<div class="d-flex align-items-center py-1 border-bottom"
|
<span class="text-muted small ms-1">inherits @(_templates.FirstOrDefault(t => t.Id == node.Template.ParentTemplateId)?.Name)</span>
|
||||||
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">
|
|
||||||
<strong>@node.Template.Name</strong>
|
|
||||||
@if (node.Template.ParentTemplateId.HasValue)
|
|
||||||
{
|
|
||||||
<span class="text-muted small ms-1">inherits @(_templates.FirstOrDefault(t => t.Id == node.Template.ParentTemplateId)?.Name)</span>
|
|
||||||
}
|
|
||||||
@if (!string.IsNullOrEmpty(node.Template.Description))
|
|
||||||
{
|
|
||||||
<span class="text-muted small ms-2">@node.Template.Description</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<span class="badge bg-light text-dark me-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>
|
|
||||||
}
|
|
||||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
|
||||||
@onclick="() => DeleteTemplate(node.Template)" @onclick:stopPropagation="true">Delete</button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
@if (!string.IsNullOrEmpty(node.Template.Description))
|
||||||
</div>
|
{
|
||||||
|
<span class="text-muted small ms-2">@node.Template.Description</span>
|
||||||
|
}
|
||||||
|
<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 ms-1">@node.Template.Compositions.Count comp</span>
|
||||||
|
}
|
||||||
|
</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
|
else
|
||||||
{
|
{
|
||||||
@@ -275,6 +280,7 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
||||||
|
BuildTemplateTree();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -283,24 +289,22 @@
|
|||||||
_loading = false;
|
_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>();
|
_templateTreeRoots = BuildTmplChildren(null);
|
||||||
AddTemplateChildren(null, 0, result);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
return _templates
|
||||||
foreach (var child in children)
|
.Where(t => t.ParentTemplateId == parentId)
|
||||||
{
|
.OrderBy(t => t.Name)
|
||||||
var hasChildren = _templates.Any(t => t.ParentTemplateId == child.Id);
|
.Select(t => new TmplTreeNode(t, BuildTmplChildren(t.Id)))
|
||||||
result.Add(new TemplateTreeNode(child, depth, hasChildren));
|
.ToList();
|
||||||
AddTemplateChildren(child.Id, depth + 1, result);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SelectTemplate(int templateId)
|
private async Task SelectTemplate(int templateId)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
@page "/monitoring/health"
|
@page "/monitoring/health"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using ScadaLink.Commons.Types.Enums
|
@using ScadaLink.Commons.Types.Enums
|
||||||
|
@using ScadaLink.Commons.Entities.Sites
|
||||||
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
@using ScadaLink.HealthMonitoring
|
@using ScadaLink.HealthMonitoring
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@inject ICentralHealthAggregator HealthAggregator
|
@inject ICentralHealthAggregator HealthAggregator
|
||||||
|
@inject ISiteRepository SiteRepository
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
@@ -56,11 +59,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Per-site detail *@
|
@* Per-site detail cards *@
|
||||||
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key))
|
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key))
|
||||||
{
|
{
|
||||||
|
var siteName = GetSiteName(siteId);
|
||||||
<div class="card mb-3">
|
<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>
|
<div>
|
||||||
@if (state.IsOnline)
|
@if (state.IsOnline)
|
||||||
{
|
{
|
||||||
@@ -70,24 +74,48 @@
|
|||||||
{
|
{
|
||||||
<span class="badge bg-danger me-2">Offline</span>
|
<span class="badge bg-danger me-2">Offline</span>
|
||||||
}
|
}
|
||||||
<strong>@siteId</strong>
|
<strong class="fs-5">@siteName (@siteId)</strong>
|
||||||
@if (state.LatestReport?.NodeRole != null)
|
|
||||||
{
|
|
||||||
<span class="badge @(state.LatestReport.NodeRole == "Active" ? "bg-primary" : "bg-secondary") ms-2">@state.LatestReport.NodeRole</span>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Last report: @state.LastReportReceivedAt.LocalDateTime.ToString("HH:mm:ss") | Seq: @state.LastSequenceNumber
|
Last report: @state.LastReportReceivedAt.LocalDateTime.ToString("HH:mm:ss") | Seq: @state.LastSequenceNumber
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body p-3">
|
||||||
@if (state.LatestReport != null)
|
@if (state.LatestReport != null)
|
||||||
{
|
{
|
||||||
var report = state.LatestReport;
|
var report = state.LatestReport;
|
||||||
<div class="row">
|
<div class="row g-3">
|
||||||
@* Connection Health *@
|
@* Column 1: Nodes *@
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<h6 class="text-muted mb-2">Data Connections</h6>
|
<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)
|
@if (report.DataConnectionStatuses.Count == 0)
|
||||||
{
|
{
|
||||||
<span class="text-muted small">None</span>
|
<span class="text-muted small">None</span>
|
||||||
@@ -96,34 +124,77 @@
|
|||||||
{
|
{
|
||||||
@foreach (var (connName, health) in report.DataConnectionStatuses)
|
@foreach (var (connName, health) in report.DataConnectionStatuses)
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between mb-1">
|
var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName);
|
||||||
<span class="small">@connName</span>
|
var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName);
|
||||||
<span class="badge @GetConnectionHealthBadge(health)">@health</span>
|
<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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Instances *@
|
@* Column 3: Instances + Store-and-Forward *@
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<h6 class="text-muted small mb-2">Instances</h6>
|
<h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6>
|
||||||
<div class="d-flex justify-content-between mb-1">
|
<table class="table table-sm table-borderless mb-0">
|
||||||
<span class="small">Deployed</span>
|
<tbody>
|
||||||
<span>@report.DeployedInstanceCount</span>
|
<tr>
|
||||||
</div>
|
<td class="small">Deployed</td>
|
||||||
<div class="d-flex justify-content-between mb-1">
|
<td class="text-end">@report.DeployedInstanceCount</td>
|
||||||
<span class="small">Enabled</span>
|
</tr>
|
||||||
<span class="text-success">@report.EnabledInstanceCount</span>
|
<tr>
|
||||||
</div>
|
<td class="small">Enabled</td>
|
||||||
<div class="d-flex justify-content-between mb-1">
|
<td class="text-end text-success">@report.EnabledInstanceCount</td>
|
||||||
<span class="small">Disabled</span>
|
</tr>
|
||||||
<span class="text-warning">@report.DisabledInstanceCount</span>
|
<tr>
|
||||||
</div>
|
<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">@category</span>
|
||||||
|
<span class="badge @(depth > 0 ? "bg-warning text-dark" : "bg-light text-dark")">@depth</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Error Counts *@
|
@* Column 4: Error Counts + Parked Messages *@
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<h6 class="text-muted mb-2">Error Counts</h6>
|
<h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6>
|
||||||
<table class="table table-sm table-borderless mb-0">
|
<table class="table table-sm table-borderless mb-0">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -146,24 +217,15 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
|
|
||||||
@* S&F Buffer Depths *@
|
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Parked Messages</h6>
|
||||||
<div class="col-md-4">
|
@if (report.ParkedMessageCount == 0)
|
||||||
<h6 class="text-muted mb-2">Store-and-Forward Buffers</h6>
|
|
||||||
@if (report.StoreAndForwardBufferDepths.Count == 0)
|
|
||||||
{
|
{
|
||||||
<span class="text-muted small">Empty</span>
|
<span class="text-muted small">Empty</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@foreach (var (category, depth) in report.StoreAndForwardBufferDepths)
|
<span class="badge bg-warning text-dark">@report.ParkedMessageCount</span>
|
||||||
{
|
|
||||||
<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>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,11 +242,23 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
private IReadOnlyDictionary<string, SiteHealthState> _siteStates = new Dictionary<string, SiteHealthState>();
|
private IReadOnlyDictionary<string, SiteHealthState> _siteStates = new Dictionary<string, SiteHealthState>();
|
||||||
|
private Dictionary<string, string> _siteNames = new();
|
||||||
private Timer? _refreshTimer;
|
private Timer? _refreshTimer;
|
||||||
private int _autoRefreshSeconds = 10;
|
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();
|
RefreshNow();
|
||||||
_refreshTimer = new Timer(_ =>
|
_refreshTimer = new Timer(_ =>
|
||||||
{
|
{
|
||||||
@@ -201,6 +275,11 @@
|
|||||||
_siteStates = HealthAggregator.GetAllSiteStates();
|
_siteStates = HealthAggregator.GetAllSiteStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetSiteName(string siteId)
|
||||||
|
{
|
||||||
|
return _siteNames.GetValueOrDefault(siteId, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetConnectionHealthBadge(ConnectionHealth health) => health switch
|
private static string GetConnectionHealthBadge(ConnectionHealth health) => health switch
|
||||||
{
|
{
|
||||||
ConnectionHealth.Connected => "bg-success",
|
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 DeployedInstanceCount,
|
||||||
int EnabledInstanceCount,
|
int EnabledInstanceCount,
|
||||||
int DisabledInstanceCount,
|
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 _totalSubscribed;
|
||||||
private int _resolvedTags;
|
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 IDictionary<string, string> _connectionDetails;
|
||||||
private readonly IDictionary<string, string> _primaryConfig;
|
private readonly IDictionary<string, string> _primaryConfig;
|
||||||
private readonly IDictionary<string, string>? _backupConfig;
|
private readonly IDictionary<string, string>? _backupConfig;
|
||||||
private readonly int _failoverRetryCount;
|
private readonly int _failoverRetryCount;
|
||||||
private ActiveEndpoint _activeEndpoint = ActiveEndpoint.Primary;
|
private ActiveEndpoint _activeEndpoint = ActiveEndpoint.Primary;
|
||||||
private int _consecutiveFailures;
|
private int _consecutiveFailures;
|
||||||
|
private int _consecutiveUnstableDisconnects;
|
||||||
|
private DateTimeOffset _lastConnectedAt;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Captured Self reference for use from non-actor threads (event handlers, callbacks).
|
/// 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);
|
_log.Info("[{0}] Entering Connecting state", _connectionName);
|
||||||
_healthCollector.UpdateConnectionHealth(_connectionName, ConnectionHealth.Connecting);
|
_healthCollector.UpdateConnectionHealth(_connectionName, ConnectionHealth.Connecting);
|
||||||
|
_healthCollector.UpdateConnectionEndpoint(_connectionName, "Connecting");
|
||||||
Become(Connecting);
|
Become(Connecting);
|
||||||
Self.Tell(new AttemptConnect());
|
Self.Tell(new AttemptConnect());
|
||||||
}
|
}
|
||||||
@@ -174,11 +182,20 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
|||||||
|
|
||||||
// ── Connected State ──
|
// ── 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()
|
private void BecomeConnected()
|
||||||
{
|
{
|
||||||
_log.Info("[{0}] Entering Connected state", _connectionName);
|
_log.Info("[{0}] Entering Connected state", _connectionName);
|
||||||
|
_lastConnectedAt = DateTimeOffset.UtcNow;
|
||||||
_healthCollector.UpdateConnectionHealth(_connectionName, ConnectionHealth.Connected);
|
_healthCollector.UpdateConnectionHealth(_connectionName, ConnectionHealth.Connected);
|
||||||
_healthCollector.UpdateTagResolution(_connectionName, _totalSubscribed, _resolvedTags);
|
_healthCollector.UpdateTagResolution(_connectionName, _totalSubscribed, _resolvedTags);
|
||||||
|
var endpointLabel = _backupConfig == null ? "Connected" : $"Connected to {_activeEndpoint.ToString().ToLower()}";
|
||||||
|
_healthCollector.UpdateConnectionEndpoint(_connectionName, endpointLabel);
|
||||||
Become(Connected);
|
Become(Connected);
|
||||||
Stash.UnstashAll();
|
Stash.UnstashAll();
|
||||||
}
|
}
|
||||||
@@ -226,6 +243,67 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
|||||||
{
|
{
|
||||||
_log.Warning("[{0}] Entering Reconnecting state", _connectionName);
|
_log.Warning("[{0}] Entering Reconnecting state", _connectionName);
|
||||||
_healthCollector.UpdateConnectionHealth(_connectionName, ConnectionHealth.Disconnected);
|
_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);
|
Become(Reconnecting);
|
||||||
|
|
||||||
// WP-9: Push bad quality for all subscribed tags on disconnect
|
// 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));
|
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) ──
|
// ── 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));
|
_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 ──
|
// ── Internal messages ──
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public class LmxProxyDataConnection : IDataConnection
|
|||||||
|
|
||||||
private readonly Dictionary<string, ILmxSubscription> _subscriptions = new();
|
private readonly Dictionary<string, ILmxSubscription> _subscriptions = new();
|
||||||
private volatile bool _disconnectFired;
|
private volatile bool _disconnectFired;
|
||||||
|
private StaleTagMonitor? _staleMonitor;
|
||||||
|
private string? _heartbeatSubscriptionId;
|
||||||
|
|
||||||
public LmxProxyDataConnection(ILmxProxyClientFactory clientFactory, ILogger<LmxProxyDataConnection> logger)
|
public LmxProxyDataConnection(ILmxProxyClientFactory clientFactory, ILogger<LmxProxyDataConnection> logger)
|
||||||
{
|
{
|
||||||
@@ -57,10 +59,48 @@ public class LmxProxyDataConnection : IDataConnection
|
|||||||
_disconnectFired = false;
|
_disconnectFired = false;
|
||||||
|
|
||||||
_logger.LogInformation("LmxProxy connected to {Host}:{Port}", _host, _port);
|
_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)
|
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
StopHeartbeatMonitor();
|
||||||
if (_client != null)
|
if (_client != null)
|
||||||
{
|
{
|
||||||
await _client.DisconnectAsync();
|
await _client.DisconnectAsync();
|
||||||
@@ -200,8 +240,16 @@ public class LmxProxyDataConnection : IDataConnection
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void StopHeartbeatMonitor()
|
||||||
|
{
|
||||||
|
_staleMonitor?.Dispose();
|
||||||
|
_staleMonitor = null;
|
||||||
|
_heartbeatSubscriptionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
StopHeartbeatMonitor();
|
||||||
foreach (var subscription in _subscriptions.Values)
|
foreach (var subscription in _subscriptions.Values)
|
||||||
{
|
{
|
||||||
try { await subscription.DisposeAsync(); }
|
try { await subscription.DisposeAsync(); }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ScadaLink.Commons.Interfaces.Protocol;
|
using ScadaLink.Commons.Interfaces.Protocol;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ScadaLink.DataConnectionLayer.Adapters;
|
namespace ScadaLink.DataConnectionLayer.Adapters;
|
||||||
@@ -26,6 +27,8 @@ public class OpcUaDataConnection : IDataConnection
|
|||||||
/// Maps subscription IDs to their tag paths for cleanup.
|
/// Maps subscription IDs to their tag paths for cleanup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<string, string> _subscriptionHandles = new();
|
private readonly Dictionary<string, string> _subscriptionHandles = new();
|
||||||
|
private StaleTagMonitor? _staleMonitor;
|
||||||
|
private string? _heartbeatSubscriptionId;
|
||||||
|
|
||||||
public OpcUaDataConnection(IOpcUaClientFactory clientFactory, ILogger<OpcUaDataConnection> logger)
|
public OpcUaDataConnection(IOpcUaClientFactory clientFactory, ILogger<OpcUaDataConnection> logger)
|
||||||
{
|
{
|
||||||
@@ -67,6 +70,38 @@ public class OpcUaDataConnection : IDataConnection
|
|||||||
_status = ConnectionHealth.Connected;
|
_status = ConnectionHealth.Connected;
|
||||||
_disconnectFired = false;
|
_disconnectFired = false;
|
||||||
_logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl);
|
_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)
|
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)
|
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
StopHeartbeatMonitor();
|
||||||
if (_client != null)
|
if (_client != null)
|
||||||
{
|
{
|
||||||
_client.ConnectionLost -= OnClientConnectionLost;
|
_client.ConnectionLost -= OnClientConnectionLost;
|
||||||
@@ -201,8 +237,16 @@ public class OpcUaDataConnection : IDataConnection
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void StopHeartbeatMonitor()
|
||||||
|
{
|
||||||
|
_staleMonitor?.Dispose();
|
||||||
|
_staleMonitor = null;
|
||||||
|
_heartbeatSubscriptionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
StopHeartbeatMonitor();
|
||||||
if (_client != null)
|
if (_client != null)
|
||||||
{
|
{
|
||||||
_client.ConnectionLost -= OnClientConnectionLost;
|
_client.ConnectionLost -= OnClientConnectionLost;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ScadaLink.Commons.Messages.Health;
|
using ScadaLink.Commons.Messages.Health;
|
||||||
|
using ScadaLink.StoreAndForward;
|
||||||
|
|
||||||
namespace ScadaLink.HealthMonitoring;
|
namespace ScadaLink.HealthMonitoring;
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ public class HealthReportSender : BackgroundService
|
|||||||
private readonly HealthMonitoringOptions _options;
|
private readonly HealthMonitoringOptions _options;
|
||||||
private readonly ILogger<HealthReportSender> _logger;
|
private readonly ILogger<HealthReportSender> _logger;
|
||||||
private readonly string _siteId;
|
private readonly string _siteId;
|
||||||
|
private readonly StoreAndForwardStorage? _sfStorage;
|
||||||
|
private readonly IClusterNodeProvider? _clusterNodeProvider;
|
||||||
private long _sequenceNumber;
|
private long _sequenceNumber;
|
||||||
|
|
||||||
public HealthReportSender(
|
public HealthReportSender(
|
||||||
@@ -23,13 +26,17 @@ public class HealthReportSender : BackgroundService
|
|||||||
IHealthReportTransport transport,
|
IHealthReportTransport transport,
|
||||||
IOptions<HealthMonitoringOptions> options,
|
IOptions<HealthMonitoringOptions> options,
|
||||||
ILogger<HealthReportSender> logger,
|
ILogger<HealthReportSender> logger,
|
||||||
ISiteIdentityProvider siteIdentityProvider)
|
ISiteIdentityProvider siteIdentityProvider,
|
||||||
|
StoreAndForwardStorage? sfStorage = null,
|
||||||
|
IClusterNodeProvider? clusterNodeProvider = null)
|
||||||
{
|
{
|
||||||
_collector = collector;
|
_collector = collector;
|
||||||
_transport = transport;
|
_transport = transport;
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_siteId = siteIdentityProvider.SiteId;
|
_siteId = siteIdentityProvider.SiteId;
|
||||||
|
_sfStorage = sfStorage;
|
||||||
|
_clusterNodeProvider = clusterNodeProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -54,6 +61,25 @@ public class HealthReportSender : BackgroundService
|
|||||||
if (!_collector.IsActiveNode)
|
if (!_collector.IsActiveNode)
|
||||||
continue;
|
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 seq = Interlocked.Increment(ref _sequenceNumber);
|
||||||
var report = _collector.CollectReport(_siteId);
|
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 UpdateConnectionHealth(string connectionName, ConnectionHealth health);
|
||||||
void RemoveConnection(string connectionName);
|
void RemoveConnection(string connectionName);
|
||||||
void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved);
|
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 SetStoreAndForwardDepths(IReadOnlyDictionary<string, int> depths);
|
||||||
void SetInstanceCounts(int deployed, int enabled, int disabled);
|
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);
|
void SetActiveNode(bool isActive);
|
||||||
bool IsActiveNode { get; }
|
bool IsActiveNode { get; }
|
||||||
SiteHealthReport CollectReport(string siteId);
|
SiteHealthReport CollectReport(string siteId);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||||
|
<ProjectReference Include="../ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ public class SiteHealthCollector : ISiteHealthCollector
|
|||||||
private int _deadLetterCount;
|
private int _deadLetterCount;
|
||||||
private readonly ConcurrentDictionary<string, ConnectionHealth> _connectionStatuses = new();
|
private readonly ConcurrentDictionary<string, ConnectionHealth> _connectionStatuses = new();
|
||||||
private readonly ConcurrentDictionary<string, TagResolutionStatus> _tagResolutionCounts = 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 IReadOnlyDictionary<string, int> _sfBufferDepths = new Dictionary<string, int>();
|
||||||
private int _deployedInstanceCount, _enabledInstanceCount, _disabledInstanceCount;
|
private int _deployedInstanceCount, _enabledInstanceCount, _disabledInstanceCount;
|
||||||
|
private int _parkedMessageCount;
|
||||||
|
private volatile string _nodeHostname = "";
|
||||||
|
private volatile IReadOnlyList<Commons.Messages.Health.NodeStatus>? _clusterNodes;
|
||||||
private volatile bool _isActiveNode;
|
private volatile bool _isActiveNode;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -60,6 +65,8 @@ public class SiteHealthCollector : ISiteHealthCollector
|
|||||||
{
|
{
|
||||||
_connectionStatuses.TryRemove(connectionName, out _);
|
_connectionStatuses.TryRemove(connectionName, out _);
|
||||||
_tagResolutionCounts.TryRemove(connectionName, out _);
|
_tagResolutionCounts.TryRemove(connectionName, out _);
|
||||||
|
_connectionEndpoints.TryRemove(connectionName, out _);
|
||||||
|
_tagQualityCounts.TryRemove(connectionName, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -71,6 +78,25 @@ public class SiteHealthCollector : ISiteHealthCollector
|
|||||||
_tagResolutionCounts[connectionName] = new TagResolutionStatus(totalSubscribed, successfullyResolved);
|
_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>
|
/// <summary>
|
||||||
/// Set the current store-and-forward buffer depths snapshot.
|
/// Set the current store-and-forward buffer depths snapshot.
|
||||||
/// Called before report collection with data from the S&F service.
|
/// Called before report collection with data from the S&F service.
|
||||||
@@ -110,6 +136,8 @@ public class SiteHealthCollector : ISiteHealthCollector
|
|||||||
// Snapshot current connection and tag resolution state
|
// Snapshot current connection and tag resolution state
|
||||||
var connectionStatuses = new Dictionary<string, ConnectionHealth>(_connectionStatuses);
|
var connectionStatuses = new Dictionary<string, ConnectionHealth>(_connectionStatuses);
|
||||||
var tagResolution = new Dictionary<string, TagResolutionStatus>(_tagResolutionCounts);
|
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
|
// Snapshot current S&F buffer depths
|
||||||
var sfBufferDepths = new Dictionary<string, int>(_sfBufferDepths);
|
var sfBufferDepths = new Dictionary<string, int>(_sfBufferDepths);
|
||||||
@@ -130,6 +158,11 @@ public class SiteHealthCollector : ISiteHealthCollector
|
|||||||
DeployedInstanceCount: _deployedInstanceCount,
|
DeployedInstanceCount: _deployedInstanceCount,
|
||||||
EnabledInstanceCount: _enabledInstanceCount,
|
EnabledInstanceCount: _enabledInstanceCount,
|
||||||
DisabledInstanceCount: _disabledInstanceCount,
|
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
|
// Resolve the health collector for the Deployment Manager
|
||||||
var siteHealthCollector = _serviceProvider.GetService<ScadaLink.HealthMonitoring.ISiteHealthCollector>();
|
var siteHealthCollector = _serviceProvider.GetService<ScadaLink.HealthMonitoring.ISiteHealthCollector>();
|
||||||
|
siteHealthCollector?.SetNodeHostname(_nodeOptions.NodeHostname);
|
||||||
|
|
||||||
// Create SiteReplicationActor on every node (not a singleton)
|
// Create SiteReplicationActor on every node (not a singleton)
|
||||||
var sfStorage = _serviceProvider.GetRequiredService<StoreAndForwardStorage>();
|
var sfStorage = _serviceProvider.GetRequiredService<StoreAndForwardStorage>();
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/js/treeview-storage.js"></script>
|
||||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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.ExternalSystemGateway;
|
||||||
using ScadaLink.HealthMonitoring;
|
using ScadaLink.HealthMonitoring;
|
||||||
using ScadaLink.Host.Actors;
|
using ScadaLink.Host.Actors;
|
||||||
|
using ScadaLink.Host.Health;
|
||||||
using ScadaLink.NotificationService;
|
using ScadaLink.NotificationService;
|
||||||
using ScadaLink.SiteEventLogging;
|
using ScadaLink.SiteEventLogging;
|
||||||
using ScadaLink.SiteRuntime;
|
using ScadaLink.SiteRuntime;
|
||||||
@@ -42,6 +43,15 @@ public static class SiteServiceRegistration
|
|||||||
services.AddSingleton<AkkaHostedService>();
|
services.AddSingleton<AkkaHostedService>();
|
||||||
services.AddHostedService(sp => sp.GetRequiredService<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
|
// Options binding
|
||||||
BindSharedOptions(services, config);
|
BindSharedOptions(services, config);
|
||||||
services.Configure<SiteRuntimeOptions>(config.GetSection("ScadaLink:SiteRuntime"));
|
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();
|
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);
|
_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 ──
|
// ── Deployed Configuration CRUD ──
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -297,6 +297,20 @@ public class StoreAndForwardStorage
|
|||||||
return messages.FirstOrDefault();
|
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>
|
/// <summary>
|
||||||
/// Gets total message count by status.
|
/// Gets total message count by status.
|
||||||
/// </summary>
|
/// </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);
|
var count = Interlocked.Increment(ref connectCount);
|
||||||
// count 1: initial connect → success
|
// count 1: initial connect → success
|
||||||
// count 2,3: reconnect failures
|
// count 2,3: reconnect failures
|
||||||
// count 4: reconnect success (resets counter)
|
// count 4: reconnect success
|
||||||
// count 5,6: reconnect failures again
|
// count 5,6: reconnect failures again
|
||||||
// count 7: reconnect success again
|
// count 7: reconnect success again
|
||||||
return count switch
|
return count switch
|
||||||
@@ -366,7 +366,7 @@ public class DataConnectionActorTests : TestKit
|
|||||||
AwaitCondition(() => connectCount >= 1, TimeSpan.FromSeconds(2));
|
AwaitCondition(() => connectCount >= 1, TimeSpan.FromSeconds(2));
|
||||||
await Task.Delay(200);
|
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);
|
RaiseDisconnected(primaryAdapter);
|
||||||
|
|
||||||
// Wait for successful reconnect (count 4)
|
// Wait for successful reconnect (count 4)
|
||||||
@@ -380,7 +380,8 @@ public class DataConnectionActorTests : TestKit
|
|||||||
AwaitCondition(() => connectCount >= 7, TimeSpan.FromSeconds(5));
|
AwaitCondition(() => connectCount >= 7, TimeSpan.FromSeconds(5));
|
||||||
await Task.Delay(200);
|
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>>());
|
_mockFactory.DidNotReceive().Create(Arg.Any<string>(), Arg.Any<IDictionary<string, string>>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user