From 472b6c7047e341b3aa9715ea87aa52715888cee0 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 9 Jul 2026 00:43:50 +0200 Subject: [PATCH 01/15] Add node "Chart" tab overlaying comparable child sensors Selecting a tree node now offers a read-only Chart tab that overlays every comparable child sensor (same type + unit) as one multi-line time chart over an operator-chosen window (last hour / 3h / day / custom). v1 draws the node's largest comparable group; children with no data in-window are omitted and intermittent series are drawn with gaps (never zero-filled). The tab is hidden when a node has fewer than two comparable children. Server: TreeViewModel.GetComparableChildGroups groups descendant sensors by (type, effective unit); SensorHistoryController.NodeChartHistory fans out over the largest group and returns one scalar line series per child (bars flattened to Mean). Client: an inline renderer reuses the bundled Plotly.js to draw the overlay, so no webpack rebuild is required. Docs: aicontext/features/site/node-children-view/feature.md. Closes #1235 Co-Authored-By: Claude Opus 4.8 --- .../site/node-children-view/feature.md | 130 ++++++++++++++ aicontext/features/site/overview.md | 1 + .../HSMServer/Controllers/HomeController.cs | 1 + .../Controllers/SensorHistoryController.cs | 117 +++++++++++++ .../Model/History/NodeChartRequest.cs | 17 ++ .../Model/TreeViewModels/NodeSensorGroup.cs | 12 ++ .../TreeViewModels/SelectedNodeViewModel.cs | 6 + .../Model/TreeViewModels/TreeViewModel.cs | 48 ++++- .../Views/Home/_ChildrenPanel.cshtml | 13 ++ .../Views/Home/_NodeChartTabContent.cshtml | 164 ++++++++++++++++++ 10 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 aicontext/features/site/node-children-view/feature.md create mode 100644 src/server/HSMServer/Model/History/NodeChartRequest.cs create mode 100644 src/server/HSMServer/Model/TreeViewModels/NodeSensorGroup.cs create mode 100644 src/server/HSMServer/Views/Home/_NodeChartTabContent.cshtml diff --git a/aicontext/features/site/node-children-view/feature.md b/aicontext/features/site/node-children-view/feature.md new file mode 100644 index 0000000000..5bb8b1aff6 --- /dev/null +++ b/aicontext/features/site/node-children-view/feature.md @@ -0,0 +1,130 @@ +# Feature: Node Children Chart (overlay comparable child sensors) + +> Owner: site | Last reviewed: 2026-07-09 | 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 + +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. + +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 (this feature) — same-unit overlay:** + +- The server groups the node's chart-comparable descendant sensors by `(SensorType, effective unit)` + and overlays the **largest** such group (the group with the most sensors). +- One line per child. 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** — `connectgaps: false` — never interpolated across the gap. +- If a node has comparable sensors in more than one unit, only the largest group is drawn and a + note explains that other-unit sensors are not overlaid (operator-approved v1 behavior for + mixed-unit nodes; full mixed-unit support is v3). +- The tab is **not rendered** when the node has no group of >= 2 comparable children. + +## Invariants + +- **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. +- **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. +- **Empty series are omitted, not zero-filled.** A window with no data for a child drops that line. +- 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 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 | + +## API / Public Contracts + +| Contract | Location | Notes | +|---|---|---| +| `POST SensorHistory/NodeChartHistory` | `Controllers/SensorHistoryController.cs` | Body `NodeChartRequest { NodeId, From, To }`. Returns `{ error, unit, note, series: [{ id, label, values: [{ time, value }] }] }`. | +| `NodeChartRequest` | `Model/History/NodeChartRequest.cs` | `NodeId` is the node's GUID string (encoded id == `ToString()`); `From`/`To` are UTC instants. | +| `NodeSensorGroup` | `Model/TreeViewModels/NodeSensorGroup.cs` | `(SensorType Type, string UnitLabel, List SensorIds)` — a comparable group. | +| `TreeViewModel.GetComparableChildGroups(Guid)` | `Model/TreeViewModels/TreeViewModel.cs` | Groups >= 2 comparable descendants by `(type, unit)`, largest first. | + +## Key Files + +| File | Purpose | +|---|---| +| `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. `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 largest comparable group, reads each sensor's history in the window + (`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. Reuses the existing sensor-history read path (`ITreeValuesCache.GetSensorValuesPage`). + +## UI / Operator Visibility + +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 (`TreeViewModel`, `GetAllNodeSensors`), sensor history read path, + bundled Plotly.js 2.28.0. +- Used by: operators triaging a node's children at a glance. + +## Tests + +Manual acceptance (issue #1235): + +- `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 + +- Bars render as a single Mean line in v1; per-property (min/max/count) overlays and rate/counter + delta strategies are deferred. +- The endpoint bounds each child to the latest `NodeChartMaxPointsPerSensor` values in the window + to keep the multi-line payload modest. + +## Known Issues / Limitations + +- **v2 — Ranking:** a sorted "top consumers" leaderboard (total / average / peak) as a second tab + on the same data. +- **v3:** mixed-unit nodes (one chart/group per unit), direct-children-vs-subtree scope toggle, + per-type aggregation strategies (rate/counter delta, enum/bool availability = % uptime / flaps). +- The mixed-unit note triggers when a second comparable group (>= 2) exists; a lone stray sensor of + a different unit (singleton) is silently not overlaid. 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 ec7d6cc0f9..7cfe5ce5bb 100644 --- a/src/server/HSMServer/Controllers/HomeController.cs +++ b/src/server/HSMServer/Controllers/HomeController.cs @@ -120,6 +120,7 @@ public async Task SelectNode(string selectedId) foreach (var alert in alerts) alert.Schedules = schedulesList; StoredUser.SelectedNode.ConnectNode(node); + StoredUser.SelectedNode.ShowChartTab = _treeViewModel.GetComparableChildGroups(id).Count > 0; 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..c60ee7680c 100644 --- a/src/server/HSMServer/Controllers/SensorHistoryController.cs +++ b/src/server/HSMServer/Controllers/SensorHistoryController.cs @@ -60,6 +60,10 @@ 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; + private readonly ITreeValuesCache _cache; private readonly TreeViewModel _tree; @@ -172,6 +176,119 @@ public void ReloadHistoryRequest([FromBody] GetSensorHistoryRequest model) StoredUser.History.Reload(model); } + /// + /// Node-level overlay chart (issue #1235): returns every comparable child sensor of the node as + /// one line series over the chosen window. v1 overlays the node's largest (type, unit) group only. + /// 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); + + var group = groups[0]; + var from = request.From.ToUtcKind(); + var to = request.To.ToUtcKind(); + var nodePath = node.FullPath; + + var series = new List(group.SensorIds.Count); + + foreach (var sensorId in group.SensorIds) + { + if (!_tree.Sensors.TryGetValue(sensorId, out var sensor)) + continue; + + var values = await BuildNodeChartSeries(sensor, from, to); + + if (values.Count == 0) // omitted, not zero-filled + continue; + + series.Add(new + { + id = sensor.Id, + label = GetNodeRelativeLabel(nodePath, sensor.FullPath), + values, + }); + } + + var note = groups.Count > 1 + ? $"Showing {series.Count} sensor(s) in {(string.IsNullOrEmpty(group.UnitLabel) ? "no unit" : group.UnitLabel)}. This node also has comparable sensors in other units, which are not overlaid here." + : null; + + return new JsonResult(new + { + error = false, + unit = group.UnitLabel, + note, + series, + }, _serializationsOptions); + } + + private async Task> BuildNodeChartSeries(SensorNodeViewModel sensor, DateTime from, DateTime to) + { + var rawValues = await GetSensorValues(sensor.EncodedId, from, to, NodeChartMaxPointsPerSensor); + + 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)); + } + + return points.OrderBy(point => point.Time) + .Select(point => (object)new { time = point.Time, value = point.Value }) + .ToList(); + } + + private static bool TryGetScalar(BaseValue value, out double scalar) + { + switch (value) + { + case BaseValue doubleValue: + scalar = doubleValue.Value; + return true; + case BaseValue intValue: + scalar = intValue.Value; + return true; + case BarBaseValue doubleBar: + scalar = doubleBar.Mean; + return true; + case BarBaseValue intBar: + scalar = intBar.Mean; + return true; + default: + scalar = 0; + return false; + } + } + + private static string GetNodeRelativeLabel(string nodePath, string sensorPath) + { + if (!string.IsNullOrEmpty(nodePath) && sensorPath.StartsWith(nodePath, StringComparison.Ordinal)) + { + var relative = sensorPath.Substring(nodePath.Length).TrimStart('/'); + + if (relative.Length > 0) + return relative; + } + + return sensorPath; + } + [HttpGet] public IActionResult GetBackgroundSensorInfo([FromQuery] Guid currentId, [FromQuery] bool isStatusService = false) { diff --git a/src/server/HSMServer/Model/History/NodeChartRequest.cs b/src/server/HSMServer/Model/History/NodeChartRequest.cs new file mode 100644 index 0000000000..eeee5cb87f --- /dev/null +++ b/src/server/HSMServer/Model/History/NodeChartRequest.cs @@ -0,0 +1,17 @@ +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 fans out over the node's largest comparable child-sensor group. + /// + public sealed record NodeChartRequest + { + public string NodeId { 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..4384b3c998 --- /dev/null +++ b/src/server/HSMServer/Model/TreeViewModels/NodeSensorGroup.cs @@ -0,0 +1,12 @@ +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, string UnitLabel, List SensorIds); +} diff --git a/src/server/HSMServer/Model/TreeViewModels/SelectedNodeViewModel.cs b/src/server/HSMServer/Model/TreeViewModels/SelectedNodeViewModel.cs index 9f0a8f4125..a41f1c877d 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) { diff --git a/src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs b/src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs index b9f3ed0ab8..ab90c43af1 100644 --- a/src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs +++ b/src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs @@ -1,4 +1,6 @@ -using HSMServer.Authentication; +using HSMCommon.Extensions; +using HSMCommon.Model; +using HSMServer.Authentication; using HSMServer.Core.Cache; using HSMServer.Core.Model; using HSMServer.Core.TableOfChanges; @@ -97,6 +99,50 @@ 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. + /// Used both to decide whether the node "Chart" tab is shown and to drive the overlay endpoint. + /// + internal List GetComparableChildGroups(Guid nodeId) + { + var groups = new Dictionary<(SensorType Type, string Unit), List>(); + + foreach (var sensorId in GetAllNodeSensors(nodeId)) + { + if (!Sensors.TryGetValue(sensorId, out var sensor) || !IsComparableChartSensor(sensor)) + continue; + + var key = (sensor.Type, GetEffectiveUnitLabel(sensor)); + + if (!groups.TryGetValue(key, out var ids)) + groups[key] = ids = new List(); + + ids.Add(sensorId); + } + + return groups.Where(pair => pair.Value.Count > 1) + .Select(pair => new NodeSensorGroup(pair.Key.Type, pair.Key.Unit, pair.Value)) + .OrderByDescending(group => group.SensorIds.Count) + .ThenBy(group => group.UnitLabel) + .ToList(); + } + + // 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; + + private static string GetEffectiveUnitLabel(SensorNodeViewModel sensor) + { + if (sensor.Type is SensorType.Rate && sensor.DisplayUnit.HasValue) + return sensor.DisplayUnit.Value.GetDisplayName(); + + return sensor.SelectedUnit?.GetDisplayName() ?? string.Empty; + } + 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..08a233833c 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..4ba750f228 --- /dev/null +++ b/src/server/HSMServer/Views/Home/_NodeChartTabContent.cshtml @@ -0,0 +1,164 @@ +@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. +
+ +
+ +
+ +
+ +
+
+ + From 2f451ac4e417aa525d5504f4fca2ecf7684c5544 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 9 Jul 2026 12:53:11 +0200 Subject: [PATCH 02/15] Avoid per-sensor reflection in node chart grouping GetComparableChildGroups runs on every node selection (to decide Chart-tab visibility). Keying the groups on the raw unit enum codes instead of the reflection-based display label removes one GetDisplayName() reflection call per descendant sensor; the label is now formatted once per surviving group. Behavior is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../Model/TreeViewModels/TreeViewModel.cs | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs b/src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs index ab90c43af1..e0e65c41b7 100644 --- a/src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs +++ b/src/server/HSMServer/Model/TreeViewModels/TreeViewModel.cs @@ -106,14 +106,16 @@ void GetNodeSensors(Guid nodeId) /// internal List GetComparableChildGroups(Guid nodeId) { - var groups = new Dictionary<(SensorType Type, string Unit), List>(); + // Key on the raw enum codes (cheap struct equality) — no reflection in this per-sensor loop, + // which runs on every node selection. 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, GetEffectiveUnitLabel(sensor)); + var key = (sensor.Type, GetEffectiveUnitCode(sensor)); if (!groups.TryGetValue(key, out var ids)) groups[key] = ids = new List(); @@ -121,9 +123,18 @@ internal List GetComparableChildGroups(Guid nodeId) ids.Add(sensorId); } - return groups.Where(pair => pair.Value.Count > 1) - .Select(pair => new NodeSensorGroup(pair.Key.Type, pair.Key.Unit, pair.Value)) - .OrderByDescending(group => group.SensorIds.Count) + var result = new List(); + + foreach (var (key, ids) in groups) + { + if (ids.Count < 2) + continue; + + var unitLabel = Sensors.TryGetValue(ids[0], out var first) ? GetEffectiveUnitLabel(first) : string.Empty; + result.Add(new NodeSensorGroup(key.Type, unitLabel, ids)); + } + + return result.OrderByDescending(group => group.SensorIds.Count) .ThenBy(group => group.UnitLabel) .ToList(); } @@ -135,6 +146,16 @@ private static bool IsComparableChartSensor(SensorNodeViewModel sensor) => sensor.Type is SensorType.Integer or SensorType.Double or SensorType.Rate or SensorType.IntegerBar or SensorType.DoubleBar; + // Rate sensors group by their display unit; everything else by the selected unit. Returns the + // raw enum value as an int so grouping needs no attribute reflection (unlike the display label). + private static int? GetEffectiveUnitCode(SensorNodeViewModel sensor) + { + if (sensor.Type is SensorType.Rate) + return sensor.DisplayUnit.HasValue ? (int)sensor.DisplayUnit.Value : null; + + return sensor.SelectedUnit.HasValue ? (int)sensor.SelectedUnit.Value : null; + } + private static string GetEffectiveUnitLabel(SensorNodeViewModel sensor) { if (sensor.Type is SensorType.Rate && sensor.DisplayUnit.HasValue) From 5fa7a1b195564da9494b1d8f07e8e847ec3fa7ed Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 9 Jul 2026 13:01:40 +0200 Subject: [PATCH 03/15] Cap node chart overlay at 20 sensors Limit the node "Chart" overlay to MaxSensorsPerChart (20) child sensors. The cap is applied to the chosen comparable group before reading history, so it bounds both the number of overlaid lines (legend readability) and the number of per-request history reads (worst-case cost). When the group is larger, the first 20 are shown and the response note reports "N of M"; ranked selection is v2. The client palette is extended to 20 distinct colors to match the cap. Co-Authored-By: Claude Opus 4.8 --- .../site/node-children-view/feature.md | 9 +++++-- .../Controllers/SensorHistoryController.cs | 25 ++++++++++++++----- .../Views/Home/_NodeChartTabContent.cshtml | 5 +++- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/aicontext/features/site/node-children-view/feature.md b/aicontext/features/site/node-children-view/feature.md index 5bb8b1aff6..a9a27515b7 100644 --- a/aicontext/features/site/node-children-view/feature.md +++ b/aicontext/features/site/node-children-view/feature.md @@ -41,6 +41,10 @@ chart. It is entirely derived from stored history: it never writes or changes se `SelectedNodeViewModel.ShowChartTab = GetComparableChildGroups(nodeId).Count > 0`. Enabled for product/tree **nodes** only, not folders. - **Empty series are omitted, not zero-filled.** A window with no data for a child drops that line. +- **At most `MaxSensorsPerChart` (20) sensors are overlaid.** The cap is applied to the chosen group + *before* reading history, so it bounds both the number of overlaid lines (legend readability) and the + number of per-request history reads (worst-case cost). When the group is larger, the first 20 (tree + order) are shown and a note reports `N of M`. Ranked "top consumers" selection is v2. - 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. @@ -117,8 +121,9 @@ Manual acceptance (issue #1235): - Bars render as a single Mean line in v1; per-property (min/max/count) overlays and rate/counter delta strategies are deferred. -- The endpoint bounds each child to the latest `NodeChartMaxPointsPerSensor` values in the window - to keep the multi-line payload modest. +- The endpoint bounds each child to the latest `NodeChartMaxPointsPerSensor` (2000) values in the + window, and the number of children to `MaxSensorsPerChart` (20), to keep the payload and read count + modest. Both are constants in `SensorHistoryController`. ## Known Issues / Limitations diff --git a/src/server/HSMServer/Controllers/SensorHistoryController.cs b/src/server/HSMServer/Controllers/SensorHistoryController.cs index c60ee7680c..9a668eb9b1 100644 --- a/src/server/HSMServer/Controllers/SensorHistoryController.cs +++ b/src/server/HSMServer/Controllers/SensorHistoryController.cs @@ -64,6 +64,10 @@ public class SensorHistoryController : BaseController // most recent within the window; bounds the payload when many children are overlaid at once. private const int NodeChartMaxPointsPerSensor = -2000; + // Max child sensors overlaid on one node chart. Applied before reading history, so it also bounds + // the number of history reads per request, and keeps the legend readable. Ranked selection is v2. + private const int MaxSensorsPerChart = 20; + private readonly ITreeValuesCache _cache; private readonly TreeViewModel _tree; @@ -202,9 +206,14 @@ public async Task NodeChartHistory([FromBody] NodeChartRequest reque var to = request.To.ToUtcKind(); var nodePath = node.FullPath; - var series = new List(group.SensorIds.Count); + // Cap before reading history: bounds both the overlaid lines and the number of history reads. + var overlaidIds = group.SensorIds.Count > MaxSensorsPerChart + ? group.SensorIds.Take(MaxSensorsPerChart).ToList() + : group.SensorIds; + + var series = new List(overlaidIds.Count); - foreach (var sensorId in group.SensorIds) + foreach (var sensorId in overlaidIds) { if (!_tree.Sensors.TryGetValue(sensorId, out var sensor)) continue; @@ -222,15 +231,19 @@ public async Task NodeChartHistory([FromBody] NodeChartRequest reque }); } - var note = groups.Count > 1 - ? $"Showing {series.Count} sensor(s) in {(string.IsNullOrEmpty(group.UnitLabel) ? "no unit" : group.UnitLabel)}. This node also has comparable sensors in other units, which are not overlaid here." - : null; + var notes = new List(2); + + if (group.SensorIds.Count > MaxSensorsPerChart) + notes.Add($"Showing the first {MaxSensorsPerChart} of {group.SensorIds.Count} comparable sensors (chart limit)."); + + if (groups.Count > 1) + notes.Add($"This node also has comparable sensors in units other than {(string.IsNullOrEmpty(group.UnitLabel) ? "no unit" : group.UnitLabel)}, which are not overlaid here."); return new JsonResult(new { error = false, unit = group.UnitLabel, - note, + note = notes.Count > 0 ? string.Join(" ", notes) : null, series, }, _serializationsOptions); } diff --git a/src/server/HSMServer/Views/Home/_NodeChartTabContent.cshtml b/src/server/HSMServer/Views/Home/_NodeChartTabContent.cshtml index 4ba750f228..2328678ae5 100644 --- a/src/server/HSMServer/Views/Home/_NodeChartTabContent.cshtml +++ b/src/server/HSMServer/Views/Home/_NodeChartTabContent.cshtml @@ -44,9 +44,12 @@