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