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); + } +}