feat(adminui): alias DTO + Galaxy gateway lookup + Source/IsAlias on tag rows

This commit is contained in:
Joseph Doherty
2026-06-11 21:05:02 -04:00
parent 2ba2f8a899
commit 4b4738a891
5 changed files with 291 additions and 4 deletions
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>
/// Operator-editable fields for a Galaxy alias tag (an equipment Tag bound to the Galaxy gateway).
/// <paramref name="FullName"/> is the picked Galaxy reference (tag_name.AttributeName); it is stored
/// as the tag's TagConfig <c>{"FullName":…}</c>. AccessLevel defaults to ReadOnly at the call site.
/// </summary>
/// <param name="TagId">Stable unique tag id; only honoured on create.</param>
/// <param name="Name">Tag display name; unique within the owning equipment.</param>
/// <param name="DriverInstanceId">The bound Galaxy gateway driver instance.</param>
/// <param name="DataType">OPC UA built-in type name (Boolean / Int32 / Float / etc.).</param>
/// <param name="AccessLevel">Tag-level OPC UA access baseline (default ReadOnly).</param>
/// <param name="FullName">The Galaxy reference (tag_name.AttributeName) this alias surfaces.</param>
public sealed record AliasTagInput(
string TagId, string Name, string DriverInstanceId, string DataType,
TagAccessLevel AccessLevel, string FullName);
@@ -3,8 +3,13 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>A tag row for the equipment page's Tags tab table — display columns plus the id used to
/// open the edit modal. RowVersion is re-read fresh on delete (matching the tree's delete path).</summary>
public sealed record EquipmentTagRow(string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel);
/// open the edit modal. RowVersion is re-read fresh on delete (matching the tree's delete path).
/// Galaxy alias rows (tags bound to a <c>GalaxyMxGateway</c> driver) carry <c>IsAlias = true</c> and a
/// <c>Source</c> of <c>"galaxy:&lt;FullName&gt;"</c> taken from the tag's <c>TagConfig</c>; ordinary
/// equipment tags carry <c>IsAlias = false</c> and a <c>null</c> <c>Source</c>.</summary>
public sealed record EquipmentTagRow(
string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel,
bool IsAlias = false, string? Source = null);
/// <summary>A virtual-tag row for the equipment page's Virtual Tags tab table.</summary>
public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled);
@@ -344,6 +344,11 @@ public interface IUnsTreeService
/// where <c>DriverType</c> lets the TagModal dispatch to a per-driver-type typed config editor.</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>Galaxy gateway driver instances (DriverType "GalaxyMxGateway") in the equipment's
/// cluster, for the alias address picker. Tuple = (DriverInstanceId, Display, DriverConfig).</summary>
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)>>
LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (decision #110 —
/// the tree only edits equipment-bound tags). Fails on a duplicate <c>TagId</c>, invalid
@@ -86,11 +86,34 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
public async Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.Tags.AsNoTracking()
// Left-join each tag to its driver so we can tell Galaxy aliases apart while still surfacing a
// tag whose driver row is missing (it is simply treated as a non-alias). EF can't parse the
// TagConfig JSON in-query, so we materialise then map IsAlias/Source in memory.
var rows = 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))
.GroupJoin(db.DriverInstances.AsNoTracking(), t => t.DriverInstanceId, d => d.DriverInstanceId,
(t, ds) => new { Tag = t, Drivers = ds })
.SelectMany(x => x.Drivers.DefaultIfEmpty(),
(x, d) => new
{
x.Tag.TagId,
x.Tag.Name,
x.Tag.DriverInstanceId,
x.Tag.DataType,
x.Tag.AccessLevel,
DriverType = d != null ? d.DriverType : null,
x.Tag.TagConfig,
})
.ToListAsync(ct);
return rows.Select(r =>
{
var isAlias = r.DriverType == "GalaxyMxGateway";
var source = isAlias ? $"galaxy:{ExtractTagConfigFullName(r.TagConfig)}" : null;
return new EquipmentTagRow(r.TagId, r.Name, r.DriverInstanceId, r.DataType, r.AccessLevel, isAlias, source);
}).ToList();
}
/// <inheritdoc />
@@ -740,6 +763,29 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)>>
LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var cluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (cluster is null)
{
return Array.Empty<(string, string, string)>();
}
var gateways = await db.DriverInstances
.Where(d => d.ClusterId == cluster && d.DriverType == "GalaxyMxGateway")
.OrderBy(d => d.DriverInstanceId)
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverConfig })
.ToListAsync(ct);
return gateways
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverConfig))
.ToList();
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateTagAsync(
string equipmentId,
@@ -1153,6 +1199,32 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
}
/// <summary>
/// Extracts the <c>FullName</c> string from a tag's <c>TagConfig</c> JSON (the Galaxy reference an
/// alias surfaces), or <c>null</c> when the config is empty, not a JSON object, lacks a string
/// <c>FullName</c>, or is malformed. A small local copy mirrors the composer's own extraction —
/// consistent with this codebase, where the composer and validator each keep their own.
/// </summary>
private static string? ExtractTagConfigFullName(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig))
{
return null;
}
try
{
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
&& doc.RootElement.TryGetProperty("FullName", out var fn)
&& fn.ValueKind == System.Text.Json.JsonValueKind.String ? fn.GetString() : null;
}
catch (System.Text.Json.JsonException)
{
return null;
}
}
/// <summary>
/// Resolves an equipment to its cluster via <c>Equipment.UnsLineId → UnsLine.UnsAreaId →
/// UnsArea.ClusterId</c>. Returns <c>null</c> when the equipment, its line, or its area cannot be