Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor
T
Joseph Doherty b0959f697b perf(adminui): cache the modals' DynamicComponent parameter dictionaries (R04 H2)
DriverConfigModal, DeviceModal and RawTagModal built a new parameter dictionary
and new EventCallbacks every render. ParameterDictionaryCache reuses the
dictionary while the values it carries are unchanged and rebuilds it with the
current value when one changes (a build-once cache would strand edits); the
callbacks are created once. Blazor still re-sets an EventCallback-bearing
child's parameters per parent render, so the forms' _lastParsed guards stay.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 17:43:01 -04:00

239 lines
10 KiB
Plaintext

@* Create / edit modal for a raw-tree Device's connection endpoint (v3 endpoint→DeviceConfig split).
Dispatches to the right per-driver device form by DriverType. Test-connect builds the merged
Driver+Device config transiently (so unsaved edits probe correctly), via DriverDeviceConfigMerger —
the same merge LoadMergedProbeConfigAsync uses. The coordinator wires this into RawTree via @bind-Visible. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.Commons.Types
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@inject IRawTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(_isNew ? "New device" : "Edit device") &middot; <span class="mono">@_driverType</span></h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body">
@if (_loadError is not null)
{
<div class="panel notice notice-alert mb-3">@_loadError</div>
}
else
{
<div class="row g-3 mb-2">
<div class="col-md-6">
<label class="form-label">Device name</label>
<input class="form-control form-control-sm mono" @bind="_name" placeholder="e.g. plc1" />
<div class="form-text">A RawPath segment — letters, digits, dash, underscore.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" id="devEnabled" @bind="_enabled" />
<label class="form-check-label" for="devEnabled">Enabled</label>
</div>
</div>
</div>
@* Rendered from DeviceFormMap — see DriverConfigFormMap for why this is a map and
not a @switch. Drivers in DeviceFormMap.SingleConnectionDriverTypes legitimately
have no per-device form; the parity test consults that set. *@
@if (DeviceFormMap.Resolve(_driverType) is { } deviceFormType)
{
<DynamicComponent Type="deviceFormType" Parameters="_deviceFormParameters" />
}
else if (DeviceFormMap.IsSingleConnection(_driverType))
{
<div class="alert alert-info">
This driver holds a single connection, authored on the <strong>driver</strong>.
There is nothing to configure per device.
</div>
}
else
{
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
}
<div class="mt-3">
<DriverTestConnectButton DriverType="@_driverType" GetConfigJson="@BuildMergedProbeConfig" />
</div>
@if (_saveError is not null)
{
<div class="panel notice notice-alert mt-3">@_saveError</div>
}
}
</div>
<div class="modal-footer">
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
@if (_loadError is null)
{
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(_isNew ? "Create device" : "Save changes")
</button>
}
</div>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown (supports @bind-Visible).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Two-way visibility callback.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The device to edit; null ⇒ create a new device under <see cref="DriverInstanceId"/>.</summary>
[Parameter] public string? DeviceId { get; set; }
/// <summary>The owning driver instance (required for create; resolved from the device on edit).</summary>
[Parameter] public string? DriverInstanceId { get; set; }
/// <summary>The driver type (required for create; joined in from the device on edit).</summary>
[Parameter] public string? DriverType { get; set; }
/// <summary>Raised after a successful create/update so the tree can refresh.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private bool _isNew;
private string _driverType = "";
private string? _resolvedDriverInstanceId;
private string _name = "";
private bool _enabled = true;
private string _deviceConfigJson = "{}";
/// <summary>Parameters handed to the DynamicComponent-rendered device form. Cached on the value it
/// carries and rebuilt when that changes (never build-once: see DriverConfigModal), with the callback
/// created once (R04 H2).</summary>
private Dictionary<string, object> _deviceFormParameters => _deviceFormParameterCache.Get(
[_deviceConfigJson],
() => new()
{
["DeviceConfigJson"] = _deviceConfigJson,
["DeviceConfigJsonChanged"] = _deviceConfigJsonChanged ??=
EventCallback.Factory.Create<string>(this, v => _deviceConfigJson = v),
});
private readonly ParameterDictionaryCache _deviceFormParameterCache = new();
private EventCallback<string>? _deviceConfigJsonChanged;
private byte[] _rowVersion = [];
private string _parentDriverConfig = "{}";
private bool _busy;
private string? _loadError;
private string? _saveError;
private bool _openHandled;
// Bumped on every open and close; LoadAsync re-checks it after each await so a superseded open can't
// install one target's fields under another target's displayed modal (RawBrowseModal idiom).
private int _openGeneration;
private bool IsStale(int generation) => generation != _openGeneration || !Visible;
protected override async Task OnParametersSetAsync()
{
if (Visible && !_openHandled)
{
_openHandled = true;
await LoadAsync(++_openGeneration);
}
else if (!Visible)
{
_openHandled = false;
_openGeneration++;
}
}
private async Task LoadAsync(int generation)
{
_busy = false; _loadError = null; _saveError = null;
_isNew = string.IsNullOrEmpty(DeviceId);
if (_isNew)
{
_resolvedDriverInstanceId = DriverInstanceId;
_driverType = DriverType ?? "";
_name = ""; // placeholder only: a pre-filled value made typing append to it (#577); the name is validated on save
_enabled = true;
_deviceConfigJson = "{}";
_rowVersion = [];
}
else
{
var dto = await Svc.LoadDeviceForEditAsync(DeviceId!);
if (IsStale(generation)) { return; }
if (dto is null) { _loadError = $"Device '{DeviceId}' was not found."; return; }
_resolvedDriverInstanceId = dto.DriverInstanceId;
_driverType = dto.DriverType;
_name = dto.Name;
_enabled = dto.Enabled;
_deviceConfigJson = string.IsNullOrWhiteSpace(dto.DeviceConfig) ? "{}" : dto.DeviceConfig;
_rowVersion = dto.RowVersion;
}
// The parent driver's DriverConfig — folded with the (possibly unsaved) DeviceConfig for Test-connect.
if (!string.IsNullOrEmpty(_resolvedDriverInstanceId))
{
var drv = await Svc.LoadDriverForEditAsync(_resolvedDriverInstanceId);
if (IsStale(generation)) { return; }
_parentDriverConfig = drv?.DriverConfig ?? "{}";
}
}
/// <summary>Builds the merged Driver+Device probe config from the CURRENT (possibly unsaved) form —
/// same merge as <c>LoadMergedProbeConfigAsync</c>, so Test-connect works before the device is saved.</summary>
private string BuildMergedProbeConfig()
=> DriverDeviceConfigMerger.Merge(
_parentDriverConfig,
new[] { new DriverDeviceConfigMerger.DeviceRow(_name, _deviceConfigJson) },
Array.Empty<RawTagEntry>());
private async Task SaveAsync()
{
_busy = true; _saveError = null;
try
{
UnsMutationResult result;
if (_isNew)
{
if (string.IsNullOrEmpty(_resolvedDriverInstanceId))
{
_saveError = "No owning driver instance was supplied.";
return;
}
result = await Svc.CreateDeviceAsync(_resolvedDriverInstanceId, _name, _deviceConfigJson);
}
else
{
result = await Svc.UpdateDeviceAsync(DeviceId!, _name, _deviceConfigJson, _enabled, _rowVersion);
}
if (!result.Ok)
{
_saveError = result.Error ?? "Save failed.";
return;
}
await OnSaved.InvokeAsync();
await SetVisibleAsync(false);
}
catch (Exception ex) { _saveError = ex.Message; }
finally { _busy = false; }
}
private Task CloseAsync() => SetVisibleAsync(false);
private async Task SetVisibleAsync(bool value)
{
Visible = value;
_openHandled = value;
_openGeneration++;
await VisibleChanged.InvokeAsync(value);
}
}