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:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// One option in the alarm trigger editor's attribute picker.
/// <see cref="Source"/> is one of "Direct", "Inherited", or "Composed" —
/// used to group entries in the dropdown.
/// </summary>
public record AlarmAttributeChoice(string CanonicalName, string DataType, string Source);
@@ -0,0 +1,345 @@
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Round-trip codec for the alarm trigger configuration JSON used by both
/// <see cref="AlarmTriggerEditor"/> (UI editing) and AlarmActor (runtime
/// evaluation). The serialized shape per trigger type:
/// ValueMatch { attributeName, matchValue } ("!=X" prefix = not equals)
/// RangeViolation { attributeName, min, max }
/// RateOfChange { attributeName, thresholdPerSecond, windowSeconds, direction }
/// HiLo { attributeName, loLo, lo, hi, hiHi,
/// loLoPriority, loPriority, hiPriority, hiHiPriority }
/// Expression { expression }
///
/// All HiLo setpoints and per-setpoint priorities are optional — any subset
/// is valid (e.g., only Hi/HiHi configured for over-temperature protection).
///
/// Parsing also accepts legacy aliases the runtime used to consume
/// (<c>attribute</c>, <c>value</c>, <c>low</c>, <c>high</c>) so older configs
/// survive a round-trip through the editor.
/// </summary>
internal static class AlarmTriggerConfigCodec
{
/// <summary>
/// Parses a trigger configuration JSON in the context of the given trigger
/// type. Returns a model with default values on null/empty/malformed input
/// or for missing keys — never throws.
/// </summary>
/// <param name="json">The trigger configuration JSON string, or null/empty for defaults.</param>
/// <param name="type">The alarm trigger type that determines which properties to extract.</param>
/// <returns>A populated AlarmTriggerModel with default values for missing fields.</returns>
internal static AlarmTriggerModel Parse(string? json, AlarmTriggerType type)
{
var model = new AlarmTriggerModel();
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;
case AlarmTriggerType.HiLo:
model.LoLo = TryReadDouble(root, "loLo");
model.Lo = TryReadDouble(root, "lo");
model.Hi = TryReadDouble(root, "hi");
model.HiHi = TryReadDouble(root, "hiHi");
model.LoLoPriority = TryReadInt(root, "loLoPriority");
model.LoPriority = TryReadInt(root, "loPriority");
model.HiPriority = TryReadInt(root, "hiPriority");
model.HiHiPriority = TryReadInt(root, "hiHiPriority");
model.LoLoDeadband = TryReadDouble(root, "loLoDeadband");
model.LoDeadband = TryReadDouble(root, "loDeadband");
model.HiDeadband = TryReadDouble(root, "hiDeadband");
model.HiHiDeadband = TryReadDouble(root, "hiHiDeadband");
model.LoLoMessage = TryReadString(root, "loLoMessage");
model.LoMessage = TryReadString(root, "loMessage");
model.HiMessage = TryReadString(root, "hiMessage");
model.HiHiMessage = TryReadString(root, "hiHiMessage");
break;
case AlarmTriggerType.Expression:
model.Expression = TryReadString(root, "expression");
break;
}
}
catch (JsonException)
{
// Malformed JSON — fall through with default model.
}
return model;
}
/// <summary>
/// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig
/// expects. Writes <c>attributeName</c> (canonical key) for the
/// attribute-bound trigger types and only the keys relevant to the
/// current trigger type. <c>Expression</c> is not bound to a single
/// attribute, so <c>attributeName</c> is omitted for it.
/// </summary>
/// <param name="model">The AlarmTriggerModel to serialize.</param>
/// <param name="type">The alarm trigger type determining which properties to serialize.</param>
/// <returns>The serialized JSON representation of the model.</returns>
internal static string Serialize(AlarmTriggerModel model, AlarmTriggerType type)
{
using var stream = new MemoryStream();
using (var w = new Utf8JsonWriter(stream))
{
w.WriteStartObject();
if (type != AlarmTriggerType.Expression)
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;
case AlarmTriggerType.HiLo:
if (model.LoLo.HasValue) w.WriteNumber("loLo", model.LoLo.Value);
if (model.Lo.HasValue) w.WriteNumber("lo", model.Lo.Value);
if (model.Hi.HasValue) w.WriteNumber("hi", model.Hi.Value);
if (model.HiHi.HasValue) w.WriteNumber("hiHi", model.HiHi.Value);
if (model.LoLoPriority.HasValue) w.WriteNumber("loLoPriority", model.LoLoPriority.Value);
if (model.LoPriority.HasValue) w.WriteNumber("loPriority", model.LoPriority.Value);
if (model.HiPriority.HasValue) w.WriteNumber("hiPriority", model.HiPriority.Value);
if (model.HiHiPriority.HasValue) w.WriteNumber("hiHiPriority", model.HiHiPriority.Value);
if (model.LoLoDeadband.HasValue) w.WriteNumber("loLoDeadband", model.LoLoDeadband.Value);
if (model.LoDeadband.HasValue) w.WriteNumber("loDeadband", model.LoDeadband.Value);
if (model.HiDeadband.HasValue) w.WriteNumber("hiDeadband", model.HiDeadband.Value);
if (model.HiHiDeadband.HasValue) w.WriteNumber("hiHiDeadband", model.HiHiDeadband.Value);
if (!string.IsNullOrEmpty(model.LoLoMessage)) w.WriteString("loLoMessage", model.LoLoMessage);
if (!string.IsNullOrEmpty(model.LoMessage)) w.WriteString("loMessage", model.LoMessage);
if (!string.IsNullOrEmpty(model.HiMessage)) w.WriteString("hiMessage", model.HiMessage);
if (!string.IsNullOrEmpty(model.HiHiMessage)) w.WriteString("hiHiMessage", model.HiHiMessage);
break;
case AlarmTriggerType.Expression:
w.WriteString("expression", model.Expression ?? "");
break;
}
w.WriteEndObject();
}
return Encoding.UTF8.GetString(stream.ToArray());
}
/// <summary>
/// Normalizes a direction string to one of: rising, falling, or either.
/// </summary>
/// <param name="raw">The raw direction string to normalize.</param>
/// <returns>Normalized direction: "rising", "falling", or "either".</returns>
internal 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
};
}
private static int? TryReadInt(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind switch
{
JsonValueKind.Number when p.TryGetInt32(out var i) => i,
JsonValueKind.Number => (int)p.GetDouble(),
JsonValueKind.String when int.TryParse(p.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) => v,
_ => null
};
}
private static string? TryReadString(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind == JsonValueKind.String ? p.GetString() : null;
}
}
internal sealed class AlarmTriggerModel
{
/// <summary>
/// The attribute name bound to this trigger.
/// </summary>
public string? AttributeName { get; set; }
// ValueMatch
/// <summary>
/// The value to match against the attribute for ValueMatch triggers.
/// </summary>
public string? MatchValue { get; set; }
/// <summary>
/// Indicates whether the match should be inverted (not equal) for ValueMatch triggers.
/// </summary>
public bool NotEquals { get; set; }
// RangeViolation
/// <summary>
/// The minimum threshold for RangeViolation triggers.
/// </summary>
public double? Min { get; set; }
/// <summary>
/// The maximum threshold for RangeViolation triggers.
/// </summary>
public double? Max { get; set; }
// RateOfChange
/// <summary>
/// The threshold per second for RateOfChange triggers.
/// </summary>
public double? ThresholdPerSecond { get; set; }
/// <summary>
/// The time window in seconds for RateOfChange rate calculation.
/// </summary>
public double? WindowSeconds { get; set; }
/// <summary>
/// The direction of change: "rising", "falling", or "either" for RateOfChange triggers.
/// </summary>
public string Direction { get; set; } = "either";
// HiLo — any subset of setpoints may be set; per-setpoint priorities
// override the alarm-level priority for that band.
/// <summary>
/// The low-low setpoint for HiLo triggers.
/// </summary>
public double? LoLo { get; set; }
/// <summary>
/// The low setpoint for HiLo triggers.
/// </summary>
public double? Lo { get; set; }
/// <summary>
/// The high setpoint for HiLo triggers.
/// </summary>
public double? Hi { get; set; }
/// <summary>
/// The high-high setpoint for HiLo triggers.
/// </summary>
public double? HiHi { get; set; }
/// <summary>
/// The priority for low-low alarm state.
/// </summary>
public int? LoLoPriority { get; set; }
/// <summary>
/// The priority for low alarm state.
/// </summary>
public int? LoPriority { get; set; }
/// <summary>
/// The priority for high alarm state.
/// </summary>
public int? HiPriority { get; set; }
/// <summary>
/// The priority for high-high alarm state.
/// </summary>
public int? HiHiPriority { get; set; }
// Hysteresis: optional deactivation deadband per setpoint. Once at the
// band, the setpoint threshold is relaxed by this amount before the alarm
// de-escalates. Prevents flapping when the value hovers at the boundary.
/// <summary>
/// The deadband for low-low alarm de-escalation.
/// </summary>
public double? LoLoDeadband { get; set; }
/// <summary>
/// The deadband for low alarm de-escalation.
/// </summary>
public double? LoDeadband { get; set; }
/// <summary>
/// The deadband for high alarm de-escalation.
/// </summary>
public double? HiDeadband { get; set; }
/// <summary>
/// The deadband for high-high alarm de-escalation.
/// </summary>
public double? HiHiDeadband { get; set; }
// Per-band operator message. Optional; surfaces on AlarmStateChanged.Message
// and may be used by notification routing or operator displays.
/// <summary>
/// The operator message for low-low alarm state.
/// </summary>
public string? LoLoMessage { get; set; }
/// <summary>
/// The operator message for low alarm state.
/// </summary>
public string? LoMessage { get; set; }
/// <summary>
/// The operator message for high alarm state.
/// </summary>
public string? HiMessage { get; set; }
/// <summary>
/// The operator message for high-high alarm state.
/// </summary>
public string? HiHiMessage { get; set; }
// Expression — boolean C# expression evaluated on attribute updates.
/// <summary>
/// The boolean C# expression to evaluate for Expression triggers.
/// </summary>
public string? Expression { get; set; }
}
@@ -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"] &gt; 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;
}
@@ -0,0 +1,68 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Compose '@SourceName' into…</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small text-muted mb-1">Parent template</label>
<select class="form-select form-select-sm" @bind="_parentTemplateId">
<option value="0" disabled selected>Select a parent template…</option>
@foreach (var opt in ParentOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
</div>
<div class="mb-1">
<label class="form-label small text-muted mb-1">Slot name</label>
<input type="text" class="form-control form-control-sm"
placeholder="Slot name"
@bind="_slotName" />
</div>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-2">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit" disabled="@(_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName))">Compose</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int SourceTemplateId { get; set; }
[Parameter] public string SourceName { get; set; } = string.Empty;
[Parameter] public IEnumerable<(int Id, string Label)> ParentOptions { get; set; } = Array.Empty<(int, string)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int SourceTemplateId, int ParentTemplateId, string SlotName)> OnSubmit { get; set; }
private bool _wasVisible;
private int _parentTemplateId;
private string _slotName = string.Empty;
protected override void OnParametersSet()
{
if (IsVisible && !_wasVisible)
{
_parentTemplateId = 0;
_slotName = SourceName;
}
_wasVisible = IsVisible;
}
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
private async Task Submit()
{
if (_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName)) return;
await OnSubmit.InvokeAsync((SourceTemplateId, _parentTemplateId, _slotName.Trim()));
}
}
@@ -0,0 +1,156 @@
@* Reusable data table with sorting, filtering, and pagination *@
@typeparam TItem
<div class="mb-2">
@if (ShowSearch)
{
<div class="row mb-2">
<div class="col-md-4">
<div class="input-group input-group-sm">
<input type="text" class="form-control form-control-sm" placeholder="Search..."
@bind="_searchTerm" @bind:event="oninput" @bind:after="ApplyFilter" />
@if (!string.IsNullOrEmpty(_searchTerm))
{
<button type="button" class="btn btn-outline-secondary"
aria-label="Clear search" @onclick="ClearSearch">&times;</button>
}
</div>
</div>
@if (FilterContent != null)
{
<div class="col-md-8 d-flex gap-2 align-items-center">
@FilterContent
</div>
}
</div>
}
</div>
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
@HeaderContent
</tr>
</thead>
<tbody>
@if (_pagedItems.Count == 0)
{
<tr>
<td colspan="100" class="text-muted text-center">@EmptyMessage</td>
</tr>
}
else
{
@foreach (var item in _pagedItems)
{
@RowContent(item)
}
}
</tbody>
</table>
@if (_totalPages > 1)
{
<nav>
<ul class="pagination pagination-sm justify-content-end">
<li class="page-item @(_currentPage <= 1 ? "disabled" : "")">
<button class="page-link" type="button"
disabled="@(_currentPage <= 1)"
aria-disabled="@((_currentPage <= 1).ToString().ToLowerInvariant())"
@onclick="() => GoToPage(_currentPage - 1)">Previous</button>
</li>
@foreach (var page in PagerWindow.Build(_currentPage, _totalPages))
{
if (page == 0)
{
<li class="page-item disabled">
<span class="page-link">&hellip;</span>
</li>
}
else
{
var p = page;
<li class="page-item @(p == _currentPage ? "active" : "")">
<button class="page-link" @onclick="() => GoToPage(p)">@(p)</button>
</li>
}
}
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
<button class="page-link" type="button"
disabled="@(_currentPage >= _totalPages)"
aria-disabled="@((_currentPage >= _totalPages).ToString().ToLowerInvariant())"
@onclick="() => GoToPage(_currentPage + 1)">Next</button>
</li>
</ul>
</nav>
}
<div class="text-muted small">
Showing @((_currentPage - 1) * PageSize + 1)&ndash;@Math.Min(_currentPage * PageSize, _filteredItems.Count) of @_filteredItems.Count items
</div>
@code {
private string _searchTerm = string.Empty;
private int _currentPage = 1;
private List<TItem> _filteredItems = new();
private List<TItem> _pagedItems = new();
private int _totalPages;
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
[Parameter, EditorRequired] public RenderFragment HeaderContent { get; set; } = default!;
[Parameter, EditorRequired] public RenderFragment<TItem> RowContent { get; set; } = default!;
[Parameter] public RenderFragment? FilterContent { get; set; }
[Parameter] public int PageSize { get; set; } = 25;
[Parameter] public bool ShowSearch { get; set; } = true;
[Parameter] public string EmptyMessage { get; set; } = "No items found.";
[Parameter] public Func<TItem, string, bool>? SearchFilter { get; set; }
protected override void OnParametersSet()
{
ApplyFilter();
}
private void ApplyFilter()
{
if (!string.IsNullOrWhiteSpace(_searchTerm) && SearchFilter != null)
{
_filteredItems = Items.Where(i => SearchFilter(i, _searchTerm)).ToList();
}
else
{
_filteredItems = Items.ToList();
}
_totalPages = Math.Max(1, (int)Math.Ceiling(_filteredItems.Count / (double)PageSize));
if (_currentPage > _totalPages) _currentPage = 1;
UpdatePage();
}
private void GoToPage(int page)
{
if (page < 1 || page > _totalPages) return;
_currentPage = page;
UpdatePage();
}
private void ClearSearch()
{
_searchTerm = string.Empty;
ApplyFilter();
}
private void UpdatePage()
{
_pagedItems = _filteredItems
.Skip((_currentPage - 1) * PageSize)
.Take(PageSize)
.ToList();
}
public void Refresh()
{
ApplyFilter();
StateHasChanged();
}
}
@@ -0,0 +1,154 @@
@* Single global host component for IDialogService. Mounted once in MainLayout.
Listens to DialogService.OnChange and renders the current dialog state.
z-index ladder follows the same convention as ConfirmDialog/DiffDialog:
Toast container 1090 > this modal 1055 > this backdrop 1040. *@
@implements IDisposable
@inject IDialogService Service
@inject IJSRuntime JS
@if (Service is DialogService svc && svc.Current is { } state)
{
<div class="modal-backdrop fade show"></div>
<div @ref="_modalRef"
class="modal fade show d-block"
tabindex="-1"
role="dialog"
aria-modal="true"
@onkeydown="OnKeyDown">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@state.Title</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
</div>
<div class="modal-body">
@if (state.Kind == DialogKind.Confirm)
{
<p class="mb-0">@state.Body</p>
}
else
{
<label class="form-label">@state.Body</label>
<input @ref="_promptInputRef"
class="form-control form-control-sm"
placeholder="@state.Placeholder"
value="@_promptValue"
@oninput="OnPromptInput" />
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="Cancel">Cancel</button>
<button type="button"
class="btn @(state.Danger ? "btn-danger" : "btn-primary") btn-sm"
@onclick="Confirm">
@ConfirmLabel(state)
</button>
</div>
</div>
</div>
</div>
}
@code {
private ElementReference _modalRef;
private ElementReference _promptInputRef;
private string _promptValue = string.Empty;
private DialogState? _lastSeenState;
private DialogState? _focusedForState;
protected override void OnInitialized()
{
// OnChange lives on the concrete DialogService — the interface stays
// narrow (just ConfirmAsync / PromptAsync). DI hands us the concrete
// instance, so a cast here is safe.
if (Service is DialogService svc) svc.OnChange += OnServiceChanged;
}
public void Dispose()
{
if (Service is DialogService svc) svc.OnChange -= OnServiceChanged;
}
private void OnServiceChanged()
{
// Seed prompt input value when a new prompt dialog opens.
if (Service is DialogService s && s.Current is { Kind: DialogKind.Prompt } promptState
&& !ReferenceEquals(promptState, _lastSeenState))
{
_promptValue = promptState.PromptInitial;
}
_lastSeenState = (Service as DialogService)?.Current;
InvokeAsync(StateHasChanged);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
var current = (Service as DialogService)?.Current;
if (current is not null)
{
try { await JS.InvokeVoidAsync("document.body.classList.add", "modal-open"); }
catch { /* prerender: no JS — ignore */ }
// Focus once per opened dialog. Without this guard, every input
// keystroke triggers a re-render which would re-focus the modal
// element and yank the caret off the prompt input.
if (!ReferenceEquals(current, _focusedForState))
{
_focusedForState = current;
try
{
if (current.Kind == DialogKind.Prompt)
await _promptInputRef.FocusAsync();
else
await _modalRef.FocusAsync();
}
catch { /* element not yet attached: ignore */ }
}
}
else
{
_focusedForState = null;
try { await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); }
catch { /* prerender: no JS — ignore */ }
}
}
private void OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
Cancel();
}
}
private void OnPromptInput(ChangeEventArgs e)
{
_promptValue = e.Value?.ToString() ?? string.Empty;
}
private void Cancel()
{
if (Service is not DialogService svc || svc.Current is null) return;
var resolveValue = svc.Current.Kind == DialogKind.Confirm
? (object)false
: (object?)null;
_promptValue = string.Empty;
svc.Resolve(resolveValue);
}
private void Confirm()
{
if (Service is not DialogService svc || svc.Current is null) return;
var resolveValue = svc.Current.Kind == DialogKind.Confirm
? (object)true
: (object?)_promptValue;
_promptValue = string.Empty;
svc.Resolve(resolveValue);
}
private static string ConfirmLabel(DialogState state) => state.Kind switch
{
DialogKind.Prompt => "Save",
_ => state.Danger ? "Delete" : "Confirm",
};
}
@@ -0,0 +1,115 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Default <see cref="IDialogService"/> implementation. Holds the currently
/// open dialog state in <see cref="Current"/> and notifies subscribers (the
/// <c>DialogHost</c> component) via <see cref="OnChange"/>. Only a single
/// dialog can be open at a time; attempting to open another while one is
/// already active throws <see cref="InvalidOperationException"/> — there is
/// no nested-dialog use case today and surfacing the bug is preferable to
/// silently queuing.
/// </summary>
public class DialogService : IDialogService
{
/// <summary>
/// Raised whenever <see cref="Current"/> changes (dialog opened or closed).
/// The host component subscribes and calls <c>StateHasChanged</c>.
/// </summary>
public event Action? OnChange;
/// <summary>
/// The dialog currently being displayed, or <c>null</c> when no dialog is
/// open. The host reads this to decide what (if anything) to render.
/// </summary>
public DialogState? Current { get; private set; }
// CentralUI-015: the pending dialog result is held in a typed TCS that the
// host completes directly via Resolve(). The previous implementation
// projected the result through Task.ContinueWith(..., TaskScheduler.Default),
// which ran the projection lambda on a thread-pool thread. Completing a
// strongly-typed TCS directly removes that off-render-thread hop entirely —
// the awaiting caller resumes on whatever SynchronizationContext it captured
// (the Blazor renderer's, for an event-handler caller).
private TaskCompletionSource<object?>? _tcs;
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
{
EnsureNoActiveDialog();
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
_tcs = tcs;
Current = new DialogState(title, DialogKind.Confirm, message, danger, PromptInitial: string.Empty, Placeholder: null);
OnChange?.Invoke();
return Project(tcs.Task, static r => r is bool b && b);
}
/// <inheritdoc />
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
{
EnsureNoActiveDialog();
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
_tcs = tcs;
Current = new DialogState(title, DialogKind.Prompt, label, Danger: false, PromptInitial: initialValue, Placeholder: placeholder);
OnChange?.Invoke();
return Project(tcs.Task, static r => r as string);
}
/// <summary>
/// Awaits the host's result and projects it to the caller's type. The
/// <c>await</c> here resumes on the caller's captured context (the renderer
/// sync context for an event-handler caller), not a thread-pool thread.
/// </summary>
private static async Task<TResult> Project<TResult>(Task<object?> source, Func<object?, TResult> selector)
{
var result = await source.ConfigureAwait(false);
return selector(result);
}
/// <summary>
/// Called by the host component when the user dismisses or confirms the
/// dialog. <paramref name="result"/> must be a <c>bool</c> for confirms
/// and a <c>string?</c> for prompts (null = cancel).
/// </summary>
/// <param name="result">The user's response: a <c>bool</c> for confirms or a <c>string?</c> for prompts.</param>
internal void Resolve(object? result)
{
var tcs = _tcs;
_tcs = null;
Current = null;
OnChange?.Invoke();
tcs?.TrySetResult(result);
}
private void EnsureNoActiveDialog()
{
if (Current is not null)
{
throw new InvalidOperationException(
"A dialog is already open. IDialogService does not support nested dialogs.");
}
}
}
/// <summary>
/// Snapshot of a dialog's display state, exposed read-only on
/// <see cref="DialogService.Current"/> for the host component to render.
/// </summary>
/// <param name="Title">Modal title text.</param>
/// <param name="Kind">Discriminates between confirm and prompt rendering.</param>
/// <param name="Body">For confirm: the message; for prompt: the input label.</param>
/// <param name="Danger">When true, the confirm button uses danger styling.</param>
/// <param name="PromptInitial">Initial value for prompt-kind dialogs.</param>
/// <param name="Placeholder">Placeholder shown when the prompt input is empty.</param>
public record DialogState(
string Title,
DialogKind Kind,
string Body,
bool Danger,
string PromptInitial,
string? Placeholder);
public enum DialogKind
{
Confirm,
Prompt
}
@@ -0,0 +1,185 @@
@* Reusable diff/comparison dialog using Bootstrap modal.
Mirrors the ConfirmDialog API: callers invoke ShowAsync(title, before, after)
via @ref to display a side-by-side or simple before/after comparison.
z-index ladder follows ConfirmDialog: modal 1055 > backdrop 1040 (toasts at 1090). *@
@inject IJSRuntime JS
@inject ILogger<DiffDialog> Logger
@implements IAsyncDisposable
@if (_visible)
{
<div class="modal-backdrop fade show"></div>
<div @ref="_modalRef"
class="modal fade show d-block"
tabindex="-1"
role="dialog"
@onkeydown="OnKeyDownAsync">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" aria-label="Close diff dialog" @onclick="Close"></button>
</div>
<div class="modal-body">
@if (BodyContent != null)
{
@BodyContent
}
else
{
<div class="row g-3">
<div class="col-md-6">
<div class="small text-muted mb-1">Before</div>
<pre class="border rounded p-2 small bg-light mb-0" style="max-height: 50vh; overflow: auto; white-space: pre-wrap;">@Before</pre>
</div>
<div class="col-md-6">
<div class="small text-muted mb-1">After</div>
<pre class="border rounded p-2 small bg-light mb-0" style="max-height: 50vh; overflow: auto; white-space: pre-wrap;">@After</pre>
</div>
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" @onclick="Close">Close</button>
</div>
</div>
</div>
</div>
}
@code {
private bool _visible;
private bool _bodyLocked;
private TaskCompletionSource<bool>? _tcs;
private ElementReference _modalRef;
[Parameter] public string Title { get; set; } = "Diff";
[Parameter] public string Before { get; set; } = string.Empty;
[Parameter] public string After { get; set; } = string.Empty;
/// <summary>
/// Optional custom body content. When supplied, it replaces the default
/// before/after panes — useful when the caller wants to render a richer
/// comparison (e.g. metadata badges, file lists, etc.).
/// </summary>
[Parameter] public RenderFragment? BodyContent { get; set; }
/// <summary>
/// Show the dialog with the supplied title and before/after text.
/// Returns when the user dismisses the dialog.
/// </summary>
public Task<bool> ShowAsync(string title, string before, string after)
{
Title = title;
Before = before;
After = after;
BodyContent = null;
return OpenAsync();
}
/// <summary>
/// Show the dialog with a custom body. Useful when the diff is not a
/// simple before/after string pair (e.g. a deployment comparison summary).
/// </summary>
public Task<bool> ShowAsync(string title, RenderFragment body)
{
Title = title;
BodyContent = body;
return OpenAsync();
}
private Task<bool> OpenAsync()
{
_visible = true;
_tcs = new TaskCompletionSource<bool>();
StateHasChanged();
return _tcs.Task;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_visible && !_bodyLocked)
{
_bodyLocked = true;
await TryLockBodyAsync();
try { await _modalRef.FocusAsync(); }
catch (InvalidOperationException)
{
// Prerender: the element reference is not attached yet — the
// next interactive render focuses it. Expected, not logged.
}
catch (JSDisconnectedException)
{
// Circuit gone before focus could run — nothing to do.
}
catch (JSException ex)
{
// A genuine focus interop failure (CentralUI-023) — log it.
Logger.LogWarning(ex, "DiffDialog: failed to focus the modal.");
}
}
}
private async Task OnKeyDownAsync(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
Close();
await Task.CompletedTask;
}
}
private void Close()
{
_visible = false;
_ = TryUnlockBodyAsync();
_tcs?.TrySetResult(true);
}
private async Task TryLockBodyAsync()
{
try
{
await JS.InvokeVoidAsync("document.body.classList.add", "modal-open");
}
catch (JSDisconnectedException)
{
// Circuit gone — the body scroll lock is moot. Expected, silent.
}
catch (JSException ex)
{
// CentralUI-023: a genuine interop failure — log instead of doing
// another (also-failing) JS call inside a bare catch.
Logger.LogWarning(ex, "DiffDialog: failed to apply body scroll lock.");
}
}
private async Task TryUnlockBodyAsync()
{
_bodyLocked = false;
try
{
await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open");
}
catch (JSDisconnectedException)
{
// Circuit gone — the body scroll lock is moot. Expected, silent.
}
catch (JSException ex)
{
Logger.LogWarning(ex, "DiffDialog: failed to remove body scroll lock.");
}
}
public async ValueTask DisposeAsync()
{
// CentralUI-011: if the dialog is disposed while still open (the user
// navigated away), complete the pending task so the awaiting caller
// resumes deterministically instead of hanging forever.
_tcs?.TrySetResult(false);
if (_bodyLocked)
{
await TryUnlockBodyAsync();
}
}
}
@@ -0,0 +1,53 @@
using System.Globalization;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Converts a <see cref="TimeSpan"/> to and from the number+unit pair behind a
/// duration input (milliseconds / seconds / minutes). A blank or non-positive
/// number represents "unset" (a <c>null</c> duration).
/// </summary>
internal static class DurationInput
{
/// <summary>The unit tokens a duration input offers, smallest first.</summary>
internal static readonly string[] Units = { "ms", "sec", "min" };
/// <summary>
/// Splits a duration into the largest whole unit that represents it exactly.
/// A null or non-positive duration yields a blank value and the default
/// <c>sec</c> unit.
/// </summary>
/// <param name="duration">The duration to split, or null for unset.</param>
internal static (string? Value, string Unit) Split(TimeSpan? duration)
{
if (duration is not { } d || d <= TimeSpan.Zero) return (null, "sec");
var ms = (long)d.TotalMilliseconds;
if (ms % 60000 == 0) return ((ms / 60000).ToString(CultureInfo.InvariantCulture), "min");
if (ms % 1000 == 0) return ((ms / 1000).ToString(CultureInfo.InvariantCulture), "sec");
return (ms.ToString(CultureInfo.InvariantCulture), "ms");
}
/// <summary>
/// Composes a number+unit pair into a duration. A blank, unparseable, or
/// non-positive value yields <c>null</c> (unset).
/// </summary>
/// <param name="value">The numeric string entered by the user.</param>
/// <param name="unit">The selected unit token (ms, sec, or min).</param>
internal static TimeSpan? Compose(string? value, string unit)
{
if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
|| n <= 0)
{
return null;
}
var factorMs = unit switch
{
"min" => 60000L,
"ms" => 1L,
_ => 1000L,
};
return TimeSpan.FromMilliseconds(n * factorMs);
}
}
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Centralised dialog/modal service. Pages inject this service and call
/// <see cref="ConfirmAsync"/> or <see cref="PromptAsync"/> programmatically
/// instead of embedding per-page modal components. A single <c>DialogHost</c>
/// rendered in <c>MainLayout</c> displays the resulting dialog state.
/// </summary>
public interface IDialogService
{
/// <summary>
/// Shows a confirmation dialog and resolves to <c>true</c> when the user
/// confirms, or <c>false</c> when the user cancels (button click, Escape,
/// or backdrop dismiss).
/// </summary>
/// <param name="title">Modal title text.</param>
/// <param name="message">Body text shown to the user.</param>
/// <param name="danger">When <c>true</c>, the confirm button renders in
/// <c>btn-danger</c> styling with the label "Delete"; otherwise a primary
/// "Confirm" button is shown.</param>
Task<bool> ConfirmAsync(string title, string message, bool danger = false);
/// <summary>
/// Shows a single-line text prompt and resolves to the entered value, or
/// <c>null</c> if the user cancels.
/// </summary>
/// <param name="title">Modal title text.</param>
/// <param name="label">Label rendered above the input field.</param>
/// <param name="initialValue">Pre-populated value for the input field.</param>
/// <param name="placeholder">Optional placeholder shown when the input is empty.</param>
Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null);
}
@@ -0,0 +1,17 @@
@* Reusable loading spinner *@
@if (IsLoading)
{
<div class="d-flex align-items-center text-secondary @CssClass">
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<span>@Message</span>
</div>
}
@code {
[Parameter] public bool IsLoading { get; set; }
[Parameter] public string Message { get; set; } = "Loading...";
[Parameter] public string CssClass { get; set; } = "";
}
@@ -0,0 +1,235 @@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject Microsoft.Extensions.Logging.ILogger<MonacoEditor> Logger
@if (ShowToolbar)
{
<div class="d-flex justify-content-end align-items-center gap-3 mb-1 small text-muted">
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="FormatAsync"
title="Format document (Ctrl/Cmd+Shift+F)">Format</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleWrap"
title="Word wrap">@(_wrap ? "Wrap on" : "Wrap off")</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleMinimap"
title="Toggle minimap">@(_minimap ? "Minimap on" : "Minimap off")</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleTheme"
title="Toggle theme">@(_dark ? "Dark" : "Light")</button>
</div>
}
<div @ref="_hostRef" class="monaco-editor-host"
style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"></div>
@code {
[Parameter] public string Value { get; set; } = "";
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public string Language { get; set; } = "csharp";
[Parameter] public string Height { get; set; } = "320px";
[Parameter] public bool ReadOnly { get; set; } = false;
[Parameter] public bool ShowToolbar { get; set; } = true;
/// <summary>
/// Runtime globals surface the script is analyzed against. Defaults to
/// template/shared-script globals; set to <c>InboundApi</c> on the API
/// method editor so <c>Route</c> and <c>Parameters</c> type-check.
/// </summary>
[Parameter] public ScriptAnalysis.ScriptKind ScriptKind { get; set; } = ScriptAnalysis.ScriptKind.Template;
/// <summary>
/// Parameter names declared on the form (derived from the SchemaBuilder's
/// JSON Schema), surfaced as completions inside Parameters["..."] literals
/// and used by the unknown-key diagnostic.
/// </summary>
[Parameter] public IReadOnlyList<string>? DeclaredParameters { get; set; }
/// <summary>
/// Full shapes (name + type + required) for the declared parameters.
/// Used by Parameters["name"] hover to show the declared type. If null,
/// derived from <see cref="DeclaredParameters"/> with type "Object".
/// </summary>
[Parameter] public IReadOnlyList<ScriptAnalysis.ParameterShape>? DeclaredParameterShapes { get; set; }
/// <summary>
/// Shapes (name + parameter list + return type) of other scripts on the
/// same template. Surfaced inside CallScript("...") for completion,
/// signature help, hover, and argument-count diagnostics.
/// </summary>
[Parameter] public IReadOnlyList<ScriptAnalysis.ScriptShape>? SiblingScripts { get; set; }
/// <summary>
/// Attributes declared on the current template. Surfaced inside
/// <c>Attributes["..."]</c> for completion and SCADA006 diagnostics.
/// </summary>
[Parameter] public IReadOnlyList<ScriptAnalysis.AttributeShape>? SelfAttributes { get; set; }
/// <summary>
/// Child compositions on the current template, each with its template's
/// attributes and scripts. Surfaced for <c>Children["X"].Attributes</c>,
/// <c>Children["X"].CallScript</c>, and SCADA007 diagnostics.
/// </summary>
[Parameter] public IReadOnlyList<ScriptAnalysis.CompositionContext>? Children { get; set; }
/// <summary>
/// Parent template when the current template is composed inside exactly
/// one other template. <c>null</c> at the root or when multiple parents
/// exist. Surfaced for <c>Parent.Attributes</c> / <c>Parent.CallScript</c>.
/// </summary>
[Parameter] public ScriptAnalysis.CompositionContext? Parent { get; set; }
/// <summary>
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
/// debounce). Hosts can render a <see cref="ProblemsPanel"/> with the same
/// data.
/// </summary>
[Parameter] public EventCallback<IReadOnlyList<ScriptAnalysis.DiagnosticMarker>> MarkersChanged { get; set; }
private ElementReference _hostRef;
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
private readonly string _id = Guid.NewGuid().ToString("N");
private string _lastSentValue = "";
private bool _initialized;
private bool _wrap;
private bool _minimap;
private bool _dark;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_dotNetRef = DotNetObjectReference.Create(this);
_lastSentValue = Value ?? "";
try
{
await JS.InvokeVoidAsync(
"MonacoBlazor.createEditor",
_id,
_hostRef,
new
{
value = Value ?? "",
language = Language,
readOnly = ReadOnly
},
_dotNetRef);
_initialized = true;
}
catch (InvalidOperationException)
{
// Prerendering: JS interop is not available yet — the next
// (interactive) render retries. Expected, not logged.
}
catch (JSDisconnectedException)
{
// Circuit disconnected before init completed — nothing to do.
}
catch (JSException ex)
{
// A genuine Monaco init failure — surface it instead of hiding it.
Logger.LogError(ex, "Monaco editor {EditorId} failed to initialize.", _id);
}
}
else if (_initialized && (Value ?? "") != _lastSentValue)
{
_lastSentValue = Value ?? "";
await SafeInvokeAsync("MonacoBlazor.setValue", "set editor value", _id, _lastSentValue);
}
}
/// <summary>
/// Invokes a Monaco JS function, swallowing the expected disconnect case but
/// logging any genuine JS error (CentralUI-018) so failures are not silent.
/// </summary>
private async ValueTask SafeInvokeAsync(string fn, string action, params object?[] args)
{
try
{
await JS.InvokeVoidAsync(fn, args);
}
catch (JSDisconnectedException)
{
// Circuit gone — the editor no longer exists; nothing to log.
}
catch (JSException ex)
{
Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to {Action}.", _id, action);
}
}
[JSInvokable]
public Task OnValueChanged(string newValue)
{
_lastSentValue = newValue ?? "";
return ValueChanged.InvokeAsync(_lastSentValue);
}
[JSInvokable]
public Task OnMarkersChanged(ScriptAnalysis.DiagnosticMarker[] markers) =>
MarkersChanged.InvokeAsync(markers ?? Array.Empty<ScriptAnalysis.DiagnosticMarker>());
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
public async Task RevealLineAsync(int line, int column = 1)
{
if (!_initialized) return;
await SafeInvokeAsync("MonacoBlazor.revealLine", "reveal line", _id, line, column);
}
/// <summary>
/// Called from JS at completion-request time so the form's latest state is
/// passed through, not whatever was captured when the editor was created.
/// </summary>
[JSInvokable]
public ScadaContext GetContext() => new(
DeclaredParameters?.ToArray() ?? Array.Empty<string>(),
SiblingScripts?.ToArray() ?? Array.Empty<ScriptAnalysis.ScriptShape>(),
DeclaredParameterShapes?.ToArray()
?? DeclaredParameters?.Select(n => new ScriptAnalysis.ParameterShape(n, "Object", true)).ToArray()
?? Array.Empty<ScriptAnalysis.ParameterShape>(),
SelfAttributes?.ToArray() ?? Array.Empty<ScriptAnalysis.AttributeShape>(),
Children?.ToArray() ?? Array.Empty<ScriptAnalysis.CompositionContext>(),
Parent,
ScriptKind);
private async Task FormatAsync()
{
if (!_initialized) return;
await SafeInvokeAsync("MonacoBlazor.format", "format document", _id);
}
private async Task ToggleWrap()
{
_wrap = !_wrap;
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle word wrap", _id, "wordWrap", _wrap ? "on" : "off");
}
private async Task ToggleMinimap()
{
_minimap = !_minimap;
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle minimap", _id, "minimap", new { enabled = _minimap });
}
private async Task ToggleTheme()
{
_dark = !_dark;
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle theme", _id, "theme", _dark ? "vs-dark" : "vs");
}
public async ValueTask DisposeAsync()
{
if (_initialized)
{
// Disposal commonly races a circuit disconnect — JSDisconnectedException
// here is expected and silent; a real JSException is still logged.
await SafeInvokeAsync("MonacoBlazor.dispose", "dispose editor", _id);
}
_dotNetRef?.Dispose();
}
public record ScadaContext(
string[] DeclaredParameters,
ScriptAnalysis.ScriptShape[] SiblingScripts,
ScriptAnalysis.ParameterShape[] DeclaredParameterShapes,
ScriptAnalysis.AttributeShape[] SelfAttributes,
ScriptAnalysis.CompositionContext[] Children,
ScriptAnalysis.CompositionContext? Parent,
ScriptAnalysis.ScriptKind ScriptKind);
}
@@ -0,0 +1,58 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Move '@FolderName' to…</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<select class="form-select form-select-sm" @bind="_targetParentId">
@foreach (var opt in FolderOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int FolderId { get; set; }
[Parameter] public string FolderName { get; set; } = string.Empty;
[Parameter] public IEnumerable<(int? Id, string Label)> FolderOptions { get; set; } = Array.Empty<(int?, string)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int FolderId, int? NewParentId)> OnSubmit { get; set; }
private bool _wasVisible;
private int? _targetParentId;
protected override void OnParametersSet()
{
if (IsVisible && !_wasVisible)
{
_targetParentId = null;
}
_wasVisible = IsVisible;
}
private async Task Close()
{
await IsVisibleChanged.InvokeAsync(false);
}
private async Task Submit()
{
await OnSubmit.InvokeAsync((FolderId, _targetParentId));
}
}
@@ -0,0 +1,59 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Move '@TemplateName' to…</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<select class="form-select form-select-sm" @bind="_targetFolderId">
@foreach (var opt in FolderOptions)
{
<option value="@opt.Id">@opt.Label</option>
}
</select>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int TemplateId { get; set; }
[Parameter] public string TemplateName { get; set; } = string.Empty;
[Parameter] public IEnumerable<(int? Id, string Label)> FolderOptions { get; set; } = Array.Empty<(int?, string)>();
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int TemplateId, int? NewFolderId)> OnSubmit { get; set; }
private bool _wasVisible;
private int? _targetFolderId;
protected override void OnParametersSet()
{
// Reset internal state on transition from hidden -> visible.
if (IsVisible && !_wasVisible)
{
_targetFolderId = null;
}
_wasVisible = IsVisible;
}
private async Task Close()
{
await IsVisibleChanged.InvokeAsync(false);
}
private async Task Submit()
{
await OnSubmit.InvokeAsync((TemplateId, _targetFolderId));
}
}
@@ -0,0 +1,40 @@
@typeparam TValue
@*
Compact multi-select control: a Bootstrap dropdown whose toggle button
summarises the current selection over a checkbox menu. Replaces a wrapped
block of chip buttons with a single control of one row's height.
*@
<div class="dropdown msd" data-test="@DataTest">
<button type="button"
class="btn btn-sm btn-outline-secondary dropdown-toggle msd-toggle text-start"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
disabled="@(Items.Count == 0)"
data-test="@($"{DataTest}-toggle")">
<span class="msd-summary">@Summary()</span>
</button>
<ul class="dropdown-menu msd-menu">
@if (Items.Count == 0)
{
<li><span class="dropdown-item-text text-muted small">@EmptyText</span></li>
}
else
{
@foreach (var item in Items)
{
var isSelected = Selected.Contains(item);
<li>
<label class="dropdown-item msd-item">
<input type="checkbox"
class="form-check-input msd-check"
checked="@isSelected"
@onchange="() => Toggle(item)"
data-test="@($"{DataTest}-opt-{item}")" />
<span>@Display(item)</span>
</label>
</li>
}
}
</ul>
</div>
@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Components;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// A compact multi-select control: a Bootstrap dropdown whose toggle button
/// summarises the current selection ("All" when empty, the single item's label
/// when one is picked, or "N selected" otherwise) over a checkbox menu.
///
/// <para>
/// It exists to keep multi-value filter controls one row tall instead of a
/// wrapped block of chip buttons. The component mutates the caller-owned
/// <see cref="Selected"/> collection in place and raises
/// <see cref="SelectionChanged"/> after every toggle so the parent can react
/// (re-render, prune dependent selections, …).
/// </para>
///
/// <para>
/// Requires the Bootstrap JS bundle (loaded in <c>App.razor</c>) for the
/// dropdown toggle; <c>data-bs-auto-close="outside"</c> keeps the menu open
/// while the operator ticks several boxes.
/// </para>
/// </summary>
/// <typeparam name="TValue">The option value type (an enum or string).</typeparam>
public partial class MultiSelectDropdown<TValue> where TValue : notnull
{
/// <summary>The options shown in the menu, in display order.</summary>
[Parameter, EditorRequired]
public IReadOnlyList<TValue> Items { get; set; } = Array.Empty<TValue>();
/// <summary>
/// The caller-owned selection set. Mutated in place by <see cref="Toggle"/>.
/// </summary>
[Parameter, EditorRequired]
public ICollection<TValue> Selected { get; set; } = default!;
/// <summary>Maps an option to its display label. Defaults to <c>ToString()</c>.</summary>
[Parameter]
public Func<TValue, string> Display { get; set; } = static v => v.ToString() ?? string.Empty;
/// <summary>Raised after each toggle, once <see cref="Selected"/> has been updated.</summary>
[Parameter]
public EventCallback SelectionChanged { get; set; }
/// <summary>Summary text shown on the toggle button when nothing is selected.</summary>
[Parameter]
public string AllLabel { get; set; } = "All";
/// <summary>Text shown in the menu when there are no options.</summary>
[Parameter]
public string EmptyText { get; set; } = "None available";
/// <summary><c>data-test</c> root for this control, its toggle and its options.</summary>
[Parameter]
public string DataTest { get; set; } = "multi-select";
private async Task Toggle(TValue item)
{
// ICollection.Remove returns false when the item was absent — that is the
// "not currently selected" case, so add it. This is a plain toggle.
if (!Selected.Remove(item))
{
Selected.Add(item);
}
await SelectionChanged.InvokeAsync();
}
private string Summary()
{
var count = Selected.Count;
if (count == 0)
{
return AllLabel;
}
if (count == 1)
{
// Prefer the single selection's label over a bare "1 selected".
foreach (var item in Items)
{
if (Selected.Contains(item))
{
return Display(item);
}
}
// The one selected value is not in the current Items list (e.g. a Kind
// narrowed out by a Channel change before the parent pruned it).
return "1 selected";
}
return $"{count} selected";
}
}
@@ -0,0 +1,32 @@
/* Compact multi-select dropdown. Tuned to sit inline with form-select-sm /
form-control-sm controls in a filter row. */
.msd-toggle {
min-width: 9rem;
max-width: 15rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Keep a long option list from running off-screen — scroll within the menu. */
.msd-menu {
max-height: 16rem;
overflow-y: auto;
}
/* The whole row is a <label> so a click anywhere toggles the checkbox; the
menu stays open thanks to data-bs-auto-close="outside". */
.msd-item {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
/* Neutralise the default form-check-input top margin so the box lines up with
the option text inside the dropdown-item. */
.msd-check {
flex: 0 0 auto;
margin: 0;
}
@@ -0,0 +1,14 @@
<div class="d-flex align-items-center justify-content-center min-vh-100">
<div class="card shadow-sm" style="max-width: 480px; width: 100%;">
<div class="card-body p-4">
<h4 class="card-title mb-3 text-center">ScadaBridge</h4>
<div class="alert alert-warning" role="alert">
<h5 class="alert-heading">Not Authorized</h5>
<p class="mb-0">You do not have permission to access this page. Contact your administrator if you believe this is an error.</p>
</div>
<div class="d-flex justify-content-center">
<a href="/" class="btn btn-outline-primary btn-sm">Return to Dashboard</a>
</div>
</div>
</div>
</div>
@@ -0,0 +1,61 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Pure helper for windowed pagination (CentralUI-016). Computes the set of
/// page numbers a pager should render: always the first and last page plus a
/// small range around the current page, with the rest elided. Keeps the
/// rendered button count bounded regardless of the total page count, instead
/// of emitting one <c>&lt;li&gt;</c> per page.
/// </summary>
public static class PagerWindow
{
/// <summary>
/// Returns the page numbers to render. A value of <c>0</c> in the result is
/// an ellipsis placeholder (a gap between non-contiguous page numbers).
/// <paramref name="radius"/> is how many pages to show on each side of the
/// current page.
/// </summary>
/// <param name="currentPage">The currently active page (1-based).</param>
/// <param name="totalPages">The total number of pages.</param>
/// <param name="radius">Number of page buttons to show on each side of the current page; default 2.</param>
/// <returns>An ordered list of page numbers and <c>0</c> ellipsis placeholders.</returns>
public static IReadOnlyList<int> Build(int currentPage, int totalPages, int radius = 2)
{
if (totalPages <= 1)
{
return totalPages == 1 ? new[] { 1 } : Array.Empty<int>();
}
currentPage = Math.Clamp(currentPage, 1, totalPages);
// Small enough that windowing buys nothing — render every page.
var maxUnwindowed = 2 * radius + 5;
if (totalPages <= maxUnwindowed)
{
return Enumerable.Range(1, totalPages).ToList();
}
var pages = new SortedSet<int> { 1, totalPages };
for (var p = currentPage - radius; p <= currentPage + radius; p++)
{
if (p >= 1 && p <= totalPages)
{
pages.Add(p);
}
}
// Walk the sorted set, inserting an ellipsis (0) wherever a gap exists.
var result = new List<int>();
var previous = 0;
foreach (var page in pages)
{
if (previous != 0 && page - previous > 1)
{
result.Add(0); // ellipsis
}
result.Add(page);
previous = page;
}
return result;
}
}
@@ -0,0 +1,180 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@using System.Text.Json
@*
Renders an input row per declared parameter so the user can supply values
for a script test run. Primitive types get typed inputs (text / number /
checkbox); Object and List fall back to a JSON textarea with inline parse
errors. The companion SchemaBuilder edits the schema; this edits values.
*@
@if (Shapes.Count == 0)
{
<div class="text-muted small fst-italic">No parameters declared.</div>
}
else
{
<div class="d-flex flex-column gap-2">
@foreach (var shape in Shapes)
{
<div class="row g-2 align-items-center">
<div class="col-sm-4">
<label class="form-label small mb-0" for="@FieldId(shape)">
<code>@shape.Name</code>
<span class="text-muted ms-1">@shape.Type@(shape.Required ? "" : "?")</span>
</label>
</div>
<div class="col-sm-8">
@RenderInput(shape)
@if (_parseErrors.TryGetValue(shape.Name, out var err))
{
<div class="text-danger small mt-1">@err</div>
}
</div>
</div>
}
</div>
}
@code {
[Parameter] public string? ParameterDefinitions { get; set; }
[Parameter] public Dictionary<string, object?> Values { get; set; } = new();
[Parameter] public EventCallback<Dictionary<string, object?>> ValuesChanged { get; set; }
private IReadOnlyList<ParameterShape> Shapes =>
ScriptParameterNames.ParseShapes(ParameterDefinitions);
private readonly Dictionary<string, string> _rawText = new();
private readonly Dictionary<string, string> _parseErrors = new();
private static string FieldId(ParameterShape shape) => $"param-{shape.Name}";
private RenderFragment RenderInput(ParameterShape shape) => __builder =>
{
switch (shape.Type)
{
case "Boolean":
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@FieldId(shape)"
checked="@AsBool(shape.Name)"
@onchange="e => SetBool(shape.Name, (bool)(e.Value ?? false))" />
</div>
break;
case "Integer":
<input class="form-control form-control-sm" type="number" step="1" id="@FieldId(shape)"
value="@AsRaw(shape.Name)"
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: true)" />
break;
case "Float":
<input class="form-control form-control-sm" type="number" step="any" id="@FieldId(shape)"
value="@AsRaw(shape.Name)"
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: false)" />
break;
case "String":
<input class="form-control form-control-sm" type="text" id="@FieldId(shape)"
value="@AsRaw(shape.Name)"
@oninput="e => SetString(shape.Name, (string?)e.Value)" />
break;
default: // Object, List, List<...>, unknown
<textarea class="form-control form-control-sm font-monospace" rows="3" id="@FieldId(shape)"
placeholder='@($"JSON {shape.Type.ToLowerInvariant()}")'
@oninput="e => SetJson(shape.Name, (string?)e.Value)">@AsRaw(shape.Name)</textarea>
break;
}
};
private string AsRaw(string name) =>
_rawText.TryGetValue(name, out var raw) ? raw : "";
private bool AsBool(string name) =>
Values.TryGetValue(name, out var v) && v is bool b && b;
private async Task SetString(string name, string? raw)
{
_rawText[name] = raw ?? "";
_parseErrors.Remove(name);
Values[name] = raw ?? "";
await ValuesChanged.InvokeAsync(Values);
}
private async Task SetBool(string name, bool value)
{
_parseErrors.Remove(name);
Values[name] = value;
await ValuesChanged.InvokeAsync(Values);
}
private async Task SetNumeric(string name, string? raw, bool integerOnly)
{
_rawText[name] = raw ?? "";
if (string.IsNullOrWhiteSpace(raw))
{
_parseErrors.Remove(name);
Values.Remove(name);
await ValuesChanged.InvokeAsync(Values);
return;
}
if (integerOnly && long.TryParse(raw, out var i))
{
_parseErrors.Remove(name);
Values[name] = i;
}
else if (!integerOnly && double.TryParse(raw,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var d))
{
_parseErrors.Remove(name);
Values[name] = d;
}
else
{
_parseErrors[name] = integerOnly ? "Not a valid integer." : "Not a valid number.";
Values.Remove(name);
}
await ValuesChanged.InvokeAsync(Values);
}
private async Task SetJson(string name, string? raw)
{
_rawText[name] = raw ?? "";
if (string.IsNullOrWhiteSpace(raw))
{
_parseErrors.Remove(name);
Values.Remove(name);
await ValuesChanged.InvokeAsync(Values);
return;
}
try
{
using var doc = JsonDocument.Parse(raw);
Values[name] = JsonElementToObject(doc.RootElement.Clone());
_parseErrors.Remove(name);
}
catch (JsonException ex)
{
_parseErrors[name] = $"JSON parse error: {ex.Message}";
Values.Remove(name);
}
await ValuesChanged.InvokeAsync(Values);
}
private static object? JsonElementToObject(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt64(out var i) ? (object)i : element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(),
JsonValueKind.Object => element.EnumerateObject()
.ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
_ => null
};
}
}
@@ -0,0 +1,68 @@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@if (Markers.Count > 0)
{
<div class="card mt-2 mb-3">
<div class="card-header py-1 small d-flex justify-content-between align-items-center">
<span>
@if (_errorCount > 0)
{
<span class="badge bg-danger me-1">@_errorCount error@(_errorCount == 1 ? "" : "s")</span>
}
@if (_warningCount > 0)
{
<span class="badge bg-warning text-dark me-1">@_warningCount warning@(_warningCount == 1 ? "" : "s")</span>
}
@if (_infoCount > 0)
{
<span class="badge bg-info text-dark me-1">@_infoCount info</span>
}
</span>
<span class="text-muted">Problems</span>
</div>
<ul class="list-unstyled mb-0 small" style="max-height: 180px; overflow-y: auto;">
@foreach (var m in Markers)
{
<li class="d-flex gap-2 align-items-start px-2 py-1 border-bottom">
<span class="badge @SeverityBadge(m.Severity)" style="min-width: 60px;">@SeverityLabel(m.Severity)</span>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none flex-grow-1 text-start"
@onclick="@(() => OnNavigate.InvokeAsync(m))">
<span class="text-muted me-2">Line @m.StartLineNumber</span>@m.Message
</button>
<code class="text-muted small">@m.Code</code>
</li>
}
</ul>
</div>
}
@code {
[Parameter, EditorRequired] public IReadOnlyList<DiagnosticMarker> Markers { get; set; } = Array.Empty<DiagnosticMarker>();
[Parameter] public EventCallback<DiagnosticMarker> OnNavigate { get; set; }
private int _errorCount;
private int _warningCount;
private int _infoCount;
protected override void OnParametersSet()
{
_errorCount = Markers.Count(m => m.Severity >= 8);
_warningCount = Markers.Count(m => m.Severity == 4);
_infoCount = Markers.Count(m => m.Severity > 0 && m.Severity < 4);
}
private static string SeverityBadge(int sev) => sev switch
{
>= 8 => "bg-danger",
4 => "bg-warning text-dark",
_ => "bg-info text-dark"
};
private static string SeverityLabel(int sev) => sev switch
{
>= 8 => "Error",
4 => "Warning",
_ => "Info"
};
}
@@ -0,0 +1,8 @@
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
{
Navigation.NavigateTo("/login", forceLoad: true);
}
}
@@ -0,0 +1,53 @@
@if (IsVisible)
{
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Rename Folder</h6>
<button type="button" class="btn-close" @onclick="Close"></button>
</div>
<div class="modal-body">
<input class="form-control form-control-sm" @bind="_name" />
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Save</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int FolderId { get; set; }
[Parameter] public string InitialName { get; set; } = string.Empty;
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int FolderId, string NewName)> OnSubmit { get; set; }
private bool _wasVisible;
private string _name = string.Empty;
protected override void OnParametersSet()
{
// Reset internal state on transition from hidden -> visible.
if (IsVisible && !_wasVisible)
{
_name = InitialName;
}
_wasVisible = IsVisible;
}
private async Task Close()
{
await IsVisibleChanged.InvokeAsync(false);
}
private async Task Submit()
{
await OnSubmit.InvokeAsync((FolderId, _name.Trim()));
}
}
@@ -0,0 +1,207 @@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@* Bootstrap-only JSON Schema editor. Two modes:
- "object" parameters: edits a top-level object schema (named properties).
- "value" return type: edits a single value schema; object/array fall back
to the same property editor as Mode=object.
Recurses through methods (not nested components) so we stay in one file. *@
@if (_root.Type == "object" && Mode == "object")
{
@PropertyList(_root, isRoot: true)
}
else
{
@ValueRoot(_root)
}
@code {
/// <summary><c>"object"</c> for parameters, <c>"value"</c> for return type.</summary>
[Parameter] public string Mode { get; set; } = "object";
/// <summary>JSON Schema text. Empty/null seeds the mode's default.</summary>
[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
private SchemaNode _root = new();
private string? _lastSeenJson;
private bool _initialized;
protected override void OnParametersSet()
{
// OnInitialized fires before this on first mount; OnParametersSet runs
// on every parameter change. Guard against the initial null==null case
// where the early-exit would skip applying the mode-appropriate default.
if (_initialized && Value == _lastSeenJson) return;
_initialized = true;
_lastSeenJson = Value;
_root = SchemaBuilderModel.Parse(
Value,
Mode == "object" ? SchemaBuilderModel.NewObject() : SchemaBuilderModel.NewValue());
}
private async Task Emit()
{
var json = SchemaBuilderModel.Serialize(_root);
_lastSeenJson = json;
await ValueChanged.InvokeAsync(json);
}
private async Task OnTypeChange(SchemaNode node)
{
if (node.Type == "array" && node.Items == null)
node.Items = new SchemaNode { Type = "string" };
await Emit();
}
private async Task AddProperty(SchemaNode parent)
{
parent.Properties.Add(new SchemaProperty { Schema = new SchemaNode { Type = "string" } });
await Emit();
}
private async Task RemoveProperty(SchemaNode parent, SchemaProperty prop)
{
parent.Properties.Remove(prop);
await Emit();
}
// ── Render helpers ─────────────────────────────────────────────────────────
/// <summary>
/// Renders the property list for an object schema node. <paramref name="isRoot"/>
/// just tweaks the wording on the Add button ("parameter" at root vs "field"
/// inside a nested object).
/// </summary>
private RenderFragment PropertyList(SchemaNode node, bool isRoot = false) => __builder =>
{
<div class="border rounded bg-white p-2">
@if (node.Properties.Count == 0)
{
<div class="text-muted small fst-italic px-1 py-2">
@(isRoot ? "No parameters defined." : "No fields defined.")
</div>
}
@foreach (var prop in node.Properties)
{
<div @key="prop.Id" class="border rounded p-2 mb-2 bg-light-subtle">
@PropertyRow(node, prop)
@NestedEditor(prop.Schema)
</div>
}
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="() => AddProperty(node)">
+ Add @(isRoot ? "parameter" : "field")
</button>
</div>
};
/// <summary>
/// One property's compact horizontal row: name, type, (items type if array),
/// required toggle, remove button. Nested object / array-of-object editors
/// render below the row via <see cref="NestedEditor"/>.
/// </summary>
private RenderFragment PropertyRow(SchemaNode parent, SchemaProperty prop) => __builder =>
{
<div class="d-flex flex-wrap align-items-center gap-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 14rem;" placeholder="name"
@bind="prop.Name" @bind:event="oninput" @bind:after="Emit" />
<select class="form-select form-select-sm" style="max-width: 9rem;"
@bind="prop.Schema.Type" @bind:after="() => OnTypeChange(prop.Schema)">
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
{
<option value="@t">@t</option>
}
</select>
@if (prop.Schema.Type == "array")
{
<span class="small text-muted">items:</span>
<select class="form-select form-select-sm" style="max-width: 9rem;"
@bind="prop.Schema.Items!.Type" @bind:after="() => OnTypeChange(prop.Schema.Items!)">
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
{
<option value="@t">@t</option>
}
</select>
}
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" id="req-@prop.Id"
@bind="prop.Required" @bind:after="Emit" />
<label class="form-check-label small" for="req-@prop.Id">required</label>
</div>
<button type="button"
class="btn btn-link btn-sm text-danger p-0 ms-auto"
title="Remove" aria-label="Remove field"
@onclick="() => RemoveProperty(parent, prop)">
<i class="bi bi-x-lg"></i>
</button>
</div>
};
/// <summary>
/// Renders the indented sub-editor for object / array-of-object properties.
/// No-op for scalar properties.
/// </summary>
private RenderFragment NestedEditor(SchemaNode schema) => __builder =>
{
if (schema.Type == "object")
{
<div class="ms-3 mt-2">
@PropertyList(schema)
</div>
}
else if (schema.Type == "array" && schema.Items?.Type == "object")
{
<div class="ms-3 mt-2">
<div class="small text-muted mb-1">item properties:</div>
@PropertyList(schema.Items)
</div>
}
};
/// <summary>
/// Mode=value root: a single type picker. When the user picks <c>object</c>
/// or <c>array</c> we expose the same nested editors used by Mode=object.
/// </summary>
private RenderFragment ValueRoot(SchemaNode node) => __builder =>
{
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<label class="form-label mb-0">Return type:</label>
<select class="form-select form-select-sm" style="max-width: 10rem;"
@bind="node.Type" @bind:after="() => OnTypeChange(node)">
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
{
<option value="@t">@t</option>
}
</select>
@if (node.Type == "array")
{
<label class="form-label mb-0 ms-2">Item type:</label>
<select class="form-select form-select-sm" style="max-width: 10rem;"
@bind="node.Items!.Type" @bind:after="() => OnTypeChange(node.Items!)">
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
{
<option value="@t">@t</option>
}
</select>
}
</div>
@if (node.Type == "object")
{
<div class="text-muted small mb-1">Properties of return value:</div>
@PropertyList(node)
}
else if (node.Type == "array" && node.Items?.Type == "object")
{
<div class="text-muted small mb-1">Item properties:</div>
@PropertyList(node.Items)
}
};
}
@@ -0,0 +1,213 @@
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// In-memory JSON Schema tree used by <see cref="SchemaBuilder"/>. The editor
/// mutates this graph directly; <see cref="SchemaBuilderModel"/> handles
/// parse / serialize round-tripping to the canonical JSON Schema text stored
/// in TemplateScript / SharedScript / ApiMethod columns.
/// </summary>
internal sealed class SchemaNode
{
/// <summary>One of: <c>string · integer · number · boolean · object · array</c>.</summary>
public string Type { get; set; } = "string";
/// <summary>For <c>type=array</c>: the schema of the array's items.</summary>
public SchemaNode? Items { get; set; }
/// <summary>For <c>type=object</c>: ordered list of named properties.</summary>
public List<SchemaProperty> Properties { get; } = new();
}
internal sealed class SchemaProperty
{
/// <summary>Stable identity for Blazor <c>@key</c> across renames.</summary>
public Guid Id { get; } = Guid.NewGuid();
/// <summary>Property name as it appears in the JSON Schema <c>properties</c> map.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>When true, the property is listed in the JSON Schema <c>required</c> array.</summary>
public bool Required { get; set; } = true;
/// <summary>The JSON Schema node describing this property's type and structure.</summary>
public SchemaNode Schema { get; set; } = new();
}
internal static class SchemaBuilderModel
{
public static readonly string[] PrimitiveTypes =
{ "string", "integer", "number", "boolean", "object", "array" };
/// <summary>
/// Parse a JSON Schema string into a <see cref="SchemaNode"/> tree.
/// Returns the supplied <paramref name="fallback"/> when the input is
/// empty or malformed. Also accepts the legacy flat-array parameter
/// shape (<c>[{name,type,required,itemType?}]</c>) for safety during the
/// transition window — translates it into an equivalent object schema.
/// </summary>
/// <param name="json">JSON Schema string to parse, or null/empty to return the fallback.</param>
/// <param name="fallback">The <see cref="SchemaNode"/> to return when the input cannot be parsed.</param>
public static SchemaNode Parse(string? json, SchemaNode fallback)
{
if (string.IsNullOrWhiteSpace(json)) return fallback;
try
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.ValueKind switch
{
JsonValueKind.Object => ParseSchema(doc.RootElement),
JsonValueKind.Array => ParseLegacyArray(doc.RootElement),
_ => fallback,
};
}
catch
{
return fallback;
}
}
/// <summary>Default empty object schema (parameters mode default).</summary>
public static SchemaNode NewObject() => new() { Type = "object" };
/// <summary>Default scalar schema (return mode default).</summary>
public static SchemaNode NewValue() => new() { Type = "string" };
/// <summary>
/// Serializes a <see cref="SchemaNode"/> tree to its canonical JSON Schema string.
/// </summary>
/// <param name="node">The schema node to serialize.</param>
public static string Serialize(SchemaNode node)
{
using var stream = new System.IO.MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
WriteNode(writer, node);
}
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
}
// ── Parse helpers ─────────────────────────────────────────────────────────
private static SchemaNode ParseSchema(JsonElement el)
{
var node = new SchemaNode { Type = "string" };
if (el.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String)
{
node.Type = NormalizeType(t.GetString());
}
if (node.Type == "array")
{
node.Items = el.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object
? ParseSchema(items)
: new SchemaNode { Type = "string" };
}
else if (node.Type == "object")
{
var requiredSet = new HashSet<string>(StringComparer.Ordinal);
if (el.TryGetProperty("required", out var req) && req.ValueKind == JsonValueKind.Array)
{
foreach (var r in req.EnumerateArray())
{
if (r.ValueKind == JsonValueKind.String)
{
var s = r.GetString();
if (!string.IsNullOrEmpty(s)) requiredSet.Add(s);
}
}
}
if (el.TryGetProperty("properties", out var props) && props.ValueKind == JsonValueKind.Object)
{
foreach (var prop in props.EnumerateObject())
{
node.Properties.Add(new SchemaProperty
{
Name = prop.Name,
Required = requiredSet.Contains(prop.Name),
Schema = prop.Value.ValueKind == JsonValueKind.Object
? ParseSchema(prop.Value)
: new SchemaNode { Type = "string" },
});
}
}
}
return node;
}
private static SchemaNode ParseLegacyArray(JsonElement arr)
{
var root = new SchemaNode { Type = "object" };
foreach (var item in arr.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object) continue;
var name = item.TryGetProperty("name", out var n) ? n.GetString() : null;
if (string.IsNullOrEmpty(name)) continue;
var rawType = item.TryGetProperty("type", out var t) ? t.GetString() : "string";
var required = !item.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False;
var schema = new SchemaNode { Type = NormalizeType(rawType) };
if (schema.Type == "array")
{
var inner = item.TryGetProperty("itemType", out var it) ? it.GetString() : "string";
schema.Items = new SchemaNode { Type = NormalizeType(inner) };
}
root.Properties.Add(new SchemaProperty
{
Name = name,
Required = required,
Schema = schema,
});
}
return root;
}
private static string NormalizeType(string? raw) => raw?.ToLowerInvariant() switch
{
"boolean" or "bool" => "boolean",
"integer" or "int" or "int32" or "int64" => "integer",
"number" or "float" or "double" or "decimal" => "number",
"string" or "datetime" => "string",
"object" => "object",
"array" or "list" => "array",
_ => "string",
};
// ── Serialize helpers ─────────────────────────────────────────────────────
private static void WriteNode(Utf8JsonWriter w, SchemaNode node)
{
w.WriteStartObject();
w.WriteString("type", node.Type);
if (node.Type == "array")
{
w.WritePropertyName("items");
WriteNode(w, node.Items ?? new SchemaNode { Type = "string" });
}
else if (node.Type == "object")
{
w.WritePropertyName("properties");
w.WriteStartObject();
foreach (var p in node.Properties.Where(p => !string.IsNullOrWhiteSpace(p.Name)))
{
w.WritePropertyName(p.Name);
WriteNode(w, p.Schema);
}
w.WriteEndObject();
var required = node.Properties
.Where(p => p.Required && !string.IsNullOrWhiteSpace(p.Name))
.Select(p => p.Name)
.ToArray();
if (required.Length > 0)
{
w.WritePropertyName("required");
w.WriteStartArray();
foreach (var r in required) w.WriteStringValue(r);
w.WriteEndArray();
}
}
w.WriteEndObject();
}
}
@@ -0,0 +1,28 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Parses the parameter-definitions JSON Schema written by SchemaBuilder and
/// returns the declared parameter names (and shapes). Used by script-edit
/// pages to feed the Monaco editor's Parameters["..."] context.
/// </summary>
public static class ScriptParameterNames
{
/// <summary>
/// Parses a parameter definitions JSON Schema and returns the declared parameter names.
/// </summary>
/// <param name="json">JSON Schema or legacy flat-array string; null/empty returns an empty list.</param>
public static IReadOnlyList<string> Parse(string? json) =>
JsonSchemaShapeParser.ParseParameters(json)
.Select(p => p.Name)
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
/// <summary>
/// Parses a parameter definitions JSON Schema and returns the full parameter shape objects.
/// </summary>
/// <param name="json">JSON Schema or legacy flat-array string; null/empty returns an empty list.</param>
public static IReadOnlyList<ParameterShape> ParseShapes(string? json) =>
JsonSchemaShapeParser.ParseParameters(json);
}
@@ -0,0 +1,252 @@
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Which kind of trigger a template script has. <see cref="None"/> is no
/// trigger; <see cref="Unknown"/> is a stored trigger-type string the runtime
/// does not recognize (preserved as-is by the editor).
/// </summary>
internal enum ScriptTriggerKind { None, Interval, ValueChange, Conditional, Call, Expression, Unknown }
/// <summary>
/// When a Conditional/Expression trigger fires. <see cref="OnTrue"/> fires once
/// as the condition becomes true; <see cref="WhileTrue"/> re-fires on a timer
/// (cadence = the script's MinTimeBetweenRuns) while the condition stays true.
/// </summary>
internal enum ScriptTriggerMode { OnTrue, WhileTrue }
/// <summary>A script's trigger as the editor emits it: a type string + config JSON.</summary>
public sealed record ScriptTriggerValue(string? TriggerType, string? Config);
/// <summary>Parsed/editable view of a script trigger's configuration.</summary>
internal sealed class ScriptTriggerModel
{
/// <summary>Interval period in milliseconds.</summary>
public long? IntervalMs { get; set; }
/// <summary>Monitored attribute (ValueChange + Conditional).</summary>
public string? AttributeName { get; set; }
/// <summary>Comparison operator (Conditional) — one of <see cref="ScriptTriggerConfigCodec.Operators"/>.</summary>
public string Operator { get; set; } = ">";
/// <summary>Comparison threshold (Conditional).</summary>
public double? Threshold { get; set; }
/// <summary>Boolean C# expression (Expression).</summary>
public string? Expression { get; set; }
/// <summary>Fire mode (Conditional + Expression). Defaults to <see cref="ScriptTriggerMode.OnTrue"/>.</summary>
public ScriptTriggerMode Mode { get; set; } = ScriptTriggerMode.OnTrue;
}
/// <summary>
/// Round-trip codec for a template script's <c>TriggerType</c> +
/// <c>TriggerConfiguration</c>, shared by <see cref="ScriptTriggerEditor"/> (UI
/// editing) and consumed at the site by <c>ScriptActor.ParseTriggerConfig</c>.
/// Serialized config shapes:
/// Interval { intervalMs }
/// ValueChange { attributeName }
/// Conditional { attributeName, operator, threshold, mode }
/// Call { }
/// Expression { expression, mode }
///
/// <c>mode</c> (Conditional + Expression) is <c>OnTrue</c> or <c>WhileTrue</c>;
/// an absent or unrecognized value parses as <c>OnTrue</c>.
///
/// Parsing also accepts the legacy aliases <c>attribute</c> and <c>value</c> so
/// older configs survive a round-trip through the editor.
/// </summary>
internal static class ScriptTriggerConfigCodec
{
/// <summary>The six comparison operators <c>ScriptActor.EvaluateCondition</c> accepts.</summary>
internal static readonly string[] Operators = { ">", ">=", "<", "<=", "==", "!=" };
/// <summary>Classifies a raw <c>TriggerType</c> string (case-insensitive).</summary>
/// <param name="triggerType">The raw trigger type string from the template script entity.</param>
internal static ScriptTriggerKind ParseKind(string? triggerType)
{
if (string.IsNullOrWhiteSpace(triggerType)) return ScriptTriggerKind.None;
return triggerType.Trim().ToLowerInvariant() switch
{
"interval" => ScriptTriggerKind.Interval,
"valuechange" => ScriptTriggerKind.ValueChange,
"conditional" => ScriptTriggerKind.Conditional,
"call" => ScriptTriggerKind.Call,
"expression" => ScriptTriggerKind.Expression,
_ => ScriptTriggerKind.Unknown
};
}
/// <summary>
/// Whether a trigger type honours the script's <c>MinTimeBetweenRuns</c>.
/// True for the auto-firing triggers it throttles — ValueChange, Conditional,
/// Expression. False for Interval (its own period is the cadence), Call
/// (invoked explicitly, never throttled), and None/Unknown.
/// </summary>
/// <param name="triggerType">The raw trigger type string to classify.</param>
internal static bool SupportsMinTimeBetweenRuns(string? triggerType) =>
ParseKind(triggerType) is ScriptTriggerKind.ValueChange
or ScriptTriggerKind.Conditional
or ScriptTriggerKind.Expression;
/// <summary>Canonical <c>TriggerType</c> string for a kind; null for None/Unknown.</summary>
/// <param name="kind">The trigger kind to convert.</param>
internal static string? KindToString(ScriptTriggerKind kind) => kind switch
{
ScriptTriggerKind.Interval => "Interval",
ScriptTriggerKind.ValueChange => "ValueChange",
ScriptTriggerKind.Conditional => "Conditional",
ScriptTriggerKind.Call => "Call",
ScriptTriggerKind.Expression => "Expression",
_ => null
};
/// <summary>
/// Parses a trigger configuration JSON in the context of the given kind.
/// Returns a model with default values on null/empty/malformed input or for
/// missing keys — never throws.
/// </summary>
/// <param name="json">The raw JSON trigger configuration string.</param>
/// <param name="kind">The trigger kind, used to determine which fields to parse.</param>
internal static ScriptTriggerModel Parse(string? json, ScriptTriggerKind kind)
{
var model = new ScriptTriggerModel();
if (string.IsNullOrWhiteSpace(json)) return model;
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
switch (kind)
{
case ScriptTriggerKind.Interval:
model.IntervalMs = TryReadLong(root, "intervalMs");
break;
case ScriptTriggerKind.ValueChange:
model.AttributeName = TryReadAttributeName(root);
break;
case ScriptTriggerKind.Conditional:
model.AttributeName = TryReadAttributeName(root);
var op = root.TryGetProperty("operator", out var o) ? o.GetString() : null;
model.Operator = NormalizeOperator(op);
model.Threshold = TryReadDouble(root, "threshold") ?? TryReadDouble(root, "value");
model.Mode = ReadMode(root);
break;
case ScriptTriggerKind.Expression:
model.Expression = root.TryGetProperty("expression", out var e) ? e.GetString() : null;
model.Mode = ReadMode(root);
break;
}
}
catch (JsonException)
{
// Malformed JSON — fall through with default model.
}
return model;
}
/// <summary>
/// Serializes the model to the JSON shape <c>ScriptActor.ParseTriggerConfig</c>
/// expects. Returns null for None/Unknown (no structured config to emit).
/// </summary>
/// <param name="model">The trigger model to serialize.</param>
/// <param name="kind">The trigger kind, used to determine which fields to emit.</param>
internal static string? Serialize(ScriptTriggerModel model, ScriptTriggerKind kind)
{
if (kind is ScriptTriggerKind.None or ScriptTriggerKind.Unknown) return null;
using var stream = new MemoryStream();
using (var w = new Utf8JsonWriter(stream))
{
w.WriteStartObject();
switch (kind)
{
case ScriptTriggerKind.Interval:
if (model.IntervalMs.HasValue)
w.WriteNumber("intervalMs", model.IntervalMs.Value);
break;
case ScriptTriggerKind.ValueChange:
w.WriteString("attributeName", model.AttributeName ?? "");
break;
case ScriptTriggerKind.Conditional:
w.WriteString("attributeName", model.AttributeName ?? "");
w.WriteString("operator", model.Operator);
if (model.Threshold.HasValue)
w.WriteNumber("threshold", model.Threshold.Value);
w.WriteString("mode", model.Mode.ToString());
break;
case ScriptTriggerKind.Expression:
w.WriteString("expression", model.Expression ?? "");
w.WriteString("mode", model.Mode.ToString());
break;
// Call → empty object.
}
w.WriteEndObject();
}
return Encoding.UTF8.GetString(stream.ToArray());
}
/// <summary>
/// Reads the optional <c>mode</c> property; an absent or unrecognized value
/// (case-insensitive) yields <see cref="ScriptTriggerMode.OnTrue"/>.
/// </summary>
private static ScriptTriggerMode ReadMode(JsonElement root)
{
var raw = root.TryGetProperty("mode", out var m) ? m.GetString() : null;
return string.Equals(raw?.Trim(), "WhileTrue", StringComparison.OrdinalIgnoreCase)
? ScriptTriggerMode.WhileTrue
: ScriptTriggerMode.OnTrue;
}
/// <summary>Returns <paramref name="raw"/> if it is a recognized operator, else "&gt;".</summary>
/// <param name="raw">The raw operator string to normalize.</param>
internal static string NormalizeOperator(string? raw)
{
var op = raw?.Trim();
return op != null && Array.IndexOf(Operators, op) >= 0 ? op : ">";
}
private static string? TryReadAttributeName(JsonElement root) =>
root.TryGetProperty("attributeName", out var a) ? a.GetString()
: root.TryGetProperty("attribute", out var a2) ? a2.GetString()
: null;
private static long? TryReadLong(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind switch
{
JsonValueKind.Number when p.TryGetInt64(out var i) => i,
JsonValueKind.Number => (long)p.GetDouble(),
JsonValueKind.String when long.TryParse(
p.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) => v,
_ => null
};
}
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
};
}
}
@@ -0,0 +1,413 @@
@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-white 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-light 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);
}
/// <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"] &gt; 80</code>.
</div>
};
private async Task OnExpressionChanged(string value)
{
_model.Expression = value;
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 &nbsp;(@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",
_ => ""
};
}
@@ -0,0 +1,121 @@
@implements IDisposable
@inject NavigationManager Navigation
@inject IJSRuntime JS
@code {
// CentralUI-005 / CentralUI-020: session expiry is a sliding window owned by
// the cookie authentication middleware (ZB.MOM.WW.ScadaBridge.Security AddCookie:
// ExpireTimeSpan = idle timeout, SlidingExpiration = true). An active user's
// cookie is continually renewed; an idle user's cookie lapses after the idle
// timeout. There is no fixed login-time deadline to redirect at.
//
// This component must NOT poll the Blazor AuthenticationStateProvider:
// CookieAuthenticationStateProvider serves a frozen constructor-time
// principal for the whole circuit (CentralUI-004), so the polled auth state
// can never transition to "expired" and the redirect would never fire
// (CentralUI-020).
//
// Instead it polls the server endpoint GET /auth/ping via fetch(). Being a
// normal HTTP request, the cookie middleware re-validates — and slides — the
// cookie on every hit, and answers 200 while the session is live or 401 once
// it has lapsed. A genuine idle user's circuit produces no other HTTP
// traffic, so once the cookie lapses the next ping returns 401 and this
// component redirects to /login. (The ping itself slides the cookie, but the
// poll interval is well under the idle timeout, so an idle session still
// lapses on schedule once the poll catches the lapsed state — the ping only
// ever observes expiry, it does not keep a dead session alive.)
/// <summary>Server endpoint that reports live session validity.</summary>
internal const string PingUrl = "/auth/ping";
/// <summary>HTTP status returned by <see cref="PingUrl"/> once the cookie has lapsed.</summary>
private const int Unauthorized = 401;
private const string ModulePath = "./_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/session-expiry.js";
/// <summary>How often the session validity is re-checked.</summary>
internal static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1);
private CancellationTokenSource? _cts;
private IJSObjectReference? _module;
protected override void OnInitialized()
{
// The login page uses the same layout, so this component renders there
// too. Polling/redirecting on /login → /login would loop.
if (IsOnLoginPage) return;
_cts = new CancellationTokenSource();
_ = PollSessionAsync(_cts.Token);
}
private bool IsOnLoginPage =>
Navigation.ToBaseRelativePath(Navigation.Uri)
.StartsWith("login", StringComparison.OrdinalIgnoreCase);
private async Task PollSessionAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try { await Task.Delay(PollInterval, token); }
catch (TaskCanceledException) { return; }
if (token.IsCancellationRequested) return;
await CheckSessionAsync();
}
}
/// <summary>
/// Runs one liveness check: pings the server and, if the session has lapsed
/// server-side (HTTP 401), redirects to the login page. Exposed for tests
/// (CentralUI-025) so the redirect path can be exercised without waiting on
/// the poll interval.
/// </summary>
internal async Task CheckSessionAsync()
{
if (IsOnLoginPage) return;
int status;
try
{
_module ??= await JS.InvokeAsync<IJSObjectReference>("import", ModulePath);
status = await _module.InvokeAsync<int>("ping", PingUrl);
}
catch (JSDisconnectedException)
{
// Circuit gone — nothing to redirect.
return;
}
catch (JSException)
{
// Network blip or fetch failure: treat as inconclusive and retry on
// the next poll rather than logging an authenticated user out on a
// transient error.
return;
}
if (status == Unauthorized)
{
await InvokeAsync(() => Navigation.NavigateTo("/login", forceLoad: true));
}
}
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
// The module reference is owned by the circuit's JS runtime; once the
// circuit is disposed disposing it would throw — fire-and-forget and
// swallow the expected disconnect.
if (_module is not null)
{
_ = DisposeModuleAsync(_module);
}
}
private static async Task DisposeModuleAsync(IJSObjectReference module)
{
try { await module.DisposeAsync(); }
catch (JSDisconnectedException) { /* circuit already gone */ }
}
}
@@ -0,0 +1,228 @@
@* Shared template folder/template tree.
Wraps TreeView<TemplateTreeNode> with template-folder-specific layout: folder
nodes carry their child folders + templates, leaves are templates. Used by:
- Templates page (Single mode, navigation on click)
- Transport Export wizard (Checkbox mode, bulk selection)
Optional ExtraTemplateChildren lets callers nest extra leaves (e.g. composition
slots) under a template without forking the component. *@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
<TreeView @ref="_tree" TItem="TemplateTreeNode"
Items="_visibleRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => (object)n.Key"
Selectable="@(SelectionMode == TreeViewSelectionMode.Single)"
SelectionMode="SelectionMode"
SelectedKeys="SelectedKeys"
SelectedKeysChanged="SelectedKeysChanged"
InitiallyExpanded="@(_initiallyExpanded)"
StorageKey="@StorageKey">
<NodeContent Context="node">
@if (NodeContent != null)
{
@NodeContent(node)
}
else
{
<span class="tv-glyph">
<i class="bi @(NodeGlyph(node))"></i>
</span>
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
title="@node.Name"
@onclick="() => InvokeNodeClick(node)">@node.Name</span>
@if (NodeExtras != null)
{
@NodeExtras(node)
}
}
</NodeContent>
<ContextMenu Context="node">
@if (ContextMenu != null)
{
@ContextMenu(node)
}
</ContextMenu>
<EmptyContent>
@if (EmptyContent != null)
{
@EmptyContent
}
else
{
<span class="text-muted fst-italic">No templates.</span>
}
</EmptyContent>
</TreeView>
@code {
[Parameter] public IReadOnlyList<TemplateFolder> Folders { get; set; } = Array.Empty<TemplateFolder>();
[Parameter] public IReadOnlyList<Template> Templates { get; set; } = Array.Empty<Template>();
[Parameter] public TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single;
[Parameter] public HashSet<object>? SelectedKeys { get; set; }
[Parameter] public EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }
[Parameter] public string Filter { get; set; } = "";
[Parameter] public RenderFragment<TemplateTreeNode>? NodeExtras { get; set; }
[Parameter] public RenderFragment<TemplateTreeNode>? NodeContent { get; set; }
[Parameter] public RenderFragment<TemplateTreeNode>? ContextMenu { get; set; }
[Parameter] public RenderFragment? EmptyContent { get; set; }
[Parameter] public EventCallback<TemplateTreeNode> OnNodeClick { get; set; }
[Parameter] public string? StorageKey { get; set; }
/// <summary>
/// Optional: caller-supplied extra leaves to nest under a template. Used by
/// the Templates page to surface composition slots; left null by the
/// Transport Export wizard (compositions aren't exportable as standalone
/// items, so the wizard's checkbox tree intentionally hides them).
/// </summary>
[Parameter] public Func<Template, IReadOnlyList<TemplateTreeNode>>? ExtraTemplateChildren { get; set; }
private TreeView<TemplateTreeNode>? _tree;
private List<TemplateTreeNode> _allRoots = new();
private List<TemplateTreeNode> _visibleRoots = new();
private HashSet<string>? _filterRevealed;
private Func<TemplateTreeNode, bool>? _initiallyExpanded;
protected override void OnParametersSet()
{
BuildTree();
ApplyFilter();
}
private void BuildTree()
{
var folderNodes = Folders.ToDictionary(
f => f.Id,
f => new TemplateTreeNode
{
Kind = TemplateTreeNodeKind.Folder,
Id = f.Id,
Name = f.Name,
});
var roots = new List<TemplateTreeNode>();
foreach (var f in Folders.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
{
var node = folderNodes[f.Id];
if (f.ParentFolderId is int pid && folderNodes.TryGetValue(pid, out var parent))
parent.Children.Add(node);
else
roots.Add(node);
}
foreach (var t in Templates
.Where(t => !t.IsDerived)
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
{
var tNode = new TemplateTreeNode
{
Kind = TemplateTreeNodeKind.Template,
Id = t.Id,
Name = t.Name,
};
if (ExtraTemplateChildren != null)
{
foreach (var extra in ExtraTemplateChildren(t))
{
tNode.Children.Add(extra);
}
}
if (t.FolderId is int fid && folderNodes.TryGetValue(fid, out var parentFolder))
parentFolder.Children.Add(tNode);
else
roots.Add(tNode);
}
SortChildren(roots);
foreach (var node in folderNodes.Values)
SortChildren(node.Children);
_allRoots = roots;
}
private static void SortChildren(List<TemplateTreeNode> children)
{
children.Sort((a, b) =>
{
var kindOrder = (int)a.Kind - (int)b.Kind;
if (kindOrder != 0) return kindOrder;
return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
});
}
private void ApplyFilter()
{
if (string.IsNullOrWhiteSpace(Filter))
{
_visibleRoots = _allRoots;
_filterRevealed = null;
_initiallyExpanded = null;
return;
}
var needle = Filter.Trim();
var revealed = new HashSet<string>(StringComparer.Ordinal);
var filtered = new List<TemplateTreeNode>();
foreach (var root in _allRoots)
{
if (CopyMatching(root, needle, revealed) is { } copy)
filtered.Add(copy);
}
_visibleRoots = filtered;
_filterRevealed = revealed;
// Force every ancestor of a match to be expanded so the matched leaf is
// visible without the user clicking through.
_initiallyExpanded = n => revealed.Contains(n.Key);
}
private static TemplateTreeNode? CopyMatching(TemplateTreeNode node, string needle, HashSet<string> revealed)
{
var selfMatch = node.Name.Contains(needle, StringComparison.OrdinalIgnoreCase);
var keptChildren = new List<TemplateTreeNode>();
foreach (var child in node.Children)
{
var copy = CopyMatching(child, needle, revealed);
if (copy != null) keptChildren.Add(copy);
}
if (!selfMatch && keptChildren.Count == 0) return null;
var clone = new TemplateTreeNode
{
Kind = node.Kind,
Id = node.Id,
Name = node.Name,
};
foreach (var k in keptChildren) clone.Children.Add(k);
if (keptChildren.Count > 0)
{
// Mark this node as an ancestor on the match path so it auto-expands.
revealed.Add(clone.Key);
}
return clone;
}
private static string NodeGlyph(TemplateTreeNode node) =>
node.Kind == TemplateTreeNodeKind.Folder ? "bi-folder" : "bi-file-earmark-text";
private async Task InvokeNodeClick(TemplateTreeNode node)
{
if (OnNodeClick.HasDelegate)
{
await OnNodeClick.InvokeAsync(node);
}
}
/// <summary>Forwarded to the inner TreeView so callers can drive expand/collapse.</summary>
public void ExpandAll() => _tree?.ExpandAll();
/// <summary>Forwarded to the inner TreeView so callers can drive expand/collapse.</summary>
public void CollapseAll() => _tree?.CollapseAll();
}
@@ -0,0 +1,42 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
public enum TemplateTreeNodeKind
{
Folder,
Template,
/// <summary>
/// Composition slot under a parent Template — produced only by callers that
/// supply <c>TemplateFolderTree.ExtraTemplateChildren</c>. The Transport
/// Export wizard intentionally never emits this kind (compositions aren't
/// independently exportable); the Templates page uses it to surface slots.
/// </summary>
Composition,
}
/// <summary>
/// Adapter node used by <c>TemplateFolderTree</c> to model the template-folder
/// hierarchy in a TreeView. Folder nodes carry sub-folders + their templates as
/// children; template nodes are leaves unless the caller injects extras via
/// <c>TemplateFolderTree.ExtraTemplateChildren</c> (e.g. composition slots).
/// </summary>
public sealed class TemplateTreeNode
{
/// <summary>Discriminator indicating whether this node represents a folder, template, or composition slot.</summary>
public required TemplateTreeNodeKind Kind { get; init; }
/// <summary>Database id of the underlying folder, template, or composition record.</summary>
public required int Id { get; init; }
/// <summary>Display name of the node.</summary>
public required string Name { get; init; }
/// <summary>Child nodes (sub-folders, templates, or composition slots).</summary>
public List<TemplateTreeNode> Children { get; } = new();
/// <summary>Stable key for TreeView selection / expansion tracking.</summary>
public string Key => Kind switch
{
TemplateTreeNodeKind.Folder => $"f:{Id}",
TemplateTreeNodeKind.Template => $"t:{Id}",
TemplateTreeNodeKind.Composition => $"c:{Id}",
_ => $"x:{Id}",
};
}
@@ -0,0 +1,20 @@
@* Displays a UTC DateTimeOffset formatted for display. Tooltip shows UTC value.
A null Value renders as a plain "never" placeholder — used for timestamps that
have not happened yet (e.g. a heartbeat-only site with no full report). *@
@if (Value is { } value)
{
<span title="@value.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss") UTC">@value.LocalDateTime.ToString(Format)</span>
}
else
{
<span class="text-muted">@NullText</span>
}
@code {
[Parameter, EditorRequired] public DateTimeOffset? Value { get; set; }
[Parameter] public string Format { get; set; } = "yyyy-MM-dd HH:mm:ss";
/// <summary>Text shown when <see cref="Value"/> is null.</summary>
[Parameter] public string NullText { get; set; } = "never";
}
@@ -0,0 +1,144 @@
@*
Reusable toast notification component.
z-index ladder:
Toast container 1090 (this component, on top)
ConfirmDialog modal element 1055 (Bootstrap default for .modal)
ConfirmDialog backdrop 1040 (Bootstrap default for .modal-backdrop)
Toasts intentionally float above ConfirmDialog so confirmation feedback
(Success/Error) is visible even while a dialog is open.
*@
@implements IDisposable
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1090;" aria-live="polite" aria-atomic="true">
@foreach (var toast in _toasts)
{
<div class="toast show mb-2" role="alert">
<div class="toast-header @GetHeaderClass(toast.Type)">
<strong class="me-auto">@toast.Title</strong>
<button type="button" class="btn-close btn-close-white" @onclick="() => Dismiss(toast)"></button>
</div>
<div class="toast-body">@toast.Message</div>
</div>
}
</div>
@code {
private const int DefaultAutoDismissMs = 5000;
private readonly List<ToastItem> _toasts = new();
private readonly object _lock = new();
// Cancels all pending auto-dismiss delays when the component is disposed
// (CentralUI-010) so their continuations never touch a disposed component.
private readonly CancellationTokenSource _disposalCts = new();
/// <summary>Number of toasts currently displayed.</summary>
public int ToastCount
{
get { lock (_lock) { return _toasts.Count; } }
}
public void ShowSuccess(string message, string title = "Success", int? autoDismissMs = null)
{
AddToast(title, message, ToastType.Success, autoDismissMs);
}
public void ShowError(string message, string title = "Error", int? autoDismissMs = null)
{
AddToast(title, message, ToastType.Error, autoDismissMs);
}
public void ShowWarning(string message, string title = "Warning", int? autoDismissMs = null)
{
AddToast(title, message, ToastType.Warning, autoDismissMs);
}
public void ShowInfo(string message, string title = "Info", int? autoDismissMs = null)
{
AddToast(title, message, ToastType.Info, autoDismissMs);
}
private void AddToast(string title, string message, ToastType type, int? autoDismissMs)
{
// If the component is already disposed, do not add or schedule anything.
if (_disposalCts.IsCancellationRequested) return;
var toast = new ToastItem { Title = title, Message = message, Type = type };
lock (_lock)
{
_toasts.Add(toast);
}
StateHasChanged();
var dismissMs = autoDismissMs ?? DefaultAutoDismissMs;
_ = AutoDismissAsync(toast, dismissMs, _disposalCts.Token);
}
/// <summary>
/// Removes a toast after its dismiss delay. The delay is bound to the
/// component's disposal token (CentralUI-010): if the host page is disposed
/// first, the delay is cancelled and the continuation never touches the
/// disposed component — no <see cref="ObjectDisposedException"/> escapes.
/// </summary>
private async Task AutoDismissAsync(ToastItem toast, int dismissMs, CancellationToken token)
{
try
{
await Task.Delay(dismissMs, token);
}
catch (OperationCanceledException)
{
return;
}
if (token.IsCancellationRequested) return;
lock (_lock)
{
_toasts.Remove(toast);
}
try
{
await InvokeAsync(StateHasChanged);
}
catch (ObjectDisposedException)
{
// Component disposed between the token check and the render — ignore.
}
}
private void Dismiss(ToastItem toast)
{
lock (_lock)
{
_toasts.Remove(toast);
}
}
private static string GetHeaderClass(ToastType type) => type switch
{
ToastType.Success => "bg-success text-white",
ToastType.Error => "bg-danger text-white",
ToastType.Warning => "bg-warning text-dark",
ToastType.Info => "bg-info text-dark",
_ => "bg-secondary text-white"
};
public void Dispose()
{
_disposalCts.Cancel();
_disposalCts.Dispose();
}
private enum ToastType { Success, Error, Warning, Info }
private class ToastItem
{
public string Title { get; init; } = "";
public string Message { get; init; } = "";
public ToastType Type { get; init; }
}
}
@@ -0,0 +1,521 @@
@* Reusable hierarchical tree view with expand/collapse, ARIA roles, and guide lines *@
@typeparam TItem
@inject IJSRuntime JSRuntime
@if (_items is null || _items.Count == 0)
{
if (EmptyContent != null)
{
@EmptyContent
}
}
else
{
<ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")">
@foreach (var item in _items)
{
RenderNode(item, 0);
}
</ul>
}
@if (_showContextMenu && _contextMenuItem != null && ContextMenu != null)
{
<div class="tv-ctx-overlay" @onclick="DismissContextMenu" style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div>
<div class="dropdown-menu show" tabindex="-1" @ref="_contextMenuRef" @onkeydown="OnContextMenuKeyDown"
@onclick="DismissContextMenu"
style="position:fixed;top:@(_contextMenuY)px;left:@(_contextMenuX)px;z-index:1050;outline:none;">
@ContextMenu(_contextMenuItem)
</div>
}
@{ void RenderNode(TItem item, int depth)
{
var key = KeySelector(item);
var children = ChildrenSelector(item);
var isBranch = HasChildrenSelector(item);
var isExpanded = _expandedKeys.Contains(KeyStr(key));
var isSelected = Selectable && SelectedKey != null && SelectedKey.Equals(key);
var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : "");
// Checkbox-mode tri-state computed for this node (folder = aggregate of
// descendant leaves; leaf = present-in-SelectedKeys).
var checkState = SelectionMode == TreeViewSelectionMode.Checkbox
? ComputeCheckState(item)
: CheckState.Unchecked;
<li role="treeitem" @key="key"
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
aria-selected="@(isSelected ? "true" : null)">
<div class="@rowClasses" style="padding-left: @(depth * IndentPx)px; --tv-depth: @depth;"
@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)">
@if (isBranch)
{
<span class="tv-toggle" @onclick="() => ToggleExpand(key)" @onclick:stopPropagation><i class="bi bi-chevron-right"></i></span>
}
else
{
<span class="tv-spacer"></span>
}
@if (SelectionMode == TreeViewSelectionMode.Checkbox)
{
<input type="checkbox"
class="form-check-input tv-checkbox @(checkState == CheckState.Indeterminate ? "tv-checkbox-indeterminate" : "")"
@ref="_checkboxRefs[KeyStr(key)]"
checked="@(checkState == CheckState.Checked)"
@onchange="() => OnCheckboxToggle(item)"
@onclick:stopPropagation />
}
<span class="tv-content" @onclick="() => OnContentClick(key)" @onclick:stopPropagation>
@NodeContent(item)
</span>
</div>
@if (isBranch && isExpanded && children is { Count: > 0 })
{
<ul role="group">
@foreach (var child in children)
{
RenderNode(child, depth + 1);
}
</ul>
}
</li>
}
}
@code {
private IReadOnlyList<TItem>? _items;
private HashSet<string> _expandedKeys = new();
/// <summary>Normalize any key object to a string for consistent comparison with sessionStorage.</summary>
private string KeyStr(object key) => key.ToString()!;
private bool _initialExpansionApplied;
private bool _storageLoaded;
private TItem? _contextMenuItem;
private double _contextMenuX;
private double _contextMenuY;
private bool _showContextMenu;
private bool _contextMenuNeedsFocus;
private ElementReference _contextMenuRef;
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
[Parameter, EditorRequired] public Func<TItem, bool> HasChildrenSelector { get; set; } = default!;
[Parameter, EditorRequired] public Func<TItem, object> KeySelector { get; set; } = default!;
[Parameter, EditorRequired] public RenderFragment<TItem> NodeContent { get; set; } = default!;
[Parameter] public RenderFragment? EmptyContent { get; set; }
[Parameter] public RenderFragment<TItem>? ContextMenu { get; set; }
[Parameter] public int IndentPx { get; set; } = 24;
[Parameter] public bool ShowGuideLines { get; set; } = true;
[Parameter] public Func<TItem, bool>? InitiallyExpanded { get; set; }
[Parameter] public bool Selectable { get; set; }
[Parameter] public object? SelectedKey { get; set; }
[Parameter] public EventCallback<object?> SelectedKeyChanged { get; set; }
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";
[Parameter] public string? StorageKey { get; set; }
// ── Checkbox-selection mode (additive; SelectionMode=Single keeps prior behaviour) ──
[Parameter] public TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single;
[Parameter] public HashSet<object>? SelectedKeys { get; set; }
[Parameter] public EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }
private readonly Dictionary<string, ElementReference> _checkboxRefs = new();
private enum CheckState { Unchecked, Checked, Indeterminate }
protected override void OnParametersSet()
{
_items = Items;
if (!_initialExpansionApplied && InitiallyExpanded != null && _items is { Count: > 0 })
{
// Only apply InitiallyExpanded when there is no StorageKey, or storage
// has already been checked and returned nothing (no prior state).
if (StorageKey == null || (_storageLoaded && _expandedKeys.Count == 0))
{
_initialExpansionApplied = true;
ApplyInitialExpansion(_items);
}
}
// Clear selection if the selected key no longer exists in the current items tree
if (Selectable && SelectedKey != null && _items is not null && !KeyExistsInTree(_items, SelectedKey))
{
_ = SelectedKeyChanged.InvokeAsync(null);
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_contextMenuNeedsFocus && _showContextMenu)
{
_contextMenuNeedsFocus = false;
// The context-menu element may have been removed (menu dismissed
// during render) or the circuit disconnected — both are expected.
try { await _contextMenuRef.FocusAsync(); }
catch (Microsoft.JSInterop.JSException) { }
catch (Microsoft.JSInterop.JSDisconnectedException) { }
catch (InvalidOperationException) { }
}
if (firstRender && StorageKey != null)
{
string? json = null;
try
{
json = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", StorageKey);
}
catch (Microsoft.JSInterop.JSDisconnectedException)
{
// Circuit disconnected before the storage read completed — there
// is nothing to restore and nothing to log.
}
_storageLoaded = true;
// CentralUI-018: a corrupt or wrong-shaped treeviewStorage payload
// must not throw out of OnAfterRenderAsync. Guard the deserialize
// and treat an unparseable payload as "no prior state".
List<string>? keys = null;
if (json != null)
{
try
{
keys = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
}
catch (System.Text.Json.JsonException)
{
keys = null;
}
}
if (keys != null)
{
// Union (don't replace): callers may have invoked RevealNode before
// this async storage load completed. Preserving those reveal-added
// keys ensures deep-link reveal isn't clobbered by the restore.
foreach (var k in keys) _expandedKeys.Add(k);
_initialExpansionApplied = true;
}
else if (InitiallyExpanded != null && _items is { Count: > 0 } && !_initialExpansionApplied)
{
// Storage returned null or a corrupt payload (no usable prior
// state) — fall back to InitiallyExpanded.
_initialExpansionApplied = true;
ApplyInitialExpansion(_items);
}
StateHasChanged();
}
// Apply checkbox tri-state (`indeterminate`) after every render in
// Checkbox mode. Blazor doesn't bind input.indeterminate natively.
if (SelectionMode == TreeViewSelectionMode.Checkbox && _items is { Count: > 0 })
{
await ApplyIndeterminateStateAsync();
}
}
private async Task ApplyIndeterminateStateAsync()
{
foreach (var (keyStr, elemRef) in _checkboxRefs)
{
var item = FindItemByKey(_items!, keyStr);
if (item is null) continue;
var state = ComputeCheckState(item);
try
{
await JSRuntime.InvokeVoidAsync(
"treeviewStorage.setIndeterminate",
elemRef,
state == CheckState.Indeterminate);
}
catch (Microsoft.JSInterop.JSDisconnectedException) { /* circuit gone */ }
catch (Microsoft.JSInterop.JSException) { /* element gone */ }
}
}
private bool KeyExistsInTree(IReadOnlyList<TItem> items, object key)
{
foreach (var item in items)
{
if (key.Equals(KeySelector(item)))
return true;
var children = ChildrenSelector(item);
if (children is { Count: > 0 } && KeyExistsInTree(children, key))
return true;
}
return false;
}
private void ApplyInitialExpansion(IReadOnlyList<TItem> items)
{
foreach (var item in items)
{
if (InitiallyExpanded!(item))
{
_expandedKeys.Add(KeyStr(KeySelector(item)));
}
var children = ChildrenSelector(item);
if (children is { Count: > 0 })
{
ApplyInitialExpansion(children);
}
}
}
private void ToggleExpand(object key)
{
var k = KeyStr(key);
if (!_expandedKeys.Remove(k))
{
_expandedKeys.Add(k);
}
PersistExpandedState();
}
private void PersistExpandedState()
{
if (StorageKey != null)
{
var json = System.Text.Json.JsonSerializer.Serialize(_expandedKeys.ToList());
_ = JSRuntime.InvokeVoidAsync("treeviewStorage.save", StorageKey, json);
}
}
private async Task OnContentClick(object key)
{
if (Selectable)
{
await SelectedKeyChanged.InvokeAsync(key);
}
}
private void OnContextMenu(MouseEventArgs e, TItem item)
{
if (ContextMenu == null) return;
_contextMenuItem = item;
_contextMenuX = e.ClientX;
_contextMenuY = e.ClientY;
_showContextMenu = true;
_contextMenuNeedsFocus = true;
}
private void DismissContextMenu()
{
_showContextMenu = false;
_contextMenuItem = default;
}
private void OnContextMenuKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
DismissContextMenu();
}
}
/// <summary>Whether the node with the given key is currently expanded.</summary>
public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key));
/// <summary>Expand every branch node in the tree.</summary>
public void ExpandAll()
{
if (_items is { Count: > 0 })
{
ExpandAllRecursive(_items);
}
PersistExpandedState();
StateHasChanged();
}
private void ExpandAllRecursive(IReadOnlyList<TItem> items)
{
foreach (var item in items)
{
if (HasChildrenSelector(item))
{
_expandedKeys.Add(KeyStr(KeySelector(item)));
}
var children = ChildrenSelector(item);
if (children is { Count: > 0 })
{
ExpandAllRecursive(children);
}
}
}
/// <summary>Collapse every node in the tree.</summary>
public void CollapseAll()
{
_expandedKeys.Clear();
PersistExpandedState();
StateHasChanged();
}
/// <summary>
/// Expand all ancestors of the given key so it becomes visible.
/// Optionally select the node.
/// </summary>
public async Task RevealNode(object key, bool select = false)
{
var parentLookup = BuildParentLookup();
var k = KeyStr(key);
// If key is not in the tree at all, no-op
if (!parentLookup.ContainsKey(k))
return;
// Walk up through ancestors
var current = k;
while (parentLookup.TryGetValue(current, out var parentKey) && parentKey != null)
{
_expandedKeys.Add(parentKey);
current = parentKey;
}
if (select && Selectable)
{
await SelectedKeyChanged.InvokeAsync(key);
}
PersistExpandedState();
StateHasChanged();
}
private Dictionary<string, string?> BuildParentLookup()
{
var lookup = new Dictionary<string, string?>();
if (_items is { Count: > 0 })
{
BuildParentLookupRecursive(_items, null, lookup);
}
return lookup;
}
private void BuildParentLookupRecursive(IReadOnlyList<TItem> items, string? parentKey, Dictionary<string, string?> lookup)
{
foreach (var item in items)
{
var key = KeyStr(KeySelector(item));
lookup[key] = parentKey;
var children = ChildrenSelector(item);
if (children is { Count: > 0 })
{
BuildParentLookupRecursive(children, key, lookup);
}
}
}
// ── Checkbox-selection helpers ──────────────────────────────────────────
// Folder = aggregate of its descendant LEAVES (we don't track folder keys
// in SelectedKeys — only leaf keys are persisted). A folder is Checked when
// every descendant leaf is in SelectedKeys, Unchecked when none are, and
// Indeterminate otherwise.
private CheckState ComputeCheckState(TItem item)
{
var children = ChildrenSelector(item);
var isBranch = HasChildrenSelector(item);
if (!isBranch || children is null || children.Count == 0)
{
var leafKey = KeySelector(item);
return SelectedKeys != null && SelectedKeys.Contains(leafKey)
? CheckState.Checked
: CheckState.Unchecked;
}
var total = 0;
var selected = 0;
CountDescendantLeaves(item, ref total, ref selected);
if (total == 0) return CheckState.Unchecked;
if (selected == 0) return CheckState.Unchecked;
if (selected == total) return CheckState.Checked;
return CheckState.Indeterminate;
}
private void CountDescendantLeaves(TItem item, ref int total, ref int selected)
{
var children = ChildrenSelector(item);
var hasChildren = HasChildrenSelector(item) && children is { Count: > 0 };
if (!hasChildren)
{
total++;
if (SelectedKeys != null && SelectedKeys.Contains(KeySelector(item)))
{
selected++;
}
return;
}
foreach (var child in children!)
{
CountDescendantLeaves(child, ref total, ref selected);
}
}
private void CollectDescendantLeafKeys(TItem item, List<object> sink)
{
var children = ChildrenSelector(item);
var hasChildren = HasChildrenSelector(item) && children is { Count: > 0 };
if (!hasChildren)
{
sink.Add(KeySelector(item));
return;
}
foreach (var child in children!)
{
CollectDescendantLeafKeys(child, sink);
}
}
private async Task OnCheckboxToggle(TItem item)
{
// SelectedKeys is the source of truth — copy-on-write so consumers
// observe a fresh reference and Blazor reliably re-renders.
var current = SelectedKeys is null
? new HashSet<object>()
: new HashSet<object>(SelectedKeys);
var leaves = new List<object>();
CollectDescendantLeafKeys(item, leaves);
if (leaves.Count == 0) return;
// Folder-toggle semantics: if every descendant leaf is currently selected,
// uncheck them all; otherwise select all. Leaf nodes have leaves = { self }
// so this simplifies to a plain toggle.
var allSelected = leaves.All(current.Contains);
if (allSelected)
{
foreach (var k in leaves) current.Remove(k);
}
else
{
foreach (var k in leaves) current.Add(k);
}
SelectedKeys = current;
await SelectedKeysChanged.InvokeAsync(current);
}
private TItem? FindItemByKey(IReadOnlyList<TItem> items, string keyStr)
{
foreach (var item in items)
{
if (KeyStr(KeySelector(item)) == keyStr) return item;
var children = ChildrenSelector(item);
if (children is { Count: > 0 })
{
var found = FindItemByKey(children, keyStr);
if (found is not null) return found;
}
}
return default;
}
}
@@ -0,0 +1,152 @@
/* TreeView component styling — see docs/requirements/Component-TreeView.md "Visual Design Guide" (V1V7). */
/* Root list — no list styling. */
.tv-root,
.tv-root ul {
list-style: none;
padding-left: 0;
margin: 0;
}
/* V1 — Row anatomy. Flex container; full-width hit surface; ~32px row. */
.tv-row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
cursor: default;
border-radius: 0.25rem;
transition: background-color 0.08s linear;
}
/* V1 — slot widths. Toggle and glyph are always present so labels align across siblings. */
.tv-row .tv-toggle,
.tv-row .tv-spacer {
flex: 0 0 auto;
width: 1.25rem; /* 20px */
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--bs-secondary-color);
user-select: none;
}
.tv-row .tv-spacer {
cursor: default;
}
.tv-row .tv-content {
flex: 1 1 auto;
min-width: 0; /* required so child label can ellipsis-truncate inside a flex item */
display: flex;
align-items: center;
gap: 0.25rem;
}
/* V5 — primary label truncation. Consumers add their own bold class for branches. */
.tv-row .tv-label {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* V1 — meta slot right-aligns trailing badges/text against row edge. */
.tv-row .tv-meta {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex: 0 0 auto;
}
/* V4 — glyph slot. Bootstrap Icons render at 1em, inherit text color. */
.tv-row .tv-glyph {
flex: 0 0 auto;
width: 1.25rem; /* 20px, same slot size as toggle */
display: inline-flex;
align-items: center;
justify-content: center;
}
/* V3 — hover. Subtle gray wash; suppressed when row is selected. */
.tv-row:hover:not(.tv-selected) {
background-color: var(--bs-tertiary-bg);
}
/* V3 — selected. Bootstrap utility `bg-primary bg-opacity-10` is the SelectedCssClass default;
the .tv-selected hook is provided for consumers that prefer scoped styling. */
.tv-row.tv-selected {
background-color: rgba(var(--bs-primary-rgb), 0.1);
}
/* V3 — keyboard focus. Inset ring composes with hover/selected without layout shift. */
.tv-row:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--bs-primary);
}
/* V3 — drop-target (valid). Overrides hover/selected. */
.tv-row.tv-drop-target {
background-color: rgba(var(--bs-info-rgb), 0.25);
}
/* V3 — dimmed (e.g. non-droppable while a drag is in progress; reserved for future use). */
.tv-row.tv-dimmed {
opacity: 0.5;
}
/* V2 — guide lines. A pseudo-element overlays the row's depth gutter and draws
one vertical line per ancestor depth at 24px intervals. The `--tv-depth` variable
is set inline per row; lines never extend into the content area. */
.tv-guides .tv-row {
position: relative;
}
.tv-guides .tv-row::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: calc(var(--tv-depth, 0) * 1.5rem);
pointer-events: none;
background-image: linear-gradient(
to right,
transparent calc(0.625rem - 0.5px),
var(--bs-border-color) calc(0.625rem - 0.5px),
var(--bs-border-color) calc(0.625rem + 0.5px),
transparent calc(0.625rem + 0.5px)
);
background-size: 1.5rem 100%;
background-repeat: repeat-x;
}
/* Branch chevron rotates on expand via the aria-expanded attribute on the parent treeitem.
Consumers using `bi-chevron-right` get the down-rotation for free. */
.tv-row .tv-toggle .bi-chevron-right {
transition: transform 0.1s linear;
}
[role="treeitem"][aria-expanded="true"] > .tv-row .tv-toggle .bi-chevron-right {
transform: rotate(90deg);
}
/* Per-row kebab (More actions): hidden by default, revealed on row hover or when
the dropdown is open. Consumers render `<span class="tv-kebab ...">` inside
NodeContent to opt in. */
.tv-row .tv-kebab {
flex: 0 0 auto;
opacity: 0;
transition: opacity 0.1s linear;
margin-left: 0.25rem;
}
.tv-row:hover .tv-kebab,
.tv-row:focus-within .tv-kebab,
.tv-row .tv-kebab.show,
.tv-row .tv-kebab .show {
opacity: 1;
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Selection mode for <see cref="TreeView{TItem}"/>. <see cref="Single"/> is the
/// default click-to-select behaviour (preserves legacy callers). <see cref="Checkbox"/>
/// renders an input checkbox per node with tri-state propagation on folders.
/// </summary>
public enum TreeViewSelectionMode
{
Single,
Checkbox,
}
@@ -0,0 +1,51 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
/// <summary>
/// Maps the trigger editors' flattened <see cref="AlarmAttributeChoice"/> list
/// into the metadata the <see cref="MonacoEditor"/> uses to drive C# completion
/// inside an expression trigger:
/// <list type="bullet">
/// <item>Direct + Inherited choices become <see cref="AttributeShape"/>s,
/// surfaced under <c>Attributes["..."]</c>.</item>
/// <item>Composed choices — whose canonical name is dotted, e.g.
/// <c>CoolingTank.Temp</c> — are grouped by their composition-instance prefix
/// into <see cref="CompositionContext"/>s, surfaced under
/// <c>Children["..."].Attributes["..."]</c>.</item>
/// </list>
/// </summary>
public static class TriggerAttributeMapper
{
/// <summary>Direct and inherited attributes, exposed as <c>Attributes["..."]</c>.</summary>
/// <param name="choices">The full flattened attribute choice list from the trigger editor.</param>
public static IReadOnlyList<AttributeShape> SelfAttributes(
IReadOnlyList<AlarmAttributeChoice> choices) =>
choices
.Where(c => c.Source is "Direct" or "Inherited")
.Select(c => new AttributeShape(c.CanonicalName, c.DataType))
.ToList();
/// <summary>
/// Composed attributes grouped by composition-instance name, exposed as
/// <c>Children["X"].Attributes["Y"]</c>. Entries without a dotted prefix
/// are skipped (no child scope to attach them to).
/// </summary>
/// <param name="choices">The full flattened attribute choice list from the trigger editor.</param>
public static IReadOnlyList<CompositionContext> Children(
IReadOnlyList<AlarmAttributeChoice> choices) =>
choices
.Where(c => c.Source == "Composed" && c.CanonicalName.Contains('.'))
.Select(c => new
{
Child = c.CanonicalName[..c.CanonicalName.IndexOf('.')],
Member = c.CanonicalName[(c.CanonicalName.IndexOf('.') + 1)..],
c.DataType
})
.GroupBy(x => x.Child, StringComparer.Ordinal)
.Select(g => new CompositionContext(
g.Key,
g.Select(x => new AttributeShape(x.Member, x.DataType)).ToList(),
Array.Empty<ScriptShape>()))
.ToList();
}