using System.Text.Json; using System.Text.Json.Nodes; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; 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. /// /// Round-trip is non-lossy (04/C-7): the stored JSON is kept as a bag, /// and overlays only the known fields onto that bag — so unknown top-level keys, /// unknown capability entries, and unknown per-policy fields all survive a load→save, mirroring the /// driver tag editors' preserve-unknown discipline (TagConfigJson). A stored blob that could not /// be parsed sets and is never overwritten — /// returns the original text verbatim. /// /// public sealed class ResilienceFormModel { public static readonly string[] Capabilities = ["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead"]; /// Gets or sets the driver recycle-interval override, in seconds; null = use the tier default. public int? RecycleIntervalSeconds { get; set; } // capability name -> (timeout, retry, breaker), each nullable. /// Gets or sets the per-capability resilience overrides, keyed by capability name. public Dictionary Policies { get; set; } = Capabilities.ToDictionary(c => c, _ => new CapabilityRow(), StringComparer.OrdinalIgnoreCase); /// /// True when the stored ResilienceConfig could not be parsed as JSON. The section locks editing and /// returns unchanged so an unrelated keystroke can /// never silently overwrite an unreadable column. /// public bool ParseFailed { get; private set; } /// The original stored text when — returned verbatim by . public string? RawStoredJson { get; private set; } private JsonObject _bag = new(); /// Per-capability timeout/retry/breaker override row; null fields fall back to the tier default. public sealed class CapabilityRow { /// Gets or sets the timeout override, in seconds; null = use the tier default. public int? TimeoutSeconds { get; set; } /// Gets or sets the retry-count override; null = use the tier default. public int? RetryCount { get; set; } /// Gets or sets the circuit-breaker failure-threshold override; null = use the tier default. public int? BreakerFailureThreshold { get; set; } /// Gets a value indicating whether all fields in this row are unset. public bool IsEmpty => TimeoutSeconds is null && RetryCount is null && BreakerFailureThreshold is null; } /// /// Range-validate the per-capability overrides against and return /// one human-readable message per violation (naming capability + field + legal range). This is /// authoring-time feedback, not the enforcement layer — the parser's clamp is the authoritative /// guard, so the form still emits. Mirrors the driver tag editors' Validate() idiom (01/S-6). /// /// A list of range-violation messages; empty when every override is in range or blank. public IReadOnlyList Validate() { var msgs = new List(); foreach (var (cap, row) in Policies) { if (row.TimeoutSeconds is { } t && (t < ResiliencePolicyRanges.MinTimeoutSeconds || t > ResiliencePolicyRanges.MaxTimeoutSeconds)) msgs.Add($"{cap} timeout must be between {ResiliencePolicyRanges.MinTimeoutSeconds} and " + $"{ResiliencePolicyRanges.MaxTimeoutSeconds} seconds (got {t})."); if (row.RetryCount is { } r && (r < ResiliencePolicyRanges.MinRetryCount || r > ResiliencePolicyRanges.MaxRetryCount)) msgs.Add($"{cap} retries must be between {ResiliencePolicyRanges.MinRetryCount} and " + $"{ResiliencePolicyRanges.MaxRetryCount} (got {r})."); if (row.BreakerFailureThreshold is { } b && (b < ResiliencePolicyRanges.MinBreakerFailureThreshold || b == 1 || b > ResiliencePolicyRanges.MaxBreakerFailureThreshold)) msgs.Add($"{cap} breaker threshold must be 0 (off) or between " + $"{ResiliencePolicyRanges.MinEnabledBreakerFailureThreshold} and " + $"{ResiliencePolicyRanges.MaxBreakerFailureThreshold} (got {b})."); } return msgs; } /// /// Parse the override JSON into a form model, preserving every key in an internal bag. Malformed JSON /// sets + and yields an otherwise-empty model. /// /// The raw resilience-override JSON, or null/blank if there is no override. /// A populated form model. public static ResilienceFormModel FromJson(string? json) { var model = new ResilienceFormModel(); if (string.IsNullOrWhiteSpace(json)) return model; JsonObject bag; try { bag = JsonNode.Parse(json) as JsonObject ?? new JsonObject(); } catch (JsonException) { model.ParseFailed = true; model.RawStoredJson = json; return model; } model._bag = bag; model.RecycleIntervalSeconds = GetIntOrNull(bag, "recycleIntervalSeconds"); if (bag.TryGetPropertyValue("capabilityPolicies", out var cpNode) && cpNode is JsonObject cp) { foreach (var (capName, polNode) in cp) { // Known capability names populate rows (reading only the known per-policy fields — unknown // per-policy fields stay untouched in the bag). Unknown capability entries stay in the bag. if (polNode is JsonObject pol && model.Policies.TryGetValue(capName, out var row)) { row.TimeoutSeconds = GetIntOrNull(pol, "timeoutSeconds"); row.RetryCount = GetIntOrNull(pol, "retryCount"); row.BreakerFailureThreshold = GetIntOrNull(pol, "breakerFailureThreshold"); } } } return model; } /// /// Overlay the model's non-null known values onto the preserved bag and serialize. Returns null when /// the result is empty (blank form = tier defaults). When , returns the /// original unparseable text unchanged. /// /// The serialized override JSON, the original text when parse failed, or null when nothing is set. public string? ToJson() { if (ParseFailed) return RawStoredJson; SetOrRemoveInt(_bag, "recycleIntervalSeconds", RecycleIntervalSeconds); var cp = _bag.TryGetPropertyValue("capabilityPolicies", out var cpNode) && cpNode is JsonObject existing ? existing : null; foreach (var (cap, row) in Policies) { // Find this known capability's existing policy object (case-insensitive), else its canonical key. var polKey = cap; JsonObject? pol = null; if (cp is not null) { foreach (var (k, v) in cp) { if (string.Equals(k, cap, StringComparison.OrdinalIgnoreCase) && v is JsonObject vo) { polKey = k; pol = vo; break; } } } if (row.IsEmpty) { // Clear the known fields; drop the policy entry only if no unknown fields remain. if (pol is not null) { pol.Remove("timeoutSeconds"); pol.Remove("retryCount"); pol.Remove("breakerFailureThreshold"); if (pol.Count == 0) cp!.Remove(polKey); } } else { if (cp is null) { cp = new JsonObject(); _bag["capabilityPolicies"] = cp; } if (pol is null) { pol = new JsonObject(); cp[polKey] = pol; } SetOrRemoveInt(pol, "timeoutSeconds", row.TimeoutSeconds); SetOrRemoveInt(pol, "retryCount", row.RetryCount); SetOrRemoveInt(pol, "breakerFailureThreshold", row.BreakerFailureThreshold); } } if (cp is not null && cp.Count == 0) _bag.Remove("capabilityPolicies"); return _bag.Count == 0 ? null : _bag.ToJsonString(); } private static int? GetIntOrNull(JsonObject o, string name) => o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue(out var i) ? i : null; private static void SetOrRemoveInt(JsonObject o, string name, int? value) { if (value is null) o.Remove(name); else o[name] = JsonValue.Create(value.Value); } }