From 93a9c6d3db5d5981602d66a754fd43fc07b551a8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 20:31:17 -0400 Subject: [PATCH] =?UTF-8?q?docs(plan):=20Galaxy=20alias=20tag=20implementa?= =?UTF-8?q?tion=20plan=20(T0=E2=80=93T10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/plans/2026-06-11-alias-tag.md | 530 ++++++++++++++++++ docs/plans/2026-06-11-alias-tag.md.tasks.json | 23 + 2 files changed, 553 insertions(+) create mode 100644 docs/plans/2026-06-11-alias-tag.md create mode 100644 docs/plans/2026-06-11-alias-tag.md.tasks.json diff --git a/docs/plans/2026-06-11-alias-tag.md b/docs/plans/2026-06-11-alias-tag.md new file mode 100644 index 00000000..b68fc969 --- /dev/null +++ b/docs/plans/2026-06-11-alias-tag.md @@ -0,0 +1,530 @@ +# 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":""}`). 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 filter** — `Phase7Composer.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 decoder** — `DeploymentArtifact.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)`). +- **Validator** — `DraftValidator.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`. +- **Service** — `UnsTreeService.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)`. +- **DTOs** — `EquipmentChildRows.cs` `EquipmentTagRow(TagId, Name, DriverInstanceId, DataType, AccessLevel)`; `TagInput.cs`. +- **Modal sibling** — `Components/Shared/Uns/ScriptedAlarmModal.razor` (the `_loadedKey` guard + `Visible/IsNew/EquipmentId/Existing/OnSaved/OnCancel` param shape to copy). +- **Galaxy picker** — `Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor` params: `CurrentAddress` + `CurrentAddressChanged` + `GetConfigJson : Func` (the gateway `DriverConfig`, fed to `BrowserService.OpenAsync("GalaxyMxGateway", configJson, ct)`). Hosted inside `DriverTagPicker` (see `GalaxyDriverPage.razor:60`). +- **Commons** — `EquipmentScriptPaths.cs`: `GetTagRefRegex`, `DeriveEquipmentBase`, `SubstituteEquipmentToken`, `ContainsEquipToken`. +- **Equipment page Tags tab** — `Components/Pages/Uns/EquipmentPage.razor`: tab markup `:139`, `OpenAddTag :344`, `ReloadTagsAsync :339`, `_tags :296`, ` :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.cs` → **T2** only. +- `EquipmentScriptPaths.cs` → **T1** only. +- `DraftValidator.cs` + `Tag.cs` → **T3** only. +- `IUnsTreeService.cs` + `UnsTreeService.cs` → **T4 → T5 → T7** (serialize). +- `EquipmentPage.razor` → **T6 → 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`: +```bash +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`): +```csharp +[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`: +```csharp +// A pure pass-through virtual-tag body: exactly `return ctx.GetTag("").Value;` +// (whitespace-insensitive). Captures 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); + +/// Recognise an exact relay body and capture its single GetTag reference. +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** +```bash +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 composer** — `Phase7Composer.cs`, the `equipmentTags` `.Where` (line 367-369). The lambda already binds `di` and `ns`: +```csharp +.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 artifact** — `DeploymentArtifact.BuildEquipmentTagPlans`. Add a driverType map alongside `driverToNamespace` (in the `diArr` loop, ~line 476-483): +```csharp +var driverToType = new Dictionary(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): +```csharp +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** +```bash +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: +```csharp +private static void ValidateAliasTagFullName(DraftSnapshot draft, List 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** +```bash +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`: +```csharp +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// 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. +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): +```csharp +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:"`, and a normal driver tag with `IsAlias == false`. + +**Step 4: Implement.** +- Interface additions (`IUnsTreeService.cs`): +```csharp +Task> + LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default); +Task CreateAliasTagAsync(string equipmentId, AliasTagInput input, CancellationToken ct = default); // impl in Task 5 +Task 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":""}`, `AccessLevel == input.AccessLevel`. +- Empty/whitespace `FullName` → `Ok == 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`): +```csharp +private static async Task 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 { ["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 `` over the same OPC type list `TagModal` uses — reuse that list source), `AccessLevel` (`