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:
+18
-211
@@ -5,11 +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.AbLegacy
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@@ -31,7 +30,7 @@ else if (!IsNew && _existing is null)
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="ablegacyDriverEdit">
|
||||
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="ablegacyDriverEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
||||
@@ -45,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
|
||||
@@ -62,84 +59,9 @@ else
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Operation settings *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Operation settings</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.TimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default read/write timeout. Default 2 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<AbLegacyDriverForm @bind-DriverConfigJson="_driverConfigJson"
|
||||
@bind-ResilienceConfig="_resilienceConfig" />
|
||||
|
||||
@* Connectivity 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 g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.ProbeEnabled" class="form-check-input" id="probeEnabled" />
|
||||
<label class="form-check-label" for="probeEnabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Interval (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Probe address</label>
|
||||
<InputText @bind-Value="_form.ProbeAddress" class="form-control form-control-sm mono"
|
||||
placeholder="S:0" />
|
||||
<div class="form-text">PCCC file address to read for probe. Default S:0 (status file word 0).</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Devices *@
|
||||
<CollectionEditor TRow="AbLegacyDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
|
||||
AnimationDelay=".10s"
|
||||
NewRow="@(() => new AbLegacyDeviceRow())" Clone="@(r => r.Clone())"
|
||||
Validate="AbLegacyDeviceRow.ValidateRow">
|
||||
<HeaderTemplate>
|
||||
<tr><th>Host address</th><th>PLC family</th><th>Device name</th><th></th></tr>
|
||||
</HeaderTemplate>
|
||||
<RowTemplate Context="d">
|
||||
<td class="mono">@d.HostAddress</td><td>@d.PlcFamily</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</label>
|
||||
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
|
||||
placeholder="10.0.0.10" /></div>
|
||||
<div class="col-md-3"><label class="form-label">PLC family</label>
|
||||
<select class="form-select form-select-sm" @bind="d.PlcFamily">
|
||||
@foreach (var e in Enum.GetValues<AbLegacyPlcFamily>()) { <option value="@e">@e</option> }
|
||||
</select></div>
|
||||
<div class="col-md-3"><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">
|
||||
@@ -147,8 +69,6 @@ else
|
||||
Pre-declared driver tags have been retired.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
@@ -161,16 +81,10 @@ 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;
|
||||
private bool _busy;
|
||||
@@ -182,9 +96,6 @@ else
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
|
||||
private List<AbLegacyDeviceRow> _devices = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
@@ -198,7 +109,7 @@ else
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = true,
|
||||
};
|
||||
_form = new FormModel();
|
||||
_driverConfigJson = "{}";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -213,11 +124,9 @@ else
|
||||
DriverType = _existing.DriverType,
|
||||
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.Select(AbLegacyDeviceRow.FromDefinition).ToList();
|
||||
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
|
||||
_resilienceConfig = _existing.ResilienceConfig;
|
||||
_rowVersion = _existing.RowVersion;
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
@@ -228,10 +137,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)
|
||||
{
|
||||
@@ -247,8 +152,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
|
||||
@@ -256,11 +161,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");
|
||||
@@ -283,7 +188,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");
|
||||
@@ -298,102 +203,4 @@ else
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
||||
_jsonOpts);
|
||||
|
||||
private static AbLegacyDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Mutable VM for the modal editor — AbLegacyDeviceOptions is an immutable record.
|
||||
public sealed class AbLegacyDeviceRow
|
||||
{
|
||||
public string HostAddress { get; set; } = "";
|
||||
public AbLegacyPlcFamily PlcFamily { get; set; } = AbLegacyPlcFamily.Slc500;
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
|
||||
// across a load→save.
|
||||
private AbLegacyDeviceOptions? _source;
|
||||
|
||||
public AbLegacyDeviceRow Clone() => (AbLegacyDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
||||
|
||||
public static AbLegacyDeviceRow FromDefinition(AbLegacyDeviceOptions d) => new()
|
||||
{
|
||||
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
|
||||
_source = d,
|
||||
};
|
||||
|
||||
public AbLegacyDeviceOptions ToDefinition()
|
||||
{
|
||||
var baseDef = _source ?? new AbLegacyDeviceOptions(HostAddress.Trim(), PlcFamily);
|
||||
return baseDef with
|
||||
{
|
||||
HostAddress = HostAddress.Trim(),
|
||||
PlcFamily = PlcFamily,
|
||||
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
public static string? ValidateRow(AbLegacyDeviceRow row, IReadOnlyList<AbLegacyDeviceRow> 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.
|
||||
// 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<AbLegacyDeviceOptions> devices) => new()
|
||||
{
|
||||
Devices = devices,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user