From 164d914ba88e33ee8ae72efc04718a606610c4e9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 13 May 2026 04:05:08 -0400 Subject: [PATCH] feat(ui): rich AlarmTriggerEditor in instance override modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-row JSON textbox with an Edit button that opens a modal hosting the full AlarmTriggerEditor. The editor pre-populates with the merged inherited + override config so the operator sees the effective state, not the override delta. On Save: - HiLo: diff against inherited, store only changed keys - Binary trigger types: whole-replace if the edited config differs Value comparison in the diff is type-aware (decoded strings, numeric GetDouble) so JSON-escape differences (e.g., literal em-dash vs —) don't produce false-positive diffs that pollute the override JSON. FlatteningService.MergeHiLoConfig is now public so the UI can pre-merge the editor seed; new public DiffHiLoConfig handles the symmetric direction. +2 encoding tests cover the new equivalence behavior. The override row's summary column shows the diff'd keys + priority chip so operators see what's overridden at a glance. --- .../Pages/Deployment/InstanceConfigure.razor | 352 +++++++++++++----- .../Flattening/FlatteningService.cs | 79 +++- .../Flattening/FlatteningServiceMergeTests.cs | 94 +++++ 3 files changed, 421 insertions(+), 104 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index e09ee9f..3caf440 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -5,6 +5,7 @@ @using ScadaLink.Commons.Entities.Templates @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Types.Enums +@using ScadaLink.TemplateEngine.Flattening @using ScadaLink.TemplateEngine.Services @attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] @inject ITemplateEngineRepository TemplateEngineRepository @@ -170,8 +171,8 @@
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. + Click Edit to override an alarm's trigger configuration or priority. + HiLo overrides merge into the inherited setpoints; other trigger types replace the whole config.
@@ -187,9 +188,8 @@ Alarm Trigger Inherited Config - Override JSON - Priority - + Override + Actions @@ -204,19 +204,21 @@ title="@alarm.TriggerConfiguration"> @(alarm.TriggerConfiguration ?? "—") - - - - - + + @if (HasOverride(alarm.Name)) + { + + @OverrideSummary(alarm.Name) + } + else + { + inherited + } + @if (HasOverride(alarm.Name)) { -
} + @* Override edit modal *@ + @if (_editingAlarm != null) + { + + } + @* Area Assignment *@
@@ -278,14 +340,18 @@ 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. + // Alarm overrides — read-only state pulled from the repo. The edit modal + // is the only mutation path (one alarm at a time). private List _overridableAlarms = new(); private Dictionary _existingAlarmOverrides = new(); - private Dictionary _alarmOverrideJson = new(); - private Dictionary _alarmOverridePriority = new(); + + // Override edit modal state — non-null while the modal is open. + private TemplateAlarm? _editingAlarm; + private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor + private string? _editingInheritedValue; // the inherited config snapshot we diff against on save + private string? _editingPriorityText; + private string? _editingError; + private IReadOnlyList _editingAvailableAttributes = Array.Empty(); // Area private List _siteAreas = new(); @@ -338,8 +404,6 @@ foreach (var o in alarmOverrides) { _existingAlarmOverrides[o.AlarmCanonicalName] = o; - _alarmOverrideJson[o.AlarmCanonicalName] = o.TriggerConfigurationOverride; - _alarmOverridePriority[o.AlarmCanonicalName] = o.PriorityLevelOverride; } } catch (Exception ex) @@ -430,42 +494,158 @@ 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. + /// Human-readable summary of the currently-saved override. Lists the + /// HiLo keys that differ from the inherited config plus a priority chip. + /// Used by the row's "Override" column. /// - private static string GetAlarmJsonPlaceholder(AlarmTriggerType type) => type switch + private string OverrideSummary(string alarmName) { - AlarmTriggerType.HiLo => "{\"hi\":90} — partial keys merge", - _ => "{\"attributeName\":\"...\"} — whole-replace" - }; + if (!_existingAlarmOverrides.TryGetValue(alarmName, out var ovr)) + return ""; - private void OnAlarmOverrideJsonChanged(string alarmName, ChangeEventArgs e) - { - var val = e.Value?.ToString(); - if (string.IsNullOrWhiteSpace(val)) - _alarmOverrideJson.Remove(alarmName); - else - _alarmOverrideJson[alarmName] = val; + var parts = new List(); + if (!string.IsNullOrWhiteSpace(ovr.TriggerConfigurationOverride)) + { + try + { + using var doc = System.Text.Json.JsonDocument.Parse(ovr.TriggerConfigurationOverride); + if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object) + { + parts.AddRange(doc.RootElement.EnumerateObject().Select(p => p.Name)); + } + } + catch (System.Text.Json.JsonException) + { + parts.Add("(invalid JSON)"); + } + } + if (ovr.PriorityLevelOverride.HasValue) + parts.Add($"priority={ovr.PriorityLevelOverride.Value}"); + + return parts.Count == 0 ? "(empty)" : string.Join(", ", parts); } - private void OnAlarmOverridePriorityChanged(string alarmName, ChangeEventArgs e) + /// + /// Opens the override editor modal pre-populated with the merged + /// (inherited + existing override) config so the user sees the effective + /// state — not just the override delta. + /// + private void BeginEditOverride(TemplateAlarm alarm) { - var raw = e.Value?.ToString(); - if (string.IsNullOrWhiteSpace(raw)) + _editingAlarm = alarm; + _editingError = null; + _editingInheritedValue = alarm.TriggerConfiguration; + + var existing = _existingAlarmOverrides.GetValueOrDefault(alarm.Name); + + // HiLo: merge inherited + override so the editor shows the effective + // setpoints. Binary: pre-fill with the override if present, else the + // inherited config — same idea. + _editingOverrideValue = alarm.TriggerType == AlarmTriggerType.HiLo + ? FlatteningService.MergeHiLoConfig(alarm.TriggerConfiguration, existing?.TriggerConfigurationOverride) + : (existing?.TriggerConfigurationOverride ?? alarm.TriggerConfiguration); + + _editingPriorityText = existing?.PriorityLevelOverride?.ToString(); + _editingAvailableAttributes = _overrideAttrs + .Select(a => new AlarmAttributeChoice(a.Name, MapDataType(a.DataType), "Direct")) + .ToList(); + } + + private void CancelEditOverride() + { + _editingAlarm = null; + _editingError = null; + } + + private async Task SaveOverrideFromModal() + { + if (_editingAlarm == null) return; + + _saving = true; + try { - _alarmOverridePriority.Remove(alarmName); - return; + int? priority = null; + if (!string.IsNullOrWhiteSpace(_editingPriorityText)) + { + if (!int.TryParse(_editingPriorityText, out var p)) + { + _editingError = "Priority must be an integer."; + _saving = false; + return; + } + priority = p; + } + + // Compute the override JSON. For HiLo, diff against inherited so we + // store only the changed keys (matches the merge-on-flatten flow). + // For binary, whole-replace if the edited config differs from + // inherited. + string? overrideJson; + if (_editingAlarm.TriggerType == AlarmTriggerType.HiLo) + { + overrideJson = FlatteningService.DiffHiLoConfig(_editingInheritedValue, _editingOverrideValue); + } + else + { + overrideJson = _editingOverrideValue == _editingInheritedValue + ? null + : _editingOverrideValue; + } + + var user = await GetCurrentUserAsync(); + var alarmName = _editingAlarm.Name; + + // No diff + no priority → clear any existing override and close. + if (string.IsNullOrWhiteSpace(overrideJson) && !priority.HasValue) + { + if (_existingAlarmOverrides.ContainsKey(alarmName)) + { + var del = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user); + if (!del.IsSuccess) + { + _editingError = del.Error; + _saving = false; + return; + } + _existingAlarmOverrides.Remove(alarmName); + _toast.ShowSuccess($"Cleared override on '{alarmName}'."); + } + else + { + _toast.ShowSuccess("No change."); + } + } + else + { + var result = await InstanceService.SetAlarmOverrideAsync( + Id, alarmName, overrideJson, priority, user); + if (!result.IsSuccess) + { + _editingError = result.Error; + _saving = false; + return; + } + _existingAlarmOverrides[alarmName] = result.Value!; + _toast.ShowSuccess($"Saved override on '{alarmName}'."); + } + + _editingAlarm = null; + _editingError = null; } - if (int.TryParse(raw, out var p)) - _alarmOverridePriority[alarmName] = p; + catch (Exception ex) + { + _editingError = ex.Message; + } + _saving = false; + } + + private async Task ClearFromModal() + { + if (_editingAlarm == null) return; + var name = _editingAlarm.Name; + await ClearAlarmOverride(name); + _editingAlarm = null; } private async Task ClearAlarmOverride(string alarmName) @@ -478,8 +658,6 @@ if (result.IsSuccess) { _existingAlarmOverrides.Remove(alarmName); - _alarmOverrideJson.Remove(alarmName); - _alarmOverridePriority.Remove(alarmName); _toast.ShowSuccess($"Cleared override on '{alarmName}'."); } else @@ -494,54 +672,22 @@ _saving = false; } - private async Task SaveAlarmOverrides() + /// + /// Mirrors TemplateEdit.MapDataType — converts the persisted DataType enum + /// to the canonical SCADA type string the AlarmTriggerEditor compares + /// against (Boolean / Integer / Float / String / Object). + /// + private static string MapDataType(DataType dt) => dt switch { - _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; - } + DataType.Boolean => "Boolean", + DataType.Int32 => "Integer", + DataType.Float => "Float", + DataType.Double => "Float", + DataType.String => "String", + DataType.DateTime => "String", + DataType.Binary => "Object", + _ => "Object" + }; // ── Area ──────────────────────────────────────────────── diff --git a/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs b/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs index 0f513d4..3e96ed5 100644 --- a/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs +++ b/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs @@ -407,7 +407,7 @@ public class FlatteningService /// Returns the derived config verbatim on parse failure of either input — /// the existing whole-replace behavior is the safe fallback. /// - internal static string? MergeHiLoConfig(string? inheritedJson, string? derivedJson) + public static string? MergeHiLoConfig(string? inheritedJson, string? derivedJson) { if (string.IsNullOrWhiteSpace(inheritedJson)) return derivedJson; if (string.IsNullOrWhiteSpace(derivedJson)) return inheritedJson; @@ -455,6 +455,83 @@ public class FlatteningService } } + /// + /// Computes the minimal HiLo override JSON given the inherited config and + /// an edited config — returns only the top-level keys whose values differ + /// from the inherited config. Returns null when no keys differ (the + /// caller should treat that as "no override"). + /// + /// Value comparison is type-aware so that JSON-escape differences (e.g., + /// a literal em-dash in the inherited config vs. in the + /// editor's serialized output) don't produce false-positive diffs. On + /// parse failure of either input, returns + /// verbatim — safe fallback that matches the existing whole-replace + /// semantics. + /// + public static string? DiffHiLoConfig(string? inheritedJson, string? editedJson) + { + if (string.IsNullOrWhiteSpace(editedJson)) return null; + if (string.IsNullOrWhiteSpace(inheritedJson)) return editedJson; + + try + { + using var inheritedDoc = JsonDocument.Parse(inheritedJson); + using var editedDoc = JsonDocument.Parse(editedJson); + + if (inheritedDoc.RootElement.ValueKind != JsonValueKind.Object + || editedDoc.RootElement.ValueKind != JsonValueKind.Object) + { + return editedJson; + } + + var changed = new List(); + foreach (var prop in editedDoc.RootElement.EnumerateObject()) + { + if (!inheritedDoc.RootElement.TryGetProperty(prop.Name, out var inhProp)) + { + changed.Add(prop); + continue; + } + if (!ValuesEquivalent(prop.Value, inhProp)) + changed.Add(prop); + } + + if (changed.Count == 0) return null; + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + foreach (var p in changed) p.WriteTo(writer); + writer.WriteEndObject(); + } + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + catch (JsonException) + { + return editedJson; + } + } + + /// + /// Compares two JSON values by their decoded meaning rather than their + /// raw text. Strings are unescaped before comparison so equivalent values + /// in different escape forms (e.g., a literal "—" vs. "—") match. + /// Numbers compare by their double value so trailing-zero differences + /// don't produce false diffs. + /// + private static bool ValuesEquivalent(JsonElement a, JsonElement b) + { + if (a.ValueKind != b.ValueKind) return false; + return a.ValueKind switch + { + JsonValueKind.String => a.GetString() == b.GetString(), + JsonValueKind.Number => a.GetDouble() == b.GetDouble(), + JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null => true, + _ => a.GetRawText() == b.GetRawText() + }; + } + private static void ResolveComposedAlarms( IReadOnlyList