647 lines
28 KiB
Plaintext
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"] > 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;
|
|
|
|
}
|