Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor
T

491 lines
24 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/clusters/{ClusterId}/drivers/new/twincat"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@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.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
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New Beckhoff TwinCAT driver" : "Edit Beckhoff TwinCAT driver") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="twincatDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="TwinCAT address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<TwinCATAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</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>
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="tcProbeEnabled" @bind-Value="_form.ProbeEnabled"
class="form-check-input" />
<label class="form-check-label" for="tcProbeEnabled">Probe enabled</label>
</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcProbeInterval">Probe interval (s)</label>
<InputNumber id="tcProbeInterval" @bind-Value="_form.ProbeIntervalSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcProbeTimeout">Probe timeout (s)</label>
<InputNumber id="tcProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcAdminProbe">Admin probe timeout (s)</label>
<InputNumber id="tcAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
@* 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 *@
<CollectionEditor TRow="TwinCATTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
AnimationDelay=".14s"
NewRow="@(() => new TwinCATTagRow())" Clone="@(r => r.Clone())"
Validate="TwinCATTagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Device</th><th>Symbol path</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td class="mono">@t.DeviceHostAddress</td>
<td class="mono">@t.SymbolPath</td><td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-6"><label class="form-label">Device host address</label>
<input class="form-control form-control-sm mono" @bind="t.DeviceHostAddress"
placeholder="192.168.0.1.1.1:851" /></div>
<div class="col-md-6"><label class="form-label">Symbol path</label>
<input class="form-control form-control-sm mono" @bind="t.SymbolPath"
placeholder="e.g. MAIN.bStart, GVL.Counter" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<TwinCATDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
</EditTemplate>
</CollectionEditor>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "TwinCat";
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,
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private List<Namespace> _namespaces = new();
private bool _loaded, _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<TwinCATDeviceRow> _devices = [];
private List<TwinCATTagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_namespaces = await db.Namespaces.AsNoTracking()
.Where(n => n.ClusterId == ClusterId)
.OrderBy(n => n.NamespaceId).ToListAsync();
if (IsNew)
{
_identityModel = new() { DriverType = DriverTypeKey, NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", Enabled = true };
_form = FormModel.FromOptions(new TwinCATDriverOptions());
}
else
{
_existing = await db.DriverInstances.AsNoTracking()
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (_existing is not null)
{
_identityModel = new()
{
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
NamespaceId = _existing.NamespaceId,
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();
_tags = opts.Tags.Select(TwinCATTagRow.FromDefinition).ToList();
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_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;
}
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
NamespaceId = _identityModel.NamespaceId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
});
}
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;
entity.NamespaceId = _identityModel.NamespaceId;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
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.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
}
catch (Exception ex)
{
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.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;
}
}
// Mutable VM for the modal editor — TwinCATTagDefinition is an immutable record.
public sealed class TwinCATTagRow
{
public string Name { get; set; } = "";
public string DeviceHostAddress { get; set; } = "";
public string SymbolPath { get; set; } = "";
public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt;
public bool Writable { get; set; } = true;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent) across a load→save.
private TwinCATTagDefinition? _source;
public TwinCATTagRow Clone() => (TwinCATTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static TwinCATTagRow FromDefinition(TwinCATTagDefinition d) => new()
{
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, SymbolPath = d.SymbolPath,
DataType = d.DataType, Writable = d.Writable,
_source = d,
};
public TwinCATTagDefinition ToDefinition()
{
var baseDef = _source ?? new TwinCATTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), SymbolPath.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
DeviceHostAddress = DeviceHostAddress.Trim(),
SymbolPath = SymbolPath.Trim(),
DataType = DataType,
Writable = Writable,
};
}
public static string? ValidateRow(TwinCATTagRow row, IReadOnlyList<TwinCATTagRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
return $"Duplicate tag name '{row.Name}'.";
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
{
// 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,
IReadOnlyList<TwinCATTagDefinition> tags) => 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,
Tags = tags,
};
}
}