feat(adminui): alias DTO + Galaxy gateway lookup + Source/IsAlias on tag rows
This commit is contained in:
@@ -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;
|
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
|
/// <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>
|
/// open the edit modal. RowVersion is re-read fresh on delete (matching the tree's delete path).
|
||||||
public sealed record EquipmentTagRow(string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel);
|
/// Galaxy alias rows (tags bound to a <c>GalaxyMxGateway</c> driver) carry <c>IsAlias = true</c> and a
|
||||||
|
/// <c>Source</c> of <c>"galaxy:<FullName>"</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>
|
/// <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);
|
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>
|
/// 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);
|
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>
|
/// <summary>
|
||||||
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (decision #110 —
|
/// 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
|
/// 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)
|
public async Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
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)
|
.Where(t => t.EquipmentId == equipmentId)
|
||||||
.OrderBy(t => t.Name)
|
.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);
|
.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 />
|
/// <inheritdoc />
|
||||||
@@ -740,6 +763,29 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
|||||||
.ToList();
|
.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 />
|
/// <inheritdoc />
|
||||||
public async Task<UnsMutationResult> CreateTagAsync(
|
public async Task<UnsMutationResult> CreateTagAsync(
|
||||||
string equipmentId,
|
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>
|
/// <summary>
|
||||||
/// Resolves an equipment to its cluster via <c>Equipment.UnsLineId → UnsLine.UnsAreaId →
|
/// 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
|
/// UnsArea.ClusterId</c>. Returns <c>null</c> when the equipment, its line, or its area cannot be
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the read-side surface for Galaxy alias tags: that
|
||||||
|
/// <see cref="UnsTreeService.LoadGalaxyGatewaysForEquipmentAsync"/> finds the
|
||||||
|
/// <c>GalaxyMxGateway</c> drivers in the equipment's cluster (and only those), and that
|
||||||
|
/// <see cref="UnsTreeService.LoadTagsForEquipmentAsync"/> flags alias tags (those bound to a Galaxy
|
||||||
|
/// gateway) with <c>IsAlias = true</c> and a <c>Source</c> derived from the tag's <c>TagConfig</c>.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class UnsTreeServiceAliasTagTests
|
||||||
|
{
|
||||||
|
private const string ClusterId = "MAIN";
|
||||||
|
private const string EquipmentId = "EQ-ALIAS-1";
|
||||||
|
private const string GatewayDriverId = "DRV-GALAXY";
|
||||||
|
private const string ModbusDriverId = "DRV-MODBUS";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds a cluster with: a SystemPlatform namespace + GalaxyMxGateway driver, an Equipment-kind
|
||||||
|
/// namespace + Modbus driver, and an area→line→equipment path. Returns the InMemory db name.
|
||||||
|
/// </summary>
|
||||||
|
private static string SeedCluster(bool withGalaxyGateway = true)
|
||||||
|
{
|
||||||
|
var dbName = $"uns-alias-{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||||
|
|
||||||
|
db.ServerClusters.Add(new ServerCluster
|
||||||
|
{
|
||||||
|
ClusterId = ClusterId,
|
||||||
|
Name = "Main",
|
||||||
|
Enterprise = "zb",
|
||||||
|
Site = "warsaw-west",
|
||||||
|
RedundancyMode = RedundancyMode.None,
|
||||||
|
CreatedBy = "test",
|
||||||
|
});
|
||||||
|
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = ClusterId, Name = "a" });
|
||||||
|
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
|
||||||
|
db.Equipment.Add(new Equipment
|
||||||
|
{
|
||||||
|
EquipmentId = EquipmentId,
|
||||||
|
EquipmentUuid = Guid.NewGuid(),
|
||||||
|
UnsLineId = "LINE-1",
|
||||||
|
Name = "machine-1",
|
||||||
|
MachineCode = "machine_001",
|
||||||
|
});
|
||||||
|
|
||||||
|
// SystemPlatform namespace hosting the Galaxy gateway.
|
||||||
|
db.Namespaces.Add(new Namespace
|
||||||
|
{
|
||||||
|
NamespaceId = "NS-SP",
|
||||||
|
ClusterId = ClusterId,
|
||||||
|
Kind = NamespaceKind.SystemPlatform,
|
||||||
|
NamespaceUri = "urn:zb:sp",
|
||||||
|
});
|
||||||
|
if (withGalaxyGateway)
|
||||||
|
{
|
||||||
|
db.DriverInstances.Add(new DriverInstance
|
||||||
|
{
|
||||||
|
DriverInstanceId = GatewayDriverId,
|
||||||
|
ClusterId = ClusterId,
|
||||||
|
NamespaceId = "NS-SP",
|
||||||
|
Name = "galaxy gateway",
|
||||||
|
DriverType = "GalaxyMxGateway",
|
||||||
|
DriverConfig = "{\"Galaxy\":{}}",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equipment-kind namespace hosting an ordinary (non-Galaxy) driver.
|
||||||
|
db.Namespaces.Add(new Namespace
|
||||||
|
{
|
||||||
|
NamespaceId = "NS-EQ",
|
||||||
|
ClusterId = ClusterId,
|
||||||
|
Kind = NamespaceKind.Equipment,
|
||||||
|
NamespaceUri = "urn:zb:eq",
|
||||||
|
});
|
||||||
|
db.DriverInstances.Add(new DriverInstance
|
||||||
|
{
|
||||||
|
DriverInstanceId = ModbusDriverId,
|
||||||
|
ClusterId = ClusterId,
|
||||||
|
NamespaceId = "NS-EQ",
|
||||||
|
Name = "modbus driver",
|
||||||
|
DriverType = "Modbus",
|
||||||
|
DriverConfig = "{}",
|
||||||
|
});
|
||||||
|
|
||||||
|
db.SaveChanges();
|
||||||
|
return dbName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The gateway lookup returns exactly the GalaxyMxGateway driver (with its id, a display string,
|
||||||
|
/// and its DriverConfig) and never the cluster's Modbus driver.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadGalaxyGatewaysForEquipment_returns_only_galaxy_gateway()
|
||||||
|
{
|
||||||
|
var dbName = SeedCluster();
|
||||||
|
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||||
|
|
||||||
|
var gateways = await service.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId);
|
||||||
|
|
||||||
|
gateways.Count.ShouldBe(1);
|
||||||
|
gateways[0].DriverInstanceId.ShouldBe(GatewayDriverId);
|
||||||
|
gateways[0].Display.ShouldContain(GatewayDriverId);
|
||||||
|
gateways[0].Display.ShouldContain("galaxy gateway");
|
||||||
|
gateways[0].DriverConfig.ShouldBe("{\"Galaxy\":{}}");
|
||||||
|
gateways.ShouldNotContain(g => g.DriverInstanceId == ModbusDriverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An equipment whose cluster has no GalaxyMxGateway driver yields an empty list.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadGalaxyGatewaysForEquipment_returns_empty_when_no_gateway()
|
||||||
|
{
|
||||||
|
var dbName = SeedCluster(withGalaxyGateway: false);
|
||||||
|
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||||
|
|
||||||
|
var gateways = await service.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId);
|
||||||
|
|
||||||
|
gateways.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A tag bound to the Galaxy gateway is an alias (<c>IsAlias = true</c>, <c>Source</c> derived from
|
||||||
|
/// its TagConfig FullName); a tag bound to the Modbus driver is not (<c>IsAlias = false</c>,
|
||||||
|
/// <c>Source = null</c>).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadTagsForEquipment_flags_galaxy_alias_rows()
|
||||||
|
{
|
||||||
|
var dbName = SeedCluster();
|
||||||
|
|
||||||
|
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||||
|
{
|
||||||
|
db.Tags.Add(new Tag
|
||||||
|
{
|
||||||
|
TagId = "TAG-ALIAS",
|
||||||
|
DriverInstanceId = GatewayDriverId,
|
||||||
|
EquipmentId = EquipmentId,
|
||||||
|
Name = "aliased-speed",
|
||||||
|
DataType = "Float",
|
||||||
|
AccessLevel = TagAccessLevel.Read,
|
||||||
|
TagConfig = "{\"FullName\":\"TestMachine_020.Speed\"}",
|
||||||
|
});
|
||||||
|
db.Tags.Add(new Tag
|
||||||
|
{
|
||||||
|
TagId = "TAG-NORMAL",
|
||||||
|
DriverInstanceId = ModbusDriverId,
|
||||||
|
EquipmentId = EquipmentId,
|
||||||
|
Name = "raw-speed",
|
||||||
|
DataType = "Int32",
|
||||||
|
AccessLevel = TagAccessLevel.Read,
|
||||||
|
TagConfig = "{}",
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||||
|
|
||||||
|
var rows = await service.LoadTagsForEquipmentAsync(EquipmentId);
|
||||||
|
|
||||||
|
var alias = rows.ShouldHaveSingleItem(r => r.TagId == "TAG-ALIAS");
|
||||||
|
alias.IsAlias.ShouldBeTrue();
|
||||||
|
alias.Source.ShouldBe("galaxy:TestMachine_020.Speed");
|
||||||
|
|
||||||
|
var normal = rows.ShouldHaveSingleItem(r => r.TagId == "TAG-NORMAL");
|
||||||
|
normal.IsAlias.ShouldBeFalse();
|
||||||
|
normal.Source.ShouldBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Small Shouldly-style helper for "exactly one match" assertions used by these tests.</summary>
|
||||||
|
internal static class SingleItemAssertions
|
||||||
|
{
|
||||||
|
public static T ShouldHaveSingleItem<T>(this IEnumerable<T> source, Func<T, bool> predicate)
|
||||||
|
{
|
||||||
|
var matches = source.Where(predicate).ToList();
|
||||||
|
matches.Count.ShouldBe(1);
|
||||||
|
return matches[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user