diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor new file mode 100644 index 00000000..93b0128c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor @@ -0,0 +1,324 @@ +@page "/clusters/{ClusterId}/drivers/new/ablegacy" +@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.AbLegacy +@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New AB Legacy driver" : "Edit AB Legacy driver") · @ClusterId

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

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Driver instance @DriverInstanceId was not found in cluster @ClusterId. +
+} +else +{ + + + + + + + @* Operation settings *@ +
+
Operation settings
+
+
+
+ + +
Default read/write timeout. Default 2 s.
+
+
+
+
+ + @* Connectivity probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
Default 5 s.
+
+
+ + +
Default 2 s.
+
+
+ + +
PCCC file address to read for probe. Default S:0 (status file word 0).
+
+
+ + +
Max 60. Used by Test Connect. Default 5.
+
+
+
+
+ + @* Devices — read-only JSON view *@ +
+
Devices
+
+

+ Device list (host addresses, PLC family) — full list-editor coming in a follow-up phase. + Each entry: { "hostAddress": "...", "plcFamily": "Slc500" }. + PLC families: Slc500, MicroLogix, Plc5, LogixPccc. +

+
@_devicesJson
+
+
+ + @* Tags — read-only JSON view *@ +
+
Tags
+
+

+ Tag list — full list-editor coming in a follow-up phase. Edit via the Tag editor pages or export/import the driver config JSON. + Each tag has a PCCC file address (e.g. N7:0, F8:0, B3:0/0). +

+
@_tagsJson
+
+
+ + +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? DriverInstanceId { get; set; } + + private const string DriverTypeKey = "AbLegacy"; + + 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; + + // Collections are preserved through round-trip and shown as read-only JSON. + private IReadOnlyList _devices = []; + private IReadOnlyList _tags = []; + private string _devicesJson = "[]"; + private string _tagsJson = "[]"; + + 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) ?? new AbLegacyDriverOptions(); + _form = FormModel.FromOptions(opts); + _form.ResilienceConfig = _existing.ResilienceConfig; + _form.RowVersion = _existing.RowVersion; + _devices = opts.Devices; + _tags = opts.Tags; + } + } + _devicesJson = System.Text.Json.JsonSerializer.Serialize(_devices, _jsonOpts); + _tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts); + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _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 AbLegacyDriverOptions? TryDeserialize(string json) + { + try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + // Flat mutable model — all scalar properties settable for Blazor @bind-Value. + // Collections (Devices, Tags) are kept on the component and passed in on ToOptions(). + public sealed class FormModel + { + // Operation + public int TimeoutSeconds { get; set; } = 2; + + // Probe + public bool ProbeEnabled { get; set; } = true; + public int ProbeIntervalSeconds { get; set; } = 5; + public int ProbeTimeoutSeconds { get; set; } = 2; + public string? ProbeAddress { get; set; } = "S:0"; + + // Admin UI probe timeout + public int AdminProbeTimeoutSeconds { get; set; } = 5; + + // Persistence + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + + public static FormModel FromOptions(AbLegacyDriverOptions o) => new() + { + TimeoutSeconds = (int)o.Timeout.TotalSeconds, + ProbeEnabled = o.Probe.Enabled, + ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, + ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, + ProbeAddress = o.Probe.ProbeAddress, + AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + }; + + public AbLegacyDriverOptions ToOptions( + IReadOnlyList devices, + IReadOnlyList tags) => new() + { + Devices = devices, + Tags = tags, + Probe = new AbLegacyProbeOptions + { + Enabled = ProbeEnabled, + Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds), + Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), + ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress, + }, + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, + }; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs new file mode 100644 index 00000000..cd39dc55 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +public sealed class AbLegacyDriverPageFormSerializationTests +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + [Fact] + public void RoundTrip_PreservesKnownFields() + { + var original = new AbLegacyDriverOptions + { + Timeout = TimeSpan.FromSeconds(4), + Probe = new AbLegacyProbeOptions + { + Enabled = true, + Interval = TimeSpan.FromSeconds(8), + Timeout = TimeSpan.FromSeconds(3), + ProbeAddress = "N7:0", + }, + ProbeTimeoutSeconds = 10, + Devices = + [ + new AbLegacyDeviceOptions("10.0.0.10", AbLegacyPlcFamily.Slc500, "PLC-A"), + new AbLegacyDeviceOptions("10.0.0.11", AbLegacyPlcFamily.MicroLogix), + ], + Tags = + [ + new AbLegacyTagDefinition("Level", "10.0.0.10", "N7:5", AbLegacyDataType.Int, Writable: false), + new AbLegacyTagDefinition("Pump", "10.0.0.10", "B3:0/0", AbLegacyDataType.Bit, Writable: true), + ], + }; + + var json = JsonSerializer.Serialize(original, _opts); + var back = JsonSerializer.Deserialize(json, _opts); + + back.ShouldNotBeNull(); + back.Timeout.ShouldBe(TimeSpan.FromSeconds(4)); + back.Probe.Enabled.ShouldBeTrue(); + back.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(8)); + back.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); + back.Probe.ProbeAddress.ShouldBe("N7:0"); + back.ProbeTimeoutSeconds.ShouldBe(10); + back.Devices.Count.ShouldBe(2); + back.Devices[0].HostAddress.ShouldBe("10.0.0.10"); + back.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.Slc500); + back.Devices[0].DeviceName.ShouldBe("PLC-A"); + back.Devices[1].PlcFamily.ShouldBe(AbLegacyPlcFamily.MicroLogix); + back.Tags.Count.ShouldBe(2); + back.Tags[0].Name.ShouldBe("Level"); + back.Tags[0].Address.ShouldBe("N7:5"); + back.Tags[0].DataType.ShouldBe(AbLegacyDataType.Int); + back.Tags[0].Writable.ShouldBeFalse(); + back.Tags[1].DataType.ShouldBe(AbLegacyDataType.Bit); + } + + [Fact] + public void Deserialize_DropsUnknownFields() + { + var jsonWithExtra = """{"unknownField":"old-value","probeTimeoutSeconds":10}"""; + + var optsWithSkip = new JsonSerializerOptions(_opts) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + + var back = JsonSerializer.Deserialize(jsonWithExtra, optsWithSkip); + back.ShouldNotBeNull(); + back.ProbeTimeoutSeconds.ShouldBe(10); + } +}