Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfb90d2078 | |||
| 9916aeaa47 | |||
| 505731fcef | |||
| 46260f30ee | |||
| 1c71d3342a | |||
| 304ebec121 | |||
| 496d2a68e3 | |||
| f98d29fc36 | |||
| 80d4d3e252 | |||
| b53221e44a | |||
| 4608adcd53 | |||
| 8fbf167389 | |||
| 90b252047e | |||
| 2220bfcf58 | |||
| b16606d97e | |||
| a9c4c2c655 | |||
| c906e73441 |
@@ -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 & 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.Security
|
||||||
@using ScadaLink.Commons.Entities.Sites
|
@using ScadaLink.Commons.Entities.Sites
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@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)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@@ -27,7 +32,9 @@
|
|||||||
<label class="form-label small">Site</label>
|
<label class="form-label small">Site</label>
|
||||||
@if (_siteLocked)
|
@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
|
else
|
||||||
{
|
{
|
||||||
@@ -40,56 +47,41 @@
|
|||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-3">
|
||||||
<label class="form-label small">Name</label>
|
<label class="form-label small">Name</label>
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||||
</div>
|
</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>
|
<OpcUaEndpointEditor Title="Primary Endpoint"
|
||||||
<div class="mb-3">
|
IdPrefix="primary"
|
||||||
<label class="form-label small">Configuration</label>
|
Config="_primaryConfig"
|
||||||
<input type="text" class="form-control form-control-sm" @bind="_formConfiguration"
|
IsLegacy="_primaryIsLegacy"
|
||||||
placeholder='e.g. {"endpoint":"opc.tcp://..."}' />
|
Errors="_primaryErrors" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<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)
|
@if (!_showBackup)
|
||||||
{
|
{
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
@onclick="() => _showBackup = true">
|
@onclick="EnableBackup">Add Backup Endpoint</button>
|
||||||
Add Backup Endpoint
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="mb-2">
|
<OpcUaEndpointEditor Title="Backup Endpoint"
|
||||||
<label class="form-label small">Configuration</label>
|
IdPrefix="backup"
|
||||||
<textarea class="form-control form-control-sm" rows="4"
|
Config="_backupConfig"
|
||||||
@bind="_formBackupConfiguration"
|
IsLegacy="_backupIsLegacy"
|
||||||
placeholder='{"Host": "backup-host", "Port": 50101}' />
|
Errors="_backupErrors" />
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label small">Failover Retry Count</label>
|
<label class="form-label small">Failover Retry Count</label>
|
||||||
<input type="number" class="form-control form-control-sm" style="max-width: 120px;"
|
<input type="number" class="form-control form-control-sm" style="max-width: 120px;"
|
||||||
min="1" max="20"
|
min="1" max="20" @bind="_formFailoverRetryCount" />
|
||||||
@bind="_formFailoverRetryCount" />
|
|
||||||
<div class="form-text">Retries on active endpoint before switching to backup (default: 3)</div>
|
<div class="form-text">Retries on active endpoint before switching to backup (default: 3)</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||||
@onclick="RemoveBackup">
|
@onclick="RemoveBackup">Remove Backup</button>
|
||||||
Remove Backup
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,20 +109,23 @@
|
|||||||
private string _siteName = string.Empty;
|
private string _siteName = string.Empty;
|
||||||
private bool _siteLocked;
|
private bool _siteLocked;
|
||||||
private string _formName = string.Empty;
|
private string _formName = string.Empty;
|
||||||
private string _formProtocol = string.Empty;
|
private OpcUaEndpointConfig _primaryConfig = new();
|
||||||
private string? _formConfiguration;
|
private OpcUaEndpointConfig _backupConfig = new();
|
||||||
|
private bool _primaryIsLegacy;
|
||||||
|
private bool _backupIsLegacy;
|
||||||
private bool _showBackup;
|
private bool _showBackup;
|
||||||
private string? _formBackupConfiguration;
|
|
||||||
private int _formFailoverRetryCount = 3;
|
private int _formFailoverRetryCount = 3;
|
||||||
|
private ValidationResult? _primaryErrors;
|
||||||
|
private ValidationResult? _backupErrors;
|
||||||
private string? _formError;
|
private string? _formError;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
try
|
||||||
|
|
||||||
if (Id.HasValue)
|
|
||||||
{
|
{
|
||||||
try
|
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||||
|
|
||||||
|
if (Id.HasValue)
|
||||||
{
|
{
|
||||||
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
|
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
|
||||||
if (_editingConnection != null)
|
if (_editingConnection != null)
|
||||||
@@ -139,29 +134,38 @@
|
|||||||
_siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
|
_siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
|
||||||
_siteLocked = true;
|
_siteLocked = true;
|
||||||
_formName = _editingConnection.Name;
|
_formName = _editingConnection.Name;
|
||||||
_formProtocol = _editingConnection.Protocol;
|
|
||||||
_formConfiguration = _editingConnection.PrimaryConfiguration;
|
(_primaryConfig, _primaryIsLegacy) =
|
||||||
_formBackupConfiguration = _editingConnection.BackupConfiguration;
|
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.PrimaryConfiguration);
|
||||||
_formFailoverRetryCount = _editingConnection.FailoverRetryCount;
|
|
||||||
_showBackup = _editingConnection.BackupConfiguration != null;
|
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);
|
_formError = $"Failed to load: {ex.Message}";
|
||||||
if (site != null)
|
}
|
||||||
{
|
finally
|
||||||
_formSiteId = site.Id;
|
{
|
||||||
_siteName = site.Name;
|
_loading = false;
|
||||||
_siteLocked = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_loading = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveConnection()
|
private async Task SaveConnection()
|
||||||
@@ -169,25 +173,38 @@
|
|||||||
_formError = null;
|
_formError = null;
|
||||||
if (_formSiteId == 0) { _formError = "Site is required."; return; }
|
if (_formSiteId == 0) { _formError = "Site is required."; return; }
|
||||||
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name 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
|
try
|
||||||
{
|
{
|
||||||
if (_editingConnection != null)
|
if (_editingConnection != null)
|
||||||
{
|
{
|
||||||
_editingConnection.Name = _formName.Trim();
|
_editingConnection.Name = _formName.Trim();
|
||||||
_editingConnection.Protocol = _formProtocol;
|
_editingConnection.Protocol = "OpcUa";
|
||||||
_editingConnection.PrimaryConfiguration = _formConfiguration?.Trim();
|
_editingConnection.PrimaryConfiguration = primaryJson;
|
||||||
_editingConnection.BackupConfiguration = _showBackup ? _formBackupConfiguration?.Trim() : null;
|
_editingConnection.BackupConfiguration = backupJson;
|
||||||
_editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
|
_editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
|
||||||
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
|
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var conn = new DataConnection(_formName.Trim(), _formProtocol, _formSiteId)
|
var conn = new DataConnection(_formName.Trim(), "OpcUa", _formSiteId)
|
||||||
{
|
{
|
||||||
PrimaryConfiguration = _formConfiguration?.Trim(),
|
PrimaryConfiguration = primaryJson,
|
||||||
BackupConfiguration = _showBackup ? _formBackupConfiguration?.Trim() : null,
|
BackupConfiguration = backupJson,
|
||||||
FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3
|
FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3
|
||||||
};
|
};
|
||||||
await SiteRepository.AddDataConnectionAsync(conn);
|
await SiteRepository.AddDataConnectionAsync(conn);
|
||||||
@@ -201,15 +218,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void EnableBackup() => _showBackup = true;
|
||||||
|
|
||||||
private void RemoveBackup()
|
private void RemoveBackup()
|
||||||
{
|
{
|
||||||
_showBackup = false;
|
_showBackup = false;
|
||||||
_formBackupConfiguration = null;
|
_backupConfig = new OpcUaEndpointConfig();
|
||||||
|
_backupIsLegacy = false;
|
||||||
_formFailoverRetryCount = 3;
|
_formFailoverRetryCount = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void GoBack()
|
private void GoBack() => NavigationManager.NavigateTo("/admin/connections");
|
||||||
{
|
|
||||||
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<string,string> 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,
|
TriggerOperandType,
|
||||||
OnTriggerScriptNotFound,
|
OnTriggerScriptNotFound,
|
||||||
CrossCallViolation,
|
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 Microsoft.Extensions.Logging;
|
||||||
using ScadaLink.Commons.Interfaces.Protocol;
|
using ScadaLink.Commons.Interfaces.Protocol;
|
||||||
|
using ScadaLink.Commons.Serialization;
|
||||||
using ScadaLink.Commons.Types;
|
using ScadaLink.Commons.Types;
|
||||||
|
using ScadaLink.Commons.Types.DataConnections;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ScadaLink.DataConnectionLayer.Adapters;
|
namespace ScadaLink.DataConnectionLayer.Adapters;
|
||||||
@@ -43,23 +45,23 @@ public class OpcUaDataConnection : IDataConnection
|
|||||||
|
|
||||||
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
|
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_endpointUrl = connectionDetails.TryGetValue("endpoint", out var url)
|
var config = OpcUaEndpointConfigSerializer.FromFlatDict(connectionDetails);
|
||||||
? url
|
|
||||||
: connectionDetails.TryGetValue("EndpointUrl", out var url2)
|
_endpointUrl = string.IsNullOrWhiteSpace(config.EndpointUrl)
|
||||||
? url2
|
? "opc.tcp://localhost:4840"
|
||||||
: "opc.tcp://localhost:4840";
|
: config.EndpointUrl;
|
||||||
|
|
||||||
var options = new OpcUaConnectionOptions(
|
var options = new OpcUaConnectionOptions(
|
||||||
SessionTimeoutMs: ParseInt(connectionDetails, "SessionTimeoutMs", 60000),
|
SessionTimeoutMs: config.SessionTimeoutMs,
|
||||||
OperationTimeoutMs: ParseInt(connectionDetails, "OperationTimeoutMs", 15000),
|
OperationTimeoutMs: config.OperationTimeoutMs,
|
||||||
PublishingIntervalMs: ParseInt(connectionDetails, "PublishingIntervalMs", 1000),
|
PublishingIntervalMs: config.PublishingIntervalMs,
|
||||||
KeepAliveCount: ParseInt(connectionDetails, "KeepAliveCount", 10),
|
KeepAliveCount: config.KeepAliveCount,
|
||||||
LifetimeCount: ParseInt(connectionDetails, "LifetimeCount", 30),
|
LifetimeCount: config.LifetimeCount,
|
||||||
MaxNotificationsPerPublish: ParseInt(connectionDetails, "MaxNotificationsPerPublish", 100),
|
MaxNotificationsPerPublish: config.MaxNotificationsPerPublish,
|
||||||
SamplingIntervalMs: ParseInt(connectionDetails, "SamplingIntervalMs", 1000),
|
SamplingIntervalMs: config.SamplingIntervalMs,
|
||||||
QueueSize: ParseInt(connectionDetails, "QueueSize", 10),
|
QueueSize: config.QueueSize,
|
||||||
SecurityMode: connectionDetails.TryGetValue("SecurityMode", out var secMode) ? secMode : "None",
|
SecurityMode: config.SecurityMode.ToString(),
|
||||||
AutoAcceptUntrustedCerts: ParseBool(connectionDetails, "AutoAcceptUntrustedCerts", true));
|
AutoAcceptUntrustedCerts: config.AutoAcceptUntrustedCerts);
|
||||||
|
|
||||||
_status = ConnectionHealth.Connecting;
|
_status = ConnectionHealth.Connecting;
|
||||||
|
|
||||||
@@ -71,49 +73,40 @@ public class OpcUaDataConnection : IDataConnection
|
|||||||
_disconnectFired = false;
|
_disconnectFired = false;
|
||||||
_logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl);
|
_logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl);
|
||||||
|
|
||||||
// Heartbeat stale tag monitoring (optional)
|
await StartHeartbeatMonitorAsync(config.Heartbeat, cancellationToken);
|
||||||
await StartHeartbeatMonitorAsync(connectionDetails, 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;
|
return;
|
||||||
|
|
||||||
var maxSilenceSeconds = ParseInt(connectionDetails, "HeartbeatMaxSilence", 30);
|
|
||||||
|
|
||||||
_staleMonitor?.Dispose();
|
_staleMonitor?.Dispose();
|
||||||
_staleMonitor = new StaleTagMonitor(TimeSpan.FromSeconds(maxSilenceSeconds));
|
_staleMonitor = new StaleTagMonitor(TimeSpan.FromSeconds(heartbeat.MaxSilenceSeconds));
|
||||||
_staleMonitor.Stale += () =>
|
_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();
|
RaiseDisconnected();
|
||||||
};
|
};
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_heartbeatSubscriptionId = await SubscribeAsync(heartbeatTag, (_, _) => _staleMonitor.OnValueReceived(), cancellationToken);
|
_heartbeatSubscriptionId = await SubscribeAsync(heartbeat.TagPath,
|
||||||
|
(_, _) => _staleMonitor.OnValueReceived(), cancellationToken);
|
||||||
_staleMonitor.Start();
|
_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)
|
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.Dispose();
|
||||||
_staleMonitor = null;
|
_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()
|
private void OnClientConnectionLost()
|
||||||
{
|
{
|
||||||
RaiseDisconnected();
|
RaiseDisconnected();
|
||||||
|
|||||||
@@ -405,8 +405,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
private readonly HashSet<string> _createdConnections = new();
|
private readonly HashSet<string> _createdConnections = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses the flattened config to find bound data connections and ensures
|
/// Sets up DCL connections from the flattened config (idempotent: tracks created connections).
|
||||||
/// the DCL Manager has corresponding connection actors created.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void EnsureDclConnections(string configJson)
|
private void EnsureDclConnections(string configJson)
|
||||||
{
|
{
|
||||||
@@ -422,35 +421,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
if (_createdConnections.Contains(name))
|
if (_createdConnections.Contains(name))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var primaryDetails = new Dictionary<string, string>();
|
var primaryDetails = FlattenConnectionConfig(connConfig.Protocol, connConfig.ConfigurationJson);
|
||||||
if (!string.IsNullOrEmpty(connConfig.ConfigurationJson))
|
var backupDetails = string.IsNullOrEmpty(connConfig.BackupConfigurationJson)
|
||||||
{
|
? null
|
||||||
try
|
: FlattenConnectionConfig(connConfig.Protocol, connConfig.BackupConfigurationJson);
|
||||||
{
|
|
||||||
// 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 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
_dclManager.Tell(new Commons.Messages.DataConnection.CreateConnectionCommand(
|
_dclManager.Tell(new Commons.Messages.DataConnection.CreateConnectionCommand(
|
||||||
name, connConfig.Protocol, primaryDetails, backupDetails, connConfig.FailoverRetryCount));
|
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 ──
|
// ── Shared Script Loading ──
|
||||||
|
|
||||||
private void LoadSharedScriptsFromStorage()
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+179
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user