fix(adminui): AB forms read/write the factory-bound *Ms duration keys, migrate legacy TimeSpan strings (#576)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 23:03:38 -04:00
parent 3d081ad29c
commit bafbcbee7e
10 changed files with 288 additions and 35 deletions
@@ -139,9 +139,16 @@
/// <summary>Top-level/nested JSON paths this form edits; everything else in the stored config is preserved.</summary>
internal static readonly string[] OwnedPaths =
[
"timeout", "transport", "enableControllerBrowse", "enableDeclarationOnlyUdtGrouping",
"enableAlarmProjection", "alarmPollInterval", "maxConcurrentReads", "probeTimeoutSeconds",
"probe.enabled", "probe.interval", "probe.timeout", "probe.probeTagPath",
"transport", "enableControllerBrowse", "enableDeclarationOnlyUdtGrouping",
"enableAlarmProjection", "maxConcurrentReads", "probeTimeoutSeconds",
"probe.enabled", "probe.probeTagPath",
];
/// <summary>The factory binds integer-millisecond keys, not the TimeSpan strings the typed options serialise (#576).</summary>
internal static readonly (string TimeSpanPath, string MsPath)[] TimeSpanToMs =
[
("timeout", "timeoutMs"), ("alarmPollInterval", "alarmPollIntervalMs"),
("probe.interval", "probe.intervalMs"), ("probe.timeout", "probe.timeoutMs"),
];
// Preserve the driver's existing Devices across a load→save (the device form authors the real endpoints;
@@ -154,7 +161,7 @@
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new AbCipDriverOptions();
_form = FormModel.FromOptions(opts);
_form = FormModel.FromJson(DriverConfigJson, opts);
// #577: a brand-new driver (nothing authored yet) must not default the probe ON with no tag path —
// that logs an operator warning per driver and runs no probe. An existing config keeps whatever
// it stores (an absent "probe" key still means "enabled" to the driver, so don't flip it on edit).
@@ -173,7 +180,7 @@
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
internal static string Serialize(AbCipDriverOptions options, string? originalJson = null)
=> DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths);
=> DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths, TimeSpanToMs);
private async Task EmitAsync()
{
@@ -222,8 +229,31 @@
// Admin UI probe timeout
public int AdminProbeTimeoutSeconds { get; set; } = 5;
// The loaded durations, so an unchanged seconds field saves the exact stored value (sub-second safe).
private TimeSpan? _loadedTimeout, _loadedAlarmPoll, _loadedProbeInterval, _loadedProbeTimeout;
/// <summary>Loads from the stored JSON: the factory's <c>*Ms</c> keys win; a legacy TimeSpan-string key is the fallback.</summary>
public static FormModel FromJson(string? json, AbCipDriverOptions o)
{
static TimeSpan? Ms(string? j, string path) => DriverConfigMs.Read(j, path) is int ms ? TimeSpan.FromMilliseconds(ms) : null;
var f = FromOptions(o);
f._loadedTimeout = Ms(json, "timeoutMs") ?? f._loadedTimeout;
f._loadedAlarmPoll = Ms(json, "alarmPollIntervalMs") ?? f._loadedAlarmPoll;
f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval;
f._loadedProbeTimeout = Ms(json, "probe.timeoutMs") ?? f._loadedProbeTimeout;
f.TimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedTimeout ?? o.Timeout);
f.AlarmPollIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedAlarmPoll ?? o.AlarmPollInterval);
f.ProbeIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedProbeInterval ?? o.Probe.Interval);
f.ProbeTimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedProbeTimeout ?? o.Probe.Timeout);
return f;
}
public static FormModel FromOptions(AbCipDriverOptions o) => new()
{
_loadedTimeout = o.Timeout,
_loadedAlarmPoll = o.AlarmPollInterval,
_loadedProbeInterval = o.Probe.Interval,
_loadedProbeTimeout = o.Probe.Timeout,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Transport = o.Transport,
MaxConcurrentReads = o.MaxConcurrentReads,
@@ -245,16 +275,16 @@
Probe = new AbCipProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
Interval = DriverConfigMs.Save(ProbeIntervalSeconds, _loadedProbeInterval),
Timeout = DriverConfigMs.Save(ProbeTimeoutSeconds, _loadedProbeTimeout),
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
},
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout),
Transport = Transport,
MaxConcurrentReads = MaxConcurrentReads,
EnableControllerBrowse = EnableControllerBrowse,
EnableAlarmProjection = EnableAlarmProjection,
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
AlarmPollInterval = DriverConfigMs.Save(AlarmPollIntervalSeconds, _loadedAlarmPoll),
EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping,
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
@@ -97,8 +97,14 @@
/// <summary>JSON paths this form edits; everything else in the stored config is preserved.</summary>
internal static readonly string[] OwnedPaths =
[
"timeout", "transport", "probeTimeoutSeconds",
"probe.enabled", "probe.interval", "probe.timeout", "probe.probeAddress",
"transport", "probeTimeoutSeconds",
"probe.enabled", "probe.probeAddress",
];
/// <summary>The factory binds integer-millisecond keys, not the TimeSpan strings the typed options serialise (#576).</summary>
internal static readonly (string TimeSpanPath, string MsPath)[] TimeSpanToMs =
[
("timeout", "timeoutMs"), ("probe.interval", "probe.intervalMs"), ("probe.timeout", "probe.timeoutMs"),
];
// Devices are separate v3 entities — this form doesn't edit them, but preserves the driver's existing
@@ -111,7 +117,7 @@
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new AbLegacyDriverOptions();
_form = FormModel.FromOptions(opts);
_form = FormModel.FromJson(DriverConfigJson, opts);
_originalJson = DriverConfigJson;
_preservedDevices = opts.Devices;
_lastParsed = DriverConfigJson;
@@ -125,7 +131,7 @@
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
internal static string Serialize(AbLegacyDriverOptions options, string? originalJson = null)
=> DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths);
=> DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths, TimeSpanToMs);
private async Task EmitAsync()
{
@@ -171,8 +177,27 @@
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
private TimeSpan? _loadedTimeout, _loadedProbeInterval, _loadedProbeTimeout;
/// <summary>Loads from the stored JSON: the factory's <c>*Ms</c> keys win; a legacy TimeSpan-string key is the fallback.</summary>
public static FormModel FromJson(string? json, AbLegacyDriverOptions o)
{
static TimeSpan? Ms(string? j, string path) => DriverConfigMs.Read(j, path) is int ms ? TimeSpan.FromMilliseconds(ms) : null;
var f = FromOptions(o);
f._loadedTimeout = Ms(json, "timeoutMs") ?? f._loadedTimeout;
f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval;
f._loadedProbeTimeout = Ms(json, "probe.timeoutMs") ?? f._loadedProbeTimeout;
f.TimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedTimeout ?? o.Timeout);
f.ProbeIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedProbeInterval ?? o.Probe.Interval);
f.ProbeTimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedProbeTimeout ?? o.Probe.Timeout);
return f;
}
public static FormModel FromOptions(AbLegacyDriverOptions o) => new()
{
_loadedTimeout = o.Timeout,
_loadedProbeInterval = o.Probe.Interval,
_loadedProbeTimeout = o.Probe.Timeout,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Transport = o.Transport,
ProbeEnabled = o.Probe.Enabled,
@@ -189,11 +214,11 @@
Probe = new AbLegacyProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
Interval = DriverConfigMs.Save(ProbeIntervalSeconds, _loadedProbeInterval),
Timeout = DriverConfigMs.Save(ProbeTimeoutSeconds, _loadedProbeTimeout),
ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress,
},
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout),
Transport = Transport,
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};