Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerEditor.razor
T
Joseph Doherty 9e243493fb ui: Central UI density/consistency sweep + Theme 0.4.1
Applies the family-wide admin-UI cleanup playbook to the Central UI so the
Blazor surfaces stop diverging from the shared kit: buttons are grouped rather
than individually sized, long cell values are contained instead of widening
tables, and hard-coded colours give way to theme tokens.

The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell,
which the kit emits as an inline style on the shell root. Being a descendant of
<html>, it beat the [data-bs-theme="dark"] override for the entire app, so the
dark accent had never rendered. Declaring --accent in site.css :root instead
lets both schemes resolve; light is unchanged because the value already matched
the kit's light default.

Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so
that block is deleted here rather than duplicated. Verified byte-identical
before removal; the repo now declares no --bs-btn-* anywhere.

NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal
surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages,
SiteCallsReport) were additionally refactored from holding the selected record
to holding its id and re-resolving from the current page each render, with the
resolve doubling as the visibility gate. A background refresh that drops the
row now closes the modal instead of showing a stale snapshot. This is a
behaviour change and is called out rather than buried: a full-suite run turned
up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack
(GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler
was disposed between render and click — a window the previous field-held record
made structurally impossible. Treat the modal lifecycle here as unreviewed.

Build 0/0; suite green apart from that one intermittent failure.
2026-08-11 05:50:12 -04:00

680 lines
30 KiB
Plaintext

@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-body 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-body-secondary text-body-secondary 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"
data-test="alarm-matchvalue-input"
@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"
data-test="alarm-matchvalue-input"
@bind="_matchValueText"
@bind:event="oninput"
@bind:after="OnMatchValueChanged" />
}
else
{
<input type="text" class="form-control form-control-sm"
data-test="alarm-matchvalue-input"
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;">
@* Token-driven fills, not literal hex: this preview renders on a
#1b1d21 surface under [data-bs-theme="dark"], where Bootstrap's
light subtle tints read as bright pastel bars. *@
<rect x="0" y="0" width="20" height="12" fill="var(--bad-bg)" />
<rect x="20" y="0" width="160" height="12" fill="var(--ok-bg)" />
<rect x="180" y="0" width="20" height="12" fill="var(--bad-bg)" />
</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", "alarm-hilo-hihi")
@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", "alarm-hilo-hi")
@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", "alarm-hilo-lo")
@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", "alarm-hilo-lolo")
};
/// <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, string setpointTestId) => __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"
data-test="@setpointTestId"
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;
// Sync the analysis-kind selector from the loaded model.
_analysisKindValue = _model.IsStrictAnalysisKind ? "Strict" : "Advisory";
_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>
@* Analysis-kind selector — Advisory (default) keeps the blank-expression
finding as a non-blocking warning; Strict escalates it to a deploy-blocking error. *@
<div class="mt-2">
<label for="alarm-trigger-kind" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Analysis kind
</label>
<select id="alarm-trigger-kind"
class="form-select form-select-sm"
style="max-width: 280px;"
@bind="_analysisKindValue"
@bind:after="OnAnalysisKindChanged">
<option value="Advisory">Advisory — blank expression is a warning</option>
<option value="Strict">Strict — blank expression blocks deploy</option>
</select>
</div>
};
private async Task OnExpressionChanged(string value)
{
_model.Expression = value;
await Emit();
}
// Backing field + handler for the Advisory|Strict selector.
private string _analysisKindValue = "Advisory";
private async Task OnAnalysisKindChanged()
{
_model.IsStrictAnalysisKind = string.Equals(_analysisKindValue, "Strict", StringComparison.OrdinalIgnoreCase);
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;
}