@namespace ScadaLink.CentralUI.Components.Shared @using System.Globalization @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 ───────────────────────────────────────────── *@ @* Expression triggers reference attributes inside the C# expression itself, so they do not use the single-attribute picker. *@ @if (TriggerType != AlarmTriggerType.Expression) {
@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; case AlarmTriggerType.HiLo: @RenderHiLo(); break; case AlarmTriggerType.Expression: @RenderExpression(); 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 AlarmTriggerModel _model = new AlarmTriggerModel(); 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 = AlarmTriggerConfigCodec.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 = AlarmTriggerConfigCodec.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"; // ── HiLo ─────────────────────────────────────────────────────────────── private RenderFragment RenderHiLo() => __builder => {
Set any subset of the four setpoints. The most-severe matching band wins (LoLo/HiHi outrank Lo/Hi). Per-setpoint priority overrides the alarm-level priority for that band. Deadband (optional) relaxes the deactivation threshold by the configured amount to prevent flapping.
@HiLoSetpointRow("HIGH-HIGH (critical)", _hiHiText, v => _hiHiText = v, OnHiHiChanged, _hiHiDeadbandText, v => _hiHiDeadbandText = v, OnHiHiDeadbandChanged, _hiHiPriorityText, v => _hiHiPriorityText = v, OnHiHiPriorityChanged, _hiHiMessageText, v => _hiHiMessageText = v, OnHiHiMessageChanged, "text-danger") @HiLoSetpointRow("HIGH (warning)", _hiText, v => _hiText = v, OnHiChanged, _hiDeadbandText, v => _hiDeadbandText = v, OnHiDeadbandChanged, _hiPriorityText, v => _hiPriorityText = v, OnHiPriorityChanged, _hiMessageText, v => _hiMessageText = v, OnHiMessageChanged, "text-warning-emphasis") @HiLoSetpointRow("LOW (warning)", _loText, v => _loText = v, OnLoChanged, _loDeadbandText, v => _loDeadbandText = v, OnLoDeadbandChanged, _loPriorityText, v => _loPriorityText = v, OnLoPriorityChanged, _loMessageText, v => _loMessageText = v, OnLoMessageChanged, "text-warning-emphasis") @HiLoSetpointRow("LOW-LOW (critical)", _loLoText, v => _loLoText = v, OnLoLoChanged, _loLoDeadbandText, v => _loLoDeadbandText = v, OnLoLoDeadbandChanged, _loLoPriorityText, v => _loLoPriorityText = v, OnLoLoPriorityChanged, _loLoMessageText, v => _loLoMessageText = v, OnLoLoMessageChanged, "text-danger") }; /// /// Renders one setpoint row: value (number) + priority (int). Both are /// optional — leaving a value blank disables that band. The /// tints the label to convey relative /// severity at a glance. /// private RenderFragment HiLoSetpointRow( string label, string? value, Action valueSetter, Func onValueChanged, string? deadband, Action deadbandSetter, Func onDeadbandChanged, string? priority, Action prioritySetter, Func onPriorityChanged, string? message, Action messageSetter, Func onMessageChanged, string severityClass) => __builder => {
setpoint
±
}; // Setpoint text mirrors — separate from the model so blank fields stay // blank (rather than appearing as 0) and we can detect "unset" cleanly. private string? _loLoText; private string? _loText; private string? _hiText; private string? _hiHiText; private string? _loLoPriorityText; private string? _loPriorityText; private string? _hiPriorityText; private string? _hiHiPriorityText; private string? _loLoDeadbandText; private string? _loDeadbandText; private string? _hiDeadbandText; private string? _hiHiDeadbandText; private string? _loLoMessageText; private string? _loMessageText; private string? _hiMessageText; private string? _hiHiMessageText; // Mirrored on the parent so the placeholder shows the right fallback. [Parameter] public int FallbackPriority { get; set; } = 500; private int _priority => FallbackPriority; private async Task OnLoLoChanged() { _model.LoLo = ParseDouble(_loLoText); await Emit(); } private async Task OnLoChanged() { _model.Lo = ParseDouble(_loText); await Emit(); } private async Task OnHiChanged() { _model.Hi = ParseDouble(_hiText); await Emit(); } private async Task OnHiHiChanged() { _model.HiHi = ParseDouble(_hiHiText); await Emit(); } private async Task OnLoLoPriorityChanged() { _model.LoLoPriority = ParseInt(_loLoPriorityText); await Emit(); } private async Task OnLoPriorityChanged() { _model.LoPriority = ParseInt(_loPriorityText); await Emit(); } private async Task OnHiPriorityChanged() { _model.HiPriority = ParseInt(_hiPriorityText); await Emit(); } private async Task OnHiHiPriorityChanged() { _model.HiHiPriority = ParseInt(_hiHiPriorityText); await Emit(); } private async Task OnLoLoDeadbandChanged() { _model.LoLoDeadband = ParseDouble(_loLoDeadbandText); await Emit(); } private async Task OnLoDeadbandChanged() { _model.LoDeadband = ParseDouble(_loDeadbandText); await Emit(); } private async Task OnHiDeadbandChanged() { _model.HiDeadband = ParseDouble(_hiDeadbandText); await Emit(); } private async Task OnHiHiDeadbandChanged() { _model.HiHiDeadband = ParseDouble(_hiHiDeadbandText); await Emit(); } private async Task OnLoLoMessageChanged() { _model.LoLoMessage = _loLoMessageText; await Emit(); } private async Task OnLoMessageChanged() { _model.LoMessage = _loMessageText; await Emit(); } private async Task OnHiMessageChanged() { _model.HiMessage = _hiMessageText; await Emit(); } private async Task OnHiHiMessageChanged() { _model.HiHiMessage = _hiHiMessageText; await Emit(); } private static int? ParseInt(string? s) => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null; // ── 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; _loLoText = FormatNullable(_model.LoLo); _loText = FormatNullable(_model.Lo); _hiText = FormatNullable(_model.Hi); _hiHiText = FormatNullable(_model.HiHi); _loLoPriorityText = _model.LoLoPriority?.ToString(CultureInfo.InvariantCulture); _loPriorityText = _model.LoPriority?.ToString(CultureInfo.InvariantCulture); _hiPriorityText = _model.HiPriority?.ToString(CultureInfo.InvariantCulture); _hiHiPriorityText = _model.HiHiPriority?.ToString(CultureInfo.InvariantCulture); _loLoDeadbandText = FormatNullable(_model.LoLoDeadband); _loDeadbandText = FormatNullable(_model.LoDeadband); _hiDeadbandText = FormatNullable(_model.HiDeadband); _hiHiDeadbandText = FormatNullable(_model.HiHiDeadband); _loLoMessageText = _model.LoLoMessage ?? string.Empty; _loMessageText = _model.LoMessage ?? string.Empty; _hiMessageText = _model.HiMessage ?? string.Empty; _hiHiMessageText = _model.HiHiMessage ?? string.Empty; } 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(); } // ── Expression ───────────────────────────────────────────────────────── private RenderFragment RenderExpression() => __builder => {
A boolean C# expression — e.g. Attributes["Temperature"] > 80.
}; private async Task OnExpressionChanged(string value) { _model.Expression = value; 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.", AlarmTriggerType.HiLo => BuildHiLoHint(attr), AlarmTriggerType.Expression => "Alarm is active while this expression is true.", _ => string.Empty }; } private string BuildHiLoHint(string attr) { var parts = new List(); if (_model.LoLo.HasValue) parts.Add($"LoLo at {Fmt(_model.LoLo)}"); if (_model.Lo.HasValue) parts.Add($"Lo at {Fmt(_model.Lo)}"); if (_model.Hi.HasValue) parts.Add($"Hi at {Fmt(_model.Hi)}"); if (_model.HiHi.HasValue) parts.Add($"HiHi at {Fmt(_model.HiHi)}"); if (parts.Count == 0) return $"Triggers when {attr} crosses any configured setpoint (none set yet)."; return $"Triggers on {attr}: {string.Join(", ", parts)}."; } 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; }