feat(adminui): IScriptTagCatalog for tag-path completion

This commit is contained in:
Joseph Doherty
2026-06-09 14:41:40 -04:00
parent 93f5a745a3
commit d1434933b4
3 changed files with 382 additions and 0 deletions
@@ -0,0 +1,146 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
/// <summary>
/// Lists the configured tag + virtual-tag path strings a virtual-tag script author can pass to
/// <c>ctx.GetTag("…")</c> / <c>ctx.SetVirtualTag("…")</c>. A later Monaco task uses this to
/// autocomplete those string literals.
/// </summary>
public interface IScriptTagCatalog
{
/// <summary>Distinct configured tag + virtual-tag paths (the strings a script passes to
/// ctx.GetTag/SetVirtualTag), optionally filtered by a literal prefix.</summary>
/// <param name="filter">Case-insensitive StartsWith prefix; <c>null</c>/empty returns all (bounded).</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct);
}
/// <summary>
/// Default <see cref="IScriptTagCatalog"/>. Returns ONLY the resolvable keys a script may pass to
/// <c>ctx.GetTag</c> / <c>ctx.SetVirtualTag</c> — the driver <c>FullName</c> for equipment tags,
/// the MXAccess dot-ref for SystemPlatform tags, and the leaf <c>Name</c> (best-effort) for
/// virtual tags.
/// </summary>
/// <remarks>
/// <para>
/// <b>Fidelity over breadth.</b> Verified: the live runtime resolves a <c>ctx.GetTag("X")</c>
/// literal against the driver <c>FullName</c> — the resolution chain is
/// <c>Phase7Composer.ExtractDependencyRefs</c> harvesting the <c>ctx.GetTag("…")</c> literals
/// into <c>EquipmentVirtualTagPlan.DependencyRefs</c>
/// (<c>src/Server/…OpcUaServer/Phase7Composer.cs</c>); those become
/// <c>VirtualTagActor._dependencyRefs</c>, registered with the
/// <c>DependencyMuxActor</c>, whose <c>_byRef</c> map is keyed by
/// <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
/// (<c>src/Server/…Runtime/VirtualTags/DependencyMuxActor.cs:97</c>) — and that
/// <c>FullReference</c> is the <c>FullName</c> field extracted from <c>Tag.TagConfig</c>
/// (see <c>Phase7Composer.ExtractTagFullName</c> + <c>EquipmentNodeWalker.ExtractFullName</c>).
/// The UNS-path engine (<c>Core.VirtualTags.VirtualTagEngine</c>, keyed by a slash-joined
/// <c>Enterprise/Site/Area/Line/Equipment/TagName</c>) is dormant — it is NOT wired into the
/// host — so UNS browse paths never resolve at runtime and are intentionally NOT suggested.
/// </para>
/// <para>
/// The per-category resolvable key:
/// <list type="bullet">
/// <item>Equipment driver tag (<c>EquipmentId != null</c>) → the driver <c>FullName</c>
/// extracted from <c>Tag.TagConfig</c> (the verified <c>DependencyMux</c> key).</item>
/// <item>SystemPlatform tag (<c>EquipmentId == null</c>) → the MXAccess dot-ref
/// (<c>FolderPath.Name</c> when a folder is set, else <c>Name</c>) — see
/// <c>Phase7Composer</c>'s <c>GalaxyTagPlan.MxAccessRef</c>.</item>
/// <item>VirtualTag → its leaf <c>Name</c> only, as a BEST-EFFORT key (the live resolution
/// of virtual-tag cascade/write targets is unconfirmed).</item>
/// </list>
/// </para>
/// <para>
/// Follow-up: surface the UNS browse path as a completion <i>detail</i> (a non-inserted hint
/// shown alongside the resolvable key) for discoverability, rather than as an inserted value.
/// </para>
/// <para>
/// Each call creates and disposes its own context via the pooled factory — the same pattern
/// <c>UnsTreeService</c> uses — so the service is safe to register Scoped per Blazor circuit.
/// </para>
/// </remarks>
public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IScriptTagCatalog
{
/// <summary>Upper bound on returned suggestions — keeps the completion list responsive on large fleets.</summary>
private const int MaxResults = 200;
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
{
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 SystemPlatform key is FolderPath/Name, and the virtual-tag
// key is its own Name. Pull just those columns, untracked.
var tagRows = await db.Tags
.AsNoTracking()
.Select(t => new { t.EquipmentId, t.Name, t.FolderPath, t.TagConfig })
.ToListAsync(ct);
var vtagRows = await db.VirtualTags
.AsNoTracking()
.Select(v => new { v.Name })
.ToListAsync(ct);
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var t in tagRows)
{
if (t.EquipmentId is null)
{
// SystemPlatform tag — the Galaxy driver subscribes by the MXAccess dot-ref, which is
// "FolderPath.Name" when a folder is set, else just "Name".
paths.Add(string.IsNullOrWhiteSpace(t.FolderPath) ? t.Name : $"{t.FolderPath}.{t.Name}");
}
else
{
// Equipment driver tag — the runtime GetTag key is the driver FullName from TagConfig.
paths.Add(ExtractFullNameFromTagConfig(t.TagConfig));
}
}
foreach (var v in vtagRows)
{
// Virtual tag — best-effort: the live resolution of virtual-tag cascade/write targets is
// unconfirmed, so emit the leaf Name only (no UNS browse path, which never resolves).
paths.Add(v.Name);
}
IEnumerable<string> query = paths;
if (!string.IsNullOrWhiteSpace(filter))
{
query = query.Where(p => p.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
}
return query
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.Take(MaxResults)
.ToList();
}
/// <summary>
/// Extracts the driver-side full reference from a <c>Tag.TagConfig</c> JSON blob — the
/// top-level <c>FullName</c> string every shipped driver stores. Mirrors
/// <c>EquipmentNodeWalker.ExtractFullName</c> / <c>Phase7Composer.ExtractTagFullName</c>
/// (AdminUI does not reference those assemblies). Falls back to the raw blob when it is not
/// a JSON object carrying a string <c>FullName</c>.
/// </summary>
private static string ExtractFullNameFromTagConfig(string tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
&& fullName.ValueKind == System.Text.Json.JsonValueKind.String)
{
return fullName.GetString() ?? tagConfig;
}
}
catch (System.Text.Json.JsonException) { /* fall through to raw blob */ }
return tagConfig;
}
}