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

@@ -169,6 +169,94 @@ public class InstanceService
}
}
/// <summary>
/// Sets a per-instance alarm override. The alarm must exist on the
/// template and must not be locked. For HiLo alarms, the override JSON
/// merges into the inherited TriggerConfiguration setpoint-by-setpoint;
/// for binary trigger types, it replaces the whole config.
/// </summary>
public async Task<Result<InstanceAlarmOverride>> SetAlarmOverrideAsync(
int instanceId,
string alarmCanonicalName,
string? triggerConfigurationOverride,
int? priorityLevelOverride,
string user,
CancellationToken cancellationToken = default)
{
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<InstanceAlarmOverride>.Failure($"Instance with ID {instanceId} not found.");
// Verify alarm exists in the template and is not locked. Only direct
// template alarms are checked here — composed-member overrides go
// through but are silently ignored at runtime if the name doesn't
// match (same behavior as attribute overrides).
var templateAlarms = await _repository.GetAlarmsByTemplateIdAsync(instance.TemplateId, cancellationToken);
var templateAlarm = templateAlarms.FirstOrDefault(a => a.Name == alarmCanonicalName);
if (templateAlarm != null && templateAlarm.IsLocked)
{
return Result<InstanceAlarmOverride>.Failure(
$"Alarm '{alarmCanonicalName}' is locked and cannot be overridden.");
}
var existingOverride = await _repository.GetAlarmOverrideAsync(
instanceId, alarmCanonicalName, cancellationToken);
if (existingOverride != null)
{
existingOverride.TriggerConfigurationOverride = triggerConfigurationOverride;
existingOverride.PriorityLevelOverride = priorityLevelOverride;
await _repository.UpdateInstanceAlarmOverrideAsync(existingOverride, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "UpdateAlarmOverride", "InstanceAlarmOverride",
existingOverride.Id.ToString(), alarmCanonicalName, existingOverride, cancellationToken);
return Result<InstanceAlarmOverride>.Success(existingOverride);
}
else
{
var newOverride = new InstanceAlarmOverride(alarmCanonicalName)
{
InstanceId = instanceId,
TriggerConfigurationOverride = triggerConfigurationOverride,
PriorityLevelOverride = priorityLevelOverride
};
await _repository.AddInstanceAlarmOverrideAsync(newOverride, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "CreateAlarmOverride", "InstanceAlarmOverride",
newOverride.Id.ToString(), alarmCanonicalName, newOverride, cancellationToken);
return Result<InstanceAlarmOverride>.Success(newOverride);
}
}
/// <summary>
/// Removes a per-instance alarm override. After removal the instance
/// inherits the template alarm config unchanged.
/// </summary>
public async Task<Result<bool>> DeleteAlarmOverrideAsync(
int instanceId,
string alarmCanonicalName,
string user,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetAlarmOverrideAsync(
instanceId, alarmCanonicalName, cancellationToken);
if (existing == null)
return Result<bool>.Failure($"No alarm override for '{alarmCanonicalName}' on instance {instanceId}.");
await _repository.DeleteInstanceAlarmOverrideAsync(existing.Id, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "DeleteAlarmOverride", "InstanceAlarmOverride",
existing.Id.ToString(), alarmCanonicalName, existing, cancellationToken);
return Result<bool>.Success(true);
}
/// <summary>
/// Sets connection bindings for an instance in bulk.
/// </summary>