refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,646 @@
|
||||
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
@using System.Globalization
|
||||
@using ZB.MOM.WW.ScadaBridge.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;
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user