17 Commits

Author SHA1 Message Date
Joseph Doherty cfb90d2078 fix(ui/admin): always clear _loading in DataConnectionForm.OnInitializedAsync 2026-05-12 01:14:18 -04:00
Joseph Doherty 9916aeaa47 refactor(ui/admin): DataConnectionForm uses OpcUaEndpointEditor and typed model 2026-05-12 01:11:49 -04:00
Joseph Doherty 505731fcef test(ui): drive DataConnectionForm tests via NavigationManager for SupplyParameterFromQuery 2026-05-12 01:09:31 -04:00
Joseph Doherty 46260f30ee test(ui): failing tests for DataConnectionForm refactor 2026-05-12 01:07:55 -04:00
Joseph Doherty 1c71d3342a feat(ui): OpcUaEndpointEditor Blazor component 2026-05-12 01:05:32 -04:00
Joseph Doherty 304ebec121 test(ui): failing bUnit tests for OpcUaEndpointEditor 2026-05-12 01:02:41 -04:00
Joseph Doherty 496d2a68e3 refactor(site-runtime): route OPC UA connection JSON through serializer 2026-05-12 00:59:25 -04:00
Joseph Doherty f98d29fc36 refactor(dcl): OpcUaDataConnection uses OpcUaEndpointConfig via FromFlatDict 2026-05-12 00:57:09 -04:00
Joseph Doherty 80d4d3e252 feat(commons): OpcUaEndpointConfigValidator 2026-05-12 00:52:55 -04:00
Joseph Doherty b53221e44a test(commons): failing tests for OpcUaEndpointConfigValidator 2026-05-12 00:50:28 -04:00
Joseph Doherty 4608adcd53 refactor(commons): defensive legacy-parse + FromFlatDict starts from POCO defaults 2026-05-12 00:48:17 -04:00
Joseph Doherty 8fbf167389 feat(commons): OpcUaEndpointConfigSerializer with legacy fallback + flat-dict interop 2026-05-12 00:44:21 -04:00
Joseph Doherty 90b252047e test(commons): decouple serializer tests from JSON whitespace and verify defaults symmetrically 2026-05-12 00:41:55 -04:00
Joseph Doherty 2220bfcf58 test(commons): failing tests for OpcUaEndpointConfigSerializer 2026-05-12 00:38:56 -04:00
Joseph Doherty b16606d97e feat(commons): OpcUaEndpointConfig POCOs + ConnectionConfig ValidationCategory 2026-05-12 00:35:27 -04:00
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
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
17 changed files with 2969 additions and 134 deletions
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
{
"planPath": "docs/plans/2026-05-12-opcua-config-model.md",
"tasks": [
{"id": 45, "subject": "Task 1: Create OPC UA config POCOs + ValidationCategory.ConnectionConfig", "status": "pending"},
{"id": 46, "subject": "Task 2: TDD failing tests for OpcUaEndpointConfigSerializer", "status": "pending", "blockedBy": [45]},
{"id": 47, "subject": "Task 3: Implement OpcUaEndpointConfigSerializer", "status": "pending", "blockedBy": [46]},
{"id": 48, "subject": "Task 4: TDD failing tests for OpcUaEndpointConfigValidator", "status": "pending", "blockedBy": [45]},
{"id": 49, "subject": "Task 5: Implement OpcUaEndpointConfigValidator", "status": "pending", "blockedBy": [48]},
{"id": 50, "subject": "Task 6: Refactor OpcUaDataConnection.ConnectAsync to use FromFlatDict", "status": "pending", "blockedBy": [47]},
{"id": 51, "subject": "Task 7: Refactor DeploymentManagerActor.EnsureDclConnections", "status": "pending", "blockedBy": [47]},
{"id": 52, "subject": "Task 8: TDD failing bUnit tests for OpcUaEndpointEditor", "status": "pending", "blockedBy": [45, 49]},
{"id": 53, "subject": "Task 9: Implement OpcUaEndpointEditor.razor", "status": "pending", "blockedBy": [52]},
{"id": 54, "subject": "Task 10: TDD failing bUnit tests for DataConnectionForm refactor", "status": "pending", "blockedBy": [47, 49]},
{"id": 55, "subject": "Task 11: Refactor DataConnectionForm.razor", "status": "pending", "blockedBy": [53, 54]},
{"id": 56, "subject": "Task 12: Solution build + all test suites green", "status": "pending", "blockedBy": [50, 51, 55]},
{"id": 57, "subject": "Task 13: Docker deploy + browser smoke", "status": "pending", "blockedBy": [56]},
{"id": 58, "subject": "Task 14: Push to origin", "status": "pending", "blockedBy": [57]}
],
"lastUpdated": "2026-05-12T04:33:33Z"
}
@@ -0,0 +1,150 @@
@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>;
}
}
@@ -5,6 +5,11 @@
@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
@@ -27,7 +32,9 @@
<label class="form-label small">Site</label>
@if (_siteLocked)
{
<input type="text" class="form-control form-control-sm" value="@_siteName" disabled />
<select class="form-select form-select-sm" disabled>
<option>@_siteName</option>
</select>
}
else
{
@@ -40,56 +47,41 @@
</select>
}
</div>
<div class="mb-2">
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="mb-3">
<label class="form-label small">Protocol</label>
<select class="form-select form-select-sm" @bind="_formProtocol">
<option value="">Select...</option>
<option value="OpcUa">OPC UA</option>
<option value="Custom">Custom</option>
</select>
</div>
<h6 class="text-muted border-bottom pb-1">Primary Endpoint</h6>
<div class="mb-3">
<label class="form-label small">Configuration</label>
<input type="text" class="form-control form-control-sm" @bind="_formConfiguration"
placeholder='e.g. {"endpoint":"opc.tcp://..."}' />
</div>
<OpcUaEndpointEditor Title="Primary Endpoint"
IdPrefix="primary"
Config="_primaryConfig"
IsLegacy="_primaryIsLegacy"
Errors="_primaryErrors" />
<h6 class="text-muted border-bottom pb-1">Backup Endpoint</h6>
<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="() => _showBackup = true">
Add Backup Endpoint
</button>
@onclick="EnableBackup">Add Backup Endpoint</button>
</div>
}
else
{
<div class="mb-2">
<label class="form-label small">Configuration</label>
<textarea class="form-control form-control-sm" rows="4"
@bind="_formBackupConfiguration"
placeholder='{"Host": "backup-host", "Port": 50101}' />
</div>
<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" />
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>
@onclick="RemoveBackup">Remove Backup</button>
</div>
}
@@ -117,20 +109,23 @@
private string _siteName = string.Empty;
private bool _siteLocked;
private string _formName = string.Empty;
private string _formProtocol = string.Empty;
private string? _formConfiguration;
private OpcUaEndpointConfig _primaryConfig = new();
private OpcUaEndpointConfig _backupConfig = new();
private bool _primaryIsLegacy;
private bool _backupIsLegacy;
private bool _showBackup;
private string? _formBackupConfiguration;
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
{
try
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
if (Id.HasValue)
{
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
if (_editingConnection != null)
@@ -139,29 +134,38 @@
_siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
_siteLocked = true;
_formName = _editingConnection.Name;
_formProtocol = _editingConnection.Protocol;
_formConfiguration = _editingConnection.PrimaryConfiguration;
_formBackupConfiguration = _editingConnection.BackupConfiguration;
_formFailoverRetryCount = _editingConnection.FailoverRetryCount;
_showBackup = _editingConnection.BackupConfiguration != null;
(_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)
else if (SiteId.HasValue)
{
_formError = $"Failed to load connection: {ex.Message}";
var site = _sites.FirstOrDefault(s => s.Id == SiteId.Value);
if (site != null)
{
_formSiteId = site.Id;
_siteName = site.Name;
_siteLocked = true;
}
}
}
else if (SiteId.HasValue)
catch (Exception ex)
{
var site = _sites.FirstOrDefault(s => s.Id == SiteId.Value);
if (site != null)
{
_formSiteId = site.Id;
_siteName = site.Name;
_siteLocked = true;
}
_formError = $"Failed to load: {ex.Message}";
}
finally
{
_loading = false;
}
_loading = false;
}
private async Task SaveConnection()
@@ -169,25 +173,38 @@
_formError = null;
if (_formSiteId == 0) { _formError = "Site is required."; return; }
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol 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 = _formProtocol;
_editingConnection.PrimaryConfiguration = _formConfiguration?.Trim();
_editingConnection.BackupConfiguration = _showBackup ? _formBackupConfiguration?.Trim() : null;
_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(), _formProtocol, _formSiteId)
var conn = new DataConnection(_formName.Trim(), "OpcUa", _formSiteId)
{
PrimaryConfiguration = _formConfiguration?.Trim(),
BackupConfiguration = _showBackup ? _formBackupConfiguration?.Trim() : null,
PrimaryConfiguration = primaryJson,
BackupConfiguration = backupJson,
FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3
};
await SiteRepository.AddDataConnectionAsync(conn);
@@ -201,15 +218,15 @@
}
}
private void EnableBackup() => _showBackup = true;
private void RemoveBackup()
{
_showBackup = false;
_formBackupConfiguration = null;
_backupConfig = new OpcUaEndpointConfig();
_backupIsLegacy = false;
_formFailoverRetryCount = 3;
}
private void GoBack()
{
NavigationManager.NavigateTo("/admin/connections");
}
private void GoBack() => NavigationManager.NavigateTo("/admin/connections");
}
@@ -0,0 +1,131 @@
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 */ }
try
{
return (LoadLegacy(json!), IsLegacy: true);
}
catch (JsonException)
{
return (new OpcUaEndpointConfig(), 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();
if (dict.TryGetValue("endpoint", out var ep) && !string.IsNullOrWhiteSpace(ep))
c.EndpointUrl = ep;
else if (dict.TryGetValue("EndpointUrl", out var ep2) && !string.IsNullOrWhiteSpace(ep2))
c.EndpointUrl = ep2;
if (dict.TryGetValue("SecurityMode", out var smStr)
&& Enum.TryParse<OpcUaSecurityMode>(smStr, ignoreCase: true, out var sm))
c.SecurityMode = sm;
if (dict.TryGetValue("AutoAcceptUntrustedCerts", out var aacStr)
&& bool.TryParse(aacStr, out var aac))
c.AutoAcceptUntrustedCerts = aac;
TryAssignInt(dict, "SessionTimeoutMs", v => c.SessionTimeoutMs = v);
TryAssignInt(dict, "OperationTimeoutMs", v => c.OperationTimeoutMs = v);
TryAssignInt(dict, "PublishingIntervalMs", v => c.PublishingIntervalMs = v);
TryAssignInt(dict, "SamplingIntervalMs", v => c.SamplingIntervalMs = v);
TryAssignInt(dict, "QueueSize", v => c.QueueSize = v);
TryAssignInt(dict, "KeepAliveCount", v => c.KeepAliveCount = v);
TryAssignInt(dict, "LifetimeCount", v => c.LifetimeCount = v);
TryAssignInt(dict, "MaxNotificationsPerPublish", v => c.MaxNotificationsPerPublish = v);
if (dict.TryGetValue("HeartbeatTagPath", out var hbPath)
&& !string.IsNullOrWhiteSpace(hbPath))
{
var hb = new OpcUaHeartbeatConfig { TagPath = hbPath };
TryAssignInt(dict, "HeartbeatMaxSilence", v => hb.MaxSilenceSeconds = v);
c.Heartbeat = hb;
}
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 void TryAssignInt(IDictionary<string, string> dict, string key, Action<int> assign)
{
if (dict.TryGetValue(key, out var s) && int.TryParse(s, out var v))
assign(v);
}
}
@@ -0,0 +1,24 @@
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; }
}
@@ -0,0 +1,7 @@
namespace ScadaLink.Commons.Types.DataConnections;
public sealed class OpcUaHeartbeatConfig
{
public string TagPath { get; set; } = "";
public int MaxSilenceSeconds { get; set; } = 30;
}
@@ -0,0 +1,8 @@
namespace ScadaLink.Commons.Types.DataConnections;
public enum OpcUaSecurityMode
{
None,
Sign,
SignAndEncrypt
}
@@ -61,5 +61,6 @@ public enum ValidationCategory
TriggerOperandType,
OnTriggerScriptNotFound,
CrossCallViolation,
MissingMetadata
MissingMetadata,
ConnectionConfig
}
@@ -0,0 +1,62 @@
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}");
}
}
@@ -1,6 +1,8 @@
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces.Protocol;
using ScadaLink.Commons.Serialization;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.DataConnections;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.DataConnectionLayer.Adapters;
@@ -43,23 +45,23 @@ public class OpcUaDataConnection : IDataConnection
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
{
_endpointUrl = connectionDetails.TryGetValue("endpoint", out var url)
? url
: connectionDetails.TryGetValue("EndpointUrl", out var url2)
? url2
: "opc.tcp://localhost:4840";
var config = OpcUaEndpointConfigSerializer.FromFlatDict(connectionDetails);
_endpointUrl = string.IsNullOrWhiteSpace(config.EndpointUrl)
? "opc.tcp://localhost:4840"
: config.EndpointUrl;
var options = new OpcUaConnectionOptions(
SessionTimeoutMs: ParseInt(connectionDetails, "SessionTimeoutMs", 60000),
OperationTimeoutMs: ParseInt(connectionDetails, "OperationTimeoutMs", 15000),
PublishingIntervalMs: ParseInt(connectionDetails, "PublishingIntervalMs", 1000),
KeepAliveCount: ParseInt(connectionDetails, "KeepAliveCount", 10),
LifetimeCount: ParseInt(connectionDetails, "LifetimeCount", 30),
MaxNotificationsPerPublish: ParseInt(connectionDetails, "MaxNotificationsPerPublish", 100),
SamplingIntervalMs: ParseInt(connectionDetails, "SamplingIntervalMs", 1000),
QueueSize: ParseInt(connectionDetails, "QueueSize", 10),
SecurityMode: connectionDetails.TryGetValue("SecurityMode", out var secMode) ? secMode : "None",
AutoAcceptUntrustedCerts: ParseBool(connectionDetails, "AutoAcceptUntrustedCerts", true));
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;
@@ -71,49 +73,40 @@ public class OpcUaDataConnection : IDataConnection
_disconnectFired = false;
_logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl);
// Heartbeat stale tag monitoring (optional)
await StartHeartbeatMonitorAsync(connectionDetails, cancellationToken);
await StartHeartbeatMonitorAsync(config.Heartbeat, cancellationToken);
}
private async Task StartHeartbeatMonitorAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken)
private async Task StartHeartbeatMonitorAsync(OpcUaHeartbeatConfig? heartbeat, CancellationToken cancellationToken)
{
if (!connectionDetails.TryGetValue("HeartbeatTagPath", out var heartbeatTag) || string.IsNullOrWhiteSpace(heartbeatTag))
if (heartbeat is null || string.IsNullOrWhiteSpace(heartbeat.TagPath))
return;
var maxSilenceSeconds = ParseInt(connectionDetails, "HeartbeatMaxSilence", 30);
_staleMonitor?.Dispose();
_staleMonitor = new StaleTagMonitor(TimeSpan.FromSeconds(maxSilenceSeconds));
_staleMonitor = new StaleTagMonitor(TimeSpan.FromSeconds(heartbeat.MaxSilenceSeconds));
_staleMonitor.Stale += () =>
{
_logger.LogWarning("OPC UA heartbeat tag '{Tag}' stale — no update in {Seconds}s", heartbeatTag, maxSilenceSeconds);
_logger.LogWarning("OPC UA heartbeat tag '{Tag}' stale — no update in {Seconds}s",
heartbeat.TagPath, heartbeat.MaxSilenceSeconds);
RaiseDisconnected();
};
try
{
_heartbeatSubscriptionId = await SubscribeAsync(heartbeatTag, (_, _) => _staleMonitor.OnValueReceived(), cancellationToken);
_heartbeatSubscriptionId = await SubscribeAsync(heartbeat.TagPath,
(_, _) => _staleMonitor.OnValueReceived(), cancellationToken);
_staleMonitor.Start();
_logger.LogInformation("OPC UA heartbeat monitor started for '{Tag}' with {Seconds}s max silence", heartbeatTag, maxSilenceSeconds);
_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", heartbeatTag);
_logger.LogWarning(ex, "Failed to subscribe to heartbeat tag '{Tag}' — stale monitor not active",
heartbeat.TagPath);
_staleMonitor.Dispose();
_staleMonitor = null;
}
}
internal static int ParseInt(IDictionary<string, string> d, string key, int defaultValue)
{
return d.TryGetValue(key, out var str) && int.TryParse(str, out var val) ? val : defaultValue;
}
internal static bool ParseBool(IDictionary<string, string> d, string key, bool defaultValue)
{
return d.TryGetValue(key, out var str) && bool.TryParse(str, out var val) ? val : defaultValue;
}
private void OnClientConnectionLost()
{
RaiseDisconnected();
@@ -405,8 +405,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
private readonly HashSet<string> _createdConnections = new();
/// <summary>
/// Parses the flattened config to find bound data connections and ensures
/// the DCL Manager has corresponding connection actors created.
/// Sets up DCL connections from the flattened config (idempotent: tracks created connections).
/// </summary>
private void EnsureDclConnections(string configJson)
{
@@ -422,35 +421,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
if (_createdConnections.Contains(name))
continue;
var primaryDetails = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(connConfig.ConfigurationJson))
{
try
{
// Parse as JsonElement to handle mixed value types (string, int, bool)
using var doc = System.Text.Json.JsonDocument.Parse(connConfig.ConfigurationJson);
foreach (var prop in doc.RootElement.EnumerateObject())
{
primaryDetails[prop.Name] = prop.Value.ToString();
}
}
catch { /* Ignore parse errors */ }
}
Dictionary<string, string>? backupDetails = null;
if (!string.IsNullOrEmpty(connConfig.BackupConfigurationJson))
{
try
{
backupDetails = new Dictionary<string, string>();
using var doc = System.Text.Json.JsonDocument.Parse(connConfig.BackupConfigurationJson);
foreach (var prop in doc.RootElement.EnumerateObject())
{
backupDetails[prop.Name] = prop.Value.ToString();
}
}
catch { backupDetails = null; /* Ignore parse errors */ }
}
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));
@@ -467,6 +441,32 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
}
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>();
}
}
// ── Shared Script Loading ──
private void LoadSharedScriptsFromStorage()
@@ -0,0 +1,108 @@
using System.Security.Claims;
using System.Text.Json;
using Bunit;
using Microsoft.AspNetCore.Components;
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();
}
private IRenderedComponent<DataConnectionForm> RenderForCreateSite(int siteId)
{
Services.GetRequiredService<NavigationManager>()
.NavigateTo($"/admin/connections/create?siteId={siteId}");
return Render<DataConnectionForm>();
}
[Fact]
public void NoProtocolDropdown_IsRendered()
{
var cut = RenderForCreateSite(1);
Assert.DoesNotContain("Custom", cut.Markup);
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 = RenderForCreateSite(1);
cut.FindAll("input[type='text']")
.First(i => i.GetAttribute("placeholder")?.StartsWith("opc.tcp") == true)
.Change("not-a-url");
// Name field (the first text input that is NOT the OPC URL)
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 = RenderForCreateSite(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());
}
}
@@ -0,0 +1,85 @@
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);
}
}
@@ -0,0 +1,179 @@
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);
using var doc = System.Text.Json.JsonDocument.Parse(json);
Assert.Equal("signAndEncrypt", doc.RootElement.GetProperty("securityMode").GetString());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Deserialize_NullOrEmpty_ReturnsDefaults(string? input)
{
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(input);
Assert.False(isLegacy);
Assert.Equal("", config.EndpointUrl);
Assert.Equal(60000, config.SessionTimeoutMs);
Assert.Null(config.Heartbeat);
}
[Fact]
public void Deserialize_LegacyFlatDict_IsLegacyTrue_PopulatesFields()
{
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()
{
var legacyJson = """{"EndpointUrl":"opc.tcp://x:4840"}""";
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);
Assert.True(isLegacy);
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
}
[Theory]
[InlineData("not json at all")]
[InlineData("[1,2,3]")]
[InlineData("{\"foo\":123}")]
[InlineData("\"just a string\"")]
public void Deserialize_Malformed_ReturnsDefaultsAsLegacy(string input)
{
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(input);
Assert.True(isLegacy);
Assert.Equal("", config.EndpointUrl);
Assert.Equal(60000, config.SessionTimeoutMs);
Assert.Null(config.Heartbeat);
}
[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);
}
}
@@ -0,0 +1,112 @@
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");
}
}