Files
scadalink-design/docs/plans/2026-05-12-opcua-config-model-design.md
Joseph Doherty c906e73441 docs(plans): OPC UA endpoint config model & form refactor design
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
2026-05-12 00:27:35 -04:00

16 KiB
Raw Blame History

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 flat IDictionary<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:

  • fieldPrefix parameter — form passes "Primary." / "Backup." so error messages disambiguate.
  • LifetimeCount ≥ 3 × KeepAliveCount is 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 — disambiguates for= attributes when the component appears twice.
  • IsLegacy — toggles the warning banner.
  • Errors (ValidationResult?) — drives per-field red text via EndsWith("." + field) match against ValidationEntry.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, and Failover Retry Count stays in the backup subsection.
  • Code-behind: _primaryConfig and _backupConfig (OpcUaEndpointConfig instances), _primaryIsLegacy/_backupIsLegacy flags, _primaryErrors/_backupErrors (ValidationResult?). Save runs the validator on both, bails out on failure, serializes via OpcUaEndpointConfigSerializer.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:

  • DeploymentManagerActor no longer parses JSON. It passes the raw PrimaryConfiguration / BackupConfiguration strings straight to the factory.
  • DataConnectionFactory.Create (OPC UA branch) calls OpcUaEndpointConfigSerializer.Deserialize(...), gets the typed model, and constructs OpcUaDataConnection with it.
  • OpcUaDataConnection.cs:44-90 is rewritten to take OpcUaEndpointConfig directly. The connectionDetails.TryGetValue(...) ladder and the ParseInt / ParseBool helpers go away. Heartbeat becomes if (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 at OpcUaEndpointConfig as the canonical schema is enough.

Verification

  1. dotnet build clean.
  2. dotnet test for Commons + CentralUI + SiteRuntime/DCL — all green, including new tests.
  3. bash docker/deploy.sh — rebuild cluster.
  4. 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.