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.
This commit is contained in:
@@ -58,6 +58,8 @@
|
||||
<MetricCard Label="Active (unacked)" Value="@_unackedCount.ToString("N0")" />
|
||||
<MetricCard Label="Acknowledged" Value="@_ackedCount.ToString("N0")" />
|
||||
<MetricCard Label="Total Active" Value="@_alarms.Count.ToString("N0")" />
|
||||
@* 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. *@
|
||||
<MetricCard Label="Showing" Value="@FilteredAlarms().Count.ToString("N0")" />
|
||||
</section>
|
||||
|
||||
@@ -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<string> Areas()
|
||||
{
|
||||
return _alarms
|
||||
.Select(alarm => alarm.Area)
|
||||
.Where(area => !string.IsNullOrWhiteSpace(area))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(area => area, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
private IReadOnlyList<string> Areas() => _view.Areas(_alarmGeneration, _alarms);
|
||||
|
||||
private IReadOnlyList<DashboardActiveAlarm> 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<DashboardActiveAlarm> 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();
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>The Alarms page's filter inputs, compared by value to decide whether a filtered view is stale.</summary>
|
||||
/// <param name="ShowActive">Include unacknowledged <c>Active</c> alarms.</param>
|
||||
/// <param name="ShowAcked">Include <c>ActiveAcked</c> alarms.</param>
|
||||
/// <param name="Area">Area to match (case-insensitive), or empty for all.</param>
|
||||
/// <param name="MinSeverity">Lowest severity shown.</param>
|
||||
/// <param name="MaxSeverity">Highest severity shown.</param>
|
||||
/// <param name="Search">Reference/source/description text search; trimmed before matching.</param>
|
||||
public sealed record DashboardAlarmFilterCriteria(
|
||||
bool ShowActive,
|
||||
bool ShowAcked,
|
||||
string Area,
|
||||
int MinSeverity,
|
||||
int MaxSeverity,
|
||||
string Search);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Owned by one page instance and touched only from its render dispatcher, so it is not
|
||||
/// synchronized.
|
||||
/// </remarks>
|
||||
public sealed class DashboardAlarmView
|
||||
{
|
||||
private long _filteredGeneration = -1;
|
||||
private DashboardAlarmFilterCriteria? _filteredCriteria;
|
||||
private IReadOnlyList<DashboardActiveAlarm> _filtered = [];
|
||||
private long _areasGeneration = -1;
|
||||
private IReadOnlyList<string> _areas = [];
|
||||
|
||||
/// <summary>Gets how many times the filtered rows were actually computed (diagnostic, for tests).</summary>
|
||||
internal int FilterComputations { get; private set; }
|
||||
|
||||
/// <summary>Gets how many times the area list was actually computed (diagnostic, for tests).</summary>
|
||||
internal int AreaComputations { get; private set; }
|
||||
|
||||
/// <summary>Returns the alarms matching <paramref name="criteria"/>, highest severity then newest first.</summary>
|
||||
/// <param name="generation">
|
||||
/// Version of <paramref name="alarms"/>; the caller bumps it whenever it replaces the list's contents.
|
||||
/// </param>
|
||||
/// <param name="criteria">The current filter inputs.</param>
|
||||
/// <param name="alarms">The full active-alarm list.</param>
|
||||
/// <returns>The filtered rows; the same instance while generation and criteria are unchanged.</returns>
|
||||
public IReadOnlyList<DashboardActiveAlarm> Filtered(
|
||||
long generation,
|
||||
DashboardAlarmFilterCriteria criteria,
|
||||
IReadOnlyList<DashboardActiveAlarm> 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;
|
||||
}
|
||||
|
||||
/// <summary>Returns the distinct non-blank areas of <paramref name="alarms"/>, sorted case-insensitively.</summary>
|
||||
/// <param name="generation">Version of <paramref name="alarms"/>.</param>
|
||||
/// <param name="alarms">The full active-alarm list.</param>
|
||||
/// <returns>The area list; the same instance while the generation is unchanged.</returns>
|
||||
public IReadOnlyList<string> Areas(long generation, IReadOnlyList<DashboardActiveAlarm> 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<DashboardActiveAlarm> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Alarms page's memoized projections: one computation per change of
|
||||
/// the alarm generation or filter inputs, however many times a render asks.
|
||||
/// </summary>
|
||||
public sealed class DashboardAlarmViewTests
|
||||
{
|
||||
private static readonly DashboardAlarmFilterCriteria DefaultCriteria =
|
||||
new(ShowActive: true, ShowAcked: false, Area: string.Empty, MinSeverity: 0, MaxSeverity: 1000, Search: string.Empty);
|
||||
|
||||
/// <summary>Repeated reads at the same generation and criteria reuse one computation.</summary>
|
||||
[Fact]
|
||||
public void Filtered_WithUnchangedInputs_ComputesOnce()
|
||||
{
|
||||
DashboardAlarmView view = new();
|
||||
DashboardActiveAlarm[] alarms = [Alarm("A", 500), Alarm("B", 800)];
|
||||
|
||||
IReadOnlyList<DashboardActiveAlarm> first = view.Filtered(1, DefaultCriteria, alarms);
|
||||
IReadOnlyList<DashboardActiveAlarm> 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));
|
||||
}
|
||||
|
||||
/// <summary>A changed filter input or a new alarm generation recomputes.</summary>
|
||||
[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<DashboardActiveAlarm> 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<DashboardActiveAlarm> next = view.Filtered(2, DefaultCriteria with { Search = "tank" }, refreshed);
|
||||
|
||||
Assert.Equal(3, view.FilterComputations);
|
||||
Assert.Equal(["Tank03.Hi"], next.Select(alarm => alarm.Reference));
|
||||
}
|
||||
|
||||
/// <summary>The area list is computed once per alarm generation, independent of filter inputs.</summary>
|
||||
[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<string> first = view.Areas(1, alarms);
|
||||
IReadOnlyList<string> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user