@namespace ScadaLink.CentralUI.Components.Shared @using System.Globalization @using System.IO @using System.Text @using System.Text.Json @using ScadaLink.Commons.Types.Enums @* Rich alarm trigger configuration editor. Replaces the raw JSON text field used for TemplateAlarm.TriggerConfiguration. The editor emits the same JSON shape that AlarmActor.ParseEvalConfig consumes: ValueMatch { attributeName, matchValue } ("!=X" prefix = not equals) RangeViolation { attributeName, min, max } RateOfChange { attributeName, thresholdPerSecond, windowSeconds, direction } *@
@* ── Monitored attribute ───────────────────────────────────────────── *@
@if (_selectedDataType is { } dt) { @dt }
@if (_selectedChoice != null && !IsAttributeCompatible(_selectedChoice)) {
Selected attribute is @_selectedChoice.DataType — this trigger type requires a numeric attribute.
} else if (_selectedChoice == null && !string.IsNullOrWhiteSpace(_model.AttributeName)) {
"@_model.AttributeName" is not in the current template. Save will still write it as-is.
}
@* ── Type-specific block ───────────────────────────────────────────── *@ @switch (TriggerType) { case AlarmTriggerType.ValueMatch: @RenderValueMatch(); break; case AlarmTriggerType.RangeViolation: @RenderRangeViolation(); break; case AlarmTriggerType.RateOfChange: @RenderRateOfChange(); break; } @* ── Hint ──────────────────────────────────────────────────────────── *@
@BuildHint()
@code { // ── Parameters ───────────────────────────────────────────────────────── [Parameter] public AlarmTriggerType TriggerType { get; set; } [Parameter] public string? Value { get; set; } [Parameter] public EventCallback ValueChanged { get; set; } /// /// Flattened attribute list (direct + inherited + composed). Used to drive /// the picker and to determine the selected attribute's data type for /// type-aware inputs. /// [Parameter] public IReadOnlyList AvailableAttributes { get; set; } = Array.Empty(); // ── Internal state ───────────────────────────────────────────────────── private TriggerModel _model = new(); private AlarmTriggerType _lastSeenType; private string? _lastSeenJson; /// The choice currently selected from , if any. private AlarmAttributeChoice? _selectedChoice; private string? _selectedDataType => _selectedChoice?.DataType; // ── Parse / serialize lifecycle ──────────────────────────────────────── protected override void OnParametersSet() { var typeChanged = _lastSeenType != TriggerType; var jsonChanged = Value != _lastSeenJson; if (!typeChanged && !jsonChanged) return; _lastSeenType = TriggerType; _lastSeenJson = Value; // Preserve attribute name across type changes — re-parse the JSON in // the context of the new type. Missing/unparseable keys fall back to // empty defaults. var preservedAttr = _model.AttributeName; _model = Parse(Value, TriggerType); if (jsonChanged == false && typeChanged && !string.IsNullOrEmpty(preservedAttr)) _model.AttributeName = preservedAttr; RefreshSelectedChoice(); SyncTextMirrors(); } private void RefreshSelectedChoice() { _selectedChoice = AvailableAttributes.FirstOrDefault( c => string.Equals(c.CanonicalName, _model.AttributeName, StringComparison.Ordinal)); } private async Task Emit() { var json = Serialize(_model, TriggerType); _lastSeenJson = json; await ValueChanged.InvokeAsync(json); } // ── Attribute picker ─────────────────────────────────────────────────── /// /// String mirror for the attribute picker — required because @bind needs a /// settable backing field, not a computed expression. /// private string _attributeName = string.Empty; private async Task OnAttributeChanged() { _model.AttributeName = _attributeName; RefreshSelectedChoice(); await Emit(); } private static int SourceOrder(string source) => source switch { "Direct" => 0, "Inherited" => 1, "Composed" => 2, _ => 3 }; private bool IsAttributeCompatible(AlarmAttributeChoice choice) => TriggerType == AlarmTriggerType.ValueMatch || IsNumericType(choice.DataType); private static bool IsNumericType(string dataType) => dataType switch { "Integer" or "Int32" or "Int64" or "Float" or "Double" or "Number" => true, _ => false }; // ── ValueMatch ───────────────────────────────────────────────────────── private RenderFragment RenderValueMatch() => __builder => {
@{ var t = _selectedChoice?.DataType; if (t == "Boolean") { } else if (IsNumericType(t ?? "")) { } else { } }
}; // ── RangeViolation ───────────────────────────────────────────────────── private RenderFragment RenderRangeViolation() => __builder => {
to
}; private async Task OnMinChanged() { _model.Min = ParseDouble(_minText); await Emit(); } private async Task OnMaxChanged() { _model.Max = ParseDouble(_maxText); await Emit(); } // ── RateOfChange ─────────────────────────────────────────────────────── private RenderFragment RenderRateOfChange() => __builder => {
units / sec
sec
}; private async Task OnThresholdChanged() { _model.ThresholdPerSecond = ParseDouble(_thresholdText); await Emit(); } private async Task OnWindowChanged() { _model.WindowSeconds = ParseDouble(_windowText); await Emit(); } private async Task OnDirectionChanged() { _model.Direction = _directionText; await Emit(); } private string _directionText = "either"; // ── Text mirrors for typed inputs ────────────────────────────────────── // @bind requires a settable backing field that round-trips text. We keep // these in sync with the model and re-parse on @bind:after. private string? _minText; private string? _maxText; private string? _thresholdText; private string? _windowText; protected override void OnInitialized() { SyncTextMirrors(); } private void SyncTextMirrors() { _attributeName = _model.AttributeName ?? string.Empty; _matchValueText = _model.MatchValue ?? string.Empty; _operatorText = _model.NotEquals ? "ne" : "eq"; _minText = FormatNullable(_model.Min); _maxText = FormatNullable(_model.Max); _thresholdText = FormatNullable(_model.ThresholdPerSecond); _windowText = FormatNullable(_model.WindowSeconds); _directionText = _model.Direction; } private string _operatorText = "eq"; private string _matchValueText = string.Empty; private async Task OnOperatorChanged() { _model.NotEquals = (_operatorText == "ne"); await Emit(); } private async Task OnMatchValueChanged() { _model.MatchValue = _matchValueText; await Emit(); } // ── Hint text ────────────────────────────────────────────────────────── private string BuildHint() { var attr = string.IsNullOrWhiteSpace(_model.AttributeName) ? "the selected attribute" : $"\"{_model.AttributeName}\""; return TriggerType switch { AlarmTriggerType.ValueMatch => $"Triggers when {attr} {(_model.NotEquals ? "is not equal to" : "equals")} \"{_model.MatchValue ?? ""}\".", AlarmTriggerType.RangeViolation => _model.Min.HasValue && _model.Max.HasValue ? $"Triggers when {attr} < {Fmt(_model.Min)} or > {Fmt(_model.Max)}." : $"Triggers when {attr} goes outside the configured range.", AlarmTriggerType.RateOfChange => $"Triggers when {attr} changes faster than {Fmt(_model.ThresholdPerSecond) ?? "?"} units/sec ({_model.Direction}) over a {Fmt(_model.WindowSeconds) ?? "?"} sec window.", _ => string.Empty }; } private static string Fmt(double? v) => v.HasValue ? v.Value.ToString("0.###", CultureInfo.InvariantCulture) : ""; private static string FormatNullable(double? v) => v.HasValue ? v.Value.ToString("R", CultureInfo.InvariantCulture) : ""; private static double? ParseDouble(string? s) => double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null; // ── Model + parse/serialize ──────────────────────────────────────────── private sealed class TriggerModel { public string? AttributeName { get; set; } // ValueMatch public string? MatchValue { get; set; } public bool NotEquals { get; set; } // RangeViolation public double? Min { get; set; } public double? Max { get; set; } // RateOfChange public double? ThresholdPerSecond { get; set; } public double? WindowSeconds { get; set; } public string Direction { get; set; } = "either"; } /// /// Parses an existing trigger configuration JSON in the context of the /// given trigger type. Returns sensible defaults on parse failure or for /// missing keys. /// private static TriggerModel Parse(string? json, AlarmTriggerType type) { var model = new TriggerModel(); if (string.IsNullOrWhiteSpace(json)) return model; try { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; model.AttributeName = root.TryGetProperty("attributeName", out var a) ? a.GetString() : root.TryGetProperty("attribute", out var a2) ? a2.GetString() : null; switch (type) { case AlarmTriggerType.ValueMatch: { var raw = root.TryGetProperty("matchValue", out var mv) ? mv.GetString() : root.TryGetProperty("value", out var mv2) ? mv2.GetString() : null; if (raw != null && raw.StartsWith("!=", StringComparison.Ordinal)) { model.NotEquals = true; model.MatchValue = raw[2..]; } else { model.MatchValue = raw; } break; } case AlarmTriggerType.RangeViolation: model.Min = TryReadDouble(root, "min") ?? TryReadDouble(root, "low"); model.Max = TryReadDouble(root, "max") ?? TryReadDouble(root, "high"); break; case AlarmTriggerType.RateOfChange: model.ThresholdPerSecond = TryReadDouble(root, "thresholdPerSecond"); model.WindowSeconds = TryReadDouble(root, "windowSeconds"); var dir = root.TryGetProperty("direction", out var d) ? d.GetString() : null; model.Direction = NormalizeDirection(dir); break; } } catch (JsonException) { // Malformed JSON — fall through with default model. } return model; } private static string NormalizeDirection(string? raw) => raw?.ToLowerInvariant() switch { "rising" or "up" or "positive" => "rising", "falling" or "down" or "negative" => "falling", _ => "either" }; private static double? TryReadDouble(JsonElement el, string name) { if (!el.TryGetProperty(name, out var p)) return null; return p.ValueKind switch { JsonValueKind.Number => p.GetDouble(), JsonValueKind.String when double.TryParse(p.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) => v, _ => null }; } /// /// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig /// expects. Always writes attributeName (canonical key) and only /// the keys relevant to the current trigger type. /// private static string Serialize(TriggerModel model, AlarmTriggerType type) { using var stream = new MemoryStream(); using (var w = new Utf8JsonWriter(stream)) { w.WriteStartObject(); w.WriteString("attributeName", model.AttributeName ?? ""); switch (type) { case AlarmTriggerType.ValueMatch: var mv = model.MatchValue ?? ""; if (model.NotEquals) mv = "!=" + mv; w.WriteString("matchValue", mv); break; case AlarmTriggerType.RangeViolation: if (model.Min.HasValue) w.WriteNumber("min", model.Min.Value); if (model.Max.HasValue) w.WriteNumber("max", model.Max.Value); break; case AlarmTriggerType.RateOfChange: if (model.ThresholdPerSecond.HasValue) w.WriteNumber("thresholdPerSecond", model.ThresholdPerSecond.Value); if (model.WindowSeconds.HasValue) w.WriteNumber("windowSeconds", model.WindowSeconds.Value); w.WriteString("direction", model.Direction); break; } w.WriteEndObject(); } return System.Text.Encoding.UTF8.GetString(stream.ToArray()); } }