Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor
T
Joseph Doherty 8149739161 feat(adminui): FOCAS typed driver page
Adds FocasDriverPage.razor (route: /clusters/{id}/drivers/new/focas) with
typed sections for timeout, probe, AlarmProjection (enabled + poll interval),
HandleRecycle (enabled + interval in minutes), FixedTree (enabled + axis/
program/timer poll intervals), and read-only JSON views for Devices and Tags.
FormModel uses flat settable properties + FromOptions/ToOptions with
appropriate unit conversions (ms, minutes). Also adds
FocasDriverPageFormSerializationTests (3 tests: JSON round-trip, unknown-field
drop, FormModel round-trip covering all sub-options classes).
2026-05-28 09:56:53 -04:00

450 lines
22 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/focas"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@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
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New Fanuc FOCAS driver" : "Edit Fanuc FOCAS 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="focasDriverEdit">
<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" />
@* 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 (160 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 — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.20s">
<div class="panel-head">Devices</div>
<div style="padding:1rem">
<div class="form-text mb-2">
Each device represents one CNC. Device list editor (with CNC series selector) coming in a follow-up phase.
Format: <code>[{"hostAddress":"192.168.0.10:8193","deviceName":"CNC1","series":"Thirty_i"}]</code>
</div>
@if (_form.DevicesJson is not null)
{
<pre class="form-control form-control-sm mono" style="min-height:4rem;max-height:12rem;overflow:auto;white-space:pre-wrap">@_form.DevicesJson</pre>
}
else
{
<p class="text-muted"><em>No devices configured.</em></p>
}
</div>
</section>
@* Tags — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.23s">
<div class="panel-head">Tags</div>
<div style="padding:1rem">
<div class="form-text mb-2">
Tag list editor coming in a follow-up phase. Tags reference device host addresses and FOCAS address strings
(e.g. <code>X0.0</code>, <code>R100</code>, <code>PARAM:1815/0</code>, <code>MACRO:500</code>).
</div>
@if (_form.TagsJson is not null)
{
<pre class="form-control form-control-sm mono" style="min-height:4rem;max-height:12rem;overflow:auto;white-space:pre-wrap">@_form.TagsJson</pre>
}
else
{
<p class="text-muted"><em>No tags configured.</em></p>
}
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "Focas";
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;
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 FocasDriverOptions());
}
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 FocasDriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_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)
{
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 static FocasDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<FocasDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
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;
// Collections JSON view (read-only)
public string? DevicesJson { get; set; }
public string? TagsJson { get; set; }
// Preserved originals (round-tripped unchanged)
private IReadOnlyList<FocasDeviceOptions> _devices = [];
private IReadOnlyList<FocasTagDefinition> _tags = [];
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
private static readonly System.Text.Json.JsonSerializerOptions _displayOpts = new()
{
WriteIndented = true,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
};
public static FormModel FromOptions(FocasDriverOptions o)
{
var m = new FormModel
{
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,
_devices = o.Devices,
_tags = o.Tags,
};
m.DevicesJson = o.Devices.Count == 0 ? null
: System.Text.Json.JsonSerializer.Serialize(o.Devices, _displayOpts);
m.TagsJson = o.Tags.Count == 0 ? null
: System.Text.Json.JsonSerializer.Serialize(o.Tags, _displayOpts);
return m;
}
public FocasDriverOptions ToOptions() => 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,
Tags = _tags,
};
}
}