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:
@@ -35,7 +35,6 @@ else
|
||||
· <span>@d.Name</span>
|
||||
· <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">
|
||||
|
||||
+9
-67
@@ -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 · <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();
|
||||
}
|
||||
}
|
||||
|
||||
+13
-97
@@ -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,
|
||||
|
||||
+11
-95
@@ -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,
|
||||
|
||||
+12
-94
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -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;
|
||||
|
||||
+11
-110
@@ -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,
|
||||
|
||||
+1
-10
@@ -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;
|
||||
|
||||
+12
-94
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+14
-96
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+10
-228
@@ -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") · <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 · <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; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
-12
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user