v3(b1-wp4b): make AdminUI compile against greenfield schema behind Batch-2/3 stubs

Namespace entity + NamespaceKind retired; DriverInstance dropped NamespaceId;
Tag is raw-only (no EquipmentId/DriverInstanceId/FullName/FolderPath); driver
options replaced pre-declared Tags with RawTags. Stub the retired surfaces so
the AdminUI + Host build green; real Raw/UNS authoring arrives in v3 Batch 2/3.

- ClusterNamespaces/NamespaceEdit: bodies replaced with 'Namespaces retired' banner
- DriverIdentitySection: dropped Namespace dropdown + NamespaceId model field
- 8 driver pages: dropped Namespace binding; retired the pre-declared Tags editor
  (replaced with a '/raw Batch 2' note); multi-device pages keep their Devices editor
- UnsTreeService: equipment-tag counts/list -> empty; Create/UpdateTag -> failure
  result ('Tag authoring moved to the Raw tree (/raw) in v3 Batch 2'); Namespace/
  driver-binding queries removed
- IScriptTagCatalog: project surviving Tag columns only (path from TagConfig FullName, else Name)
- OpcUaClientTagConfigEditor: rebind stale FullName -> model's NodeId
- ClusterDrivers: drop retired NamespaceId column

AdminUI + Host build 0/0. AdminUI.Tests / Host.IntegrationTests remain red (WP6).
This commit is contained in:
Joseph Doherty
2026-07-15 21:09:04 -04:00
parent 604928b29d
commit 11576460a7
15 changed files with 155 additions and 1147 deletions
@@ -35,7 +35,6 @@ else
&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> }
<span class="text-muted small ms-2">ns=@d.NamespaceId</span>
</summary>
<div style="padding:0 1rem 1rem">
<div class="d-flex mb-2">
@@ -1,81 +1,23 @@
@page "/clusters/{ClusterId}/namespaces"
@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">Namespaces &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/namespaces/new" class="btn btn-primary btn-sm">New namespace</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="namespaces" />
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
Namespaces are content (decision #123) — they're served at the OPC UA endpoint and bound
to driver instances. NamespaceUri must be unique fleet-wide.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count namespace@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No namespaces defined for this cluster.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>NamespaceId</th>
<th>Kind</th>
<th>URI</th>
<th>Status</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var n in _rows)
{
<tr>
<td><span class="mono">@n.NamespaceId</span></td>
<td>@n.Kind</td>
<td><span class="mono small">@n.NamespaceUri</span></td>
<td>
@if (n.Enabled) { <span class="chip chip-ok">Enabled</span> }
else { <span class="chip chip-idle">Disabled</span> }
</td>
<td class="text-muted small">@(n.Notes ?? "")</td>
<td><a href="/clusters/@ClusterId/namespaces/@n.NamespaceId" class="btn btn-sm btn-outline-primary">Edit</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
<section class="panel notice rise" style="animation-delay:.02s">
<div class="panel-head">Namespaces are retired in v3</div>
<div style="padding:1rem">
The <code>Namespace</code> entity has been removed. The v3 Raw/UNS split replaces it —
raw driver folders and the unified namespace are managed on the <strong>/raw</strong> and
<strong>/uns</strong> pages, arriving in v3 Batch 2/3. This page is a placeholder so existing
links do not 404.
</div>
</section>
@code {
[Parameter] public string ClusterId { get; set; } = "";
private List<Namespace>? _rows;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_rows = await db.Namespaces.AsNoTracking()
.Where(n => n.ClusterId == ClusterId)
.OrderBy(n => n.NamespaceId)
.ToListAsync();
}
}
@@ -36,7 +36,7 @@ else
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
@@ -175,37 +175,13 @@ else
</CollectionEditor>
@* Tags *@
<CollectionEditor TRow="AbCipTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
AnimationDelay=".14s"
NewRow="@(() => new AbCipTagRow())" Clone="@(r => r.Clone())"
Validate="AbCipTagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Device</th><th>Tag path</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td class="mono">@t.DeviceHostAddress</td>
<td class="mono">@t.TagPath</td><td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-6"><label class="form-label">Device host address</label>
<input class="form-control form-control-sm mono" @bind="t.DeviceHostAddress"
placeholder="ab://gateway/1,0" /></div>
<div class="col-md-6"><label class="form-label">Tag path</label>
<input class="form-control form-control-sm mono" @bind="t.TagPath"
placeholder="e.g. Program:Main.SomeTag" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<AbCipDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
</EditTemplate>
</CollectionEditor>
<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>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -231,7 +207,6 @@ else
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private List<Namespace> _namespaces = new();
private bool _loaded;
private bool _busy;
private string? _error;
@@ -242,17 +217,12 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
// Held separately because Devices is a collection — edited via the CollectionEditor modal.
private List<AbCipDeviceRow> _devices = [];
private List<AbCipTagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_namespaces = await db.Namespaces.AsNoTracking()
.Where(n => n.ClusterId == ClusterId)
.OrderBy(n => n.NamespaceId)
.ToListAsync();
if (IsNew)
{
@@ -261,7 +231,6 @@ else
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "",
Enabled = true,
};
_form = new FormModel();
@@ -277,7 +246,6 @@ else
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
NamespaceId = _existing.NamespaceId,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new AbCipDriverOptions();
@@ -285,7 +253,6 @@ else
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(AbCipDeviceRow.FromDefinition).ToList();
_tags = opts.Tags.Select(AbCipTagRow.FromDefinition).ToList();
}
}
_loaded = true;
@@ -298,8 +265,7 @@ else
{
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
@@ -313,7 +279,6 @@ else
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
NamespaceId = _identityModel.NamespaceId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
@@ -327,7 +292,6 @@ else
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;
@@ -373,8 +337,7 @@ else
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static AbCipDriverOptions? TryDeserialize(string json)
@@ -423,53 +386,8 @@ else
}
}
// Mutable VM for the modal editor — AbCipTagDefinition is an immutable record.
public sealed class AbCipTagRow
{
public string Name { get; set; } = "";
public string DeviceHostAddress { get; set; } = "";
public string TagPath { get; set; } = "";
public AbCipDataType DataType { get; set; } = AbCipDataType.DInt;
public bool Writable { get; set; } = true;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent, Members, SafetyTag) across a load→save.
private AbCipTagDefinition? _source;
public AbCipTagRow Clone() => (AbCipTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbCipTagRow FromDefinition(AbCipTagDefinition d) => new()
{
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, TagPath = d.TagPath,
DataType = d.DataType, Writable = d.Writable,
_source = d,
};
public AbCipTagDefinition ToDefinition()
{
var baseDef = _source ?? new AbCipTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), TagPath.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
DeviceHostAddress = DeviceHostAddress.Trim(),
TagPath = TagPath.Trim(),
DataType = DataType,
Writable = Writable,
};
}
public static string? ValidateRow(AbCipTagRow row, IReadOnlyList<AbCipTagRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
return $"Duplicate tag name '{row.Name}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
// Collections (Devices) are kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Operation
@@ -509,11 +427,9 @@ else
};
public AbCipDriverOptions ToOptions(
IReadOnlyList<AbCipDeviceOptions> devices,
IReadOnlyList<AbCipTagDefinition> tags) => new()
IReadOnlyList<AbCipDeviceOptions> devices) => new()
{
Devices = devices,
Tags = tags,
Probe = new AbCipProbeOptions
{
Enabled = ProbeEnabled,
@@ -37,7 +37,7 @@ else
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
@@ -140,37 +140,13 @@ else
</CollectionEditor>
@* Tags *@
<CollectionEditor TRow="AbLegacyTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
AnimationDelay=".12s"
NewRow="@(() => new AbLegacyTagRow())" Clone="@(r => r.Clone())"
Validate="AbLegacyTagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Device</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td class="mono">@t.DeviceHostAddress</td>
<td class="mono">@t.Address</td><td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-6"><label class="form-label">Device host address</label>
<input class="form-control form-control-sm mono" @bind="t.DeviceHostAddress"
placeholder="10.0.0.10" /></div>
<div class="col-md-6"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" @bind="t.Address"
placeholder="e.g. N7:0, F8:0, B3:0/0" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<AbLegacyDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
</EditTemplate>
</CollectionEditor>
<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>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -196,7 +172,6 @@ else
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private List<Namespace> _namespaces = new();
private bool _loaded;
private bool _busy;
private string? _error;
@@ -209,15 +184,10 @@ else
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<AbLegacyDeviceRow> _devices = [];
private List<AbLegacyTagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_namespaces = await db.Namespaces.AsNoTracking()
.Where(n => n.ClusterId == ClusterId)
.OrderBy(n => n.NamespaceId)
.ToListAsync();
if (IsNew)
{
@@ -226,7 +196,6 @@ else
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "",
Enabled = true,
};
_form = new FormModel();
@@ -242,7 +211,6 @@ else
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
NamespaceId = _existing.NamespaceId,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new AbLegacyDriverOptions();
@@ -250,7 +218,6 @@ else
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(AbLegacyDeviceRow.FromDefinition).ToList();
_tags = opts.Tags.Select(AbLegacyTagRow.FromDefinition).ToList();
}
}
_loaded = true;
@@ -263,8 +230,7 @@ else
{
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
@@ -278,7 +244,6 @@ else
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
NamespaceId = _identityModel.NamespaceId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
@@ -292,7 +257,6 @@ else
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;
@@ -338,8 +302,7 @@ else
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static AbLegacyDriverOptions? TryDeserialize(string json)
@@ -388,51 +351,6 @@ else
}
}
// Mutable VM for the modal editor — AbLegacyTagDefinition is an immutable record.
public sealed class AbLegacyTagRow
{
public string Name { get; set; } = "";
public string DeviceHostAddress { get; set; } = "";
public string Address { get; set; } = "";
public AbLegacyDataType DataType { get; set; } = AbLegacyDataType.Int;
public bool Writable { get; set; } = true;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent) across a load→save.
private AbLegacyTagDefinition? _source;
public AbLegacyTagRow Clone() => (AbLegacyTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbLegacyTagRow FromDefinition(AbLegacyTagDefinition d) => new()
{
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, Address = d.Address,
DataType = d.DataType, Writable = d.Writable,
_source = d,
};
public AbLegacyTagDefinition ToDefinition()
{
var baseDef = _source ?? new AbLegacyTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), Address.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
DeviceHostAddress = DeviceHostAddress.Trim(),
Address = Address.Trim(),
DataType = DataType,
Writable = Writable,
};
}
public static string? ValidateRow(AbLegacyTagRow row, IReadOnlyList<AbLegacyTagRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
return $"Duplicate tag name '{row.Name}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel
@@ -464,11 +382,9 @@ else
};
public AbLegacyDriverOptions ToOptions(
IReadOnlyList<AbLegacyDeviceOptions> devices,
IReadOnlyList<AbLegacyTagDefinition> tags) => new()
IReadOnlyList<AbLegacyDeviceOptions> devices) => new()
{
Devices = devices,
Tags = tags,
Probe = new AbLegacyProbeOptions
{
Enabled = ProbeEnabled,
@@ -36,7 +36,7 @@ else
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
@@ -218,37 +218,13 @@ else
</CollectionEditor>
@* Tags *@
<CollectionEditor TRow="FocasTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
AnimationDelay=".23s"
NewRow="@(() => new FocasTagRow())" Clone="@(r => r.Clone())"
Validate="FocasTagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Device</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td class="mono">@t.DeviceHostAddress</td>
<td class="mono">@t.Address</td><td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-6"><label class="form-label">Device host address</label>
<input class="form-control form-control-sm mono" @bind="t.DeviceHostAddress"
placeholder="192.168.0.10:8193" /></div>
<div class="col-md-6"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" @bind="t.Address"
placeholder="e.g. X0.0, R100, PARAM:1815/0, MACRO:500" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<FocasDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
</EditTemplate>
</CollectionEditor>
<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>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -274,7 +250,6 @@ else
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;
@@ -286,18 +261,14 @@ else
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<FocasDeviceRow> _devices = [];
private List<FocasTagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_namespaces = await db.Namespaces.AsNoTracking()
.Where(n => n.ClusterId == ClusterId)
.OrderBy(n => n.NamespaceId).ToListAsync();
if (IsNew)
{
_identityModel = new() { DriverType = DriverTypeKey, NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", Enabled = true };
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
_form = FormModel.FromOptions(new FocasDriverOptions());
}
else
@@ -311,7 +282,6 @@ else
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
NamespaceId = _existing.NamespaceId,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new FocasDriverOptions();
@@ -319,7 +289,6 @@ else
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(FocasDeviceRow.FromDefinition).ToList();
_tags = opts.Tags.Select(FocasTagRow.FromDefinition).ToList();
}
}
_loaded = true;
@@ -331,8 +300,7 @@ else
try
{
var opts = _form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList());
_devices.Select(r => r.ToDefinition()).ToList());
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
@@ -345,7 +313,6 @@ else
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
NamespaceId = _identityModel.NamespaceId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
@@ -359,7 +326,6 @@ else
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;
@@ -405,8 +371,7 @@ else
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static FocasDriverOptions? TryDeserialize(string json)
@@ -455,51 +420,6 @@ else
}
}
// Mutable VM for the modal editor — FocasTagDefinition is an immutable record.
public sealed class FocasTagRow
{
public string Name { get; set; } = "";
public string DeviceHostAddress { get; set; } = "";
public string Address { get; set; } = "";
public FocasDataType DataType { get; set; } = FocasDataType.Int32;
public bool Writable { get; set; } = true;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent) across a load→save.
private FocasTagDefinition? _source;
public FocasTagRow Clone() => (FocasTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static FocasTagRow FromDefinition(FocasTagDefinition d) => new()
{
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, Address = d.Address,
DataType = d.DataType, Writable = d.Writable,
_source = d,
};
public FocasTagDefinition ToDefinition()
{
var baseDef = _source ?? new FocasTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), Address.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
DeviceHostAddress = DeviceHostAddress.Trim(),
Address = Address.Trim(),
DataType = DataType,
Writable = Writable,
};
}
public static string? ValidateRow(FocasTagRow row, IReadOnlyList<FocasTagRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
return $"Duplicate tag name '{row.Name}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel
@@ -549,8 +469,7 @@ else
};
public FocasDriverOptions ToOptions(
IReadOnlyList<FocasDeviceOptions> devices,
IReadOnlyList<FocasTagDefinition> tags) => new()
IReadOnlyList<FocasDeviceOptions> devices) => new()
{
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new FocasProbeOptions
@@ -578,7 +497,6 @@ else
TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds),
},
Devices = devices,
Tags = tags,
};
}
}
@@ -36,7 +36,7 @@ else
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
@@ -224,7 +224,6 @@ else
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private List<Namespace> _namespaces = new();
private bool _loaded;
private bool _busy;
private string? _error;
@@ -238,10 +237,6 @@ else
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)
{
@@ -250,7 +245,6 @@ else
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "",
Enabled = true,
};
_form = new FormModel();
@@ -266,7 +260,6 @@ else
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
NamespaceId = _existing.NamespaceId,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? CreateDefaultOptions();
@@ -303,7 +296,6 @@ else
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
NamespaceId = _identityModel.NamespaceId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
@@ -317,7 +309,6 @@ else
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;
@@ -36,7 +36,7 @@ else
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
@@ -273,44 +273,13 @@ else
</div>
</section>
<CollectionEditor TRow="ModbusTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
NewRow="@(() => new ModbusTagRow())" Clone="@(r => r.Clone())"
Validate="ModbusTagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Region</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td>@t.Region</td><td class="mono">@t.Address</td>
<td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-3"><label class="form-label">Region</label>
<select class="form-select form-select-sm" @bind="t.Region">
@foreach (var e in Enum.GetValues<ModbusRegion>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Address</label>
<input type="number" class="form-control form-control-sm" @bind="t.Address" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<ModbusDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Byte order</label>
<select class="form-select form-select-sm" @bind="t.ByteOrder">
@foreach (var e in Enum.GetValues<ModbusByteOrder>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-2"><label class="form-label">Bit index</label>
<input type="number" class="form-control form-control-sm" @bind="t.BitIndex" /></div>
<div class="col-md-2"><label class="form-label">String len</label>
<input type="number" class="form-control form-control-sm" @bind="t.StringLength" /></div>
<div class="col-md-2"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
</EditTemplate>
</CollectionEditor>
<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>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -336,7 +305,6 @@ else
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private List<Namespace> _namespaces = new();
private bool _loaded;
private bool _busy;
private string? _error;
@@ -347,16 +315,9 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Tags is a collection — edited via the CollectionEditor modal.
private List<ModbusTagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_namespaces = await db.Namespaces.AsNoTracking()
.Where(n => n.ClusterId == ClusterId)
.OrderBy(n => n.NamespaceId)
.ToListAsync();
if (IsNew)
{
@@ -365,7 +326,6 @@ else
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "",
Enabled = true,
};
_form = new FormModel();
@@ -381,14 +341,12 @@ else
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
NamespaceId = _existing.NamespaceId,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new ModbusDriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_tags = opts.Tags.Select(ModbusTagRow.FromDefinition).ToList();
}
}
_loaded = true;
@@ -399,7 +357,7 @@ else
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts);
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -412,7 +370,6 @@ else
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
NamespaceId = _identityModel.NamespaceId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
@@ -426,7 +383,6 @@ else
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;
@@ -470,7 +426,7 @@ else
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts);
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
private static ModbusDriverOptions? TryDeserialize(string json)
{
@@ -478,60 +434,6 @@ else
catch { return null; }
}
// Mutable VM for the modal editor — ModbusTagDefinition is an immutable record.
public sealed class ModbusTagRow
{
public string Name { get; set; } = "";
public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters;
public int Address { get; set; }
public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
public bool Writable { get; set; } = true;
public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
public int BitIndex { get; set; }
public int StringLength { get; set; }
public bool WriteIdempotent { get; set; }
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (StringByteOrder, ArrayCount, Deadband, UnitId, CoalesceProhibited) across a load→save.
private ModbusTagDefinition? _source;
public ModbusTagRow Clone() => (ModbusTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static ModbusTagRow FromDefinition(ModbusTagDefinition d) => new()
{
Name = d.Name, Region = d.Region, Address = d.Address, DataType = d.DataType,
Writable = d.Writable, ByteOrder = d.ByteOrder, BitIndex = d.BitIndex,
StringLength = d.StringLength, WriteIdempotent = d.WriteIdempotent,
_source = d,
};
public ModbusTagDefinition ToDefinition()
{
var baseDef = _source ?? new ModbusTagDefinition(Name.Trim(), Region, 0, DataType);
return baseDef with
{
Name = Name.Trim(),
Region = Region,
Address = (ushort)Math.Clamp(Address, 0, 65535),
DataType = DataType,
Writable = Writable,
ByteOrder = ByteOrder,
BitIndex = (byte)Math.Clamp(BitIndex, 0, 255),
StringLength = (ushort)Math.Clamp(StringLength, 0, 65535),
WriteIdempotent = WriteIdempotent,
};
}
public static string? ValidateRow(ModbusTagRow row, IReadOnlyList<ModbusTagRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
return $"Duplicate tag name '{row.Name}'.";
return null;
}
}
// Flat mutable model — all scalars exposed as settable properties so Blazor @bind-Value works.
// Collection (Tags) is kept on the component (_tags) and passed in when building the final Options.
public sealed class FormModel
@@ -623,13 +525,12 @@ else
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public ModbusDriverOptions ToOptions(IReadOnlyList<ModbusTagDefinition> tags) => new()
public ModbusDriverOptions ToOptions() => new()
{
Host = Host,
Port = Port,
UnitId = (byte)Math.Clamp(UnitId, 0, 255),
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Tags = tags,
Probe = new ModbusProbeOptions
{
Enabled = ProbeEnabled,
@@ -36,7 +36,7 @@ else
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
@@ -285,7 +285,6 @@ else
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private List<Namespace> _namespaces = new();
private bool _loaded;
private bool _busy;
private string? _error;
@@ -305,10 +304,6 @@ else
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)
{
@@ -317,7 +312,6 @@ else
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "",
Enabled = true,
};
_form = new FormModel();
@@ -333,7 +327,6 @@ else
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
NamespaceId = _existing.NamespaceId,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new OpcUaClientDriverOptions();
@@ -366,7 +359,6 @@ else
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
NamespaceId = _identityModel.NamespaceId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
@@ -380,7 +372,6 @@ else
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;
@@ -36,7 +36,7 @@ else
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
@@ -146,37 +146,13 @@ else
</section>
@* Tags *@
<CollectionEditor TRow="S7TagRow" Items="_tags" Title="Tags" ItemNoun="tag"
AnimationDelay=".11s"
NewRow="@(() => new S7TagRow())" Clone="@(r => r.Clone())"
Validate="S7TagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td class="mono">@t.Address</td>
<td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-6"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" @bind="t.Address"
placeholder="e.g. DB1.DBW0, M0.0, I0.0, QD4" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<S7DataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">String length</label>
<input type="number" class="form-control form-control-sm" @bind="t.StringLength" />
<div class="form-text">Only for String type. Max 254.</div></div>
<div class="col-md-3"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
</EditTemplate>
</CollectionEditor>
<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>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -202,7 +178,6 @@ else
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;
@@ -212,19 +187,13 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Tags is a collection — edited via the CollectionEditor modal.
private List<S7TagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_namespaces = await db.Namespaces.AsNoTracking()
.Where(n => n.ClusterId == ClusterId)
.OrderBy(n => n.NamespaceId).ToListAsync();
if (IsNew)
{
_identityModel = new() { DriverType = DriverTypeKey, NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", Enabled = true };
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
_form = FormModel.FromOptions(new S7DriverOptions());
}
else
@@ -238,14 +207,12 @@ else
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
NamespaceId = _existing.NamespaceId,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new S7DriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_tags = opts.Tags.Select(S7TagRow.FromDefinition).ToList();
}
}
_loaded = true;
@@ -256,7 +223,7 @@ else
_busy = true; _error = null;
try
{
var opts = _form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList());
var opts = _form.ToOptions();
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
@@ -269,7 +236,6 @@ else
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
NamespaceId = _identityModel.NamespaceId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
@@ -283,7 +249,6 @@ else
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;
@@ -328,7 +293,7 @@ else
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts);
_form.ToOptions(), _jsonOpts);
private static S7DriverOptions? TryDeserialize(string json)
{
@@ -336,53 +301,7 @@ else
catch { return null; }
}
// Mutable VM for the modal editor — S7TagDefinition is an immutable record.
public sealed class S7TagRow
{
public string Name { get; set; } = "";
public string Address { get; set; } = "";
public S7DataType DataType { get; set; } = S7DataType.Int16;
public bool Writable { get; set; } = true;
public int StringLength { get; set; } = 254;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent) across a load→save.
private S7TagDefinition? _source;
public S7TagRow Clone() => (S7TagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static S7TagRow FromDefinition(S7TagDefinition d) => new()
{
Name = d.Name, Address = d.Address, DataType = d.DataType,
Writable = d.Writable, StringLength = d.StringLength,
_source = d,
};
public S7TagDefinition ToDefinition()
{
var baseDef = _source ?? new S7TagDefinition(Name.Trim(), Address.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
Address = Address.Trim(),
DataType = DataType,
Writable = Writable,
StringLength = StringLength,
};
}
public static string? ValidateRow(S7TagRow row, IReadOnlyList<S7TagRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
return $"Duplicate tag name '{row.Name}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collection (Tags) is kept on the component (_tags) and passed in on ToOptions().
public sealed class FormModel
{
// Connection
@@ -417,7 +336,7 @@ else
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public S7DriverOptions ToOptions(IReadOnlyList<S7TagDefinition> tags) => new()
public S7DriverOptions ToOptions() => new()
{
Host = Host,
Port = Port,
@@ -432,7 +351,6 @@ else
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
Tags = tags,
};
}
}
@@ -36,7 +36,7 @@ else
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" Namespaces="_namespaces" IsNew="IsNew" ShowDriverType="false" />
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
@@ -157,37 +157,13 @@ else
</CollectionEditor>
@* Tags *@
<CollectionEditor TRow="TwinCATTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
AnimationDelay=".14s"
NewRow="@(() => new TwinCATTagRow())" Clone="@(r => r.Clone())"
Validate="TwinCATTagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Device</th><th>Symbol path</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td class="mono">@t.DeviceHostAddress</td>
<td class="mono">@t.SymbolPath</td><td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
</RowTemplate>
<EditTemplate Context="t">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Name</label>
<input class="form-control form-control-sm" @bind="t.Name" /></div>
<div class="col-md-6"><label class="form-label">Device host address</label>
<input class="form-control form-control-sm mono" @bind="t.DeviceHostAddress"
placeholder="192.168.0.1.1.1:851" /></div>
<div class="col-md-6"><label class="form-label">Symbol path</label>
<input class="form-control form-control-sm mono" @bind="t.SymbolPath"
placeholder="e.g. MAIN.bStart, GVL.Counter" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" @bind="t.DataType">
@foreach (var e in Enum.GetValues<TwinCATDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
</div>
</EditTemplate>
</CollectionEditor>
<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>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -213,7 +189,6 @@ else
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;
@@ -223,20 +198,16 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
// Held separately because Devices is a collection — edited via the CollectionEditor modal.
private List<TwinCATDeviceRow> _devices = [];
private List<TwinCATTagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_namespaces = await db.Namespaces.AsNoTracking()
.Where(n => n.ClusterId == ClusterId)
.OrderBy(n => n.NamespaceId).ToListAsync();
if (IsNew)
{
_identityModel = new() { DriverType = DriverTypeKey, NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", Enabled = true };
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
_form = FormModel.FromOptions(new TwinCATDriverOptions());
}
else
@@ -250,7 +221,6 @@ else
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
NamespaceId = _existing.NamespaceId,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new TwinCATDriverOptions();
@@ -258,7 +228,6 @@ else
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(TwinCATDeviceRow.FromDefinition).ToList();
_tags = opts.Tags.Select(TwinCATTagRow.FromDefinition).ToList();
}
}
_loaded = true;
@@ -271,8 +240,7 @@ else
{
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
@@ -285,7 +253,6 @@ else
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
NamespaceId = _identityModel.NamespaceId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
@@ -299,7 +266,6 @@ else
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;
@@ -345,8 +311,7 @@ else
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static TwinCATDriverOptions? TryDeserialize(string json)
@@ -393,53 +358,8 @@ else
}
}
// Mutable VM for the modal editor — TwinCATTagDefinition is an immutable record.
public sealed class TwinCATTagRow
{
public string Name { get; set; } = "";
public string DeviceHostAddress { get; set; } = "";
public string SymbolPath { get; set; } = "";
public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt;
public bool Writable { get; set; } = true;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent) across a load→save.
private TwinCATTagDefinition? _source;
public TwinCATTagRow Clone() => (TwinCATTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static TwinCATTagRow FromDefinition(TwinCATTagDefinition d) => new()
{
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, SymbolPath = d.SymbolPath,
DataType = d.DataType, Writable = d.Writable,
_source = d,
};
public TwinCATTagDefinition ToDefinition()
{
var baseDef = _source ?? new TwinCATTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), SymbolPath.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
DeviceHostAddress = DeviceHostAddress.Trim(),
SymbolPath = SymbolPath.Trim(),
DataType = DataType,
Writable = Writable,
};
}
public static string? ValidateRow(TwinCATTagRow row, IReadOnlyList<TwinCATTagRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
return $"Duplicate tag name '{row.Name}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
// The Devices collection is kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Options
@@ -471,8 +391,7 @@ else
};
public TwinCATDriverOptions ToOptions(
IReadOnlyList<TwinCATDeviceOptions> devices,
IReadOnlyList<TwinCATTagDefinition> tags) => new()
IReadOnlyList<TwinCATDeviceOptions> devices) => new()
{
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
UseNativeNotifications = UseNativeNotifications,
@@ -486,7 +405,6 @@ else
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
Devices = devices,
Tags = tags,
};
}
}
@@ -1,243 +1,25 @@
@page "/clusters/{ClusterId}/namespaces/new"
@page "/clusters/{ClusterId}/namespaces/{NamespaceId}"
@* Live-edit form pattern — one page handles both create (NamespaceId is null) and update.
RowVersion is preserved across post-back so EF Core enforces last-write-wins; concurrency
conflicts surface as a toast and reload the current row. *@
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@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 namespace" : "Edit namespace") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/namespaces" class="btn btn-outline-secondary btn-sm">Cancel</a>
<h4 class="mb-0">Namespaces &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/namespaces" class="btn btn-outline-secondary btn-sm">Back</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="namespaces" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Namespace <span class="mono">@NamespaceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="namespaceEdit">
<DataAnnotationsValidator />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">@(IsNew ? "Identity" : $"Edit {_form.NamespaceId}")</div>
<div style="padding:1rem">
<div class="mb-3">
<label class="form-label" for="nsId">NamespaceId</label>
<InputText id="nsId" @bind-Value="_form.NamespaceId" disabled="@(!IsNew)"
class="form-control form-control-sm mono"
placeholder="LINE3-equipment" />
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="kind">Kind</label>
<InputSelect id="kind" @bind-Value="_form.Kind" class="form-select form-select-sm">
<option value="@NamespaceKind.Equipment">Equipment (raw signals)</option>
<option value="@NamespaceKind.Simulated">Simulated (replay — reserved)</option>
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="enabled">Enabled</label>
<div class="form-check form-switch">
<InputCheckbox id="enabled" @bind-Value="_form.Enabled" class="form-check-input" />
<label class="form-check-label" for="enabled">Active in deployments</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="uri">NamespaceUri</label>
<InputText id="uri" @bind-Value="_form.NamespaceUri" class="form-control form-control-sm mono"
placeholder="urn:zb:warsaw-west:equipment" />
<div class="form-text">Must be unique fleet-wide. Clients pin discovery here.</div>
</div>
<div class="mb-3">
<label class="form-label" for="notes">Notes</label>
<InputTextArea id="notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
</div>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
}
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
<a href="/clusters/@ClusterId/namespaces" class="btn btn-outline-secondary">Cancel</a>
@if (!IsNew)
{
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">
Delete
</button>
}
</div>
</EditForm>
}
<section class="panel notice rise" style="animation-delay:.02s">
<div class="panel-head">Namespaces are retired in v3</div>
<div style="padding:1rem">
The <code>Namespace</code> entity has been removed. The v3 Raw/UNS split replaces it —
raw driver folders and the unified namespace are managed on the <strong>/raw</strong> and
<strong>/uns</strong> pages, arriving in v3 Batch 2/3. This editor no longer applies.
</div>
</section>
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? NamespaceId { get; set; }
private bool IsNew => string.IsNullOrEmpty(NamespaceId);
private FormModel _form = new();
private Namespace? _existing;
private bool _loaded;
private bool _busy;
private string? _error;
protected override async Task OnInitializedAsync()
{
if (IsNew)
{
_form = new FormModel
{
NamespaceId = "",
Kind = NamespaceKind.Equipment,
NamespaceUri = "",
Enabled = true,
};
}
else
{
await using var db = await DbFactory.CreateDbContextAsync();
_existing = await db.Namespaces.AsNoTracking()
.FirstOrDefaultAsync(n => n.ClusterId == ClusterId && n.NamespaceId == NamespaceId);
if (_existing is not null)
{
_form = new FormModel
{
NamespaceId = _existing.NamespaceId,
Kind = _existing.Kind,
NamespaceUri = _existing.NamespaceUri,
Enabled = _existing.Enabled,
Notes = _existing.Notes,
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.Namespaces.AnyAsync(n => n.NamespaceId == _form.NamespaceId))
{
_error = $"Namespace '{_form.NamespaceId}' already exists.";
return;
}
db.Namespaces.Add(new Namespace
{
NamespaceId = _form.NamespaceId,
ClusterId = ClusterId,
Kind = _form.Kind,
NamespaceUri = _form.NamespaceUri,
Enabled = _form.Enabled,
Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes,
});
}
else
{
var entity = await db.Namespaces.FirstOrDefaultAsync(
n => n.ClusterId == ClusterId && n.NamespaceId == NamespaceId);
if (entity is null)
{
_error = "Row no longer exists.";
return;
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.Kind = _form.Kind;
entity.NamespaceUri = _form.NamespaceUri;
entity.Enabled = _form.Enabled;
entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/namespaces");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this namespace 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.Namespaces.FirstOrDefaultAsync(
n => n.ClusterId == ClusterId && n.NamespaceId == NamespaceId);
if (entity is null)
{
Nav.NavigateTo($"/clusters/{ClusterId}/namespaces");
return;
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.Namespaces.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/namespaces");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this namespace while you were viewing it. Reload before deleting.";
}
catch (Exception ex)
{
_error = ex.Message;
}
finally
{
_busy = false;
}
}
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$", ErrorMessage = "Use letters, digits, dash, underscore.")]
public string NamespaceId { get; set; } = "";
public NamespaceKind Kind { get; set; } = NamespaceKind.Equipment;
[Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$", ErrorMessage = "Use a URN, e.g. urn:zb:warsaw-west:equipment.")]
public string NamespaceUri { get; set; } = "";
public bool Enabled { get; set; } = true;
public string? Notes { get; set; }
public byte[] RowVersion { get; set; } = [];
}
}
@@ -40,16 +40,6 @@
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
</div>
}
<div class="col-md-6 mb-3">
<label class="form-label" for="ns">Namespace</label>
<InputSelect id="ns" @bind-Value="Model.NamespaceId" class="form-select form-select-sm">
@foreach (var ns in Namespaces)
{
<option value="@ns.NamespaceId">@ns.NamespaceId (@ns.Kind)</option>
}
</InputSelect>
<ValidationMessage For="@(() => Model.NamespaceId)" />
</div>
</div>
<div class="mb-3">
<label class="form-label" for="enabled">Enabled</label>
@@ -63,7 +53,6 @@
@code {
[Parameter, EditorRequired] public DriverIdentityModel Model { get; set; } = new();
[Parameter, EditorRequired] public IReadOnlyList<Namespace> Namespaces { get; set; } = [];
[Parameter] public bool IsNew { get; set; }
[Parameter] public bool ShowDriverType { get; set; }
@@ -73,7 +62,6 @@
public string DriverInstanceId { get; set; } = "";
[Required] public string Name { get; set; } = "";
[Required] public string DriverType { get; set; } = "Modbus";
[Required] public string NamespaceId { get; set; } = "";
public bool Enabled { get; set; } = true;
}
}
@@ -1,10 +1,10 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
<div class="row g-2">
<div class="col-md-12"><label class="form-label">Upstream node reference (FullName)</label>
<input type="text" class="form-control form-control-sm mono" value="@_m.FullName"
<div class="col-md-12"><label class="form-label">Upstream node reference (NodeId)</label>
<input type="text" class="form-control form-control-sm mono" value="@_m.NodeId"
placeholder="nsu=urn:server:ns;s=Line3.Temp"
@onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? string.Empty))" />
@onchange="@(e => Update(() => _m.NodeId = e.Value?.ToString() ?? string.Empty))" />
<div class="form-text">The remote OPC UA NodeId the driver reads/writes/subscribes against. Use the browse picker on the driver page to find it.</div></div>
</div>
@@ -140,12 +140,13 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
// Only the resolvable keys are projected, so no UNS join is needed: the equipment-tag key is
// the FullName from TagConfig, the FolderPath-scoped tag key is FolderPath/Name, and the
// virtual-tag key is its own Name. Pull just those columns, untracked.
// v3: Tag is Raw-only — EquipmentId / FolderPath / DriverInstanceId were retired (the
// equipment↔Tag + driver bindings are gone). The resolvable key is the driver FullName carried
// in TagConfig; project only the surviving columns. (The Raw/UNS tag catalog is rebuilt in
// Batch 2/3.)
var tagRows = await db.Tags
.AsNoTracking()
.Select(t => new { t.EquipmentId, t.Name, t.FolderPath, t.DataType, t.DriverInstanceId, t.TagConfig })
.Select(t => new { t.Name, t.DataType, t.TagConfig })
.ToListAsync(ct);
var vtagRows = await db.VirtualTags
@@ -157,13 +158,10 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
foreach (var t in tagRows)
{
var path = t.EquipmentId is null
// FolderPath-scoped tag (EquipmentId null) — subscribes by the MXAccess dot-ref, which
// is "FolderPath.Name" when a folder is set, else just "Name".
? (string.IsNullOrWhiteSpace(t.FolderPath) ? t.Name : $"{t.FolderPath}.{t.Name}")
// Equipment driver tag — the runtime GetTag key is the driver FullName from TagConfig.
: ExtractFullNameFromTagConfig(t.TagConfig);
entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, t.DriverInstanceId));
// The runtime GetTag key is the driver FullName from TagConfig; fall back to Name if absent.
var full = ExtractFullNameFromTagConfig(t.TagConfig);
var path = string.IsNullOrWhiteSpace(full) ? t.Name : full;
entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, null));
}
foreach (var v in vtagRows)
@@ -185,7 +183,10 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
/// </summary>
private static string ExtractFullNameFromTagConfig(string tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
// Best-effort: pull the driver FullName out of the TagConfig JSON blob if present.
// Returns "" when absent/unparseable so the caller falls back to the tag Name — never
// the raw blob (which would surface as a garbage completion path).
if (string.IsNullOrWhiteSpace(tagConfig)) return "";
try
{
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
@@ -193,10 +194,10 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
&& fullName.ValueKind == System.Text.Json.JsonValueKind.String)
{
return fullName.GetString() ?? tagConfig;
return fullName.GetString() ?? "";
}
}
catch (System.Text.Json.JsonException) { /* fall through to raw blob */ }
return tagConfig;
catch (System.Text.Json.JsonException) { /* fall through to tag Name */ }
return "";
}
}
@@ -51,14 +51,10 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
})
.ToListAsync(ct);
// Per-equipment driver-tag counts (tags with no equipment are excluded).
var tagCounts = (await db.Tags
.AsNoTracking()
.Where(t => t.EquipmentId != null)
.GroupBy(t => t.EquipmentId)
.Select(g => new { EquipmentId = g.Key!, Count = g.Count() })
.ToListAsync(ct))
.ToDictionary(x => x.EquipmentId, x => x.Count, StringComparer.Ordinal);
// Per-equipment driver-tag counts: the equipment↔Tag binding was retired in v3
// (Tag is now Raw-only; authoring moved to the Raw tree in Batch 2/3). No binding
// exists to count against, so every equipment reports zero driver tags.
var tagCounts = new Dictionary<string, int>(StringComparer.Ordinal);
// Per-equipment virtual-tag counts (virtual tags with no equipment are excluded).
var vtagCounts = (await db.VirtualTags
@@ -83,14 +79,11 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
public Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.Tags.AsNoTracking()
.Where(t => t.EquipmentId == equipmentId)
.OrderBy(t => t.Name)
.Select(t => new EquipmentTagRow(t.TagId, t.Name, t.DriverInstanceId, t.DataType, t.AccessLevel))
.ToListAsync(ct);
// The equipment↔Tag binding was retired in v3 (Tag is now Raw-only; authoring moved to
// the Raw tree in Batch 2/3). No equipment-bound tags exist to list.
return Task.FromResult<IReadOnlyList<EquipmentTagRow>>(Array.Empty<EquipmentTagRow>());
}
/// <inheritdoc />
@@ -141,7 +134,8 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
e.Name,
e.MachineCode,
e.UnsLineId,
e.DriverInstanceId,
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
null,
e.ZTag,
e.SAPID,
e.Manufacturer,
@@ -168,9 +162,10 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.Where(t => t.TagId == tagId)
.Select(t => new TagEditDto(
t.TagId,
t.EquipmentId!,
// Tag is Raw-only in v3 — equipment binding + driver binding retired.
string.Empty,
t.Name,
t.DriverInstanceId,
string.Empty,
t.DataType,
t.AccessLevel,
t.WriteIdempotent,
@@ -293,37 +288,8 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
// A cluster move must not orphan driver-bound equipment from its driver's
// cluster. Any equipment under this area that is bound to a driver in a different cluster
// than the target blocks the move.
if (newClusterId != entity.ClusterId)
{
var lineIds = await db.UnsLines
.Where(l => l.UnsAreaId == unsAreaId)
.Select(l => l.UnsLineId)
.ToListAsync(ct);
var boundEquipment = await db.Equipment
.Where(eq => lineIds.Contains(eq.UnsLineId) && eq.DriverInstanceId != null)
.Select(eq => new { eq.EquipmentId, eq.DriverInstanceId })
.ToListAsync(ct);
foreach (var eq in boundEquipment)
{
var driverCluster = await db.DriverInstances
.Where(d => d.DriverInstanceId == eq.DriverInstanceId)
.Select(d => d.ClusterId)
.FirstOrDefaultAsync(ct);
if (driverCluster is not null && driverCluster != newClusterId)
{
return new UnsMutationResult(
false,
$"Cannot move area to '{newClusterId}': equipment '{eq.EquipmentId}' is bound to a driver in cluster '{driverCluster}' (decision #122). Re-home or unbind it first.");
}
}
}
// The equipment↔driver binding was retired in v3, so a cluster move can no longer
// orphan driver-bound equipment — the old cross-cluster binding guard is gone.
entity.Name = name;
entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes;
entity.ClusterId = newClusterId;
@@ -416,40 +382,8 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
// A reparent to a different area must not orphan driver-bound equipment
// from its driver's cluster. Resolve the new area's cluster and check every bound
// equipment item under this line against it.
if (newUnsAreaId != entity.UnsAreaId)
{
var newAreaCluster = await db.UnsAreas
.Where(a => a.UnsAreaId == newUnsAreaId)
.Select(a => (string?)a.ClusterId)
.FirstOrDefaultAsync(ct);
if (newAreaCluster is not null)
{
var boundEquipment = await db.Equipment
.Where(eq => eq.UnsLineId == unsLineId && eq.DriverInstanceId != null)
.Select(eq => new { eq.EquipmentId, eq.DriverInstanceId })
.ToListAsync(ct);
foreach (var eq in boundEquipment)
{
var driverCluster = await db.DriverInstances
.Where(d => d.DriverInstanceId == eq.DriverInstanceId)
.Select(d => d.ClusterId)
.FirstOrDefaultAsync(ct);
if (driverCluster is not null && driverCluster != newAreaCluster)
{
return new UnsMutationResult(
false,
$"Cannot move line to area '{newUnsAreaId}': equipment '{eq.EquipmentId}' is bound to a driver in cluster '{driverCluster}' (decision #122). Re-home or unbind it first.");
}
}
}
}
// The equipment↔driver binding was retired in v3, so a line reparent can no longer
// orphan driver-bound equipment — the old cross-cluster binding guard is gone.
entity.UnsAreaId = newUnsAreaId;
entity.Name = name;
entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes;
@@ -525,7 +459,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId,
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
UnsLineId = input.UnsLineId,
Name = input.Name,
MachineCode = input.MachineCode,
@@ -596,7 +530,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
DriverInstanceId = string.IsNullOrWhiteSpace(row.DriverInstanceId) ? null : row.DriverInstanceId,
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
UnsLineId = row.UnsLineId,
Name = row.Name,
MachineCode = row.MachineCode,
@@ -650,7 +584,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return guard.Value;
}
entity.DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId;
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
entity.UnsLineId = input.UnsLineId;
entity.Name = input.Name;
entity.MachineCode = input.MachineCode;
@@ -803,10 +737,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return "cluster nodes";
}
if (await db.Namespaces.AnyAsync(ns => ns.ClusterId == clusterId, ct))
{
return "namespaces";
}
// The Namespace entity was retired in v3 (Raw/UNS split); no namespaces child-check remains.
if (await db.UnsAreas.AnyAsync(a => a.ClusterId == clusterId, ct))
{
@@ -826,141 +757,40 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
$"Delete failed: {blocker} still reference cluster '{clusterId}' — remove them first.";
/// <inheritdoc />
public async Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType, string DriverConfig)>> LoadTagDriversForEquipmentAsync(
public Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType, string DriverConfig)>> LoadTagDriversForEquipmentAsync(
string equipmentId,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (equipmentCluster is null)
{
return Array.Empty<(string, string, string, string)>();
}
// Drivers in the equipment's cluster whose namespace is Equipment-kind.
// GalaxyMxGateway is an ordinary Equipment-kind driver post-de-split, so it surfaces here
// alongside the PLC drivers; its DriverConfig feeds the Galaxy live-browse address picker.
var equipmentNamespaceIds = await db.Namespaces
.Where(n => n.ClusterId == equipmentCluster && n.Kind == NamespaceKind.Equipment)
.Select(n => n.NamespaceId)
.ToListAsync(ct);
var drivers = await db.DriverInstances
.Where(d => d.ClusterId == equipmentCluster && equipmentNamespaceIds.Contains(d.NamespaceId))
.OrderBy(d => d.DriverInstanceId)
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverType, d.DriverConfig })
.ToListAsync(ct);
return drivers
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverType, d.DriverConfig))
.ToList();
// The equipment↔driver binding and the Namespace entity were retired in v3 (Raw/UNS split).
// Equipment no longer scopes a candidate-driver list; tag/driver authoring moved to the Raw
// tree in Batch 2/3. No candidate drivers to offer.
return Task.FromResult<IReadOnlyList<(string, string, string, string)>>(
Array.Empty<(string, string, string, string)>());
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateTagAsync(
public Task<UnsMutationResult> CreateTagAsync(
string equipmentId,
TagInput input,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (await db.Tags.AnyAsync(t => t.TagId == input.TagId, ct))
{
return new UnsMutationResult(false, $"Tag '{input.TagId}' already exists.");
}
if (!IsValidJson(input.TagConfig))
{
return new UnsMutationResult(false, "TagConfig is not valid JSON.");
}
if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
var guard = await CheckTagDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
if (guard is not null)
{
return guard.Value;
}
if (await db.Tags.AnyAsync(t => t.EquipmentId == equipmentId && t.Name == input.Name, ct))
{
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
}
db.Tags.Add(new Tag
{
TagId = input.TagId,
DriverInstanceId = input.DriverInstanceId,
EquipmentId = equipmentId,
Name = input.Name,
FolderPath = null,
DataType = input.DataType,
AccessLevel = input.AccessLevel,
WriteIdempotent = input.WriteIdempotent,
PollGroupId = string.IsNullOrWhiteSpace(input.PollGroupId) ? null : input.PollGroupId,
TagConfig = input.TagConfig,
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
// Equipment-bound tag authoring was retired in v3: Tag is now Raw-only and the equipment↔Tag
// binding no longer exists. Authoring moved to the Raw tree (/raw) in v3 Batch 2.
return Task.FromResult(new UnsMutationResult(
false, "Tag authoring moved to the Raw tree (/raw) in v3 Batch 2."));
}
/// <inheritdoc />
public async Task<UnsMutationResult> UpdateTagAsync(
public Task<UnsMutationResult> UpdateTagAsync(
string tagId,
TagInput input,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct);
if (entity is null)
{
return new UnsMutationResult(false, "Row no longer exists.");
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
if (!IsValidJson(input.TagConfig))
{
return new UnsMutationResult(false, "TagConfig is not valid JSON.");
}
var equipmentCluster = await ResolveEquipmentClusterAsync(db, entity.EquipmentId, ct);
var guard = await CheckTagDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
if (guard is not null)
{
return guard.Value;
}
if (await db.Tags.AnyAsync(
t => t.EquipmentId == entity.EquipmentId && t.Name == input.Name && t.TagId != tagId,
ct))
{
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
}
// EquipmentId and FolderPath (null) are preserved — tree tags are always equipment-bound.
entity.DriverInstanceId = input.DriverInstanceId;
entity.Name = input.Name;
entity.DataType = input.DataType;
entity.AccessLevel = input.AccessLevel;
entity.WriteIdempotent = input.WriteIdempotent;
entity.PollGroupId = string.IsNullOrWhiteSpace(input.PollGroupId) ? null : input.PollGroupId;
entity.TagConfig = input.TagConfig;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this tag while you were editing.");
}
// Equipment-bound tag editing was retired in v3: Tag is now Raw-only and the equipment↔Tag
// binding no longer exists. Authoring moved to the Raw tree (/raw) in v3 Batch 2.
return Task.FromResult(new UnsMutationResult(
false, "Tag authoring moved to the Raw tree (/raw) in v3 Batch 2."));
}
/// <inheritdoc />
@@ -1048,9 +878,9 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null;
var configs = await db.Tags.Where(t => t.EquipmentId == equipmentId)
.Select(t => t.TagConfig).ToListAsync(ct);
var fullNames = configs.Select(TagConfigFullName.Extract);
// The equipment↔Tag binding was retired in v3 (Tag is Raw-only), so no equipment-bound driver
// tags exist to derive an equipment tag base from. The {{equip}} token can't be resolved here.
var fullNames = Array.Empty<string>();
if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null)
{
return new UnsMutationResult(false,
@@ -1350,11 +1180,8 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
}
var ns = await db.Namespaces.FirstOrDefaultAsync(n => n.NamespaceId == driver.NamespaceId, ct);
if (ns?.Kind != NamespaceKind.Equipment)
{
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not in an Equipment-kind namespace.");
}
// The Namespace entity + NamespaceKind were retired in v3 (Raw/UNS split); the
// driver-in-Equipment-namespace check no longer applies.
if (driver.ClusterId != equipmentCluster)
{