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:
@@ -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