Files
lmxopcua/docs/plans/2026-06-11-alias-tag.md
T
Joseph Doherty 93a9c6d3db docs(plan): Galaxy alias tag implementation plan (T0–T10)
11-task TDD plan from the approved alias-tag design. Approach A (reuse
Tag entity, broaden composer/artifact equipment-tag filter); converter
rewrites relay VirtualTags as alias Tags. No entity/EF migration.
2026-06-11 20:31:17 -04:00

34 KiB

Galaxy Alias Tag (UNS) Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.

Goal: Let an Equipment node surface a Galaxy attribute under a friendly UNS name as a first-class driver-bound Tag (an alias), resolved by a direct Galaxy-driver subscription — replacing relay VirtualTags whose script is nothing but return ctx.GetTag("X").Value;.

Architecture: Approach A — an alias is an ordinary Tag row (EquipmentId set, DriverInstanceId = the Galaxy gateway, TagConfig = {"FullName":"<galaxyRef>"}). The only structural change is broadening the equipment-tag filter in the composer + artifact decoder to admit GalaxyMxGateway-backed equipment tags; everything downstream (materialise, subscribe, write-through) is reused unchanged. No Configuration entity and no EF migration. A converter rewrites existing relay VirtualTags as alias Tags.

Tech Stack: .NET 10, Akka.NET, Blazor Server (InteractiveServer, no bUnit), EF Core (OtOpcUaConfigDbContext, optimistic RowVersion), xUnit + Shouldly, EF InMemory for service tests.

Design: docs/plans/2026-06-11-alias-tag-design.md (committed master 305023aa).

Grounded facts (verified against the code)

  • Composer filterPhase7Composer.cs:365-381 equipmentTags = EquipmentId is not null AND driver namespace Kind == NamespaceKind.Equipment; FullName: ExtractTagFullName(t.TagConfig) (reads top-level "FullName"). The Galaxy/SystemPlatform filter is the inverse (EquipmentId is null, Kind == SystemPlatform) — untouched.
  • Artifact decoderDeploymentArtifact.cs:447-526 BuildEquipmentTagPlans mirrors it (builds equipmentNamespaces + driverToNamespace, requires equipmentNamespaces.Contains(nsId)). Byte-parity with the composer. The artifact's DriverInstances array carries DriverType (DriverInstancePlan(DriverInstanceId, DriverType, ConfigJson)).
  • ValidatorDraftValidator.cs; DraftSnapshot exposes full Tags (with TagConfig) + DriverInstances (with DriverType). ValidateDriverNamespaceCompatibility (line 192) checks a driver's home namespace and is unaffected. No tag-level namespace-kind invariant in sp_ValidateDraft.
  • ServiceUnsTreeService.cs: CreateTagAsync (744), UpdateTagAsync (794), LoadTagsForEquipmentAsync (86), LoadTagDriversForEquipmentAsync (714, filters to Equipment-kind namespaces), CheckTagDriverGuardAsync (1192, rejects non-Equipment-kind drivers — aliases need a Galaxy-aware variant), ResolveEquipmentClusterAsync (1161). Name-uniqueness guard = db.Tags.AnyAsync(t => t.EquipmentId == eq && t.Name == name).
  • DTOsEquipmentChildRows.cs EquipmentTagRow(TagId, Name, DriverInstanceId, DataType, AccessLevel); TagInput.cs.
  • Modal siblingComponents/Shared/Uns/ScriptedAlarmModal.razor (the _loadedKey guard + Visible/IsNew/EquipmentId/Existing/OnSaved/OnCancel param shape to copy).
  • Galaxy pickerComponents/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor params: CurrentAddress + CurrentAddressChanged + GetConfigJson : Func<string> (the gateway DriverConfig, fed to BrowserService.OpenAsync("GalaxyMxGateway", configJson, ct)). Hosted inside DriverTagPicker (see GalaxyDriverPage.razor:60).
  • CommonsEquipmentScriptPaths.cs: GetTagRefRegex, DeriveEquipmentBase, SubstituteEquipmentToken, ContainsEquipToken.
  • Equipment page Tags tabComponents/Pages/Uns/EquipmentPage.razor: tab markup :139, OpenAddTag :344, ReloadTagsAsync :339, _tags :296, <TagModal …> :180.
  • FleetAdmin policy — string "FleetAdmin" (e.g. RoleGrants.razor, ScriptAnalysisEndpoints.cs).

Hard rules

Stage by explicit path (never git add .); never stage sql_login.txt or src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/; never echo the gateway API key into a new tracked file; no force-push; no --no-verify; no Configuration entity / EF migration change. Feature branch off master 305023aa.

Same-file contention / ordering

  • Phase7Composer.cs + DeploymentArtifact.csT2 only.
  • EquipmentScriptPaths.csT1 only.
  • DraftValidator.cs + Tag.csT3 only.
  • IUnsTreeService.cs + UnsTreeService.csT4 → T5 → T7 (serialize).
  • EquipmentPage.razorT6 → T8 (serialize).
  • T1, T2, T3 are mutually independent (parallel after T0). T9 (docs) is parallel with anything.

Task 0: Feature branch

Classification: trivial Estimated implement time: ~1 min Parallelizable with: none

Step 1: From master at 305023aa:

git checkout -b feat/uns-alias-tag
git log --oneline -1   # expect 305023aa design commit on the branch base

No code change. Do not commit anything yet.


Task 1: Commons relay-body parser

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 2, Task 3

Files:

  • Modify: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs
  • Test: tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs

Add a pure helper that recognises an exact relay body and extracts its single ctx.GetTag reference. This is load-bearing for the converter (Task 7) and is fully offline-TDD-able.

Step 1: Write failing tests (append to EquipmentScriptPathsTests.cs):

[Theory]
[InlineData("return ctx.GetTag(\"TestMachine_020.TestChangingInt\").Value;", "TestMachine_020.TestChangingInt")]
[InlineData("  return   ctx . GetTag ( \"A.B\" ) . Value ; ", "A.B")]
[InlineData("return ctx.GetTag(\"{{equip}}.Speed\").Value;", "{{equip}}.Speed")]
public void TryParseRelayBody_accepts_exact_passthrough(string src, string expectedRef)
{
    EquipmentScriptPaths.TryParseRelayBody(src, out var r).ShouldBeTrue();
    r.ShouldBe(expectedRef);
}

[Theory]
[InlineData("return ctx.GetTag(\"A.B\").Value * 2;")]                       // arithmetic
[InlineData("var x = ctx.GetTag(\"A.B\").Value; return x;")]               // extra statement
[InlineData("return ctx.GetTag(\"A.B\").Value + ctx.GetTag(\"C.D\").Value;")] // two reads
[InlineData("return ctx.GetTag(\"A.B\").Quality;")]                        // not .Value
[InlineData("return 5;")]                                                   // no GetTag
[InlineData("")]                                                            // empty
public void TryParseRelayBody_rejects_non_relay(string src)
{
    EquipmentScriptPaths.TryParseRelayBody(src, out var r).ShouldBeFalse();
    r.ShouldBeNull();
}

Step 2: Run dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests --filter TryParseRelayBody → FAIL (method missing).

Step 3: Implement in EquipmentScriptPaths.cs:

// A pure pass-through virtual-tag body: exactly `return ctx.GetTag("<ref>").Value;`
// (whitespace-insensitive). Captures <ref> for relay→alias conversion. Anything with extra
// statements, arithmetic, a different member than .Value, or multiple GetTag calls is NOT a relay.
private static readonly Regex RelayBodyRegex = new(
    @"^\s*return\s+ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)\s*\.\s*Value\s*;\s*$",
    RegexOptions.Compiled);

/// <summary>Recognise an exact relay body and capture its single GetTag reference.</summary>
public static bool TryParseRelayBody(string? source, out string? tagReference)
{
    tagReference = null;
    if (string.IsNullOrWhiteSpace(source)) return false;
    var m = RelayBodyRegex.Match(source);
    if (!m.Success) return false;
    tagReference = m.Groups[1].Value;
    return true;
}

Step 4: Run the filter again → PASS. Run the full EquipmentScriptPaths suite → all green.

Step 5: Commit

git add src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs \
        tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs
git commit -m "feat(commons): TryParseRelayBody — detect pure ctx.GetTag relay scripts"

Task 2: Broaden the equipment-tag filter (composer + artifact)

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 1, Task 3

Data-contract composition + byte-parity pair — the two sites must change together.

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs:365-369
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs:474-505
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs (new)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs (new)

Step 1: Write failing composer test (Phase7ComposerAliasTagTests.cs). Model the arrange on Phase7ComposerEquipTokenTests.cs (same project — copy its builder/setup). Assert:

  • An equipment-scoped Tag (EquipmentId set) bound to a GalaxyMxGateway driver whose namespace is SystemPlatform, with TagConfig={"FullName":"TestMachine_020.TestChangingInt"}, now appears in the composed EquipmentTags with FullName == "TestMachine_020.TestChangingInt".
  • A SystemPlatform mirror Tag (EquipmentId == null, same Galaxy driver) still appears in GalaxyTags and not in EquipmentTags (no regression).

Step 2: dotnet test …OpcUaServer.Tests --filter AliasTag → FAIL (alias tag dropped).

Step 3: Implement composerPhase7Composer.cs, the equipmentTags .Where (line 367-369). The lambda already binds di and ns:

.Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
            && namespacesById.TryGetValue(di.NamespaceId, out var ns)
            && (ns.Kind == NamespaceKind.Equipment || di.DriverType == "GalaxyMxGateway"))

(One clause added: || di.DriverType == "GalaxyMxGateway". The Galaxy/SystemPlatform producer keeps its EquipmentId is null guard, so a Galaxy alias — EquipmentId set — never double-counts.)

Step 4: Implement artifactDeploymentArtifact.BuildEquipmentTagPlans. Add a driverType map alongside driverToNamespace (in the diArr loop, ~line 476-483):

var driverToType = new Dictionary<string, string>(StringComparer.Ordinal);
// … inside the existing foreach over diArr, after reading id/ns:
var dtype = el.TryGetProperty("DriverType", out var dtEl) ? dtEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(dtype))
    driverToType[id!] = dtype!;

Then broaden the qualifying check (replace line 504-505):

if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
var isGalaxyAlias = driverToType.TryGetValue(di!, out var dtype2) && dtype2 == "GalaxyMxGateway";
if (!equipmentNamespaces.Contains(nsId) && !isGalaxyAlias) continue;

Step 5: Write failing parity test (DeploymentArtifactAliasParityTests.cs). Model on DeploymentArtifactEquipTokenTests.cs. Compose a draft containing one Galaxy alias tag, serialise to the artifact, decode via DeploymentArtifact, and assert the decoded EquipmentTags equals the composer's EquipmentTags for that tag (same FullName, EquipmentId, DriverInstanceId, Name, DataType) — i.e. the alias survives the round-trip byte-parity.

Step 6: Run both new test files + the existing DeploymentArtifact* and Phase7Composer* suites → all green (no parity regression).

Step 7: Commit

git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs
git commit -m "feat(composer): admit GalaxyMxGateway-backed equipment alias tags (+byte-parity)"

Task 3: DraftValidator alias-reference check + doc reframe

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 1, Task 2

Files:

  • Modify: src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
  • Modify: src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs:5-9,33 (doc only)
  • Test: tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs

Step 1: Write failing tests (append to DraftValidatorTests.cs, follow its existing arrange helpers). A draft with a Galaxy-gateway-bound equipment Tag whose TagConfig lacks FullName (e.g. {}) → Validate returns a AliasTagMissingReference error keyed on the TagId. A well-formed alias ({"FullName":"X.Y"}) → no such error. A normal Equipment-kind driver tag with {} → no alias error (not a Galaxy alias).

Step 2: dotnet test …Configuration.Tests --filter AliasTag → FAIL.

Step 3: Implement. Register a new method in Validate(...) and add it:

private static void ValidateAliasTagFullName(DraftSnapshot draft, List<ValidationError> errors)
{
    var typeByDriver = draft.DriverInstances
        .ToDictionary(d => d.DriverInstanceId, d => d.DriverType, StringComparer.Ordinal);
    foreach (var t in draft.Tags)
    {
        if (t.EquipmentId is null) continue;
        if (!typeByDriver.TryGetValue(t.DriverInstanceId, out var dtype) || dtype != "GalaxyMxGateway")
            continue;
        if (string.IsNullOrWhiteSpace(ExtractTagConfigFullName(t.TagConfig)))
            errors.Add(new("AliasTagMissingReference",
                $"Alias tag '{t.TagId}' on equipment '{t.EquipmentId}' is missing a Galaxy reference (TagConfig.FullName)",
                t.TagId));
    }
}

// Minimal local reader (mirrors Phase7Composer.ExtractTagFullName) — top-level "FullName" string.
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; }
}

Step 4: Run the filter → PASS; full DraftValidatorTests → green.

Step 5: Doc reframe in Tag.cs — replace the class-summary line + the EquipmentId remark (lines 5-9, 33) so they read: EquipmentId set ⟺ the tag participates in the Equipment tree, regardless of the driver's namespace kind. A GalaxyMxGateway-bound equipment tag is an alias (a Galaxy attribute surfaced under a UNS name); its Galaxy reference lives in TagConfig.FullName. EquipmentId is NULL for SystemPlatform mirror tags (FolderPath-scoped).

Step 6: Commit

git add src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs \
        src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs \
        tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs
git commit -m "feat(validation): require TagConfig.FullName on Galaxy alias tags; reframe Tag doc"

Task 4: Service read-side — alias DTO, gateway lookup, Source column

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 2, Task 3 (different files); serialize before Task 5

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/AliasTagInput.cs
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs (extend EquipmentTagRow)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs (LoadTagsForEquipmentAsync :86 + new LoadGalaxyGatewaysForEquipmentAsync)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs (new)

Step 1: Add the DTO AliasTagInput.cs:

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). FullName is the picked Galaxy reference (tag_name.AttributeName); it is stored as
/// TagConfig {"FullName":…}. AccessLevel defaults ReadOnly at the call site.</summary>
public sealed record AliasTagInput(
    string TagId, string Name, string DriverInstanceId, string DataType,
    TagAccessLevel AccessLevel, string FullName);

Step 2: Extend EquipmentTagRow (defaulted trailing params keep existing constructions compiling — mirror the UnsMutationResult.CreatedId trick):

public sealed record EquipmentTagRow(
    string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel,
    bool IsAlias = false, string? Source = null);

Step 3: Write failing tests (UnsTreeServiceAliasTagTests.cs; use the UnsTreeTestDb helper like UnsTreeServiceTagTests.cs). Seed a cluster with a GalaxyMxGateway driver in a SystemPlatform namespace + an Equipment-kind namespace, an equipment, and tags. Assert:

  • LoadGalaxyGatewaysForEquipmentAsync(eq) returns the gateway (DriverInstanceId, display, DriverConfig) and excludes non-Galaxy drivers.
  • LoadTagsForEquipmentAsync marks a Galaxy-gateway-bound tag with IsAlias == true and Source == "galaxy:<FullName>", and a normal driver tag with IsAlias == false.

Step 4: Implement.

  • Interface additions (IUnsTreeService.cs):
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)>>
    LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default);
Task<UnsMutationResult> CreateAliasTagAsync(string equipmentId, AliasTagInput input, CancellationToken ct = default);   // impl in Task 5
Task<UnsMutationResult> UpdateAliasTagAsync(string tagId, AliasTagInput input, byte[] rowVersion, CancellationToken ct = default); // Task 5
  • LoadGalaxyGatewaysForEquipmentAsync (model on LoadTagDriversForEquipmentAsync :714 but filter d.DriverType == "GalaxyMxGateway" in the equipment's cluster; select DriverInstanceId, Name, DriverConfig).
  • LoadTagsForEquipmentAsync (:86) — to set IsAlias/Source, the projection needs each tag's driver type + TagConfig. Join db.Tags to db.DriverInstances on DriverInstanceId; in a post-query .Select, set IsAlias = driverType == "GalaxyMxGateway", and Source = IsAlias ? $"galaxy:{ExtractFullName(TagConfig)}" : null. Pull the rows first (AsNoTracking().Where(eq).Join(...)), then map in memory so you can parse TagConfig with the same small FullName reader used in Task 3 (add a private ExtractTagConfigFullName to UnsTreeService or a shared Commons helper — DRY: prefer one shared reader).

Step 5: Run dotnet test …AdminUI.Tests --filter AliasTag → PASS. Run UnsTreeServiceEquipmentChildRowsTests + UnsTreeServiceTagTests → green (defaulted params kept them compiling).

Step 6: Commit (stage the five files by path).

git commit -m "feat(adminui): alias DTO + Galaxy gateway lookup + Source/IsAlias on tag rows"

Task 5: Service write-side — Create/Update alias + Galaxy guard

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (serialize after Task 4)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs

Step 1: Write failing tests (append). Assert:

  • CreateAliasTagAsync(eq, input) writes a Tag with DriverInstanceId = gateway, EquipmentId = eq, FolderPath = null, TagConfig == {"FullName":"<input.FullName>"}, AccessLevel == input.AccessLevel.
  • Empty/whitespace FullNameOk == false (error mentions reference).
  • Binding a non-Galaxy driver via the alias path → Ok == false ("not a Galaxy gateway").
  • Cross-cluster gateway → Ok == false (cluster mismatch).
  • Duplicate Name on the equipment (vs an existing tag) → Ok == false.
  • UpdateAliasTagAsync updates Name/DataType/AccessLevel/FullName and bumps via RowVersion path.

Step 2: Run → FAIL (methods are interface stubs from Task 4; implement now).

Step 3: Implement a Galaxy-aware guard + the two methods (model on CreateTagAsync :744 / UpdateTagAsync :794, but assemble TagConfig from FullName and use the alias guard instead of CheckTagDriverGuardAsync):

private static async Task<UnsMutationResult?> CheckAliasDriverGuardAsync(
    OtOpcUaConfigDbContext db, string driverInstanceId, string? equipmentCluster, CancellationToken ct)
{
    var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
    if (driver is null) return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
    if (driver.DriverType != "GalaxyMxGateway")
        return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not a Galaxy gateway.");
    if (driver.ClusterId != equipmentCluster)
        return new UnsMutationResult(false,
            $"Galaxy gateway '{driverInstanceId}' is in cluster '{driver.ClusterId}' but the equipment is in cluster '{equipmentCluster}'.");
    return null;
}

private static string BuildAliasTagConfig(string fullName) =>
    System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, string> { ["FullName"] = fullName });

CreateAliasTagAsync: validate FullName non-empty (return new UnsMutationResult(false, "Alias is missing a Galaxy reference.")), resolve cluster, run CheckAliasDriverGuardAsync, reuse the equipment signal-name uniqueness check, then db.Tags.Add(new Tag { …, FolderPath = null, AccessLevel = input.AccessLevel, TagConfig = BuildAliasTagConfig(input.FullName) }). Factor the entity build into a private BuildAliasTag(equipmentId, input)Task 7's converter reuses it.

Step 4: Run → PASS; full UnsTreeService* suite → green.

Step 5: Commit

git commit -m "feat(adminui): CreateAliasTagAsync/UpdateAliasTagAsync + Galaxy-gateway guard"

Task 6: AliasTagModal + Tags-tab "Add alias" wiring

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (serialize after Task 5; before Task 8 on EquipmentPage.razor)

No bUnit — proven live in Task 10. Keep markup faithful to existing modals.

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AliasTagModal.razor
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor

Step 1: Build AliasTagModal.razor — copy the shell + _loadedKey guard + param block from ScriptedAlarmModal.razor. Params: Visible, IsNew, EquipmentId, Existing (a small AliasTagEditDto — add it to IUnsTreeService load path if editing is in scope, else reuse TagEditDto/LoadTagAsync and pre-split TagConfig.FullName), Gateways (IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)> from LoadGalaxyGatewaysForEquipmentAsync), OnSaved, OnCancel. Body:

  • A gateway <select> (auto-selected when exactly one).
  • The Galaxy picker: host <DriverTagPicker> wrapping <GalaxyAddressPickerBody CurrentAddress="_form.FullName" CurrentAddressChanged="v => _form.FullName = v" GetConfigJson="() => _selectedGatewayConfig" /> (mirror GalaxyDriverPage.razor:60). _selectedGatewayConfig = the selected gateway's DriverConfig.
  • Name (InputText), DataType (<select> over the same OPC type list TagModal uses — reuse that list source), AccessLevel (<select> over TagAccessLevel, default ReadOnly).
  • Save → Svc.CreateAliasTagAsync / Svc.UpdateAliasTagAsync; surface result.Error; on Ok raise OnSaved.

Step 2: Wire the Tags tab in EquipmentPage.razor:

  • Beside Add tag (:142) add: <button class="btn btn-outline-primary btn-sm" @onclick="OpenAddAlias" disabled="@(_gateways.Count == 0)">Add alias (browse Galaxy)</button> with a hint span when _gateways.Count == 0 ("no Galaxy gateway in this cluster").
  • In the tags table, render the alias badge + Source column: when t.IsAlias, show a <span class="badge">alias</span> and @t.Source; Edit routes alias rows to OpenEditAlias, native rows to the existing OpenEditTag.
  • Add <AliasTagModal Visible=… EquipmentId=@EquipmentId Gateways=_gateways OnSaved=OnAliasSaved OnCancel=… /> next to <TagModal …>.
  • Handlers: load _gateways via Svc.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId!) inside ReloadTagsAsync (:339); OpenAddAlias/OpenEditAlias/OnAliasSaved mirror the tag handlers (ReloadTagsAsync after save/delete).

Step 3: dotnet build the AdminUI project → 0 errors. (Behaviour verified live in Task 10.)

Step 4: Commit

git commit -m "feat(adminui): AliasTagModal + Tags-tab Add-alias (Galaxy picker)"

Task 7: Converter service method

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (serialize after Task 5; needs Task 1)

Atomic config mutation (deletes VirtualTag/Script rows, inserts Tag rows). Correctness-critical.

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RelayConversion.cs (request + result DTOs)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceRelayConverterTests.cs (new)

Step 1: DTOs (RelayConversion.cs):

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;

/// <summary>One relay VirtualTag that will/would become an alias Tag.</summary>
public sealed record RelayConversionItem(string EquipmentId, string VirtualTagName, string FullName, string DataType);
/// <summary>One relay VirtualTag that cannot be converted, with the reason.</summary>
public sealed record RelayConversionSkip(string EquipmentId, string VirtualTagName, string Reason);
/// <summary>Outcome of a (dry-run or applied) conversion pass.</summary>
public sealed record RelayConversionResult(
    IReadOnlyList<RelayConversionItem> Converted,
    IReadOnlyList<RelayConversionSkip> Skipped,
    bool Applied);

Step 2: Interface (IUnsTreeService.cs):

/// <summary>Convert pure-relay VirtualTags to Galaxy alias Tags. equipmentId = null → fleet-wide.
/// dryRun = true previews without mutating. FleetAdmin-gated at the call site.</summary>
Task<RelayConversionResult> ConvertRelayVirtualTagsToAliasesAsync(
    string? equipmentId, bool dryRun, CancellationToken ct = default);

Step 3: Write failing tests (UnsTreeServiceRelayConverterTests.cs, use UnsTreeTestDb). Cases:

  1. Exact relay converted — vtag speed-rpm bound to script return ctx.GetTag("TestMachine_020.TestChangingInt").Value; on an equipment whose cluster has the gateway → apply: a Tag exists (Name=speed-rpm, TagConfig={"FullName":"TestMachine_020.TestChangingInt"}, AccessLevel=ReadOnly, DataType carried), and the VirtualTag + its orphan Script are gone. Result lists it under Converted.
  2. Non-relay untouched — vtag whose script does arithmetic → Skipped (reason "not a pure relay"); vtag + script remain.
  3. Shared script kept then deleted — one Script referenced by two relay vtags: converting the first keeps the Script (still referenced); converting both deletes it.
  4. Historize=true — relay vtag with Historize=trueSkipped (reason mentions historize); unchanged.
  5. No gateway — equipment whose cluster has no GalaxyMxGatewaySkipped (reason "no Galaxy gateway"); unchanged.
  6. Dry-run no mutation — case 1 with dryRun=true → same Converted list but the VirtualTag/Script still present and no Tag added.
  7. Fleet vs per-equipment scopeequipmentId=null sweeps all; a specific id limits to that equipment.

Step 4: Run --filter RelayConverter → FAIL.

Step 5: Implement ConvertRelayVirtualTagsToAliasesAsync:

  • One db context; gather candidate VirtualTags (Where(EquipmentId == id) or all), join their Script.SourceCode.
  • Per vtag: EquipmentScriptPaths.TryParseRelayBody(source, out var rawRef) → non-relay ⇒ skip ("not a pure relay").
  • Resolve the equipment's cluster gateway (reuse LoadGalaxyGatewaysForEquipmentAsync logic / ResolveEquipmentClusterAsync); none ⇒ skip ("no Galaxy gateway in cluster").
  • Historize == true ⇒ skip ("historized — Tag has no historize column; convert manually").
  • {{equip}} expansion: if EquipmentScriptPaths.ContainsEquipToken(rawRef), derive the equipment's base from its existing non-relay Tag FullNames (DeriveEquipmentBase over db.Tags.Where(EquipmentId==eq && driver==GalaxyMxGateway).Select(TagConfig.FullName) plus any Equipment-kind driver tag FullNames); if base is null ⇒ skip ("equipment-relative token with no derivable base"); else fullName = SubstituteEquipmentToken("ctx.GetTag(\"" + rawRef + "\")", base)-extracted concrete ref (or apply SubstituteEquipmentToken to rawRef directly via a small wrapper). Otherwise fullName = rawRef.
  • If dryRun: add to Converted, mutate nothing.
  • Else: db.Tags.Add(BuildAliasTag(eq, new AliasTagInput(NewTagId(), vtag.Name, gatewayId, vtag.DataType, TagAccessLevel.ReadOnly, fullName))) (reuse Task 5's BuildAliasTag); db.VirtualTags.Remove(vtag); decrement the Script's live reference count (count of VirtualTags + ScriptedAlarms still referencing ScriptId after this batch's removals) and db.Scripts.Remove(script) only when it reaches zero.
  • One await db.SaveChangesAsync(ct) at the end for atomicity (all-or-nothing). Return RelayConversionResult(converted, skipped, Applied: !dryRun).
  • Name-collision safety: the delete-vtag + add-tag for the same (EquipmentId, Name) happen in one SaveChanges, so the equipment signal-name collision check is satisfied (no generation where both exist).

Step 6: Run --filter RelayConverter → PASS; full UnsTreeService* → green.

Step 7: Commit (stage the four files).

git commit -m "feat(adminui): ConvertRelayVirtualTagsToAliasesAsync (relay VTag -> alias Tag)"

Task 8: Converter UI (per-equipment + fleet-wide)

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (serialize after Task 6; needs Task 7)

No bUnit — proven live in Task 10.

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor
  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/RelayAliasConvert.razor
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor (nav entry)

Step 1: Per-equipment action — on the Tags tab add a Convert relay virtual-tags to aliases… button (shown when the equipment has any virtual tags). It calls ConvertRelayVirtualTagsToAliasesAsync(EquipmentId, dryRun:true), shows a preview panel (Converted list + Skipped-with-reason list), and a Apply button that re-calls with dryRun:false then ReloadTagsAsync + ReloadVirtualTagsAsync.

Step 2: Fleet-wide page RelayAliasConvert.razor:

@page "/uns/convert-relays"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "FleetAdmin")]
@rendermode RenderMode.InteractiveServer
@inject IUnsTreeService Svc

A Preview button → ConvertRelayVirtualTagsToAliasesAsync(null, dryRun:true) → table of Converted (Equipment, Name, FullName) + Skipped (Equipment, Name, Reason) + counts; an Apply conversion button (confirmation) → dryRun:false → re-preview to show the now-empty Converted set. Reuse the panel markup from Step 1 (a small shared <RelayConversionPreview> partial is optional; inline is fine).

Step 3: Nav — add a RelayAliasConvert link under the existing UNS/maintenance nav group in MainLayout.razor (FleetAdmin-gated link, mirror how RoleGrants/maintenance links render).

Step 4: dotnet build AdminUI → 0 errors.

Step 5: Commit

git commit -m "feat(adminui): relay->alias converter UI (per-equipment + fleet-wide page)"

Task 9: Docs

Classification: small Estimated implement time: ~4 min Parallelizable with: any task (docs-only)

Files:

  • Modify: docs/Uns.md
  • Modify: CLAUDE.md

Step 1: docs/Uns.md — add an "Alias tags" subsection under the equipment Tags tab: what an alias is (a Galaxy attribute surfaced under a UNS name via a direct driver subscription, no script), how to add one (Tags tab → Add alias (browse Galaxy)), AccessLevel/write-through note, and the converter (per-equipment action + /uns/convert-relays fleet sweep, dry-run then apply; skip reasons).

Step 2: CLAUDE.md — a short note near the "Contained Name vs Tag Name" / Galaxy section: alias tags = GalaxyMxGateway-backed equipment Tags carrying TagConfig.FullName; they replace pure-relay virtual tags; converter at /uns/convert-relays.

Step 3: Commit

git add docs/Uns.md CLAUDE.md
git commit -m "docs: alias tags + relay converter (Uns.md, CLAUDE.md)"

Task 10: Full-suite gate + live /run verify + finish

Classification: verification Estimated implement time: ~6 min (+ user-driven live) Parallelizable with: none (after all)

Step 1: dotnet build ZB.MOM.WW.OtOpcUa.slnx → 0 errors. dotnet test ZB.MOM.WW.OtOpcUa.slnx → green (call out any pre-existing unrelated failures).

Step 2: Live docker-dev /run (user drives; agent does not sign in). Rebuild: docker compose -f docker-dev/docker-compose.yml up -d --build central-1 central-2 (no -v; migrator no-ops — no schema change), UI at http://localhost:9200. Checklist:

  1. On blender-20 (/uns/equipment/EQ-89e6583eeb9c) Tags tab → Add alias → Galaxy picker browses the gateway → pick an attribute → save → alias row shows the badge + galaxy:<FullName>.
  2. Publish/deploy → read the alias live via Client.CLI (browse/read) — value + quality + timestamp present.
  3. Set an alias AccessLevel writable → write via Client.CLI → the Galaxy attribute changes; a read-only alias rejects the write.
  4. Per-equipment converter dry-run on blender-20 → preview lists the 24 relays → Apply → the 24 virtual tags become alias Tags (Tags tab), Virtual Tags tab emptied of them, values still live, no new script-log entries for them.
  5. Fleet-wide /uns/convert-relays → preview → (optionally) apply.

Step 3: Finish — use superpowers-extended-cc:finishing-a-development-branch (verify tests, present the 4 options; carry the session's standing intent toward merge-to-master + push, but confirm given the user-driven live step). Do not stage pending.md, sql_login.txt, or pki/.

Done gate: build clean + dotnet test green + live /run pass.