refactor(adminui): B2-WP8 DriverTypeNames rewire + retire routed driver-authoring flow

Part 1 — rewire the three DriverType dispatch maps from hand-authored string
literals to the canonical DriverTypeNames constants (single source of truth):
- TagConfigEditorMap, TagConfigValidator (AdminUI)
- EquipmentTagConfigInspector (ControlPlane)
This FIXES the live drift ("TwinCat"->TwinCAT, "Focas"->FOCAS). The maps are
OrdinalIgnoreCase so behavior is identical; the value is no future drift.
Calculation is not added to the inspector (no CalculationTagDefinitionFactory.Inspect).
Added TagConfigDriverTypeNameGuardTests pinning the maps to the constants.

Part 2 — retire the routed /clusters/{id}/drivers driver-authoring flow (/raw
Waves A-C now cover driver/device/tag authoring end-to-end):
- Deleted DriverTypePicker, DriverEditRouter, the 8 *DriverPage shells, and the
  ClusterDrivers list page.
- Removed the "Drivers" tab from ClusterNav. Routes /clusters/{id}/drivers* now
  simply do not exist -> clean 404.
- The Forms/DeviceForms bodies, shared driver sections, and address pickers used
  by the /raw modals are untouched.

Part 3 — updated PageAuthorizationGuardTests census: removed the 10 deleted
ConfigEditor pages + ClusterDrivers, updated group-count comments (ConfigEditor
21->11, AuthenticatedRead 17->16). Removed the now-dead Clusters.Drivers usings
from the 4 *DriverPageFormSerializationTests + the guard test.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 04:39:22 -04:00
parent dc80a3b4f6
commit 96471d2345
21 changed files with 103 additions and 1927 deletions
@@ -1,83 +0,0 @@
@page "/clusters/{ClusterId}/drivers"
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Drivers &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers/new" class="btn btn-primary btn-sm">New driver</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count driver instance@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No driver instances for this cluster.</div>
}
else
{
@foreach (var d in _rows)
{
<details style="border-top:1px solid var(--rule)">
<summary style="padding:.75rem 1rem;cursor:pointer">
<span class="mono">@d.DriverInstanceId</span>
&middot; <span>@d.Name</span>
&middot; <span class="chip chip-idle ms-1">@d.DriverType</span>
@if (!d.Enabled) { <span class="chip chip-idle ms-1">Disabled</span> }
</summary>
<div style="padding:0 1rem 1rem">
<div class="d-flex mb-2">
<a href="/clusters/@ClusterId/drivers/@d.DriverInstanceId" class="btn btn-sm btn-outline-primary">Edit</a>
</div>
<pre class="mono small" style="background:var(--surface-2);padding:1rem;border-radius:4px;overflow:auto">@FormatJson(d.DriverConfig)</pre>
@if (!string.IsNullOrWhiteSpace(d.ResilienceConfig))
{
<div class="text-muted small mt-2">Resilience overrides:</div>
<pre class="mono small" style="background:var(--surface-2);padding:1rem;border-radius:4px;overflow:auto">@FormatJson(d.ResilienceConfig)</pre>
}
</div>
</details>
}
}
</section>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
private List<DriverInstance>? _rows;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_rows = await db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId)
.OrderBy(d => d.DriverInstanceId)
.ToListAsync();
}
private static string FormatJson(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return "";
try
{
using var doc = System.Text.Json.JsonDocument.Parse(raw);
return System.Text.Json.JsonSerializer.Serialize(doc.RootElement,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
}
catch
{
return raw;
}
}
}
@@ -1,208 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/abcip"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@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.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.AbCip
@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 AB CIP driver" : "Edit AB CIP 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&hellip;</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="_identityModel" OnValidSubmit="SubmitAsync" FormName="abcipDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" 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="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="AB CIP address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<AbCipAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@(() => _driverConfigJson)" />
</DriverTagPicker>
<AbCipDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
Pre-declared driver tags have been retired.
</div>
</section>
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "AbCip";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
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;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_driverConfigJson = "{}";
}
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,
Enabled = _existing.Enabled,
};
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
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,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _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; }
}
}
@@ -1,206 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/ablegacy"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@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.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@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 AB Legacy driver" : "Edit AB Legacy 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&hellip;</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="_identityModel" OnValidSubmit="SubmitAsync" FormName="ablegacyDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" 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="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="AB Legacy address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<AbLegacyAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
<AbLegacyDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
Pre-declared driver tags have been retired.
</div>
</section>
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "AbLegacy";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
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;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_driverConfigJson = "{}";
}
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,
Enabled = _existing.Enabled,
};
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
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,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _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; }
}
}
@@ -1,82 +0,0 @@
@* Dispatch page: reads DriverInstance.DriverType and dispatches to the matching typed editor
via <DynamicComponent> using _componentMap. Shows an error panel when the driver type has
no registered typed page. *@
@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@if (!_loaded)
{
<p>Loading…</p>
}
else if (_existing is null)
{
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Edit driver instance &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" />
<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 if (ResolveComponentType() is { } pageType)
{
<DynamicComponent Type="pageType" Parameters="BuildParameters()" />
}
else
{
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Edit driver instance &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" />
<section class="panel notice rise" style="animation-delay:.02s; border-color:var(--alert)">
Driver instance <span class="mono">@DriverInstanceId</span> has an unknown <code>DriverType</code> value of
<strong><span class="mono">@_existing.DriverType</span></strong>. No editor is registered for this type.
Likely causes: the row was written by a newer build, or the type-string was corrupted in the database.
</section>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string DriverInstanceId { get; set; } = "";
private DriverInstance? _existing;
private bool _loaded;
private static readonly IReadOnlyDictionary<string, Type> _componentMap =
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
["Modbus"] = typeof(ModbusDriverPage),
["AbCip"] = typeof(AbCipDriverPage),
["AbLegacy"] = typeof(AbLegacyDriverPage),
["S7"] = typeof(S7DriverPage),
["TwinCat"] = typeof(TwinCATDriverPage),
["Focas"] = typeof(FocasDriverPage),
["OpcUaClient"] = typeof(OpcUaClientDriverPage),
["GalaxyMxGateway"] = typeof(GalaxyDriverPage),
};
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_existing = await db.DriverInstances.AsNoTracking()
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
_loaded = true;
}
private Type? ResolveComponentType()
=> _componentMap.TryGetValue(_existing!.DriverType, out var t) ? t : null;
private IDictionary<string, object> BuildParameters()
=> new Dictionary<string, object>
{
["ClusterId"] = ClusterId,
["DriverInstanceId"] = DriverInstanceId,
};
}
@@ -1,49 +0,0 @@
@* Driver type picker — presents a card grid of registered driver types and links to the
per-type new-driver creation page (/clusters/{ClusterId}/drivers/new/{slug}). *@
@page "/clusters/{ClusterId}/drivers/new"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">New 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" />
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Pick a driver type</div>
<div style="padding:1rem">
<div class="row g-3">
@foreach (var t in _types)
{
<div class="col-md-4 col-lg-3">
<a href="/clusters/@ClusterId/drivers/new/@t.Slug" class="card h-100 text-decoration-none">
<div class="card-body">
<div class="text-muted small mono">@t.Icon</div>
<div class="card-title mt-1"><strong>@t.DisplayName</strong></div>
<div class="card-text small text-muted">@t.Description</div>
</div>
</a>
</div>
}
</div>
</div>
</section>
@code {
[Parameter] public string ClusterId { get; set; } = "";
private sealed record DriverTypeEntry(string DisplayName, string Slug, string Icon, string Description);
private static readonly IReadOnlyList<DriverTypeEntry> _types = new[]
{
new DriverTypeEntry("Modbus TCP", "modbustcp", "[M]", "Modbus/TCP — generic registers/coils via port 502."),
new DriverTypeEntry("AbCip", "abcip", "[CIP]", "Allen-Bradley CompactLogix/ControlLogix via CIP."),
new DriverTypeEntry("AbLegacy", "ablegacy", "[AB]", "Allen-Bradley PLC-5/SLC-500/MicroLogix via DF1."),
new DriverTypeEntry("S7", "s7", "[S7]", "Siemens S7-300/400/1200/1500 via ISO-on-TCP."),
new DriverTypeEntry("TwinCat", "twincat", "[TC]", "Beckhoff TwinCAT via ADS."),
new DriverTypeEntry("Focas", "focas", "[FOC]", "Fanuc CNC via FOCAS library."),
new DriverTypeEntry("OpcUaClient", "opcuaclient", "[OPC]", "Upstream OPC UA server pull."),
new DriverTypeEntry("Galaxy", "galaxy", "[Gx]", "AVEVA System Platform (Wonderware) via mxaccessgw."),
};
}
@@ -1,209 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/focas"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@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.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@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="_identityModel" 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" 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="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="FOCAS address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<FOCASAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@(() => _driverConfigJson)" />
</DriverTagPicker>
<FocasDriverForm @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">
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
Pre-declared driver tags have been retired.
</div>
</section>
</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 DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private string _driverConfigJson = "{}";
private string? _resilienceConfig;
private byte[] _rowVersion = [];
private DriverInstance? _existing;
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_driverConfigJson = "{}";
}
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,
Enabled = _existing.Enabled,
};
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
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,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _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; }
}
}
@@ -1,198 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/galaxy"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@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.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@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 AVEVA Galaxy driver" : "Edit AVEVA Galaxy 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&hellip;</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="_identityModel" OnValidSubmit="SubmitAsync" FormName="galaxyDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" 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="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Galaxy address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<GalaxyAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@(() => _driverConfigJson)" />
</DriverTagPicker>
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "GalaxyMxGateway";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
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;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_driverConfigJson = "{}";
}
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,
Enabled = _existing.Enabled,
};
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
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,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _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; }
}
}
@@ -1,214 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/modbustcp"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@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.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@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 Modbus/TCP driver" : "Edit Modbus/TCP 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&hellip;</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="_identityModel" OnValidSubmit="SubmitAsync" FormName="modbustcpDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" 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="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Modbus address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<ModbusAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
<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>
<ModbusDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
Pre-declared driver tags have been retired.
</div>
</section>
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "Modbus";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
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;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_driverConfigJson = "{}";
}
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,
Enabled = _existing.Enabled,
};
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
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,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _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; }
}
}
@@ -1,206 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/opcuaclient"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@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.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@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 OPC UA Client driver" : "Edit OPC UA Client 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&hellip;</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="_identityModel" OnValidSubmit="SubmitAsync" FormName="opcuaclientDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" 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="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="OPC UA address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<OpcUaClientAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@(() => _driverConfigJson)" />
</DriverTagPicker>
<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>
<OpcUaClientDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "OpcUaClient";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
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;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_driverConfigJson = "{}";
}
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,
Enabled = _existing.Enabled,
};
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
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,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _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; }
}
}
@@ -1,213 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/s7"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@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.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@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 Siemens S7 driver" : "Edit Siemens S7 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="_identityModel" OnValidSubmit="SubmitAsync" FormName="s7DriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" 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="@(() => _driverConfigJson)" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="S7 address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<S7AddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
<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>
<S7DriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
Pre-declared driver tags have been retired.
</div>
</section>
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "S7";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
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;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_driverConfigJson = "{}";
}
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,
Enabled = _existing.Enabled,
};
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
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,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _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; }
}
}
@@ -1,214 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/twincat"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@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.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@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="_identityModel" 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" 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="@(() => _driverConfigJson)" />
<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)"
GetConfigJson="@(() => _driverConfigJson)" />
</DriverTagPicker>
<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>
<TwinCATDriverForm @bind-DriverConfigJson="_driverConfigJson"
@bind-ResilienceConfig="_resilienceConfig" />
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
Pre-declared driver tags have been retired.
</div>
</section>
</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 DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private string _driverConfigJson = "{}";
private string? _resilienceConfig;
private byte[] _rowVersion = [];
private DriverInstance? _existing;
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_driverConfigJson = "{}";
}
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,
Enabled = _existing.Enabled,
};
_driverConfigJson = string.IsNullOrWhiteSpace(_existing.DriverConfig) ? "{}" : _existing.DriverConfig;
_resilienceConfig = _existing.ResilienceConfig;
_rowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
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,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = _driverConfigJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _rowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = _driverConfigJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_resilienceConfig) ? null : _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 = _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; }
}
}
@@ -11,7 +11,6 @@
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><a class="nav-link @Active("overview")" href="/clusters/@ClusterId">Overview</a></li>
<li class="nav-item"><a class="nav-link @Active("namespaces")" href="/clusters/@ClusterId/namespaces">Namespaces</a></li>
<li class="nav-item"><a class="nav-link @Active("drivers")" href="/clusters/@ClusterId/drivers">Drivers</a></li>
<li class="nav-item"><a class="nav-link @Active("acls")" href="/clusters/@ClusterId/acls">ACLs</a></li>
<li class="nav-item"><a class="nav-link @Active("audit")" href="/clusters/@ClusterId/audit">Audit</a></li>
<li class="nav-item"><a class="nav-link @Active("redundancy")" href="/clusters/@ClusterId/redundancy">Redundancy</a></li>
@@ -1,23 +1,26 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Maps a driver's <c>DriverType</c> string to its typed tag-config editor component (mirrors
/// <c>DriverEditRouter._componentMap</c>). Drivers absent from the map fall back to the generic
/// raw-JSON textarea in the TagModal.
/// Maps a driver's <c>DriverType</c> string to its typed tag-config editor component. Drivers absent
/// from the map fall back to the generic raw-JSON textarea in the TagModal. Keys are the canonical
/// <see cref="DriverTypeNames"/> constants
/// (case-insensitive) so this map can never drift from the authoritative factory names.
/// </summary>
public static class TagConfigEditorMap
{
private static readonly IReadOnlyDictionary<string, Type> Map =
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
["Modbus"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),
["S7"] = typeof(Components.Shared.Uns.TagEditors.S7TagConfigEditor),
["AbCip"] = typeof(Components.Shared.Uns.TagEditors.AbCipTagConfigEditor),
["AbLegacy"] = typeof(Components.Shared.Uns.TagEditors.AbLegacyTagConfigEditor),
["TwinCat"] = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor),
["Focas"] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
["OpcUaClient"] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
["Calculation"] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
[DriverTypeNames.Modbus] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),
[DriverTypeNames.S7] = typeof(Components.Shared.Uns.TagEditors.S7TagConfigEditor),
[DriverTypeNames.AbCip] = typeof(Components.Shared.Uns.TagEditors.AbCipTagConfigEditor),
[DriverTypeNames.AbLegacy] = typeof(Components.Shared.Uns.TagEditors.AbLegacyTagConfigEditor),
[DriverTypeNames.TwinCAT] = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor),
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
};
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
@@ -1,3 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
@@ -5,21 +7,22 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <see cref="TagConfigEditorMap"/>). Each entry parses the tag's config JSON into the driver's typed
/// model and runs its <c>Validate()</c>. Returns an error string to block the TagModal save, or null
/// when the config is valid — or when no typed validator is registered (unmapped drivers use the raw
/// textarea and are validated server-side at deploy).
/// textarea and are validated server-side at deploy). Keys are the canonical
/// <see cref="DriverTypeNames"/> constants (case-insensitive) so this map can never drift.
/// </summary>
public static class TagConfigValidator
{
private static readonly IReadOnlyDictionary<string, Func<string?, string?>> Validators =
new Dictionary<string, Func<string?, string?>>(StringComparer.OrdinalIgnoreCase)
{
["Modbus"] = j => ModbusTagConfigModel.FromJson(j).Validate(),
["S7"] = j => S7TagConfigModel.FromJson(j).Validate(),
["AbCip"] = j => AbCipTagConfigModel.FromJson(j).Validate(),
["AbLegacy"] = j => AbLegacyTagConfigModel.FromJson(j).Validate(),
["TwinCat"] = j => TwinCATTagConfigModel.FromJson(j).Validate(),
["Focas"] = j => FocasTagConfigModel.FromJson(j).Validate(),
["OpcUaClient"] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
["Calculation"] = j => CalculationTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Modbus] = j => ModbusTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.S7] = j => S7TagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.AbCip] = j => AbCipTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.AbLegacy] = j => AbLegacyTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.TwinCAT] = j => TwinCATTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
};
/// <summary>
@@ -1,3 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
@@ -20,12 +21,12 @@ public static class EquipmentTagConfigInspector
private static readonly IReadOnlyDictionary<string, Func<string, IReadOnlyList<string>>> Inspectors =
new Dictionary<string, Func<string, IReadOnlyList<string>>>(StringComparer.OrdinalIgnoreCase)
{
["Modbus"] = ModbusTagDefinitionFactory.Inspect,
["S7"] = S7TagDefinitionFactory.Inspect,
["AbCip"] = AbCipTagDefinitionFactory.Inspect,
["AbLegacy"] = AbLegacyTagDefinitionFactory.Inspect,
["TwinCat"] = TwinCATTagDefinitionFactory.Inspect,
["Focas"] = FocasTagDefinitionFactory.Inspect,
[DriverTypeNames.Modbus] = ModbusTagDefinitionFactory.Inspect,
[DriverTypeNames.S7] = S7TagDefinitionFactory.Inspect,
[DriverTypeNames.AbCip] = AbCipTagDefinitionFactory.Inspect,
[DriverTypeNames.AbLegacy] = AbLegacyTagDefinitionFactory.Inspect,
[DriverTypeNames.TwinCAT] = TwinCATTagDefinitionFactory.Inspect,
[DriverTypeNames.FOCAS] = FocasTagDefinitionFactory.Inspect,
};
/// <summary>Inspects a tag's <c>TagConfig</c> for the given driver type. Returns the driver's warnings,
@@ -6,7 +6,6 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Uns;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
@@ -29,7 +28,10 @@ public class PageAuthorizationGuardTests
/// </summary>
private static readonly IReadOnlyDictionary<Type, string> ExpectedPolicy = new Dictionary<Type, string>
{
// ── ConfigEditor (21): the config-authoring surface (incl. live ResilienceConfig) ──
// ── ConfigEditor (11): the config-authoring surface (incl. live ResilienceConfig).
// The routed /clusters/{id}/drivers* driver-authoring pages (DriverTypePicker,
// DriverEditRouter, + the 8 per-type *DriverPage shells) retired in B2-WP8 — driver
// authoring now flows through the /raw tree, so those routes no longer exist. ──
[typeof(GlobalUns)] = AdminUiPolicies.ConfigEditor,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Raw.GlobalRaw)] = AdminUiPolicies.ConfigEditor,
[typeof(EquipmentPage)] = AdminUiPolicies.ConfigEditor,
@@ -38,16 +40,6 @@ public class PageAuthorizationGuardTests
[typeof(NodeEdit)] = AdminUiPolicies.ConfigEditor,
[typeof(NamespaceEdit)] = AdminUiPolicies.ConfigEditor,
[typeof(AclEdit)] = AdminUiPolicies.ConfigEditor,
[typeof(DriverTypePicker)] = AdminUiPolicies.ConfigEditor,
[typeof(DriverEditRouter)] = AdminUiPolicies.ConfigEditor,
[typeof(ModbusDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(S7DriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(AbCipDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(AbLegacyDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(TwinCATDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(FocasDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(OpcUaClientDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(GalaxyDriverPage)] = AdminUiPolicies.ConfigEditor,
[typeof(Deployments)] = AdminUiPolicies.ConfigEditor,
[typeof(Scripts)] = AdminUiPolicies.ConfigEditor,
[typeof(ScriptEdit)] = AdminUiPolicies.ConfigEditor,
@@ -55,7 +47,7 @@ public class PageAuthorizationGuardTests
// ── FleetAdmin (1) ──
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
// ── AuthenticatedRead (17): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ──
// ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ──
[typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Fleet)] = AdminUiPolicies.AuthenticatedRead,
@@ -71,7 +63,6 @@ public class PageAuthorizationGuardTests
[typeof(ClusterAudit)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterAcls)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterNamespaces)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterDrivers)] = AdminUiPolicies.AuthenticatedRead,
[typeof(ClusterRedundancy)] = AdminUiPolicies.AuthenticatedRead,
};
@@ -2,7 +2,6 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
@@ -2,7 +2,6 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
@@ -4,7 +4,6 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
@@ -2,7 +2,6 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
@@ -0,0 +1,65 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// B2-WP8 drift guard: asserts the tag-config dispatch maps (<see cref="TagConfigEditorMap"/> and
/// <see cref="TagConfigValidator"/>) are keyed by the canonical <see cref="DriverTypeNames"/>
/// constants. Because the maps are <c>OrdinalIgnoreCase</c>, a drifted literal (e.g. the historical
/// <c>"TwinCat"</c>/<c>"Focas"</c>) still matched case-insensitively — so a plain lookup test cannot
/// catch drift. These tests resolve <em>through the constants themselves</em>, so if a map key ever
/// stops matching its <see cref="DriverTypeNames"/> constant (a rename on either side) the dispatch
/// goes dark and the test fails.
/// </summary>
public sealed class TagConfigDriverTypeNameGuardTests
{
// Every DriverTypeNames constant that has a typed editor registered.
public static TheoryData<string> EditorMappedDriverTypes() =>
[
DriverTypeNames.Modbus,
DriverTypeNames.S7,
DriverTypeNames.AbCip,
DriverTypeNames.AbLegacy,
DriverTypeNames.TwinCAT,
DriverTypeNames.FOCAS,
DriverTypeNames.OpcUaClient,
DriverTypeNames.Calculation,
];
[Theory]
[MemberData(nameof(EditorMappedDriverTypes))]
public void EditorMap_resolves_every_mapped_DriverTypeNames_constant(string driverType)
=> TagConfigEditorMap.Resolve(driverType).ShouldNotBeNull(
$"TagConfigEditorMap must dispatch DriverTypeNames.{driverType} — key/constant drift.");
// Drivers whose typed model has a required field: a REGISTERED validator returns a non-null error for
// the empty config, whereas an unmapped/drifted key falls through to the null "unmapped ⇒ valid" branch.
// So a non-null result here unambiguously proves the constant is still keyed in the validator map.
public static TheoryData<string> ValidatorRequiredFieldDriverTypes() =>
[
DriverTypeNames.S7,
DriverTypeNames.AbCip,
DriverTypeNames.AbLegacy,
DriverTypeNames.TwinCAT,
DriverTypeNames.FOCAS,
DriverTypeNames.OpcUaClient,
];
[Theory]
[MemberData(nameof(ValidatorRequiredFieldDriverTypes))]
public void Validator_dispatches_every_required_field_DriverTypeNames_constant(string driverType)
=> TagConfigValidator.Validate(driverType, "{}").ShouldNotBeNullOrEmpty(
$"TagConfigValidator must dispatch DriverTypeNames.{driverType} — key/constant drift.");
// The drift the WP8 rewire fixed: these two constants differ in case from the retired literals
// ("TwinCat"/"Focas"). Pin them explicitly.
[Fact]
public void TwinCAT_and_FOCAS_constants_resolve_their_editors()
{
TagConfigEditorMap.Resolve(DriverTypeNames.TwinCAT).ShouldNotBeNull();
TagConfigEditorMap.Resolve(DriverTypeNames.FOCAS).ShouldNotBeNull();
}
}