feat(adminui): ConvertRelayVirtualTagsToAliasesAsync (relay VTag -> alias Tag)
This commit is contained in:
@@ -554,4 +554,15 @@ public interface IUnsTreeService
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
|
||||
Task<UnsMutationResult> DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Convert pure-relay VirtualTags (body == return ctx.GetTag("X").Value;) to Galaxy alias
|
||||
/// Tags. <paramref name="equipmentId"/> null = fleet-wide; otherwise scoped to that equipment.
|
||||
/// <paramref name="dryRun"/> = true previews without mutating. FleetAdmin-gated at the call site.
|
||||
/// Operates on the editable config (does not publish).</summary>
|
||||
/// <param name="equipmentId">The equipment to scope to; <c>null</c> sweeps every equipment.</param>
|
||||
/// <param name="dryRun"><c>true</c> previews the conversion without mutating the config.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The per-relay converted and skipped lists, plus whether the pass was applied.</returns>
|
||||
Task<RelayConversionResult> ConvertRelayVirtualTagsToAliasesAsync(
|
||||
string? equipmentId, bool dryRun, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>One relay VirtualTag that will/would become an alias Tag.</summary>
|
||||
/// <param name="EquipmentId">The owning equipment whose relay virtual tag is converted.</param>
|
||||
/// <param name="VirtualTagName">The relay virtual tag's name (becomes the alias tag's name).</param>
|
||||
/// <param name="FullName">The resolved Galaxy reference the alias surfaces (any <c>{{equip}}</c> token expanded).</param>
|
||||
/// <param name="DataType">The OPC UA built-in type name carried over from the virtual tag.</param>
|
||||
public sealed record RelayConversionItem(string EquipmentId, string VirtualTagName, string FullName, string DataType);
|
||||
|
||||
/// <summary>One relay VirtualTag that cannot be converted, with the reason.</summary>
|
||||
/// <param name="EquipmentId">The owning equipment whose virtual tag was skipped.</param>
|
||||
/// <param name="VirtualTagName">The skipped virtual tag's name.</param>
|
||||
/// <param name="Reason">A human-readable explanation of why the virtual tag was not converted.</param>
|
||||
public sealed record RelayConversionSkip(string EquipmentId, string VirtualTagName, string Reason);
|
||||
|
||||
/// <summary>Outcome of a (dry-run or applied) conversion pass.</summary>
|
||||
/// <param name="Converted">The relay virtual tags that were (or, on dry-run, would be) converted to alias tags.</param>
|
||||
/// <param name="Skipped">The candidate virtual tags that could not be converted, each with a reason.</param>
|
||||
/// <param name="Applied"><c>true</c> when the pass mutated the config; <c>false</c> for a dry-run preview.</param>
|
||||
public sealed record RelayConversionResult(
|
||||
IReadOnlyList<RelayConversionItem> Converted,
|
||||
IReadOnlyList<RelayConversionSkip> Skipped,
|
||||
bool Applied);
|
||||
@@ -1032,6 +1032,167 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RelayConversionResult> ConvertRelayVirtualTagsToAliasesAsync(
|
||||
string? equipmentId, bool dryRun, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
// Candidate relay virtual tags: the whole draft, or one equipment when scoped.
|
||||
var candidates = await db.VirtualTags
|
||||
.Where(v => equipmentId == null || v.EquipmentId == equipmentId)
|
||||
.OrderBy(v => v.EquipmentId).ThenBy(v => v.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Source-by-id for every script a candidate references. A small map keeps the per-vtag relay
|
||||
// parse a dictionary lookup rather than a query.
|
||||
var scriptIds = candidates.Select(v => v.ScriptId).Distinct().ToList();
|
||||
var scripts = (await db.Scripts.Where(s => scriptIds.Contains(s.ScriptId)).ToListAsync(ct))
|
||||
.ToDictionary(s => s.ScriptId, s => s, StringComparer.Ordinal);
|
||||
|
||||
var converted = new List<RelayConversionItem>();
|
||||
var skipped = new List<RelayConversionSkip>();
|
||||
var aliasTagsToAdd = new List<Tag>();
|
||||
var toRemoveVtags = new List<VirtualTag>();
|
||||
|
||||
// Per-equipment caches so a sweep resolves each equipment's cluster, gateway, and (lazily) its
|
||||
// derivable {{equip}} base only once.
|
||||
var clusterCache = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
var gatewayCache = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
var equipBaseCache = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var vtag in candidates)
|
||||
{
|
||||
var source = scripts.GetValueOrDefault(vtag.ScriptId)?.SourceCode;
|
||||
if (!EquipmentScriptPaths.TryParseRelayBody(source, out var rawRef) || rawRef is null)
|
||||
{
|
||||
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
|
||||
"Script body is not a pure relay (return ctx.GetTag(\"…\").Value;) — convert manually."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (vtag.Historize)
|
||||
{
|
||||
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
|
||||
"Virtual tag is historized — a Tag has no historize column; convert manually."));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve (and cache) the equipment's cluster and its first Galaxy gateway.
|
||||
if (!gatewayCache.TryGetValue(vtag.EquipmentId, out var gatewayId))
|
||||
{
|
||||
if (!clusterCache.TryGetValue(vtag.EquipmentId, out var cluster))
|
||||
{
|
||||
cluster = await ResolveEquipmentClusterAsync(db, vtag.EquipmentId, ct);
|
||||
clusterCache[vtag.EquipmentId] = cluster;
|
||||
}
|
||||
|
||||
gatewayId = cluster is null
|
||||
? null
|
||||
: await db.DriverInstances
|
||||
.Where(d => d.ClusterId == cluster && d.DriverType == "GalaxyMxGateway")
|
||||
.OrderBy(d => d.DriverInstanceId)
|
||||
.Select(d => d.DriverInstanceId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
gatewayCache[vtag.EquipmentId] = gatewayId;
|
||||
}
|
||||
|
||||
if (gatewayId is null)
|
||||
{
|
||||
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
|
||||
"There is no Galaxy gateway in this equipment's cluster to bind the alias to."));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the alias FullName, expanding the {{equip}} token against the equipment's derivable
|
||||
// tag base (its existing Galaxy-alias tag FullNames) when present.
|
||||
string fullName;
|
||||
if (EquipmentScriptPaths.ContainsEquipToken(rawRef))
|
||||
{
|
||||
if (!equipBaseCache.TryGetValue(vtag.EquipmentId, out var equipBase))
|
||||
{
|
||||
var configs = await db.Tags
|
||||
.Where(t => t.EquipmentId == vtag.EquipmentId && t.DriverInstanceId == gatewayId)
|
||||
.Select(t => t.TagConfig)
|
||||
.ToListAsync(ct);
|
||||
equipBase = EquipmentScriptPaths.DeriveEquipmentBase(configs.Select(ExtractTagConfigFullName));
|
||||
equipBaseCache[vtag.EquipmentId] = equipBase;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(equipBase))
|
||||
{
|
||||
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
|
||||
$"Relay uses the equipment-relative {EquipmentScriptPaths.EquipToken} token but the equipment "
|
||||
+ "has no derivable tag base (add at least one Galaxy alias tag first)."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var expandedSource = EquipmentScriptPaths.SubstituteEquipmentToken(source!, equipBase);
|
||||
if (!EquipmentScriptPaths.TryParseRelayBody(expandedSource, out var concrete) || concrete is null)
|
||||
{
|
||||
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
|
||||
$"Could not expand the {EquipmentScriptPaths.EquipToken} token into a concrete Galaxy reference."));
|
||||
continue;
|
||||
}
|
||||
|
||||
fullName = concrete;
|
||||
}
|
||||
else
|
||||
{
|
||||
fullName = rawRef;
|
||||
}
|
||||
|
||||
converted.Add(new RelayConversionItem(vtag.EquipmentId, vtag.Name, fullName, vtag.DataType));
|
||||
|
||||
if (!dryRun)
|
||||
{
|
||||
aliasTagsToAdd.Add(BuildAliasTag(
|
||||
vtag.EquipmentId,
|
||||
new AliasTagInput(NewTagId(), vtag.Name, gatewayId, vtag.DataType, TagAccessLevel.Read, fullName)));
|
||||
toRemoveVtags.Add(vtag);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun && toRemoveVtags.Count > 0)
|
||||
{
|
||||
// Snapshot every (VirtualTagId, ScriptId) pair in the whole draft BEFORE staging removals, so
|
||||
// the orphan check sees a stable view (EF still reports a RemoveRange'd entity as present in a
|
||||
// LINQ-to-entities query until SaveChanges). A script stays alive if any virtual tag NOT being
|
||||
// removed still binds it, or any ScriptedAlarm uses it as a predicate.
|
||||
var allVtagPairs = await db.VirtualTags
|
||||
.Select(v => new { v.VirtualTagId, v.ScriptId })
|
||||
.ToListAsync(ct);
|
||||
var alarmScriptIds = (await db.ScriptedAlarms.Select(a => a.PredicateScriptId).ToListAsync(ct))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var removedVtagIds = toRemoveVtags.Select(v => v.VirtualTagId).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
db.Tags.AddRange(aliasTagsToAdd);
|
||||
db.VirtualTags.RemoveRange(toRemoveVtags);
|
||||
|
||||
foreach (var scriptId in toRemoveVtags.Select(v => v.ScriptId).Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
var stillUsedByVtag = allVtagPairs.Any(p => p.ScriptId == scriptId && !removedVtagIds.Contains(p.VirtualTagId));
|
||||
var stillUsedByAlarm = alarmScriptIds.Contains(scriptId);
|
||||
if (!stillUsedByVtag && !stillUsedByAlarm && scripts.TryGetValue(scriptId, out var s))
|
||||
{
|
||||
db.Scripts.Remove(s);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return new RelayConversionResult(converted, skipped, Applied: !dryRun);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mints a unique alias <c>TagId</c> following the codebase's only id-minting convention — the
|
||||
/// <c>EQ-</c> equipment-id scheme of <c>"<prefix>-" + Guid.ToString("N")[..12]</c> (decision #125).
|
||||
/// No <c>TAG-</c> minting helper existed before this converter, so a fresh GUID-derived id is used:
|
||||
/// it cannot collide with a removed relay's namespace and needs no read-back uniqueness check.
|
||||
/// </summary>
|
||||
private static string NewTagId() => $"TAG-{Guid.NewGuid().ToString("N")[..12]}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<(string ScriptId, string Display)>> LoadScriptsAsync(CancellationToken ct = default)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user