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:
+19
-259
@@ -5,6 +5,7 @@
|
||||
@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
|
||||
@@ -30,7 +31,7 @@ else if (!IsNew && _existing is null)
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="abcipDriverEdit">
|
||||
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="abcipDriverEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
||||
@@ -44,9 +45,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,122 +58,12 @@ else
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<AbCipAddressPickerBody CurrentAddress="@_pickedAddress"
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
||||
GetConfigJson="@SerializeCurrentConfig" />
|
||||
GetConfigJson="@(() => _driverConfigJson)" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Operation timeout *@
|
||||
<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 libplctag call timeout. Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.EnableControllerBrowse" class="form-check-input" id="enableBrowse" />
|
||||
<label class="form-check-label" for="enableBrowse">Enable controller browse</label>
|
||||
</div>
|
||||
<div class="form-text">Walk the Logix symbol table and surface globals under Discovered/.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.EnableDeclarationOnlyUdtGrouping" class="form-check-input" id="enableUdtGroup" />
|
||||
<label class="form-check-label" for="enableUdtGroup">Enable declaration-only UDT grouping</label>
|
||||
</div>
|
||||
<div class="form-text">Only enable when UDT member declaration order matches controller compiled layout.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<AbCipDriverForm @bind-DriverConfigJson="_driverConfigJson"
|
||||
@bind-ResilienceConfig="_resilienceConfig" />
|
||||
|
||||
@* Alarm projection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Alarm projection</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.EnableAlarmProjection" class="form-check-input" id="enableAlarm" />
|
||||
<label class="form-check-label" for="enableAlarm">Enable ALMD alarm projection</label>
|
||||
</div>
|
||||
<div class="form-text">Surfaces ALMD tags as alarm conditions via IAlarmSource. Default off.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Alarm poll interval (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.AlarmPollIntervalSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 1 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Connectivity probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<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 tag path</label>
|
||||
<InputText @bind-Value="_form.ProbeTagPath" class="form-control form-control-sm mono"
|
||||
placeholder="e.g. Program:Main.SomeTag" />
|
||||
<div class="form-text">Required when probe is enabled. Leave blank and an operator warning is logged.</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="AbCipDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
|
||||
AnimationDelay=".12s"
|
||||
NewRow="@(() => new AbCipDeviceRow())" Clone="@(r => r.Clone())"
|
||||
Validate="AbCipDeviceRow.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="ab://gateway/1,0" /></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<AbCipPlcFamily>()) { <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">
|
||||
@@ -182,8 +71,6 @@ else
|
||||
Pre-declared driver tags have been retired.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
@@ -196,16 +83,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;
|
||||
@@ -217,9 +98,6 @@ else
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
// Held separately because Devices is a collection — edited via the CollectionEditor modal.
|
||||
private List<AbCipDeviceRow> _devices = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
@@ -233,7 +111,7 @@ else
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = true,
|
||||
};
|
||||
_form = new FormModel();
|
||||
_driverConfigJson = "{}";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -248,11 +126,9 @@ else
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new AbCipDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
_devices = opts.Devices.Select(AbCipDeviceRow.FromDefinition).ToList();
|
||||
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
|
||||
_resilienceConfig = _existing.ResilienceConfig;
|
||||
_rowVersion = _existing.RowVersion;
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
@@ -263,10 +139,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)
|
||||
{
|
||||
@@ -282,8 +154,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
|
||||
@@ -291,11 +163,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");
|
||||
@@ -318,7 +190,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");
|
||||
@@ -333,116 +205,4 @@ else
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
||||
_jsonOpts);
|
||||
|
||||
private static AbCipDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Mutable VM for the modal editor — AbCipDeviceOptions is an immutable record.
|
||||
public sealed class AbCipDeviceRow
|
||||
{
|
||||
public string HostAddress { get; set; } = "";
|
||||
public AbCipPlcFamily PlcFamily { get; set; } = AbCipPlcFamily.ControlLogix;
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
|
||||
// (AllowPacking, ConnectionSize) across a load→save.
|
||||
private AbCipDeviceOptions? _source;
|
||||
|
||||
public AbCipDeviceRow Clone() => (AbCipDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
||||
|
||||
public static AbCipDeviceRow FromDefinition(AbCipDeviceOptions d) => new()
|
||||
{
|
||||
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
|
||||
_source = d,
|
||||
};
|
||||
|
||||
public AbCipDeviceOptions ToDefinition()
|
||||
{
|
||||
var baseDef = _source ?? new AbCipDeviceOptions(HostAddress.Trim(), PlcFamily);
|
||||
return baseDef with
|
||||
{
|
||||
HostAddress = HostAddress.Trim(),
|
||||
PlcFamily = PlcFamily,
|
||||
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
public static string? ValidateRow(AbCipDeviceRow row, IReadOnlyList<AbCipDeviceRow> 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) are kept on the component and passed in on ToOptions().
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Operation
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
public bool EnableControllerBrowse { get; set; } = false;
|
||||
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
|
||||
|
||||
// Alarm projection
|
||||
public bool EnableAlarmProjection { get; set; } = false;
|
||||
public int AlarmPollIntervalSeconds { get; set; } = 1;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public string? ProbeTagPath { get; set; }
|
||||
|
||||
// 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(AbCipDriverOptions o) => new()
|
||||
{
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
EnableControllerBrowse = o.EnableControllerBrowse,
|
||||
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
|
||||
EnableAlarmProjection = o.EnableAlarmProjection,
|
||||
AlarmPollIntervalSeconds = (int)o.AlarmPollInterval.TotalSeconds,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
ProbeTagPath = o.Probe.ProbeTagPath,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public AbCipDriverOptions ToOptions(
|
||||
IReadOnlyList<AbCipDeviceOptions> devices) => new()
|
||||
{
|
||||
Devices = devices,
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
|
||||
},
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
EnableControllerBrowse = EnableControllerBrowse,
|
||||
EnableAlarmProjection = EnableAlarmProjection,
|
||||
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
|
||||
EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping,
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+30
-323
@@ -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.FOCAS
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@@ -30,7 +30,7 @@ else if (!IsNew && _existing is null)
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="focasDriverEdit">
|
||||
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="focasDriverEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
|
||||
CancelHref="@($"/clusters/{ClusterId}/drivers")"
|
||||
@@ -45,8 +45,7 @@ else
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<DriverTestConnectButton DriverType="@DriverTypeKey"
|
||||
GetConfigJson="@SerializeCurrentConfig"
|
||||
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
|
||||
GetConfigJson="@(() => _driverConfigJson)" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
||||
@onclick="@(() => _showPicker = true)">
|
||||
Pick address
|
||||
@@ -59,163 +58,11 @@ else
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<FOCASAddressPickerBody CurrentAddress="@_pickedAddress"
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
||||
GetConfigJson="@SerializeCurrentConfig" />
|
||||
GetConfigJson="@(() => _driverConfigJson)" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Connection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.05s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasTimeoutSec">Timeout (seconds)</label>
|
||||
<InputNumber id="focasTimeoutSec" @bind-Value="_form.TimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Per-operation timeout. Default 2 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</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="focasProbeEnabled" @bind-Value="_form.ProbeEnabled"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasProbeEnabled">Probe enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasProbeInterval">Probe interval (s)</label>
|
||||
<InputNumber id="focasProbeInterval" @bind-Value="_form.ProbeIntervalSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasProbeTimeout">Probe timeout (s)</label>
|
||||
<InputNumber id="focasProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasAdminProbe">Admin probe timeout (s)</label>
|
||||
<InputNumber id="focasAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Test Connect timeout (1–60 s).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Alarm projection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.11s">
|
||||
<div class="panel-head">Alarm projection</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="focasAlarmEnabled" @bind-Value="_form.AlarmProjectionEnabled"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasAlarmEnabled">Alarm projection enabled</label>
|
||||
</div>
|
||||
<div class="form-text">Surfaces FOCAS alarms via IAlarmSource.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasAlarmPoll">Alarm poll interval (s)</label>
|
||||
<InputNumber id="focasAlarmPoll" @bind-Value="_form.AlarmProjectionPollIntervalSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">One cnc_rdalmmsg2 call per device per tick. Default 2 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Handle recycle *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Handle recycle</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="focasRecycleEnabled" @bind-Value="_form.HandleRecycleEnabled"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasRecycleEnabled">Handle recycle enabled</label>
|
||||
</div>
|
||||
<div class="form-text">Proactive FWLIB session recycle to prevent handle pool exhaustion. Default off.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasRecycleInterval">Recycle interval (minutes)</label>
|
||||
<InputNumber id="focasRecycleInterval" @bind-Value="_form.HandleRecycleIntervalMinutes"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Typical: 30 min (shared pool) or 360 min (single client).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Fixed tree *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.17s">
|
||||
<div class="panel-head">Fixed-node tree</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="focasFixedTree" @bind-Value="_form.FixedTreeEnabled"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="focasFixedTree">Fixed tree enabled</label>
|
||||
</div>
|
||||
<div class="form-text">Exposes Identity/, Axes/, etc. from cnc_sysinfo/cnc_rdaxisname/cnc_rddynamic2. Default off.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasAxisPoll">Axis poll interval (ms)</label>
|
||||
<InputNumber id="focasAxisPoll" @bind-Value="_form.FixedTreePollIntervalMs"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">cnc_rddynamic2 cadence per axis. Default 250 ms.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasProgramPoll">Program poll interval (s)</label>
|
||||
<InputNumber id="focasProgramPoll" @bind-Value="_form.FixedTreeProgramPollIntervalSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Program/mode info cadence. 0 = disabled. Default 1 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="focasTimerPoll">Timer poll interval (s)</label>
|
||||
<InputNumber id="focasTimerPoll" @bind-Value="_form.FixedTreeTimerPollIntervalSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Power-on/cutting/cycle timer cadence. 0 = disabled. Default 30 s.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Devices *@
|
||||
<CollectionEditor TRow="FocasDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
|
||||
AnimationDelay=".20s"
|
||||
NewRow="@(() => new FocasDeviceRow())" Clone="@(r => r.Clone())"
|
||||
Validate="FocasDeviceRow.ValidateRow">
|
||||
<HeaderTemplate>
|
||||
<tr><th>Host address</th><th>CNC series</th><th>Device name</th><th></th></tr>
|
||||
</HeaderTemplate>
|
||||
<RowTemplate Context="d">
|
||||
<td class="mono">@d.HostAddress</td><td>@d.Series</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="192.168.0.10:8193" /></div>
|
||||
<div class="col-md-3"><label class="form-label">CNC series</label>
|
||||
<select class="form-select form-select-sm" @bind="d.Series">
|
||||
@foreach (var e in Enum.GetValues<FocasCncSeries>()) { <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>
|
||||
<FocasDriverForm @bind-DriverConfigJson="_driverConfigJson"
|
||||
@bind-ResilienceConfig="_resilienceConfig" />
|
||||
|
||||
@* Tags *@
|
||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
||||
@@ -225,8 +72,6 @@ else
|
||||
Pre-declared driver tags have been retired.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
@@ -239,18 +84,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
|
||||
@@ -259,17 +99,20 @@ else
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
|
||||
private List<FocasDeviceRow> _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 FocasDriverOptions());
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = "",
|
||||
Name = "",
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = true,
|
||||
};
|
||||
_driverConfigJson = "{}";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -284,11 +127,9 @@ else
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new FocasDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
_devices = opts.Devices.Select(FocasDeviceRow.FromDefinition).ToList();
|
||||
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
|
||||
_resilienceConfig = _existing.ResilienceConfig;
|
||||
_rowVersion = _existing.RowVersion;
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
@@ -299,15 +140,13 @@ else
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var opts = _form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList());
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
|
||||
{
|
||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
|
||||
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists.";
|
||||
return;
|
||||
}
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
@@ -316,8 +155,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
|
||||
@@ -325,11 +164,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");
|
||||
@@ -352,7 +191,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");
|
||||
@@ -367,136 +206,4 @@ else
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(
|
||||
_devices.Select(r => r.ToDefinition()).ToList()),
|
||||
_jsonOpts);
|
||||
|
||||
private static FocasDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<FocasDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Mutable VM for the modal editor — FocasDeviceOptions is an immutable record.
|
||||
public sealed class FocasDeviceRow
|
||||
{
|
||||
public string HostAddress { get; set; } = "";
|
||||
public FocasCncSeries Series { get; set; } = FocasCncSeries.Unknown;
|
||||
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 FocasDeviceOptions? _source;
|
||||
|
||||
public FocasDeviceRow Clone() => (FocasDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
|
||||
|
||||
public static FocasDeviceRow FromDefinition(FocasDeviceOptions d) => new()
|
||||
{
|
||||
HostAddress = d.HostAddress, Series = d.Series, DeviceName = d.DeviceName,
|
||||
_source = d,
|
||||
};
|
||||
|
||||
public FocasDeviceOptions ToDefinition()
|
||||
{
|
||||
var baseDef = _source ?? new FocasDeviceOptions(HostAddress.Trim());
|
||||
return baseDef with
|
||||
{
|
||||
HostAddress = HostAddress.Trim(),
|
||||
Series = Series,
|
||||
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
public static string? ValidateRow(FocasDeviceRow row, IReadOnlyList<FocasDeviceRow> 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
|
||||
{
|
||||
// Connection
|
||||
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 int AdminProbeTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
// Alarm projection
|
||||
public bool AlarmProjectionEnabled { get; set; } = false;
|
||||
public int AlarmProjectionPollIntervalSeconds { get; set; } = 2;
|
||||
|
||||
// Handle recycle
|
||||
public bool HandleRecycleEnabled { get; set; } = false;
|
||||
public int HandleRecycleIntervalMinutes { get; set; } = 60;
|
||||
|
||||
// Fixed tree
|
||||
public bool FixedTreeEnabled { get; set; } = false;
|
||||
public int FixedTreePollIntervalMs { get; set; } = 250;
|
||||
public int FixedTreeProgramPollIntervalSeconds { get; set; } = 1;
|
||||
public int FixedTreeTimerPollIntervalSeconds { get; set; } = 30;
|
||||
|
||||
// Common
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
public static FormModel FromOptions(FocasDriverOptions o) => new()
|
||||
{
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
AlarmProjectionEnabled = o.AlarmProjection.Enabled,
|
||||
AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds,
|
||||
HandleRecycleEnabled = o.HandleRecycle.Enabled,
|
||||
HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes,
|
||||
FixedTreeEnabled = o.FixedTree.Enabled,
|
||||
FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds,
|
||||
FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds,
|
||||
FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds,
|
||||
};
|
||||
|
||||
public FocasDriverOptions ToOptions(
|
||||
IReadOnlyList<FocasDeviceOptions> devices) => new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
},
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
AlarmProjection = new FocasAlarmProjectionOptions
|
||||
{
|
||||
Enabled = AlarmProjectionEnabled,
|
||||
PollInterval = TimeSpan.FromSeconds(AlarmProjectionPollIntervalSeconds),
|
||||
},
|
||||
HandleRecycle = new FocasHandleRecycleOptions
|
||||
{
|
||||
Enabled = HandleRecycleEnabled,
|
||||
Interval = TimeSpan.FromMinutes(HandleRecycleIntervalMinutes),
|
||||
},
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = FixedTreeEnabled,
|
||||
PollInterval = TimeSpan.FromMilliseconds(FixedTreePollIntervalMs),
|
||||
ProgramPollInterval = TimeSpan.FromSeconds(FixedTreeProgramPollIntervalSeconds),
|
||||
TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds),
|
||||
},
|
||||
Devices = devices,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+19
-281
@@ -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.Galaxy.Config
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@@ -30,7 +30,7 @@ else if (!IsNew && _existing is null)
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="galaxyDriverEdit">
|
||||
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="galaxyDriverEdit">
|
||||
<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.Galaxy.ProbeTimeoutSeconds" />
|
||||
<DriverTestConnectButton DriverType="@DriverTypeKey" GetConfigJson="@(() => _driverConfigJson)" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
||||
@onclick="@(() => _showPicker = true)">
|
||||
Pick address
|
||||
@@ -59,147 +57,11 @@ else
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<GalaxyAddressPickerBody CurrentAddress="@_pickedAddress"
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
||||
GetConfigJson="@SerializeCurrentConfig" />
|
||||
GetConfigJson="@(() => _driverConfigJson)" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* mxaccessgw connection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">mxaccessgw connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Gateway endpoint</label>
|
||||
<InputText @bind-Value="_form.Galaxy.GatewayEndpoint" class="form-control form-control-sm mono"
|
||||
placeholder="https://localhost:5001" />
|
||||
<div class="form-text">gRPC endpoint of the mxaccessgw process.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">API key secret ref</label>
|
||||
<InputText @bind-Value="_form.Galaxy.GatewayApiKeySecretRef" class="form-control form-control-sm mono"
|
||||
placeholder="env:MX_API_KEY" />
|
||||
<div class="form-text">Forms: <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.Galaxy.GatewayUseTls" class="form-check-input" id="gwTls" />
|
||||
<label class="form-check-label" for="gwTls">Use TLS</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">CA certificate path (optional)</label>
|
||||
<InputText @bind-Value="_form.Galaxy.GatewayCaCertificatePath" class="form-control form-control-sm mono"
|
||||
placeholder="C:\certs\ca.pem" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Connect timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.GatewayConnectTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10 s.</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Call timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.GatewayDefaultCallTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 30 s.</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Stream timeout (s, 0 = unlimited)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.GatewayStreamTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 0 (lifetime of driver).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* MXAccess *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">MXAccess</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Client name</label>
|
||||
<InputText @bind-Value="_form.Galaxy.MxClientName" class="form-control form-control-sm"
|
||||
placeholder="OtOpcUa-Primary" />
|
||||
<div class="form-text">Must be unique per OtOpcUa instance — redundancy pairs each get a distinct name.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Publishing interval (ms)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.MxPublishingIntervalMs" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 1000 ms.</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Write user ID</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.MxWriteUserId" class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = anonymous.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Event pump channel capacity</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.MxEventPumpChannelCapacity" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 50000. Raise if events.dropped appears.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Repository *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Galaxy repository</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Discover page size</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.RepositoryDiscoverPageSize" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5000 objects per page.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.Galaxy.RepositoryWatchDeployEvents" class="form-check-input" id="watchDeploy" />
|
||||
<label class="form-check-label" for="watchDeploy">Watch deploy events</label>
|
||||
</div>
|
||||
<div class="form-text mt-0">Triggers address-space rebuild on Galaxy re-deploy.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Reconnect *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.12s">
|
||||
<div class="panel-head">Reconnect backoff</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Initial backoff (ms)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.ReconnectInitialBackoffMs" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 500 ms.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max backoff (ms)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.ReconnectMaxBackoffMs" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 30000 ms.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.Galaxy.ReconnectReplayOnSessionLost" class="form-check-input" id="replayOnLost" />
|
||||
<label class="form-check-label" for="replayOnLost">Replay subscriptions on session lost</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Diagnostics *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Diagnostics</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.Galaxy.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 30.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson"
|
||||
@bind-ResilienceConfig="_resilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
@@ -212,17 +74,10 @@ else
|
||||
|
||||
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
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;
|
||||
@@ -247,7 +102,7 @@ else
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = true,
|
||||
};
|
||||
_form = new FormModel();
|
||||
_driverConfigJson = "{}";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -262,29 +117,19 @@ else
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? CreateDefaultOptions();
|
||||
_form = new FormModel();
|
||||
_form.Galaxy = GalaxyFormModel.FromRecord(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
|
||||
_resilienceConfig = _existing.ResilienceConfig;
|
||||
_rowVersion = _existing.RowVersion;
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private static GalaxyDriverOptions CreateDefaultOptions() => new(
|
||||
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
|
||||
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
|
||||
Repository: new GalaxyRepositoryOptions(),
|
||||
Reconnect: new GalaxyReconnectOptions());
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var opts = _form.Galaxy.ToRecord();
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
@@ -299,8 +144,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
|
||||
@@ -308,11 +153,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");
|
||||
@@ -335,7 +180,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");
|
||||
@@ -350,111 +195,4 @@ else
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(_form.Galaxy.ToRecord(), _jsonOpts);
|
||||
|
||||
private static GalaxyDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public sealed class FormModel
|
||||
{
|
||||
public GalaxyFormModel Galaxy { get; set; } = new();
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutable flat mirror of <see cref="GalaxyDriverOptions"/> and its nested records.
|
||||
/// All positional-record fields are flattened with a section prefix to avoid name
|
||||
/// collisions. <see cref="FromRecord"/> / <see cref="ToRecord"/> handle translation.
|
||||
/// </summary>
|
||||
public sealed class GalaxyFormModel
|
||||
{
|
||||
// GalaxyGatewayOptions
|
||||
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
|
||||
public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
|
||||
public bool GatewayUseTls { get; set; } = true;
|
||||
public string? GatewayCaCertificatePath { get; set; }
|
||||
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
|
||||
public int GatewayDefaultCallTimeoutSeconds { get; set; } = 30;
|
||||
public int GatewayStreamTimeoutSeconds { get; set; } = 0;
|
||||
|
||||
// GalaxyMxAccessOptions
|
||||
public string MxClientName { get; set; } = "OtOpcUa";
|
||||
public int MxPublishingIntervalMs { get; set; } = 1000;
|
||||
public int MxWriteUserId { get; set; } = 0;
|
||||
public int MxEventPumpChannelCapacity { get; set; } = 50_000;
|
||||
|
||||
// GalaxyRepositoryOptions
|
||||
public int RepositoryDiscoverPageSize { get; set; } = 5000;
|
||||
public bool RepositoryWatchDeployEvents { get; set; } = true;
|
||||
|
||||
// GalaxyReconnectOptions
|
||||
public int ReconnectInitialBackoffMs { get; set; } = 500;
|
||||
public int ReconnectMaxBackoffMs { get; set; } = 30_000;
|
||||
public bool ReconnectReplayOnSessionLost { get; set; } = true;
|
||||
|
||||
// GalaxyDriverOptions top-level
|
||||
public int ProbeTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
|
||||
{
|
||||
// Null-coalesce each nested record to its default so that persisted configs
|
||||
// that pre-date a section (e.g. no Reconnect key, or PascalCase keys that
|
||||
// don't match the camelCase deserializer) don't cause a NullReferenceException.
|
||||
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
|
||||
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
|
||||
var repo = r.Repository ?? new GalaxyRepositoryOptions();
|
||||
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
|
||||
return new()
|
||||
{
|
||||
GatewayEndpoint = gw.Endpoint,
|
||||
GatewayApiKeySecretRef = gw.ApiKeySecretRef,
|
||||
GatewayUseTls = gw.UseTls,
|
||||
GatewayCaCertificatePath = gw.CaCertificatePath,
|
||||
GatewayConnectTimeoutSeconds = gw.ConnectTimeoutSeconds,
|
||||
GatewayDefaultCallTimeoutSeconds = gw.DefaultCallTimeoutSeconds,
|
||||
GatewayStreamTimeoutSeconds = gw.StreamTimeoutSeconds,
|
||||
MxClientName = mx.ClientName,
|
||||
MxPublishingIntervalMs = mx.PublishingIntervalMs,
|
||||
MxWriteUserId = mx.WriteUserId,
|
||||
MxEventPumpChannelCapacity = mx.EventPumpChannelCapacity,
|
||||
RepositoryDiscoverPageSize = repo.DiscoverPageSize,
|
||||
RepositoryWatchDeployEvents = repo.WatchDeployEvents,
|
||||
ReconnectInitialBackoffMs = rc.InitialBackoffMs,
|
||||
ReconnectMaxBackoffMs = rc.MaxBackoffMs,
|
||||
ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost,
|
||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
public GalaxyDriverOptions ToRecord() => new(
|
||||
Gateway: new GalaxyGatewayOptions(
|
||||
Endpoint: GatewayEndpoint,
|
||||
ApiKeySecretRef: GatewayApiKeySecretRef,
|
||||
UseTls: GatewayUseTls,
|
||||
CaCertificatePath: string.IsNullOrWhiteSpace(GatewayCaCertificatePath) ? null : GatewayCaCertificatePath,
|
||||
ConnectTimeoutSeconds: GatewayConnectTimeoutSeconds,
|
||||
DefaultCallTimeoutSeconds: GatewayDefaultCallTimeoutSeconds,
|
||||
StreamTimeoutSeconds: GatewayStreamTimeoutSeconds),
|
||||
MxAccess: new GalaxyMxAccessOptions(
|
||||
ClientName: MxClientName,
|
||||
PublishingIntervalMs: MxPublishingIntervalMs,
|
||||
WriteUserId: MxWriteUserId,
|
||||
EventPumpChannelCapacity: MxEventPumpChannelCapacity),
|
||||
Repository: new GalaxyRepositoryOptions(
|
||||
DiscoverPageSize: RepositoryDiscoverPageSize,
|
||||
WatchDeployEvents: RepositoryWatchDeployEvents),
|
||||
Reconnect: new GalaxyReconnectOptions(
|
||||
InitialBackoffMs: ReconnectInitialBackoffMs,
|
||||
MaxBackoffMs: ReconnectMaxBackoffMs,
|
||||
ReplayOnSessionLost: ReconnectReplayOnSessionLost))
|
||||
{
|
||||
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+23
-381
@@ -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.Modbus
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@@ -30,7 +30,7 @@ else if (!IsNew && _existing is null)
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="modbustcpDriverEdit">
|
||||
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="modbustcpDriverEdit">
|
||||
<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
|
||||
@@ -61,217 +59,16 @@ else
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Transport *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Transport</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Host</label>
|
||||
<InputText @bind-Value="_form.Host" class="form-control form-control-sm" placeholder="127.0.0.1" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Port</label>
|
||||
<InputNumber @bind-Value="_form.Port" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Unit ID (slave ID)</label>
|
||||
<InputNumber @bind-Value="_form.UnitId" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<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 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">PLC family</label>
|
||||
<InputSelect @bind-Value="_form.Family" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<ModbusFamily>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">MELSEC sub-family</label>
|
||||
<InputSelect @bind-Value="_form.MelsecSubFamily" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<MelsecFamily>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<div class="form-text">Only used when Family = MELSEC.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.AutoReconnect" class="form-check-input" id="autoReconnect" />
|
||||
<label class="form-check-label" for="autoReconnect">Auto-reconnect</label>
|
||||
</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">
|
||||
Host / Port / Unit ID now live on the driver's <strong>device</strong> (v3 endpoint→DeviceConfig split).
|
||||
Author them from the Raw tree (<strong>/raw</strong>) device modal.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Batch sizes *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Batch sizes</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max registers per read</label>
|
||||
<InputNumber @bind-Value="_form.MaxRegistersPerRead" class="form-control form-control-sm" />
|
||||
<div class="form-text">Spec max 125. Reduce for limited devices.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max registers per write</label>
|
||||
<InputNumber @bind-Value="_form.MaxRegistersPerWrite" class="form-control form-control-sm" />
|
||||
<div class="form-text">Spec max 123.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max coils per read</label>
|
||||
<InputNumber @bind-Value="_form.MaxCoilsPerRead" class="form-control form-control-sm" />
|
||||
<div class="form-text">Spec max 2000.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max read gap (coalescing)</label>
|
||||
<InputNumber @bind-Value="_form.MaxReadGap" class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = no coalescing. Typical 5–32.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Write options *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Write options</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.UseFC15ForSingleCoilWrites" class="form-check-input" id="useFC15" />
|
||||
<label class="form-check-label" for="useFC15">Use FC15 for single coil writes</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.UseFC16ForSingleRegisterWrites" class="form-check-input" id="useFC16" />
|
||||
<label class="form-check-label" for="useFC16">Use FC16 for single register writes</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.DisableFC23" class="form-check-input" id="disableFC23" />
|
||||
<label class="form-check-label" for="disableFC23">Disable FC23 (reserved — no effect today)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.WriteOnChangeOnly" class="form-check-input" id="writeOnChangeOnly" />
|
||||
<label class="form-check-label" for="writeOnChangeOnly">Write on change only</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Keep-alive *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.12s">
|
||||
<div class="panel-head">TCP keep-alive</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.KeepAliveEnabled" class="form-check-input" id="kaEnabled" />
|
||||
<label class="form-check-label" for="kaEnabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Time (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.KeepAliveTimeSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Idle time before first probe. Default 30 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Interval (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.KeepAliveIntervalSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Between probes once started. Default 10 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Retry count</label>
|
||||
<InputNumber @bind-Value="_form.KeepAliveRetryCount" class="form-control form-control-sm" />
|
||||
<div class="form-text">Probes before declaring socket dead. Default 3.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Reconnect backoff *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Reconnect backoff</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Initial delay (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ReconnectInitialDelaySeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = immediate retry.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max delay (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.ReconnectMaxDelaySeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 30 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Backoff multiplier</label>
|
||||
<InputNumber @bind-Value="_form.ReconnectBackoffMultiplier" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 2.0 (doubles each step).</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Idle disconnect (seconds, 0 = off)</label>
|
||||
<InputNumber @bind-Value="_form.IdleDisconnectTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Proactive close after idle period. 0 = disabled.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Probe *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.16s">
|
||||
<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">Probe 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">Probe 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 register address</label>
|
||||
<InputNumber @bind-Value="_form.ProbeAddress" class="form-control form-control-sm" />
|
||||
<div class="form-text">Zero-based. Default 0 (FC03 at register 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 class="col-md-3">
|
||||
<label class="form-label">Auto-prohibit re-probe interval (seconds, 0 = off)</label>
|
||||
<InputNumber @bind-Value="_form.AutoProhibitReprobeIntervalSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Interval to retry auto-prohibited coalesced ranges.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<ModbusDriverForm @bind-DriverConfigJson="_driverConfigJson"
|
||||
@bind-ResilienceConfig="_resilienceConfig" />
|
||||
|
||||
<section class="panel notice rise mt-3" style="animation-delay:.18s">
|
||||
<div class="panel-head">Tags</div>
|
||||
@@ -280,8 +77,6 @@ else
|
||||
Pre-declared driver tags have been retired.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
@@ -294,16 +89,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;
|
||||
@@ -328,7 +117,7 @@ else
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = true,
|
||||
};
|
||||
_form = new FormModel();
|
||||
_driverConfigJson = "{}";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -343,10 +132,9 @@ else
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new ModbusDriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
|
||||
_resilienceConfig = _existing.ResilienceConfig;
|
||||
_rowVersion = _existing.RowVersion;
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
@@ -357,7 +145,6 @@ else
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
@@ -373,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
|
||||
@@ -382,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");
|
||||
@@ -409,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");
|
||||
@@ -424,149 +211,4 @@ else
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
|
||||
|
||||
private static ModbusDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<ModbusDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalars exposed as settable properties so Blazor @bind-Value works.
|
||||
// Collection (Tags) is kept on the component (_tags) and passed in when building the final Options.
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Transport
|
||||
public string Host { get; set; } = "127.0.0.1";
|
||||
public int Port { get; set; } = 502;
|
||||
public int UnitId { get; set; } = 1;
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
|
||||
// Family
|
||||
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
||||
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
|
||||
|
||||
// Transport flags
|
||||
public bool AutoReconnect { get; set; } = true;
|
||||
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
|
||||
|
||||
// Batch sizes (using int; clamped to ushort on ToOptions)
|
||||
public int MaxRegistersPerRead { get; set; } = 125;
|
||||
public int MaxRegistersPerWrite { get; set; } = 123;
|
||||
public int MaxCoilsPerRead { get; set; } = 2000;
|
||||
public int MaxReadGap { get; set; } = 0;
|
||||
|
||||
// Write options
|
||||
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
|
||||
public bool UseFC16ForSingleRegisterWrites { get; set; } = false;
|
||||
public bool DisableFC23 { get; set; } = false;
|
||||
public bool WriteOnChangeOnly { get; set; } = false;
|
||||
|
||||
// Keep-alive
|
||||
public bool KeepAliveEnabled { get; set; } = true;
|
||||
public int KeepAliveTimeSeconds { get; set; } = 30;
|
||||
public int KeepAliveIntervalSeconds { get; set; } = 10;
|
||||
public int KeepAliveRetryCount { get; set; } = 3;
|
||||
|
||||
// Reconnect backoff
|
||||
public int ReconnectInitialDelaySeconds { get; set; } = 0;
|
||||
public int ReconnectMaxDelaySeconds { get; set; } = 30;
|
||||
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public int ProbeAddress { get; set; } = 0;
|
||||
|
||||
// Auto-prohibit re-probe (0 = disabled)
|
||||
public int AutoProhibitReprobeIntervalSeconds { get; set; } = 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(ModbusDriverOptions o) => new()
|
||||
{
|
||||
Host = o.Host,
|
||||
Port = o.Port,
|
||||
UnitId = o.UnitId,
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
Family = o.Family,
|
||||
MelsecSubFamily = o.MelsecSubFamily,
|
||||
AutoReconnect = o.AutoReconnect,
|
||||
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
|
||||
MaxRegistersPerRead = o.MaxRegistersPerRead,
|
||||
MaxRegistersPerWrite = o.MaxRegistersPerWrite,
|
||||
MaxCoilsPerRead = o.MaxCoilsPerRead,
|
||||
MaxReadGap = o.MaxReadGap,
|
||||
UseFC15ForSingleCoilWrites = o.UseFC15ForSingleCoilWrites,
|
||||
UseFC16ForSingleRegisterWrites = o.UseFC16ForSingleRegisterWrites,
|
||||
DisableFC23 = o.DisableFC23,
|
||||
WriteOnChangeOnly = o.WriteOnChangeOnly,
|
||||
KeepAliveEnabled = o.KeepAlive.Enabled,
|
||||
KeepAliveTimeSeconds = (int)o.KeepAlive.Time.TotalSeconds,
|
||||
KeepAliveIntervalSeconds = (int)o.KeepAlive.Interval.TotalSeconds,
|
||||
KeepAliveRetryCount = o.KeepAlive.RetryCount,
|
||||
ReconnectInitialDelaySeconds = (int)o.Reconnect.InitialDelay.TotalSeconds,
|
||||
ReconnectMaxDelaySeconds = (int)o.Reconnect.MaxDelay.TotalSeconds,
|
||||
ReconnectBackoffMultiplier = o.Reconnect.BackoffMultiplier,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
ProbeAddress = o.Probe.ProbeAddress,
|
||||
AutoProhibitReprobeIntervalSeconds = o.AutoProhibitReprobeInterval.HasValue
|
||||
? (int)o.AutoProhibitReprobeInterval.Value.TotalSeconds : 0,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public ModbusDriverOptions ToOptions() => new()
|
||||
{
|
||||
Host = Host,
|
||||
Port = Port,
|
||||
UnitId = (byte)Math.Clamp(UnitId, 0, 255),
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
Probe = new ModbusProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
ProbeAddress = (ushort)Math.Clamp(ProbeAddress, 0, 65535),
|
||||
},
|
||||
MaxRegistersPerRead = (ushort)Math.Clamp(MaxRegistersPerRead, 0, 65535),
|
||||
MaxRegistersPerWrite = (ushort)Math.Clamp(MaxRegistersPerWrite, 0, 65535),
|
||||
MaxCoilsPerRead = (ushort)Math.Clamp(MaxCoilsPerRead, 0, 65535),
|
||||
UseFC15ForSingleCoilWrites = UseFC15ForSingleCoilWrites,
|
||||
UseFC16ForSingleRegisterWrites = UseFC16ForSingleRegisterWrites,
|
||||
DisableFC23 = DisableFC23,
|
||||
AutoProhibitReprobeInterval = AutoProhibitReprobeIntervalSeconds > 0
|
||||
? TimeSpan.FromSeconds(AutoProhibitReprobeIntervalSeconds) : null,
|
||||
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
|
||||
Family = Family,
|
||||
MelsecSubFamily = MelsecSubFamily,
|
||||
WriteOnChangeOnly = WriteOnChangeOnly,
|
||||
AutoReconnect = AutoReconnect,
|
||||
KeepAlive = new ModbusKeepAliveOptions
|
||||
{
|
||||
Enabled = KeepAliveEnabled,
|
||||
Time = TimeSpan.FromSeconds(KeepAliveTimeSeconds),
|
||||
Interval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
|
||||
RetryCount = KeepAliveRetryCount,
|
||||
},
|
||||
IdleDisconnectTimeout = IdleDisconnectTimeoutSeconds > 0
|
||||
? TimeSpan.FromSeconds(IdleDisconnectTimeoutSeconds) : null,
|
||||
Reconnect = new ModbusReconnectOptions
|
||||
{
|
||||
InitialDelay = TimeSpan.FromSeconds(ReconnectInitialDelaySeconds),
|
||||
MaxDelay = TimeSpan.FromSeconds(ReconnectMaxDelaySeconds),
|
||||
BackoffMultiplier = ReconnectBackoffMultiplier,
|
||||
},
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+24
-372
@@ -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.OpcUaClient
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@@ -30,7 +30,7 @@ else if (!IsNew && _existing is null)
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="opcuaclientDriverEdit">
|
||||
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="opcuaclientDriverEdit">
|
||||
<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.OpcUa.ProbeTimeoutSeconds" />
|
||||
<DriverTestConnectButton DriverType="@DriverTypeKey" GetConfigJson="@(() => _driverConfigJson)" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
||||
@onclick="@(() => _showPicker = true)">
|
||||
Pick address
|
||||
@@ -59,209 +57,19 @@ else
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<OpcUaClientAddressPickerBody CurrentAddress="@_pickedAddress"
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)"
|
||||
GetConfigJson="@SerializeCurrentConfig" />
|
||||
GetConfigJson="@(() => _driverConfigJson)" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Endpoint *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Endpoint</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Endpoint URL</label>
|
||||
<InputText @bind-Value="_form.OpcUa.EndpointUrl" class="form-control form-control-sm mono"
|
||||
placeholder="opc.tcp://plc.internal:4840" />
|
||||
<div class="form-text">Single-endpoint shortcut. When EndpointUrls list is non-empty, this field is ignored.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Browse root NodeId (blank = ObjectsFolder)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.BrowseRoot" class="form-control form-control-sm mono"
|
||||
placeholder="i=85" />
|
||||
<div class="form-text">Restrict mirroring to a sub-tree.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Application URI</label>
|
||||
<InputText @bind-Value="_form.OpcUa.ApplicationUri" class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Session name</label>
|
||||
<InputText @bind-Value="_form.OpcUa.SessionName" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.OpcUa.AutoAcceptCertificates" class="form-check-input" id="autoAcceptCerts" />
|
||||
<label class="form-check-label" for="autoAcceptCerts">Auto-accept certificates <span class="badge bg-warning text-dark">Dev only</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Per-endpoint connect timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.PerEndpointConnectTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 3 s — failover sweep budget.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Operation timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.TimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10 s — steady-state reads/writes.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Session timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.SessionTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 120 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Keep-alive interval (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.KeepAliveIntervalSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Reconnect period (s)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.ReconnectPeriodSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Initial delay after session drop. Default 5 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max discovered nodes</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.MaxDiscoveredNodes" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10000.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max browse depth</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.MaxBrowseDepth" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-12">
|
||||
<CollectionEditor TRow="EndpointUrlRow" Items="_endpoints"
|
||||
Title="Endpoint URLs" ItemNoun="endpoint" AnimationDelay=".07s"
|
||||
NewRow="@(() => new EndpointUrlRow())" Clone="@(r => r.Clone())"
|
||||
Validate="EndpointUrlRow.ValidateRow">
|
||||
<HeaderTemplate>
|
||||
<tr><th>Endpoint URL (failover list — first reachable wins)</th><th></th></tr>
|
||||
</HeaderTemplate>
|
||||
<RowTemplate Context="e">
|
||||
<td class="mono">@e.Url</td>
|
||||
</RowTemplate>
|
||||
<EditTemplate Context="e">
|
||||
<label class="form-label">Endpoint URL</label>
|
||||
<input class="form-control form-control-sm mono" @bind="e.Url"
|
||||
placeholder="opc.tcp://plc.internal:4840" />
|
||||
<div class="form-text">When this list is non-empty, the single Endpoint URL above is ignored.</div>
|
||||
</EditTemplate>
|
||||
</CollectionEditor>
|
||||
</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">
|
||||
The single Endpoint URL now lives on the driver's <strong>device</strong> (v3 endpoint→DeviceConfig split).
|
||||
Author it from the Raw tree (<strong>/raw</strong>) device modal. The failover EndpointUrls list below stays driver-level.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Security *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Security</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Security mode</label>
|
||||
<InputSelect @bind-Value="_form.OpcUa.SecurityMode" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<OpcUaSecurityMode>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Security policy</label>
|
||||
<InputSelect @bind-Value="_form.OpcUa.SecurityPolicy" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<OpcUaSecurityPolicy>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Authentication *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Authentication</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Auth type</label>
|
||||
<InputSelect @bind-Value="_form.OpcUa.AuthType" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<OpcUaAuthType>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
@if (_form.OpcUa.AuthType == OpcUaAuthType.Username)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Username</label>
|
||||
<InputText @bind-Value="_form.OpcUa.Username" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Password</label>
|
||||
<InputText @bind-Value="_form.OpcUa.Password" type="password" class="form-control form-control-sm" autocomplete="new-password" />
|
||||
</div>
|
||||
}
|
||||
@if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate)
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">User certificate path (PFX/PEM)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.UserCertificatePath" class="form-control form-control-sm mono"
|
||||
placeholder="C:\certs\user.pfx" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Certificate password (if PFX-locked)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.UserCertificatePassword" type="password" class="form-control form-control-sm" autocomplete="new-password" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Namespace mapping *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.12s">
|
||||
<div class="panel-head">Namespace mapping</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Target namespace kind</label>
|
||||
<InputSelect @bind-Value="_form.OpcUa.TargetNamespaceKind" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<OpcUaTargetNamespaceKind>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<div class="form-text">Equipment = raw data re-mapped to UNS. SystemPlatform = processed data; hierarchy preserved as-is.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">UNS mapping table (read-only — edit via raw JSON import)</label>
|
||||
<pre class="form-control form-control-sm mono" style="min-height:3rem;overflow:auto;white-space:pre-wrap;">@_unsMappingTableJson</pre>
|
||||
<div class="form-text">Keys = remote browse-path prefixes; values = UNS Area/Line/Name paths. Required when TargetNamespaceKind = Equipment.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Diagnostics *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Diagnostics</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.ProbeTimeoutSeconds" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 15.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
<OpcUaClientDriverForm @bind-DriverConfigJson="_driverConfigJson"
|
||||
@bind-ResilienceConfig="_resilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
@@ -274,16 +82,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;
|
||||
@@ -295,12 +97,6 @@ else
|
||||
|
||||
private void OnAddressPicked(string address) => _pickedAddress = address;
|
||||
|
||||
// Held separately because EndpointUrls is a collection — edited via the CollectionEditor modal.
|
||||
private List<EndpointUrlRow> _endpoints = [];
|
||||
|
||||
// Read-only JSON snippet for the UnsMappingTable, which has no list editor yet.
|
||||
private string _unsMappingTableJson = "{}";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
@@ -314,7 +110,7 @@ else
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = true,
|
||||
};
|
||||
_form = new FormModel();
|
||||
_driverConfigJson = "{}";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -329,13 +125,9 @@ else
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new OpcUaClientDriverOptions();
|
||||
_form = new FormModel();
|
||||
_form.OpcUa = OpcUaClientFormModel.FromRecord(opts);
|
||||
_endpoints = opts.EndpointUrls.Select(EndpointUrlRow.FromUrl).ToList();
|
||||
_unsMappingTableJson = System.Text.Json.JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
|
||||
_resilienceConfig = _existing.ResilienceConfig;
|
||||
_rowVersion = _existing.RowVersion;
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
@@ -346,8 +138,6 @@ else
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var opts = _form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList());
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
@@ -362,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
|
||||
@@ -371,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");
|
||||
@@ -398,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");
|
||||
@@ -413,142 +203,4 @@ else
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts);
|
||||
|
||||
private static OpcUaClientDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public sealed class FormModel
|
||||
{
|
||||
public OpcUaClientFormModel OpcUa { get; set; } = new();
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutable VM for a single endpoint URL row. EndpointUrls is a plain
|
||||
/// <c>List<string></c> (a failover list) so the row is a thin wrapper the
|
||||
/// <see cref="CollectionEditor{TRow}"/> modal can bind to.
|
||||
/// </summary>
|
||||
public sealed class EndpointUrlRow
|
||||
{
|
||||
public string Url { get; set; } = "";
|
||||
public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone();
|
||||
public static EndpointUrlRow FromUrl(string u) => new() { Url = u };
|
||||
public string ToUrl() => Url.Trim();
|
||||
|
||||
public static string? ValidateRow(EndpointUrlRow row, IReadOnlyList<EndpointUrlRow> all, int? editIndex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Url)) return "URL is required.";
|
||||
if (!row.Url.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase))
|
||||
return "Endpoint URL must start with opc.tcp://";
|
||||
for (var i = 0; i < all.Count; i++)
|
||||
if (i != editIndex && string.Equals(all[i].Url.Trim(), row.Url.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
return $"Duplicate endpoint '{row.Url}'.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutable mirror of <see cref="OpcUaClientDriverOptions"/> with int wrappers for
|
||||
/// TimeSpan fields so Blazor InputNumber can bind them.
|
||||
/// EndpointUrls is edited via the CollectionEditor (held on the page as a row list and
|
||||
/// threaded into <see cref="ToRecord"/>); UnsMappingTable is shown as read-only JSON and
|
||||
/// survives round-trip via the original deserialized record, re-serialized unchanged.
|
||||
/// </summary>
|
||||
public sealed class OpcUaClientFormModel
|
||||
{
|
||||
// Connection
|
||||
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
|
||||
public string? BrowseRoot { get; set; }
|
||||
public string ApplicationUri { get; set; } = "urn:localhost:OtOpcUa:GatewayClient";
|
||||
public string SessionName { get; set; } = "OtOpcUa-Gateway";
|
||||
public bool AutoAcceptCertificates { get; set; } = false;
|
||||
public int PerEndpointConnectTimeoutSeconds { get; set; } = 3;
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
public int SessionTimeoutSeconds { get; set; } = 120;
|
||||
public int KeepAliveIntervalSeconds { get; set; } = 5;
|
||||
public int ReconnectPeriodSeconds { get; set; } = 5;
|
||||
public int MaxDiscoveredNodes { get; set; } = 10_000;
|
||||
public int MaxBrowseDepth { get; set; } = 10;
|
||||
|
||||
// Security
|
||||
public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None;
|
||||
public OpcUaSecurityPolicy SecurityPolicy { get; set; } = OpcUaSecurityPolicy.None;
|
||||
|
||||
// Authentication
|
||||
public OpcUaAuthType AuthType { get; set; } = OpcUaAuthType.Anonymous;
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string? UserCertificatePath { get; set; }
|
||||
public string? UserCertificatePassword { get; set; }
|
||||
|
||||
// Namespace mapping
|
||||
public OpcUaTargetNamespaceKind TargetNamespaceKind { get; set; } = OpcUaTargetNamespaceKind.Equipment;
|
||||
|
||||
// Diagnostics
|
||||
public int ProbeTimeoutSeconds { get; set; } = 15;
|
||||
|
||||
// Preserved read-only collection (round-tripped unchanged from original record)
|
||||
internal IReadOnlyDictionary<string, string> _unsMappingTable = new System.Collections.Generic.Dictionary<string, string>();
|
||||
|
||||
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
|
||||
{
|
||||
EndpointUrl = r.EndpointUrl,
|
||||
BrowseRoot = r.BrowseRoot,
|
||||
ApplicationUri = r.ApplicationUri,
|
||||
SessionName = r.SessionName,
|
||||
AutoAcceptCertificates = r.AutoAcceptCertificates,
|
||||
PerEndpointConnectTimeoutSeconds = (int)r.PerEndpointConnectTimeout.TotalSeconds,
|
||||
TimeoutSeconds = (int)r.Timeout.TotalSeconds,
|
||||
SessionTimeoutSeconds = (int)r.SessionTimeout.TotalSeconds,
|
||||
KeepAliveIntervalSeconds = (int)r.KeepAliveInterval.TotalSeconds,
|
||||
ReconnectPeriodSeconds = (int)r.ReconnectPeriod.TotalSeconds,
|
||||
MaxDiscoveredNodes = r.MaxDiscoveredNodes,
|
||||
MaxBrowseDepth = r.MaxBrowseDepth,
|
||||
SecurityMode = r.SecurityMode,
|
||||
SecurityPolicy = r.SecurityPolicy,
|
||||
AuthType = r.AuthType,
|
||||
Username = r.Username,
|
||||
Password = r.Password,
|
||||
UserCertificatePath = r.UserCertificatePath,
|
||||
UserCertificatePassword = r.UserCertificatePassword,
|
||||
TargetNamespaceKind = r.TargetNamespaceKind,
|
||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
||||
_unsMappingTable = r.UnsMappingTable,
|
||||
};
|
||||
|
||||
public OpcUaClientDriverOptions ToRecord(IReadOnlyList<string> endpointUrls) => new()
|
||||
{
|
||||
EndpointUrl = EndpointUrl,
|
||||
EndpointUrls = endpointUrls,
|
||||
BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot,
|
||||
ApplicationUri = ApplicationUri,
|
||||
SessionName = SessionName,
|
||||
AutoAcceptCertificates = AutoAcceptCertificates,
|
||||
PerEndpointConnectTimeout = TimeSpan.FromSeconds(PerEndpointConnectTimeoutSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
SessionTimeout = TimeSpan.FromSeconds(SessionTimeoutSeconds),
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
|
||||
ReconnectPeriod = TimeSpan.FromSeconds(ReconnectPeriodSeconds),
|
||||
MaxDiscoveredNodes = MaxDiscoveredNodes,
|
||||
MaxBrowseDepth = MaxBrowseDepth,
|
||||
SecurityMode = SecurityMode,
|
||||
SecurityPolicy = SecurityPolicy,
|
||||
AuthType = AuthType,
|
||||
Username = string.IsNullOrWhiteSpace(Username) ? null : Username,
|
||||
Password = string.IsNullOrWhiteSpace(Password) ? null : Password,
|
||||
UserCertificatePath = string.IsNullOrWhiteSpace(UserCertificatePath) ? null : UserCertificatePath,
|
||||
UserCertificatePassword = string.IsNullOrWhiteSpace(UserCertificatePassword) ? null : UserCertificatePassword,
|
||||
TargetNamespaceKind = TargetNamespaceKind,
|
||||
UnsMappingTable = _unsMappingTable,
|
||||
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+32
-175
@@ -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.S7
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@@ -30,7 +30,7 @@ else if (!IsNew && _existing is null)
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="s7DriverEdit">
|
||||
<EditForm Model="_identityModel" OnValidSubmit="SubmitAsync" FormName="s7DriverEdit">
|
||||
<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
|
||||
@@ -61,91 +59,17 @@ else
|
||||
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
|
||||
</DriverTagPicker>
|
||||
|
||||
@* Connection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.05s">
|
||||
<div class="panel-head">Connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="s7Host">Host</label>
|
||||
<InputText id="s7Host" @bind-Value="_form.Host"
|
||||
class="form-control form-control-sm mono"
|
||||
placeholder="192.168.0.1" />
|
||||
<div class="form-text">PLC IP address or hostname.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="s7Port">Port</label>
|
||||
<InputNumber id="s7Port" @bind-Value="_form.Port"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">ISO-on-TCP; usually 102.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="s7TimeoutSec">Timeout (seconds)</label>
|
||||
<InputNumber id="s7TimeoutSec" @bind-Value="_form.TimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="s7CpuType">CPU type</label>
|
||||
<InputSelect id="s7CpuType" @bind-Value="_form.CpuType"
|
||||
class="form-select form-select-sm">
|
||||
@foreach (var v in Enum.GetValues<S7CpuType>())
|
||||
{
|
||||
<option value="@v">@v</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<div class="form-text">Controls ISO-TSAP slot byte during handshake.</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label" for="s7Rack">Rack</label>
|
||||
<InputNumber id="s7Rack" @bind-Value="_form.Rack"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Almost always 0.</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label" for="s7Slot">Slot</label>
|
||||
<InputNumber id="s7Slot" @bind-Value="_form.Slot"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">S7-300/400 = 2; S7-1200/1500 = 0.</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">
|
||||
Host / Port / Rack / Slot now live on the driver's <strong>device</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="s7ProbeEnabled" @bind-Value="_form.ProbeEnabled"
|
||||
class="form-check-input" />
|
||||
<label class="form-check-label" for="s7ProbeEnabled">Probe enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="s7ProbeIntervalSec">Probe interval (s)</label>
|
||||
<InputNumber id="s7ProbeIntervalSec" @bind-Value="_form.ProbeIntervalSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="s7ProbeTimeoutSec">Probe timeout (s)</label>
|
||||
<InputNumber id="s7ProbeTimeoutSec" @bind-Value="_form.ProbeTimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="s7AdminProbeTimeout">Admin probe timeout (s)</label>
|
||||
<InputNumber id="s7AdminProbeTimeout" @bind-Value="_form.AdminProbeTimeoutSeconds"
|
||||
class="form-control form-control-sm" />
|
||||
<div class="form-text">Test Connect timeout (1–60 s).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<S7DriverForm @bind-DriverConfigJson="_driverConfigJson"
|
||||
@bind-ResilienceConfig="_resilienceConfig" />
|
||||
|
||||
@* 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">
|
||||
@@ -153,8 +77,6 @@ else
|
||||
Pre-declared driver tags have been retired.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
|
||||
</DriverFormShell>
|
||||
</EditForm>
|
||||
}
|
||||
@@ -167,18 +89,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
|
||||
@@ -193,8 +110,14 @@ else
|
||||
|
||||
if (IsNew)
|
||||
{
|
||||
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
|
||||
_form = FormModel.FromOptions(new S7DriverOptions());
|
||||
_identityModel = new()
|
||||
{
|
||||
DriverInstanceId = "",
|
||||
Name = "",
|
||||
DriverType = DriverTypeKey,
|
||||
Enabled = true,
|
||||
};
|
||||
_driverConfigJson = "{}";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -209,10 +132,9 @@ else
|
||||
DriverType = _existing.DriverType,
|
||||
Enabled = _existing.Enabled,
|
||||
};
|
||||
var opts = TryDeserialize(_existing.DriverConfig) ?? new S7DriverOptions();
|
||||
_form = FormModel.FromOptions(opts);
|
||||
_form.ResilienceConfig = _existing.ResilienceConfig;
|
||||
_form.RowVersion = _existing.RowVersion;
|
||||
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
|
||||
_resilienceConfig = _existing.ResilienceConfig;
|
||||
_rowVersion = _existing.RowVersion;
|
||||
}
|
||||
}
|
||||
_loaded = true;
|
||||
@@ -223,8 +145,6 @@ else
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
var opts = _form.ToOptions();
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
{
|
||||
@@ -239,8 +159,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
|
||||
@@ -248,11 +168,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");
|
||||
@@ -275,7 +195,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");
|
||||
@@ -290,67 +210,4 @@ else
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private string SerializeCurrentConfig()
|
||||
=> System.Text.Json.JsonSerializer.Serialize(
|
||||
_form.ToOptions(), _jsonOpts);
|
||||
|
||||
private static S7DriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<S7DriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
|
||||
public sealed class FormModel
|
||||
{
|
||||
// Connection
|
||||
public string Host { get; set; } = "127.0.0.1";
|
||||
public int Port { get; set; } = 102;
|
||||
public S7CpuType CpuType { get; set; } = S7CpuType.S71500;
|
||||
public short Rack { get; set; } = 0;
|
||||
public short Slot { get; set; } = 0;
|
||||
public int TimeoutSeconds { get; set; } = 5;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
public int ProbeIntervalSeconds { get; set; } = 5;
|
||||
public int ProbeTimeoutSeconds { get; set; } = 2;
|
||||
public int AdminProbeTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
// Common
|
||||
public string? ResilienceConfig { get; set; }
|
||||
public byte[] RowVersion { get; set; } = [];
|
||||
|
||||
public static FormModel FromOptions(S7DriverOptions o) => new()
|
||||
{
|
||||
Host = o.Host,
|
||||
Port = o.Port,
|
||||
CpuType = o.CpuType,
|
||||
Rack = o.Rack,
|
||||
Slot = o.Slot,
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
|
||||
};
|
||||
|
||||
public S7DriverOptions ToOptions() => new()
|
||||
{
|
||||
Host = Host,
|
||||
Port = Port,
|
||||
CpuType = CpuType,
|
||||
Rack = Rack,
|
||||
Slot = Slot,
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
Probe = new S7ProbeOptions
|
||||
{
|
||||
Enabled = ProbeEnabled,
|
||||
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
|
||||
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
|
||||
},
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+33
-229
@@ -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 (1–60 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user