# 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.