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
This commit is contained in:
293
docs/plans/2026-05-12-opcua-config-model-design.md
Normal file
293
docs/plans/2026-05-12-opcua-config-model-design.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# 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
|
||||
|
||||
```csharp
|
||||
// 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
|
||||
|
||||
```csharp
|
||||
// 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
|
||||
|
||||
```csharp
|
||||
// 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.
|
||||
Reference in New Issue
Block a user