diff --git a/aicontext/features/site/node-children-view/feature.md b/aicontext/features/site/node-children-view/feature.md index 6006ae770d..66903ac6d9 100644 --- a/aicontext/features/site/node-children-view/feature.md +++ b/aicontext/features/site/node-children-view/feature.md @@ -1,101 +1,159 @@ -# Feature: Node children view (overlay chart + ranking) +# Feature: Node Children Chart (overlay comparable child sensors) -> Owner: site | Last reviewed: 2026-07-08 | Canonical: yes -> Status: **Proposed — not yet implemented.** Tracked by SoftFx/Hierarchical-Sensor-Monitoring#1235 (v1). -> Scope: When an operator selects a tree node, show its child sensors *together* over a chosen time window — first as one overlaid multi-line chart (v1), later folded into a ranked "top consumers" summary (v2). +> Owner: site | Last reviewed: 2026-07-13 | Canonical: yes +> Scope: A read-only "Chart" tab on a tree node that overlays its comparable child sensors' history on one multi-line time chart over an operator-chosen window. --- ## Overview -Today, selecting a node renders each child sensor as its **own separate tile/plot**. To compare children — which process used the most CPU, which interface moved the most data, which disk was busiest over the last hour — the operator must open each sensor individually and eyeball its chart. There is no node-level combined view. +Selecting a tree node normally shows each child sensor as its own tile. To answer "which +child was hottest over the last hour" (which process used the most CPU, which NIC moved the +most data), the operator had to open each child one by one. -This feature adds a node-level view that fetches every comparable child sensor's history over one window and presents them together, with **zero configuration**: the operator clicks the node and picks a period; the view assembles itself from the node's descendants. +The node **Chart** tab overlays every *comparable* child sensor as one line on a single time +chart. It is entirely derived from stored history: it never writes or changes sensor state. -- **v1 — Chart:** one time-series chart, one line per child (raw values, no aggregation). -- **v2 — Ranking:** the same data folded into a sorted leaderboard (total / average / peak). -- **v3 — Heterogeneous nodes, scope, and per-type aggregation strategies.** +**v1 (this feature) — grouped overlay with a group selector:** -Dashboards already allow multi-line panels, but require adding each sensor by hand; this is the node-driven, no-setup version. +- The server groups the node's chart-comparable descendant sensors by `(SensorType, effective unit)`. + Each group of >= 2 sensors can be overlaid on its own chart. +- The Chart tab overlays the **largest** group by default. When a node has more than one comparable + group, a **"Type" selector** (next to the Period selector) lets the operator switch which group + `(type, unit)` is charted — one unit at a time, so the y-axis stays meaningful. +- One line per child in the selected group. Children with no data in the window are **omitted** (not + zero-filled). Sparse/intermittent series (e.g. top-N processes reported only while active) are drawn + with **gaps**, never interpolated across an idle stretch. The server omits idle points rather than + sending nulls, so the client inserts a `null` y wherever the interval between samples jumps above ~4x + the series' median spacing; `connectgaps: false` then breaks the line there (a bare `connectgaps:false` + would not, since there would be no null to break on). +- The tab is **not rendered** when the node has no group of >= 2 comparable children. ## Invariants -- Overlay/ranking happens only over **comparable** children — a shared sensor type **and** unit. Mixed-unit nodes (e.g. `Disks monitoring`: `%`, `MB/s`, `GB`) are split into one group per unit; children of different units are never plotted on a single Y axis or ranked against each other. -- A node with **fewer than 2 comparable children** shows no view and falls back to the normal node panel. -- The view is **read-only** — derived entirely from stored history; it never writes or mutates sensor state. -- The time window is operator-chosen (last hour / 3h / day / custom) and shared by every series. A child with **no data in the window is omitted, not zero-filled**. -- Aggregation (v2) is **per sensor type**: instant numeric contributes its value; bar sensors contribute `Mean` (line) / `Max` (peak); rate/counter → delta over window; enum/bool → availability. -- **Sparse series stay honest:** a child that reports intermittently (e.g. top-N CPU processes, which report only while in the top-10 at ≥1%) is drawn with gaps/markers, never interpolated across the gap. +- **Read-only.** Derived from stored history via the existing history-read path; never writes, + never mutates sensor/TTL state. +- **Comparable = numeric line-able + same type + same effective unit.** v1 comparable types: + `Integer, Double, Rate, IntegerBar, DoubleBar`. Excluded: `Boolean, Enum, Version, String, + TimeSpan, File` and service `status`/`alive` step sensors (their availability aggregation is v3). +- **Effective unit** = `DisplayUnit` for `Rate` sensors, otherwise `SelectedUnit` (`OriginalUnit`). + Two unitless sensors of the same type still group together. `EffectiveUnitCode` (group key) and + `EffectiveUnitLabel` (displayed unit) must branch on the **same** source: a Rate sensor without a + `DisplayUnit` is unit-less (code `null`, label empty) — it never borrows `SelectedUnit` for the label, + which would let two such sensors share the `null` key yet show conflicting units. +- **Bars are flattened to their `Mean`** so every series is a single scalar line; bar min/max/count + overlays are out of scope for v1. +- **Tab visibility is a server render-time decision.** `SelectNode` sets + `SelectedNodeViewModel.ShowChartTab = GetComparableChildGroups(nodeId).Count > 0`. Enabled for + product/tree **nodes** only, not folders. The flag is **reset to `false` on every selection change** + (`SelectedNodeViewModel.Subscribe`) because the view-model instance is reused — otherwise a node's + `true` would leak onto the next selected folder and render a Chart tab that has no endpoint. +- **Empty series are omitted, not zero-filled.** A window with no data for a child drops that line. + A child whose read **throws** is treated the same way — logged and dropped as a single series, so one + malformed sensor can't fault the fan-out and 500 the whole overlay. **Non-finite points (`NaN`/ + `Infinity`) are skipped** in `TryGetScalar` (they'd serialize as JSON string literals Plotly can't + plot); a child left with no finite points is omitted like an empty window. +- **At most `MaxSensorsPerChart` (15) sensors are overlaid, and only that many histories are read.** + To avoid reading a whole large group's history on every interaction, the endpoint **shortlists the + top 15 by each child's current in-memory `LastValue`** (no DB read) and reads history *only* for those, + then drops any with no data in the window and orders the rest by in-window peak. Ranking the shortlist + by **live value, not tree order**, is what keeps intermittent top-N series (per-process CPU, etc.) + visible — tree-order-first collapsed the chart to one line because the first ids by path are usually + idle. Tradeoff: a child that peaked earlier in the window but is idle *now* can fall outside the + shortlist (acceptable for the recent-window common case; a custom historical window is ranked by a + value that may be outside it). The note reports "group has N sensors; charting the 15 highest current". +- Scope is the node's full descendant set (`GetAllNodeSensors`); the two real examples + (`Top CPU processes`, per-interface `Network`) are homogeneous. Direct-children-vs-subtree toggle + is v3. ## Primary Workflows | # | Workflow | Initiator | |---|---|---| -| 1 | Select node → pick period → view overlaid multi-line chart of children (v1) | operator | -| 2 | Toggle to Ranking → children sorted by total/avg/peak over the window (v2) | operator | -| 3 | Mixed-unit node → pick which unit group to view/rank (v3) | operator | +| 1 | Select a node -> open Chart tab -> pick a window (Last hour / 3h / day / Custom) -> one overlaid line per comparable child | operator | +| 2 | Change the window -> re-query + redraw; children absent in the new window drop out | operator | +| 3 | On a multi-group node, pick a different `(type, unit)` group from the **Type** selector -> re-query + redraw that group | operator | ## API / Public Contracts | Contract | Location | Notes | |---|---|---| -| Node panel render | `HomeController.SelectNode` → `Views/Home/_NodeDataPanel.cshtml` | new Chart/Ranking tab added here | -| Child sensor ids | `TreeViewModel.GetAllNodeSensors(Guid)` (via `HomeController.GetNodeSensors`) | already returns all descendant sensor ids of a node | -| Per-sensor history | `SensorHistoryController.ChartHistory` (`GetSensorHistoryRequest`) | existing; Plotly chart data for one sensor | -| Multi-target history (prior art) | `grafana/JsonDatasource` `query` (`QueryHistoryRequest.Targets[]`) | precedent for one request → many series; the node-batch endpoint should follow this shape | - -**New (v1):** a node-history endpoint taking a node id + window (+ optional unit-group) that returns the full series set in one call, so the client makes one request instead of N. Reuse `GetAllNodeSensors` for enumeration and the same history read `ChartHistory` uses. +| `POST SensorHistory/NodeChartHistory` | `Controllers/SensorHistoryController.cs` | Body `NodeChartRequest { NodeId, GroupKey?, From, To }`. Returns `{ error, unit, note, selectedKey, groups: [{ key, label, count }], series: [{ id, label, values: [{ time, value }] }] }`. | +| `NodeChartRequest` | `Model/History/NodeChartRequest.cs` | `NodeId` is the node's GUID string (encoded id == `ToString()`); optional `GroupKey` selects the group; `From`/`To` are UTC instants. | +| `NodeSensorGroup` | `Model/TreeViewModels/NodeSensorGroup.cs` | `(SensorType Type, int? UnitCode, string UnitLabel, List SensorIds)`; `Key` = stable `"{typeInt}:{unitCode}"` used by the group selector. | +| `TreeViewModel.GetComparableChildGroups(Guid)` | `Model/TreeViewModels/TreeViewModel.cs` | Groups >= 2 comparable descendants by `(type, unit)`, largest first. | ## Key Files | File | Purpose | |---|---| -| `src/server/HSMServer/Controllers/HomeController.cs` | `SelectNode` (node panel), `GetNodeSensors` → `GetAllNodeSensors` | -| `src/server/HSMServer/Controllers/SensorHistoryController.cs` | existing per-sensor history (`ChartHistory`/`TableHistory`); the node-batch endpoint lands here or in `HomeController` | -| `src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs` | `GetAllNodeSensors(Guid)` (descendant sensor ids); sensor type/unit used for grouping | -| `src/server/HSMServer/Views/Home/_NodeDataPanel.cshtml` | node panel; hosts the new Chart/Ranking tab | -| `src/server/HSMServer/Views/Home/Sensor/History/_SensorGraphTabContent.cshtml` | existing Plotly graph tab to model the multi-series chart on | -| `src/server/HSMServer/wwwroot` (Plotly.js 2.28.0) | client charting already in the bundle — reuse for the overlay | +| `src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs` | `GetComparableChildGroups` + comparability/unit helpers. | +| `src/server/HSMServer/Model/TreeViewModels/NodeSensorGroup.cs` | Comparable-group record. | +| `src/server/HSMServer/Model/TreeViewModels/SelectedNodeViewModel.cs` | `ShowChartTab` flag. | +| `src/server/HSMServer/Controllers/HomeController.cs` | `SelectNode` sets `ShowChartTab` for nodes. | +| `src/server/HSMServer/Controllers/SensorHistoryController.cs` | `NodeChartHistory` endpoint + scalar projection (`BuildNodeChartSeries`, `TryGetScalar`, `GetNodeRelativeLabel`). | +| `src/server/HSMServer/Model/History/NodeChartRequest.cs` | Request DTO. | +| `src/server/HSMServer/Views/Home/_ChildrenPanel.cshtml` | Conditional Chart tab link + pane. | +| `src/server/HSMServer/Views/Home/_NodeChartTabContent.cshtml` | Window picker, notes, graph div, and the inline Plotly overlay renderer. | ## Data Flow -1. Operator selects node `N` and window `[from, to]`. -2. Server resolves children = `GetAllNodeSensors(N)` → leaf sensors; groups them by `(type, unit)`. -3. For each comparable group, read each sensor's history over `[from, to]` (same read path as `ChartHistory`). -4. **v1** returns the series (per sensor: points) → Plotly overlays them on one shared time axis. -5. **v2** folds each series to a scalar via a per-type strategy — instant numeric → total (area ≈ resource-time) / avg / peak; bar → `Mean`/`Max`; rate/counter → delta over window; enum/bool → % uptime / downtime / flaps — then sorts into a leaderboard. +1. `SelectNode` connects the node and computes `ShowChartTab` from `GetComparableChildGroups`. +2. `_ChildrenPanel` renders the Chart tab only when `ShowChartTab`; the pane includes + `_NodeChartTabContent`. +3. On tab click / window change, the inline script POSTs `{ nodeId, from, to }` to + `NodeChartHistory`. +4. The endpoint takes the selected comparable group, shortlists the top `MaxSensorsPerChart` children by + their current in-memory `LastValue` (no DB read), reads history only for those (`GetSensorValues`, + latest N, bounded), converts to display values, projects each to a scalar (`Value`, or bar `Mean`), + and returns one `series` entry per non-empty child. +5. The client builds one `scattergl` line per series (palette-cycled, `connectgaps: false`) and + calls `Plotly.newPlot` with a shared legend. Global `Plotly`/`jQuery` are reused — no bundle + rebuild. ## Storage / Persistence -None. Reads existing sensor history only. +None. Reuses the existing sensor-history read path (`ITreeValuesCache.GetSensorValuesPage`). ## UI / Operator Visibility -This *is* the operator-visible surface: a new tab in the node data panel. Add a `screens/site/` spec when the screen behavior lands. +Node data panel -> **Chart** tab (only when the node has >= 2 comparable children). Window picker +(Last hour / Last 3 hours / Last day / Custom From-To). A static note explains omitted/gapped +series; a dynamic note appears for mixed-unit nodes. ## Dependencies -- Depends on: sensor-tree (node selection), stored sensor history, the Plotly bundle. -- Used by: operators diagnosing "which child was hottest over this window". +- Depends on: sensor-tree (`TreeViewModel`, `GetAllNodeSensors`), sensor history read path, + bundled Plotly.js 2.28.0. +- Used by: operators triaging a node's children at a glance. ## Tests -Create `tests.md` when v1 lands. Required coverage: +Manual acceptance (issue #1235): -- happy path — homogeneous node (`Top CPU processes`, `Network`) → chart renders a line per child; -- boundary — 0 or 1 comparable child → no view; -- mixed-unit node → grouped by unit, never one shared axis; -- sparse series → gaps preserved, not interpolated; -- window with no data for a child → that series omitted (not zeroed). +- `Top CPU processes` -> Chart -> Last hour -> one line per process. +- `Network` -> one line per interface (MB/s). +- Node with < 2 comparable children -> no Chart tab. +- Change window -> re-query + redraw; children with no data in-window are absent, not zeroed. ## Notes -- The multi-line chart is the **substrate**; the ranking is the same data aggregated. Build v1 first, add v2 on the same fetch. -- Reuse Plotly (already bundled) and the existing history read; the only new server surface is the node-level batch that fans out over `GetAllNodeSensors`. +- Bars render as a single Mean line in v1; per-property (min/max/count) overlays and rate/counter + delta strategies are deferred. +- Bounds (constants in `SensorHistoryController`): `MaxSensorsPerChart` (15) lines — and, because the + shortlist happens before reading, also the number of history reads per request (so a huge same-unit + group no longer fans out into hundreds of reads); `NodeChartMaxPointsPerSensor` (2000) values per child; + `NodeChartReadConcurrency` (8) concurrent reads. **Both caps surface a `note` when they clip** — the + 2000-point cap takes the most-recent values, so a dense series over a long window notes that earlier + points may be omitted (no silent truncation), and a group larger than 15 notes that only the + highest-current-value children are charted. ## Known Issues / Limitations -- **v1** (#1235): overlay multi-line chart for **same-unit nodes only**; no ranking, no mixed-unit grouping. -- **v2:** ranking/leaderboard tab on the same data. -- **v3:** mixed-unit grouping, scope toggle (direct children vs flattened subtree), full per-type aggregation strategies (rate/counter delta, enum/bool availability). -- Top-N-sampled folders (`Top CPU processes`) yield intermittent series by design — the UI must explain the gaps rather than hide them. +- **v2 — Ranking:** a sorted "top consumers" leaderboard (total / average / peak) as a second tab + on the same data. +- **v3:** direct-children-vs-subtree scope toggle, per-type aggregation strategies (rate/counter + delta, enum/bool availability = % uptime / flaps). Mixed-unit nodes are already handled by the group + selector (one unit charted at a time), so the earlier "largest group only + note" behavior is gone. +- Group selection is by `(type, unit)`; a lone comparable sensor of a different unit (a singleton + group) still isn't chartable on its own — it needs a peer to form a group of >= 2. diff --git a/aicontext/features/site/overview.md b/aicontext/features/site/overview.md index 95d9fed60e..bd4d5ca8db 100644 --- a/aicontext/features/site/overview.md +++ b/aicontext/features/site/overview.md @@ -9,6 +9,7 @@ registration/account screens. ## Feature Folders To Add Here - `sensor-tree/` - hierarchy navigation, search, status display. +- `node-children-view/` - node "Chart" tab overlaying comparable child sensors (issue #1235). - `dashboards/` - dashboard screens, charts, table behavior. - `alerts/` - alert UI, templates, schedules, enable/disable workflows. - `configuration/` - server/site configuration screens. diff --git a/src/server/HSMServer/Controllers/HomeController.cs b/src/server/HSMServer/Controllers/HomeController.cs index a79e20b42f..e5b1f84793 100644 --- a/src/server/HSMServer/Controllers/HomeController.cs +++ b/src/server/HSMServer/Controllers/HomeController.cs @@ -119,6 +119,7 @@ public async Task SelectNode(string selectedId) foreach (var alert in alerts) alert.Schedules = schedulesList; StoredUser.SelectedNode.ConnectNode(node); + StoredUser.SelectedNode.ShowChartTab = _treeViewModel.HasComparableChildGroup(id); CurrentUser.Tree.AddOpenedNode(id); } else if (_treeViewModel.Sensors.TryGetValue(id, out var sensor)) diff --git a/src/server/HSMServer/Controllers/SensorHistoryController.cs b/src/server/HSMServer/Controllers/SensorHistoryController.cs index 1c790edf6c..7fd63b8417 100644 --- a/src/server/HSMServer/Controllers/SensorHistoryController.cs +++ b/src/server/HSMServer/Controllers/SensorHistoryController.cs @@ -12,12 +12,14 @@ using HSMServer.Model.TreeViewModel; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using NLog; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading; using System.Threading.Tasks; using HSMServer.JsonConverters; using System.Linq; @@ -60,6 +62,21 @@ public class SensorHistoryController : BaseController private const int LatestHistoryCount = -300; private const int SensorValuesCount = -5000; + // Latest N values per child sensor for the node overlay chart (issue #1235). Negative = take the + // most recent within the window; bounds the payload when many children are overlaid at once. + private const int NodeChartMaxPointsPerSensor = -2000; + + // Max child sensors charted on one node chart. Because the endpoint shortlists candidates by their + // current value BEFORE reading history (see NodeChartHistory), this is also the number of history + // reads per request — the DB fan-out is bounded to the shortlist, not the whole comparable group. + // Kept small so the overlay stays readable and one tab open / period change is cheap. + private const int MaxSensorsPerChart = 15; + + // Max concurrent per-sensor history reads for one node chart request. Bounds DB pressure while + // cutting latency from the sum of all reads toward the slowest. + private const int NodeChartReadConcurrency = 8; + + private readonly Logger _logger = LogManager.GetCurrentClassLogger(); private readonly ITreeValuesCache _cache; private readonly TreeViewModel _tree; @@ -172,6 +189,201 @@ public void ReloadHistoryRequest([FromBody] GetSensorHistoryRequest model) StoredUser.History.Reload(model); } + /// + /// Node-level overlay chart (issue #1235): returns the node's comparable child sensors as one line + /// series each over the chosen window. Descendants are grouped by (type, unit); the operator picks + /// which group with GroupKey (default: the largest), and within it the top + /// MaxSensorsPerChart children by current value are charted. Read-only; children with no data + /// in the window are omitted (drawn with gaps, never zero-filled). + /// + [HttpPost] + public async Task NodeChartHistory([FromBody] NodeChartRequest request) + { + if (request is null) + return _emptyJsonResult; + + var nodeId = request.NodeId.ToGuid(); + + if (!_tree.Nodes.TryGetValue(nodeId, out var node)) + return _emptyJsonResult; + + var groups = _tree.GetComparableChildGroups(nodeId); + + if (groups.Count == 0) + return new JsonResult(new { error = false, series = Array.Empty() }, _serializationsOptions); + + // The operator picks which comparable (type, unit) group to overlay; default to the largest. + var group = groups.FirstOrDefault(g => g.Key == request.GroupKey) ?? groups[0]; + var from = request.From.ToUtcKind(); + var to = request.To.ToUtcKind(); + var nodePath = node.FullPath; + + // Perf: don't read every child's history just to rank by peak. A large comparable group (e.g. + // per-process CPU) can be dozens–hundreds of sensors, and reading them all on every tab open / + // period change is a heavy DB burst whose results are then mostly discarded. Instead shortlist + // the top MaxSensorsPerChart by each child's CURRENT value — already in memory, no DB read — + // and read history only for those. Ranking by live value (not tree order) still surfaces the + // hot intermittent series (the tree-order-first approach collapsed the chart to one line, + // because the first ids by path are usually idle). Tradeoff: a child that peaked earlier in the + // window but is idle now can fall outside the shortlist — acceptable for the recent-window case. + var candidates = group.SensorIds + .Select(id => _tree.Sensors.TryGetValue(id, out var s) ? s : null) + .Where(s => s is not null) + .OrderByDescending(s => TryGetScalar(s.LastValue, out var v) ? v : double.NegativeInfinity) + .Take(MaxSensorsPerChart) + .ToList(); + + // Reads are independent, so they run with bounded concurrency — safe here because the comparable + // types exclude File, the only sensor kind with shared read state. + using var readGate = new SemaphoreSlim(NodeChartReadConcurrency); + + var reads = candidates.Select(async sensor => + { + await readGate.WaitAsync(); + + try + { + var (points, truncated) = await ReadNodeChartPoints(sensor, from, to); + + // no data in the window -> omitted, not zero-filled + return points.Count == 0 + ? null + : new NodeChartSeries(sensor.Id, GetNodeRelativeLabel(nodePath, sensor.FullPath), points, truncated); + } + catch (Exception ex) + { + // One malformed/faulting child must not fault Task.WhenAll and 500 the whole overlay: + // drop just that series (same outcome as an empty window) and keep the rest of the chart. + _logger.Error(ex, $"Node chart: failed to read history for sensor {sensor.Id}"); + return null; + } + finally + { + readGate.Release(); + } + }); + + // Order the rendered lines by in-window peak (the shortlist is already <= MaxSensorsPerChart). + var shown = (await Task.WhenAll(reads)) + .Where(s => s is not null) + .OrderByDescending(s => s.Points.Max(p => p.Value)) + .ToList(); + + var series = shown.Select(s => new + { + id = s.Id, + label = s.Label, + values = s.Points.Select(p => new { time = p.Time, value = p.Value }), + }); + + var notes = new List(2); + + if (group.SensorIds.Count > MaxSensorsPerChart) + notes.Add($"Group has {group.SensorIds.Count} sensors; charting the {MaxSensorsPerChart} with the highest current value."); + + if (shown.Any(s => s.Truncated)) + notes.Add($"Dense series show only their most recent {-NodeChartMaxPointsPerSensor} points; earlier data in the window may be omitted."); + + return new JsonResult(new + { + error = false, + unit = group.UnitLabel, + note = notes.Count > 0 ? string.Join(" ", notes) : null, + selectedKey = group.Key, + groups = groups.Select(g => new + { + key = g.Key, + label = GetGroupLabel(g), + count = g.SensorIds.Count, + }), + series, + }, _serializationsOptions); + } + + private static string GetGroupLabel(NodeSensorGroup group) + { + var unit = string.IsNullOrEmpty(group.UnitLabel) ? "no unit" : group.UnitLabel; + + return $"{GetFriendlyType(group.Type)}, {unit} ({group.SensorIds.Count})"; + } + + private static string GetFriendlyType(SensorType type) => type switch + { + SensorType.IntegerBar => "Integer bar", + SensorType.DoubleBar => "Double bar", + _ => type.ToString(), + }; + + private async Task<(List<(DateTime Time, double Value)> Points, bool Truncated)> ReadNodeChartPoints(SensorNodeViewModel sensor, DateTime from, DateTime to) + { + var maxPoints = -NodeChartMaxPointsPerSensor; + + // Over-read by one (a negative count returns the most-recent |N|) so we can tell "hit the cap" + // (older points in the window were dropped) from "the window holds exactly maxPoints" (nothing + // dropped) — otherwise the truncation note false-positives at exactly maxPoints. + var rawValues = await GetSensorValues(sensor.EncodedId, from, to, NodeChartMaxPointsPerSensor - 1); + + var truncated = rawValues.Count > maxPoints; + + var points = new List<(DateTime Time, double Value)>(rawValues.Count); + + foreach (var raw in rawValues) + { + var display = sensor.ToDisplayValue(raw); + + if (TryGetScalar(display, out var scalar)) + points.Add((display.Time.ToUniversalTime(), scalar)); + } + + points.Sort((a, b) => a.Time.CompareTo(b.Time)); + + // Drop the over-read extra(s) so the displayed series honors the cap (oldest first after sort). + if (points.Count > maxPoints) + points.RemoveRange(0, points.Count - maxPoints); + + return (points, truncated); + } + + private static bool TryGetScalar(BaseValue value, out double scalar) + { + switch (value) + { + // NaN/Infinity would serialize as JSON "NaN"/"Infinity" string literals (AllowNamedFloating- + // PointLiterals) and Plotly can't plot a string as a number, so drop non-finite points as if + // there were no data. int/int-bar means are always finite. + case BaseValue doubleValue: + scalar = doubleValue.Value; + return double.IsFinite(scalar); + case BaseValue intValue: + scalar = intValue.Value; + return true; + case BarBaseValue doubleBar: + scalar = doubleBar.Mean; + return double.IsFinite(scalar); + case BarBaseValue intBar: + scalar = intBar.Mean; + return true; + default: + scalar = 0; + return false; + } + } + + private static string GetNodeRelativeLabel(string nodePath, string sensorPath) + { + // Require a '/' boundary so node "a/b" doesn't match sensor "a/bc/d" (can't happen for ids from + // GetAllNodeSensors, which are true descendants, but keeps the intent explicit). + if (!string.IsNullOrEmpty(nodePath) + && sensorPath.Length > nodePath.Length + && sensorPath[nodePath.Length] == '/' + && sensorPath.StartsWith(nodePath, StringComparison.Ordinal)) + return sensorPath.Substring(nodePath.Length + 1); + + return sensorPath; + } + + private sealed record NodeChartSeries(Guid Id, string Label, List<(DateTime Time, double Value)> Points, bool Truncated); + [HttpGet] public IActionResult GetBackgroundSensorInfo([FromQuery] Guid currentId, [FromQuery] bool isStatusService = false) { diff --git a/src/server/HSMServer/HSMSwaggerComments.xml b/src/server/HSMServer/HSMSwaggerComments.xml index 45ecc4302b..caca9ac22f 100644 --- a/src/server/HSMServer/HSMSwaggerComments.xml +++ b/src/server/HSMServer/HSMSwaggerComments.xml @@ -72,6 +72,15 @@ Update which sensor groups agents under this product should collect (#1198). + + + Node-level overlay chart (issue #1235): returns the node's comparable child sensors as one line + series each over the chosen window. Descendants are grouped by (type, unit); the operator picks + which group with GroupKey (default: the largest), and within it the top + MaxSensorsPerChart children by current value are charted. Read-only; children with no data + in the window are omitted (drawn with gaps, never zero-filled). + + Controller for receiving sensors data via https protocol. There is a default product for testing swagger methods. @@ -287,6 +296,19 @@ (BadRequest) so the admin fixes the key first. + + + Request for the node-level overlay chart (issue #1235): one node id plus the operator-chosen + time window. The server groups the node's comparable descendants by (type, unit); GroupKey + selects which group to chart (default: the largest). + + + + + Stable key of the comparable group to overlay (see NodeSensorGroup.Key). When null/unknown + the server falls back to the largest comparable group. + + Set fields, for which collecting lists of values is required @@ -315,9 +337,64 @@ Sensor group names that should be disabled. All others are considered enabled. Valid values: "computer", "system", "disk", "network", "module", "process". + + + A set of a node's descendant sensors that share the same sensor type and effective unit, + and can therefore be overlaid on a single multi-line time chart (see issue #1235). + + + + + A set of a node's descendant sensors that share the same sensor type and effective unit, + and can therefore be overlaid on a single multi-line time chart (see issue #1235). + + + + Stable identifier of the (type, unit) group — used by the client's group selector. + Sensor groups currently disabled for agents of this product (#1198). + + The unit that identifies this sensor's chart scale: the rate display unit for Rate + sensors, otherwise the selected unit. Returned as the raw enum code (no reflection). Note this + mixes two enums (RateDisplayUnit vs Unit), so it is only unambiguous when the sensor Type is + also part of the key it is compared under (as in GetComparableChildGroups). + + + Display label for (e.g. "%", "MB/sec"), or empty. + Must branch on the SAME source as : a Rate sensor without a + DisplayUnit has code null, so its label is empty too — never the SelectedUnit, or two + such sensors would share the null group key yet show different (conflicting) units. + + + + Groups a node's chart-comparable descendant sensors by (type, effective unit) and returns + the groups that contain at least two sensors, ordered from the largest group to the smallest. + Drives the overlay endpoint; use for tab visibility. + + + + + Cheap existence check for the node "Chart" tab: returns true as soon as any (type, unit) group + reaches two sensors. Runs on every node selection, so it avoids the labels/sort/allocation of + . + + + + + True when the selected node has at least one group of >= 2 comparable child sensors to overlay + on the node "Chart" tab (issue #1235). Set by the controller after the node is connected. + + + + + Required by the IFolderManager.RemoveFolderFromChats contract so the same + multicast delegate can fan out to Telegram and Slack. + is unused here because Slack has no Telegram-style auto-remove at zero folders; + it is only consumed by TelegramChatsManager.TryRemove for the audit trail. + + Settings for the downloadable HSM Agent (epic #1167). The externally-reachable Sensor-API base URL diff --git a/src/server/HSMServer/Model/History/NodeChartRequest.cs b/src/server/HSMServer/Model/History/NodeChartRequest.cs new file mode 100644 index 0000000000..55c7eec5c6 --- /dev/null +++ b/src/server/HSMServer/Model/History/NodeChartRequest.cs @@ -0,0 +1,24 @@ +using System; + +namespace HSMServer.Model.Model.History +{ + /// + /// Request for the node-level overlay chart (issue #1235): one node id plus the operator-chosen + /// time window. The server groups the node's comparable descendants by (type, unit); GroupKey + /// selects which group to chart (default: the largest). + /// + public sealed record NodeChartRequest + { + public string NodeId { get; set; } + + /// + /// Stable key of the comparable group to overlay (see NodeSensorGroup.Key). When null/unknown + /// the server falls back to the largest comparable group. + /// + public string GroupKey { get; set; } + + public DateTime From { get; set; } = DateTime.MinValue; + + public DateTime To { get; set; } = DateTime.MaxValue; + } +} diff --git a/src/server/HSMServer/Model/TreeViewModels/NodeSensorGroup.cs b/src/server/HSMServer/Model/TreeViewModels/NodeSensorGroup.cs new file mode 100644 index 0000000000..f30b0f2912 --- /dev/null +++ b/src/server/HSMServer/Model/TreeViewModels/NodeSensorGroup.cs @@ -0,0 +1,16 @@ +using HSMCommon.Model; +using System; +using System.Collections.Generic; + +namespace HSMServer.Model.TreeViewModel +{ + /// + /// A set of a node's descendant sensors that share the same sensor type and effective unit, + /// and can therefore be overlaid on a single multi-line time chart (see issue #1235). + /// + public sealed record NodeSensorGroup(SensorType Type, int? UnitCode, string UnitLabel, List SensorIds) + { + /// Stable identifier of the (type, unit) group — used by the client's group selector. + public string Key => $"{(int)Type}:{(UnitCode.HasValue ? UnitCode.Value.ToString() : "-")}"; + } +} diff --git a/src/server/HSMServer/Model/TreeViewModels/SelectedNodeViewModel.cs b/src/server/HSMServer/Model/TreeViewModels/SelectedNodeViewModel.cs index 9f0a8f4125..ffd7b81d80 100644 --- a/src/server/HSMServer/Model/TreeViewModels/SelectedNodeViewModel.cs +++ b/src/server/HSMServer/Model/TreeViewModels/SelectedNodeViewModel.cs @@ -17,6 +17,12 @@ public class SelectedNodeViewModel public bool HasChildren => _nodes.VisibleItems?.Count > 0 || _sensors.VisibleItems?.Count > 0; + /// + /// True when the selected node has at least one group of >= 2 comparable child sensors to overlay + /// on the node "Chart" tab (issue #1235). Set by the controller after the node is connected. + /// + public bool ShowChartTab { get; set; } + public void ConnectNode(ProductNodeViewModel newNode) { @@ -43,7 +49,13 @@ private void Subscribe(BaseNodeViewModel newSelected) return; _selectedNode = newSelected; - + + // Reset whenever the selected node/folder changes (this instance is reused) so a previous node's + // true can't leak onto the next selection; the node branch of SelectNode re-enables it, folders + // leave it off. Selecting a sensor never calls this, but that's safe: the sensor panel + // (_NodeDataPanel) doesn't render _ChildrenPanel — only _Node.cshtml / _Folder.cshtml do. + ShowChartTab = false; + _nodes.Reset(); _sensors.Reset(); } diff --git a/src/server/HSMServer/Model/TreeViewModels/SensorNodeViewModel.cs b/src/server/HSMServer/Model/TreeViewModels/SensorNodeViewModel.cs index bbf8c219de..b5a623b5f6 100644 --- a/src/server/HSMServer/Model/TreeViewModels/SensorNodeViewModel.cs +++ b/src/server/HSMServer/Model/TreeViewModels/SensorNodeViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Collections.Generic; +using HSMCommon.Extensions; using HSMServer.Core; using HSMServer.Core.Model; using HSMServer.Core.Model.Sensors; @@ -62,6 +63,22 @@ public class SensorNodeViewModel : NodeViewModel public RateDisplayUnit? DisplayUnit { get; private set; } + /// The unit that identifies this sensor's chart scale: the rate display unit for Rate + /// sensors, otherwise the selected unit. Returned as the raw enum code (no reflection). Note this + /// mixes two enums (RateDisplayUnit vs Unit), so it is only unambiguous when the sensor Type is + /// also part of the key it is compared under (as in GetComparableChildGroups). + public int? EffectiveUnitCode => Type is SensorType.Rate + ? (DisplayUnit.HasValue ? (int)DisplayUnit.Value : null) + : (SelectedUnit.HasValue ? (int)SelectedUnit.Value : null); + + /// Display label for (e.g. "%", "MB/sec"), or empty. + /// Must branch on the SAME source as : a Rate sensor without a + /// DisplayUnit has code null, so its label is empty too — never the SelectedUnit, or two + /// such sensors would share the null group key yet show different (conflicting) units. + public string EffectiveUnitLabel => Type is SensorType.Rate + ? (DisplayUnit.HasValue ? DisplayUnit.Value.GetDisplayName() : string.Empty) + : (SelectedUnit?.GetDisplayName() ?? string.Empty); + public DateTime CreationTime { get; private set; } public Dictionary EnumOptions { get; private set; } diff --git a/src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs b/src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs index b9f3ed0ab8..70af336ba4 100644 --- a/src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs +++ b/src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs @@ -1,4 +1,5 @@ -using HSMServer.Authentication; +using HSMCommon.Model; +using HSMServer.Authentication; using HSMServer.Core.Cache; using HSMServer.Core.Model; using HSMServer.Core.TableOfChanges; @@ -97,6 +98,82 @@ void GetNodeSensors(Guid nodeId) return sensors; } + /// + /// Groups a node's chart-comparable descendant sensors by (type, effective unit) and returns + /// the groups that contain at least two sensors, ordered from the largest group to the smallest. + /// Drives the overlay endpoint; use for tab visibility. + /// + internal List GetComparableChildGroups(Guid nodeId) + { + // Key on the raw enum codes (cheap struct equality) — no reflection in this per-sensor loop. + // The display label is formatted once per surviving group. + var groups = new Dictionary<(SensorType Type, int? Unit), List>(); + + foreach (var sensorId in GetAllNodeSensors(nodeId)) + { + if (!Sensors.TryGetValue(sensorId, out var sensor) || !IsComparableChartSensor(sensor)) + continue; + + var key = (sensor.Type, sensor.EffectiveUnitCode); + + if (!groups.TryGetValue(key, out var ids)) + groups[key] = ids = new List(); + + ids.Add(sensorId); + } + + var result = new List(); + + foreach (var (key, ids) in groups) + { + if (ids.Count < 2) + continue; + + var unitLabel = Sensors.TryGetValue(ids[0], out var first) ? first.EffectiveUnitLabel : string.Empty; + result.Add(new NodeSensorGroup(key.Type, key.Unit, unitLabel, ids)); + } + + // Final tie-break on the unique group Key so the default group (groups[0]) and the selector + // order are stable across requests: groups come from an unordered Dictionary, and count + + // UnitLabel alone tie for e.g. a unit-less Integer vs a unit-less Double group (both label ""). + return result.OrderByDescending(group => group.SensorIds.Count) + .ThenBy(group => group.UnitLabel) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .ToList(); + } + + /// + /// Cheap existence check for the node "Chart" tab: returns true as soon as any (type, unit) group + /// reaches two sensors. Runs on every node selection, so it avoids the labels/sort/allocation of + /// . + /// + internal bool HasComparableChildGroup(Guid nodeId) + { + var counts = new Dictionary<(SensorType Type, int? Unit), int>(); + + foreach (var sensorId in GetAllNodeSensors(nodeId)) + { + if (!Sensors.TryGetValue(sensorId, out var sensor) || !IsComparableChartSensor(sensor)) + continue; + + var key = (sensor.Type, sensor.EffectiveUnitCode); + + if (counts.TryGetValue(key, out var count)) // already seen once -> this is the second + return true; + + counts[key] = count + 1; + } + + return false; + } + + // v1 overlays numeric line-able sensors only; enum/bool/version/timespan/string and service + // status/alive step charts are out of scope here (tracked for later aggregation strategies). + private static bool IsComparableChartSensor(SensorNodeViewModel sensor) => + !sensor.IsServiceStatus && !sensor.IsServiceAlive && + sensor.Type is SensorType.Integer or SensorType.Double or SensorType.Rate + or SensorType.IntegerBar or SensorType.DoubleBar; + internal Guid GetBackgroundPlotId(SensorNodeViewModel sensor, bool isStatusService) { var sensorId = Guid.Empty; diff --git a/src/server/HSMServer/Views/Home/_ChildrenPanel.cshtml b/src/server/HSMServer/Views/Home/_ChildrenPanel.cshtml index bbcefa18f1..9e1995a7f2 100644 --- a/src/server/HSMServer/Views/Home/_ChildrenPanel.cshtml +++ b/src/server/HSMServer/Views/Home/_ChildrenPanel.cshtml @@ -5,6 +5,7 @@ var listDivId = $"list_{nodeId}"; var gridDivId = $"grid_{nodeId}"; var journalDivId = $"journal_{nodeId}"; + var chartDivId = $"chart_{nodeId}"; } @@ -15,6 +16,12 @@ + @if (Model.ShowChartTab) + { + + } @@ -34,6 +41,12 @@ + @if (Model.ShowChartTab) + { +
+ @await Html.PartialAsync("_NodeChartTabContent", nodeId) +
+ }
diff --git a/src/server/HSMServer/Views/Home/_NodeChartTabContent.cshtml b/src/server/HSMServer/Views/Home/_NodeChartTabContent.cshtml new file mode 100644 index 0000000000..336dc27999 --- /dev/null +++ b/src/server/HSMServer/Views/Home/_NodeChartTabContent.cshtml @@ -0,0 +1,298 @@ +@model string + +@{ + var nodeId = Model; + var graphId = $"graph_node_{nodeId}"; + var dataUrl = Url.Action("NodeChartHistory", "SensorHistory"); +} + +
+
+ + + +
+ + +
+ +
+ + + + + +
+
+ +
+ One line per comparable child sensor. Sensors with no data in the window are omitted, and + intermittent series (e.g. top-N processes reported only while active) are drawn with gaps. +
+ +
+ +
+ +
+ +
+
+ +