From a243cfd126dc7052a9bb187d3e303cea872b1217 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 09:52:31 -0400 Subject: [PATCH] feat(adminui): Galaxy typed driver page --- .../Clusters/Drivers/GalaxyDriverPage.razor | 422 ++++++++++++++++++ .../GalaxyDriverPageFormSerializationTests.cs | 95 ++++ 2 files changed, 517 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor new file mode 100644 index 00000000..d47a672e --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor @@ -0,0 +1,422 @@ +@page "/clusters/{ClusterId}/drivers/new/galaxy" +@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.Galaxy.Config +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New AVEVA Galaxy driver" : "Edit AVEVA Galaxy driver") · @ClusterId

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

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Driver instance @DriverInstanceId was not found in cluster @ClusterId. +
+} +else +{ + + + + + + + @* mxaccessgw connection *@ +
+
mxaccessgw connection
+
+
+
+ + +
gRPC endpoint of the mxaccessgw process.
+
+
+ + +
Forms: env:NAME, file:PATH, dev:KEY. Cleartext is accepted but triggers a startup warning.
+
+
+
+ + +
+
+
+ + +
+
+ + +
Default 10 s.
+
+
+ + +
Default 30 s.
+
+
+ + +
Default 0 (lifetime of driver).
+
+
+
+
+ + @* MXAccess *@ +
+
MXAccess
+
+
+
+ + +
Must be unique per OtOpcUa instance — redundancy pairs each get a distinct name.
+
+
+ + +
Default 1000 ms.
+
+
+ + +
0 = anonymous.
+
+
+ + +
Default 50000. Raise if events.dropped appears.
+
+
+
+
+ + @* Repository *@ +
+
Galaxy repository
+
+
+
+ + +
Default 5000 objects per page.
+
+
+
+ + +
+
Triggers address-space rebuild on Galaxy re-deploy.
+
+
+
+
+ + @* Reconnect *@ +
+
Reconnect backoff
+
+
+
+ + +
Default 500 ms.
+
+
+ + +
Default 30000 ms.
+
+
+
+ + +
+
+
+
+
+ + @* Diagnostics *@ +
+
Diagnostics
+
+
+
+ + +
Max 60. Used by Test Connect. Default 30.
+
+
+
+
+ + +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? DriverInstanceId { get; set; } + + private const string DriverTypeKey = "Galaxy"; + + 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.Galaxy = GalaxyFormModel.FromRecord(opts); + _form.ResilienceConfig = _existing.ResilienceConfig; + _form.RowVersion = _existing.RowVersion; + } + } + _loaded = true; + } + + private static GalaxyDriverOptions CreateDefaultOptions() => new( + Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"), + MxAccess: new GalaxyMxAccessOptions("OtOpcUa"), + Repository: new GalaxyRepositoryOptions(), + Reconnect: new GalaxyReconnectOptions()); + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + var opts = _form.Galaxy.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 GalaxyDriverOptions? TryDeserialize(string json) + { + try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + public sealed class FormModel + { + public GalaxyFormModel Galaxy { get; set; } = new(); + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + } + + /// + /// Mutable flat mirror of and its nested records. + /// All positional-record fields are flattened with a section prefix to avoid name + /// collisions. / handle translation. + /// + public sealed class GalaxyFormModel + { + // GalaxyGatewayOptions + public string GatewayEndpoint { get; set; } = "https://localhost:5001"; + public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY"; + public bool GatewayUseTls { get; set; } = true; + public string? GatewayCaCertificatePath { get; set; } + public int GatewayConnectTimeoutSeconds { get; set; } = 10; + public int GatewayDefaultCallTimeoutSeconds { get; set; } = 30; + public int GatewayStreamTimeoutSeconds { get; set; } = 0; + + // GalaxyMxAccessOptions + public string MxClientName { get; set; } = "OtOpcUa"; + public int MxPublishingIntervalMs { get; set; } = 1000; + public int MxWriteUserId { get; set; } = 0; + public int MxEventPumpChannelCapacity { get; set; } = 50_000; + + // GalaxyRepositoryOptions + public int RepositoryDiscoverPageSize { get; set; } = 5000; + public bool RepositoryWatchDeployEvents { get; set; } = true; + + // GalaxyReconnectOptions + public int ReconnectInitialBackoffMs { get; set; } = 500; + public int ReconnectMaxBackoffMs { get; set; } = 30_000; + public bool ReconnectReplayOnSessionLost { get; set; } = true; + + // GalaxyDriverOptions top-level + public int ProbeTimeoutSeconds { get; set; } = 30; + + public static GalaxyFormModel FromRecord(GalaxyDriverOptions r) => new() + { + GatewayEndpoint = r.Gateway.Endpoint, + GatewayApiKeySecretRef = r.Gateway.ApiKeySecretRef, + GatewayUseTls = r.Gateway.UseTls, + GatewayCaCertificatePath = r.Gateway.CaCertificatePath, + GatewayConnectTimeoutSeconds = r.Gateway.ConnectTimeoutSeconds, + GatewayDefaultCallTimeoutSeconds = r.Gateway.DefaultCallTimeoutSeconds, + GatewayStreamTimeoutSeconds = r.Gateway.StreamTimeoutSeconds, + MxClientName = r.MxAccess.ClientName, + MxPublishingIntervalMs = r.MxAccess.PublishingIntervalMs, + MxWriteUserId = r.MxAccess.WriteUserId, + MxEventPumpChannelCapacity = r.MxAccess.EventPumpChannelCapacity, + RepositoryDiscoverPageSize = r.Repository.DiscoverPageSize, + RepositoryWatchDeployEvents = r.Repository.WatchDeployEvents, + ReconnectInitialBackoffMs = r.Reconnect.InitialBackoffMs, + ReconnectMaxBackoffMs = r.Reconnect.MaxBackoffMs, + ReconnectReplayOnSessionLost = r.Reconnect.ReplayOnSessionLost, + ProbeTimeoutSeconds = r.ProbeTimeoutSeconds, + }; + + public GalaxyDriverOptions ToRecord() => new( + Gateway: new GalaxyGatewayOptions( + Endpoint: GatewayEndpoint, + ApiKeySecretRef: GatewayApiKeySecretRef, + UseTls: GatewayUseTls, + CaCertificatePath: string.IsNullOrWhiteSpace(GatewayCaCertificatePath) ? null : GatewayCaCertificatePath, + ConnectTimeoutSeconds: GatewayConnectTimeoutSeconds, + DefaultCallTimeoutSeconds: GatewayDefaultCallTimeoutSeconds, + StreamTimeoutSeconds: GatewayStreamTimeoutSeconds), + MxAccess: new GalaxyMxAccessOptions( + ClientName: MxClientName, + PublishingIntervalMs: MxPublishingIntervalMs, + WriteUserId: MxWriteUserId, + EventPumpChannelCapacity: MxEventPumpChannelCapacity), + Repository: new GalaxyRepositoryOptions( + DiscoverPageSize: RepositoryDiscoverPageSize, + WatchDeployEvents: RepositoryWatchDeployEvents), + Reconnect: new GalaxyReconnectOptions( + InitialBackoffMs: ReconnectInitialBackoffMs, + MaxBackoffMs: ReconnectMaxBackoffMs, + ReplayOnSessionLost: ReconnectReplayOnSessionLost)) + { + ProbeTimeoutSeconds = ProbeTimeoutSeconds, + }; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs new file mode 100644 index 00000000..77b4c70d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/GalaxyDriverPageFormSerializationTests.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +public sealed class GalaxyDriverPageFormSerializationTests +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + [Fact] + public void RoundTrip_PreservesKnownFields() + { + var original = new GalaxyDriverOptions( + Gateway: new GalaxyGatewayOptions( + Endpoint: "https://gw.internal:5001", + ApiKeySecretRef: "env:MY_API_KEY", + UseTls: true, + CaCertificatePath: "C:\\certs\\ca.pem", + ConnectTimeoutSeconds: 15, + DefaultCallTimeoutSeconds: 45, + StreamTimeoutSeconds: 0), + MxAccess: new GalaxyMxAccessOptions( + ClientName: "OtOpcUa-Primary", + PublishingIntervalMs: 500, + WriteUserId: 1, + EventPumpChannelCapacity: 100_000), + Repository: new GalaxyRepositoryOptions( + DiscoverPageSize: 2000, + WatchDeployEvents: false), + Reconnect: new GalaxyReconnectOptions( + InitialBackoffMs: 1000, + MaxBackoffMs: 60_000, + ReplayOnSessionLost: false)) + { + ProbeTimeoutSeconds = 45, + }; + + var json = JsonSerializer.Serialize(original, _opts); + var back = JsonSerializer.Deserialize(json, _opts); + + back.ShouldNotBeNull(); + back.Gateway.Endpoint.ShouldBe("https://gw.internal:5001"); + back.Gateway.ApiKeySecretRef.ShouldBe("env:MY_API_KEY"); + back.Gateway.UseTls.ShouldBeTrue(); + back.Gateway.CaCertificatePath.ShouldBe("C:\\certs\\ca.pem"); + back.Gateway.ConnectTimeoutSeconds.ShouldBe(15); + back.Gateway.DefaultCallTimeoutSeconds.ShouldBe(45); + back.Gateway.StreamTimeoutSeconds.ShouldBe(0); + back.MxAccess.ClientName.ShouldBe("OtOpcUa-Primary"); + back.MxAccess.PublishingIntervalMs.ShouldBe(500); + back.MxAccess.WriteUserId.ShouldBe(1); + back.MxAccess.EventPumpChannelCapacity.ShouldBe(100_000); + back.Repository.DiscoverPageSize.ShouldBe(2000); + back.Repository.WatchDeployEvents.ShouldBeFalse(); + back.Reconnect.InitialBackoffMs.ShouldBe(1000); + back.Reconnect.MaxBackoffMs.ShouldBe(60_000); + back.Reconnect.ReplayOnSessionLost.ShouldBeFalse(); + back.ProbeTimeoutSeconds.ShouldBe(45); + } + + [Fact] + public void Deserialize_DropsUnknownFields() + { + // Minimal JSON that sets only ProbeTimeoutSeconds and an unknown field. + // The nested records must supply their required positional args for JSON deserialization + // to succeed — provide them here so the test exercises the Unknown-field skip path. + var jsonWithExtra = """ + { + "unknownField": "old-value", + "gateway": { "endpoint": "https://localhost:5001", "apiKeySecretRef": "dev:test" }, + "mxAccess": { "clientName": "OtOpcUa" }, + "repository": {}, + "reconnect": {}, + "probeTimeoutSeconds": 20 + } + """; + + var optsWithSkip = new JsonSerializerOptions(_opts) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + var back = JsonSerializer.Deserialize(jsonWithExtra, optsWithSkip); + back.ShouldNotBeNull(); + back.ProbeTimeoutSeconds.ShouldBe(20); + back.Gateway.Endpoint.ShouldBe("https://localhost:5001"); + } +}