From a9c4c2c655fd61b2586b7382d69cc92cf94a27f3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 00:33:51 -0400 Subject: [PATCH] docs(plans): implementation plan for OPC UA config model refactor 14 bite-sized tasks (TDD pattern) covering: - Commons foundation: POCOs, serializer, validator - Runtime adoption: OpcUaDataConnection + DeploymentManagerActor swap - UI build: + DataConnectionForm rewrite - Verification: build/test green + Docker browser smoke + push Tasks #45-#58 created with blocking dependencies; companion .tasks.json sidecar persists the plan for executing-plans skill. --- docs/plans/2026-05-12-opcua-config-model.md | 1645 +++++++++++++++++ ...026-05-12-opcua-config-model.md.tasks.json | 20 + 2 files changed, 1665 insertions(+) create mode 100644 docs/plans/2026-05-12-opcua-config-model.md create mode 100644 docs/plans/2026-05-12-opcua-config-model.md.tasks.json diff --git a/docs/plans/2026-05-12-opcua-config-model.md b/docs/plans/2026-05-12-opcua-config-model.md new file mode 100644 index 0000000..6375aaf --- /dev/null +++ b/docs/plans/2026-05-12-opcua-config-model.md @@ -0,0 +1,1645 @@ +# OPC UA Endpoint Config Model & Form Refactor — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Extract OPC UA endpoint configuration from free-form JSON strings into a strongly-typed `OpcUaEndpointConfig` POCO with a validator and serializer in Commons; consume the model from both the site-side runtime (`OpcUaDataConnection`) and the central UI (`/admin/connections` create/edit), rendering typed Bootstrap controls instead of a JSON textarea. + +**Architecture:** New POCO/validator/serializer in `ScadaLink.Commons` are the single schema. Typed nested JSON (`System.Text.Json`, camelCase) is the storage shape; a legacy flat string-dict fallback in the serializer keeps existing rows readable. `IDataConnection.ConnectAsync` keeps its `IDictionary` contract (protocol-agnostic), and `OpcUaDataConnection` uses `OpcUaEndpointConfigSerializer.FromFlatDict` internally to get the typed model. A shared `` Blazor component renders the form, used twice (primary + backup). + +**Tech Stack:** C# 12 / .NET 10, System.Text.Json, Blazor Server, Bootstrap 5, xUnit + bUnit + NSubstitute. + +**Design doc:** [docs/plans/2026-05-12-opcua-config-model-design.md](2026-05-12-opcua-config-model-design.md) + +--- + +## Tasks at a glance + +- **Task 1:** Create POCOs (`OpcUaEndpointConfig`, `OpcUaHeartbeatConfig`, `OpcUaSecurityMode`) + add `ConnectionConfig` to `ValidationCategory`. +- **Task 2:** TDD `OpcUaEndpointConfigSerializer` — write failing tests for Serialize/Deserialize/legacy/ToFlatDict/FromFlatDict. +- **Task 3:** Implement `OpcUaEndpointConfigSerializer` until tests pass. +- **Task 4:** TDD `OpcUaEndpointConfigValidator` — write failing tests for all rules. +- **Task 5:** Implement `OpcUaEndpointConfigValidator` until tests pass. +- **Task 6:** Refactor `OpcUaDataConnection.ConnectAsync` to use `FromFlatDict`. Remove `ParseInt`/`ParseBool` helpers and the string-fishing ladder. +- **Task 7:** Refactor `DeploymentManagerActor.EnsureDclConnections` to round-trip JSON → model → flat dict via the serializer (handles both new typed nested JSON and legacy flat dict). +- **Task 8:** TDD `` Blazor component — bUnit tests for binding, heartbeat toggle, error rendering, legacy banner. +- **Task 9:** Implement `OpcUaEndpointEditor.razor` until tests pass. +- **Task 10:** TDD `DataConnectionForm.razor` refactor — bUnit tests for invalid-save / valid-save / legacy-load. +- **Task 11:** Refactor `DataConnectionForm.razor` to use editor; remove Protocol dropdown; set entity `Protocol = "OpcUa"` on save. +- **Task 12:** Full solution build + all test suites green. +- **Task 13:** Docker deploy + browser smoke. +- **Task 14:** Commit & push. + +--- + +## Task 1: Create POCOs and add ValidationCategory value + +**Files:** +- Create: `src/ScadaLink.Commons/Types/DataConnections/OpcUaSecurityMode.cs` +- Create: `src/ScadaLink.Commons/Types/DataConnections/OpcUaHeartbeatConfig.cs` +- Create: `src/ScadaLink.Commons/Types/DataConnections/OpcUaEndpointConfig.cs` +- Modify: `src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs` (add `ConnectionConfig` enum value) + +**Step 1: Create `OpcUaSecurityMode.cs`** + +```csharp +namespace ScadaLink.Commons.Types.DataConnections; + +public enum OpcUaSecurityMode +{ + None, + Sign, + SignAndEncrypt +} +``` + +**Step 2: Create `OpcUaHeartbeatConfig.cs`** + +```csharp +namespace ScadaLink.Commons.Types.DataConnections; + +public sealed class OpcUaHeartbeatConfig +{ + public string TagPath { get; set; } = ""; + public int MaxSilenceSeconds { get; set; } = 30; +} +``` + +**Step 3: Create `OpcUaEndpointConfig.cs`** + +```csharp +namespace ScadaLink.Commons.Types.DataConnections; + +public sealed class OpcUaEndpointConfig +{ + // Connection + public string EndpointUrl { get; set; } = ""; + public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None; + public bool AutoAcceptUntrustedCerts { get; set; } = true; + + // Timing + public int SessionTimeoutMs { get; set; } = 60000; + public int OperationTimeoutMs { get; set; } = 15000; + + // Subscription + public int PublishingIntervalMs { get; set; } = 1000; + public int SamplingIntervalMs { get; set; } = 1000; + public int QueueSize { get; set; } = 10; + public int KeepAliveCount { get; set; } = 10; + public int LifetimeCount { get; set; } = 30; + public int MaxNotificationsPerPublish { get; set; } = 100; + + // Heartbeat (optional) + public OpcUaHeartbeatConfig? Heartbeat { get; set; } +} +``` + +**Step 4: Add `ConnectionConfig` to `ValidationCategory`** + +In `src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs:50-65`, append `ConnectionConfig` to the enum: + +```csharp +public enum ValidationCategory +{ + FlatteningFailure, + NamingCollision, + ScriptCompilation, + AlarmTriggerReference, + ScriptTriggerReference, + ConnectionBinding, + CallTargetNotFound, + ParameterMismatch, + ReturnTypeMismatch, + TriggerOperandType, + OnTriggerScriptNotFound, + CrossCallViolation, + MissingMetadata, + ConnectionConfig +} +``` + +**Step 5: Build to confirm everything compiles** + +Run: `dotnet build src/ScadaLink.Commons/ScadaLink.Commons.csproj` +Expected: build succeeded, 0 errors. + +**Step 6: Commit** + +```bash +git add src/ScadaLink.Commons/Types/DataConnections/ src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs +git commit -m "feat(commons): OpcUaEndpointConfig POCOs + ConnectionConfig ValidationCategory" +``` + +--- + +## Task 2: TDD — `OpcUaEndpointConfigSerializer` tests (failing) + +**Files:** +- Create: `tests/ScadaLink.Commons.Tests/Types/DataConnections/OpcUaEndpointConfigSerializerTests.cs` + +**Step 1: Write the failing test file** + +```csharp +using ScadaLink.Commons.Serialization; +using ScadaLink.Commons.Types.DataConnections; + +namespace ScadaLink.Commons.Tests.Types.DataConnections; + +public class OpcUaEndpointConfigSerializerTests +{ + [Fact] + public void Serialize_TypedRoundtrip_PreservesAllFields() + { + var original = new OpcUaEndpointConfig + { + EndpointUrl = "opc.tcp://plant-a:4840", + SecurityMode = OpcUaSecurityMode.SignAndEncrypt, + AutoAcceptUntrustedCerts = false, + SessionTimeoutMs = 90000, + OperationTimeoutMs = 20000, + PublishingIntervalMs = 500, + SamplingIntervalMs = 250, + QueueSize = 50, + KeepAliveCount = 5, + LifetimeCount = 15, + MaxNotificationsPerPublish = 200, + Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Sensors.HB", MaxSilenceSeconds = 60 } + }; + + var json = OpcUaEndpointConfigSerializer.Serialize(original); + var (round, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(json); + + Assert.False(isLegacy); + Assert.Equal(original.EndpointUrl, round.EndpointUrl); + Assert.Equal(original.SecurityMode, round.SecurityMode); + Assert.Equal(original.AutoAcceptUntrustedCerts, round.AutoAcceptUntrustedCerts); + Assert.Equal(original.SessionTimeoutMs, round.SessionTimeoutMs); + Assert.Equal(original.OperationTimeoutMs, round.OperationTimeoutMs); + Assert.Equal(original.PublishingIntervalMs, round.PublishingIntervalMs); + Assert.Equal(original.SamplingIntervalMs, round.SamplingIntervalMs); + Assert.Equal(original.QueueSize, round.QueueSize); + Assert.Equal(original.KeepAliveCount, round.KeepAliveCount); + Assert.Equal(original.LifetimeCount, round.LifetimeCount); + Assert.Equal(original.MaxNotificationsPerPublish, round.MaxNotificationsPerPublish); + Assert.NotNull(round.Heartbeat); + Assert.Equal("Sensors.HB", round.Heartbeat!.TagPath); + Assert.Equal(60, round.Heartbeat.MaxSilenceSeconds); + } + + [Fact] + public void Serialize_UsesCamelCase() + { + var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" }; + var json = OpcUaEndpointConfigSerializer.Serialize(config); + Assert.Contains("\"endpointUrl\"", json); + Assert.DoesNotContain("\"EndpointUrl\"", json); + } + + [Fact] + public void Serialize_SecurityModeAsCamelCaseString() + { + var config = new OpcUaEndpointConfig { SecurityMode = OpcUaSecurityMode.SignAndEncrypt }; + var json = OpcUaEndpointConfigSerializer.Serialize(config); + Assert.Contains("\"securityMode\":\"signAndEncrypt\"", json); + } + + [Fact] + public void Deserialize_NullOrEmpty_ReturnsDefaults() + { + var (config1, legacy1) = OpcUaEndpointConfigSerializer.Deserialize(null); + var (config2, legacy2) = OpcUaEndpointConfigSerializer.Deserialize(""); + var (config3, legacy3) = OpcUaEndpointConfigSerializer.Deserialize(" "); + + Assert.False(legacy1); + Assert.False(legacy2); + Assert.False(legacy3); + Assert.Equal("", config1.EndpointUrl); + Assert.Equal(60000, config1.SessionTimeoutMs); + Assert.Null(config1.Heartbeat); + } + + [Fact] + public void Deserialize_LegacyFlatDict_IsLegacyTrue_PopulatesFields() + { + // The legacy shape that DeploymentManagerActor produced from raw JSON before this refactor. + var legacyJson = """ + { + "endpoint": "opc.tcp://legacy:4840", + "SessionTimeoutMs": "45000", + "SamplingIntervalMs": "500", + "SecurityMode": "Sign", + "AutoAcceptUntrustedCerts": "false", + "HeartbeatTagPath": "Old.Heartbeat", + "HeartbeatMaxSilence": "20" + } + """; + + var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson); + + Assert.True(isLegacy); + Assert.Equal("opc.tcp://legacy:4840", config.EndpointUrl); + Assert.Equal(45000, config.SessionTimeoutMs); + Assert.Equal(500, config.SamplingIntervalMs); + Assert.Equal(OpcUaSecurityMode.Sign, config.SecurityMode); + Assert.False(config.AutoAcceptUntrustedCerts); + Assert.NotNull(config.Heartbeat); + Assert.Equal("Old.Heartbeat", config.Heartbeat!.TagPath); + Assert.Equal(20, config.Heartbeat.MaxSilenceSeconds); + } + + [Fact] + public void Deserialize_LegacyWithEndpointUrlPascalKey_Works() + { + // Some pre-refactor configs used "EndpointUrl" instead of "endpoint". + var legacyJson = """{"EndpointUrl":"opc.tcp://x:4840"}"""; + var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson); + Assert.True(isLegacy); + Assert.Equal("opc.tcp://x:4840", config.EndpointUrl); + } + + [Fact] + public void ToFlatDict_OmitsNullHeartbeat() + { + var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" }; + var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config); + + Assert.Equal("opc.tcp://x:4840", dict["endpoint"]); + Assert.Equal("60000", dict["SessionTimeoutMs"]); + Assert.Equal("None", dict["SecurityMode"]); + Assert.Equal("True", dict["AutoAcceptUntrustedCerts"]); + Assert.False(dict.ContainsKey("HeartbeatTagPath")); + Assert.False(dict.ContainsKey("HeartbeatMaxSilence")); + } + + [Fact] + public void ToFlatDict_IncludesHeartbeat_WhenSet() + { + var config = new OpcUaEndpointConfig + { + EndpointUrl = "opc.tcp://x:4840", + Heartbeat = new OpcUaHeartbeatConfig { TagPath = "HB.Tag", MaxSilenceSeconds = 45 } + }; + var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config); + + Assert.Equal("HB.Tag", dict["HeartbeatTagPath"]); + Assert.Equal("45", dict["HeartbeatMaxSilence"]); + } + + [Fact] + public void FromFlatDict_RoundTripsAllKeys() + { + var dict = new Dictionary + { + ["endpoint"] = "opc.tcp://x:4840", + ["SessionTimeoutMs"] = "30000", + ["SecurityMode"] = "Sign", + ["HeartbeatTagPath"] = "Hb", + ["HeartbeatMaxSilence"] = "15" + }; + var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict); + + Assert.Equal("opc.tcp://x:4840", config.EndpointUrl); + Assert.Equal(30000, config.SessionTimeoutMs); + Assert.Equal(OpcUaSecurityMode.Sign, config.SecurityMode); + Assert.NotNull(config.Heartbeat); + Assert.Equal("Hb", config.Heartbeat!.TagPath); + } +} +``` + +**Step 2: Run the tests to verify failure** + +Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointConfigSerializerTests` +Expected: build error — type `OpcUaEndpointConfigSerializer` does not exist. + +**Step 3: Commit the failing tests** + +```bash +git add tests/ScadaLink.Commons.Tests/Types/DataConnections/ +git commit -m "test(commons): failing tests for OpcUaEndpointConfigSerializer" +``` + +--- + +## Task 3: Implement `OpcUaEndpointConfigSerializer` + +**Files:** +- Create: `src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs` + +**Step 1: Implement the serializer** + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; +using ScadaLink.Commons.Types.DataConnections; + +namespace ScadaLink.Commons.Serialization; + +/// +/// Serializes to/from the typed nested JSON +/// shape stored in DataConnection.PrimaryConfiguration / BackupConfiguration. +/// On read, falls back to the legacy flat string-dict shape for pre-refactor rows +/// and returns IsLegacy=true so the form can prompt the user to re-save. +/// +public static class OpcUaEndpointConfigSerializer +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + public static string Serialize(OpcUaEndpointConfig config) + => JsonSerializer.Serialize(config, JsonOpts); + + public static (OpcUaEndpointConfig Config, bool IsLegacy) Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return (new OpcUaEndpointConfig(), false); + + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind == JsonValueKind.Object + && doc.RootElement.TryGetProperty("endpointUrl", out _)) + { + var typed = JsonSerializer.Deserialize(json, JsonOpts); + if (typed != null) + return (typed, false); + } + } + catch (JsonException) { /* fall through to legacy */ } + + return (LoadLegacy(json!), IsLegacy: true); + } + + /// + /// Flattens the typed config to the IDictionary<string,string> shape that + /// IDataConnection.ConnectAsync expects. Keys match the historical convention + /// used by OpcUaDataConnection so the adapter can keep that interface. + /// + public static IDictionary ToFlatDict(OpcUaEndpointConfig config) + { + var dict = new Dictionary + { + ["endpoint"] = config.EndpointUrl, + ["SecurityMode"] = config.SecurityMode.ToString(), + ["AutoAcceptUntrustedCerts"] = config.AutoAcceptUntrustedCerts.ToString(), + ["SessionTimeoutMs"] = config.SessionTimeoutMs.ToString(), + ["OperationTimeoutMs"] = config.OperationTimeoutMs.ToString(), + ["PublishingIntervalMs"] = config.PublishingIntervalMs.ToString(), + ["SamplingIntervalMs"] = config.SamplingIntervalMs.ToString(), + ["QueueSize"] = config.QueueSize.ToString(), + ["KeepAliveCount"] = config.KeepAliveCount.ToString(), + ["LifetimeCount"] = config.LifetimeCount.ToString(), + ["MaxNotificationsPerPublish"] = config.MaxNotificationsPerPublish.ToString(), + }; + if (config.Heartbeat is { } hb) + { + dict["HeartbeatTagPath"] = hb.TagPath; + dict["HeartbeatMaxSilence"] = hb.MaxSilenceSeconds.ToString(); + } + return dict; + } + + public static OpcUaEndpointConfig FromFlatDict(IDictionary dict) + { + var c = new OpcUaEndpointConfig + { + EndpointUrl = dict.TryGetValue("endpoint", out var ep) ? ep + : dict.TryGetValue("EndpointUrl", out var ep2) ? ep2 : "", + SecurityMode = Enum.TryParse( + dict.GetValueOrDefault("SecurityMode"), ignoreCase: true, out var sm) + ? sm : OpcUaSecurityMode.None, + AutoAcceptUntrustedCerts = ParseBool(dict, "AutoAcceptUntrustedCerts", true), + SessionTimeoutMs = ParseInt(dict, "SessionTimeoutMs", 60000), + OperationTimeoutMs = ParseInt(dict, "OperationTimeoutMs", 15000), + PublishingIntervalMs = ParseInt(dict, "PublishingIntervalMs", 1000), + SamplingIntervalMs = ParseInt(dict, "SamplingIntervalMs", 1000), + QueueSize = ParseInt(dict, "QueueSize", 10), + KeepAliveCount = ParseInt(dict, "KeepAliveCount", 10), + LifetimeCount = ParseInt(dict, "LifetimeCount", 30), + MaxNotificationsPerPublish = ParseInt(dict, "MaxNotificationsPerPublish", 100), + }; + if (dict.TryGetValue("HeartbeatTagPath", out var hbPath) + && !string.IsNullOrWhiteSpace(hbPath)) + { + c.Heartbeat = new OpcUaHeartbeatConfig + { + TagPath = hbPath, + MaxSilenceSeconds = ParseInt(dict, "HeartbeatMaxSilence", 30) + }; + } + return c; + } + + private static OpcUaEndpointConfig LoadLegacy(string json) + { + var dict = JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + return FromFlatDict(dict); + } + + private static int ParseInt(IDictionary d, string key, int defaultValue) + => d.TryGetValue(key, out var s) && int.TryParse(s, out var v) ? v : defaultValue; + + private static bool ParseBool(IDictionary d, string key, bool defaultValue) + => d.TryGetValue(key, out var s) && bool.TryParse(s, out var v) ? v : defaultValue; +} +``` + +**Step 2: Run tests, verify they pass** + +Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointConfigSerializerTests` +Expected: all 8 tests pass. + +**Step 3: Commit** + +```bash +git add src/ScadaLink.Commons/Serialization/ +git commit -m "feat(commons): OpcUaEndpointConfigSerializer with legacy fallback + flat-dict interop" +``` + +--- + +## Task 4: TDD — `OpcUaEndpointConfigValidator` tests (failing) + +**Files:** +- Create: `tests/ScadaLink.Commons.Tests/Validators/OpcUaEndpointConfigValidatorTests.cs` + +**Step 1: Write the failing test file** + +```csharp +using ScadaLink.Commons.Types.DataConnections; +using ScadaLink.Commons.Types.Flattening; +using ScadaLink.Commons.Validators; + +namespace ScadaLink.Commons.Tests.Validators; + +public class OpcUaEndpointConfigValidatorTests +{ + private static OpcUaEndpointConfig Valid() => new() + { + EndpointUrl = "opc.tcp://plant-a:4840", + // Defaults satisfy the spec: Lifetime(30) >= 3 * KeepAlive(10). + }; + + [Fact] + public void Validate_DefaultsWithValidUrl_IsValid() + { + var result = OpcUaEndpointConfigValidator.Validate(Valid()); + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } + + [Fact] + public void Validate_MissingEndpointUrl_Fails() + { + var c = Valid(); + c.EndpointUrl = ""; + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.False(r.IsValid); + Assert.Contains(r.Errors, e => + e.EntityName == "EndpointUrl" + && e.Category == ValidationCategory.ConnectionConfig + && e.Message.Contains("required", StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData("http://x")] + [InlineData("opc.tcp://")] + [InlineData("not a url")] + public void Validate_BadEndpointUrl_Fails(string url) + { + var c = Valid(); + c.EndpointUrl = url; + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.False(r.IsValid); + Assert.Contains(r.Errors, e => e.EntityName == "EndpointUrl"); + } + + [Fact] + public void Validate_LifetimeLessThanThreeTimesKeepAlive_Fails() + { + var c = Valid(); + c.KeepAliveCount = 10; + c.LifetimeCount = 29; // 3*10 = 30; 29 < 30 → invalid + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.False(r.IsValid); + Assert.Contains(r.Errors, e => e.EntityName == "LifetimeCount"); + } + + [Theory] + [InlineData(nameof(OpcUaEndpointConfig.SessionTimeoutMs))] + [InlineData(nameof(OpcUaEndpointConfig.OperationTimeoutMs))] + [InlineData(nameof(OpcUaEndpointConfig.PublishingIntervalMs))] + [InlineData(nameof(OpcUaEndpointConfig.SamplingIntervalMs))] + public void Validate_NonPositiveTiming_Fails(string field) + { + var c = Valid(); + typeof(OpcUaEndpointConfig).GetProperty(field)!.SetValue(c, 0); + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.False(r.IsValid); + Assert.Contains(r.Errors, e => e.EntityName == field); + } + + [Fact] + public void Validate_QueueSizeZero_Fails() + { + var c = Valid(); + c.QueueSize = 0; + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.Contains(r.Errors, e => e.EntityName == "QueueSize"); + } + + [Fact] + public void Validate_HeartbeatEnabledNoTagPath_Fails() + { + var c = Valid(); + c.Heartbeat = new OpcUaHeartbeatConfig { TagPath = "", MaxSilenceSeconds = 30 }; + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.TagPath"); + } + + [Fact] + public void Validate_HeartbeatNonPositiveSilence_Fails() + { + var c = Valid(); + c.Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Hb", MaxSilenceSeconds = 0 }; + var r = OpcUaEndpointConfigValidator.Validate(c); + Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.MaxSilenceSeconds"); + } + + [Fact] + public void Validate_FieldPrefix_AppliedToEveryError() + { + var c = Valid(); + c.EndpointUrl = ""; + c.QueueSize = 0; + var r = OpcUaEndpointConfigValidator.Validate(c, fieldPrefix: "Primary."); + Assert.All(r.Errors, e => Assert.StartsWith("Primary.", e.EntityName!)); + Assert.Contains(r.Errors, e => e.EntityName == "Primary.EndpointUrl"); + Assert.Contains(r.Errors, e => e.EntityName == "Primary.QueueSize"); + } +} +``` + +**Step 2: Run, verify failure** + +Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointConfigValidatorTests` +Expected: build error — `OpcUaEndpointConfigValidator` not defined. + +**Step 3: Commit** + +```bash +git add tests/ScadaLink.Commons.Tests/Validators/ +git commit -m "test(commons): failing tests for OpcUaEndpointConfigValidator" +``` + +--- + +## Task 5: Implement `OpcUaEndpointConfigValidator` + +**Files:** +- Create: `src/ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs` + +**Step 1: Implement** + +```csharp +using ScadaLink.Commons.Types.DataConnections; +using ScadaLink.Commons.Types.Flattening; + +namespace ScadaLink.Commons.Validators; + +/// +/// Pure-function validator for . Errors carry +/// the offending property name in +/// (optionally prefixed, e.g. "Primary.EndpointUrl") so the form can render +/// per-field messages. +/// +public static class OpcUaEndpointConfigValidator +{ + public static ValidationResult Validate(OpcUaEndpointConfig config, string fieldPrefix = "") + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(config.EndpointUrl)) + errors.Add(Err("EndpointUrl", "Endpoint URL is required.")); + else if (!Uri.TryCreate(config.EndpointUrl, UriKind.Absolute, out var uri) + || uri.Scheme != "opc.tcp" + || string.IsNullOrEmpty(uri.Host)) + errors.Add(Err("EndpointUrl", "Endpoint URL must be a valid opc.tcp:// URI.")); + + if (config.SessionTimeoutMs <= 0) + errors.Add(Err("SessionTimeoutMs", "Must be > 0.")); + if (config.OperationTimeoutMs <= 0) + errors.Add(Err("OperationTimeoutMs", "Must be > 0.")); + if (config.PublishingIntervalMs <= 0) + errors.Add(Err("PublishingIntervalMs", "Must be > 0.")); + if (config.SamplingIntervalMs <= 0) + errors.Add(Err("SamplingIntervalMs", "Must be > 0.")); + if (config.QueueSize < 1) + errors.Add(Err("QueueSize", "Must be ≥ 1.")); + if (config.KeepAliveCount < 1) + errors.Add(Err("KeepAliveCount", "Must be ≥ 1.")); + if (config.LifetimeCount < config.KeepAliveCount * 3) + errors.Add(Err("LifetimeCount", + "Must be at least 3× KeepAliveCount per OPC UA spec.")); + if (config.MaxNotificationsPerPublish < 1) + errors.Add(Err("MaxNotificationsPerPublish", "Must be ≥ 1.")); + + if (config.Heartbeat is { } hb) + { + if (string.IsNullOrWhiteSpace(hb.TagPath)) + errors.Add(Err("Heartbeat.TagPath", + "Tag path is required when heartbeat is enabled.")); + if (hb.MaxSilenceSeconds <= 0) + errors.Add(Err("Heartbeat.MaxSilenceSeconds", "Must be > 0.")); + } + + return errors.Count == 0 + ? ValidationResult.Success() + : ValidationResult.FromErrors(errors.ToArray()); + + ValidationEntry Err(string field, string message) => + ValidationEntry.Error( + ValidationCategory.ConnectionConfig, + message, + entityName: $"{fieldPrefix}{field}"); + } +} +``` + +**Step 2: Run tests, verify pass** + +Run: `dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointConfigValidatorTests` +Expected: all 11 tests pass. + +**Step 3: Commit** + +```bash +git add src/ScadaLink.Commons/Validators/ +git commit -m "feat(commons): OpcUaEndpointConfigValidator" +``` + +--- + +## Task 6: Refactor `OpcUaDataConnection.ConnectAsync` + +**Files:** +- Modify: `src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:44-115` +- Verify: `tests/ScadaLink.DataConnectionLayer.Tests/` — any existing test that constructs a `Dictionary` and passes it to `ConnectAsync` should keep working unchanged. + +**Step 1: Replace `ConnectAsync`, `StartHeartbeatMonitorAsync`, and remove `ParseInt`/`ParseBool` helpers** + +In `OpcUaDataConnection.cs`, replace lines 44-115 with: + +```csharp +public async Task ConnectAsync(IDictionary connectionDetails, CancellationToken cancellationToken = default) +{ + var config = OpcUaEndpointConfigSerializer.FromFlatDict(connectionDetails); + + _endpointUrl = string.IsNullOrWhiteSpace(config.EndpointUrl) + ? "opc.tcp://localhost:4840" + : config.EndpointUrl; + + var options = new OpcUaConnectionOptions( + SessionTimeoutMs: config.SessionTimeoutMs, + OperationTimeoutMs: config.OperationTimeoutMs, + PublishingIntervalMs: config.PublishingIntervalMs, + KeepAliveCount: config.KeepAliveCount, + LifetimeCount: config.LifetimeCount, + MaxNotificationsPerPublish: config.MaxNotificationsPerPublish, + SamplingIntervalMs: config.SamplingIntervalMs, + QueueSize: config.QueueSize, + SecurityMode: config.SecurityMode.ToString(), + AutoAcceptUntrustedCerts: config.AutoAcceptUntrustedCerts); + + _status = ConnectionHealth.Connecting; + + _client = _clientFactory.Create(); + _client.ConnectionLost += OnClientConnectionLost; + await _client.ConnectAsync(_endpointUrl, options, cancellationToken); + + _status = ConnectionHealth.Connected; + _disconnectFired = false; + _logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl); + + await StartHeartbeatMonitorAsync(config.Heartbeat, cancellationToken); +} + +private async Task StartHeartbeatMonitorAsync(OpcUaHeartbeatConfig? heartbeat, CancellationToken cancellationToken) +{ + if (heartbeat is null || string.IsNullOrWhiteSpace(heartbeat.TagPath)) + return; + + _staleMonitor?.Dispose(); + _staleMonitor = new StaleTagMonitor(TimeSpan.FromSeconds(heartbeat.MaxSilenceSeconds)); + _staleMonitor.Stale += () => + { + _logger.LogWarning("OPC UA heartbeat tag '{Tag}' stale — no update in {Seconds}s", + heartbeat.TagPath, heartbeat.MaxSilenceSeconds); + RaiseDisconnected(); + }; + + try + { + _heartbeatSubscriptionId = await SubscribeAsync(heartbeat.TagPath, + (_, _) => _staleMonitor.OnValueReceived(), cancellationToken); + _staleMonitor.Start(); + _logger.LogInformation("OPC UA heartbeat monitor started for '{Tag}' with {Seconds}s max silence", + heartbeat.TagPath, heartbeat.MaxSilenceSeconds); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to subscribe to heartbeat tag '{Tag}' — stale monitor not active", + heartbeat.TagPath); + _staleMonitor.Dispose(); + _staleMonitor = null; + } +} +``` + +Delete the two `internal static` helpers at lines 107-115 (`ParseInt`, `ParseBool`). Add `using ScadaLink.Commons.Serialization;` and `using ScadaLink.Commons.Types.DataConnections;` at the top. + +**Step 2: Run DCL test suite — confirm nothing broke** + +Run: `dotnet test tests/ScadaLink.DataConnectionLayer.Tests/ScadaLink.DataConnectionLayer.Tests.csproj` +Expected: all green. Existing tests pass `Dictionary` to `ConnectAsync`; `FromFlatDict` accepts the same keys (including `endpoint` and `EndpointUrl` casing variants). + +**Step 3: If a test was directly verifying `ParseInt`/`ParseBool` behavior, delete it** + +`grep -n "ParseInt\|ParseBool" tests/ScadaLink.DataConnectionLayer.Tests/ -r` — if it returns hits, delete those test cases. They were testing internals that no longer exist; the same behavior is now covered by `OpcUaEndpointConfigSerializerTests.FromFlatDict_*`. + +**Step 4: Commit** + +```bash +git add src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs +git add tests/ScadaLink.DataConnectionLayer.Tests/ # if any test edits +git commit -m "refactor(dcl): OpcUaDataConnection uses OpcUaEndpointConfig via FromFlatDict" +``` + +--- + +## Task 7: Refactor `DeploymentManagerActor.EnsureDclConnections` + +**Files:** +- Modify: `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:411-468` +- Verify: `tests/ScadaLink.SiteRuntime.Tests/` — particularly any test that hands a sample `FlattenedConfiguration` with a connection JSON. + +**Why this changes:** The current code does dumb `JsonDocument.EnumerateObject` + `prop.Value.ToString()` to flatten the connection JSON into a `Dictionary`. With the new typed nested JSON shape (containing a nested `heartbeat` object), that flattener produces `"heartbeat" → "{\"tagPath\":\"...\"}"` — broken. The serializer's `Deserialize` + `ToFlatDict` does it correctly and also handles the legacy shape. + +**Step 1: Replace the body of `EnsureDclConnections`** + +Replace lines 411-468 with: + +```csharp +/// +/// Sets up DCL connections from the flattened config (idempotent: tracks created connections). +/// +private void EnsureDclConnections(string configJson) +{ + if (_dclManager == null) return; + + try + { + var config = System.Text.Json.JsonSerializer.Deserialize(configJson); + if (config?.Connections == null) return; + + foreach (var (name, connConfig) in config.Connections) + { + if (_createdConnections.Contains(name)) + continue; + + var primaryDetails = FlattenConnectionConfig(connConfig.Protocol, connConfig.ConfigurationJson); + var backupDetails = string.IsNullOrEmpty(connConfig.BackupConfigurationJson) + ? null + : FlattenConnectionConfig(connConfig.Protocol, connConfig.BackupConfigurationJson); + + _dclManager.Tell(new Commons.Messages.DataConnection.CreateConnectionCommand( + name, connConfig.Protocol, primaryDetails, backupDetails, connConfig.FailoverRetryCount)); + + _createdConnections.Add(name); + _logger.LogInformation( + "Created DCL connection {Connection} (protocol={Protocol})", + name, connConfig.Protocol); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse flattened config for DCL connections"); + } +} + +private static IDictionary FlattenConnectionConfig(string protocol, string? json) +{ + if (string.IsNullOrEmpty(json)) + return new Dictionary(); + + if (string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase)) + { + var (config, _) = Commons.Serialization.OpcUaEndpointConfigSerializer.Deserialize(json); + return Commons.Serialization.OpcUaEndpointConfigSerializer.ToFlatDict(config); + } + + // Fallback: assume legacy flat-dict shape for any future / unknown protocol. + try + { + var dict = new Dictionary(); + using var doc = System.Text.Json.JsonDocument.Parse(json); + foreach (var prop in doc.RootElement.EnumerateObject()) + dict[prop.Name] = prop.Value.ToString(); + return dict; + } + catch + { + return new Dictionary(); + } +} +``` + +**Step 2: Run SiteRuntime tests** + +Run: `dotnet test tests/ScadaLink.SiteRuntime.Tests/ScadaLink.SiteRuntime.Tests.csproj` +Expected: green. Any existing test passing legacy-style JSON for an OPC UA connection still works because `Deserialize` handles both shapes. + +**Step 3: Commit** + +```bash +git add src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs +git commit -m "refactor(site-runtime): route OPC UA connection JSON through serializer" +``` + +--- + +## Task 8: TDD — `` Blazor component tests (failing) + +**Files:** +- Create: `tests/ScadaLink.CentralUI.Tests/Forms/OpcUaEndpointEditorTests.cs` + +**Step 1: Write the failing tests** + +```csharp +using Bunit; +using ScadaLink.Commons.Types.DataConnections; +using ScadaLink.Commons.Types.Flattening; +using ScadaLink.Commons.Validators; +using OpcUaEndpointEditor = ScadaLink.CentralUI.Components.Forms.OpcUaEndpointEditor; + +namespace ScadaLink.CentralUI.Tests.Forms; + +public class OpcUaEndpointEditorTests : BunitContext +{ + [Fact] + public void Renders_All_Four_Section_Labels() + { + var config = new OpcUaEndpointConfig(); + var cut = Render(p => p + .Add(c => c.Config, config) + .Add(c => c.Title, "Primary Endpoint")); + + Assert.Contains("Primary Endpoint", cut.Markup); + Assert.Contains("Timing", cut.Markup); + Assert.Contains("Subscription", cut.Markup); + Assert.Contains("Heartbeat", cut.Markup); + } + + [Fact] + public void Binding_MutatesPassedConfigInstance() + { + var config = new OpcUaEndpointConfig(); + var cut = Render(p => p.Add(c => c.Config, config)); + + cut.Find("input[type='text']").Change("opc.tcp://new-host:4840"); + + Assert.Equal("opc.tcp://new-host:4840", config.EndpointUrl); + } + + [Fact] + public void EnableHeartbeat_CreatesSubObject() + { + var config = new OpcUaEndpointConfig(); + var cut = Render(p => p.Add(c => c.Config, config)); + + Assert.Null(config.Heartbeat); + cut.FindAll("button").First(b => b.TextContent.Contains("Enable Heartbeat")).Click(); + + Assert.NotNull(config.Heartbeat); + } + + [Fact] + public void RemoveHeartbeat_NullsSubObject() + { + var config = new OpcUaEndpointConfig + { + Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Hb", MaxSilenceSeconds = 30 } + }; + var cut = Render(p => p.Add(c => c.Config, config)); + + cut.FindAll("button").First(b => b.TextContent.Contains("Remove Heartbeat")).Click(); + + Assert.Null(config.Heartbeat); + } + + [Fact] + public void Errors_Parameter_RendersPerFieldRedText() + { + var config = new OpcUaEndpointConfig { EndpointUrl = "" }; + var errors = OpcUaEndpointConfigValidator.Validate(config, "Primary."); + var cut = Render(p => p + .Add(c => c.Config, config) + .Add(c => c.Errors, errors)); + + Assert.Contains("Endpoint URL is required.", cut.Markup); + Assert.Contains("text-danger", cut.Markup); + } + + [Fact] + public void IsLegacy_True_RendersWarningBanner() + { + var cut = Render(p => p + .Add(c => c.Config, new OpcUaEndpointConfig()) + .Add(c => c.IsLegacy, true)); + + Assert.Contains("alert-warning", cut.Markup); + Assert.Contains("migrated from a legacy format", cut.Markup); + } +} +``` + +**Step 2: Run, verify failure** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointEditorTests` +Expected: build error — `OpcUaEndpointEditor` does not exist. + +**Step 3: Commit** + +```bash +git add tests/ScadaLink.CentralUI.Tests/Forms/ +git commit -m "test(ui): failing bUnit tests for OpcUaEndpointEditor" +``` + +--- + +## Task 9: Implement `` Blazor component + +**Files:** +- Create: `src/ScadaLink.CentralUI/Components/Forms/OpcUaEndpointEditor.razor` + +**Step 1: Implement the component** + +```razor +@namespace ScadaLink.CentralUI.Components.Forms +@using ScadaLink.Commons.Types.DataConnections +@using ScadaLink.Commons.Types.Flattening + +
+
@Title
+ + @if (IsLegacy) + { +
+ This connection was migrated from a legacy format. + Review the settings and Save to update. +
+ } + +
+
+ + + @RenderFieldError("EndpointUrl") +
+
+ + +
+
+
+ + +
+
+
+ +
Timing
+
+
+ + + @RenderFieldError("SessionTimeoutMs") +
+
+ + + @RenderFieldError("OperationTimeoutMs") +
+
+ +
Subscription
+
+
+ + + @RenderFieldError("PublishingIntervalMs") +
+
+ + + @RenderFieldError("SamplingIntervalMs") +
+
+ + + @RenderFieldError("QueueSize") +
+
+ + + @RenderFieldError("KeepAliveCount") +
+
+ + + @RenderFieldError("LifetimeCount") +
+
+ + + @RenderFieldError("MaxNotificationsPerPublish") +
+
+ +
Heartbeat
+ @if (Config.Heartbeat is null) + { + + } + else + { +
+
+ + + @RenderFieldError("Heartbeat.TagPath") +
+
+ + + @RenderFieldError("Heartbeat.MaxSilenceSeconds") +
+
+ +
+
+ } +
+ +@code { + [Parameter, EditorRequired] public OpcUaEndpointConfig Config { get; set; } = default!; + [Parameter] public string Title { get; set; } = "Endpoint"; + [Parameter] public string IdPrefix { get; set; } = "endpoint"; + [Parameter] public bool IsLegacy { get; set; } + [Parameter] public ValidationResult? Errors { get; set; } + + private void EnableHeartbeat() => + Config.Heartbeat = new OpcUaHeartbeatConfig(); + + private RenderFragment? RenderFieldError(string field) + { + var match = Errors?.Errors.FirstOrDefault(e => + e.EntityName != null + && (e.EntityName == field || e.EntityName.EndsWith("." + field))); + return match is null + ? null + : @
@match.Message
; + } +} +``` + +**Step 2: Run the editor tests, verify pass** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointEditorTests` +Expected: all 6 tests pass. + +**Step 3: Commit** + +```bash +git add src/ScadaLink.CentralUI/Components/Forms/ +git commit -m "feat(ui): OpcUaEndpointEditor Blazor component" +``` + +--- + +## Task 10: TDD — `DataConnectionForm` refactor tests (failing/updating) + +**Files:** +- Create or modify: `tests/ScadaLink.CentralUI.Tests/DataConnectionFormTests.cs` + +Before writing, run: `ls tests/ScadaLink.CentralUI.Tests/ | grep -i "DataConnection"`. If a test file already exists, **modify** it; otherwise create new. + +**Step 1: Write the failing tests** + +```csharp +using System.Security.Claims; +using System.Text.Json; +using Bunit; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Interfaces.Repositories; +using DataConnectionForm = ScadaLink.CentralUI.Components.Pages.Admin.DataConnectionForm; + +namespace ScadaLink.CentralUI.Tests; + +public class DataConnectionFormTests : BunitContext +{ + private readonly ISiteRepository _siteRepo = Substitute.For(); + + public DataConnectionFormTests() + { + Services.AddSingleton(_siteRepo); + AddTestAuth(); + var sites = new List { new("Plant-A", "plant-a") { Id = 1 } }; + _siteRepo.GetAllSitesAsync(Arg.Any()) + .Returns(Task.FromResult>(sites)); + } + + private void AddTestAuth() + { + var claims = new[] + { + new Claim("Username", "tester"), + new Claim(ClaimTypes.Role, "Admin") + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + } + + [Fact] + public void NoProtocolDropdown_IsRendered() + { + var cut = Render(p => p.Add(f => f.SiteId, 1)); + Assert.DoesNotContain("Custom", cut.Markup); + // OPC UA security mode dropdown does exist, but the protocol dropdown's + // distinctive labels (e.g. an option saying "OPC UA" directly) should be gone. + var labels = cut.FindAll("label").Select(l => l.TextContent.Trim()).ToList(); + Assert.DoesNotContain(labels, l => l == "Protocol"); + } + + [Fact] + public async Task Save_InvalidPrimaryUrl_DoesNotCallRepo() + { + var cut = Render(p => p.Add(f => f.SiteId, 1)); + cut.FindAll("input[type='text']") + .First(i => i.GetAttribute("placeholder")?.StartsWith("opc.tcp") == true) + .Change("not-a-url"); + + // Name field + cut.FindAll("input[type='text']") + .First(i => i.GetAttribute("placeholder") is null + || !i.GetAttribute("placeholder")!.StartsWith("opc.tcp")) + .Change("My Connection"); + + await cut.FindAll("button") + .First(b => b.TextContent.Trim() == "Save").ClickAsync(new()); + + await _siteRepo.DidNotReceive().AddDataConnectionAsync(Arg.Any()); + Assert.Contains("Endpoint URL must be a valid", cut.Markup); + } + + [Fact] + public async Task Save_ValidConfig_PersistsTypedJsonAndProtocolOpcUa() + { + DataConnection? captured = null; + await _siteRepo.AddDataConnectionAsync( + Arg.Do(d => captured = d)); + + var cut = Render(p => p.Add(f => f.SiteId, 1)); + + // Name + cut.FindAll("input[type='text']") + .First(i => i.GetAttribute("placeholder") is null + || !i.GetAttribute("placeholder")!.StartsWith("opc.tcp")) + .Change("PLC-1"); + // Endpoint URL + cut.FindAll("input[type='text']") + .First(i => i.GetAttribute("placeholder")?.StartsWith("opc.tcp") == true) + .Change("opc.tcp://plant-a:4840"); + + await cut.FindAll("button") + .First(b => b.TextContent.Trim() == "Save").ClickAsync(new()); + + Assert.NotNull(captured); + Assert.Equal("OpcUa", captured!.Protocol); + Assert.NotNull(captured.PrimaryConfiguration); + + using var doc = JsonDocument.Parse(captured.PrimaryConfiguration!); + Assert.Equal("opc.tcp://plant-a:4840", + doc.RootElement.GetProperty("endpointUrl").GetString()); + Assert.Equal(60000, + doc.RootElement.GetProperty("sessionTimeoutMs").GetInt32()); + } +} +``` + +**Step 2: Run, verify failure** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter FullyQualifiedName~DataConnectionFormTests` +Expected: at least one test fails — current form still has the Protocol dropdown and stores raw textarea JSON, not typed-camelCase JSON. + +**Step 3: Commit** + +```bash +git add tests/ScadaLink.CentralUI.Tests/DataConnectionFormTests.cs +git commit -m "test(ui): failing tests for DataConnectionForm refactor" +``` + +--- + +## Task 11: Refactor `DataConnectionForm.razor` + +**Files:** +- Modify: `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor` (whole file) + +**Step 1: Rewrite the form** + +Replace the entire file with: + +```razor +@page "/admin/connections/create" +@page "/admin/connections/{Id:int}/edit" +@page "/admin/data-connections/create" +@page "/admin/data-connections/{Id:int}/edit" +@using ScadaLink.Security +@using ScadaLink.Commons.Entities.Sites +@using ScadaLink.Commons.Interfaces.Repositories +@using ScadaLink.Commons.Types.DataConnections +@using ScadaLink.Commons.Types.Flattening +@using ScadaLink.Commons.Serialization +@using ScadaLink.Commons.Validators +@using ScadaLink.CentralUI.Components.Forms +@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] +@inject ISiteRepository SiteRepository +@inject NavigationManager NavigationManager + +
+
+ +

@(Id.HasValue ? "Edit Data Connection" : "Add Data Connection")

+
+ + @if (_loading) + { + + } + else + { +
+
+
+ + @if (_siteLocked) + { + + } + else + { + + } +
+
+ + +
+ + + +
Backup Endpoint
+ @if (!_showBackup) + { +
+ +
+ } + else + { + +
+ + +
Retries on active endpoint before switching to backup (default: 3)
+
+
+ +
+ } + + @if (_formError != null) + { +
@_formError
+ } +
+ + +
+
+
+ } +
+ +@code { + [Parameter] public int? Id { get; set; } + [SupplyParameterFromQuery] public int? SiteId { get; set; } + + private bool _loading = true; + private DataConnection? _editingConnection; + private List _sites = new(); + private int _formSiteId; + private string _siteName = string.Empty; + private bool _siteLocked; + private string _formName = string.Empty; + private OpcUaEndpointConfig _primaryConfig = new(); + private OpcUaEndpointConfig _backupConfig = new(); + private bool _primaryIsLegacy; + private bool _backupIsLegacy; + private bool _showBackup; + private int _formFailoverRetryCount = 3; + private ValidationResult? _primaryErrors; + private ValidationResult? _backupErrors; + private string? _formError; + + protected override async Task OnInitializedAsync() + { + _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); + + if (Id.HasValue) + { + try + { + _editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value); + if (_editingConnection != null) + { + _formSiteId = _editingConnection.SiteId; + _siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}"; + _siteLocked = true; + _formName = _editingConnection.Name; + + (_primaryConfig, _primaryIsLegacy) = + OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.PrimaryConfiguration); + + if (!string.IsNullOrWhiteSpace(_editingConnection.BackupConfiguration)) + { + (_backupConfig, _backupIsLegacy) = + OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.BackupConfiguration); + _showBackup = true; + _formFailoverRetryCount = _editingConnection.FailoverRetryCount; + } + } + } + catch (Exception ex) + { + _formError = $"Failed to load connection: {ex.Message}"; + } + } + else if (SiteId.HasValue) + { + var site = _sites.FirstOrDefault(s => s.Id == SiteId.Value); + if (site != null) + { + _formSiteId = site.Id; + _siteName = site.Name; + _siteLocked = true; + } + } + + _loading = false; + } + + private async Task SaveConnection() + { + _formError = null; + if (_formSiteId == 0) { _formError = "Site is required."; return; } + if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; } + + _primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary."); + _backupErrors = _showBackup + ? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.") + : null; + + if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false })) + { + _formError = "Fix the errors below before saving."; + return; + } + + var primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig); + var backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null; + + try + { + if (_editingConnection != null) + { + _editingConnection.Name = _formName.Trim(); + _editingConnection.Protocol = "OpcUa"; + _editingConnection.PrimaryConfiguration = primaryJson; + _editingConnection.BackupConfiguration = backupJson; + _editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3; + await SiteRepository.UpdateDataConnectionAsync(_editingConnection); + } + else + { + var conn = new DataConnection(_formName.Trim(), "OpcUa", _formSiteId) + { + PrimaryConfiguration = primaryJson, + BackupConfiguration = backupJson, + FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3 + }; + await SiteRepository.AddDataConnectionAsync(conn); + } + await SiteRepository.SaveChangesAsync(); + NavigationManager.NavigateTo("/admin/connections"); + } + catch (Exception ex) + { + _formError = $"Save failed: {ex.Message}"; + } + } + + private void EnableBackup() => _showBackup = true; + + private void RemoveBackup() + { + _showBackup = false; + _backupConfig = new OpcUaEndpointConfig(); + _backupIsLegacy = false; + _formFailoverRetryCount = 3; + } + + private void GoBack() => NavigationManager.NavigateTo("/admin/connections"); +} +``` + +**Step 2: Run the form tests, verify pass** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter FullyQualifiedName~DataConnectionFormTests` +Expected: all tests pass. + +**Step 3: Run the full CentralUI suite** + +Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj` +Expected: all tests pass (the pre-existing `DataConnectionsPageTests` is unaffected; it tests the list page, not the form). + +**Step 4: Commit** + +```bash +git add src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor +git commit -m "refactor(ui/admin): DataConnectionForm uses OpcUaEndpointEditor and typed model" +``` + +--- + +## Task 12: Full solution build + all test suites green + +**Step 1: Solution-wide build** + +Run: `dotnet build` +Expected: build succeeded, 0 errors. Warnings acceptable. + +**Step 2: Solution-wide test** + +Run: `dotnet test` +Expected: all test projects green. Likely-affected suites: +- `ScadaLink.Commons.Tests` (+ new serializer & validator tests, ~19 new total) +- `ScadaLink.DataConnectionLayer.Tests` (existing tests must still pass) +- `ScadaLink.SiteRuntime.Tests` (existing tests must still pass) +- `ScadaLink.CentralUI.Tests` (+ new editor tests, + new/updated form tests) + +**Step 3: If anything fails**, do NOT proceed to Docker. Diagnose and fix in a new commit before moving on. + +There is no commit in this task — it's a checkpoint. + +--- + +## Task 13: Docker deploy + browser smoke + +**Step 1: Rebuild and start the cluster** + +Run: `bash docker/deploy.sh` +Expected: `scadalink:latest` rebuilt, 5 cluster containers up (central-a, central-b, site-a, site-b, site-c per `docker/README.md`). + +**Step 2: Open the connections page** + +URL: `http://localhost:9000/admin/connections` +Login: `multi-role` / `password` +Expected: existing test data shows 3 sites and ~6 OPC UA connections in the tree. + +**Step 3: Edit an existing legacy connection** + +Pick any pre-refactor connection (likely all of them on first deploy). Right-click → Edit, or use the connection row's Edit action. +Expected: +- "Primary Endpoint" header renders. +- A yellow `alert-warning` banner reads "This connection was migrated from a legacy format..." +- All fields are populated from the legacy JSON. +- Click Save → returns to the list page. +- Open the same connection again → the legacy banner is gone (it now uses the typed shape). + +**Step 4: Create a new connection with validation failure** + +From the list page, click a site row to select it, then click `+ Connection`. +- Leave Endpoint URL blank, fill Name = "test", click Save. +- Expected: red text "Endpoint URL is required." under the URL field. No navigation. + +**Step 5: Create a new connection, valid** + +- Endpoint URL: `opc.tcp://plant-a:4840` +- Toggle "Enable Heartbeat" → Tag path: `Sensors.Heartbeat`, Max silence: `30` +- Add Backup Endpoint: `opc.tcp://plant-a-backup:4840` +- Click Save → navigates back to the list page. +- New connection appears under the selected site. + +**Step 6: Verify DB JSON shape** + +Run: `docker exec scadalink-central-a sqlite3 /var/lib/scadalink/scadalink.db "SELECT PrimaryConfiguration FROM DataConnections ORDER BY Id DESC LIMIT 1;"` +Expected: typed nested JSON containing `"endpointUrl"`, `"securityMode"`, `"heartbeat":{"tagPath":...}`. + +**Step 7: Deploy a template that uses the connection** + +Optional but a strong smoke. Use any pre-existing template that has a connection binding. Navigate to `/deployment/topology`, deploy to the site that owns the new connection. +Expected: site logs (`docker logs scadalink-site-a`) show "OPC UA connected to opc.tcp://..." — the runtime parsed the new JSON shape correctly. + +**Step 8: If anything's off**, capture screenshots / logs, fix, and re-deploy before committing. + +No commit yet; this is verification. + +--- + +## Task 14: Final commit & push + +**Step 1: Review the branch state** + +Run: `git status` and `git log --oneline f3b33e7..HEAD` +Expected: ~7 commits from this plan (Task 1, 2, 3, 4, 5, 6, 7, 9, 10, 11 — some folded into a single commit each per the task instructions above). + +**Step 2: Push** + +Run: `git push` +Expected: branch advances on origin; existing PR #1 picks up the new commits. + +**Step 3: Verify the PR** + +Open `https://gitea.dohertylan.com/dohertj2/scadalink-design/pulls/1` and confirm the new commits are listed. + +--- + +## Out of scope (do NOT do) + +- **No EF Core migration.** Column types are unchanged. +- **No CLI changes.** The `data-connection` CLI commands continue to pass through raw JSON; if a user passes legacy-dict JSON, the new serializer's legacy fallback handles it. (Note for future work: the CLI could grow `--endpoint-url` / `--security-mode` flags; out of scope here.) +- **No new protocols.** "Custom" is gone from the UI; if/when a real Custom adapter lands, the form re-introduces a protocol dropdown. +- **No live (debounced) validation.** Save-time only. +- **No "Verify endpoint" connectivity test button.** +- **No requirements-doc rewrite.** The design doc points at `OpcUaEndpointConfig` as the canonical schema; that's enough. diff --git a/docs/plans/2026-05-12-opcua-config-model.md.tasks.json b/docs/plans/2026-05-12-opcua-config-model.md.tasks.json new file mode 100644 index 0000000..c8e0513 --- /dev/null +++ b/docs/plans/2026-05-12-opcua-config-model.md.tasks.json @@ -0,0 +1,20 @@ +{ + "planPath": "docs/plans/2026-05-12-opcua-config-model.md", + "tasks": [ + {"id": 45, "subject": "Task 1: Create OPC UA config POCOs + ValidationCategory.ConnectionConfig", "status": "pending"}, + {"id": 46, "subject": "Task 2: TDD failing tests for OpcUaEndpointConfigSerializer", "status": "pending", "blockedBy": [45]}, + {"id": 47, "subject": "Task 3: Implement OpcUaEndpointConfigSerializer", "status": "pending", "blockedBy": [46]}, + {"id": 48, "subject": "Task 4: TDD failing tests for OpcUaEndpointConfigValidator", "status": "pending", "blockedBy": [45]}, + {"id": 49, "subject": "Task 5: Implement OpcUaEndpointConfigValidator", "status": "pending", "blockedBy": [48]}, + {"id": 50, "subject": "Task 6: Refactor OpcUaDataConnection.ConnectAsync to use FromFlatDict", "status": "pending", "blockedBy": [47]}, + {"id": 51, "subject": "Task 7: Refactor DeploymentManagerActor.EnsureDclConnections", "status": "pending", "blockedBy": [47]}, + {"id": 52, "subject": "Task 8: TDD failing bUnit tests for OpcUaEndpointEditor", "status": "pending", "blockedBy": [45, 49]}, + {"id": 53, "subject": "Task 9: Implement OpcUaEndpointEditor.razor", "status": "pending", "blockedBy": [52]}, + {"id": 54, "subject": "Task 10: TDD failing bUnit tests for DataConnectionForm refactor", "status": "pending", "blockedBy": [47, 49]}, + {"id": 55, "subject": "Task 11: Refactor DataConnectionForm.razor", "status": "pending", "blockedBy": [53, 54]}, + {"id": 56, "subject": "Task 12: Solution build + all test suites green", "status": "pending", "blockedBy": [50, 51, 55]}, + {"id": 57, "subject": "Task 13: Docker deploy + browser smoke", "status": "pending", "blockedBy": [56]}, + {"id": 58, "subject": "Task 14: Push to origin", "status": "pending", "blockedBy": [57]} + ], + "lastUpdated": "2026-05-12T04:33:33Z" +}