diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index 94c1be3..e09ee9f 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -165,6 +165,76 @@ + @* Alarm Overrides *@ +
+
+ Alarm Overrides + + 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. + +
+
+ @if (_overridableAlarms.Count == 0) + { +

No overridable (non-locked) alarms on this template.

+ } + else + { + + + + + + + + + + + + + @foreach (var alarm in _overridableAlarms) + { + + + + + + + + + } + +
AlarmTriggerInherited ConfigOverride JSONPriority
@alarm.Name + @alarm.TriggerType + + @(alarm.TriggerConfiguration ?? "—") + + + + + + @if (HasOverride(alarm.Name)) + { + + } +
+
+ +
+ } +
+
+ @* Area Assignment *@
@@ -208,6 +278,15 @@ private List _overrideAttrs = new(); private Dictionary _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 _overridableAlarms = new(); + private Dictionary _existingAlarmOverrides = new(); + private Dictionary _alarmOverrideJson = new(); + private Dictionary _alarmOverridePriority = new(); + // Area private List _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(); + + /// + /// 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. + /// + 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()