diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs new file mode 100644 index 00000000..8dd05184 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs @@ -0,0 +1,104 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers; + +/// +/// Mutable, all-nullable form model for the driver resilience override. Binds the typed +/// fields in DriverResilienceSection; null/blank = "use the driver's tier default", so a +/// blank form serializes back to null (preserving DriverInstance.ResilienceConfig = null). +/// Emits / reads the exact override JSON shape DriverResilienceOptionsParser consumes. +/// +public sealed class ResilienceFormModel +{ + public static readonly string[] Capabilities = + ["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead"]; + + public int? BulkheadMaxConcurrent { get; set; } + public int? BulkheadMaxQueue { get; set; } + public int? RecycleIntervalSeconds { get; set; } + + // capability name -> (timeout, retry, breaker), each nullable. + public Dictionary Policies { get; set; } = + Capabilities.ToDictionary(c => c, _ => new CapabilityRow()); + + public sealed class CapabilityRow + { + public int? TimeoutSeconds { get; set; } + public int? RetryCount { get; set; } + public int? BreakerFailureThreshold { get; set; } + public bool IsEmpty => TimeoutSeconds is null && RetryCount is null && BreakerFailureThreshold is null; + } + + private static readonly JsonSerializerOptions ReadOpts = new() { PropertyNameCaseInsensitive = true }; + private static readonly JsonSerializerOptions WriteOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static ResilienceFormModel FromJson(string? json) + { + var model = new ResilienceFormModel(); + if (string.IsNullOrWhiteSpace(json)) return model; + + Shape? shape; + try { shape = JsonSerializer.Deserialize(json, ReadOpts); } + catch (JsonException) { return model; } // malformed -> empty form; raw view (next task) shows the text + if (shape is null) return model; + + model.BulkheadMaxConcurrent = shape.BulkheadMaxConcurrent; + model.BulkheadMaxQueue = shape.BulkheadMaxQueue; + model.RecycleIntervalSeconds = shape.RecycleIntervalSeconds; + if (shape.CapabilityPolicies is not null) + foreach (var (cap, p) in shape.CapabilityPolicies) + if (model.Policies.TryGetValue(cap, out var row)) + { + row.TimeoutSeconds = p.TimeoutSeconds; + row.RetryCount = p.RetryCount; + row.BreakerFailureThreshold = p.BreakerFailureThreshold; + } + return model; + } + + /// Emit only the non-null overrides; returns null when nothing is overridden. + public string? ToJson() + { + var caps = Policies + .Where(kv => !kv.Value.IsEmpty) + .ToDictionary(kv => kv.Key, kv => new PolicyShape + { + TimeoutSeconds = kv.Value.TimeoutSeconds, + RetryCount = kv.Value.RetryCount, + BreakerFailureThreshold = kv.Value.BreakerFailureThreshold, + }); + + var hasAny = BulkheadMaxConcurrent is not null || BulkheadMaxQueue is not null + || RecycleIntervalSeconds is not null || caps.Count > 0; + if (!hasAny) return null; + + var shape = new Shape + { + BulkheadMaxConcurrent = BulkheadMaxConcurrent, + BulkheadMaxQueue = BulkheadMaxQueue, + RecycleIntervalSeconds = RecycleIntervalSeconds, + CapabilityPolicies = caps.Count > 0 ? caps : null, + }; + return JsonSerializer.Serialize(shape, WriteOpts); + } + + private sealed class Shape + { + public int? BulkheadMaxConcurrent { get; set; } + public int? BulkheadMaxQueue { get; set; } + public int? RecycleIntervalSeconds { get; set; } + public Dictionary? CapabilityPolicies { get; set; } + } + + private sealed class PolicyShape + { + public int? TimeoutSeconds { get; set; } + public int? RetryCount { get; set; } + public int? BreakerFailureThreshold { get; set; } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs new file mode 100644 index 00000000..12e8a65f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs @@ -0,0 +1,41 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Resilience; + +public class ResilienceFormModelTests +{ + [Fact] + public void Blank_form_serializes_to_null() + => new ResilienceFormModel().ToJson().ShouldBeNull(); + + [Fact] + public void Partial_override_round_trips() + { + var m = new ResilienceFormModel { BulkheadMaxConcurrent = 16 }; + m.Policies["Read"].TimeoutSeconds = 5; + m.Policies["Read"].RetryCount = 5; + + var json = m.ToJson(); + json.ShouldNotBeNull(); + + var back = ResilienceFormModel.FromJson(json); + back.BulkheadMaxConcurrent.ShouldBe(16); + back.Policies["Read"].TimeoutSeconds.ShouldBe(5); + back.Policies["Write"].IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void Emitted_json_is_consumable_by_the_runtime_parser() + { + var m = new ResilienceFormModel { BulkheadMaxConcurrent = 16 }; + m.Policies["Read"].TimeoutSeconds = 7; + + var opts = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, m.ToJson(), out var diag); + diag.ShouldBeNull(); + opts.BulkheadMaxConcurrent.ShouldBe(16); + opts.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(7); + opts.Resolve(DriverCapability.Write).RetryCount.ShouldBe(0); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj index 61ad438d..18546af9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj @@ -19,6 +19,7 @@ +