feat(alarms): HiLo trigger type with per-band level, hysteresis, messages, overrides
Adds a new HiLo alarm trigger type with four configurable setpoints
(LoLo / Lo / Hi / HiHi). Each setpoint carries an optional priority,
deadband (for hysteresis), and operator message. The site runtime emits
AlarmStateChanged with an AlarmLevel field so consumers can differentiate
warning vs critical bands.
Plumbing:
- new AlarmLevel enum + AlarmStateChanged.Level/Message init properties
- AlarmTriggerEditor (Blazor) gets a HiLo render with severity tinting
- AlarmTriggerConfigCodec extracted from the editor for testability
- sitestream.proto carries level + message over gRPC
- SemanticValidator enforces numeric attribute, setpoint ordering,
non-negative deadband
- on-trigger scripts get an Alarm global (Name/Level/Priority/Message)
so notification routing can branch by severity
- per-instance InstanceAlarmOverride entity + EF migration + flattening
step + CLI commands; HiLo overrides merge setpoint-by-setpoint, binary
types whole-replace
- DebugView shows a Level badge + per-band message tooltip
- App.razor auto-reloads on permanent Blazor circuit failure
- docker/regen-proto.sh automates the proto regen workflow (the linux/arm64
protoc segfault means generated files are checked in for now)
This commit is contained in:
@@ -15,6 +15,7 @@ public static class InstanceCommands
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetBindings(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetOverrides(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildAlarmOverride(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetArea(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDiff(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDeploy(urlOption, formatOption, usernameOption, passwordOption));
|
||||
@@ -186,6 +187,59 @@ public static class InstanceCommands
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildAlarmOverride(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("alarm-override") { Description = "Manage per-instance alarm overrides" };
|
||||
|
||||
// set
|
||||
var setIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||
var setAlarmOption = new Option<string>("--alarm") { Description = "Alarm canonical name (e.g., 'TempLevels' or 'Pump.TempSensor.Heat')", Required = true };
|
||||
var setConfigOption = new Option<string?>("--trigger-config") { Description = "JSON override for TriggerConfiguration (HiLo: partial merge; others: whole-replace)" };
|
||||
var setPriorityOption = new Option<int?>("--priority") { Description = "Priority override (0-1000)" };
|
||||
var setCmd = new Command("set") { Description = "Set (upsert) an alarm override on an instance" };
|
||||
setCmd.Add(setIdOption); setCmd.Add(setAlarmOption); setCmd.Add(setConfigOption); setCmd.Add(setPriorityOption);
|
||||
setCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new SetInstanceAlarmOverrideCommand(
|
||||
result.GetValue(setIdOption),
|
||||
result.GetValue(setAlarmOption)!,
|
||||
result.GetValue(setConfigOption),
|
||||
result.GetValue(setPriorityOption)));
|
||||
});
|
||||
group.Add(setCmd);
|
||||
|
||||
// delete
|
||||
var delIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||
var delAlarmOption = new Option<string>("--alarm") { Description = "Alarm canonical name", Required = true };
|
||||
var delCmd = new Command("delete") { Description = "Remove an alarm override on an instance" };
|
||||
delCmd.Add(delIdOption); delCmd.Add(delAlarmOption);
|
||||
delCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new DeleteInstanceAlarmOverrideCommand(
|
||||
result.GetValue(delIdOption),
|
||||
result.GetValue(delAlarmOption)!));
|
||||
});
|
||||
group.Add(delCmd);
|
||||
|
||||
// list
|
||||
var listIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||
var listCmd = new Command("list") { Description = "List all alarm overrides for an instance" };
|
||||
listCmd.Add(listIdOption);
|
||||
listCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new ListInstanceAlarmOverridesCommand(result.GetValue(listIdOption)));
|
||||
});
|
||||
group.Add(listCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Command BuildSetArea(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
<tr>
|
||||
<th>Alarm</th>
|
||||
<th>State</th>
|
||||
<th>Level</th>
|
||||
<th>Priority</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
@@ -198,12 +199,30 @@
|
||||
<tbody aria-live="polite" aria-atomic="false">
|
||||
@foreach (var alarm in FilteredAlarmStates)
|
||||
{
|
||||
<tr class="@GetAlarmRowClass(alarm.State)">
|
||||
<td class="small">@alarm.AlarmName</td>
|
||||
<tr class="@GetAlarmRowClass(alarm.State)"
|
||||
title="@(string.IsNullOrEmpty(alarm.Message) ? null : alarm.Message)">
|
||||
<td class="small">
|
||||
@alarm.AlarmName
|
||||
@if (!string.IsNullOrEmpty(alarm.Message))
|
||||
{
|
||||
<span class="ms-1 text-info" aria-label="Has operator message">💬</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge @GetAlarmStateBadge(alarm.State)"
|
||||
aria-label="@($"Alarm state: {alarm.State}")">@alarm.State</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (alarm.Level != AlarmLevel.None)
|
||||
{
|
||||
<span class="badge @GetAlarmLevelBadge(alarm.Level)"
|
||||
aria-label="@($"Alarm level: {alarm.Level}")">@FormatLevel(alarm.Level)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">@alarm.Priority</td>
|
||||
<td class="small text-muted"
|
||||
title="@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
|
||||
@@ -468,6 +487,26 @@
|
||||
_ => ""
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Severity-tinted badge class for HiLo alarm levels. The critical bands
|
||||
/// (HighHigh / LowLow) get the danger class; warning bands get amber.
|
||||
/// </summary>
|
||||
private static string GetAlarmLevelBadge(AlarmLevel level) => level switch
|
||||
{
|
||||
AlarmLevel.HighHigh or AlarmLevel.LowLow => "bg-danger",
|
||||
AlarmLevel.High or AlarmLevel.Low => "bg-warning text-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
private static string FormatLevel(AlarmLevel level) => level switch
|
||||
{
|
||||
AlarmLevel.HighHigh => "HiHi",
|
||||
AlarmLevel.High => "Hi",
|
||||
AlarmLevel.Low => "Lo",
|
||||
AlarmLevel.LowLow => "LoLo",
|
||||
_ => "—"
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_session != null)
|
||||
|
||||
@@ -754,7 +754,8 @@
|
||||
<AlarmTriggerEditor TriggerType="@_alarmTriggerType"
|
||||
Value="@_alarmTriggerConfig"
|
||||
ValueChanged="@(v => _alarmTriggerConfig = v)"
|
||||
AvailableAttributes="@BuildAlarmAttributeChoices()" />
|
||||
AvailableAttributes="@BuildAlarmAttributeChoices()"
|
||||
FallbackPriority="@_alarmPriority" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.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 }
|
||||
///
|
||||
/// 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>
|
||||
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;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Malformed JSON — fall through with default model.
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig
|
||||
/// expects. Always writes <c>attributeName</c> (canonical key) and only
|
||||
/// the keys relevant to the current trigger type.
|
||||
/// </summary>
|
||||
internal static string Serialize(AlarmTriggerModel model, AlarmTriggerType type)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var w = new Utf8JsonWriter(stream))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("attributeName", model.AttributeName ?? "");
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case AlarmTriggerType.ValueMatch:
|
||||
var mv = model.MatchValue ?? "";
|
||||
if (model.NotEquals) mv = "!=" + mv;
|
||||
w.WriteString("matchValue", mv);
|
||||
break;
|
||||
|
||||
case AlarmTriggerType.RangeViolation:
|
||||
if (model.Min.HasValue) w.WriteNumber("min", model.Min.Value);
|
||||
if (model.Max.HasValue) w.WriteNumber("max", model.Max.Value);
|
||||
break;
|
||||
|
||||
case AlarmTriggerType.RateOfChange:
|
||||
if (model.ThresholdPerSecond.HasValue)
|
||||
w.WriteNumber("thresholdPerSecond", model.ThresholdPerSecond.Value);
|
||||
if (model.WindowSeconds.HasValue)
|
||||
w.WriteNumber("windowSeconds", model.WindowSeconds.Value);
|
||||
w.WriteString("direction", model.Direction);
|
||||
break;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
w.WriteEndObject();
|
||||
}
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
public string? AttributeName { get; set; }
|
||||
|
||||
// ValueMatch
|
||||
public string? MatchValue { get; set; }
|
||||
public bool NotEquals { get; set; }
|
||||
|
||||
// RangeViolation
|
||||
public double? Min { get; set; }
|
||||
public double? Max { get; set; }
|
||||
|
||||
// RateOfChange
|
||||
public double? ThresholdPerSecond { get; set; }
|
||||
public double? WindowSeconds { get; set; }
|
||||
public string Direction { get; set; } = "either";
|
||||
|
||||
// HiLo — any subset of setpoints may be set; per-setpoint priorities
|
||||
// override the alarm-level priority for that band.
|
||||
public double? LoLo { get; set; }
|
||||
public double? Lo { get; set; }
|
||||
public double? Hi { get; set; }
|
||||
public double? HiHi { get; set; }
|
||||
public int? LoLoPriority { get; set; }
|
||||
public int? LoPriority { get; set; }
|
||||
public int? HiPriority { get; set; }
|
||||
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.
|
||||
public double? LoLoDeadband { get; set; }
|
||||
public double? LoDeadband { get; set; }
|
||||
public double? HiDeadband { get; set; }
|
||||
public double? HiHiDeadband { get; set; }
|
||||
|
||||
// Per-band operator message. Optional; surfaces on AlarmStateChanged.Message
|
||||
// and may be used by notification routing or operator displays.
|
||||
public string? LoLoMessage { get; set; }
|
||||
public string? LoMessage { get; set; }
|
||||
public string? HiMessage { get; set; }
|
||||
public string? HiHiMessage { get; set; }
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
@namespace ScadaLink.CentralUI.Components.Shared
|
||||
@using System.Globalization
|
||||
@using System.IO
|
||||
@using System.Text
|
||||
@using System.Text.Json
|
||||
@using ScadaLink.Commons.Types.Enums
|
||||
|
||||
@* Rich alarm trigger configuration editor. Replaces the raw JSON text field
|
||||
@@ -83,6 +80,9 @@
|
||||
case AlarmTriggerType.RateOfChange:
|
||||
@RenderRateOfChange();
|
||||
break;
|
||||
case AlarmTriggerType.HiLo:
|
||||
@RenderHiLo();
|
||||
break;
|
||||
}
|
||||
|
||||
@* ── Hint ──────────────────────────────────────────────────────────── *@
|
||||
@@ -108,7 +108,7 @@
|
||||
|
||||
// ── Internal state ─────────────────────────────────────────────────────
|
||||
|
||||
private TriggerModel _model = new();
|
||||
private AlarmTriggerModel _model = new AlarmTriggerModel();
|
||||
private AlarmTriggerType _lastSeenType;
|
||||
private string? _lastSeenJson;
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
// the context of the new type. Missing/unparseable keys fall back to
|
||||
// empty defaults.
|
||||
var preservedAttr = _model.AttributeName;
|
||||
_model = Parse(Value, TriggerType);
|
||||
_model = AlarmTriggerConfigCodec.Parse(Value, TriggerType);
|
||||
if (jsonChanged == false && typeChanged && !string.IsNullOrEmpty(preservedAttr))
|
||||
_model.AttributeName = preservedAttr;
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
|
||||
private async Task Emit()
|
||||
{
|
||||
var json = Serialize(_model, TriggerType);
|
||||
var json = AlarmTriggerConfigCodec.Serialize(_model, TriggerType);
|
||||
_lastSeenJson = json;
|
||||
await ValueChanged.InvokeAsync(json);
|
||||
}
|
||||
@@ -358,6 +358,150 @@
|
||||
|
||||
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.
|
||||
@@ -382,6 +526,22 @@
|
||||
_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";
|
||||
@@ -420,10 +580,25 @@
|
||||
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),
|
||||
|
||||
_ => 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) : "";
|
||||
|
||||
@@ -433,140 +608,4 @@
|
||||
private static double? ParseDouble(string? s) =>
|
||||
double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||||
|
||||
// ── Model + parse/serialize ────────────────────────────────────────────
|
||||
|
||||
private sealed class TriggerModel
|
||||
{
|
||||
public string? AttributeName { get; set; }
|
||||
|
||||
// ValueMatch
|
||||
public string? MatchValue { get; set; }
|
||||
public bool NotEquals { get; set; }
|
||||
|
||||
// RangeViolation
|
||||
public double? Min { get; set; }
|
||||
public double? Max { get; set; }
|
||||
|
||||
// RateOfChange
|
||||
public double? ThresholdPerSecond { get; set; }
|
||||
public double? WindowSeconds { get; set; }
|
||||
public string Direction { get; set; } = "either";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an existing trigger configuration JSON in the context of the
|
||||
/// given trigger type. Returns sensible defaults on parse failure or for
|
||||
/// missing keys.
|
||||
/// </summary>
|
||||
private static TriggerModel Parse(string? json, AlarmTriggerType type)
|
||||
{
|
||||
var model = new TriggerModel();
|
||||
if (string.IsNullOrWhiteSpace(json)) return model;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
model.AttributeName =
|
||||
root.TryGetProperty("attributeName", out var a) ? a.GetString()
|
||||
: root.TryGetProperty("attribute", out var a2) ? a2.GetString()
|
||||
: null;
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case AlarmTriggerType.ValueMatch:
|
||||
{
|
||||
var raw = root.TryGetProperty("matchValue", out var mv) ? mv.GetString()
|
||||
: root.TryGetProperty("value", out var mv2) ? mv2.GetString()
|
||||
: null;
|
||||
if (raw != null && raw.StartsWith("!=", StringComparison.Ordinal))
|
||||
{
|
||||
model.NotEquals = true;
|
||||
model.MatchValue = raw[2..];
|
||||
}
|
||||
else
|
||||
{
|
||||
model.MatchValue = raw;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AlarmTriggerType.RangeViolation:
|
||||
model.Min = TryReadDouble(root, "min") ?? TryReadDouble(root, "low");
|
||||
model.Max = TryReadDouble(root, "max") ?? TryReadDouble(root, "high");
|
||||
break;
|
||||
|
||||
case AlarmTriggerType.RateOfChange:
|
||||
model.ThresholdPerSecond = TryReadDouble(root, "thresholdPerSecond");
|
||||
model.WindowSeconds = TryReadDouble(root, "windowSeconds");
|
||||
var dir = root.TryGetProperty("direction", out var d) ? d.GetString() : null;
|
||||
model.Direction = NormalizeDirection(dir);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Malformed JSON — fall through with default model.
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
private static string NormalizeDirection(string? raw) => raw?.ToLowerInvariant() switch
|
||||
{
|
||||
"rising" or "up" or "positive" => "rising",
|
||||
"falling" or "down" or "negative" => "falling",
|
||||
_ => "either"
|
||||
};
|
||||
|
||||
private static double? TryReadDouble(JsonElement el, string name)
|
||||
{
|
||||
if (!el.TryGetProperty(name, out var p)) return null;
|
||||
return p.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => p.GetDouble(),
|
||||
JsonValueKind.String when double.TryParse(p.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) => v,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig
|
||||
/// expects. Always writes <c>attributeName</c> (canonical key) and only
|
||||
/// the keys relevant to the current trigger type.
|
||||
/// </summary>
|
||||
private static string Serialize(TriggerModel model, AlarmTriggerType type)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var w = new Utf8JsonWriter(stream))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("attributeName", model.AttributeName ?? "");
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case AlarmTriggerType.ValueMatch:
|
||||
var mv = model.MatchValue ?? "";
|
||||
if (model.NotEquals) mv = "!=" + mv;
|
||||
w.WriteString("matchValue", mv);
|
||||
break;
|
||||
|
||||
case AlarmTriggerType.RangeViolation:
|
||||
if (model.Min.HasValue) w.WriteNumber("min", model.Min.Value);
|
||||
if (model.Max.HasValue) w.WriteNumber("max", model.Max.Value);
|
||||
break;
|
||||
|
||||
case AlarmTriggerType.RateOfChange:
|
||||
if (model.ThresholdPerSecond.HasValue)
|
||||
w.WriteNumber("thresholdPerSecond", model.ThresholdPerSecond.Value);
|
||||
if (model.WindowSeconds.HasValue)
|
||||
w.WriteNumber("windowSeconds", model.WindowSeconds.Value);
|
||||
w.WriteString("direction", model.Direction);
|
||||
break;
|
||||
}
|
||||
|
||||
w.WriteEndObject();
|
||||
}
|
||||
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public class Instance
|
||||
public string UniqueName { get; set; }
|
||||
public InstanceState State { get; set; }
|
||||
public ICollection<InstanceAttributeOverride> AttributeOverrides { get; set; } = new List<InstanceAttributeOverride>();
|
||||
public ICollection<InstanceAlarmOverride> AlarmOverrides { get; set; } = new List<InstanceAlarmOverride>();
|
||||
public ICollection<InstanceConnectionBinding> ConnectionBindings { get; set; } = new List<InstanceConnectionBinding>();
|
||||
|
||||
public Instance(string uniqueName)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace ScadaLink.Commons.Entities.Instances;
|
||||
|
||||
/// <summary>
|
||||
/// Per-instance override for a template-defined alarm. Lets a deployed
|
||||
/// instance tweak setpoints, priority, or per-band messages without forking
|
||||
/// the template. Locked alarms (TemplateAlarm.IsLocked) cannot be overridden
|
||||
/// — LockEnforcer rejects the change at write time.
|
||||
///
|
||||
/// Merge semantics (applied during flattening, after template inheritance and
|
||||
/// composition):
|
||||
/// • <see cref="TriggerConfigurationOverride"/> with a HiLo trigger merges
|
||||
/// into the inherited JSON setpoint-by-setpoint (derived keys win,
|
||||
/// inherited keys survive for unset derived keys). Same logic as
|
||||
/// template-to-template HiLo override, just one layer deeper.
|
||||
/// • For ValueMatch / RangeViolation / RateOfChange, the override replaces
|
||||
/// the whole TriggerConfiguration JSON (existing whole-replace semantics).
|
||||
/// • <see cref="PriorityLevelOverride"/> replaces the alarm's PriorityLevel
|
||||
/// when set.
|
||||
/// </summary>
|
||||
public class InstanceAlarmOverride
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int InstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical name of the alarm being overridden — matches
|
||||
/// <c>ResolvedAlarm.CanonicalName</c> after flattening, so composed-member
|
||||
/// alarms are referenced as <c>[CompositionInstance].[AlarmName]</c>.
|
||||
/// </summary>
|
||||
public string AlarmCanonicalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Partial JSON (for HiLo) or full JSON (for binary trigger types) to
|
||||
/// override the inherited TriggerConfiguration. <c>null</c> means
|
||||
/// "leave inherited as-is".
|
||||
/// </summary>
|
||||
public string? TriggerConfigurationOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the alarm's PriorityLevel when set. <c>null</c> = keep inherited.
|
||||
/// </summary>
|
||||
public int? PriorityLevelOverride { get; set; }
|
||||
|
||||
public InstanceAlarmOverride(string alarmCanonicalName)
|
||||
{
|
||||
AlarmCanonicalName = alarmCanonicalName ?? throw new ArgumentNullException(nameof(alarmCanonicalName));
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,13 @@ public interface ITemplateEngineRepository
|
||||
Task UpdateInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default);
|
||||
Task DeleteInstanceAttributeOverrideAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
// InstanceAlarmOverride
|
||||
Task<IReadOnlyList<InstanceAlarmOverride>> GetAlarmOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default);
|
||||
Task<InstanceAlarmOverride?> GetAlarmOverrideAsync(int instanceId, string alarmCanonicalName, CancellationToken cancellationToken = default);
|
||||
Task AddInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default);
|
||||
Task UpdateInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default);
|
||||
Task DeleteInstanceAlarmOverrideAsync(int id, CancellationToken cancellationToken = default);
|
||||
|
||||
// InstanceConnectionBinding
|
||||
Task<IReadOnlyList<InstanceConnectionBinding>> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default);
|
||||
Task AddInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -10,3 +10,21 @@ public record MgmtDeleteInstanceCommand(int InstanceId);
|
||||
public record SetConnectionBindingsCommand(int InstanceId, IReadOnlyList<(string AttributeName, int DataConnectionId)> Bindings);
|
||||
public record SetInstanceOverridesCommand(int InstanceId, IReadOnlyDictionary<string, string?> Overrides);
|
||||
public record SetInstanceAreaCommand(int InstanceId, int? AreaId);
|
||||
|
||||
/// <summary>
|
||||
/// Sets (or upserts) a per-instance alarm override. For HiLo trigger types the
|
||||
/// <c>TriggerConfigurationOverride</c> JSON is merged setpoint-by-setpoint with
|
||||
/// the inherited config; for binary trigger types it replaces the whole config.
|
||||
/// Either field is optional — pass null to leave it unchanged.
|
||||
/// </summary>
|
||||
public record SetInstanceAlarmOverrideCommand(
|
||||
int InstanceId,
|
||||
string AlarmCanonicalName,
|
||||
string? TriggerConfigurationOverride,
|
||||
int? PriorityLevelOverride);
|
||||
|
||||
public record DeleteInstanceAlarmOverrideCommand(
|
||||
int InstanceId,
|
||||
string AlarmCanonicalName);
|
||||
|
||||
public record ListInstanceAlarmOverridesCommand(int InstanceId);
|
||||
|
||||
@@ -7,4 +7,23 @@ public record AlarmStateChanged(
|
||||
string AlarmName,
|
||||
AlarmState State,
|
||||
int Priority,
|
||||
DateTimeOffset Timestamp);
|
||||
DateTimeOffset Timestamp)
|
||||
{
|
||||
/// <summary>
|
||||
/// Severity level when <see cref="State"/> is <see cref="AlarmState.Active"/>.
|
||||
/// Always <see cref="AlarmLevel.None"/> for binary trigger types
|
||||
/// (ValueMatch, RangeViolation, RateOfChange); set by the HiLo trigger
|
||||
/// type to one of Low/LowLow/High/HighHigh based on the crossed setpoint.
|
||||
/// Added as an init-property so existing positional constructors still
|
||||
/// work — message contract evolves additively.
|
||||
/// </summary>
|
||||
public AlarmLevel Level { get; init; } = AlarmLevel.None;
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-band operator message (e.g., "Coolant critically low —
|
||||
/// shut down"). Set by HiLo triggers when the per-setpoint message is
|
||||
/// configured; otherwise empty. Notification routing and UI tooltips may
|
||||
/// surface this to operators.
|
||||
/// </summary>
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
19
src/ScadaLink.Commons/Types/Enums/AlarmLevel.cs
Normal file
19
src/ScadaLink.Commons/Types/Enums/AlarmLevel.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace ScadaLink.Commons.Types.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Severity level for an active alarm. Binary alarm types (ValueMatch,
|
||||
/// RangeViolation, RateOfChange) always emit <see cref="None"/>. The HiLo
|
||||
/// trigger type emits one of the directional levels based on which setpoint
|
||||
/// the monitored attribute has crossed.
|
||||
///
|
||||
/// Conventional ordering (lowest setpoint to highest):
|
||||
/// LowLow < Low < normal-band < High < HighHigh
|
||||
/// </summary>
|
||||
public enum AlarmLevel
|
||||
{
|
||||
None,
|
||||
Low,
|
||||
LowLow,
|
||||
High,
|
||||
HighHigh
|
||||
}
|
||||
@@ -4,5 +4,12 @@ public enum AlarmTriggerType
|
||||
{
|
||||
ValueMatch,
|
||||
RangeViolation,
|
||||
RateOfChange
|
||||
RateOfChange,
|
||||
/// <summary>
|
||||
/// Multi-setpoint level alarm: monitors a single numeric attribute against
|
||||
/// up to four configurable setpoints (LoLo, Lo, Hi, HiHi). Each setpoint
|
||||
/// may carry its own priority; transitions between levels emit a fresh
|
||||
/// AlarmStateChanged with the corresponding <see cref="AlarmLevel"/>.
|
||||
/// </summary>
|
||||
HiLo
|
||||
}
|
||||
|
||||
23
src/ScadaLink.Commons/Types/Scripts/AlarmContext.cs
Normal file
23
src/ScadaLink.Commons/Types/Scripts/AlarmContext.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.Commons.Types.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Alarm context exposed to on-trigger scripts via <c>Alarm</c>. Lets scripts
|
||||
/// branch on the firing severity — e.g., page on-call for HiHi/LoLo but only
|
||||
/// email the day shift for Hi/Lo. Always present when an on-trigger script
|
||||
/// runs; <see cref="Level"/> is <see cref="AlarmLevel.None"/> for binary
|
||||
/// trigger types.
|
||||
/// </summary>
|
||||
public sealed class AlarmContext
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public AlarmLevel Level { get; init; } = AlarmLevel.None;
|
||||
public int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-band operator message configured on the HiLo alarm, or empty for
|
||||
/// binary trigger types and bands without a message.
|
||||
/// </summary>
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using ScadaLink.Commons.Messages.Streaming;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Communication.Grpc;
|
||||
using AlarmState = ScadaLink.Commons.Types.Enums.AlarmState;
|
||||
using AlarmLevel = ScadaLink.Commons.Types.Enums.AlarmLevel;
|
||||
|
||||
namespace ScadaLink.Communication.Actors;
|
||||
|
||||
@@ -59,7 +60,9 @@ public class StreamRelayActor : ReceiveActor
|
||||
AlarmName = msg.AlarmName,
|
||||
State = MapAlarmState(msg.State),
|
||||
Priority = msg.Priority,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp)
|
||||
Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp),
|
||||
Level = MapAlarmLevel(msg.Level),
|
||||
Message = msg.Message ?? string.Empty
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,4 +91,13 @@ public class StreamRelayActor : ReceiveActor
|
||||
AlarmState.Active => AlarmStateEnum.AlarmStateActive,
|
||||
_ => AlarmStateEnum.AlarmStateUnspecified
|
||||
};
|
||||
|
||||
private static AlarmLevelEnum MapAlarmLevel(AlarmLevel level) => level switch
|
||||
{
|
||||
AlarmLevel.Low => AlarmLevelEnum.AlarmLevelLow,
|
||||
AlarmLevel.LowLow => AlarmLevelEnum.AlarmLevelLowLow,
|
||||
AlarmLevel.High => AlarmLevelEnum.AlarmLevelHigh,
|
||||
AlarmLevel.HighHigh => AlarmLevelEnum.AlarmLevelHighHigh,
|
||||
_ => AlarmLevelEnum.AlarmLevelNone
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,7 +137,11 @@ public class SiteStreamGrpcClient : IAsyncDisposable
|
||||
evt.AlarmChanged.AlarmName,
|
||||
MapAlarmState(evt.AlarmChanged.State),
|
||||
evt.AlarmChanged.Priority,
|
||||
evt.AlarmChanged.Timestamp.ToDateTimeOffset()),
|
||||
evt.AlarmChanged.Timestamp.ToDateTimeOffset())
|
||||
{
|
||||
Level = MapAlarmLevel(evt.AlarmChanged.Level),
|
||||
Message = evt.AlarmChanged.Message ?? string.Empty
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
|
||||
@@ -162,6 +166,18 @@ public class SiteStreamGrpcClient : IAsyncDisposable
|
||||
_ => AlarmState.Normal
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps proto AlarmLevelEnum to domain AlarmLevel. Internal for testability.
|
||||
/// </summary>
|
||||
internal static AlarmLevel MapAlarmLevel(AlarmLevelEnum level) => level switch
|
||||
{
|
||||
AlarmLevelEnum.AlarmLevelLow => AlarmLevel.Low,
|
||||
AlarmLevelEnum.AlarmLevelLowLow => AlarmLevel.LowLow,
|
||||
AlarmLevelEnum.AlarmLevelHigh => AlarmLevel.High,
|
||||
AlarmLevelEnum.AlarmLevelHighHigh => AlarmLevel.HighHigh,
|
||||
_ => AlarmLevel.None
|
||||
};
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var cts in _subscriptions.Values)
|
||||
|
||||
@@ -34,6 +34,17 @@ enum AlarmStateEnum {
|
||||
ALARM_STATE_ACTIVE = 2;
|
||||
}
|
||||
|
||||
// Severity level for an active alarm. Binary trigger types (ValueMatch,
|
||||
// RangeViolation, RateOfChange) always emit ALARM_LEVEL_NONE. The HiLo
|
||||
// trigger type emits one of the directional values.
|
||||
enum AlarmLevelEnum {
|
||||
ALARM_LEVEL_NONE = 0;
|
||||
ALARM_LEVEL_LOW = 1;
|
||||
ALARM_LEVEL_LOW_LOW = 2;
|
||||
ALARM_LEVEL_HIGH = 3;
|
||||
ALARM_LEVEL_HIGH_HIGH = 4;
|
||||
}
|
||||
|
||||
message AttributeValueUpdate {
|
||||
string instance_unique_name = 1;
|
||||
string attribute_path = 2;
|
||||
@@ -49,4 +60,6 @@ message AlarmStateUpdate {
|
||||
AlarmStateEnum state = 3;
|
||||
int32 priority = 4;
|
||||
google.protobuf.Timestamp timestamp = 5;
|
||||
AlarmLevelEnum level = 6; // ALARM_LEVEL_NONE for binary trigger types; set by HiLo.
|
||||
string message = 7; // Optional per-band operator message; empty when unset.
|
||||
}
|
||||
|
||||
@@ -30,4 +30,21 @@
|
||||
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- gRPC proto generation. The compiled C# is checked in under
|
||||
SiteStreamGrpc/ (Sitestream.cs + SitestreamGrpc.cs) because protoc
|
||||
segfaults inside our linux_arm64 Docker build image. To regenerate
|
||||
after schema changes:
|
||||
1. Temporarily uncomment the Protobuf ItemGroup below.
|
||||
2. Delete SiteStreamGrpc/*.cs.
|
||||
3. `dotnet build` (on macOS) — Grpc.Tools writes fresh files to obj/.
|
||||
4. Copy obj/Debug/net10.0/Protos/*.cs into SiteStreamGrpc/.
|
||||
5. Re-comment the ItemGroup.
|
||||
Eventually we should switch the Docker build image to one with a
|
||||
working protoc on arm64. -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos\sitestream.proto" GrpcServices="Both" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// <auto-generated>
|
||||
// Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
// source: sitestream.proto
|
||||
// source: Protos/sitestream.proto
|
||||
// </auto-generated>
|
||||
#pragma warning disable 1591, 0612, 3021, 8981
|
||||
#region Designer generated code
|
||||
@@ -11,11 +11,11 @@ using pbr = global::Google.Protobuf.Reflection;
|
||||
using scg = global::System.Collections.Generic;
|
||||
namespace ScadaLink.Communication.Grpc {
|
||||
|
||||
/// <summary>Holder for reflection information generated from sitestream.proto</summary>
|
||||
/// <summary>Holder for reflection information generated from Protos/sitestream.proto</summary>
|
||||
public static partial class SitestreamReflection {
|
||||
|
||||
#region Descriptor
|
||||
/// <summary>File descriptor for sitestream.proto</summary>
|
||||
/// <summary>File descriptor for Protos/sitestream.proto</summary>
|
||||
public static pbr::FileDescriptor Descriptor {
|
||||
get { return descriptor; }
|
||||
}
|
||||
@@ -24,36 +24,41 @@ namespace ScadaLink.Communication.Grpc {
|
||||
static SitestreamReflection() {
|
||||
byte[] descriptorData = global::System.Convert.FromBase64String(
|
||||
string.Concat(
|
||||
"ChBzaXRlc3RyZWFtLnByb3RvEgpzaXRlc3RyZWFtGh9nb29nbGUvcHJvdG9i",
|
||||
"dWYvdGltZXN0YW1wLnByb3RvIk0KFUluc3RhbmNlU3RyZWFtUmVxdWVzdBIW",
|
||||
"Cg5jb3JyZWxhdGlvbl9pZBgBIAEoCRIcChRpbnN0YW5jZV91bmlxdWVfbmFt",
|
||||
"ZRgCIAEoCSKoAQoPU2l0ZVN0cmVhbUV2ZW50EhYKDmNvcnJlbGF0aW9uX2lk",
|
||||
"GAEgASgJEj0KEWF0dHJpYnV0ZV9jaGFuZ2VkGAIgASgLMiAuc2l0ZXN0cmVh",
|
||||
"bS5BdHRyaWJ1dGVWYWx1ZVVwZGF0ZUgAEjUKDWFsYXJtX2NoYW5nZWQYAyAB",
|
||||
"KAsyHC5zaXRlc3RyZWFtLkFsYXJtU3RhdGVVcGRhdGVIAEIHCgVldmVudCLI",
|
||||
"AQoUQXR0cmlidXRlVmFsdWVVcGRhdGUSHAoUaW5zdGFuY2VfdW5pcXVlX25h",
|
||||
"bWUYASABKAkSFgoOYXR0cmlidXRlX3BhdGgYAiABKAkSFgoOYXR0cmlidXRl",
|
||||
"X25hbWUYAyABKAkSDQoFdmFsdWUYBCABKAkSJAoHcXVhbGl0eRgFIAEoDjIT",
|
||||
"LnNpdGVzdHJlYW0uUXVhbGl0eRItCgl0aW1lc3RhbXAYBiABKAsyGi5nb29n",
|
||||
"bGUucHJvdG9idWYuVGltZXN0YW1wIrABChBBbGFybVN0YXRlVXBkYXRlEhwK",
|
||||
"FGluc3RhbmNlX3VuaXF1ZV9uYW1lGAEgASgJEhIKCmFsYXJtX25hbWUYAiAB",
|
||||
"KAkSKQoFc3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVt",
|
||||
"EhAKCHByaW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2ds",
|
||||
"ZS5wcm90b2J1Zi5UaW1lc3RhbXAqXAoHUXVhbGl0eRIXChNRVUFMSVRZX1VO",
|
||||
"U1BFQ0lGSUVEEAASEAoMUVVBTElUWV9HT09EEAESFQoRUVVBTElUWV9VTkNF",
|
||||
"UlRBSU4QAhIPCgtRVUFMSVRZX0JBRBADKl0KDkFsYXJtU3RhdGVFbnVtEhsK",
|
||||
"F0FMQVJNX1NUQVRFX1VOU1BFQ0lGSUVEEAASFgoSQUxBUk1fU1RBVEVfTk9S",
|
||||
"TUFMEAESFgoSQUxBUk1fU1RBVEVfQUNUSVZFEAIyagoRU2l0ZVN0cmVhbVNl",
|
||||
"cnZpY2USVQoRU3Vic2NyaWJlSW5zdGFuY2USIS5zaXRlc3RyZWFtLkluc3Rh",
|
||||
"bmNlU3RyZWFtUmVxdWVzdBobLnNpdGVzdHJlYW0uU2l0ZVN0cmVhbUV2ZW50",
|
||||
"MAFCH6oCHFNjYWRhTGluay5Db21tdW5pY2F0aW9uLkdycGNiBnByb3RvMw=="));
|
||||
"ChdQcm90b3Mvc2l0ZXN0cmVhbS5wcm90bxIKc2l0ZXN0cmVhbRofZ29vZ2xl",
|
||||
"L3Byb3RvYnVmL3RpbWVzdGFtcC5wcm90byJNChVJbnN0YW5jZVN0cmVhbVJl",
|
||||
"cXVlc3QSFgoOY29ycmVsYXRpb25faWQYASABKAkSHAoUaW5zdGFuY2VfdW5p",
|
||||
"cXVlX25hbWUYAiABKAkiqAEKD1NpdGVTdHJlYW1FdmVudBIWCg5jb3JyZWxh",
|
||||
"dGlvbl9pZBgBIAEoCRI9ChFhdHRyaWJ1dGVfY2hhbmdlZBgCIAEoCzIgLnNp",
|
||||
"dGVzdHJlYW0uQXR0cmlidXRlVmFsdWVVcGRhdGVIABI1Cg1hbGFybV9jaGFu",
|
||||
"Z2VkGAMgASgLMhwuc2l0ZXN0cmVhbS5BbGFybVN0YXRlVXBkYXRlSABCBwoF",
|
||||
"ZXZlbnQiyAEKFEF0dHJpYnV0ZVZhbHVlVXBkYXRlEhwKFGluc3RhbmNlX3Vu",
|
||||
"aXF1ZV9uYW1lGAEgASgJEhYKDmF0dHJpYnV0ZV9wYXRoGAIgASgJEhYKDmF0",
|
||||
"dHJpYnV0ZV9uYW1lGAMgASgJEg0KBXZhbHVlGAQgASgJEiQKB3F1YWxpdHkY",
|
||||
"BSABKA4yEy5zaXRlc3RyZWFtLlF1YWxpdHkSLQoJdGltZXN0YW1wGAYgASgL",
|
||||
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCLsAQoQQWxhcm1TdGF0ZVVw",
|
||||
"ZGF0ZRIcChRpbnN0YW5jZV91bmlxdWVfbmFtZRgBIAEoCRISCgphbGFybV9u",
|
||||
"YW1lGAIgASgJEikKBXN0YXRlGAMgASgOMhouc2l0ZXN0cmVhbS5BbGFybVN0",
|
||||
"YXRlRW51bRIQCghwcmlvcml0eRgEIAEoBRItCgl0aW1lc3RhbXAYBSABKAsy",
|
||||
"Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEikKBWxldmVsGAYgASgOMhou",
|
||||
"c2l0ZXN0cmVhbS5BbGFybUxldmVsRW51bRIPCgdtZXNzYWdlGAcgASgJKlwK",
|
||||
"B1F1YWxpdHkSFwoTUVVBTElUWV9VTlNQRUNJRklFRBAAEhAKDFFVQUxJVFlf",
|
||||
"R09PRBABEhUKEVFVQUxJVFlfVU5DRVJUQUlOEAISDwoLUVVBTElUWV9CQUQQ",
|
||||
"AypdCg5BbGFybVN0YXRlRW51bRIbChdBTEFSTV9TVEFURV9VTlNQRUNJRklF",
|
||||
"RBAAEhYKEkFMQVJNX1NUQVRFX05PUk1BTBABEhYKEkFMQVJNX1NUQVRFX0FD",
|
||||
"VElWRRACKoUBCg5BbGFybUxldmVsRW51bRIUChBBTEFSTV9MRVZFTF9OT05F",
|
||||
"EAASEwoPQUxBUk1fTEVWRUxfTE9XEAESFwoTQUxBUk1fTEVWRUxfTE9XX0xP",
|
||||
"VxACEhQKEEFMQVJNX0xFVkVMX0hJR0gQAxIZChVBTEFSTV9MRVZFTF9ISUdI",
|
||||
"X0hJR0gQBDJqChFTaXRlU3RyZWFtU2VydmljZRJVChFTdWJzY3JpYmVJbnN0",
|
||||
"YW5jZRIhLnNpdGVzdHJlYW0uSW5zdGFuY2VTdHJlYW1SZXF1ZXN0Ghsuc2l0",
|
||||
"ZXN0cmVhbS5TaXRlU3RyZWFtRXZlbnQwAUIfqgIcU2NhZGFMaW5rLkNvbW11",
|
||||
"bmljYXRpb24uR3JwY2IGcHJvdG8z"));
|
||||
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
||||
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, },
|
||||
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
|
||||
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), typeof(global::ScadaLink.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.InstanceStreamRequest), global::ScadaLink.Communication.Grpc.InstanceStreamRequest.Parser, new[]{ "CorrelationId", "InstanceUniqueName" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteStreamEvent), global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AttributeValueUpdate), global::ScadaLink.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp" }, null, null, null, null)
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null)
|
||||
}));
|
||||
}
|
||||
#endregion
|
||||
@@ -73,6 +78,19 @@ namespace ScadaLink.Communication.Grpc {
|
||||
[pbr::OriginalName("ALARM_STATE_ACTIVE")] AlarmStateActive = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity level for an active alarm. Binary trigger types (ValueMatch,
|
||||
/// RangeViolation, RateOfChange) always emit ALARM_LEVEL_NONE. The HiLo
|
||||
/// trigger type emits one of the directional values.
|
||||
/// </summary>
|
||||
public enum AlarmLevelEnum {
|
||||
[pbr::OriginalName("ALARM_LEVEL_NONE")] AlarmLevelNone = 0,
|
||||
[pbr::OriginalName("ALARM_LEVEL_LOW")] AlarmLevelLow = 1,
|
||||
[pbr::OriginalName("ALARM_LEVEL_LOW_LOW")] AlarmLevelLowLow = 2,
|
||||
[pbr::OriginalName("ALARM_LEVEL_HIGH")] AlarmLevelHigh = 3,
|
||||
[pbr::OriginalName("ALARM_LEVEL_HIGH_HIGH")] AlarmLevelHighHigh = 4,
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Messages
|
||||
@@ -1074,6 +1092,8 @@ namespace ScadaLink.Communication.Grpc {
|
||||
state_ = other.state_;
|
||||
priority_ = other.priority_;
|
||||
timestamp_ = other.timestamp_ != null ? other.timestamp_.Clone() : null;
|
||||
level_ = other.level_;
|
||||
message_ = other.message_;
|
||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -1143,6 +1163,36 @@ namespace ScadaLink.Communication.Grpc {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "level" field.</summary>
|
||||
public const int LevelFieldNumber = 6;
|
||||
private global::ScadaLink.Communication.Grpc.AlarmLevelEnum level_ = global::ScadaLink.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone;
|
||||
/// <summary>
|
||||
/// ALARM_LEVEL_NONE for binary trigger types; set by HiLo.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public global::ScadaLink.Communication.Grpc.AlarmLevelEnum Level {
|
||||
get { return level_; }
|
||||
set {
|
||||
level_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "message" field.</summary>
|
||||
public const int MessageFieldNumber = 7;
|
||||
private string message_ = "";
|
||||
/// <summary>
|
||||
/// Optional per-band operator message; empty when unset.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public string Message {
|
||||
get { return message_; }
|
||||
set {
|
||||
message_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override bool Equals(object other) {
|
||||
@@ -1163,6 +1213,8 @@ namespace ScadaLink.Communication.Grpc {
|
||||
if (State != other.State) return false;
|
||||
if (Priority != other.Priority) return false;
|
||||
if (!object.Equals(Timestamp, other.Timestamp)) return false;
|
||||
if (Level != other.Level) return false;
|
||||
if (Message != other.Message) return false;
|
||||
return Equals(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -1175,6 +1227,8 @@ namespace ScadaLink.Communication.Grpc {
|
||||
if (State != global::ScadaLink.Communication.Grpc.AlarmStateEnum.AlarmStateUnspecified) hash ^= State.GetHashCode();
|
||||
if (Priority != 0) hash ^= Priority.GetHashCode();
|
||||
if (timestamp_ != null) hash ^= Timestamp.GetHashCode();
|
||||
if (Level != global::ScadaLink.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone) hash ^= Level.GetHashCode();
|
||||
if (Message.Length != 0) hash ^= Message.GetHashCode();
|
||||
if (_unknownFields != null) {
|
||||
hash ^= _unknownFields.GetHashCode();
|
||||
}
|
||||
@@ -1213,6 +1267,14 @@ namespace ScadaLink.Communication.Grpc {
|
||||
output.WriteRawTag(42);
|
||||
output.WriteMessage(Timestamp);
|
||||
}
|
||||
if (Level != global::ScadaLink.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone) {
|
||||
output.WriteRawTag(48);
|
||||
output.WriteEnum((int) Level);
|
||||
}
|
||||
if (Message.Length != 0) {
|
||||
output.WriteRawTag(58);
|
||||
output.WriteString(Message);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(output);
|
||||
}
|
||||
@@ -1243,6 +1305,14 @@ namespace ScadaLink.Communication.Grpc {
|
||||
output.WriteRawTag(42);
|
||||
output.WriteMessage(Timestamp);
|
||||
}
|
||||
if (Level != global::ScadaLink.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone) {
|
||||
output.WriteRawTag(48);
|
||||
output.WriteEnum((int) Level);
|
||||
}
|
||||
if (Message.Length != 0) {
|
||||
output.WriteRawTag(58);
|
||||
output.WriteString(Message);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(ref output);
|
||||
}
|
||||
@@ -1268,6 +1338,12 @@ namespace ScadaLink.Communication.Grpc {
|
||||
if (timestamp_ != null) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeMessageSize(Timestamp);
|
||||
}
|
||||
if (Level != global::ScadaLink.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) Level);
|
||||
}
|
||||
if (Message.Length != 0) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeStringSize(Message);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
size += _unknownFields.CalculateSize();
|
||||
}
|
||||
@@ -1298,6 +1374,12 @@ namespace ScadaLink.Communication.Grpc {
|
||||
}
|
||||
Timestamp.MergeFrom(other.Timestamp);
|
||||
}
|
||||
if (other.Level != global::ScadaLink.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone) {
|
||||
Level = other.Level;
|
||||
}
|
||||
if (other.Message.Length != 0) {
|
||||
Message = other.Message;
|
||||
}
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -1340,6 +1422,14 @@ namespace ScadaLink.Communication.Grpc {
|
||||
input.ReadMessage(Timestamp);
|
||||
break;
|
||||
}
|
||||
case 48: {
|
||||
Level = (global::ScadaLink.Communication.Grpc.AlarmLevelEnum) input.ReadEnum();
|
||||
break;
|
||||
}
|
||||
case 58: {
|
||||
Message = input.ReadString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1382,6 +1472,14 @@ namespace ScadaLink.Communication.Grpc {
|
||||
input.ReadMessage(Timestamp);
|
||||
break;
|
||||
}
|
||||
case 48: {
|
||||
Level = (global::ScadaLink.Communication.Grpc.AlarmLevelEnum) input.ReadEnum();
|
||||
break;
|
||||
}
|
||||
case 58: {
|
||||
Message = input.ReadString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// <auto-generated>
|
||||
// Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
// source: sitestream.proto
|
||||
// source: Protos/sitestream.proto
|
||||
// </auto-generated>
|
||||
#pragma warning disable 0414, 1591, 8981, 0612
|
||||
#region Designer generated code
|
||||
|
||||
@@ -41,6 +41,11 @@ public class InstanceConfiguration : IEntityTypeConfiguration<Instance>
|
||||
.HasForeignKey(o => o.InstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(i => i.AlarmOverrides)
|
||||
.WithOne()
|
||||
.HasForeignKey(o => o.InstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(i => i.ConnectionBindings)
|
||||
.WithOne()
|
||||
.HasForeignKey(b => b.InstanceId)
|
||||
@@ -67,6 +72,23 @@ public class InstanceAttributeOverrideConfiguration : IEntityTypeConfiguration<I
|
||||
}
|
||||
}
|
||||
|
||||
public class InstanceAlarmOverrideConfiguration : IEntityTypeConfiguration<InstanceAlarmOverride>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<InstanceAlarmOverride> builder)
|
||||
{
|
||||
builder.HasKey(o => o.Id);
|
||||
|
||||
builder.Property(o => o.AlarmCanonicalName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(400); // Larger than attribute names to fit composed paths.
|
||||
|
||||
builder.Property(o => o.TriggerConfigurationOverride)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(o => new { o.InstanceId, o.AlarmCanonicalName }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class InstanceConnectionBindingConfiguration : IEntityTypeConfiguration<InstanceConnectionBinding>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<InstanceConnectionBinding> builder)
|
||||
|
||||
1342
src/ScadaLink.ConfigurationDatabase/Migrations/20260513055537_AddInstanceAlarmOverrides.Designer.cs
generated
Normal file
1342
src/ScadaLink.ConfigurationDatabase/Migrations/20260513055537_AddInstanceAlarmOverrides.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInstanceAlarmOverrides : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InstanceAlarmOverrides",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
InstanceId = table.Column<int>(type: "int", nullable: false),
|
||||
AlarmCanonicalName = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: false),
|
||||
TriggerConfigurationOverride = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
PriorityLevelOverride = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InstanceAlarmOverrides", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_InstanceAlarmOverrides_Instances_InstanceId",
|
||||
column: x => x.InstanceId,
|
||||
principalTable: "Instances",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InstanceAlarmOverrides_InstanceId_AlarmCanonicalName",
|
||||
table: "InstanceAlarmOverrides",
|
||||
columns: new[] { "InstanceId", "AlarmCanonicalName" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "InstanceAlarmOverrides");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -478,6 +478,37 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
b.ToTable("Instances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AlarmCanonicalName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("nvarchar(400)");
|
||||
|
||||
b.Property<int>("InstanceId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("PriorityLevelOverride")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("TriggerConfigurationOverride")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstanceId", "AlarmCanonicalName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InstanceAlarmOverrides");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1144,6 +1175,15 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b =>
|
||||
{
|
||||
b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null)
|
||||
.WithMany("AlarmOverrides")
|
||||
.HasForeignKey("InstanceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b =>
|
||||
{
|
||||
b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null)
|
||||
@@ -1271,6 +1311,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
|
||||
modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b =>
|
||||
{
|
||||
b.Navigation("AlarmOverrides");
|
||||
|
||||
b.Navigation("AttributeOverrides");
|
||||
|
||||
b.Navigation("ConnectionBindings");
|
||||
|
||||
@@ -169,6 +169,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
{
|
||||
return await _dbContext.Set<Instance>()
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
|
||||
}
|
||||
@@ -177,6 +178,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
{
|
||||
return await _dbContext.Set<Instance>()
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -222,6 +222,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
{
|
||||
return await _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
|
||||
}
|
||||
@@ -230,6 +231,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
{
|
||||
return await _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
@@ -246,6 +248,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
return await _context.Instances
|
||||
.Where(i => i.SiteId == siteId)
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
@@ -254,6 +257,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
{
|
||||
return await _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
||||
}
|
||||
@@ -307,6 +311,43 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
}
|
||||
}
|
||||
|
||||
// InstanceAlarmOverride
|
||||
|
||||
public async Task<IReadOnlyList<InstanceAlarmOverride>> GetAlarmOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.InstanceAlarmOverrides
|
||||
.Where(o => o.InstanceId == instanceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<InstanceAlarmOverride?> GetAlarmOverrideAsync(int instanceId, string alarmCanonicalName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.InstanceAlarmOverrides
|
||||
.FirstOrDefaultAsync(
|
||||
o => o.InstanceId == instanceId && o.AlarmCanonicalName == alarmCanonicalName,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.InstanceAlarmOverrides.AddAsync(alarmOverride, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.InstanceAlarmOverrides.Update(alarmOverride);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteInstanceAlarmOverrideAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var alarmOverride = await _context.InstanceAlarmOverrides.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (alarmOverride != null)
|
||||
{
|
||||
_context.InstanceAlarmOverrides.Remove(alarmOverride);
|
||||
}
|
||||
}
|
||||
|
||||
// InstanceConnectionBinding
|
||||
|
||||
public async Task<IReadOnlyList<InstanceConnectionBinding>> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -30,6 +30,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
|
||||
// Instances
|
||||
public DbSet<Instance> Instances => Set<Instance>();
|
||||
public DbSet<InstanceAttributeOverride> InstanceAttributeOverrides => Set<InstanceAttributeOverride>();
|
||||
public DbSet<InstanceAlarmOverride> InstanceAlarmOverrides => Set<InstanceAlarmOverride>();
|
||||
public DbSet<InstanceConnectionBinding> InstanceConnectionBindings => Set<InstanceConnectionBinding>();
|
||||
public DbSet<Area> Areas => Set<Area>();
|
||||
|
||||
|
||||
@@ -26,17 +26,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/_framework/blazor.web.js"></script>
|
||||
<script src="/_framework/blazor.web.js"
|
||||
autostart="false"></script>
|
||||
<script>
|
||||
// Reconnection overlay for failover behavior
|
||||
// Blazor object is available after blazor.web.js initializes
|
||||
// Reconnection overlay for failover behavior. After a docker redeploy
|
||||
// (or other server-side restart), Blazor exhausts its retry budget and
|
||||
// leaves the user staring at a stuck "Reconnect failed" overlay. Auto-
|
||||
// reload in that case so the user lands on a fresh circuit instead of
|
||||
// having to manually refresh.
|
||||
Blazor.start({
|
||||
circuit: {
|
||||
reconnectionOptions: {
|
||||
maxRetries: 8,
|
||||
retryIntervalMilliseconds: 1500
|
||||
},
|
||||
reconnectionHandler: {
|
||||
onConnectionDown: () => { /* default overlay */ },
|
||||
onConnectionUp: () => {
|
||||
var m = document.getElementById('reconnect-modal');
|
||||
if (m) m.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof Blazor !== 'undefined') {
|
||||
Blazor.addEventListener('enhancedload', () => {
|
||||
document.getElementById('reconnect-modal').style.display = 'none';
|
||||
Blazor.addEventListener?.('enhancedload', () => {
|
||||
var m = document.getElementById('reconnect-modal');
|
||||
if (m) m.style.display = 'none';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// When Blazor gives up reconnecting, it adds the
|
||||
// `components-reconnect-failed` class to the reconnect modal element.
|
||||
// Watch for it and auto-reload so the user gets a fresh circuit.
|
||||
var mo = new MutationObserver(() => {
|
||||
var m = document.getElementById('reconnect-modal');
|
||||
if (!m) return;
|
||||
if (m.classList.contains('components-reconnect-failed')) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
mo.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
</script>
|
||||
<script src="/js/treeview-storage.js"></script>
|
||||
<script src="_content/ScadaLink.CentralUI/js/monaco-init.js"></script>
|
||||
|
||||
@@ -119,6 +119,7 @@ public class ManagementActor : ReceiveActor
|
||||
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
|
||||
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
|
||||
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
|
||||
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
|
||||
or GetDeploymentDiffCommand
|
||||
or MgmtDeployArtifactsCommand
|
||||
or RetryParkedMessageCommand or DiscardParkedMessageCommand
|
||||
@@ -172,6 +173,9 @@ public class ManagementActor : ReceiveActor
|
||||
SetConnectionBindingsCommand cmd => await HandleSetConnectionBindings(sp, cmd, user),
|
||||
SetInstanceOverridesCommand cmd => await HandleSetInstanceOverrides(sp, cmd, user),
|
||||
SetInstanceAreaCommand cmd => await HandleSetInstanceArea(sp, cmd, user),
|
||||
SetInstanceAlarmOverrideCommand cmd => await HandleSetInstanceAlarmOverride(sp, cmd, user),
|
||||
DeleteInstanceAlarmOverrideCommand cmd => await HandleDeleteInstanceAlarmOverride(sp, cmd, user),
|
||||
ListInstanceAlarmOverridesCommand cmd => await HandleListInstanceAlarmOverrides(sp, cmd, user),
|
||||
|
||||
// Sites
|
||||
ListSitesCommand => await HandleListSites(sp, user),
|
||||
@@ -593,6 +597,37 @@ public class ManagementActor : ReceiveActor
|
||||
: throw new InvalidOperationException(result.Error);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleSetInstanceAlarmOverride(IServiceProvider sp, SetInstanceAlarmOverrideCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
var svc = sp.GetRequiredService<InstanceService>();
|
||||
var result = await svc.SetAlarmOverrideAsync(
|
||||
cmd.InstanceId, cmd.AlarmCanonicalName,
|
||||
cmd.TriggerConfigurationOverride, cmd.PriorityLevelOverride,
|
||||
user.Username);
|
||||
return result.IsSuccess
|
||||
? result.Value
|
||||
: throw new InvalidOperationException(result.Error);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleDeleteInstanceAlarmOverride(IServiceProvider sp, DeleteInstanceAlarmOverrideCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
var svc = sp.GetRequiredService<InstanceService>();
|
||||
var result = await svc.DeleteAlarmOverrideAsync(
|
||||
cmd.InstanceId, cmd.AlarmCanonicalName, user.Username);
|
||||
return result.IsSuccess
|
||||
? result.Value
|
||||
: throw new InvalidOperationException(result.Error);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleListInstanceAlarmOverrides(IServiceProvider sp, ListInstanceAlarmOverridesCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
return await repo.GetAlarmOverridesByInstanceIdAsync(cmd.InstanceId);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleGetDeploymentDiff(IServiceProvider sp, GetDeploymentDiffCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
|
||||
@@ -38,6 +38,12 @@ public class AlarmActor : ReceiveActor
|
||||
private readonly ISiteHealthCollector? _healthCollector;
|
||||
|
||||
private AlarmState _currentState = AlarmState.Normal;
|
||||
/// <summary>
|
||||
/// Always <see cref="AlarmLevel.None"/> for binary trigger types. For
|
||||
/// <see cref="AlarmTriggerType.HiLo"/> this is the source of truth — the
|
||||
/// state machine transitions when the computed level changes.
|
||||
/// </summary>
|
||||
private AlarmLevel _currentLevel = AlarmLevel.None;
|
||||
private readonly AlarmTriggerType _triggerType;
|
||||
private readonly AlarmEvalConfig _evalConfig;
|
||||
private readonly int _priority;
|
||||
@@ -126,6 +132,12 @@ public class AlarmActor : ReceiveActor
|
||||
|
||||
try
|
||||
{
|
||||
if (_triggerType == AlarmTriggerType.HiLo)
|
||||
{
|
||||
HandleHiLoTransition(EvaluateHiLo(changed.Value));
|
||||
return;
|
||||
}
|
||||
|
||||
var isTriggered = _triggerType switch
|
||||
{
|
||||
AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value),
|
||||
@@ -150,7 +162,7 @@ public class AlarmActor : ReceiveActor
|
||||
// Spawn AlarmExecutionActor if on-trigger script defined
|
||||
if (_onTriggerCompiledScript != null)
|
||||
{
|
||||
SpawnAlarmExecution();
|
||||
SpawnAlarmExecution(AlarmLevel.None, _priority, string.Empty);
|
||||
}
|
||||
}
|
||||
else if (!isTriggered && _currentState == AlarmState.Active)
|
||||
@@ -176,6 +188,78 @@ public class AlarmActor : ReceiveActor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HiLo state machine: emit an AlarmStateChanged whenever the evaluated
|
||||
/// level changes. Spawns the on-trigger script only on the Normal→Active
|
||||
/// edge (i.e., when entering an alarm band from the normal band) — not on
|
||||
/// level escalations like Hi→HiHi or Low→LowLow.
|
||||
/// </summary>
|
||||
private void HandleHiLoTransition(AlarmLevel newLevel)
|
||||
{
|
||||
if (newLevel == _currentLevel) return;
|
||||
|
||||
var previousLevel = _currentLevel;
|
||||
_currentLevel = newLevel;
|
||||
_currentState = newLevel == AlarmLevel.None ? AlarmState.Normal : AlarmState.Active;
|
||||
var priority = LevelPriority(newLevel);
|
||||
var message = LevelMessage(newLevel);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Alarm {Alarm} on {Instance} transitioned {Prev} → {New} (priority={Priority})",
|
||||
_alarmName, _instanceName, previousLevel, newLevel, priority);
|
||||
|
||||
var alarmChanged = new AlarmStateChanged(
|
||||
_instanceName, _alarmName, _currentState, priority, DateTimeOffset.UtcNow)
|
||||
{
|
||||
Level = newLevel,
|
||||
Message = message
|
||||
};
|
||||
_instanceActor.Tell(alarmChanged);
|
||||
|
||||
if (previousLevel == AlarmLevel.None
|
||||
&& newLevel != AlarmLevel.None
|
||||
&& _onTriggerCompiledScript != null)
|
||||
{
|
||||
SpawnAlarmExecution(newLevel, priority, message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the per-setpoint priority for the given level. Falls back to
|
||||
/// the alarm-level <see cref="_priority"/> when the HiLo config did not
|
||||
/// override the priority for that band, or for <see cref="AlarmLevel.None"/>.
|
||||
/// </summary>
|
||||
private int LevelPriority(AlarmLevel level)
|
||||
{
|
||||
if (_evalConfig is not HiLoEvalConfig hiLo) return _priority;
|
||||
return level switch
|
||||
{
|
||||
AlarmLevel.LowLow => hiLo.LoLoPriority ?? _priority,
|
||||
AlarmLevel.Low => hiLo.LoPriority ?? _priority,
|
||||
AlarmLevel.High => hiLo.HiPriority ?? _priority,
|
||||
AlarmLevel.HighHigh => hiLo.HiHiPriority ?? _priority,
|
||||
_ => _priority
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-band operator message. Empty string when no message is configured
|
||||
/// for the band, or for non-HiLo trigger types, or for the None level
|
||||
/// (alarm clear).
|
||||
/// </summary>
|
||||
private string LevelMessage(AlarmLevel level)
|
||||
{
|
||||
if (_evalConfig is not HiLoEvalConfig hiLo) return string.Empty;
|
||||
return level switch
|
||||
{
|
||||
AlarmLevel.LowLow => hiLo.LoLoMessage ?? string.Empty,
|
||||
AlarmLevel.Low => hiLo.LoMessage ?? string.Empty,
|
||||
AlarmLevel.High => hiLo.HiMessage ?? string.Empty,
|
||||
AlarmLevel.HighHigh => hiLo.HiHiMessage ?? string.Empty,
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsMonitoredAttribute(string attributeName)
|
||||
{
|
||||
return _evalConfig.MonitoredAttributeName == attributeName;
|
||||
@@ -254,9 +338,57 @@ public class AlarmActor : ReceiveActor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns an AlarmExecutionActor to run the on-trigger script.
|
||||
/// HiLo level evaluator: returns the most-severe matching band for the
|
||||
/// given value. Severity order checked from highest to lowest so that a
|
||||
/// value at exactly Hi==HiHi resolves to HighHigh. Unset setpoints (null)
|
||||
/// are skipped, allowing partial configs (e.g., HighHigh only).
|
||||
///
|
||||
/// Hysteresis: when the alarm is already in a level whose threshold the
|
||||
/// value would re-cross from inside, the threshold is relaxed by the
|
||||
/// configured deadband. This prevents flapping at the boundary — once at
|
||||
/// HighHigh with HiHi=100 and hiHiDeadband=5, the alarm stays HighHigh
|
||||
/// until the value drops below 95.
|
||||
/// </summary>
|
||||
private void SpawnAlarmExecution()
|
||||
private AlarmLevel EvaluateHiLo(object? value)
|
||||
{
|
||||
if (_evalConfig is not HiLoEvalConfig config) return AlarmLevel.None;
|
||||
if (value == null) return _currentLevel;
|
||||
|
||||
double numericValue;
|
||||
try { numericValue = Convert.ToDouble(value); }
|
||||
catch { return _currentLevel; }
|
||||
|
||||
// When the current level is at-or-above HighHigh, relax the HiHi exit.
|
||||
// Same for the other directions.
|
||||
var hiHiThreshold = config.HiHi;
|
||||
if (hiHiThreshold is { } hh && _currentLevel == AlarmLevel.HighHigh)
|
||||
hiHiThreshold = hh - Math.Max(0, config.HiHiDeadband ?? 0);
|
||||
|
||||
var hiThreshold = config.Hi;
|
||||
if (hiThreshold is { } h && (_currentLevel == AlarmLevel.High || _currentLevel == AlarmLevel.HighHigh))
|
||||
hiThreshold = h - Math.Max(0, config.HiDeadband ?? 0);
|
||||
|
||||
var loLoThreshold = config.LoLo;
|
||||
if (loLoThreshold is { } ll && _currentLevel == AlarmLevel.LowLow)
|
||||
loLoThreshold = ll + Math.Max(0, config.LoLoDeadband ?? 0);
|
||||
|
||||
var loThreshold = config.Lo;
|
||||
if (loThreshold is { } l && (_currentLevel == AlarmLevel.Low || _currentLevel == AlarmLevel.LowLow))
|
||||
loThreshold = l + Math.Max(0, config.LoDeadband ?? 0);
|
||||
|
||||
if (hiHiThreshold is { } effHiHi && numericValue >= effHiHi) return AlarmLevel.HighHigh;
|
||||
if (hiThreshold is { } effHi && numericValue >= effHi) return AlarmLevel.High;
|
||||
if (loLoThreshold is { } effLoLo && numericValue <= effLoLo) return AlarmLevel.LowLow;
|
||||
if (loThreshold is { } effLo && numericValue <= effLo) return AlarmLevel.Low;
|
||||
return AlarmLevel.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns an AlarmExecutionActor to run the on-trigger script.
|
||||
/// Passes the firing alarm's level/priority/message so the script can
|
||||
/// branch on severity via the <c>Alarm</c> global.
|
||||
/// </summary>
|
||||
private void SpawnAlarmExecution(AlarmLevel level, int priority, string message)
|
||||
{
|
||||
if (_onTriggerCompiledScript == null) return;
|
||||
|
||||
@@ -266,6 +398,9 @@ public class AlarmActor : ReceiveActor
|
||||
var props = Props.Create(() => new AlarmExecutionActor(
|
||||
_alarmName,
|
||||
_instanceName,
|
||||
level,
|
||||
priority,
|
||||
message,
|
||||
_onTriggerCompiledScript,
|
||||
_instanceActor,
|
||||
_sharedScriptLibrary,
|
||||
@@ -319,6 +454,25 @@ public class AlarmActor : ReceiveActor
|
||||
? ParseDirection(dirEl.GetString())
|
||||
: RateOfChangeDirection.Either),
|
||||
|
||||
AlarmTriggerType.HiLo => new HiLoEvalConfig(
|
||||
attr,
|
||||
LoLo: TryReadDouble(root, "loLo"),
|
||||
Lo: TryReadDouble(root, "lo"),
|
||||
Hi: TryReadDouble(root, "hi"),
|
||||
HiHi: TryReadDouble(root, "hiHi"),
|
||||
LoLoPriority: TryReadInt(root, "loLoPriority"),
|
||||
LoPriority: TryReadInt(root, "loPriority"),
|
||||
HiPriority: TryReadInt(root, "hiPriority"),
|
||||
HiHiPriority: TryReadInt(root, "hiHiPriority"),
|
||||
LoLoDeadband: TryReadDouble(root, "loLoDeadband"),
|
||||
LoDeadband: TryReadDouble(root, "loDeadband"),
|
||||
HiDeadband: TryReadDouble(root, "hiDeadband"),
|
||||
HiHiDeadband: TryReadDouble(root, "hiHiDeadband"),
|
||||
LoLoMessage: TryReadString(root, "loLoMessage"),
|
||||
LoMessage: TryReadString(root, "loMessage"),
|
||||
HiMessage: TryReadString(root, "hiMessage"),
|
||||
HiHiMessage: TryReadString(root, "hiHiMessage")),
|
||||
|
||||
_ => new ValueMatchEvalConfig(attr, null)
|
||||
};
|
||||
}
|
||||
@@ -336,6 +490,35 @@ public class AlarmActor : ReceiveActor
|
||||
_ => RateOfChangeDirection.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(), System.Globalization.NumberStyles.Float, System.Globalization.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(), System.Globalization.NumberStyles.Integer, System.Globalization.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 messages ──
|
||||
internal record AlarmExecutionCompleted(string AlarmName, bool Success);
|
||||
}
|
||||
@@ -351,3 +534,27 @@ internal record RateOfChangeEvalConfig(
|
||||
double ThresholdPerSecond,
|
||||
TimeSpan WindowDuration,
|
||||
RateOfChangeDirection Direction) : AlarmEvalConfig(MonitoredAttributeName);
|
||||
|
||||
/// <summary>
|
||||
/// HiLo evaluation config: any subset of the four setpoints may be set; null
|
||||
/// means "don't evaluate that band". Per-setpoint priorities override the
|
||||
/// alarm-level priority for AlarmStateChanged messages emitted for that band.
|
||||
/// </summary>
|
||||
internal record HiLoEvalConfig(
|
||||
string MonitoredAttributeName,
|
||||
double? LoLo,
|
||||
double? Lo,
|
||||
double? Hi,
|
||||
double? HiHi,
|
||||
int? LoLoPriority,
|
||||
int? LoPriority,
|
||||
int? HiPriority,
|
||||
int? HiHiPriority,
|
||||
double? LoLoDeadband = null,
|
||||
double? LoDeadband = null,
|
||||
double? HiDeadband = null,
|
||||
double? HiHiDeadband = null,
|
||||
string? LoLoMessage = null,
|
||||
string? LoMessage = null,
|
||||
string? HiMessage = null,
|
||||
string? HiHiMessage = null) : AlarmEvalConfig(MonitoredAttributeName);
|
||||
|
||||
@@ -2,6 +2,8 @@ using Akka.Actor;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Scripts;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Actors;
|
||||
@@ -18,6 +20,9 @@ public class AlarmExecutionActor : ReceiveActor
|
||||
public AlarmExecutionActor(
|
||||
string alarmName,
|
||||
string instanceName,
|
||||
AlarmLevel level,
|
||||
int priority,
|
||||
string message,
|
||||
Script<object?> compiledScript,
|
||||
IActorRef instanceActor,
|
||||
SharedScriptLibrary sharedScriptLibrary,
|
||||
@@ -28,13 +33,17 @@ public class AlarmExecutionActor : ReceiveActor
|
||||
var parent = Context.Parent;
|
||||
|
||||
ExecuteAlarmScript(
|
||||
alarmName, instanceName, compiledScript, instanceActor,
|
||||
alarmName, instanceName, level, priority, message,
|
||||
compiledScript, instanceActor,
|
||||
sharedScriptLibrary, options, self, parent, logger);
|
||||
}
|
||||
|
||||
private static void ExecuteAlarmScript(
|
||||
string alarmName,
|
||||
string instanceName,
|
||||
AlarmLevel level,
|
||||
int priority,
|
||||
string message,
|
||||
Script<object?> compiledScript,
|
||||
IActorRef instanceActor,
|
||||
SharedScriptLibrary sharedScriptLibrary,
|
||||
@@ -66,7 +75,14 @@ public class AlarmExecutionActor : ReceiveActor
|
||||
{
|
||||
Instance = context,
|
||||
Parameters = new ScriptParameters(),
|
||||
CancellationToken = cts.Token
|
||||
CancellationToken = cts.Token,
|
||||
Alarm = new AlarmContext
|
||||
{
|
||||
Name = alarmName,
|
||||
Level = level,
|
||||
Priority = priority,
|
||||
Message = message
|
||||
}
|
||||
};
|
||||
|
||||
await compiledScript.RunAsync(globals, cts.Token);
|
||||
|
||||
@@ -181,6 +181,14 @@ public class ScriptGlobals
|
||||
public ScriptParameters Parameters { get; set; } = new ScriptParameters();
|
||||
public CancellationToken CancellationToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Alarm context when this script is invoked as an on-trigger handler.
|
||||
/// Null for instance scripts, shared scripts, and inbound-API-routed
|
||||
/// scripts. Lets on-trigger scripts read the firing alarm's Name, Level
|
||||
/// (HiLo only), Priority, and per-band Message to branch routing logic.
|
||||
/// </summary>
|
||||
public Commons.Types.Scripts.AlarmContext? Alarm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where this script sits in the composition tree. Defaults to root for
|
||||
/// scripts on top-level templates; a flattened composed script gets
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
@@ -80,6 +81,7 @@ public class FlatteningService
|
||||
// Step 5: Resolve alarms from inheritance chain
|
||||
var alarms = ResolveInheritedAlarms(templateChain);
|
||||
ResolveComposedAlarms(templateChain, compositionMap, composedTemplateChains, alarms);
|
||||
ApplyInstanceAlarmOverrides(instance.AlarmOverrides, alarms);
|
||||
|
||||
// Step 6: Resolve scripts from inheritance chain
|
||||
var scripts = ResolveInheritedScripts(templateChain);
|
||||
@@ -292,6 +294,43 @@ public class FlatteningService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies per-instance alarm overrides on top of the
|
||||
/// inheritance-and-composition resolved alarms. Skips overrides for
|
||||
/// alarms that are locked at the template level. For HiLo triggers the
|
||||
/// override JSON is merged setpoint-by-setpoint (preserving inherited
|
||||
/// keys not present in the override); for other trigger types the
|
||||
/// override replaces the whole TriggerConfiguration.
|
||||
/// </summary>
|
||||
private static void ApplyInstanceAlarmOverrides(
|
||||
ICollection<InstanceAlarmOverride> overrides,
|
||||
Dictionary<string, ResolvedAlarm> alarms)
|
||||
{
|
||||
foreach (var ovr in overrides)
|
||||
{
|
||||
if (!alarms.TryGetValue(ovr.AlarmCanonicalName, out var existing))
|
||||
continue; // Cannot add new alarms via overrides
|
||||
|
||||
if (existing.IsLocked)
|
||||
continue; // Locked alarms cannot be overridden
|
||||
|
||||
var newConfig = existing.TriggerConfiguration;
|
||||
if (!string.IsNullOrWhiteSpace(ovr.TriggerConfigurationOverride))
|
||||
{
|
||||
newConfig = existing.TriggerType == nameof(AlarmTriggerType.HiLo)
|
||||
? MergeHiLoConfig(existing.TriggerConfiguration, ovr.TriggerConfigurationOverride)
|
||||
: ovr.TriggerConfigurationOverride;
|
||||
}
|
||||
|
||||
alarms[ovr.AlarmCanonicalName] = existing with
|
||||
{
|
||||
TriggerConfiguration = newConfig,
|
||||
PriorityLevel = ovr.PriorityLevelOverride ?? existing.PriorityLevel,
|
||||
Source = "Override"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyConnectionBindings(
|
||||
ICollection<InstanceConnectionBinding> bindings,
|
||||
Dictionary<string, ResolvedAttribute> attributes,
|
||||
@@ -332,6 +371,18 @@ public class FlatteningService
|
||||
if (result.TryGetValue(alarm.Name, out var existing) && existing.IsLocked)
|
||||
continue;
|
||||
|
||||
// HiLo per-setpoint override: derived templates can supply a
|
||||
// partial TriggerConfiguration (e.g., just `hi`) and have the
|
||||
// remaining setpoints inherited. Other trigger types replace
|
||||
// the whole config on override (current behavior).
|
||||
var triggerConfig = alarm.TriggerConfiguration;
|
||||
if (existing != null
|
||||
&& alarm.TriggerType == AlarmTriggerType.HiLo
|
||||
&& existing.TriggerType == nameof(AlarmTriggerType.HiLo))
|
||||
{
|
||||
triggerConfig = MergeHiLoConfig(existing.TriggerConfiguration, triggerConfig);
|
||||
}
|
||||
|
||||
result[alarm.Name] = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = alarm.Name,
|
||||
@@ -339,7 +390,7 @@ public class FlatteningService
|
||||
PriorityLevel = alarm.PriorityLevel,
|
||||
IsLocked = alarm.IsLocked,
|
||||
TriggerType = alarm.TriggerType.ToString(),
|
||||
TriggerConfiguration = alarm.TriggerConfiguration,
|
||||
TriggerConfiguration = triggerConfig,
|
||||
OnTriggerScriptCanonicalName = null, // Resolved later
|
||||
Source = source
|
||||
};
|
||||
@@ -349,6 +400,61 @@ public class FlatteningService
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges a derived HiLo trigger configuration onto an inherited one.
|
||||
/// Top-level keys present in <paramref name="derivedJson"/> override the
|
||||
/// inherited values; keys absent in the derived config are inherited.
|
||||
/// Returns the derived config verbatim on parse failure of either input —
|
||||
/// the existing whole-replace behavior is the safe fallback.
|
||||
/// </summary>
|
||||
internal static string? MergeHiLoConfig(string? inheritedJson, string? derivedJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(inheritedJson)) return derivedJson;
|
||||
if (string.IsNullOrWhiteSpace(derivedJson)) return inheritedJson;
|
||||
|
||||
try
|
||||
{
|
||||
using var inheritedDoc = JsonDocument.Parse(inheritedJson);
|
||||
using var derivedDoc = JsonDocument.Parse(derivedJson);
|
||||
|
||||
if (inheritedDoc.RootElement.ValueKind != JsonValueKind.Object
|
||||
|| derivedDoc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return derivedJson;
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(stream))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
var derivedKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var prop in derivedDoc.RootElement.EnumerateObject())
|
||||
derivedKeys.Add(prop.Name);
|
||||
|
||||
// Inherited keys not present in derived survive.
|
||||
foreach (var prop in inheritedDoc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (derivedKeys.Contains(prop.Name)) continue;
|
||||
prop.WriteTo(writer);
|
||||
}
|
||||
|
||||
// Derived keys win.
|
||||
foreach (var prop in derivedDoc.RootElement.EnumerateObject())
|
||||
{
|
||||
prop.WriteTo(writer);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return derivedJson;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResolveComposedAlarms(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
|
||||
@@ -169,6 +169,94 @@ public class InstanceService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a per-instance alarm override. The alarm must exist on the
|
||||
/// template and must not be locked. For HiLo alarms, the override JSON
|
||||
/// merges into the inherited TriggerConfiguration setpoint-by-setpoint;
|
||||
/// for binary trigger types, it replaces the whole config.
|
||||
/// </summary>
|
||||
public async Task<Result<InstanceAlarmOverride>> SetAlarmOverrideAsync(
|
||||
int instanceId,
|
||||
string alarmCanonicalName,
|
||||
string? triggerConfigurationOverride,
|
||||
int? priorityLevelOverride,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<InstanceAlarmOverride>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
// Verify alarm exists in the template and is not locked. Only direct
|
||||
// template alarms are checked here — composed-member overrides go
|
||||
// through but are silently ignored at runtime if the name doesn't
|
||||
// match (same behavior as attribute overrides).
|
||||
var templateAlarms = await _repository.GetAlarmsByTemplateIdAsync(instance.TemplateId, cancellationToken);
|
||||
var templateAlarm = templateAlarms.FirstOrDefault(a => a.Name == alarmCanonicalName);
|
||||
if (templateAlarm != null && templateAlarm.IsLocked)
|
||||
{
|
||||
return Result<InstanceAlarmOverride>.Failure(
|
||||
$"Alarm '{alarmCanonicalName}' is locked and cannot be overridden.");
|
||||
}
|
||||
|
||||
var existingOverride = await _repository.GetAlarmOverrideAsync(
|
||||
instanceId, alarmCanonicalName, cancellationToken);
|
||||
|
||||
if (existingOverride != null)
|
||||
{
|
||||
existingOverride.TriggerConfigurationOverride = triggerConfigurationOverride;
|
||||
existingOverride.PriorityLevelOverride = priorityLevelOverride;
|
||||
await _repository.UpdateInstanceAlarmOverrideAsync(existingOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "UpdateAlarmOverride", "InstanceAlarmOverride",
|
||||
existingOverride.Id.ToString(), alarmCanonicalName, existingOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAlarmOverride>.Success(existingOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newOverride = new InstanceAlarmOverride(alarmCanonicalName)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
TriggerConfigurationOverride = triggerConfigurationOverride,
|
||||
PriorityLevelOverride = priorityLevelOverride
|
||||
};
|
||||
|
||||
await _repository.AddInstanceAlarmOverrideAsync(newOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "CreateAlarmOverride", "InstanceAlarmOverride",
|
||||
newOverride.Id.ToString(), alarmCanonicalName, newOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAlarmOverride>.Success(newOverride);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a per-instance alarm override. After removal the instance
|
||||
/// inherits the template alarm config unchanged.
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> DeleteAlarmOverrideAsync(
|
||||
int instanceId,
|
||||
string alarmCanonicalName,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _repository.GetAlarmOverrideAsync(
|
||||
instanceId, alarmCanonicalName, cancellationToken);
|
||||
if (existing == null)
|
||||
return Result<bool>.Failure($"No alarm override for '{alarmCanonicalName}' on instance {instanceId}.");
|
||||
|
||||
await _repository.DeleteInstanceAlarmOverrideAsync(existing.Id, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "DeleteAlarmOverride", "InstanceAlarmOverride",
|
||||
existing.Id.ToString(), alarmCanonicalName, existing, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets connection bindings for an instance in bulk.
|
||||
/// </summary>
|
||||
|
||||
@@ -136,6 +136,75 @@ public class SemanticValidator
|
||||
}
|
||||
}
|
||||
|
||||
// HiLo requires numeric attribute + ordered setpoints
|
||||
if (alarm.TriggerType == "HiLo" && !string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
|
||||
{
|
||||
var attrName = ValidationService.ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
|
||||
if (attrName != null && attributeMap.TryGetValue(attrName, out var attr))
|
||||
{
|
||||
if (!NumericDataTypes.Contains(attr.DataType))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' uses HiLo trigger on attribute '{attrName}' which has non-numeric type '{attr.DataType}'.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
var setpoints = ValidationService.ExtractHiLoSetpoints(alarm.TriggerConfiguration);
|
||||
|
||||
// At least one setpoint must be configured — otherwise the alarm
|
||||
// can never fire.
|
||||
if (!setpoints.LoLo.HasValue && !setpoints.Lo.HasValue
|
||||
&& !setpoints.Hi.HasValue && !setpoints.HiHi.HasValue)
|
||||
{
|
||||
warnings.Add(ValidationEntry.Warning(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' is HiLo but no setpoints (LoLo/Lo/Hi/HiHi) are configured — it will never fire.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
|
||||
// Ordering: LoLo ≤ Lo, Hi ≤ HiHi, and the highest Lo-side band
|
||||
// must sit strictly below the lowest Hi-side band — otherwise the
|
||||
// bands overlap and the evaluator's behavior is ambiguous.
|
||||
if (setpoints.LoLo is { } loLo && setpoints.Lo is { } lo && loLo > lo)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: LoLo ({loLo}) must be ≤ Lo ({lo}).",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
if (setpoints.Hi is { } hi && setpoints.HiHi is { } hiHi && hi > hiHi)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: Hi ({hi}) must be ≤ HiHi ({hiHi}).",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
var highestLowSide = setpoints.Lo ?? setpoints.LoLo;
|
||||
var lowestHighSide = setpoints.Hi ?? setpoints.HiHi;
|
||||
if (highestLowSide is { } lowSide && lowestHighSide is { } highSide
|
||||
&& lowSide >= highSide)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' HiLo bands overlap: low-side setpoint ({lowSide}) must be strictly less than high-side setpoint ({highSide}).",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
|
||||
// Deadbands must be non-negative — negative deadband would invert
|
||||
// the hysteresis (alarm could escape faster than it entered).
|
||||
foreach (var (name, value) in new (string, double?)[] {
|
||||
("LoLo deadband", setpoints.LoLoDeadband),
|
||||
("Lo deadband", setpoints.LoDeadband),
|
||||
("Hi deadband", setpoints.HiDeadband),
|
||||
("HiHi deadband", setpoints.HiHiDeadband)
|
||||
})
|
||||
{
|
||||
if (value is { } d && d < 0)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
|
||||
$"Alarm '{alarm.CanonicalName}' {name} ({d}) must be non-negative.",
|
||||
alarm.CanonicalName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On-trigger script must exist
|
||||
if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName) &&
|
||||
!scriptNames.Contains(alarm.OnTriggerScriptCanonicalName))
|
||||
|
||||
@@ -232,4 +232,50 @@ public class ValidationService
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the four HiLo setpoints from a trigger configuration JSON.
|
||||
/// Any unset (or non-numeric) setpoint comes back as <c>null</c>. Returns
|
||||
/// all-nulls on malformed JSON — callers should treat that as "nothing to
|
||||
/// validate" and let other checks surface the deeper problem.
|
||||
/// </summary>
|
||||
internal static HiLoSetpoints ExtractHiLoSetpoints(string triggerConfigJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(triggerConfigJson);
|
||||
var root = doc.RootElement;
|
||||
return new HiLoSetpoints(
|
||||
LoLo: ReadDouble(root, "loLo"),
|
||||
Lo: ReadDouble(root, "lo"),
|
||||
Hi: ReadDouble(root, "hi"),
|
||||
HiHi: ReadDouble(root, "hiHi"),
|
||||
LoLoDeadband: ReadDouble(root, "loLoDeadband"),
|
||||
LoDeadband: ReadDouble(root, "loDeadband"),
|
||||
HiDeadband: ReadDouble(root, "hiDeadband"),
|
||||
HiHiDeadband: ReadDouble(root, "hiHiDeadband"));
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new HiLoSetpoints(null, null, null, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static double? ReadDouble(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(),
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct HiLoSetpoints(
|
||||
double? LoLo, double? Lo, double? Hi, double? HiHi,
|
||||
double? LoLoDeadband = null, double? LoDeadband = null,
|
||||
double? HiDeadband = null, double? HiHiDeadband = null);
|
||||
|
||||
Reference in New Issue
Block a user