Files
ScadaBridge/src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerEditor.razor
T

647 lines
28 KiB
Plaintext

@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 } *@
<div class="border rounded bg-white p-3">
@* ── Monitored attribute ───────────────────────────────────────────── *@
@* Expression triggers reference attributes inside the C# expression itself,
so they do not use the single-attribute picker. *@
@if (TriggerType != AlarmTriggerType.Expression)
{
<div class="mb-3">
<label for="alarm-attr-select" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Monitored attribute
</label>
<div class="input-group input-group-sm">
<select id="alarm-attr-select"
class="form-select"
@bind="_attributeName"
@bind:after="OnAttributeChanged">
<option value="">— select attribute —</option>
@{
var groups = AvailableAttributes
.GroupBy(c => c.Source)
.OrderBy(g => SourceOrder(g.Key))
.ToList();
}
@foreach (var grp in groups)
{
<optgroup label="@grp.Key">
@foreach (var choice in grp.OrderBy(c => c.CanonicalName, StringComparer.Ordinal))
{
var label = $"{choice.CanonicalName} ({choice.DataType})";
var disabled = !IsAttributeCompatible(choice);
<option value="@choice.CanonicalName" disabled="@disabled">@label</option>
}
</optgroup>
}
@* If the saved attribute name isn't in the current list, keep it selectable so it's visible. *@
@if (!string.IsNullOrEmpty(_model.AttributeName) && _selectedChoice == null)
{
<optgroup label="Unknown">
<option value="@_model.AttributeName">@_model.AttributeName (not found)</option>
</optgroup>
}
</select>
@if (_selectedDataType is { } dt)
{
<span class="input-group-text bg-light text-muted small">@dt</span>
}
</div>
@if (_selectedChoice != null && !IsAttributeCompatible(_selectedChoice))
{
<div class="form-text text-danger">
Selected attribute is @_selectedChoice.DataType — this trigger type requires a numeric attribute.
</div>
}
else if (_selectedChoice == null && !string.IsNullOrWhiteSpace(_model.AttributeName))
{
<div class="form-text text-warning-emphasis">
"@_model.AttributeName" is not in the current template. Save will still write it as-is.
</div>
}
</div>
}
@* ── 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 ──────────────────────────────────────────────────────────── *@
<div class="mt-3 pt-2 border-top small text-muted">
@BuildHint()
</div>
</div>
@code {
// ── Parameters ─────────────────────────────────────────────────────────
[Parameter] public AlarmTriggerType TriggerType { get; set; }
[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
/// <summary>
/// Flattened attribute list (direct + inherited + composed). Used to drive
/// the picker and to determine the selected attribute's data type for
/// type-aware inputs.
/// </summary>
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; } =
Array.Empty<AlarmAttributeChoice>();
// ── Internal state ─────────────────────────────────────────────────────
private AlarmTriggerModel _model = new AlarmTriggerModel();
private AlarmTriggerType _lastSeenType;
private string? _lastSeenJson;
/// <summary>The choice currently selected from <see cref="AvailableAttributes"/>, if any.</summary>
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 ───────────────────────────────────────────────────
/// <summary>
/// String mirror for the attribute picker — required because @bind needs a
/// settable backing field, not a computed expression.
/// </summary>
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 =>
{
<div class="row g-2">
<div class="col-md-4">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Operator
</label>
<select class="form-select form-select-sm"
@bind="_operatorText"
@bind:after="OnOperatorChanged">
<option value="eq">equals</option>
<option value="ne">not equals</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Match value
</label>
@{
var t = _selectedChoice?.DataType;
if (t == "Boolean")
{
<select class="form-select form-select-sm"
@bind="_matchValueText"
@bind:after="OnMatchValueChanged">
<option value="True">True</option>
<option value="False">False</option>
</select>
}
else if (IsNumericType(t ?? ""))
{
<input type="number" step="any" class="form-control form-control-sm"
@bind="_matchValueText"
@bind:event="oninput"
@bind:after="OnMatchValueChanged" />
}
else
{
<input type="text" class="form-control form-control-sm"
placeholder="value"
@bind="_matchValueText"
@bind:event="oninput"
@bind:after="OnMatchValueChanged" />
}
}
</div>
</div>
};
// ── RangeViolation ─────────────────────────────────────────────────────
private RenderFragment RenderRangeViolation() => __builder =>
{
<div class="row g-2 align-items-end">
<div class="col-md-5">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Minimum
</label>
<input type="number" step="any" class="form-control form-control-sm"
@bind="_minText"
@bind:event="oninput"
@bind:after="OnMinChanged" />
</div>
<div class="col-md-2 text-center pb-1 text-muted small">to</div>
<div class="col-md-5">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Maximum
</label>
<input type="number" step="any" class="form-control form-control-sm"
@bind="_maxText"
@bind:event="oninput"
@bind:after="OnMaxChanged" />
</div>
</div>
<div class="mt-3" aria-hidden="true">
<svg viewBox="0 0 200 12" preserveAspectRatio="none"
style="width:100%; height:10px; border-radius:5px; overflow:hidden;">
<rect x="0" y="0" width="20" height="12" fill="#f8d7da" />
<rect x="20" y="0" width="160" height="12" fill="#d1e7dd" />
<rect x="180" y="0" width="20" height="12" fill="#f8d7da" />
</svg>
<div class="d-flex justify-content-between small text-muted mt-1">
<span>alarm</span>
<span>normal</span>
<span>alarm</span>
</div>
</div>
};
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 =>
{
<div class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Rate threshold
</label>
<div class="input-group input-group-sm">
<input type="number" step="any" class="form-control"
@bind="_thresholdText"
@bind:event="oninput"
@bind:after="OnThresholdChanged" />
<span class="input-group-text">units / sec</span>
</div>
</div>
<div class="col-md-6">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Sampling window
</label>
<div class="input-group input-group-sm">
<input type="number" step="any" min="0" class="form-control"
@bind="_windowText"
@bind:event="oninput"
@bind:after="OnWindowChanged" />
<span class="input-group-text">sec</span>
</div>
</div>
</div>
<div class="mt-3 row g-2">
<div class="col-md-6">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Direction
</label>
<select class="form-select form-select-sm"
@bind="_directionText"
@bind:after="OnDirectionChanged">
<option value="rising">Rising only</option>
<option value="falling">Falling only</option>
<option value="either">Either direction</option>
</select>
</div>
</div>
};
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 =>
{
<div class="small text-muted mb-2 fst-italic">
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.
</div>
@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")
};
/// <summary>
/// Renders one setpoint row: value (number) + priority (int). Both are
/// optional — leaving a value blank disables that band. The
/// <paramref name="severityClass"/> tints the label to convey relative
/// severity at a glance.
/// </summary>
private RenderFragment HiLoSetpointRow(
string label,
string? value, Action<string?> valueSetter, Func<Task> onValueChanged,
string? deadband, Action<string?> deadbandSetter, Func<Task> onDeadbandChanged,
string? priority, Action<string?> prioritySetter, Func<Task> onPriorityChanged,
string? message, Action<string?> messageSetter, Func<Task> onMessageChanged,
string severityClass) => __builder =>
{
<div class="row g-2 align-items-end mb-1">
<div class="col-md-5">
<label class="form-label small text-uppercase fw-semibold mb-1 @severityClass">
@label
</label>
<div class="input-group input-group-sm">
<span class="input-group-text">setpoint</span>
<input type="number" step="any" class="form-control"
placeholder="—"
value="@value"
@oninput="@(e => { valueSetter(e.Value?.ToString()); _ = onValueChanged(); })" />
</div>
</div>
<div class="col-md-3">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Deadband
</label>
<div class="input-group input-group-sm">
<span class="input-group-text">±</span>
<input type="number" step="any" min="0" class="form-control"
placeholder="0"
value="@deadband"
@oninput="@(e => { deadbandSetter(e.Value?.ToString()); _ = onDeadbandChanged(); })" />
</div>
</div>
<div class="col-md-4">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Priority
</label>
<div class="input-group input-group-sm">
<input type="number" min="0" max="1000" class="form-control"
placeholder="@_priority"
value="@priority"
@oninput="@(e => { prioritySetter(e.Value?.ToString()); _ = onPriorityChanged(); })" />
</div>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-12">
<input type="text" class="form-control form-control-sm"
placeholder="Optional operator message for this band…"
value="@message"
@oninput="@(e => { messageSetter(e.Value?.ToString()); _ = onMessageChanged(); })" />
</div>
</div>
};
// 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 =>
{
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">Trigger expression</label>
<MonacoEditor Height="120px"
Language="csharp"
ScriptKind="ScriptAnalysis.ScriptKind.Template"
ShowToolbar="false"
Value="@(_model.Expression ?? string.Empty)"
ValueChanged="OnExpressionChanged"
SelfAttributes="@TriggerAttributeMapper.SelfAttributes(AvailableAttributes)"
Children="@TriggerAttributeMapper.Children(AvailableAttributes)" />
<div class="form-text">
A boolean C# expression — e.g. <code>Attributes["Temperature"] &gt; 80</code>.
</div>
};
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<string>();
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;
}