feat(alarms): HiLo trigger type with per-band level, hysteresis, messages, overrides

Adds a new HiLo alarm trigger type with four configurable setpoints
(LoLo / Lo / Hi / HiHi). Each setpoint carries an optional priority,
deadband (for hysteresis), and operator message. The site runtime emits
AlarmStateChanged with an AlarmLevel field so consumers can differentiate
warning vs critical bands.

Plumbing:
  - new AlarmLevel enum + AlarmStateChanged.Level/Message init properties
  - AlarmTriggerEditor (Blazor) gets a HiLo render with severity tinting
  - AlarmTriggerConfigCodec extracted from the editor for testability
  - sitestream.proto carries level + message over gRPC
  - SemanticValidator enforces numeric attribute, setpoint ordering,
    non-negative deadband
  - on-trigger scripts get an Alarm global (Name/Level/Priority/Message)
    so notification routing can branch by severity
  - per-instance InstanceAlarmOverride entity + EF migration + flattening
    step + CLI commands; HiLo overrides merge setpoint-by-setpoint, binary
    types whole-replace
  - DebugView shows a Level badge + per-band message tooltip
  - App.razor auto-reloads on permanent Blazor circuit failure
  - docker/regen-proto.sh automates the proto regen workflow (the linux/arm64
    protoc segfault means generated files are checked in for now)
This commit is contained in:
Joseph Doherty
2026-05-13 03:23:32 -04:00
parent 783da8e21a
commit 751248feb6
46 changed files with 4693 additions and 204 deletions

View File

@@ -191,6 +191,7 @@
<tr>
<th>Alarm</th>
<th>State</th>
<th>Level</th>
<th>Priority</th>
<th>Timestamp</th>
</tr>
@@ -198,12 +199,30 @@
<tbody aria-live="polite" aria-atomic="false">
@foreach (var alarm in FilteredAlarmStates)
{
<tr class="@GetAlarmRowClass(alarm.State)">
<td class="small">@alarm.AlarmName</td>
<tr class="@GetAlarmRowClass(alarm.State)"
title="@(string.IsNullOrEmpty(alarm.Message) ? null : alarm.Message)">
<td class="small">
@alarm.AlarmName
@if (!string.IsNullOrEmpty(alarm.Message))
{
<span class="ms-1 text-info" aria-label="Has operator message">💬</span>
}
</td>
<td>
<span class="badge @GetAlarmStateBadge(alarm.State)"
aria-label="@($"Alarm state: {alarm.State}")">@alarm.State</span>
</td>
<td>
@if (alarm.Level != AlarmLevel.None)
{
<span class="badge @GetAlarmLevelBadge(alarm.Level)"
aria-label="@($"Alarm level: {alarm.Level}")">@FormatLevel(alarm.Level)</span>
}
else
{
<span class="text-muted small">—</span>
}
</td>
<td class="small">@alarm.Priority</td>
<td class="small text-muted"
title="@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
@@ -468,6 +487,26 @@
_ => ""
};
/// <summary>
/// Severity-tinted badge class for HiLo alarm levels. The critical bands
/// (HighHigh / LowLow) get the danger class; warning bands get amber.
/// </summary>
private static string GetAlarmLevelBadge(AlarmLevel level) => level switch
{
AlarmLevel.HighHigh or AlarmLevel.LowLow => "bg-danger",
AlarmLevel.High or AlarmLevel.Low => "bg-warning text-dark",
_ => "bg-secondary"
};
private static string FormatLevel(AlarmLevel level) => level switch
{
AlarmLevel.HighHigh => "HiHi",
AlarmLevel.High => "Hi",
AlarmLevel.Low => "Lo",
AlarmLevel.LowLow => "LoLo",
_ => "—"
};
public void Dispose()
{
if (_session != null)

View File

@@ -754,7 +754,8 @@
<AlarmTriggerEditor TriggerType="@_alarmTriggerType"
Value="@_alarmTriggerConfig"
ValueChanged="@(v => _alarmTriggerConfig = v)"
AvailableAttributes="@BuildAlarmAttributeChoices()" />
AvailableAttributes="@BuildAlarmAttributeChoices()"
FallbackPriority="@_alarmPriority" />
</div>
<div class="col-12">
<div class="form-check">