Files
scadalink-design/docs/plans/2026-05-12-opcua-config-model.md
Joseph Doherty a9c4c2c655 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: <OpcUaEndpointEditor> + 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.
2026-05-12 00:33:51 -04:00

1646 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<string,string>` contract (protocol-agnostic), and `OpcUaDataConnection` uses `OpcUaEndpointConfigSerializer.FromFlatDict` internally to get the typed model. A shared `<OpcUaEndpointEditor>` 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 `<OpcUaEndpointEditor>` 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<string, string>
{
["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;
/// <summary>
/// Serializes <see cref="OpcUaEndpointConfig"/> to/from the typed nested JSON
/// shape stored in <c>DataConnection.PrimaryConfiguration</c> / <c>BackupConfiguration</c>.
/// 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.
/// </summary>
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<OpcUaEndpointConfig>(json, JsonOpts);
if (typed != null)
return (typed, false);
}
}
catch (JsonException) { /* fall through to legacy */ }
return (LoadLegacy(json!), IsLegacy: true);
}
/// <summary>
/// Flattens the typed config to the IDictionary&lt;string,string&gt; shape that
/// IDataConnection.ConnectAsync expects. Keys match the historical convention
/// used by OpcUaDataConnection so the adapter can keep that interface.
/// </summary>
public static IDictionary<string, string> ToFlatDict(OpcUaEndpointConfig config)
{
var dict = new Dictionary<string, string>
{
["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<string, string> dict)
{
var c = new OpcUaEndpointConfig
{
EndpointUrl = dict.TryGetValue("endpoint", out var ep) ? ep
: dict.TryGetValue("EndpointUrl", out var ep2) ? ep2 : "",
SecurityMode = Enum.TryParse<OpcUaSecurityMode>(
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<Dictionary<string, string>>(json)
?? new Dictionary<string, string>();
return FromFlatDict(dict);
}
private static int ParseInt(IDictionary<string, string> d, string key, int defaultValue)
=> d.TryGetValue(key, out var s) && int.TryParse(s, out var v) ? v : defaultValue;
private static bool ParseBool(IDictionary<string, string> 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;
/// <summary>
/// Pure-function validator for <see cref="OpcUaEndpointConfig"/>. Errors carry
/// the offending property name in <see cref="ValidationEntry.EntityName"/>
/// (optionally prefixed, e.g. "Primary.EndpointUrl") so the form can render
/// per-field messages.
/// </summary>
public static class OpcUaEndpointConfigValidator
{
public static ValidationResult Validate(OpcUaEndpointConfig config, string fieldPrefix = "")
{
var errors = new List<ValidationEntry>();
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<string,string>` 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<string, string> 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<string,string>` 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<string,string>`. 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
/// <summary>
/// Sets up DCL connections from the flattened config (idempotent: tracks created connections).
/// </summary>
private void EnsureDclConnections(string configJson)
{
if (_dclManager == null) return;
try
{
var config = System.Text.Json.JsonSerializer.Deserialize<Commons.Types.Flattening.FlattenedConfiguration>(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<string, string> FlattenConnectionConfig(string protocol, string? json)
{
if (string.IsNullOrEmpty(json))
return new Dictionary<string, string>();
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<string, string>();
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<string, string>();
}
}
```
**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 — `<OpcUaEndpointEditor>` 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<OpcUaEndpointEditor>(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<OpcUaEndpointEditor>(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<OpcUaEndpointEditor>(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<OpcUaEndpointEditor>(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<OpcUaEndpointEditor>(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<OpcUaEndpointEditor>(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 `<OpcUaEndpointEditor>` 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
<div class="opcua-endpoint-editor">
<h6 class="text-muted border-bottom pb-1">@Title</h6>
@if (IsLegacy)
{
<div class="alert alert-warning py-1 small mb-2">
This connection was migrated from a legacy format.
Review the settings and Save to update.
</div>
}
<div class="row g-2 mb-2">
<div class="col-md-7">
<label class="form-label small">Endpoint URL</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.EndpointUrl"
placeholder="opc.tcp://host:4840" />
@RenderFieldError("EndpointUrl")
</div>
<div class="col-md-3">
<label class="form-label small">Security Mode</label>
<select class="form-select form-select-sm" @bind="Config.SecurityMode">
<option value="@OpcUaSecurityMode.None">None</option>
<option value="@OpcUaSecurityMode.Sign">Sign</option>
<option value="@OpcUaSecurityMode.SignAndEncrypt">Sign &amp; Encrypt</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@($"{IdPrefix}-autoaccept")"
@bind="Config.AutoAcceptUntrustedCerts" />
<label class="form-check-label small"
for="@($"{IdPrefix}-autoaccept")">Auto-accept certs</label>
</div>
</div>
</div>
<div class="text-muted small mt-2 mb-1">Timing</div>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Session timeout (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SessionTimeoutMs" min="1" />
@RenderFieldError("SessionTimeoutMs")
</div>
<div class="col-md-3">
<label class="form-label small">Operation timeout (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.OperationTimeoutMs" min="1" />
@RenderFieldError("OperationTimeoutMs")
</div>
</div>
<div class="text-muted small mt-2 mb-1">Subscription</div>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Publishing interval (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.PublishingIntervalMs" min="1" />
@RenderFieldError("PublishingIntervalMs")
</div>
<div class="col-md-3">
<label class="form-label small">Sampling interval (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SamplingIntervalMs" min="1" />
@RenderFieldError("SamplingIntervalMs")
</div>
<div class="col-md-2">
<label class="form-label small">Queue size</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.QueueSize" min="1" />
@RenderFieldError("QueueSize")
</div>
<div class="col-md-2">
<label class="form-label small">Keep-alive count</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.KeepAliveCount" min="1" />
@RenderFieldError("KeepAliveCount")
</div>
<div class="col-md-2">
<label class="form-label small">Lifetime count</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.LifetimeCount" min="1" />
@RenderFieldError("LifetimeCount")
</div>
<div class="col-md-3">
<label class="form-label small">Max notifications / publish</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.MaxNotificationsPerPublish" min="1" />
@RenderFieldError("MaxNotificationsPerPublish")
</div>
</div>
<div class="text-muted small mt-2 mb-1">Heartbeat</div>
@if (Config.Heartbeat is null)
{
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="EnableHeartbeat">Enable Heartbeat</button>
}
else
{
<div class="row g-2 mb-2">
<div class="col-md-6">
<label class="form-label small">Tag path</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.Heartbeat.TagPath"
placeholder="Sensors.Heartbeat" />
@RenderFieldError("Heartbeat.TagPath")
</div>
<div class="col-md-3">
<label class="form-label small">Max silence (s)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.Heartbeat.MaxSilenceSeconds" min="1" />
@RenderFieldError("Heartbeat.MaxSilenceSeconds")
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="() => Config.Heartbeat = null">
Remove Heartbeat
</button>
</div>
</div>
}
</div>
@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
: @<div class="text-danger small">@match.Message</div>;
}
}
```
**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<ISiteRepository>();
public DataConnectionFormTests()
{
Services.AddSingleton(_siteRepo);
AddTestAuth();
var sites = new List<Site> { new("Plant-A", "plant-a") { Id = 1 } };
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(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<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
[Fact]
public void NoProtocolDropdown_IsRendered()
{
var cut = Render<DataConnectionForm>(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<DataConnectionForm>(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<DataConnection>());
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<DataConnection>(d => captured = d));
var cut = Render<DataConnectionForm>(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
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-0">@(Id.HasValue ? "Edit Data Connection" : "Add Data Connection")</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Site</label>
@if (_siteLocked)
{
<input type="text" class="form-control form-control-sm" value="@_siteName" disabled />
}
else
{
<select class="form-select form-select-sm" @bind="_formSiteId">
<option value="0">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name</option>
}
</select>
}
</div>
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<OpcUaEndpointEditor Title="Primary Endpoint"
IdPrefix="primary"
Config="_primaryConfig"
IsLegacy="_primaryIsLegacy"
Errors="_primaryErrors" />
<h6 class="text-muted border-bottom pb-1 mt-3">Backup Endpoint</h6>
@if (!_showBackup)
{
<div class="mb-3">
<button type="button" class="btn btn-outline-secondary btn-sm"
@onclick="EnableBackup">Add Backup Endpoint</button>
</div>
}
else
{
<OpcUaEndpointEditor Title="Backup Endpoint"
IdPrefix="backup"
Config="_backupConfig"
IsLegacy="_backupIsLegacy"
Errors="_backupErrors" />
<div class="mb-2">
<label class="form-label small">Failover Retry Count</label>
<input type="number" class="form-control form-control-sm" style="max-width: 120px;"
min="1" max="20" @bind="_formFailoverRetryCount" />
<div class="form-text">Retries on active endpoint before switching to backup (default: 3)</div>
</div>
<div class="mb-3">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="RemoveBackup">Remove Backup</button>
</div>
}
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
[SupplyParameterFromQuery] public int? SiteId { get; set; }
private bool _loading = true;
private DataConnection? _editingConnection;
private List<Site> _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.