From 2c16062457e5c062e0aefbdf43aa33f277726aef Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 09:55:15 -0400 Subject: [PATCH] feat(adminui): Historian.Wonderware typed driver page --- .../HistorianWonderwareDriverPage.razor | 305 ++++++++++++++++++ ...derwareDriverPageFormSerializationTests.cs | 83 +++++ 2 files changed, 388 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor new file mode 100644 index 00000000..6e0bc73d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor @@ -0,0 +1,305 @@ +@page "/clusters/{ClusterId}/drivers/new/historianwonderware" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New Wonderware Historian driver" : "Edit Wonderware Historian driver") · @ClusterId

+ Cancel +
+ + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Driver instance @DriverInstanceId was not found in cluster @ClusterId. +
+} +else +{ + + + + + + + @* Connection *@ +
+
Connection
+
+
+
+ + +
Must match the sidecar's OTOPCUA_HISTORIAN_PIPE environment variable.
+
+
+ + +
Per-process secret verified in the Hello frame — must match the sidecar's configured secret.
+
+
+ + +
Sent in Hello for sidecar logging. Default: OtOpcUa.
+
+
+
+
+ + @* Timing *@ +
+
Timing
+
+
+
+ + +
Cap on pipe connect + Hello round-trip. Null = 10 s.
+
+
+ + +
Cap on a single read/write once connected. Null = 30 s.
+
+
+ + +
+
+ + +
+
+
+
+ + @* Diagnostics *@ +
+
Diagnostics
+
+
+
+ + +
Max 60. Used by Test Connect. Default 15.
+
+
+
+
+ + +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? DriverInstanceId { get; set; } + + private const string DriverTypeKey = "Historian.Wonderware"; + + private bool IsNew => string.IsNullOrEmpty(DriverInstanceId); + + private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + }; + + private FormModel _form = new(); + private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey }; + private DriverInstance? _existing; + private List _namespaces = new(); + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _namespaces = await db.Namespaces.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NamespaceId) + .ToListAsync(); + + if (IsNew) + { + _identityModel = new() + { + DriverInstanceId = "", + Name = "", + DriverType = DriverTypeKey, + NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", + Enabled = true, + }; + _form = new FormModel(); + } + else + { + _existing = await db.DriverInstances.AsNoTracking() + .FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (_existing is not null) + { + _identityModel = new() + { + DriverInstanceId = _existing.DriverInstanceId, + Name = _existing.Name, + DriverType = _existing.DriverType, + NamespaceId = _existing.NamespaceId, + Enabled = _existing.Enabled, + }; + var opts = TryDeserialize(_existing.DriverConfig) ?? CreateDefaultOptions(); + _form = new FormModel(); + _form.Historian = WonderwareHistorianClientFormModel.FromRecord(opts); + _form.ResilienceConfig = _existing.ResilienceConfig; + _form.RowVersion = _existing.RowVersion; + } + } + _loaded = true; + } + + private static WonderwareHistorianClientOptions CreateDefaultOptions() => + new(PipeName: "otopcua-historian", SharedSecret: ""); + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + var opts = _form.Historian.ToRecord(); + var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts); + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId)) + { + _error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return; + } + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = _identityModel.DriverInstanceId, + ClusterId = ClusterId, + NamespaceId = _identityModel.NamespaceId, + Name = _identityModel.Name, + DriverType = DriverTypeKey, + Enabled = _identityModel.Enabled, + DriverConfig = configJson, + ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig, + }); + } + else + { + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.NamespaceId = _identityModel.NamespaceId; + entity.Name = _identityModel.Name; + entity.Enabled = _identityModel.Enabled; + entity.DriverConfig = configJson; + entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes."; + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.DriverInstances.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were viewing it. Reload before deleting."; + } + catch (Exception ex) + { + _error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)"; + } + finally { _busy = false; } + } + + private static WonderwareHistorianClientOptions? TryDeserialize(string json) + { + try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + public sealed class FormModel + { + public WonderwareHistorianClientFormModel Historian { get; set; } = new(); + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + } + + /// + /// Mutable mirror of (positional record). + /// ConnectTimeoutSeconds and CallTimeoutSeconds are nullable int — null + /// round-trips to a null TimeSpan?, which the record resolves to its compiled default. + /// + public sealed class WonderwareHistorianClientFormModel + { + public string PipeName { get; set; } = "otopcua-historian"; + public string SharedSecret { get; set; } = ""; + public string PeerName { get; set; } = "OtOpcUa"; + public int? ConnectTimeoutSeconds { get; set; } + public int? CallTimeoutSeconds { get; set; } + public int ProbeTimeoutSeconds { get; set; } = 15; + + public static WonderwareHistorianClientFormModel FromRecord(WonderwareHistorianClientOptions r) => new() + { + PipeName = r.PipeName, + SharedSecret = r.SharedSecret, + PeerName = r.PeerName, + ConnectTimeoutSeconds = r.ConnectTimeout.HasValue ? (int)r.ConnectTimeout.Value.TotalSeconds : null, + CallTimeoutSeconds = r.CallTimeout.HasValue ? (int)r.CallTimeout.Value.TotalSeconds : null, + ProbeTimeoutSeconds = r.ProbeTimeoutSeconds, + }; + + public WonderwareHistorianClientOptions ToRecord() => new( + PipeName: PipeName, + SharedSecret: SharedSecret, + PeerName: PeerName, + ConnectTimeout: ConnectTimeoutSeconds.HasValue ? TimeSpan.FromSeconds(ConnectTimeoutSeconds.Value) : null, + CallTimeout: CallTimeoutSeconds.HasValue ? TimeSpan.FromSeconds(CallTimeoutSeconds.Value) : null) + { + ProbeTimeoutSeconds = ProbeTimeoutSeconds, + }; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs new file mode 100644 index 00000000..1e21c422 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +public sealed class HistorianWonderwareDriverPageFormSerializationTests +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + [Fact] + public void RoundTrip_PreservesKnownFields() + { + var original = new WonderwareHistorianClientOptions( + PipeName: "otopcua-historian-prod", + SharedSecret: "t0ps3cr3t", + PeerName: "OtOpcUa-Primary", + ConnectTimeout: TimeSpan.FromSeconds(20), + CallTimeout: TimeSpan.FromSeconds(60)) + { + ProbeTimeoutSeconds = 25, + }; + + var json = JsonSerializer.Serialize(original, _opts); + var back = JsonSerializer.Deserialize(json, _opts); + + back.ShouldNotBeNull(); + back.PipeName.ShouldBe("otopcua-historian-prod"); + back.SharedSecret.ShouldBe("t0ps3cr3t"); + back.PeerName.ShouldBe("OtOpcUa-Primary"); + back.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(20)); + back.CallTimeout.ShouldBe(TimeSpan.FromSeconds(60)); + back.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(20)); + back.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(60)); + back.ProbeTimeoutSeconds.ShouldBe(25); + } + + [Fact] + public void RoundTrip_NullTimeouts_UsesDefaults() + { + var original = new WonderwareHistorianClientOptions( + PipeName: "otopcua-historian", + SharedSecret: "secret"); + + var json = JsonSerializer.Serialize(original, _opts); + var back = JsonSerializer.Deserialize(json, _opts); + + back.ShouldNotBeNull(); + back.ConnectTimeout.ShouldBeNull(); + back.CallTimeout.ShouldBeNull(); + back.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(10)); + back.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(30)); + } + + [Fact] + public void Deserialize_DropsUnknownFields() + { + var jsonWithExtra = """ + { + "unknownField": "old-value", + "pipeName": "otopcua-historian", + "sharedSecret": "s3cr3t", + "probeTimeoutSeconds": 20 + } + """; + + var optsWithSkip = new JsonSerializerOptions(_opts) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + var back = JsonSerializer.Deserialize(jsonWithExtra, optsWithSkip); + back.ShouldNotBeNull(); + back.ProbeTimeoutSeconds.ShouldBe(20); + back.PipeName.ShouldBe("otopcua-historian"); + } +}