feat(ui): rich AlarmTriggerEditor in instance override modal

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.
This commit is contained in:
Joseph Doherty
2026-05-13 04:05:08 -04:00
parent 4e446a7170
commit 164d914ba8
3 changed files with 421 additions and 104 deletions

View File

@@ -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.
/// </summary>
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
}
}
/// <summary>
/// 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 <c>null</c> 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. <c>—</c> in the
/// editor's serialized output) don't produce false-positive diffs. On
/// parse failure of either input, returns <paramref name="editedJson"/>
/// verbatim — safe fallback that matches the existing whole-replace
/// semantics.
/// </summary>
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<JsonProperty>();
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;
}
}
/// <summary>
/// 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.
/// </summary>
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<Template> templateChain,
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,