feat(adminui): B2-WP3 driver/device config modals + page-to-form refactor

Extract the 8 typed driver pages into embeddable <Driver>DriverForm bodies
(channel/protocol config) and add per-driver <Driver>DeviceForm bodies
(connection endpoint -> Device.DeviceConfig, v3 endpoint split). New
DriverConfigModal + DeviceModal dispatch by DriverType (DriverTypeNames) for
the /raw tree; Test-connect inside DeviceModal builds the merged Driver+Device
config transiently via DriverDeviceConfigMerger. Routed pages now host the
form bodies and keep working. Round-trip + enum-serialization guard tests for
the Modbus driver form + Modbus device model; retargeted the existing
page-form serialization tests + the _jsonOpts converter guard to the forms.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 03:26:24 -04:00
parent 54ab413396
commit 844f93f64f
44 changed files with 3728 additions and 2474 deletions
@@ -5,10 +5,10 @@
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
@@ -30,7 +30,7 @@ else if (!IsNew && _existing is null)
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="twincatDriverEdit">
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="twincatDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
@@ -44,9 +44,7 @@ else
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<DriverTestConnectButton DriverType="@DriverTypeKey" GetConfigJson="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
@@ -59,104 +57,20 @@ else
OnPickAddress="@OnAddressPicked">
<TwinCATAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
GetConfigJson="@(() => _driverConfigJson)" />
</DriverTagPicker>
@* Options *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Options</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<label class="form-label" for="tcTimeoutSec">Timeout (seconds)</label>
<InputNumber id="tcTimeoutSec" @bind-Value="_form.TimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Default 2 s per operation.</div>
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-4">
<InputCheckbox id="tcNativeNotif" @bind-Value="_form.UseNativeNotifications"
class="form-check-input" />
<label class="form-check-label" for="tcNativeNotif">Use native ADS notifications</label>
</div>
<div class="form-text">Recommended. Disable for AMS routers with notification limits.</div>
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-4">
<InputCheckbox id="tcControllerBrowse" @bind-Value="_form.EnableControllerBrowse"
class="form-check-input" />
<label class="form-check-label" for="tcControllerBrowse">Enable controller browse</label>
</div>
<div class="form-text">Walks the symbol table via SymbolLoaderFactory at discovery. Default off.</div>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="tcNotifDelay">Notification max delay (ms)</label>
<InputNumber id="tcNotifDelay" @bind-Value="_form.NotificationMaxDelayMs"
class="form-control form-control-sm" />
<div class="form-text">0 = push immediately. Increase to coalesce high-churn signals.</div>
</div>
</div>
<section class="panel notice rise mt-3" style="animation-delay:.04s">
<div class="panel-head">Connection endpoint</div>
<div style="padding:1rem" class="text-muted">
Device host addresses (AMS Net Id:port) now live on the driver's <strong>devices</strong>
(v3 endpoint→DeviceConfig split). Author them from the Raw tree (<strong>/raw</strong>) device modal.
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="tcProbeEnabled" @bind-Value="_form.ProbeEnabled"
class="form-check-input" />
<label class="form-check-label" for="tcProbeEnabled">Probe enabled</label>
</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcProbeInterval">Probe interval (s)</label>
<InputNumber id="tcProbeInterval" @bind-Value="_form.ProbeIntervalSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcProbeTimeout">Probe timeout (s)</label>
<InputNumber id="tcProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcAdminProbe">Admin probe timeout (s)</label>
<InputNumber id="tcAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
<TwinCATDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
@* Devices *@
<CollectionEditor TRow="TwinCATDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
AnimationDelay=".11s"
NewRow="@(() => new TwinCATDeviceRow())" Clone="@(r => r.Clone())"
Validate="TwinCATDeviceRow.ValidateRow">
<HeaderTemplate>
<tr><th>Host address</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address (AMS Net Id:port)</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="192.168.0.1.1.1:851" /></div>
<div class="col-md-6"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
</EditTemplate>
</CollectionEditor>
@* Tags *@
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
@@ -164,8 +78,6 @@ else
Pre-declared driver tags have been retired.
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@@ -178,18 +90,13 @@ else
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,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private string _driverConfigJson = "{}";
private string? _resilienceConfig;
private byte[] _rowVersion = [];
private DriverInstance? _existing;
private bool _loaded, _busy;
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
@@ -198,17 +105,20 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices is a collection — edited via the CollectionEditor modal.
private List<TwinCATDeviceRow> _devices = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
_form = FormModel.FromOptions(new TwinCATDriverOptions());
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_driverConfigJson = "{}";
}
else
{
@@ -223,11 +133,9 @@ else
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new TwinCATDriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(TwinCATDeviceRow.FromDefinition).ToList();
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
@@ -238,10 +146,6 @@ else
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -256,8 +160,8 @@ else
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig,
});
}
else
@@ -265,11 +169,11 @@ 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;
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _resilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -292,7 +196,7 @@ else
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.Entry(entity).Property(e => e.RowVersion).OriginalValue = _rowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
@@ -307,104 +211,4 @@ else
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static TwinCATDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<TwinCATDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Mutable VM for the modal editor — TwinCATDeviceOptions is an immutable record.
public sealed class TwinCATDeviceRow
{
public string HostAddress { get; set; } = "";
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves any fields the editor doesn't
// expose across a load→save.
private TwinCATDeviceOptions? _source;
public TwinCATDeviceRow Clone() => (TwinCATDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static TwinCATDeviceRow FromDefinition(TwinCATDeviceOptions d) => new()
{
HostAddress = d.HostAddress, DeviceName = d.DeviceName,
_source = d,
};
public TwinCATDeviceOptions ToDefinition()
{
var baseDef = _source ?? new TwinCATDeviceOptions(HostAddress.Trim());
return baseDef with
{
HostAddress = HostAddress.Trim(),
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(TwinCATDeviceRow row, IReadOnlyList<TwinCATDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// The Devices collection is kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Options
public int TimeoutSeconds { get; set; } = 2;
public bool UseNativeNotifications { get; set; } = true;
public bool EnableControllerBrowse { get; set; } = false;
public int NotificationMaxDelayMs { get; set; } = 0;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public int AdminProbeTimeoutSeconds { get; set; } = 10;
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(TwinCATDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
UseNativeNotifications = o.UseNativeNotifications,
EnableControllerBrowse = o.EnableControllerBrowse,
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public TwinCATDriverOptions ToOptions(
IReadOnlyList<TwinCATDeviceOptions> devices) => new()
{
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
UseNativeNotifications = UseNativeNotifications,
EnableControllerBrowse = EnableControllerBrowse,
NotificationMaxDelayMs = NotificationMaxDelayMs,
Probe = new TwinCATProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
Devices = devices,
};
}
}