Replace raw-JSON text inputs with rich UI: script parameter/return types use a JSON Schema builder (SchemaBuilder + JsonSchemaShapeParser, with a migration to convert existing definitions); alarm trigger config uses a type-aware editor with a flattened attribute picker (AlarmTriggerEditor). AlarmActor gains optional direction (rising/falling/either) on RateOfChange triggers.
573 lines
23 KiB
Plaintext
573 lines
23 KiB
Plaintext
@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 } *@
|
|
|
|
<div class="border rounded bg-white p-3">
|
|
|
|
@* ── Monitored attribute ───────────────────────────────────────────── *@
|
|
<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;
|
|
}
|
|
|
|
@* ── 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 TriggerModel _model = new();
|
|
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 = 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 ───────────────────────────────────────────────────
|
|
|
|
/// <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";
|
|
|
|
// ── 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";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses an existing trigger configuration JSON in the context of the
|
|
/// given trigger type. Returns sensible defaults on parse failure or for
|
|
/// missing keys.
|
|
/// </summary>
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig
|
|
/// expects. Always writes <c>attributeName</c> (canonical key) and only
|
|
/// the keys relevant to the current trigger type.
|
|
/// </summary>
|
|
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());
|
|
}
|
|
}
|