feat(ui): instance alarm override editor in InstanceConfigure

Adds an Alarm Overrides card to the per-instance Configure page (next to
the existing Attribute Overrides and Connection Bindings cards). Each
non-locked template alarm gets a row showing its trigger type, inherited
config, and inputs for an override JSON + priority override. A Clear
button removes the override; the Save Alarm Overrides button upserts
all dirty rows.

The HiLo merge / binary whole-replace semantics are surfaced via the JSON
placeholder hint per trigger type. Wired to the existing
InstanceService.SetAlarmOverrideAsync / DeleteAlarmOverrideAsync flow.
This commit is contained in:
Joseph Doherty
2026-05-13 03:28:39 -04:00
parent 751248feb6
commit 4e446a7170

View File

@@ -165,6 +165,76 @@
</div>
</div>
@* Alarm Overrides *@
<div class="card mb-3">
<div class="card-header py-2">
<strong>Alarm Overrides</strong>
<small class="text-muted ms-2">
For HiLo alarms, override JSON merges into the inherited config setpoint-by-setpoint.
For binary trigger types, override JSON replaces the whole config. Leave both fields empty to inherit.
</small>
</div>
<div class="card-body p-0">
@if (_overridableAlarms.Count == 0)
{
<p class="text-muted small p-3 mb-0">No overridable (non-locked) alarms on this template.</p>
}
else
{
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>Alarm</th>
<th style="width: 110px;">Trigger</th>
<th>Inherited Config</th>
<th style="width: 320px;">Override JSON</th>
<th style="width: 120px;">Priority</th>
<th style="width: 80px;"></th>
</tr>
</thead>
<tbody>
@foreach (var alarm in _overridableAlarms)
{
<tr>
<td class="small">@alarm.Name</td>
<td>
<span class="badge bg-light text-dark border">@alarm.TriggerType</span>
</td>
<td class="small text-muted text-truncate font-monospace" style="max-width: 280px;"
title="@alarm.TriggerConfiguration">
@(alarm.TriggerConfiguration ?? "—")
</td>
<td>
<input type="text" class="form-control form-control-sm font-monospace"
placeholder='@GetAlarmJsonPlaceholder(alarm.TriggerType)'
value="@GetAlarmOverrideJson(alarm.Name)"
@onchange="(e) => OnAlarmOverrideJsonChanged(alarm.Name, e)" />
</td>
<td>
<input type="number" min="0" max="1000" class="form-control form-control-sm"
placeholder="@alarm.PriorityLevel"
value="@GetAlarmOverridePriority(alarm.Name)"
@onchange="(e) => OnAlarmOverridePriorityChanged(alarm.Name, e)" />
</td>
<td>
@if (HasOverride(alarm.Name))
{
<button class="btn btn-outline-danger btn-sm"
@onclick="() => ClearAlarmOverride(alarm.Name)"
disabled="@_saving">Clear</button>
}
</td>
</tr>
}
</tbody>
</table>
<div class="p-2">
<button class="btn btn-success btn-sm" @onclick="SaveAlarmOverrides" disabled="@_saving">Save Alarm Overrides</button>
</div>
}
</div>
</div>
@* Area Assignment *@
<div class="card mb-3">
<div class="card-header py-2">
@@ -208,6 +278,15 @@
private List<TemplateAttribute> _overrideAttrs = new();
private Dictionary<string, string?> _overrideValues = new();
// Alarm overrides — keyed by alarm name. Dirty rows live in
// _alarmOverrideJson / _alarmOverridePriority; existing overrides are
// loaded via _existingAlarmOverrides so the UI can show inherited
// placeholders correctly.
private List<TemplateAlarm> _overridableAlarms = new();
private Dictionary<string, InstanceAlarmOverride> _existingAlarmOverrides = new();
private Dictionary<string, string?> _alarmOverrideJson = new();
private Dictionary<string, int?> _alarmOverridePriority = new();
// Area
private List<Area> _siteAreas = new();
private int _reassignAreaId;
@@ -249,6 +328,19 @@
var existingOverrides = await TemplateEngineRepository.GetOverridesByInstanceIdAsync(Id);
foreach (var o in existingOverrides)
_overrideValues[o.AttributeName] = o.OverrideValue;
// Alarm overrides — load all non-locked template alarms and
// existing override rows. Pre-seed the dirty maps from existing
// values so the inputs render with what's currently saved.
var alarms = await TemplateEngineRepository.GetAlarmsByTemplateIdAsync(_instance.TemplateId);
_overridableAlarms = alarms.Where(a => !a.IsLocked).ToList();
var alarmOverrides = await TemplateEngineRepository.GetAlarmOverridesByInstanceIdAsync(Id);
foreach (var o in alarmOverrides)
{
_existingAlarmOverrides[o.AlarmCanonicalName] = o;
_alarmOverrideJson[o.AlarmCanonicalName] = o.TriggerConfigurationOverride;
_alarmOverridePriority[o.AlarmCanonicalName] = o.PriorityLevelOverride;
}
}
catch (Exception ex)
{
@@ -333,6 +425,124 @@
_saving = false;
}
// ── Alarm overrides ─────────────────────────────────────
private bool HasOverride(string alarmName) =>
_existingAlarmOverrides.ContainsKey(alarmName);
private string? GetAlarmOverrideJson(string alarmName) =>
_alarmOverrideJson.GetValueOrDefault(alarmName);
private string? GetAlarmOverridePriority(string alarmName) =>
_alarmOverridePriority.GetValueOrDefault(alarmName)?.ToString();
/// <summary>
/// Placeholder hint shown in the JSON override input. Encourages the
/// HiLo "partial JSON" idiom (e.g. {"hi":90}) vs. the binary-trigger
/// "whole-config" idiom.
/// </summary>
private static string GetAlarmJsonPlaceholder(AlarmTriggerType type) => type switch
{
AlarmTriggerType.HiLo => "{\"hi\":90} — partial keys merge",
_ => "{\"attributeName\":\"...\"} — whole-replace"
};
private void OnAlarmOverrideJsonChanged(string alarmName, ChangeEventArgs e)
{
var val = e.Value?.ToString();
if (string.IsNullOrWhiteSpace(val))
_alarmOverrideJson.Remove(alarmName);
else
_alarmOverrideJson[alarmName] = val;
}
private void OnAlarmOverridePriorityChanged(string alarmName, ChangeEventArgs e)
{
var raw = e.Value?.ToString();
if (string.IsNullOrWhiteSpace(raw))
{
_alarmOverridePriority.Remove(alarmName);
return;
}
if (int.TryParse(raw, out var p))
_alarmOverridePriority[alarmName] = p;
}
private async Task ClearAlarmOverride(string alarmName)
{
_saving = true;
try
{
var user = await GetCurrentUserAsync();
var result = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user);
if (result.IsSuccess)
{
_existingAlarmOverrides.Remove(alarmName);
_alarmOverrideJson.Remove(alarmName);
_alarmOverridePriority.Remove(alarmName);
_toast.ShowSuccess($"Cleared override on '{alarmName}'.");
}
else
{
_toast.ShowError($"Clear failed: {result.Error}");
}
}
catch (Exception ex)
{
_toast.ShowError($"Clear failed: {ex.Message}");
}
_saving = false;
}
private async Task SaveAlarmOverrides()
{
_saving = true;
try
{
var user = await GetCurrentUserAsync();
var touched = _alarmOverrideJson.Keys
.Union(_alarmOverridePriority.Keys, StringComparer.Ordinal)
.Distinct()
.ToList();
var saved = 0;
foreach (var alarmName in touched)
{
var json = _alarmOverrideJson.GetValueOrDefault(alarmName);
var priority = _alarmOverridePriority.GetValueOrDefault(alarmName);
// If both fields are empty AND there's an existing override,
// clear it. Otherwise upsert (allows priority-only or json-only).
if (string.IsNullOrWhiteSpace(json) && !priority.HasValue)
{
if (_existingAlarmOverrides.ContainsKey(alarmName))
{
await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user);
_existingAlarmOverrides.Remove(alarmName);
}
continue;
}
var result = await InstanceService.SetAlarmOverrideAsync(
Id, alarmName, json, priority, user);
if (!result.IsSuccess)
{
_toast.ShowError($"Save failed for '{alarmName}': {result.Error}");
continue;
}
_existingAlarmOverrides[alarmName] = result.Value!;
saved++;
}
if (saved > 0) _toast.ShowSuccess($"Saved {saved} alarm override(s).");
}
catch (Exception ex)
{
_toast.ShowError($"Save alarm overrides failed: {ex.Message}");
}
_saving = false;
}
// ── Area ────────────────────────────────────────────────
private async Task ReassignArea()