410349767d
T34c only fixed the bounded modal-surface offenders. Bootstrap 5.3's bg-light
and bg-white are fixed light values that do NOT flip under [data-bs-theme=dark],
so every remaining use was a dark-mode contrast break. 35 swaps across 19 files:
surface / <pre> / <code> bg-light -> bg-body-secondary
panel bg-white -> bg-body
neutral badge bg-light text-dark -> bg-secondary-subtle text-secondary-emphasis
muted badge / input group bg-light text-muted -> bg-body-secondary text-body-secondary
DELIBERATELY LEFT (7 sites): the neutral member of a status-badge switch or
ternary whose siblings are all solid, non-theme-aware colours (bg-success,
bg-danger, bg-warning) — swapping only the neutral one to a subtle token breaks
the visual weight of the set, so these stay until the whole family is retoned:
Topology.razor:513 (Current badge) and :588 (InstanceState.NotDeployed)
InstanceConfigure.razor:1520 (same InstanceState switch)
NotificationReport.razor StatusBadgeClass fallback
TransportImport.razor ConflictKind badge fallback
SecuredWrites.razor text-bg-light fallback (a text-bg-* family with no
theme-aware member at all)
Health.razor:377 depth ternary
Also untouched by design: SchemaBuilder.razor:88 (already bg-light-subtle,
theme-aware), bg-dark text-light console panels, and site.css / #reconnect-modal.
InstanceConfigure.razor is edited concurrently elsewhere; its change here is 5
pure class-string swaps on existing lines, no reflow. No test asserted any of
these classes. Also marks all four M10 residuals done in the plan doc.
440 lines
19 KiB
Plaintext
440 lines
19 KiB
Plaintext
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
|
@using System.Globalization
|
|
|
|
@* Structured editor for a template script's trigger. Owns both the trigger-type
|
|
selector and the type-specific configuration, emitting (via Changed) the
|
|
canonical TriggerType string + the TriggerConfiguration JSON that
|
|
ScriptActor.ParseTriggerConfig consumes:
|
|
Interval { intervalMs }
|
|
ValueChange { attributeName }
|
|
Conditional { attributeName, operator, threshold, mode }
|
|
Expression { expression, mode }
|
|
Call { } *@
|
|
|
|
<div class="border rounded bg-body p-3">
|
|
|
|
@* ── Trigger type ──────────────────────────────────────────────────── *@
|
|
<div class="mb-3">
|
|
<label for="script-trigger-type" class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
|
Trigger type
|
|
</label>
|
|
<select id="script-trigger-type" class="form-select form-select-sm"
|
|
@bind="_kindValue" @bind:after="OnKindChanged">
|
|
<option value="None">— none (never runs automatically) —</option>
|
|
<option value="Interval">Interval — run on a fixed timer</option>
|
|
<option value="ValueChange">Value change — run when an attribute changes</option>
|
|
<option value="Conditional">Conditional — run when a condition is met</option>
|
|
<option value="Expression">Expression — run when a boolean expression becomes true</option>
|
|
<option value="Call">Call — run only when invoked by another script</option>
|
|
@if (_kind == ScriptTriggerKind.Unknown)
|
|
{
|
|
<optgroup label="Unrecognized">
|
|
<option value="Unknown">@_rawType (unknown)</option>
|
|
</optgroup>
|
|
}
|
|
</select>
|
|
</div>
|
|
|
|
@* ── Type-specific configuration ───────────────────────────────────── *@
|
|
@switch (_kind)
|
|
{
|
|
case ScriptTriggerKind.Interval:
|
|
@RenderInterval();
|
|
break;
|
|
case ScriptTriggerKind.ValueChange:
|
|
<div class="mb-1">@RenderAttributePicker("Monitored attribute")</div>
|
|
break;
|
|
case ScriptTriggerKind.Conditional:
|
|
@RenderConditional();
|
|
break;
|
|
case ScriptTriggerKind.Expression:
|
|
@RenderExpression();
|
|
break;
|
|
case ScriptTriggerKind.Call:
|
|
<div class="small text-muted">
|
|
No automatic trigger — this script runs only when another script
|
|
invokes it via <code>Instance.CallScript("...")</code>.
|
|
</div>
|
|
break;
|
|
case ScriptTriggerKind.Unknown:
|
|
<div class="alert alert-warning py-2 small mb-0">
|
|
Unrecognized trigger type <code>@_rawType</code>. Its stored
|
|
configuration is shown below and left untouched — pick a known
|
|
trigger type above to reconfigure it.
|
|
<pre class="bg-body-secondary border rounded p-2 mt-2 mb-0">@(string.IsNullOrWhiteSpace(TriggerConfig) ? "(empty)" : TriggerConfig)</pre>
|
|
</div>
|
|
break;
|
|
}
|
|
|
|
@* ── Fire mode (Conditional + Expression) ──────────────────────────── *@
|
|
@if (_kind is ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression)
|
|
{
|
|
@RenderMode();
|
|
}
|
|
|
|
@* ── Hint ──────────────────────────────────────────────────────────── *@
|
|
@if (_kind is ScriptTriggerKind.Interval or ScriptTriggerKind.ValueChange
|
|
or ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression)
|
|
{
|
|
<div class="mt-3 pt-2 border-top small text-muted">@BuildHint()</div>
|
|
}
|
|
</div>
|
|
|
|
@code {
|
|
// ── Parameters ─────────────────────────────────────────────────────────
|
|
|
|
[Parameter] public string? TriggerType { get; set; }
|
|
[Parameter] public string? TriggerConfig { get; set; }
|
|
|
|
/// <summary>Raised whenever the type or config changes — emits both atomically.</summary>
|
|
[Parameter] public EventCallback<ScriptTriggerValue> Changed { get; set; }
|
|
|
|
/// <summary>Flattened attribute list (direct + inherited + composed) for the picker.</summary>
|
|
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; } =
|
|
Array.Empty<AlarmAttributeChoice>();
|
|
|
|
// ── Internal state ─────────────────────────────────────────────────────
|
|
|
|
private ScriptTriggerKind _kind;
|
|
private string _kindValue = "None";
|
|
private string? _rawType;
|
|
private ScriptTriggerModel _model = new();
|
|
|
|
// Last type/config seen on Parameters — distinguishes an external change
|
|
// (re-parse) from this component's own echo (skip).
|
|
private bool _seen;
|
|
private string? _lastType;
|
|
private string? _lastConfig;
|
|
|
|
// Text mirrors — @bind needs settable backing fields; kept in sync with the
|
|
// model so blank inputs round-trip blank rather than as 0.
|
|
private string _attributeName = string.Empty;
|
|
private string _operator = ">";
|
|
private string? _thresholdText;
|
|
private string? _intervalText;
|
|
private string _intervalUnit = "ms";
|
|
|
|
// ── Parse / serialize lifecycle ────────────────────────────────────────
|
|
|
|
protected override void OnParametersSet()
|
|
{
|
|
if (_seen && _lastType == TriggerType && _lastConfig == TriggerConfig) return;
|
|
_seen = true;
|
|
_lastType = TriggerType;
|
|
_lastConfig = TriggerConfig;
|
|
|
|
_rawType = TriggerType;
|
|
_kind = ScriptTriggerConfigCodec.ParseKind(TriggerType);
|
|
_kindValue = _kind.ToString();
|
|
_model = ScriptTriggerConfigCodec.Parse(TriggerConfig, _kind);
|
|
SyncMirrors();
|
|
}
|
|
|
|
private void SyncMirrors()
|
|
{
|
|
_attributeName = _model.AttributeName ?? string.Empty;
|
|
_operator = _model.Operator;
|
|
_thresholdText = _model.Threshold?.ToString("R", CultureInfo.InvariantCulture);
|
|
(_intervalText, _intervalUnit) = SplitInterval(_model.IntervalMs);
|
|
// Sync the analysis-kind selector from the loaded model.
|
|
_analysisKindValue = _model.IsStrictAnalysisKind ? "Strict" : "Advisory";
|
|
}
|
|
|
|
/// <summary>Chooses the largest whole unit (min/sec/ms) that represents the period exactly.</summary>
|
|
private static (string?, string) SplitInterval(long? ms)
|
|
{
|
|
if (ms is not { } v) return (null, "ms");
|
|
if (v >= 60000 && v % 60000 == 0) return ((v / 60000).ToString(CultureInfo.InvariantCulture), "min");
|
|
if (v >= 1000 && v % 1000 == 0) return ((v / 1000).ToString(CultureInfo.InvariantCulture), "sec");
|
|
return (v.ToString(CultureInfo.InvariantCulture), "ms");
|
|
}
|
|
|
|
private static long UnitFactor(string unit) => unit switch
|
|
{
|
|
"min" => 60000,
|
|
"sec" => 1000,
|
|
_ => 1
|
|
};
|
|
|
|
/// <summary>Serializes the current model and raises <see cref="Changed"/> once.</summary>
|
|
private async Task Emit()
|
|
{
|
|
var type = ScriptTriggerConfigCodec.KindToString(_kind);
|
|
var config = ScriptTriggerConfigCodec.Serialize(_model, _kind);
|
|
_lastType = type;
|
|
_lastConfig = config;
|
|
await Changed.InvokeAsync(new ScriptTriggerValue(type, config));
|
|
}
|
|
|
|
// ── Trigger type ───────────────────────────────────────────────────────
|
|
|
|
private async Task OnKindChanged()
|
|
{
|
|
if (!Enum.TryParse<ScriptTriggerKind>(_kindValue, out var newKind)
|
|
|| newKind == ScriptTriggerKind.Unknown)
|
|
{
|
|
_kindValue = _kind.ToString();
|
|
return;
|
|
}
|
|
|
|
// Carry the attribute name across a ValueChange <-> Conditional switch.
|
|
var preservedAttr = _model.AttributeName;
|
|
_kind = newKind;
|
|
_model = new ScriptTriggerModel();
|
|
if (newKind is ScriptTriggerKind.ValueChange or ScriptTriggerKind.Conditional)
|
|
_model.AttributeName = preservedAttr;
|
|
SyncMirrors();
|
|
await Emit();
|
|
}
|
|
|
|
// ── Interval ───────────────────────────────────────────────────────────
|
|
|
|
private RenderFragment RenderInterval() => __builder =>
|
|
{
|
|
<div class="row g-2 align-items-end" style="max-width: 420px;">
|
|
<div class="col-7">
|
|
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">Run every</label>
|
|
<input type="number" min="1" step="1" class="form-control form-control-sm"
|
|
placeholder="period"
|
|
@bind="_intervalText" @bind:event="oninput" @bind:after="OnIntervalChanged" />
|
|
</div>
|
|
<div class="col-5">
|
|
<select class="form-select form-select-sm"
|
|
@bind="_intervalUnit" @bind:after="OnIntervalChanged">
|
|
<option value="ms">milliseconds</option>
|
|
<option value="sec">seconds</option>
|
|
<option value="min">minutes</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
};
|
|
|
|
private async Task OnIntervalChanged()
|
|
{
|
|
_model.IntervalMs =
|
|
long.TryParse(_intervalText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) && n > 0
|
|
? n * UnitFactor(_intervalUnit)
|
|
: null;
|
|
await Emit();
|
|
}
|
|
|
|
// ── Conditional ────────────────────────────────────────────────────────
|
|
|
|
private RenderFragment RenderConditional() => __builder =>
|
|
{
|
|
<div class="mb-2">@RenderAttributePicker("Monitored attribute")</div>
|
|
<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">Operator</label>
|
|
<select class="form-select form-select-sm"
|
|
@bind="_operator" @bind:after="OnOperatorChanged">
|
|
@foreach (var op in ScriptTriggerConfigCodec.Operators)
|
|
{
|
|
<option value="@op">@op — @OperatorLabel(op)</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-7">
|
|
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">Threshold</label>
|
|
<input type="number" step="any" class="form-control form-control-sm"
|
|
placeholder="numeric value"
|
|
@bind="_thresholdText" @bind:event="oninput" @bind:after="OnThresholdChanged" />
|
|
</div>
|
|
</div>
|
|
};
|
|
|
|
private async Task OnOperatorChanged()
|
|
{
|
|
_model.Operator = ScriptTriggerConfigCodec.NormalizeOperator(_operator);
|
|
await Emit();
|
|
}
|
|
|
|
private async Task OnThresholdChanged()
|
|
{
|
|
_model.Threshold =
|
|
double.TryParse(_thresholdText, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)
|
|
? v
|
|
: null;
|
|
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>
|
|
@* Analysis-kind selector — Advisory keeps the blank-expression finding
|
|
as a non-blocking warning; Strict escalates it to a deploy-blocking error. *@
|
|
<div class="mt-2">
|
|
<label for="script-trigger-kind" class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
|
Analysis kind
|
|
</label>
|
|
<select id="script-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();
|
|
}
|
|
|
|
// ── Fire mode (Conditional + Expression) ───────────────────────────────
|
|
|
|
private RenderFragment RenderMode() => __builder =>
|
|
{
|
|
<div class="mt-3">
|
|
<label for="script-trigger-mode" class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
|
Fire mode
|
|
</label>
|
|
<select id="script-trigger-mode" class="form-select form-select-sm"
|
|
style="max-width: 420px;"
|
|
@bind="_model.Mode" @bind:after="OnModeChanged">
|
|
<option value="@ScriptTriggerMode.OnTrue">
|
|
Once — when the condition becomes true
|
|
</option>
|
|
<option value="@ScriptTriggerMode.WhileTrue">
|
|
Repeatedly — while the condition stays true
|
|
</option>
|
|
</select>
|
|
@if (_model.Mode == ScriptTriggerMode.WhileTrue)
|
|
{
|
|
<div class="form-text">
|
|
Re-runs on a timer while the condition holds, at the script's
|
|
<strong>Min time between runs</strong> interval — set that field below
|
|
the script editor, or the trigger fires only once.
|
|
</div>
|
|
}
|
|
</div>
|
|
};
|
|
|
|
private async Task OnModeChanged() => await Emit();
|
|
|
|
// ── Attribute picker (ValueChange + Conditional) ───────────────────────
|
|
|
|
private RenderFragment RenderAttributePicker(string label) => __builder =>
|
|
{
|
|
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">@label</label>
|
|
<select class="form-select form-select-sm"
|
|
@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))
|
|
{
|
|
<option value="@choice.CanonicalName">@choice.CanonicalName (@choice.DataType)</option>
|
|
}
|
|
</optgroup>
|
|
}
|
|
@* Keep a saved-but-missing attribute selectable so it stays visible. *@
|
|
@if (!string.IsNullOrEmpty(_attributeName)
|
|
&& !AvailableAttributes.Any(c => string.Equals(c.CanonicalName, _attributeName, StringComparison.Ordinal)))
|
|
{
|
|
<optgroup label="Unknown">
|
|
<option value="@_attributeName">@_attributeName (not on this template)</option>
|
|
</optgroup>
|
|
}
|
|
</select>
|
|
};
|
|
|
|
private async Task OnAttributeChanged()
|
|
{
|
|
_model.AttributeName = _attributeName;
|
|
await Emit();
|
|
}
|
|
|
|
private static int SourceOrder(string source) => source switch
|
|
{
|
|
"Direct" => 0,
|
|
"Inherited" => 1,
|
|
"Composed" => 2,
|
|
_ => 3
|
|
};
|
|
|
|
// ── Hint ───────────────────────────────────────────────────────────────
|
|
|
|
private string BuildHint()
|
|
{
|
|
var attr = string.IsNullOrWhiteSpace(_model.AttributeName)
|
|
? "the selected attribute"
|
|
: $"\"{_model.AttributeName}\"";
|
|
|
|
return _kind switch
|
|
{
|
|
ScriptTriggerKind.Interval =>
|
|
_model.IntervalMs is { } ms
|
|
? $"Runs every {_intervalText} {UnitLabel(_intervalUnit)} ({ms} ms)."
|
|
: "Runs on a fixed timer — set the period above.",
|
|
|
|
ScriptTriggerKind.ValueChange =>
|
|
$"Runs whenever {attr} changes value.",
|
|
|
|
ScriptTriggerKind.Conditional =>
|
|
_model.Threshold is { } t
|
|
? (_model.Mode == ScriptTriggerMode.WhileTrue
|
|
? $"Re-runs while {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}."
|
|
: $"Runs when {attr} changes, if {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}.")
|
|
: $"Runs when {attr} changes and meets the configured condition — set a threshold above.",
|
|
|
|
ScriptTriggerKind.Expression =>
|
|
_model.Mode == ScriptTriggerMode.WhileTrue
|
|
? "Re-runs while this expression stays true."
|
|
: "Runs once each time this expression becomes true.",
|
|
|
|
_ => string.Empty
|
|
};
|
|
}
|
|
|
|
private static string UnitLabel(string unit) => unit switch
|
|
{
|
|
"min" => "minute(s)",
|
|
"sec" => "second(s)",
|
|
_ => "millisecond(s)"
|
|
};
|
|
|
|
private static string OperatorLabel(string op) => op switch
|
|
{
|
|
">" => "greater than",
|
|
">=" => "at least",
|
|
"<" => "less than",
|
|
"<=" => "at most",
|
|
"==" => "equals",
|
|
"!=" => "not equal",
|
|
_ => ""
|
|
};
|
|
}
|