diff --git a/docker/regen-proto.sh b/docker/regen-proto.sh new file mode 100755 index 0000000..710043c --- /dev/null +++ b/docker/regen-proto.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# +# Regenerates the gRPC C# files from sitestream.proto. +# +# Background: protoc (linux/arm64) segfaults inside our Docker build container +# (Grpc.Tools 2.71.0). As a workaround the generated Sitestream.cs + +# SitestreamGrpc.cs are checked into src/ScadaLink.Communication/SiteStreamGrpc/ +# and the Protobuf ItemGroup in the .csproj is commented out — Docker just +# compiles the checked-in C# files. +# +# Run this script ON YOUR DEV MACHINE whenever Protos/sitestream.proto changes: +# +# 1. Temporarily uncomments the Protobuf ItemGroup so Grpc.Tools runs. +# 2. dotnet build (regen writes fresh files to obj/). +# 3. Copies the regenerated files back into SiteStreamGrpc/. +# 4. Re-comments the Protobuf ItemGroup so Docker builds stay safe. +# +# Once we move to a Dockerfile base image that ships a working linux/arm64 +# protoc, this script can be retired and Docker can regen the proto on every +# build like every other normal .NET project. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +COMM_DIR="$REPO_ROOT/src/ScadaLink.Communication" +CSPROJ="$COMM_DIR/ScadaLink.Communication.csproj" +GEN_DIR="$COMM_DIR/SiteStreamGrpc" + +echo "=== Regenerating gRPC files from sitestream.proto ===" + +if [[ ! -f "$CSPROJ" ]]; then + echo "ERROR: csproj not found at $CSPROJ" >&2 + exit 1 +fi + +# Backup so we can always restore the comment state on failure. +BACKUP="$(mktemp)" +cp "$CSPROJ" "$BACKUP" +trap 'cp "$BACKUP" "$CSPROJ"; rm -f "$BACKUP"; echo "Restored csproj from backup."' ERR + +# 1. Uncomment the Protobuf ItemGroup (strip the surrounding wrapper). +python3 - <\s*\n\s*]*/>\s*\n\s*)\s*\n\s*-->", + r"\1", + src, + count=1, +) +if new == src: + raise SystemExit("Couldn't find commented Protobuf ItemGroup to enable.") +p.write_text(new) +PY + +# 2. Delete the stale files so any failure to regen is obvious. +rm -f "$GEN_DIR/Sitestream.cs" "$GEN_DIR/SitestreamGrpc.cs" + +# 3. Regenerate by building. +echo "Building Communication project (regen)..." +dotnet build "$CSPROJ" --nologo -v minimal | tail -5 + +# 4. Copy generated files back into the source tree. +mkdir -p "$GEN_DIR" +cp "$COMM_DIR/obj/Debug/net10.0/Protos/Sitestream.cs" "$GEN_DIR/Sitestream.cs" +cp "$COMM_DIR/obj/Debug/net10.0/Protos/SitestreamGrpc.cs" "$GEN_DIR/SitestreamGrpc.cs" +echo "Copied regenerated files to $GEN_DIR/" + +# 5. Re-comment the Protobuf ItemGroup so Docker builds keep working. +python3 - <\s*\n\s*]*/>\s*\n\s*)", + r"\n ", + src, + count=1, +) +p.write_text(new) +PY + +rm -f "$BACKUP" +trap - ERR + +echo "" +echo "Done. Review and commit:" +echo " git diff src/ScadaLink.Communication/Protos/sitestream.proto" +echo " git diff src/ScadaLink.Communication/SiteStreamGrpc/" diff --git a/docs/requirements/Component-Commons.md b/docs/requirements/Component-Commons.md index 0ec53b3..22e7905 100644 --- a/docs/requirements/Component-Commons.md +++ b/docs/requirements/Component-Commons.md @@ -32,7 +32,8 @@ Commons must define shared primitive and utility types used across multiple comp - **`InstanceState` enum**: Enabled, Disabled. - **`DeploymentStatus` enum**: Pending, InProgress, Success, Failed. - **`AlarmState` enum**: Active, Normal. -- **`AlarmTriggerType` enum**: ValueMatch, RangeViolation, RateOfChange. +- **`AlarmLevel` enum**: None, Low, LowLow, High, HighHigh. Severity level for an active alarm; always `None` for binary trigger types, set by `HiLo` triggers. +- **`AlarmTriggerType` enum**: ValueMatch, RangeViolation, RateOfChange, HiLo. - **`ConnectionHealth` enum**: Connected, Disconnected, Connecting, Error. Types defined here must be immutable and thread-safe. diff --git a/docs/requirements/Component-SiteRuntime.md b/docs/requirements/Component-SiteRuntime.md index f3d72bb..e76d8af 100644 --- a/docs/requirements/Component-SiteRuntime.md +++ b/docs/requirements/Component-SiteRuntime.md @@ -176,19 +176,20 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak ### Alarm Evaluation - Subscribes to attribute change notifications from its parent Instance Actor for the attribute(s) referenced by its trigger definition. - On each value update, evaluates the trigger condition: - - **Value Match**: Incoming value equals the predefined target. + - **Value Match**: Incoming value equals the predefined target. Supports `"!=X"` prefix for not-equals semantics. - **Range Violation**: Value is outside the allowed min/max range. - - **Rate of Change**: Value change rate exceeds the defined threshold over time. -- When the condition is met and the alarm is currently in **normal** state, the alarm transitions to **active**: + - **Rate of Change**: Value change rate exceeds the defined threshold over a configurable time window. Direction filter (rising / falling / either) restricts which side of the rate triggers. + - **HiLo**: Multi-setpoint level alarm with up to four configurable setpoints (LoLo, Lo, Hi, HiHi). Any subset may be configured. Each setpoint may carry its own priority that overrides the alarm-level priority for that band. +- For binary trigger types (ValueMatch / RangeViolation / RateOfChange), when the condition is met and the alarm is currently in **normal** state, the alarm transitions to **active**: - Updates the alarm state on the parent Instance Actor (which publishes to the Akka stream). - If an on-trigger script is defined, spawns an Alarm Execution Actor to execute it. -- When the condition clears and the alarm is in **active** state, the alarm transitions to **normal**: - - Updates the alarm state on the parent Instance Actor. - - No script execution on clear. +- When the condition clears and the alarm is in **active** state, the alarm transitions to **normal**. +- For HiLo triggers, the actor tracks the current `AlarmLevel` (None / Low / LowLow / High / HighHigh). Each level transition emits a fresh `AlarmStateChanged` with the new level and its priority; level escalations (e.g., High → HighHigh) and de-escalations (HighHigh → High) both produce events. The on-trigger script fires only on the Normal → non-None edge, not on escalations between alarm bands. +- No script execution on clear in any trigger type. ### Alarm State -- Held **in memory** only — not persisted to SQLite. -- On restart (or failover), alarm states are re-evaluated from incoming values. All alarms start in normal state and transition to active when conditions are detected. +- Held **in memory** only — not persisted to SQLite. State comprises `AlarmState` (Active / Normal) and `AlarmLevel` (None for binary triggers; the active band for HiLo). +- On restart (or failover), alarm states are re-evaluated from incoming values. All alarms start in normal state with level None and transition based on incoming values. ### Alarm Execution Actor - **Short-lived** child actor created when an on-trigger script needs to execute. diff --git a/docs/requirements/HighLevelReqs.md b/docs/requirements/HighLevelReqs.md index 661215a..b88be7d 100644 --- a/docs/requirements/HighLevelReqs.md +++ b/docs/requirements/HighLevelReqs.md @@ -106,16 +106,18 @@ Each alarm has: - **Priority Level**: Numeric value from 0–1000. - **Lock Flag**: Controls whether the alarm can be overridden downstream. - **Trigger Definition**: One of the following trigger types: - - **Value Match**: Triggers when a monitored attribute equals a predefined value. + - **Value Match**: Triggers when a monitored attribute equals a predefined value. Supports a `!=X` prefix on the match value for not-equals semantics. - **Range Violation**: Triggers when a monitored attribute value falls outside an allowed range. - - **Rate of Change**: Triggers when a monitored attribute value changes faster than a defined threshold. + - **Rate of Change**: Triggers when a monitored attribute value changes faster than a defined threshold over a configurable time window. A direction filter (rising / falling / either) restricts which side of the rate triggers. + - **HiLo**: Multi-setpoint level alarm. Any subset of four setpoints (LoLo, Lo, Hi, HiHi) may be configured. The most severe matching band wins (LoLo/HiHi outrank Lo/Hi). Each setpoint may carry its own priority that overrides the alarm-level priority for that band. - **On-Trigger Script** *(optional)*: A script to execute when the alarm triggers. The alarm on-trigger script executes in the context of the instance and can call instance scripts, but instance scripts **cannot** call alarm on-trigger scripts. The call direction is one-way. ### 3.4.1 Alarm State - Alarm state (active/normal) is **managed at the site level** per instance, held **in memory** by the Alarm Actor. +- Active alarms additionally carry an **alarm level**: `None` for binary trigger types (ValueMatch, RangeViolation, RateOfChange); one of `Low`, `LowLow`, `High`, `HighHigh` for HiLo triggers based on which setpoint the monitored attribute has crossed. Level transitions within an active HiLo alarm (e.g., High → HighHigh) emit fresh state-change events without re-running the on-trigger script — the script only fires on the Normal → non-None edge. - When the alarm condition clears, the alarm **automatically returns to normal state** — no acknowledgment workflow is required. - Alarm state is **not persisted** — on restart, alarm states are re-evaluated from incoming values. -- Alarm state changes are published to the site-wide Akka stream as `[InstanceUniqueName].[AlarmName]`, alarm state (active/normal), priority, timestamp. +- Alarm state changes are published to the site-wide Akka stream as `[InstanceUniqueName].[AlarmName]`, alarm state (active/normal), alarm level, priority, timestamp. ### 3.5 Template Relationships diff --git a/src/ScadaLink.CLI/Commands/InstanceCommands.cs b/src/ScadaLink.CLI/Commands/InstanceCommands.cs index bb9193b..691c973 100644 --- a/src/ScadaLink.CLI/Commands/InstanceCommands.cs +++ b/src/ScadaLink.CLI/Commands/InstanceCommands.cs @@ -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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var group = new Command("alarm-override") { Description = "Manage per-instance alarm overrides" }; + + // set + var setIdOption = new Option("--instance-id") { Description = "Instance ID", Required = true }; + var setAlarmOption = new Option("--alarm") { Description = "Alarm canonical name (e.g., 'TempLevels' or 'Pump.TempSensor.Heat')", Required = true }; + var setConfigOption = new Option("--trigger-config") { Description = "JSON override for TriggerConfiguration (HiLo: partial merge; others: whole-replace)" }; + var setPriorityOption = new Option("--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("--instance-id") { Description = "Instance ID", Required = true }; + var delAlarmOption = new Option("--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("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Instance ID", Required = true }; diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor index d4ba08f..c14e4e2 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor @@ -191,6 +191,7 @@ Alarm State + Level Priority Timestamp @@ -198,12 +199,30 @@ @foreach (var alarm in FilteredAlarmStates) { - - @alarm.AlarmName + + + @alarm.AlarmName + @if (!string.IsNullOrEmpty(alarm.Message)) + { + 💬 + } + @alarm.State + + @if (alarm.Level != AlarmLevel.None) + { + @FormatLevel(alarm.Level) + } + else + { + + } + @alarm.Priority @@ -468,6 +487,26 @@ _ => "" }; + /// + /// Severity-tinted badge class for HiLo alarm levels. The critical bands + /// (HighHigh / LowLow) get the danger class; warning bands get amber. + /// + 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) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor index 4e6243c..fa8946b 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -754,7 +754,8 @@ + AvailableAttributes="@BuildAlarmAttributeChoices()" + FallbackPriority="@_alarmPriority" />
diff --git a/src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs b/src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs new file mode 100644 index 0000000..fca75ef --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs @@ -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; + +/// +/// Round-trip codec for the alarm trigger configuration JSON used by both +/// (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 +/// (attribute, value, low, high) so older configs +/// survive a round-trip through the editor. +/// +internal static class AlarmTriggerConfigCodec +{ + /// + /// 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. + /// + 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; + } + + /// + /// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig + /// expects. Always writes attributeName (canonical key) and only + /// the keys relevant to the current trigger type. + /// + 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; } +} diff --git a/src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerEditor.razor b/src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerEditor.razor index cd009c9..bb79bf5 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerEditor.razor +++ b/src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerEditor.razor @@ -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 => + { +
+ 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. +
+ + @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") + }; + + /// + /// Renders one setpoint row: value (number) + priority (int). Both are + /// optional — leaving a value blank disables that band. The + /// tints the label to convey relative + /// severity at a glance. + /// + private RenderFragment HiLoSetpointRow( + string label, + string? value, Action valueSetter, Func onValueChanged, + string? deadband, Action deadbandSetter, Func onDeadbandChanged, + string? priority, Action prioritySetter, Func onPriorityChanged, + string? message, Action messageSetter, Func onMessageChanged, + string severityClass) => __builder => + { +
+
+ +
+ setpoint + +
+
+
+ +
+ ± + +
+
+
+ +
+ +
+
+
+
+
+ +
+
+ }; + + // 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(); + 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"; - } - - /// - /// Parses an existing trigger configuration JSON in the context of the - /// given trigger type. Returns sensible defaults on parse failure or for - /// missing keys. - /// - 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 - }; - } - - /// - /// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig - /// expects. Always writes attributeName (canonical key) and only - /// the keys relevant to the current trigger type. - /// - 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()); - } } diff --git a/src/ScadaLink.Commons/Entities/Instances/Instance.cs b/src/ScadaLink.Commons/Entities/Instances/Instance.cs index e1c2ca6..5ece819 100644 --- a/src/ScadaLink.Commons/Entities/Instances/Instance.cs +++ b/src/ScadaLink.Commons/Entities/Instances/Instance.cs @@ -11,6 +11,7 @@ public class Instance public string UniqueName { get; set; } public InstanceState State { get; set; } public ICollection AttributeOverrides { get; set; } = new List(); + public ICollection AlarmOverrides { get; set; } = new List(); public ICollection ConnectionBindings { get; set; } = new List(); public Instance(string uniqueName) diff --git a/src/ScadaLink.Commons/Entities/Instances/InstanceAlarmOverride.cs b/src/ScadaLink.Commons/Entities/Instances/InstanceAlarmOverride.cs new file mode 100644 index 0000000..15ccc03 --- /dev/null +++ b/src/ScadaLink.Commons/Entities/Instances/InstanceAlarmOverride.cs @@ -0,0 +1,48 @@ +namespace ScadaLink.Commons.Entities.Instances; + +/// +/// 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): +/// • 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). +/// • replaces the alarm's PriorityLevel +/// when set. +/// +public class InstanceAlarmOverride +{ + public int Id { get; set; } + public int InstanceId { get; set; } + + /// + /// Canonical name of the alarm being overridden — matches + /// ResolvedAlarm.CanonicalName after flattening, so composed-member + /// alarms are referenced as [CompositionInstance].[AlarmName]. + /// + public string AlarmCanonicalName { get; set; } + + /// + /// Partial JSON (for HiLo) or full JSON (for binary trigger types) to + /// override the inherited TriggerConfiguration. null means + /// "leave inherited as-is". + /// + public string? TriggerConfigurationOverride { get; set; } + + /// + /// Replaces the alarm's PriorityLevel when set. null = keep inherited. + /// + public int? PriorityLevelOverride { get; set; } + + public InstanceAlarmOverride(string alarmCanonicalName) + { + AlarmCanonicalName = alarmCanonicalName ?? throw new ArgumentNullException(nameof(alarmCanonicalName)); + } +} diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs index 397aa33..41461f7 100644 --- a/src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs +++ b/src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs @@ -65,6 +65,13 @@ public interface ITemplateEngineRepository Task UpdateInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default); Task DeleteInstanceAttributeOverrideAsync(int id, CancellationToken cancellationToken = default); + // InstanceAlarmOverride + Task> GetAlarmOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default); + Task 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> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default); Task AddInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default); diff --git a/src/ScadaLink.Commons/Messages/Management/InstanceCommands.cs b/src/ScadaLink.Commons/Messages/Management/InstanceCommands.cs index 0a31a56..8a70822 100644 --- a/src/ScadaLink.Commons/Messages/Management/InstanceCommands.cs +++ b/src/ScadaLink.Commons/Messages/Management/InstanceCommands.cs @@ -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 Overrides); public record SetInstanceAreaCommand(int InstanceId, int? AreaId); + +/// +/// Sets (or upserts) a per-instance alarm override. For HiLo trigger types the +/// TriggerConfigurationOverride 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. +/// +public record SetInstanceAlarmOverrideCommand( + int InstanceId, + string AlarmCanonicalName, + string? TriggerConfigurationOverride, + int? PriorityLevelOverride); + +public record DeleteInstanceAlarmOverrideCommand( + int InstanceId, + string AlarmCanonicalName); + +public record ListInstanceAlarmOverridesCommand(int InstanceId); diff --git a/src/ScadaLink.Commons/Messages/Streaming/AlarmStateChanged.cs b/src/ScadaLink.Commons/Messages/Streaming/AlarmStateChanged.cs index ad7de6e..8264358 100644 --- a/src/ScadaLink.Commons/Messages/Streaming/AlarmStateChanged.cs +++ b/src/ScadaLink.Commons/Messages/Streaming/AlarmStateChanged.cs @@ -7,4 +7,23 @@ public record AlarmStateChanged( string AlarmName, AlarmState State, int Priority, - DateTimeOffset Timestamp); + DateTimeOffset Timestamp) +{ + /// + /// Severity level when is . + /// Always 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. + /// + public AlarmLevel Level { get; init; } = AlarmLevel.None; + + /// + /// 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. + /// + public string Message { get; init; } = string.Empty; +} diff --git a/src/ScadaLink.Commons/Types/Enums/AlarmLevel.cs b/src/ScadaLink.Commons/Types/Enums/AlarmLevel.cs new file mode 100644 index 0000000..09c8169 --- /dev/null +++ b/src/ScadaLink.Commons/Types/Enums/AlarmLevel.cs @@ -0,0 +1,19 @@ +namespace ScadaLink.Commons.Types.Enums; + +/// +/// Severity level for an active alarm. Binary alarm types (ValueMatch, +/// RangeViolation, RateOfChange) always emit . 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 +/// +public enum AlarmLevel +{ + None, + Low, + LowLow, + High, + HighHigh +} diff --git a/src/ScadaLink.Commons/Types/Enums/AlarmTriggerType.cs b/src/ScadaLink.Commons/Types/Enums/AlarmTriggerType.cs index 314e006..d497c09 100644 --- a/src/ScadaLink.Commons/Types/Enums/AlarmTriggerType.cs +++ b/src/ScadaLink.Commons/Types/Enums/AlarmTriggerType.cs @@ -4,5 +4,12 @@ public enum AlarmTriggerType { ValueMatch, RangeViolation, - RateOfChange + RateOfChange, + /// + /// 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 . + /// + HiLo } diff --git a/src/ScadaLink.Commons/Types/Scripts/AlarmContext.cs b/src/ScadaLink.Commons/Types/Scripts/AlarmContext.cs new file mode 100644 index 0000000..6fd505a --- /dev/null +++ b/src/ScadaLink.Commons/Types/Scripts/AlarmContext.cs @@ -0,0 +1,23 @@ +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.Commons.Types.Scripts; + +/// +/// Alarm context exposed to on-trigger scripts via Alarm. 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; is for binary +/// trigger types. +/// +public sealed class AlarmContext +{ + public string Name { get; init; } = string.Empty; + public AlarmLevel Level { get; init; } = AlarmLevel.None; + public int Priority { get; init; } + + /// + /// Per-band operator message configured on the HiLo alarm, or empty for + /// binary trigger types and bands without a message. + /// + public string Message { get; init; } = string.Empty; +} diff --git a/src/ScadaLink.Communication/Actors/StreamRelayActor.cs b/src/ScadaLink.Communication/Actors/StreamRelayActor.cs index 2feba15..a26d3fa 100644 --- a/src/ScadaLink.Communication/Actors/StreamRelayActor.cs +++ b/src/ScadaLink.Communication/Actors/StreamRelayActor.cs @@ -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 + }; } diff --git a/src/ScadaLink.Communication/Grpc/SiteStreamGrpcClient.cs b/src/ScadaLink.Communication/Grpc/SiteStreamGrpcClient.cs index f7c8f92..b9a9dcb 100644 --- a/src/ScadaLink.Communication/Grpc/SiteStreamGrpcClient.cs +++ b/src/ScadaLink.Communication/Grpc/SiteStreamGrpcClient.cs @@ -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 }; + /// + /// Maps proto AlarmLevelEnum to domain AlarmLevel. Internal for testability. + /// + 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) diff --git a/src/ScadaLink.Communication/Protos/sitestream.proto b/src/ScadaLink.Communication/Protos/sitestream.proto index 0d0ebe0..d86459c 100644 --- a/src/ScadaLink.Communication/Protos/sitestream.proto +++ b/src/ScadaLink.Communication/Protos/sitestream.proto @@ -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. } diff --git a/src/ScadaLink.Communication/ScadaLink.Communication.csproj b/src/ScadaLink.Communication/ScadaLink.Communication.csproj index 3caeb10..23aa1b3 100644 --- a/src/ScadaLink.Communication/ScadaLink.Communication.csproj +++ b/src/ScadaLink.Communication/ScadaLink.Communication.csproj @@ -30,4 +30,21 @@ + + + diff --git a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs index 4eb8b44..b1892bc 100644 --- a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs +++ b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs @@ -1,6 +1,6 @@ // // Generated by the protocol buffer compiler. DO NOT EDIT! -// source: sitestream.proto +// source: Protos/sitestream.proto // #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 { - /// Holder for reflection information generated from sitestream.proto + /// Holder for reflection information generated from Protos/sitestream.proto public static partial class SitestreamReflection { #region Descriptor - /// File descriptor for sitestream.proto + /// File descriptor for Protos/sitestream.proto 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, } + /// + /// 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. + /// + 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 { } } + /// Field number for the "level" field. + public const int LevelFieldNumber = 6; + private global::ScadaLink.Communication.Grpc.AlarmLevelEnum level_ = global::ScadaLink.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone; + /// + /// ALARM_LEVEL_NONE for binary trigger types; set by HiLo. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ScadaLink.Communication.Grpc.AlarmLevelEnum Level { + get { return level_; } + set { + level_ = value; + } + } + + /// Field number for the "message" field. + public const int MessageFieldNumber = 7; + private string message_ = ""; + /// + /// Optional per-band operator message; empty when unset. + /// + [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; + } } } } diff --git a/src/ScadaLink.Communication/SiteStreamGrpc/SitestreamGrpc.cs b/src/ScadaLink.Communication/SiteStreamGrpc/SitestreamGrpc.cs index 172be57..6aa6ecb 100644 --- a/src/ScadaLink.Communication/SiteStreamGrpc/SitestreamGrpc.cs +++ b/src/ScadaLink.Communication/SiteStreamGrpc/SitestreamGrpc.cs @@ -1,6 +1,6 @@ // // Generated by the protocol buffer compiler. DO NOT EDIT! -// source: sitestream.proto +// source: Protos/sitestream.proto // #pragma warning disable 0414, 1591, 8981, 0612 #region Designer generated code diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/InstanceConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/InstanceConfiguration.cs index 55ce1ac..1860f51 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/InstanceConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/InstanceConfiguration.cs @@ -41,6 +41,11 @@ public class InstanceConfiguration : IEntityTypeConfiguration .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 +{ + public void Configure(EntityTypeBuilder 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 { public void Configure(EntityTypeBuilder builder) diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260513055537_AddInstanceAlarmOverrides.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260513055537_AddInstanceAlarmOverrides.Designer.cs new file mode 100644 index 0000000..cccc684 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260513055537_AddInstanceAlarmOverrides.Designer.cs @@ -0,0 +1,1342 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260513055537_AddInstanceAlarmOverrides")] + partial class AddInstanceAlarmOverrides + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyValue") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("GrpcNodeBAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .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) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260513055537_AddInstanceAlarmOverrides.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260513055537_AddInstanceAlarmOverrides.cs new file mode 100644 index 0000000..985fdbe --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260513055537_AddInstanceAlarmOverrides.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + public partial class AddInstanceAlarmOverrides : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "InstanceAlarmOverrides", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + InstanceId = table.Column(type: "int", nullable: false), + AlarmCanonicalName = table.Column(type: "nvarchar(400)", maxLength: 400, nullable: false), + TriggerConfigurationOverride = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: true), + PriorityLevelOverride = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "InstanceAlarmOverrides"); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index 3a81753..1d7f3df 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -478,6 +478,37 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.ToTable("Instances"); }); + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("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("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"); diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs index 949eafb..5dbf2f9 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs @@ -169,6 +169,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository { return await _dbContext.Set() .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() .Include(i => i.AttributeOverrides) + .Include(i => i.AlarmOverrides) .Include(i => i.ConnectionBindings) .FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken); } diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs index bc9bfa2..f494153 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs @@ -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> GetAlarmOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default) + { + return await _context.InstanceAlarmOverrides + .Where(o => o.InstanceId == instanceId) + .ToListAsync(cancellationToken); + } + + public async Task 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> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default) diff --git a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs index ad0f2fc..8f79494 100644 --- a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs +++ b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs @@ -30,6 +30,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext // Instances public DbSet Instances => Set(); public DbSet InstanceAttributeOverrides => Set(); + public DbSet InstanceAlarmOverrides => Set(); public DbSet InstanceConnectionBindings => Set(); public DbSet Areas => Set(); diff --git a/src/ScadaLink.Host/Components/App.razor b/src/ScadaLink.Host/Components/App.razor index 2e03de0..ac6d3d2 100644 --- a/src/ScadaLink.Host/Components/App.razor +++ b/src/ScadaLink.Host/Components/App.razor @@ -26,17 +26,54 @@
- + diff --git a/src/ScadaLink.ManagementService/ManagementActor.cs b/src/ScadaLink.ManagementService/ManagementActor.cs index 11e770d..0ea63e3 100644 --- a/src/ScadaLink.ManagementService/ManagementActor.cs +++ b/src/ScadaLink.ManagementService/ManagementActor.cs @@ -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 HandleSetInstanceAlarmOverride(IServiceProvider sp, SetInstanceAlarmOverrideCommand cmd, AuthenticatedUser user) + { + await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); + var svc = sp.GetRequiredService(); + 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 HandleDeleteInstanceAlarmOverride(IServiceProvider sp, DeleteInstanceAlarmOverrideCommand cmd, AuthenticatedUser user) + { + await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); + var svc = sp.GetRequiredService(); + 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 HandleListInstanceAlarmOverrides(IServiceProvider sp, ListInstanceAlarmOverridesCommand cmd, AuthenticatedUser user) + { + await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); + var repo = sp.GetRequiredService(); + return await repo.GetAlarmOverridesByInstanceIdAsync(cmd.InstanceId); + } + private static async Task HandleGetDeploymentDiff(IServiceProvider sp, GetDeploymentDiffCommand cmd, AuthenticatedUser user) { await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); diff --git a/src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs b/src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs index 18ddab4..e425c8b 100644 --- a/src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs @@ -38,6 +38,12 @@ public class AlarmActor : ReceiveActor private readonly ISiteHealthCollector? _healthCollector; private AlarmState _currentState = AlarmState.Normal; + /// + /// Always for binary trigger types. For + /// this is the source of truth — the + /// state machine transitions when the computed level changes. + /// + 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 } } + /// + /// 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. + /// + 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); + } + } + + /// + /// Returns the per-setpoint priority for the given level. Falls back to + /// the alarm-level when the HiLo config did not + /// override the priority for that band, or for . + /// + 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 + }; + } + + /// + /// 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). + /// + 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 } /// - /// 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. /// - 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; + } + + /// + /// 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 Alarm global. + /// + 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); + +/// +/// 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. +/// +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); diff --git a/src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs b/src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs index de88818..c288774 100644 --- a/src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs @@ -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 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 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); diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs index 6d6d11e..bb3442b 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs @@ -181,6 +181,14 @@ public class ScriptGlobals public ScriptParameters Parameters { get; set; } = new ScriptParameters(); public CancellationToken CancellationToken { get; set; } + /// + /// 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. + /// + public Commons.Types.Scripts.AlarmContext? Alarm { get; set; } + /// /// Where this script sits in the composition tree. Defaults to root for /// scripts on top-level templates; a flattened composed script gets diff --git a/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs b/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs index edc32ed..0f513d4 100644 --- a/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs +++ b/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs @@ -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 } } + /// + /// 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. + /// + private static void ApplyInstanceAlarmOverrides( + ICollection overrides, + Dictionary 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 bindings, Dictionary 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; } + /// + /// Merges a derived HiLo trigger configuration onto an inherited one. + /// Top-level keys present in 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. + /// + 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(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