From 2a7d6facceef1b80c704d93d2d31a507da38d03d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 06:19:34 -0400 Subject: [PATCH] perf(dashboard): memoize AlarmsPage filtered rows and area list FilteredAlarms() ran the full filter+sort twice per render (Showing card and table) and Areas() once more, including every search keystroke render. Both now go through DashboardAlarmView, recomputed only when the alarm generation (bumped per poll) or the filter criteria change. --- .../Components/Pages/AlarmsPage.razor | 48 ++----- .../Dashboard/DashboardAlarmView.cs | 125 ++++++++++++++++++ .../Dashboard/DashboardAlarmViewTests.cs | 83 ++++++++++++ 3 files changed, 221 insertions(+), 35 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAlarmView.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardAlarmViewTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor index b85b34e..690a6be 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor @@ -58,6 +58,8 @@ + @* FilteredAlarms() is memoized on (alarm generation, filter inputs), so this card and the + table below share one computation per change instead of filtering twice per render. *@ @@ -189,6 +191,10 @@ private int _maxSeverity = 1000; private string _search = string.Empty; + // Bumped whenever _alarms is replaced, so the memoized view recomputes exactly then. + private readonly DashboardAlarmView _view = new(); + private long _alarmGeneration; + // Upper bound on waiting for either background loop while disposing, mirroring // DashboardPageBase's snapshot-watch drain: both loops marshal renders through the // renderer's dispatcher and disposal can run on that same dispatcher, so the wait is @@ -295,42 +301,13 @@ return _workerPid is int pid ? $"{refreshed} · worker pid {pid}" : refreshed; } - private IReadOnlyList Areas() - { - return _alarms - .Select(alarm => alarm.Area) - .Where(area => !string.IsNullOrWhiteSpace(area)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(area => area, StringComparer.OrdinalIgnoreCase) - .ToArray(); - } + private IReadOnlyList Areas() => _view.Areas(_alarmGeneration, _alarms); - private IReadOnlyList FilteredAlarms() - { - string query = _search.Trim(); - return _alarms - .Where(MatchesState) - .Where(alarm => _areaFilter.Length == 0 - || string.Equals(alarm.Area, _areaFilter, StringComparison.OrdinalIgnoreCase)) - .Where(alarm => alarm.Severity >= _minSeverity && alarm.Severity <= _maxSeverity) - .Where(alarm => query.Length == 0 - || alarm.Reference.Contains(query, StringComparison.OrdinalIgnoreCase) - || alarm.Source.Contains(query, StringComparison.OrdinalIgnoreCase) - || alarm.Description.Contains(query, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(alarm => alarm.Severity) - .ThenByDescending(alarm => alarm.LastTransition ?? DateTimeOffset.MinValue) - .ToArray(); - } - - private bool MatchesState(DashboardActiveAlarm alarm) - { - return alarm.State switch - { - AlarmConditionState.Active => _showActive, - AlarmConditionState.ActiveAcked => _showAcked, - _ => true, - }; - } + private IReadOnlyList FilteredAlarms() => + _view.Filtered( + _alarmGeneration, + new DashboardAlarmFilterCriteria(_showActive, _showAcked, _areaFilter, _minSeverity, _maxSeverity, _search), + _alarms); private static string StateText(AlarmConditionState state) { @@ -439,6 +416,7 @@ _lastRefresh = DateTimeOffset.UtcNow; _alarms.Clear(); _alarms.AddRange(result.Alarms); + _alarmGeneration++; _unackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.Active); _ackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.ActiveAcked); StateHasChanged(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAlarmView.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAlarmView.cs new file mode 100644 index 0000000..afa4aa6 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAlarmView.cs @@ -0,0 +1,125 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// The Alarms page's filter inputs, compared by value to decide whether a filtered view is stale. +/// Include unacknowledged Active alarms. +/// Include ActiveAcked alarms. +/// Area to match (case-insensitive), or empty for all. +/// Lowest severity shown. +/// Highest severity shown. +/// Reference/source/description text search; trimmed before matching. +public sealed record DashboardAlarmFilterCriteria( + bool ShowActive, + bool ShowAcked, + string Area, + int MinSeverity, + int MaxSeverity, + string Search); + +/// +/// Memoized projections of the Alarms page's alarm list: the filtered, sorted rows and +/// the distinct area list. A render used to run the full filter-and-sort twice (the +/// "Showing" card and the table) and the area projection once more, on every render — +/// including the renders that typing in the search box triggers per keystroke. Each +/// projection is now recomputed only when the alarm list (its generation) or, for the +/// rows, the filter criteria change. +/// +/// +/// Owned by one page instance and touched only from its render dispatcher, so it is not +/// synchronized. +/// +public sealed class DashboardAlarmView +{ + private long _filteredGeneration = -1; + private DashboardAlarmFilterCriteria? _filteredCriteria; + private IReadOnlyList _filtered = []; + private long _areasGeneration = -1; + private IReadOnlyList _areas = []; + + /// Gets how many times the filtered rows were actually computed (diagnostic, for tests). + internal int FilterComputations { get; private set; } + + /// Gets how many times the area list was actually computed (diagnostic, for tests). + internal int AreaComputations { get; private set; } + + /// Returns the alarms matching , highest severity then newest first. + /// + /// Version of ; the caller bumps it whenever it replaces the list's contents. + /// + /// The current filter inputs. + /// The full active-alarm list. + /// The filtered rows; the same instance while generation and criteria are unchanged. + public IReadOnlyList Filtered( + long generation, + DashboardAlarmFilterCriteria criteria, + IReadOnlyList alarms) + { + ArgumentNullException.ThrowIfNull(criteria); + ArgumentNullException.ThrowIfNull(alarms); + + if (generation == _filteredGeneration && criteria == _filteredCriteria) + { + return _filtered; + } + + _filtered = Apply(criteria, alarms); + _filteredGeneration = generation; + _filteredCriteria = criteria; + FilterComputations++; + return _filtered; + } + + /// Returns the distinct non-blank areas of , sorted case-insensitively. + /// Version of . + /// The full active-alarm list. + /// The area list; the same instance while the generation is unchanged. + public IReadOnlyList Areas(long generation, IReadOnlyList alarms) + { + ArgumentNullException.ThrowIfNull(alarms); + + if (generation == _areasGeneration) + { + return _areas; + } + + _areas = alarms + .Select(alarm => alarm.Area) + .Where(area => !string.IsNullOrWhiteSpace(area)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(area => area, StringComparer.OrdinalIgnoreCase) + .ToArray(); + _areasGeneration = generation; + AreaComputations++; + return _areas; + } + + private static DashboardActiveAlarm[] Apply( + DashboardAlarmFilterCriteria criteria, + IReadOnlyList alarms) + { + string query = criteria.Search.Trim(); + return alarms + .Where(alarm => MatchesState(criteria, alarm)) + .Where(alarm => criteria.Area.Length == 0 + || string.Equals(alarm.Area, criteria.Area, StringComparison.OrdinalIgnoreCase)) + .Where(alarm => alarm.Severity >= criteria.MinSeverity && alarm.Severity <= criteria.MaxSeverity) + .Where(alarm => query.Length == 0 + || alarm.Reference.Contains(query, StringComparison.OrdinalIgnoreCase) + || alarm.Source.Contains(query, StringComparison.OrdinalIgnoreCase) + || alarm.Description.Contains(query, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(alarm => alarm.Severity) + .ThenByDescending(alarm => alarm.LastTransition ?? DateTimeOffset.MinValue) + .ToArray(); + } + + private static bool MatchesState(DashboardAlarmFilterCriteria criteria, DashboardActiveAlarm alarm) + { + return alarm.State switch + { + AlarmConditionState.Active => criteria.ShowActive, + AlarmConditionState.ActiveAcked => criteria.ShowAcked, + _ => true, + }; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardAlarmViewTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardAlarmViewTests.cs new file mode 100644 index 0000000..d9a5645 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardAlarmViewTests.cs @@ -0,0 +1,83 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Dashboard; + +namespace ZB.MOM.WW.MxGateway.Tests.Dashboard; + +/// +/// Verifies the Alarms page's memoized projections: one computation per change of +/// the alarm generation or filter inputs, however many times a render asks. +/// +public sealed class DashboardAlarmViewTests +{ + private static readonly DashboardAlarmFilterCriteria DefaultCriteria = + new(ShowActive: true, ShowAcked: false, Area: string.Empty, MinSeverity: 0, MaxSeverity: 1000, Search: string.Empty); + + /// Repeated reads at the same generation and criteria reuse one computation. + [Fact] + public void Filtered_WithUnchangedInputs_ComputesOnce() + { + DashboardAlarmView view = new(); + DashboardActiveAlarm[] alarms = [Alarm("A", 500), Alarm("B", 800)]; + + IReadOnlyList first = view.Filtered(1, DefaultCriteria, alarms); + IReadOnlyList second = view.Filtered(1, DefaultCriteria with { }, alarms); + + Assert.Same(first, second); + Assert.Equal(1, view.FilterComputations); + Assert.Equal(["B", "A"], first.Select(alarm => alarm.Reference)); + } + + /// A changed filter input or a new alarm generation recomputes. + [Fact] + public void Filtered_WhenCriteriaOrGenerationChange_Recomputes() + { + DashboardAlarmView view = new(); + DashboardActiveAlarm[] alarms = [Alarm("Tank01.Hi", 500), Alarm("Pump02.Lo", 800)]; + + view.Filtered(1, DefaultCriteria, alarms); + IReadOnlyList searched = view.Filtered(1, DefaultCriteria with { Search = "tank" }, alarms); + + Assert.Equal(2, view.FilterComputations); + Assert.Equal(["Tank01.Hi"], searched.Select(alarm => alarm.Reference)); + + DashboardActiveAlarm[] refreshed = [Alarm("Tank03.Hi", 900)]; + IReadOnlyList next = view.Filtered(2, DefaultCriteria with { Search = "tank" }, refreshed); + + Assert.Equal(3, view.FilterComputations); + Assert.Equal(["Tank03.Hi"], next.Select(alarm => alarm.Reference)); + } + + /// The area list is computed once per alarm generation, independent of filter inputs. + [Fact] + public void Areas_ComputesOncePerGeneration() + { + DashboardAlarmView view = new(); + DashboardActiveAlarm[] alarms = [Alarm("A", 1, "north"), Alarm("B", 1, "North"), Alarm("C", 1, "east"), Alarm("D", 1, " ")]; + + IReadOnlyList first = view.Areas(1, alarms); + IReadOnlyList second = view.Areas(1, alarms); + + Assert.Same(first, second); + Assert.Equal(1, view.AreaComputations); + Assert.Equal(["east", "north"], first); + + view.Areas(2, alarms); + Assert.Equal(2, view.AreaComputations); + } + + private static DashboardActiveAlarm Alarm(string reference, int severity, string area = "") + { + return new DashboardActiveAlarm( + Reference: reference, + Provider: string.Empty, + Area: area, + Source: string.Empty, + AlarmType: string.Empty, + Severity: severity, + State: AlarmConditionState.Active, + LastTransition: null, + OperatorUser: string.Empty, + OperatorComment: string.Empty, + Description: string.Empty); + } +}