Captures the design decisions from the brainstorming session: - OpcUaEndpointConfig POCO + validator + serializer in Commons - Single source of truth: both UI and site runtime consume the model - Typed nested JSON storage (camelCase), legacy flat-dict fallback - Shared <OpcUaEndpointEditor> Blazor component used twice - Custom protocol removed from dropdown; Protocol field hidden - Validation timing on Save only; per-field red text via ValidationEntry
16 KiB
OPC UA Endpoint Config Model & Form Refactor — Design
Date: 2026-05-12
Branch: feature/templates-folder-hierarchy (and successors)
Status: Design approved, ready for implementation planning
Problem
DataConnection.PrimaryConfiguration and BackupConfiguration are free-form JSON strings. Today:
- The site-side runtime (
OpcUaDataConnection.cs:44-90) parses them as a flatIDictionary<string,string>and string-fishes ~12 keys (endpoint/EndpointUrl,SessionTimeoutMs,SecurityMode,AutoAcceptUntrustedCerts, etc.). - The Central UI form (
DataConnectionForm.razor) edits them as plain textareas. Its placeholder hints are inconsistent:{"endpoint":"opc.tcp://..."}for primary but{"Host":"backup-host","Port":50101}for backup — the latter is not actually parsed by the runtime. - There is no schema, no validator, no documentation that's actually checked by code.
- The form's Protocol dropdown still offers "Custom" although no backend adapter exists — selecting it produces a deploy-time
"Unknown protocol type: Custom"failure.
We want a strongly-typed model for OPC UA endpoint configuration, a validator that's the single source of truth for what's legal, and a form that renders typed controls per field instead of a JSON blob.
Decision summary
| # | Decision | Choice |
|---|---|---|
| 1 | Scope of the model | Single source of truth — used by both UI and runtime. Drops the dictionary-key string-fishing in OpcUaDataConnection.cs. |
| 2 | Field coverage in the form | All fields, grouped: Connection / Timing / Subscription / Heartbeat. Sensible defaults pre-filled. |
| 3 | Custom protocol option | Remove from dropdown. OPC UA is the only supported protocol today. |
| 4 | Storage format | Typed nested JSON via System.Text.Json with camelCase + JsonStringEnumConverter. |
| 5 | Model location | ScadaLink.Commons/Types/DataConnections/ plus a sibling Validators/Serialization namespace. |
| 6 | Validator return type | ValidationResult + ValidationEntry — matches SemanticValidator convention. |
| 7 | Form structure | Shared <OpcUaEndpointEditor> Blazor component, used twice (primary + backup). |
| 8 | Protocol field in UI | Hidden; entity field set to "OpcUa" implicitly on save. |
| 9 | Validation timing | On Save click only. No live per-field validation. |
| 10 | Legacy-row handling | Best-effort parse + warning banner. Save rewrites to the new shape. |
Architecture
┌──────────────────────────────────────┐
│ ScadaLink.Commons │
│ Types/DataConnections/ │
│ OpcUaEndpointConfig.cs (POCO) │
│ OpcUaHeartbeatConfig.cs (POCO) │
│ OpcUaSecurityMode.cs (enum) │
│ Validators/ │
│ OpcUaEndpointConfigValidator.cs │
│ Serialization/ │
│ OpcUaEndpointConfigSerializer.cs │
└──────────────────────────────────────┘
▲
│ (referenced by both)
┌───────┴────────────────────────┐
▼ ▼
┌──────────────────────────┐ ┌────────────────────────────┐
│ ScadaLink.CentralUI │ │ ScadaLink.SiteRuntime │
│ Components/Forms/ │ │ Actors/ │
│ OpcUaEndpointEditor │ │ DeploymentManagerActor │
│ .razor (shared) │ │ (passes raw JSON to │
│ │ │ DataConnectionFactory)│
│ Pages/Admin/ │ │ │
│ DataConnectionForm │ │ DataConnections.OpcUa/ │
│ .razor │ │ OpcUaDataConnection.cs │
└──────────────────────────┘ │ (consumes typed model) │
└────────────────────────────┘
Both sides deserialize from DataConnection.PrimaryConfiguration / BackupConfiguration strings into the same OpcUaEndpointConfig instance. The DB column type does not change.
The model
// ScadaLink.Commons/Types/DataConnections/OpcUaEndpointConfig.cs
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; }
}
public sealed class OpcUaHeartbeatConfig
{
public string TagPath { get; set; } = "";
public int MaxSilenceSeconds { get; set; } = 30;
}
public enum OpcUaSecurityMode { None, Sign, SignAndEncrypt }
Defaults match the runtime's current fallbacks so a default-constructed config equals the empty/missing-JSON case. Settable properties (not init) so the form can @bind directly.
The validator
// ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs
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")
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);
ValidationEntry Err(string field, string msg) =>
new(Field: $"{fieldPrefix}{field}",
Message: msg,
Category: ValidationCategory.Schema);
}
}
Key points:
fieldPrefixparameter — form passes"Primary."/"Backup."so error messages disambiguate.LifetimeCount ≥ 3 × KeepAliveCountis an actual OPC UA spec constraint and exemplifies the "domain knowledge in the validator" win.- Static, pure, no DI — trivial to unit-test.
Serialization & legacy fallback
// ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs
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.TryGetProperty("endpointUrl", out _))
return (JsonSerializer.Deserialize<OpcUaEndpointConfig>(json, JsonOpts)!, false);
}
catch (JsonException) { /* fall through */ }
return (LoadLegacy(json), IsLegacy: true);
}
private static OpcUaEndpointConfig LoadLegacy(string json)
{
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json)
?? new();
var c = new OpcUaEndpointConfig
{
EndpointUrl = dict.GetValueOrDefault("endpoint")
?? dict.GetValueOrDefault("EndpointUrl") ?? "",
SecurityMode = Enum.TryParse<OpcUaSecurityMode>(
dict.GetValueOrDefault("SecurityMode"), 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)
};
var hbPath = dict.GetValueOrDefault("HeartbeatTagPath");
if (!string.IsNullOrWhiteSpace(hbPath))
c.Heartbeat = new OpcUaHeartbeatConfig
{
TagPath = hbPath,
MaxSilenceSeconds = ParseInt(dict, "HeartbeatMaxSilence", 30)
};
return c;
}
}
Deserialize returns (Config, IsLegacy). The form raises a Bootstrap warning banner when IsLegacy=true. On Save we always Serialize — the row gets rewritten to the new shape and the banner disappears on next edit.
The shared Blazor component
src/ScadaLink.CentralUI/Components/Forms/OpcUaEndpointEditor.razor
Parameters:
Config([EditorRequired]) — bound by reference; parent owns the instance.Title— header text (e.g. "Primary Endpoint").IdPrefix— disambiguatesfor=attributes when the component appears twice.IsLegacy— toggles the warning banner.Errors(ValidationResult?) — drives per-field red text viaEndsWith("." + field)match againstValidationEntry.Field.
Rendering: four section labels (Connection, Timing, Subscription, Heartbeat) with Bootstrap row g-2 grids. Heartbeat starts collapsed behind an "Enable Heartbeat" button; once shown it has a "Remove Heartbeat" button. Per-field error text appears immediately below each control.
DataConnectionForm changes
- Removed: Protocol
<select>, the JSON<textarea>for primary, the JSON<textarea>for backup. - Added: Two
<OpcUaEndpointEditor>instances. The backup one is still gated behind "Add Backup Endpoint" / "Remove Backup" buttons, andFailover Retry Countstays in the backup subsection. - Code-behind:
_primaryConfigand_backupConfig(OpcUaEndpointConfiginstances),_primaryIsLegacy/_backupIsLegacyflags,_primaryErrors/_backupErrors(ValidationResult?). Save runs the validator on both, bails out on failure, serializes viaOpcUaEndpointConfigSerializer.Serialize. - Protocol on the entity is set to the literal
"OpcUa"on create. The column stays so the runtime's protocol-dispatch (DataConnectionFactory) is untouched.
Runtime parser swap
src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:426-456 — today this code parses both JSON strings into Dictionary<string, string> and hands the dict to DataConnectionFactory.
After the change:
DeploymentManagerActorno longer parses JSON. It passes the rawPrimaryConfiguration/BackupConfigurationstrings straight to the factory.DataConnectionFactory.Create(OPC UA branch) callsOpcUaEndpointConfigSerializer.Deserialize(...), gets the typed model, and constructsOpcUaDataConnectionwith it.OpcUaDataConnection.cs:44-90is rewritten to takeOpcUaEndpointConfigdirectly. TheconnectionDetails.TryGetValue(...)ladder and theParseInt/ParseBoolhelpers go away. Heartbeat becomesif (cfg.Heartbeat is { } hb) { ... }.
Pre-refactor deployment artifacts still load: the serializer's legacy-dict fallback handles them. IsLegacy is discarded by the runtime (only the form cares).
Tests
| Project | New / changed tests |
|---|---|
ScadaLink.Commons.Tests |
OpcUaEndpointConfigSerializerTests: typed-JSON roundtrip preserves all fields; legacy flat-dict deserializes correctly and sets IsLegacy=true; empty/null JSON returns defaults; unknown JSON shape falls back cleanly. |
ScadaLink.Commons.Tests |
OpcUaEndpointConfigValidatorTests: missing URL → error; bad scheme → error; LifetimeCount < 3×KeepAliveCount → error; heartbeat-enabled-but-no-tag-path → error; valid config → IsValid=true; fieldPrefix applied to every error's Field. |
ScadaLink.CentralUI.Tests |
OpcUaEndpointEditorTests (bUnit): renders all grouped sections; binding mutates the passed Config; Enable/Remove Heartbeat toggles the sub-object; passing Errors renders per-field red text; IsLegacy=true shows the warning banner. |
ScadaLink.CentralUI.Tests |
DataConnectionsFormTests (bUnit, add if missing): Save with invalid primary URL → no navigation, validator error shown; Save with valid config → repo AddDataConnectionAsync called with Protocol="OpcUa" and JSON containing "endpointUrl" in camelCase. |
| Site/DCL test project | Update existing tests to construct OpcUaDataConnection from OpcUaEndpointConfig instead of IDictionary<string,string>. |
Out of scope
- No EF Core migration; the legacy-parse path handles pre-existing rows.
- No new protocols. Custom dropdown option is removed. If/when a second protocol lands, the form re-introduces a protocol dropdown and a
if (Protocol == "OpcUa")branch around the editor component. - No live (debounced) validation.
- No certificate management UI beyond
AutoAcceptUntrustedCerts. - No "Verify endpoint" button.
- No rewrite of
docs/requirements/Component-DataConnectionLayer.md— a short note pointing atOpcUaEndpointConfigas the canonical schema is enough.
Verification
dotnet buildclean.dotnet testfor Commons + CentralUI + SiteRuntime/DCL — all green, including new tests.bash docker/deploy.sh— rebuild cluster.- Browser smoke at
http://localhost:9000/admin/connections:- New connection via site context menu → form shows the OPC UA endpoint editor; no Protocol dropdown.
- Bad URL → Save → red error under Endpoint URL.
- Valid config, toggle heartbeat, set timing knobs → Save → row created; reload → fields round-trip.
- Edit a pre-refactor row → warning banner appears, fields populated from legacy dict; Save rewrites; second edit no banner.
- Add backup endpoint, save, deploy a template that uses the connection → site logs show primary online and failover settings honored.