diff --git a/CLAUDE.md b/CLAUDE.md index acc8fe69..3e7d1313 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,6 +217,8 @@ The AdminUI's global **UNS** page (`/uns`) is the single surface for managing th **v3 (Batch 2): device I/O is authored in the `/raw` project tree** (`GlobalRaw.razor` → `RawTree.razor`, `IRawTreeService`) — a cluster-rooted `Folder → Driver → Device → TagGroup → Tag` hierarchy with per-node context menus (the reusable `ContextMenu`), lazy loading, driver/device config modals (endpoint moved to `Device.DeviceConfig`; Test-connect probes the `DriverDeviceConfigMerger`-merged config), manual-entry + CSV import/export (RFC-4180, staged review grid, all-or-nothing), and browse-commit of discovered leaves into raw tags. The routed `/clusters/{id}/drivers` flow (`DriverTypePicker`, `DriverEditRouter`, the 8 `*DriverPage` shells, the Drivers list) is **retired** — its form bodies live on inside the `/raw` modals. The `DriverType` dispatch maps are keyed off the `DriverTypeNames` constants (fixing the `TwinCat`/`Focas` drift). A `Calculation` pseudo-driver (`DriverTypeNames.Calculation`, `IDependencyConsumer`, deploy-time `scriptId`-existence + Tarjan cycle gates) computes signal-level tags from other tags' RawPaths. See `docs/Raw.md`. +**v3 (Batch 3): UNS Equipment is reference-only.** The equipment **Tags** tab no longer authors or binds tags — it holds `UnsTagReference` rows pointing at raw tags authored in `/raw`. "+ Add reference" opens the `RawTree` in picker mode, **cluster-scoped** (structurally + server-enforced `tag.cluster == equipment.cluster`); rows show effective name / RawPath / inherited DataType+AccessLevel / display-name override. **Effective-name uniqueness** (references + VirtualTags + ScriptedAlarms share the `{EquipmentId}/{EffectiveName}` NodeId space) is enforced at authoring (`IEffectiveNameGuard`) and at the deploy gate (`DraftValidator.UnsEffectiveNameCollision` — catches rename-induced collisions). Scripts use **`ctx.GetTag("{{equip}}/")`** — resolved per-equipment through `UnsTagReference` effective names to the backing RawPath at both compose seams (`AddressSpaceComposer` + `DeploymentArtifact`, via the shared `EquipmentReferenceMap`, byte-parity); an unresolved `` is a deploy error (`EquipReferenceUnresolved`) AND a live Monaco diagnostic (`OTSCRIPT_EQUIPREF`) — editor accepts ⇔ publish accepts. `EquipmentScriptPaths.DeriveEquipmentBase` is deleted (the dot-joint `{{equip}}.X` became the slash-joint `{{equip}}/`). Raw rename warns when a beneath-it tag is historized-without-override / UNS-referenced / named by a script literal (`RawTreeService` substring scan). `ImportEquipmentModal` dropped the `DriverInstanceId` column. See `docs/Uns.md` + `docs/ScriptEditor.md`. + The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`. ## Scripting / Script Editor diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md index b445768d..c3047afc 100644 --- a/docs/ScriptEditor.md +++ b/docs/ScriptEditor.md @@ -307,76 +307,88 @@ Register the replacement in `EndpointRouteBuilderExtensions.AddAdminUI` ### Why -Today each VirtualTag bound to a script typically needs its own near-duplicate -script because tag paths are hard-coded absolutes (e.g. `TestMachine_001.Speed`). -The `{{equip}}` token breaks this coupling: point many VirtualTags' `ScriptId` at -a single template script, and each resolves the token to its own equipment's tag -base prefix at deploy time. No schema change is required — sharing a `Script` -record across VirtualTags already works; `{{equip}}` is what makes the shared -script resolve per-equipment. +Each VirtualTag bound to a script would otherwise need its own near-duplicate +script because tag paths are hard-coded absolute RawPaths (e.g. +`Cell1/Modbus/Dev1/Speed`). The `{{equip}}/` token breaks this coupling: +point many VirtualTags' `ScriptId` at a single template script, and each resolves +the token through **its own equipment's tag references** at deploy time. No schema +change is required — sharing a `Script` record across VirtualTags already works; +`{{equip}}/` is what makes the shared script resolve per-equipment. ### Before / after -**Before — one script per machine:** +**Before — one script per machine (hard-coded RawPath):** ```csharp // Script "Calc_TestMachine_001" — hard-coded, cannot reuse -return ctx.GetTag("TestMachine_001.Speed").Value; +return ctx.GetTag("Cell1/Modbus/Dev1/Speed").Value; ``` **After — one shared template:** ```csharp // Script "Calc_Speed" — works for any machine -return ctx.GetTag("{{equip}}.Speed").Value; +return ctx.GetTag("{{equip}}/Speed").Value; ``` -`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`. -At deploy, each VirtualTag receives its own expanded copy: -`TestMachine_001.Speed` and `TestMachine_002.Speed` respectively. +`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`, and +each has a **tag reference** whose effective name is `Speed`. At deploy, each +VirtualTag receives its own expanded copy — `{{equip}}/Speed` resolves to that +equipment's referenced raw tag's RawPath. -### Token rules +### Token rules (v3) -- The token is `{{equip}}` (double braces, lowercase). +- The token joint is a **slash**: `{{equip}}/` (double-brace stem, + lowercase). `` is a **reference effective name** — the reference's + `DisplayNameOverride`, else the backing raw tag's `Name`. - It is substituted **only inside `ctx.GetTag(…)` / `ctx.SetVirtualTag(…)` first-argument string literals** — comments, logger strings, and other code are untouched. -- The operator writes the separator and tail: `ctx.GetTag("{{equip}}.Speed")`, - `ctx.GetTag("{{equip}}.Sub.Field")`, etc. -- The token expands to the equipment's **tag base prefix** — the common - substring-before-the-first-dot of that equipment's configured driver-tag - `FullName` values. Example: tags `TestMachine_001.Speed` and - `TestMachine_001.Temp` → base `TestMachine_001`. +- The whole post-prefix literal content is the reference name, so effective names + with internal spaces are supported: `ctx.GetTag("{{equip}}/Motor Speed")`. +- `{{equip}}/` **resolves through the owning equipment's `UnsTagReference` + rows** (by effective name) to the backing raw tag's `RawPath`. Example: a + reference named `Speed` backing `Cell1/Modbus/Dev1/Speed` → the token expands to + `Cell1/Modbus/Dev1/Speed`. +- Scripted-alarm predicate scripts resolve `{{equip}}/` the same way; alarm + **message-template** tokens follow the same resolution rule. ### Validation requirement -Saving a VirtualTag that is bound to a `{{equip}}`-using script is rejected in -the AdminUI if the equipment does not have at least one configured driver tag, or -if its tags span multiple object prefixes (i.e. the common prefix is ambiguous or -absent). The rejection message is surfaced as a clear validation error on the save -form. This check is enforced eagerly so that an unresolved `{{equip}}` token — -which would leave a path that resolves to nothing at runtime (Bad quality) — can -never reach the deployed artifact. +Saving a VirtualTag (or ScriptedAlarm) whose script uses `{{equip}}/` is +rejected in the AdminUI when `` is not one of the owning equipment's +reference effective names. The same rule runs at the deploy gate +(`DraftValidator.ValidateEquipReferenceResolution`, error code +`EquipReferenceUnresolved`), which also catches a **rename-induced** breakage the +authoring check never saw. The rejection names the script/alarm, the equipment, and +the missing ref — so an unresolved token can never reach the deployed artifact. ### Editor support In the Monaco script editor (ScriptEdit page and the `/uns` virtual-tag modal's inline script panel): -- **Hover** — hovering a `{{equip}}` path literal shows an - *"Equipment-relative path — resolved at deploy"* note. -- **Completions** — typing `{{equip}}.` inside a `GetTag`/`SetVirtualTag` literal - offers completion of attribute leaf names (the part after the first dot of known - tag references in the catalog). +- **Hover** — hovering a `{{equip}}/` path literal shows an + *"Equipment-relative path — resolved through the equipment's tag reference at + deploy"* note. +- **Completions** — typing `{{equip}}/` inside a `GetTag`/`SetVirtualTag` literal + offers the owning equipment's **reference effective names** (needs an equipment + context; inert on the shared ScriptEdit page). +- **Diagnostics** — an unresolved `{{equip}}/` is flagged (`OTSCRIPT_EQUIPREF`) + identically to the deploy gate, preserving *editor accepts ⇔ publish accepts*. ### Maintainer note -Substitution runs at the two compose seams — -`Phase7Composer.Compose` and `DeploymentArtifact.BuildEquipmentVirtualTagPlans` -— via the shared `ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths` helper, -**before** dependency extraction. The runtime, the static change-trigger -dependency graph, and the literal-only path rule are therefore all unchanged: -by the time they see the script, `{{equip}}` has been replaced with a concrete -tag-base prefix and the path is a normal string literal. +Substitution runs at the two compose seams — `AddressSpaceComposer.Compose` and +`DeploymentArtifact.ParseComposition` — via the shared +`ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths.SubstituteEquipmentToken` +helper, fed the equipment's reference map (effective name → RawPath) built by +`EquipmentReferenceMap` over the shared `RawPathResolver`. Substitution runs +**before** dependency extraction, so the runtime, the static change-trigger +dependency graph, and the literal-only path rule are unchanged: by the time they +see the script, `{{equip}}/` has been replaced with a concrete RawPath and +the path is a normal string literal. An unresolved ref is left un-substituted (the +validator rejects it first — substitution never throws). The two seams build the +reference map identically, so their plans stay byte-parity. --- diff --git a/docs/Uns.md b/docs/Uns.md index cb0dfa3b..912958b5 100644 --- a/docs/Uns.md +++ b/docs/Uns.md @@ -74,26 +74,53 @@ changed by editing the area's cluster in the Area modal, which moves the whole branch. There is no separate "served-by" concept and no migration — it is simply `UnsArea.ClusterId`. -### Tags +### Tags — reference-only (v3) -Tags created on the equipment page are **equipment-bound** and require a driver -instance. The driver list on the Tags tab is scoped to the equipment's cluster -and to drivers on an **Equipment-kind** namespace, so a driver-less equipment -shows no eligible drivers until you bind one (edit the equipment on the Details -tab and pick a driver). +> **v3 (Batch 3):** UNS equipment no longer authors or binds tags. Device I/O is +> authored once in the **Raw project tree** (`/raw` — see [`Raw.md`](Raw.md)); an +> equipment's **Tags** tab holds **references** to those raw tags. The old +> driver-bound Tag modal on this tab is retired. -**Galaxy / AVEVA System Platform points are ordinary equipment tags** bound to -a `GalaxyMxGateway` driver instance. Author them on the Tags tab using the -standard Tag modal; the Galaxy address picker browses the live Galaxy hierarchy -so you can select the attribute and set `TagConfig.FullName`. There is no -separate alias concept or `SystemPlatform`-kind namespace. +The **Tags** tab is a list of `UnsTagReference` rows. Each row shows the +**effective name**, the backing tag's **RawPath**, its inherited **DataType** and +**AccessLevel** (read-only — they come from the raw tag), and an editable +**display-name override**. The effective name is the override else the raw tag's +`Name`. + +**"+ Add reference"** opens a raw-tree picker (the `/raw` tree in picker mode) +**scoped to the equipment's cluster** — cross-cluster tags are structurally +unreachable, and the service also enforces `tag.cluster == equipment.cluster` on +commit. Tick individual tag leaves, or use a device / tag-group's *"Select all +tags below"* menu to pull many at once. + +**Effective-name uniqueness:** within an equipment the effective name must be +unique across references, VirtualTags, and ScriptedAlarms (they share the +equipment's UNS NodeId space, `{EquipmentId}/{EffectiveName}`). This is enforced +both at authoring (a readable rejection naming the colliding source) and at the +deploy gate (`DraftValidator`, `UnsEffectiveNameCollision`) — the deploy gate is +what catches **rename-induced** collisions a raw rename produced after the +reference was authored, naming both colliding sources and the equipment. + +Removing a raw tag in `/raw` is blocked while a `UnsTagReference` points at it +(the error names the referencing equipment); renaming a raw tag or any ancestor +warns when a beneath-it tag is historized (no `historianTagname` override), +UNS-referenced, or named by a script literal — see [`Raw.md`](Raw.md). + +**Galaxy / AVEVA System Platform points** are now ordinary raw tags on a +`GalaxyMxGateway` driver in `/raw`, referenced into equipment like any other raw +tag. There is no separate alias concept or `SystemPlatform`-kind namespace. ### Virtual tags A virtual tag is bound to an equipment and driven by a **script** (no driver). Add and edit virtual tags on the equipment page's **Virtual Tags** tab; the data type is chosen from the standard OPC UA type list and the Monaco script -editor is available inline. +editor is available inline. Scripts may read the equipment's references +relative-to-equipment with **`ctx.GetTag("{{equip}}/")`** — the token +resolves through the equipment's `UnsTagReference` rows (by effective name) to +the backing raw tag's RawPath at deploy; an unresolved `` is a deploy +error and a live Monaco diagnostic (editor accepts ⇔ publish accepts). See +[`ScriptEditor.md`](ScriptEditor.md). ### Galaxy tags diff --git a/docs/plans/2026-07-16-v3-batch3-PR.md b/docs/plans/2026-07-16-v3-batch3-PR.md new file mode 100644 index 00000000..7047d19f --- /dev/null +++ b/docs/plans/2026-07-16-v3-batch3-PR.md @@ -0,0 +1,89 @@ +# v3 Batch 3 — UNS reference-only Equipment + `{{equip}}/` reference-relative resolution + +Implements `docs/plans/2026-07-15-v3-batch3-uns-rework-plan.md` (WP1–WP4), executed via the +`/v3-batch 3` coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, a +code-reviewer pass + green build/test after each wave, then the non-negotiable 5-item live gate on +docker-dev. + +## What landed + +- **Contracts** — `IEffectiveNameGuard` (authoring-time effective-name uniqueness seam; WP2 implements, + WP1 consumes). +- **Wave A** (3 parallel agents): + - **WP1** — equipment **Tags** tab → `UnsTagReference` list (effective name / RawPath / inherited + DataType+AccessLevel / display-name override); new `AddReferenceModal` reusing `RawTree` in opt-in + `PickerMode` (multi-select tag leaves + device/tag-group "Select all tags below"), **cluster-scoped** + (structural + server-enforced `tag.cluster == equipment.cluster`); `UnsTreeService` reference CRUD; + consumes `IEffectiveNameGuard` in all colliding mutations; `ImportEquipmentModal`/`EquipmentInput` + dropped `DriverInstanceId`; deleted the retired equipment `TagModal`. + - **WP2** — `EffectiveNameGuard` (injectable, ordinal) + DI registration; verified the deploy-gate + `UnsEffectiveNameCollision` rule (already computes ref effective name as override-else-current-raw-name, + so it catches rename-induced collisions; ordinal; names both sources + equipment). + - **WP3** — `RawTreeService.BuildRenameWarningsAsync`: refined historized warning (only tags **without** + a `historianTagname` override) + UNS-referenced + **new** substring scan of every `Script.SourceCode` + for affected tags' OLD RawPaths (recomputed pre-save via `RawPathResolver`, ordinal). +- **Wave B** (1 integration agent): + - **WP4** — `{{equip}}/` reference-relative resolution. `EquipmentScriptPaths.DeriveEquipmentBase` + deleted; slash joint replaces the dot joint; new shared `EquipmentReferenceMap` (`equipmentId → + effectiveName → RawPath`) built identically at both compose seams (`AddressSpaceComposer` + + `DeploymentArtifact`, byte-parity). Unresolved-ref deploy error (`EquipReferenceUnresolved`) covering VT + scripts, SA predicates, and SA message-template tokens; authoring guard (`ValidateEquipTokenAsync`, + the Wave-A M1 fix); Monaco `{{equip}}/` completion of reference effective names + unresolved diagnostic + (`OTSCRIPT_EQUIPREF`), `EquipmentId` threaded request→model→JS. + +**Address space stays dark** (values light up in Batch 4). Full solution builds 0 errors; Commons **309**, +Configuration **121**, AdminUI **659**, OpcUaServer **335/4-skip**, Runtime **361/42-skip** (the skips are +the Batch-1 dark-address-space tests Batch 4 un-skips). + +## Reviewer findings fixed in-branch + +- **Wave A** — no HIGH. Verified: the register-AND-consume guard seam (prod DI injects the real guard; the + NoOp fallback only wins under `new` in tests; guard invoked in all 6 colliding mutations); cluster scoping + structural + server-enforced; Razor default `RawTree` byte-unchanged; WP3 pre-save RawPath recomputation. + L1 (misleading concurrency comment) fixed. +- **Wave B** — no HIGH; byte-parity + production wiring confirmed. **M1** fixed: the editor⇔authoring⇔deploy + invariant now holds on the **ScriptedAlarm** surface too (SA predicate + message-template `{{equip}}` refs + validated at authoring, matching the deploy gate). **L3** fixed: parity test hardened (unresolved-ref-intact + + folder/tag-group RawPath ancestry). L1 already closed at the write path (empty override normalized→null). +- **Deferred (documented follow-ups):** absolute-path Monaco completion still projects the raw leaf name, not + the RawPath (out of `{{equip}}` scope; `{{equip}}/` completion works) — rework `ScriptTagCatalog` to RawPaths + in Batch 4; a broken raw-topology-chain reference is deploy-accepted but compose-dropped (low reachability); + message-template `{{equip}}/X` is gate-validated but rendered/substituted only in Batch 4. + +## Live `/run` gate (docker-dev `:9200`, both central nodes rebuilt on Batch-3 code) + +Substrate: an Area→Line→Equipment seeded in MAIN, plus a SITE-A raw tag so cross-cluster exclusion is +demonstrable. (Hand-seeded equipment surfaced the Batch-1 `EquipmentIdNotDerived` invariant — the canonical +`EQ-` id was applied before the gate.) + +1. **Reference picker + list** — "+ Add reference" header *"The tree is scoped to the equipment's cluster"*; + only the **Main cluster** root shows (SITE-A/B absent — the seeded `SiteAOnly` tag unreachable); lazy + expansion; a device **"Select all tags below"** pulled tags across a collapsed group → **4 references in + one commit**. Rows show effective name / RawPath (incl. deep `opcua1/plc/OpcPlc/Telemetry/Basic/…` + ancestry) / DataType / Access / override. Set override "MainPressure" → effective name updated + `HR200 → MainPressure`, RawPath unchanged. +2. **Collision** — *authoring:* setting a reference override to an existing VirtualTag's name → red banner + **"effective name 'GateVt' already used by VirtualTag 'VT-gatevt' in equipment '…'"** (not persisted). + *rename-induced:* renamed a raw tag so two references collide (allowed at rename) → deploy **422** + `[UnsEffectiveNameCollision] 2 UNS signals collide on effective name 'ImportedTag' … reference '…', + reference '…'`. +3. **`{{equip}}` (editor ⇔ deploy)** — resolving `{{equip}}/MainPressure` deploys **202**; misspelled + `{{equip}}/MainPresure` deploys **422** `[EquipReferenceUnresolved] … has no reference named 'MainPresure'`. + Monaco: red squiggle + hover *"'{{equip}}/MainPresure' does not resolve — … no reference named + 'MainPresure' … (OTSCRIPT_EQUIPREF)"*; typing `{{equip}}/` completes the four reference **effective** + names (AlternatingBoolean, ImportedTag, **MainPressure**, RandomSignedInt32). +4. **Rename warning** — renaming a driver whose beneath-it tag is historized-without-override + UNS-referenced + + named in a script literal → **"Renamed — with warnings"** listing all three (historized fork, + UNS-referenced by 'gateequip', "2 scripts ('cval','gatevt') reference tags … by their raw paths"); renaming + an unrelated driver → **no warning dialog**. (Note: direct *tag* rename flows through `UpdateTagAsync` + which has no warnings surface — warnings fire on container renames affecting descendants; documented + follow-up if per-tag-rename warnings are wanted.) +5. **Import without driver column** — the Import equipment CSV modal columns are `Name, MachineCode, UnsLineId` + (+ optional `ZTag, SAPID, Manufacturer, Model`); **no `DriverInstanceId`**. + +## Docs + +`docs/Uns.md` Tags section rewritten to the reference-only model; `docs/ScriptEditor.md` updated (WP4) to the +slash/reference `{{equip}}` semantics; `CLAUDE.md` gains a v3 Batch 3 paragraph. + +https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentReferenceMap.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentReferenceMap.cs new file mode 100644 index 00000000..2a6eeb3d --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentReferenceMap.cs @@ -0,0 +1,94 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Types; + +/// +/// Builds the per-equipment reference map — equipmentId → (effectiveName → backing-tag RawPath) — +/// the single authority both compose seams (AddressSpaceComposer + DeploymentArtifact) +/// and the draft validator use to resolve {{equip}}/<RefName> script paths. A reference's +/// effective name is its DisplayNameOverride else the backing raw tag's Name; the +/// RawPath is computed through the shared (the same identity authority the +/// rest of v3 uses), so the entity side (authoring/validation) and the artifact-decode side agree +/// byte-for-byte. +/// Input-shape agnostic + pure: callers flatten their references (EF entities on one side, +/// artifact JSON on the other) into the same primitive tuple + tag lookup, so the join, the +/// effective-name rule, and the collision policy live ONLY here. References are consumed in +/// UnsTagReferenceId-ordinal order and effective-name collisions keep the FIRST — a valid draft +/// has none (the uniqueness validator rejects them), and the deterministic first-wins keeps the two +/// seams parity-stable on any input. +/// +public static class EquipmentReferenceMap +{ + /// A flattened UNS tag reference: which equipment references which raw tag under an optional override. + /// Stable logical id — the ordering key for deterministic first-wins. + /// The referencing equipment. + /// The backing raw tag. + /// Optional effective-name override; = use the raw tag's Name. + public readonly record struct ReferenceRow(string UnsTagReferenceId, string EquipmentId, string TagId, string? DisplayNameOverride); + + /// The backing raw tag's identity inputs for its RawPath + effective name. + /// The raw tag's leaf name (also the default effective name). + /// The tag's owning device id. + /// The tag's owning tag-group id, or when directly under the device. + public readonly record struct TagRow(string Name, string DeviceId, string? TagGroupId); + + /// + /// Build the reference map. For each reference (ordered by UnsTagReferenceId), resolve the + /// backing tag's RawPath via and key it under the effective name within + /// the owning equipment. References whose backing tag is unknown or whose RawPath cannot be built + /// (broken chain) are skipped. Effective-name collisions within an equipment keep the first. + /// + /// The flattened UNS tag references (any order; sorted internally). + /// Backing raw tags keyed by TagId. + /// The shared RawPath resolver over the raw topology. + /// equipmentId → (effectiveName → RawPath). Equipments with no resolvable reference are absent. + public static IReadOnlyDictionary> Build( + IEnumerable references, + IReadOnlyDictionary tagsById, + RawPathResolver resolver) + { + ArgumentNullException.ThrowIfNull(references); + ArgumentNullException.ThrowIfNull(tagsById); + ArgumentNullException.ThrowIfNull(resolver); + + var byEquip = new Dictionary>(StringComparer.Ordinal); + + foreach (var r in references.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal)) + { + if (string.IsNullOrEmpty(r.EquipmentId) || string.IsNullOrEmpty(r.TagId)) continue; + if (!tagsById.TryGetValue(r.TagId, out var tag)) continue; + var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!; + if (string.IsNullOrEmpty(effectiveName)) continue; + var rawPath = resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name); + if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names) + + if (!byEquip.TryGetValue(r.EquipmentId, out var map)) + byEquip[r.EquipmentId] = map = new Dictionary(StringComparer.Ordinal); + map.TryAdd(effectiveName, rawPath); // first-wins on collision (validator rejects real collisions) + } + + return byEquip.ToDictionary( + kv => kv.Key, + kv => (IReadOnlyDictionary)kv.Value, + StringComparer.Ordinal); + } + + /// The set of effective names a single equipment's references contribute (for the authoring + /// gate + Monaco completion, which need only the names — not the RawPaths). + /// The flattened UNS tag references for (typically) one equipment. + /// Backing raw tag Name keyed by TagId. + /// The distinct effective names, ordinal. + public static IReadOnlySet EffectiveNames( + IEnumerable references, + IReadOnlyDictionary tagNameById) + { + ArgumentNullException.ThrowIfNull(references); + ArgumentNullException.ThrowIfNull(tagNameById); + var names = new HashSet(StringComparer.Ordinal); + foreach (var r in references) + { + var effectiveName = r.DisplayNameOverride + ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null); + if (!string.IsNullOrEmpty(effectiveName)) names.Add(effectiveName); + } + return names; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs index d29894ca..be647250 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs @@ -3,19 +3,29 @@ using System.Text.RegularExpressions; namespace ZB.MOM.WW.OtOpcUa.Commons.Types; /// -/// Helpers for equipment-relative virtual-tag script paths. The reserved token -/// {{equip}} inside a ctx.GetTag/ctx.SetVirtualTag path literal is -/// replaced at the compose seams with the owning equipment's tag base prefix (derived -/// from its child-tag FullNames). Pure + regex-based (no Roslyn) so the OpcUaServer -/// composer and the Runtime artifact-decode path can both share it. Also the single home -/// for the ctx.GetTag("…") dependency-ref extraction those two seams used to -/// duplicate. +/// Helpers for equipment-relative virtual-tag / scripted-alarm script paths. The reserved token +/// {{equip}}/<RefName> inside a ctx.GetTag/ctx.SetVirtualTag path literal is +/// replaced at the compose seams (AddressSpaceComposer + DeploymentArtifact) with the +/// backing raw tag's RawPath, resolved through the owning equipment's +/// UnsTagReference rows by effective name (<RefName>). +/// v3 syntax: the token joint is a slash{{equip}}/<RefName> — replacing +/// the v2 dot-prefix derivation ({{equip}}.X). <RefName> is a reference's effective name +/// (its DisplayNameOverride else the backing raw tag's Name). An unresolved +/// <RefName> is left un-substituted (substitution never throws); the deploy-time validator + +/// the authoring gate + the Monaco diagnostic reject it first, preserving the invariant +/// the editor accepts ⇔ publish accepts. +/// Pure + regex-based (no Roslyn) so the OpcUaServer composer and the Runtime artifact-decode path can +/// both share it. Also the single home for the ctx.GetTag("…") dependency-ref extraction those +/// two seams used to duplicate. /// public static class EquipmentScriptPaths { - /// The reserved equipment-base token. + /// The reserved equipment token stem. public const string EquipToken = "{{equip}}"; + /// The reserved equipment reference-relative prefix — the token stem plus the slash joint. + public const string EquipTokenPrefix = "{{equip}}/"; + // ctx.GetTag("ref") — reads only; the dependency graph subscribes to exactly these. private static readonly Regex GetTagRefRegex = new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled); @@ -32,6 +42,14 @@ public static class EquipmentScriptPaths private static readonly Regex PathLiteralRegex = new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", RegexOptions.Compiled); + // {{equip}}/ occurrence in FREE TEXT (message templates). runs until the next + // delimiter that cannot appear in an intra-literal RawPath segment used inline in a template — + // whitespace, quote, brace, paren, semicolon, or the '/' separator. (Reference effective names may + // contain internal spaces; that space-bearing form is unambiguous only inside a path literal, so the + // free-text scan is best-effort — see ExtractEquipReferenceNamesFromText.) + private static readonly Regex EquipRefTextRegex = + new(@"\{\{equip\}\}/([^\s""{}();/]+)", RegexOptions.Compiled); + // 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. @@ -46,44 +64,89 @@ public static class EquipmentScriptPaths !string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal); /// - /// Equipment tag base = the single shared substring-before-first-dot across the - /// equipment's child-tag FullNames. Returns null when there are no usable - /// FullNames or they don't agree on one prefix (equipment spanning multiple objects). + /// Replace each {{equip}}/<RefName> reference-relative path with the backing raw tag's + /// RawPath from (effective name → RawPath), inside + /// ctx.GetTag/ctx.SetVirtualTag path literals only. A path literal whose content is + /// {{equip}}/<RefName> is treated as a single reference: <RefName> is the + /// entire remainder after the prefix (so reference effective names may contain internal spaces). + /// Identity when is null/empty, is empty, + /// or the token is absent (so every existing script — none of which use the token — is byte-unchanged). + /// An <RefName> absent from the map is left un-substituted (never throws) — the validator + /// rejects it first at deploy. /// - /// The equipment's child-tag driver FullNames. - /// The shared base prefix, or null when none/ambiguous. - public static string? DeriveEquipmentBase(IEnumerable childFullNames) + /// The script source. + /// The equipment's reference map: effective name → backing-tag RawPath. + /// The source with each resolvable {{equip}}/<RefName> substituted inside path literals. + public static string SubstituteEquipmentToken(string source, IReadOnlyDictionary referenceMap) { - string? found = null; - foreach (var fn in childFullNames) - { - if (string.IsNullOrWhiteSpace(fn)) continue; - var dot = fn.IndexOf('.'); - var prefix = dot < 0 ? fn : fn.Substring(0, dot); - if (prefix.Length == 0) continue; - if (found is null) found = prefix; - else if (!string.Equals(found, prefix, StringComparison.Ordinal)) return null; - } - return found; + if (string.IsNullOrEmpty(source) || referenceMap is null || referenceMap.Count == 0) return source; + if (!source.Contains(EquipTokenPrefix, StringComparison.Ordinal)) return source; + return PathLiteralRegex.Replace(source, m => + m.Groups[1].Value + + SubstituteContent(m.Groups[2].Value, referenceMap) + + m.Groups[3].Value); + } + + // Substitute the reference-relative token inside a single path-literal's content. The content is one + // whole tag path; when it starts with the {{equip}}/ prefix the remainder is the reference name. + private static string SubstituteContent(string content, IReadOnlyDictionary referenceMap) + { + if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) return content; + var refName = content.Substring(EquipTokenPrefix.Length); + if (refName.Length == 0) return content; + return referenceMap.TryGetValue(refName, out var rawPath) ? rawPath : content; } /// - /// Replace {{equip}} with inside - /// ctx.GetTag/ctx.SetVirtualTag path literals only. Identity when - /// is null/empty or the token is absent (so every existing - /// script — none of which use the token — is byte-unchanged). + /// Distinct <RefName> values used via {{equip}}/<RefName> inside + /// ctx.GetTag/ctx.SetVirtualTag path literals, in first-seen order. Scoped to the SAME + /// path literals operates on (so a token in a comment / logger + /// string is not reported), and the entire post-prefix remainder is the reference name (matching + /// substitution) — this keeps the validator / authoring gate / Monaco diagnostic in lockstep with what + /// resolves. Feeds the deploy-gate + authoring rejection + editor completion. /// - /// The script source. - /// The equipment base prefix, or null/empty for no substitution. - /// The source with the token substituted inside path literals. - public static string SubstituteEquipmentToken(string source, string? equipBase) + /// The virtual-tag / scripted-alarm predicate script source. + /// Distinct reference names, first-seen order; empty when none / no token. + public static IReadOnlyList ExtractEquipReferenceNames(string? scriptSource) { - if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(equipBase)) return source; - if (!source.Contains(EquipToken, StringComparison.Ordinal)) return source; - return PathLiteralRegex.Replace(source, m => - m.Groups[1].Value - + m.Groups[2].Value.Replace(EquipToken, equipBase, StringComparison.Ordinal) - + m.Groups[3].Value); + if (string.IsNullOrEmpty(scriptSource) + || !scriptSource.Contains(EquipTokenPrefix, StringComparison.Ordinal)) + return Array.Empty(); + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(); + foreach (Match m in PathLiteralRegex.Matches(scriptSource)) + { + var content = m.Groups[2].Value; + if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) continue; + var refName = content.Substring(EquipTokenPrefix.Length); + if (refName.Length > 0 && seen.Add(refName)) result.Add(refName); + } + return result; + } + + /// + /// Distinct <RefName> values used via {{equip}}/<RefName> in FREE TEXT — the + /// scripted-alarm MessageTemplate, which is not a path literal — in first-seen order. Best-effort: + /// a reference name is captured up to the next whitespace / quote / brace / paren / semicolon / slash, so + /// a space-bearing effective name used inline in a template resolves only up to its first space (a + /// documented, benign limitation — template rendering is dark until Batch 4). Feeds the deploy-gate rule + /// for alarm message tokens. + /// + /// The free text (e.g. a scripted-alarm message template). + /// Distinct reference names, first-seen order; empty when none / no token. + public static IReadOnlyList ExtractEquipReferenceNamesFromText(string? text) + { + if (string.IsNullOrEmpty(text) + || !text.Contains(EquipTokenPrefix, StringComparison.Ordinal)) + return Array.Empty(); + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(); + foreach (Match m in EquipRefTextRegex.Matches(text)) + { + var refName = m.Groups[1].Value; + if (refName.Length > 0 && seen.Add(refName)) result.Add(refName); + } + return result; } /// @@ -115,10 +178,11 @@ public static class EquipmentScriptPaths /// {{equip}} double-brace form is excluded by the token regex. Deterministic so the live /// composer (AddressSpaceComposer) and the artifact-decode mirror (DeploymentArtifact) /// produce the exact same ordered list — the byte-parity contract EquipmentScriptedAlarmPlan - /// equality depends on. Scripted alarms do NOT use {{equip}} substitution (only virtual - /// tags do) — pass the predicate source as-is. + /// equality depends on. The predicate source passed here is the ALREADY-substituted source (the two + /// compose seams substitute {{equip}}/<RefName> before extraction, identically), so the + /// merged refs are resolved RawPaths. /// - /// The resolved predicate script source. + /// The resolved (substituted) predicate script source. /// The alarm message template carrying {TagPath} tokens. /// The merged, distinct, deterministically-ordered dependency refs. public static IReadOnlyList ExtractAlarmDependencyRefs(string? predicateSource, string? messageTemplate) @@ -154,7 +218,7 @@ public static class EquipmentScriptPaths /// The virtual-tag script source to inspect. /// /// When the method returns , the captured GetTag path literal - /// (e.g. TestMachine_020.TestChangingInt or {{equip}}.Speed); + /// (e.g. TestMachine_020.TestChangingInt or {{equip}}/Speed); /// otherwise . /// /// diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs index 86174ef1..11d6e08c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -39,6 +39,7 @@ public static class DraftValidator ValidateRawNameCharset(draft, errors); ValidateHistorizedTagnameLength(draft, errors); ValidateUnsEffectiveLeafUniqueness(draft, errors); + ValidateEquipReferenceResolution(draft, errors); ValidateCalculationTags(draft, errors); return errors; } @@ -238,6 +239,64 @@ public static class DraftValidator } } + /// v3: every {{equip}}/<RefName> used in an equipment's VirtualTag script, + /// ScriptedAlarm predicate script, or ScriptedAlarm message template must resolve to one of the owning + /// equipment's UnsTagReference effective names (DisplayNameOverride else the backing raw + /// tag's Name) — the same set the compose seams substitute against. An unresolved <RefName> + /// is a deploy error (replacing the v2 silent null-base no-substitution), naming the script/alarm, the + /// equipment, and the missing ref. This is the deploy-gate half of the invariant the Monaco diagnostic + + /// the authoring gate enforce (editor accepts ⇔ publish accepts). + private static void ValidateEquipReferenceResolution(DraftSnapshot draft, List errors) + { + var tagNameById = draft.Tags.GroupBy(t => t.TagId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal); + + // equipmentId → set of reference effective names ({{equip}}/ resolves through REFERENCES + // only — VirtualTags/ScriptedAlarms are computed signals with no backing RawPath). + var refNamesByEquip = new Dictionary>(StringComparer.Ordinal); + foreach (var r in draft.UnsTagReferences) + { + var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null); + if (string.IsNullOrEmpty(effective)) continue; + if (!refNamesByEquip.TryGetValue(r.EquipmentId, out var set)) + refNamesByEquip[r.EquipmentId] = set = new HashSet(StringComparer.Ordinal); + set.Add(effective!); + } + + var scriptSourceById = draft.Scripts.GroupBy(s => s.ScriptId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal); + + void CheckRefs(string equipmentId, IEnumerable refNames, string source) + { + var have = refNamesByEquip.TryGetValue(equipmentId, out var set) ? set : null; + foreach (var name in refNames) + { + if (have is not null && have.Contains(name)) continue; + errors.Add(new("EquipReferenceUnresolved", + $"{source} uses '{EquipmentScriptPaths.EquipTokenPrefix}{name}' but equipment '{equipmentId}' has " + + $"no reference named '{name}'; add a reference with that effective name or correct the script.", + equipmentId)); + } + } + + foreach (var v in draft.VirtualTags) + { + var src = scriptSourceById.TryGetValue(v.ScriptId, out var s) ? s : null; + var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src); + if (used.Count > 0) CheckRefs(v.EquipmentId, used, $"VirtualTag '{v.VirtualTagId}'"); + } + + foreach (var a in draft.ScriptedAlarms) + { + var src = scriptSourceById.TryGetValue(a.PredicateScriptId, out var s) ? s : null; + var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src) + .Concat(EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(a.MessageTemplate)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (used.Count > 0) CheckRefs(a.EquipmentId, used, $"ScriptedAlarm '{a.ScriptedAlarmId}'"); + } + } + private static bool IsValidSegment(string? s) => s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor index b1711eeb..34f99e9d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor @@ -78,17 +78,9 @@ else -
- - - - @foreach (var (id, display) in _driverOptions) - { - - } - -
+ @* v3: equipment no longer binds a driver — device I/O is authored in the /raw tree and surfaced + here by reference on the Tags tab. The old "Driver instance" select was removed. *@
@@ -138,38 +130,48 @@ else } else if (_activeTab == "tags") { + @* v3 Batch 3: equipment is reference-only — the Tags tab lists UnsTagReference rows pointing at + raw tags. Datatype/access are inherited (read-only) from the backing raw tag; the display-name + override is the only per-reference editable field. *@
- +
- @if (!string.IsNullOrWhiteSpace(_tagError)) + @if (!string.IsNullOrWhiteSpace(_refError)) { -
@_tagError
+
@_refError
} - @if (_tags is null) + @if (_refs is null) {

Loading…

} - else if (_tags.Count == 0) + else if (_refs.Count == 0) { -

No tags yet.

+

No tag references yet.

} else { - +
- + + + + - @foreach (var t in _tags) + @foreach (var r in _refs) { - - - - - - + + + + + + } @@ -177,9 +179,8 @@ else
NameDriverData typeAccessActions
Effective nameRaw pathData typeAccessDisplay-name overrideActions
@t.Name@t.DriverInstanceId@t.DataType@t.AccessLevel - - +
@r.EffectiveName@r.RawPath@r.DataType@r.AccessLevel + + + +
} - + } else if (_activeTab == "vtags") { @@ -290,15 +291,13 @@ else private EquipmentEditDto? _equipment; private FormModel _form = new(); private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>(); - private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>(); - // --- Tags tab state. _tags is null until the tab is first activated (drives the lazy load + spinner). --- - private IReadOnlyList? _tags; - private string? _tagError; - private bool _tagModalVisible; - private bool _tagModalIsNew; - private TagEditDto? _tagModalExisting; - private IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> _tagDriverOptions = Array.Empty<(string, string, string, string)>(); + // --- Tags tab state (v3 reference-only). _refs is null until the tab is first activated (lazy load + + // spinner). _overrideEdits carries the per-row editable display-name override, keyed by reference id. --- + private IReadOnlyList? _refs; + private readonly Dictionary _overrideEdits = new(StringComparer.Ordinal); + private string? _refError; + private bool _refModalVisible; // --- Virtual Tags tab state. _vtags is null until the tab is first activated. --- private IReadOnlyList? _vtags; @@ -329,54 +328,48 @@ else { _activeTab = tab; if (IsNew) { return; } - if (tab == "tags" && _tags is null) { await ReloadTagsAsync(); } + if (tab == "tags" && _refs is null) { await ReloadReferencesAsync(); } else if (tab == "vtags" && _vtags is null) { await ReloadVirtualTagsAsync(); } else if (tab == "alarms" && _alarms is null) { await ReloadAlarmsAsync(); } } - // --- Tags tab handlers (mirror GlobalUns; the owning equipment is fixed = EquipmentId) --- + // --- Tags tab handlers (v3 reference-only; the owning equipment is fixed = EquipmentId) --- - private async Task ReloadTagsAsync() + private async Task ReloadReferencesAsync() { - _tags = await Svc.LoadTagsForEquipmentAsync(EquipmentId!); + _refs = await Svc.LoadReferencesForEquipmentAsync(EquipmentId!); + // Seed the per-row override edit buffer so each row's binds to a live value. + _overrideEdits.Clear(); + foreach (var r in _refs) { _overrideEdits[r.UnsTagReferenceId] = r.DisplayNameOverride; } } - private async Task OpenAddTag() + private void OpenAddReference() { - _tagError = null; - _tagModalIsNew = true; - _tagModalExisting = null; - _tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!); - _tagModalVisible = true; + _refError = null; + _refModalVisible = true; } - private async Task OpenEditTag(string tagId) + private async Task OnReferencesCommittedAsync() { - _tagError = null; - var dto = await Svc.LoadTagAsync(tagId); - if (dto is null) { _tagError = "That tag no longer exists; the list was refreshed."; await ReloadTagsAsync(); return; } - _tagModalIsNew = false; - _tagModalExisting = dto; - _tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!); - _tagModalVisible = true; + _refModalVisible = false; + await ReloadReferencesAsync(); } - private async Task OnTagSavedAsync() + private async Task SaveOverride(EquipmentReferenceRow r) { - _tagModalVisible = false; - await ReloadTagsAsync(); + _refError = null; + var edited = _overrideEdits.GetValueOrDefault(r.UnsTagReferenceId); + var res = await Svc.SetReferenceOverrideAsync(r.UnsTagReferenceId, edited, r.RowVersion); + if (res.Ok) { await ReloadReferencesAsync(); } + else { _refError = res.Error; } } - private async Task DeleteTag(string tagId) + private async Task RemoveReference(EquipmentReferenceRow r) { - _tagModalVisible = false; - _tagError = null; - // Load the tag fresh to capture its current RowVersion for the optimistic-concurrency delete. - var dto = await Svc.LoadTagAsync(tagId); - if (dto is null) { await ReloadTagsAsync(); return; } - var r = await Svc.DeleteTagAsync(tagId, dto.RowVersion); - if (r.Ok) { await ReloadTagsAsync(); } - else { _tagError = r.Error; } + _refError = null; + var res = await Svc.RemoveReferenceAsync(r.UnsTagReferenceId, r.RowVersion); + if (res.Ok) { await ReloadReferencesAsync(); } + else { _refError = res.Error; } } // --- Virtual Tags tab handlers --- @@ -478,7 +471,7 @@ else // path lands on Details because the field initializes to "details" and a fresh page instance // starts with that initial value. The Tags/Virtual Tags lists are reset to null below so the // lazy loaders re-fetch for the (possibly different) equipment this parameter set targets. - _tags = null; + _refs = null; _vtags = null; _alarms = null; if (!IsNew) @@ -489,7 +482,6 @@ else LoadFormFrom(_equipment); var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId); _lineOptions = ctx.Lines; - _driverOptions = ctx.Drivers; } } else @@ -497,7 +489,6 @@ else _form = new FormModel { UnsLineId = LineId ?? "" }; var ctx = await Svc.LoadEquipmentPickContextAsync(LineId); _lineOptions = ctx.Lines; - _driverOptions = ctx.Drivers; } _loading = false; } @@ -510,7 +501,6 @@ else Name = e.Name, MachineCode = e.MachineCode, UnsLineId = e.UnsLineId, - DriverInstanceId = e.DriverInstanceId, ZTag = e.ZTag, SAPID = e.SAPID, Manufacturer = e.Manufacturer, @@ -536,7 +526,6 @@ else _form.Name, _form.MachineCode, _form.UnsLineId, - _form.DriverInstanceId, _form.ZTag, _form.SAPID, _form.Manufacturer, @@ -582,7 +571,6 @@ else public string Name { get; set; } = ""; [Required] public string MachineCode { get; set; } = ""; [Required] public string UnsLineId { get; set; } = ""; - public string? DriverInstanceId { get; set; } public string? ZTag { get; set; } public string? SAPID { get; set; } public string? Manufacturer { get; set; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor index 6b4e549d..d4c73da1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor @@ -29,6 +29,13 @@ [Parameter] public bool ReadOnly { get; set; } = false; [Parameter] public bool ShowToolbar { get; set; } = true; + /// + /// Owning-equipment context for equipment-relative {{equip}}/<RefName> completion + + /// diagnostics. Set on the per-equipment VirtualTag / ScriptedAlarm modals; left null on the shared + /// ScriptEdit page (where the token cannot resolve to a single equipment). + /// + [Parameter] public string? EquipmentId { get; set; } + /// /// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic /// debounce). The marker DTO is not modelled yet — typed as object[] until a @@ -61,7 +68,8 @@ { value = Value ?? "", language = Language, - readOnly = ReadOnly + readOnly = ReadOnly, + equipmentId = EquipmentId }, _dotNetRef); _initialized = true; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor index 6aa815f3..fef10dcd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor @@ -69,6 +69,28 @@ /// [Parameter] public EventCallback OnRootsChanged { get; set; } + // --------------------------------------------------------------------------- + // v3 Batch 3 — raw-tag PICKER mode (opt-in; default off keeps the /raw usage byte-unchanged). + // In picker mode the mutation context-menus are suppressed: Tag leaves render a multi-select + // checkbox, and Device/TagGroup containers offer a "select all tags below" menu. The equipment + // page's "+ Add reference" modal drives this against a single cluster-scoped root. + // --------------------------------------------------------------------------- + + /// When true, render the tree as a raw-tag picker (checkboxes + select-all) instead of + /// the editing tree. Default false — the /raw editing usage is unaffected. + [Parameter] public bool PickerMode { get; set; } + + /// The shared, caller-owned set of selected raw TagIds (picker mode only). The component + /// mutates it in place and raises after every change. + [Parameter] public HashSet? SelectedTagIds { get; set; } + + /// Raised after the selection set changes (picker mode) so the host can update its count / footer. + [Parameter] public EventCallback OnSelectionChanged { get; set; } + + /// Supplies the raw TagIds beneath a Device/TagGroup node for the "select all tags below" + /// affordance (picker mode). The host wires this to IUnsTreeService.LoadDescendantTagIdsAsync. + [Parameter] public Func>>? ResolveDescendantTagIds { get; set; } + // --- Configure-driver modal (edit) --- private bool _driverCfgVisible; private string? _driverCfgId; @@ -198,6 +220,12 @@ private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder => { + @if (PickerMode && node.Kind == RawNodeKind.Tag) + { + + } @node.DisplayName @if (muted) @@ -270,17 +298,67 @@ // named handlers below are the seam Wave B/C replaces with the real modals. // --------------------------------------------------------------------------- - private IReadOnlyList MenuFor(RawNode node) => node.Kind switch + private IReadOnlyList MenuFor(RawNode node) { - RawNodeKind.Cluster => ClusterMenu(node), - RawNodeKind.Folder => FolderMenu(node), - RawNodeKind.Driver => DriverMenu(node), - RawNodeKind.Device => DeviceMenu(node), - RawNodeKind.TagGroup => TagGroupMenu(node), - RawNodeKind.Tag => TagMenu(node), - _ => Array.Empty(), // Enterprise: label only, no menu. + // Picker mode: no mutation menus. Device/TagGroup containers get a "select all tags below" + // affordance; every other kind (and Tag leaves, which carry the checkbox) has no menu. + if (PickerMode) + { + return node.Kind is RawNodeKind.Device or RawNodeKind.TagGroup + ? PickerContainerMenu(node) + : Array.Empty(); + } + + return node.Kind switch + { + RawNodeKind.Cluster => ClusterMenu(node), + RawNodeKind.Folder => FolderMenu(node), + RawNodeKind.Driver => DriverMenu(node), + RawNodeKind.Device => DeviceMenu(node), + RawNodeKind.TagGroup => TagGroupMenu(node), + RawNodeKind.Tag => TagMenu(node), + _ => Array.Empty(), // Enterprise: label only, no menu. + }; + } + + // --- Picker-mode menu + selection helpers --- + + private List PickerContainerMenu(RawNode node) => new() + { + new() { Label = "Select all tags below", Icon = "☑", OnClick = () => SelectAllUnderAsync(node) }, + new() { Label = "Clear all tags below", Icon = "☐", OnClick = () => ClearAllUnderAsync(node) }, }; + private bool IsTagSelected(RawNode node) => + node.EntityId is not null && SelectedTagIds is not null && SelectedTagIds.Contains(node.EntityId); + + private async Task ToggleTagSelection(RawNode node, bool selected) + { + if (SelectedTagIds is null || node.EntityId is null) { return; } + if (selected) { SelectedTagIds.Add(node.EntityId); } + else { SelectedTagIds.Remove(node.EntityId); } + await OnSelectionChanged.InvokeAsync(); + StateHasChanged(); + } + + private async Task SelectAllUnderAsync(RawNode node) + { + if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; } + var ids = await ResolveDescendantTagIds(node); + foreach (var id in ids) { SelectedTagIds.Add(id); } + await OnSelectionChanged.InvokeAsync(); + StateHasChanged(); + } + + private async Task ClearAllUnderAsync(RawNode node) + { + if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; } + var ids = await ResolveDescendantTagIds(node); + foreach (var id in ids) { SelectedTagIds.Remove(id); } + await OnSelectionChanged.InvokeAsync(); + StateHasChanged(); + } + private List ClusterMenu(RawNode node) => new() { new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) }, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor new file mode 100644 index 00000000..3d05abd8 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor @@ -0,0 +1,125 @@ +@* v3 Batch 3 "+ Add reference" picker: reuses the Raw project tree (RawTree) in picker mode to + multi-select raw tags, scoped to the equipment's own cluster. The cluster scope is structural — + IUnsTreeService.LoadReferencePickerRootAsync hands back a SINGLE cluster root, so raw tags of any + other cluster are simply unreachable through the tree, not merely hidden. On commit the selected + tag ids are added as UnsTagReference rows via AddReferencesAsync; the host reloads its Tags list. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw +@inject IUnsTreeService Svc + +@if (Visible) +{ + + +} + +@code { + /// Whether the modal is shown. The host owns this flag. + [Parameter] public bool Visible { get; set; } + + /// The equipment the selected raw tags are referenced under. + [Parameter] public string? EquipmentId { get; set; } + + /// Raised after references are committed so the host can reload its Tags list and close. + [Parameter] public EventCallback OnCommitted { get; set; } + + /// Raised when the user cancels so the host can close. + [Parameter] public EventCallback OnCancel { get; set; } + + private IReadOnlyList _roots = Array.Empty(); + private readonly HashSet _selected = new(StringComparer.Ordinal); + private bool _busy; + private bool _loading; + private string? _error; + private bool _wasVisible; + + protected override async Task OnParametersSetAsync() + { + // Reset + reload only on the false→true transition so re-renders while open don't wipe state. + if (Visible && !_wasVisible) + { + _selected.Clear(); + _error = null; + _roots = Array.Empty(); + _loading = true; + StateHasChanged(); + + var root = string.IsNullOrEmpty(EquipmentId) + ? null + : await Svc.LoadReferencePickerRootAsync(EquipmentId); + _roots = root is null ? Array.Empty() : new[] { root }; + _loading = false; + } + _wasVisible = Visible; + } + + private Task> ResolveDescendantsAsync(RawNode node) => + Svc.LoadDescendantTagIdsAsync(node); + + // RawTree mutates _selected in place; this just re-renders the footer count. + private void OnSelectionChanged() => StateHasChanged(); + + private async Task CommitAsync() + { + if (_selected.Count == 0) { return; } + _busy = true; + _error = null; + try + { + var res = await Svc.AddReferencesAsync(EquipmentId!, _selected.ToList()); + if (res.Ok) { await OnCommitted.InvokeAsync(); } + else { _error = res.Error; } + } + finally + { + _busy = false; + } + } + + private Task CancelAsync() => OnCancel.InvokeAsync(); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor index e6cf2cf4..759875b4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor @@ -2,9 +2,10 @@ the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported so the page can reload the whole tree (an import can add equipment across many lines/clusters). - Required header columns (in order): Name, MachineCode, UnsLineId, DriverInstanceId. + Required header columns (in order): Name, MachineCode, UnsLineId. Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and - skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page. *@ + skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page. + v3: equipment no longer carries a driver, so the old DriverInstanceId column is gone. *@ @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @inject IUnsTreeService Svc @@ -21,7 +22,7 @@ - + @if (!string.IsNullOrWhiteSpace(_scriptError)) {
@_scriptError
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 7239ee9e..72a33228 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -49,6 +49,10 @@ public static class EndpointRouteBuilderExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + // v3 Batch 3 (WP2): authoring-time UNS effective-name uniqueness guard. Consumed by + // UnsTreeService's reference/VirtualTag/ScriptedAlarm mutations (WP1); mirrors the + // deploy-time DraftValidator UnsEffectiveNameCollision rule. + services.AddScoped(); // WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude). services.AddScoped(); services.AddSingleton(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs index e6f03fd9..d7004c2d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs @@ -23,13 +23,16 @@ public interface IScriptTagCatalog /// The resolved tag info, or when is not a known configured path. Task GetTagInfoAsync(string path, CancellationToken ct); - /// Distinct attribute leaf names — the substring after the first dot of each - /// configured tag FullName — optionally prefix-filtered. Powers {{equip}}. completion, - /// which needs the per-equipment object prefix stripped. - /// Case-insensitive StartsWith prefix over the leaf; null/empty = all (bounded). + /// v3: the owning equipment's UnsTagReference effective names (its + /// DisplayNameOverride else the backing raw tag's Name) — the resolvable set for + /// {{equip}}/<RefName> completion + diagnostics, matching what the compose seams substitute + /// and the deploy gate enforces. Optionally prefix-filtered. Empty when the equipment id is blank or has + /// no references. + /// The equipment whose references to list; blank returns empty. + /// Case-insensitive StartsWith prefix over the reference name; null/empty = all (bounded). /// Cancellation token. - /// Distinct leaf names. - Task> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct); + /// Distinct reference effective names. + Task> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct); } /// Resolved info for one configured tag/virtual-tag path (for hover). @@ -114,17 +117,32 @@ public sealed class ScriptTagCatalog(IDbContextFactory d } /// - public async Task> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct) + public async Task> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct) { - var entries = await BuildEntriesAsync(ct); - var leaves = new HashSet(StringComparer.Ordinal); - foreach (var e in entries) + if (string.IsNullOrEmpty(equipmentId)) return Array.Empty(); + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var refs = await db.UnsTagReferences.AsNoTracking() + .Where(r => r.EquipmentId == equipmentId) + .Select(r => new { r.TagId, r.DisplayNameOverride }) + .ToListAsync(ct); + if (refs.Count == 0) return Array.Empty(); + + var tagIds = refs.Select(r => r.TagId).Distinct().ToList(); + var tagNameById = await db.Tags.AsNoTracking() + .Where(t => tagIds.Contains(t.TagId)) + .Select(t => new { t.TagId, t.Name }) + .ToDictionaryAsync(t => t.TagId, t => t.Name, ct); + + // Effective name = DisplayNameOverride else the backing raw tag's Name (ordinal set). + var names = new HashSet(StringComparer.Ordinal); + foreach (var r in refs) { - var dot = e.Path.IndexOf('.'); - if (dot < 0 || dot + 1 >= e.Path.Length) continue; - leaves.Add(e.Path.Substring(dot + 1)); + var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null); + if (!string.IsNullOrEmpty(eff)) names.Add(eff!); } - IEnumerable q = leaves; + + IEnumerable q = names; if (!string.IsNullOrWhiteSpace(filter)) q = q.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase)); return q.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).Take(MaxResults).ToList(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs index 62f962a4..2c71031c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs @@ -1,15 +1,19 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; -public record DiagnoseRequest(string Code); +// EquipmentId (optional) carries the owning-equipment context the {{equip}}/ completion + +// diagnostic need to resolve reference effective names. Null on the shared ScriptEdit page (no single +// equipment) — the equip-ref features are then inert, matching the deploy gate (which only resolves per +// owning equipment). +public record DiagnoseRequest(string Code, string? EquipmentId = null); public record DiagnoseResponse(IReadOnlyList Markers); public record DiagnosticMarker(int Severity, int StartLineNumber, int StartColumn, int EndLineNumber, int EndColumn, string Message, string Code); -public record CompletionsRequest(string CodeText, int Line, int Column); +public record CompletionsRequest(string CodeText, int Line, int Column, string? EquipmentId = null); public record CompletionsResponse(IReadOnlyList Items); public record CompletionItem(string Label, string InsertText, string Detail, string Kind, int InsertTextRules = 0); -public record HoverRequest(string CodeText, int Line, int Column); +public record HoverRequest(string CodeText, int Line, int Column, string? EquipmentId = null); public record HoverResponse(string? Markdown); public record SignatureHelpRequest(string CodeText, int Line, int Column); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs index 7fec4785..6783f56f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs @@ -16,7 +16,7 @@ public static class ScriptAnalysisEndpoints // ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate. var group = endpoints.MapGroup("/api/script-analysis") .RequireAuthorization(AdminUiPolicies.ConfigEditor); - group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r))); + group.MapPost("/diagnostics", async (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(await s.DiagnoseAsync(r))); group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r))); group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r))); group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r))); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs index 32331a0e..663ef22c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -130,6 +130,58 @@ public sealed class ScriptAnalysisService } } + // ctx.GetTag("…") / ctx.SetVirtualTag("…") first-arg literal — three-part capture, mirroring + // EquipmentScriptPaths.PathLiteralRegex so the editor's {{equip}}/ diagnostic is scoped to the + // SAME path literals the compose seams substitute (never a comment / logger string). + private static readonly System.Text.RegularExpressions.Regex EquipPathLiteralRegex = + new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", + System.Text.RegularExpressions.RegexOptions.Compiled); + + /// Diagnostics plus the v3 equipment-relative {{equip}}/<RefName> resolution check. + /// Runs the synchronous Roslyn/policy then, when an equipment context + catalog + /// are present, appends a marker for each {{equip}}/<RefName> whose <RefName> is + /// not one of the equipment's reference effective names — flagging exactly what the deploy gate + /// (DraftValidator.ValidateEquipReferenceResolution) rejects, so the editor accepts ⇔ publish + /// accepts. Inert (base markers only) on the shared ScriptEdit page where EquipmentId is null. + /// The diagnose request (its EquipmentId carries the owning-equipment context). + /// The base diagnostics plus any unresolved-reference markers. + public async Task DiagnoseAsync(DiagnoseRequest req) + { + var baseResp = Diagnose(req); + if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code)) + return baseResp; + try + { + var code = Normalize(req.Code); + if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal)) + return baseResp; + + var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, CancellationToken.None); + var resolvable = new HashSet(names, StringComparer.Ordinal); + var markers = new List(baseResp.Markers); + + foreach (System.Text.RegularExpressions.Match m in EquipPathLiteralRegex.Matches(code)) + { + var content = m.Groups[2].Value; + if (!content.StartsWith(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal)) continue; + var refName = content.Substring(EquipmentScriptPaths.EquipTokenPrefix.Length); + if (refName.Length == 0 || resolvable.Contains(refName)) continue; + // Mark the whole literal content span (the {{equip}}/ path). + var span = new Microsoft.CodeAnalysis.Text.TextSpan(m.Groups[2].Index, content.Length); + markers.Add(ToUserMarker(code, span, + $"'{EquipmentScriptPaths.EquipTokenPrefix}{refName}' does not resolve — this equipment has no " + + $"reference named '{refName}'. Add a matching reference or correct the path.", + "OTSCRIPT_EQUIPREF")); + } + return new DiagnoseResponse(markers.Distinct().ToList()); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Script equip-ref diagnostics failed; returning base markers."); + return baseResp; + } + } + private static int Sev(DiagnosticSeverity s) => s switch { DiagnosticSeverity.Error => 8, @@ -173,13 +225,17 @@ public sealed class ScriptAnalysisService if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix, out _)) { - const string equipDot = EquipmentScriptPaths.EquipToken + "."; // "{{equip}}." - if (pathPrefix.StartsWith(equipDot, StringComparison.Ordinal)) + // v3: {{equip}}/ completes the owning equipment's UnsTagReference effective names + // (needs the equipment context — inert on the shared ScriptEdit page where EquipmentId is null). + const string equipPrefix = EquipmentScriptPaths.EquipTokenPrefix; // "{{equip}}/" + if (pathPrefix.StartsWith(equipPrefix, StringComparison.Ordinal)) { - var leaves = await _catalog.GetEquipmentRelativeLeavesAsync( - pathPrefix.Substring(equipDot.Length), CancellationToken.None); - return new CompletionsResponse(leaves - .Select(n => new CompletionItem(equipDot + n, equipDot + n, "tag path", "Field")) + if (string.IsNullOrEmpty(req.EquipmentId)) + return new CompletionsResponse(Array.Empty()); + var names = await _catalog.GetEquipmentReferenceNamesAsync( + req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None); + return new CompletionsResponse(names + .Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field")) .ToList()); } @@ -281,7 +337,8 @@ public sealed class ScriptAnalysisService { return new HoverResponse( $"**Equipment-relative path** `{Code(tagPath)}`\n\n" + - "The {{equip}} token is replaced with the owning equipment's tag base when the VirtualTag is deployed."); + "`{{equip}}/` resolves through the owning equipment's tag reference " + + "(by effective name) to the backing raw tag's path when the VirtualTag is deployed."); } var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EffectiveNameGuard.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EffectiveNameGuard.cs new file mode 100644 index 00000000..97c8d124 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EffectiveNameGuard.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Default . Loads the owning equipment's current effective-name +/// set — references (DisplayNameOverride else the backing raw tag's Name), +/// VirtualTags (Name), and ScriptedAlarms (Name) — and reports the first collision +/// with a proposed name. Comparison is to match the +/// {EquipmentId}/{EffectiveName} NodeId semantics and the deploy-time +/// DraftValidator UnsEffectiveNameCollision rule. +/// +/// +/// Each call creates and disposes its own pooled context — the same per-call pattern +/// uses — so the guard is safe to register Scoped. +/// +public sealed class EffectiveNameGuard(IDbContextFactory dbFactory) : IEffectiveNameGuard +{ + /// + public async Task CheckAsync( + string equipmentId, + string proposedEffectiveName, + EffectiveNameSourceKind kind, + string? excludeSourceId, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + // References for this equipment. The effective name is the display-name override else the + // backing raw tag's current Name, so the two sets (references + tags) are loaded and joined + // in memory — the per-equipment set is small and this mirrors the deploy rule exactly + // (a reference whose backing tag is missing contributes no effective name, so it is skipped). + var references = await db.UnsTagReferences + .AsNoTracking() + .Where(r => r.EquipmentId == equipmentId) + .Select(r => new { r.UnsTagReferenceId, r.TagId, r.DisplayNameOverride }) + .ToListAsync(ct); + + var tagIds = references.Select(r => r.TagId).Distinct(StringComparer.Ordinal).ToList(); + var tagNameById = (await db.Tags + .AsNoTracking() + .Where(t => tagIds.Contains(t.TagId)) + .Select(t => new { t.TagId, t.Name }) + .ToListAsync(ct)) + .GroupBy(t => t.TagId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal); + + foreach (var r in references) + { + if (IsSelf(EffectiveNameSourceKind.Reference, r.UnsTagReferenceId, kind, excludeSourceId)) + continue; + var effective = r.DisplayNameOverride + ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null); + if (Collides(effective, proposedEffectiveName)) + return Message(proposedEffectiveName, "reference", r.UnsTagReferenceId, equipmentId); + } + + var virtualTags = await db.VirtualTags + .AsNoTracking() + .Where(v => v.EquipmentId == equipmentId) + .Select(v => new { v.VirtualTagId, v.Name }) + .ToListAsync(ct); + + foreach (var v in virtualTags) + { + if (IsSelf(EffectiveNameSourceKind.VirtualTag, v.VirtualTagId, kind, excludeSourceId)) + continue; + if (Collides(v.Name, proposedEffectiveName)) + return Message(proposedEffectiveName, "VirtualTag", v.VirtualTagId, equipmentId); + } + + var scriptedAlarms = await db.ScriptedAlarms + .AsNoTracking() + .Where(a => a.EquipmentId == equipmentId) + .Select(a => new { a.ScriptedAlarmId, a.Name }) + .ToListAsync(ct); + + foreach (var a in scriptedAlarms) + { + if (IsSelf(EffectiveNameSourceKind.ScriptedAlarm, a.ScriptedAlarmId, kind, excludeSourceId)) + continue; + if (Collides(a.Name, proposedEffectiveName)) + return Message(proposedEffectiveName, "ScriptedAlarm", a.ScriptedAlarmId, equipmentId); + } + + return null; + } + + /// True when a row is the self-row being edited — same kind AND same logical id — so it + /// is excluded from the collision set (a rename / override-change of a row cannot collide with itself). + private static bool IsSelf( + EffectiveNameSourceKind rowKind, + string rowId, + EffectiveNameSourceKind editedKind, + string? excludeSourceId) => + excludeSourceId is not null + && rowKind == editedKind + && string.Equals(rowId, excludeSourceId, StringComparison.Ordinal); + + private static bool Collides(string? existing, string proposed) => + existing is not null && string.Equals(existing, proposed, StringComparison.Ordinal); + + private static string Message(string name, string sourceLabel, string sourceId, string equipmentId) => + $"effective name '{name}' already used by {sourceLabel} '{sourceId}' in equipment '{equipmentId}'"; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs index b1cee5e6..ec313790 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs @@ -9,3 +9,28 @@ public sealed record EquipmentTagRow( /// A virtual-tag row for the equipment page's Virtual Tags tab table. public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled); + +/// +/// A UNS tag-reference row for the equipment page's Tags tab table (v3 reference-only equipment). Each +/// row projects a together with the backing raw +/// tag's inherited (read-only) datatype/access and computed RawPath. The effective name is the +/// DisplayNameOverride when set, else the backing raw tag's Name. RowVersion is the +/// reference row's concurrency token, echoed on the override-set / remove mutations. +/// +/// The reference's stable logical id (the mutation key). +/// Override else the raw tag's Name — the UNS leaf name. +/// The backing raw tag's computed RawPath (folder/driver/device/group/tag). +/// Inherited OPC UA built-in type name from the raw tag (read-only). +/// Inherited access level from the raw tag (read-only). +/// The optional display-name override; null = use the raw name. +/// Sibling display ordering within the equipment's Tags list. +/// The reference row's optimistic-concurrency token. +public sealed record EquipmentReferenceRow( + string UnsTagReferenceId, + string EffectiveName, + string RawPath, + string DataType, + TagAccessLevel AccessLevel, + string? DisplayNameOverride, + int SortOrder, + byte[] RowVersion); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentInput.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentInput.cs index 4a39f767..dba8d117 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentInput.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentInput.cs @@ -11,7 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// UNS level-5 segment; matches ^[a-z0-9-]{1,32}$. /// Operator colloquial id; unique fleet-wide. /// Logical FK to the owning . -/// Optional driver binding; whitespace/empty means driver-less. /// Optional ERP equipment id. /// Optional SAP PM equipment id. /// Optional OPC 40010 manufacturer name. @@ -28,7 +27,6 @@ public sealed record EquipmentInput( string Name, string MachineCode, string UnsLineId, - string? DriverInstanceId, string? ZTag, string? SAPID, string? Manufacturer, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IEffectiveNameGuard.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IEffectiveNameGuard.cs new file mode 100644 index 00000000..959d8acd --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IEffectiveNameGuard.cs @@ -0,0 +1,61 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Which authoring surface a proposed effective name comes from. Used to word the collision +/// error and to exclude the correct same-kind self-row on an edit (rename / override change). +/// +public enum EffectiveNameSourceKind +{ + /// A (effective name = + /// DisplayNameOverride else the backing raw tag's Name). + Reference, + + /// An equipment-bound (effective name = Name). + VirtualTag, + + /// An equipment-bound (effective name = Name). + ScriptedAlarm, +} + +/// +/// v3 Batch 3 authoring-time guard for the UNS effective-name uniqueness rule. Within an +/// equipment the EFFECTIVE leaf name must be unique across its UnsTagReferences +/// (DisplayNameOverride else the backing raw tag's Name), its VirtualTags +/// (Name), and its ScriptedAlarms (Name) — the three share the equipment's +/// UNS NodeId space ({EquipmentId}/{EffectiveName}). This is the authoring mirror of the +/// deploy-time DraftValidator UnsEffectiveNameCollision rule; comparison is +/// to match NodeId semantics and the validator. +/// +/// +/// Injectable (registered Scoped, its own pooled context per call — the same pattern +/// UnsTreeService uses). Consumed by UnsTreeService's reference / virtual-tag / +/// scripted-alarm mutations, which call before persisting and surface a +/// non-null result as the mutation's readable failure. The deploy gate remains the backstop for +/// rename-induced collisions the authoring check never saw. +/// +public interface IEffectiveNameGuard +{ + /// + /// Tests whether would collide with an existing + /// effective name within . + /// + /// The owning equipment's logical id. + /// The effective name to be authored (override else raw name for a reference; Name for a VT/alarm). + /// Which surface the proposed name is for (words the error; identifies the self-row kind to exclude). + /// + /// On an edit, the logical id of the row being changed (its UnsTagReferenceId / + /// VirtualTagId / ScriptedAlarmId) so it is excluded from the collision set; + /// on a create. + /// + /// Cancellation token. + /// + /// when the name is free; otherwise a readable error naming the + /// colliding existing source and the equipment. + /// + Task CheckAsync( + string equipmentId, + string proposedEffectiveName, + EffectiveNameSourceKind kind, + string? excludeSourceId, + CancellationToken ct = default); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index 1faecc18..b2dd3f01 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -139,6 +139,82 @@ public interface IUnsTreeService /// The equipment's virtual-tag rows ordered by Name; empty if it has none. Task> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default); + // --------------------------------------------------------------------------------------------- + // v3 UNS reference-only equipment: the Tags tab lists UnsTagReference rows pointing at raw tags. + // --------------------------------------------------------------------------------------------- + + /// + /// Loads the rows for a single equipment as flat + /// projections for the equipment page's Tags tab, ordered by SortOrder then effective name. + /// Each row carries the effective name (override else the backing raw tag's Name), the backing + /// tag's computed RawPath, its inherited (read-only) datatype/access, the optional display-name + /// override, and the reference's concurrency token. Reads untracked. Returns an empty list when the + /// equipment has no references. + /// + /// The equipment whose references to load. + /// A token to cancel the load. + /// The equipment's reference rows; empty if it has none. + Task> LoadReferencesForEquipmentAsync(string equipmentId, CancellationToken ct = default); + + /// + /// Adds one per supplied raw TagId to an + /// equipment. Every backing tag MUST live in the same cluster as the equipment (cross-cluster + /// references are rejected). Each new reference's effective name (the raw tag's Name — no + /// override on add) must be unique within the equipment across references, VirtualTags, and + /// ScriptedAlarms (enforced via ), and a tag already referenced by + /// the equipment is rejected. The batch is all-or-nothing. + /// + /// The referencing equipment. + /// The raw tag ids to reference. + /// A token to cancel the operation. + /// Success, or the first guard/cluster/duplicate failure (nothing is added). + Task AddReferencesAsync(string equipmentId, IReadOnlyList tagIds, CancellationToken ct = default); + + /// + /// Removes a UNS tag reference. A missing row is treated as success (already gone). Uses last-write-wins + /// optimistic concurrency on . + /// + /// The reference to remove. + /// The concurrency token the caller last read. + /// A token to cancel the operation. + /// Success, a concurrency failure, or a delete-failed failure. + Task RemoveReferenceAsync(string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Sets (or clears) a reference's DisplayNameOverride. A whitespace-only override collapses to + /// null (use the raw name). The resulting effective name (override else the backing raw tag's + /// Name) must be unique within the equipment across references, VirtualTags, and ScriptedAlarms + /// (enforced via , excluding this reference). Uses last-write-wins + /// optimistic concurrency. + /// + /// The reference to update. + /// The new override, or null/blank to clear it. + /// The concurrency token the caller last read. + /// A token to cancel the operation. + /// Success, a missing-row failure, a name-collision failure, or a concurrency failure. + Task SetReferenceOverrideAsync(string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Builds the single cluster-rooted that seeds the "+ Add reference" raw-tag + /// picker for an equipment, structurally scoped to the equipment's own cluster (raw tags in any other + /// cluster are unreachable through this root — the scope is enforced by the query, not merely hidden). + /// The picker's RawTree lazily expands it via . + /// Returns null when the equipment cannot be resolved to a cluster. + /// + /// The equipment whose cluster scopes the picker. + /// A token to cancel the load. + /// The cluster root node, or null when the equipment/cluster can't be resolved. + Task LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default); + + /// + /// Enumerates every raw TagId beneath a Device or TagGroup picker node (the whole subtree), for + /// the picker's "select all tags below" affordance. Returns an empty list for non-container node kinds. + /// + /// The picker node (Device or TagGroup) to enumerate tags under. + /// A token to cancel the query. + /// The tag ids in the node's subtree; empty for unsupported kinds. + Task> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default); + /// /// Loads a single UNS area projected for editing, or null if it no longer exists. /// Reads untracked and captures the current concurrency token for last-write-wins saves. @@ -284,24 +360,22 @@ public interface IUnsTreeService /// /// Creates a new equipment under a UNS line. The EquipmentId is system-generated /// (EQ- + the first 12 hex chars of a fresh EquipmentUuid). - /// Fails if the line is unset, if the MachineCode is already used fleet-wide, or if the - /// driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID - /// collapse to null. + /// Fails if the line is unset or if the MachineCode is already used fleet-wide. Whitespace-only + /// ZTag/SAPID collapse to null. (v3: equipment no longer binds a driver — the reference-only + /// Tags tab points at raw tags instead — so no driver-cluster guard applies.) /// /// The operator-editable equipment fields. /// A token to cancel the operation. - /// Success, a missing-line failure, a duplicate-MachineCode failure, or a cluster-guard failure. + /// Success, a missing-line failure, or a duplicate-MachineCode failure. Task CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default); /// /// Bulk-imports equipment from a parsed set of rows in a single /// context, applying the same rules as the single-add path: a row whose UnsLineId does not - /// exist is an error; a row whose DriverInstanceId is set but does not resolve is an error; - /// a driver-bound row whose driver is in a different cluster than its line fails the cluster - /// guard; and a row whose MachineCode already exists in the DB or earlier in the - /// same batch is silently skipped (additive-only — never an update). Inserted rows get a + /// exist is an error; and a row whose MachineCode already exists in the DB or earlier + /// in the same batch is silently skipped (additive-only — never an update). Inserted rows get a /// system-generated EQ- id and a fresh EquipmentUuid. All inserts are saved once at - /// the end. + /// the end. (v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard.) /// /// The parsed equipment rows to import. /// A token to cancel the operation. @@ -309,16 +383,16 @@ public interface IUnsTreeService Task ImportEquipmentAsync(IReadOnlyList rows, CancellationToken ct = default); /// - /// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external - /// ids, and the OPC 40010 identification fields). The driver-cluster guard blocks - /// binding to a driver in a different cluster than the equipment's line. Uses last-write-wins - /// optimistic concurrency on . + /// Updates an equipment's mutable fields (line, name, MachineCode, external ids, and the OPC 40010 + /// identification fields). Fails on a MachineCode already used by another row. Uses last-write-wins + /// optimistic concurrency on . (v3: equipment + /// no longer binds a driver, so there is no driver-cluster guard.) /// /// The equipment to update. /// The new operator-editable equipment fields. /// The concurrency token the caller last read. /// A token to cancel the operation. - /// Success, a missing-row failure, a cluster-guard failure, or a concurrency failure. + /// Success, a missing-row failure, a duplicate-MachineCode failure, or a concurrency failure. Task UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default); /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index f619c59e..1039b03c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -1282,24 +1282,41 @@ public sealed class RawTreeService(IDbContextFactory dbF } /// - /// Builds the non-blocking rename warnings answerable from this batch's schema: historized tags and - /// equipment-referenced tags beneath the renamed node (whose RawPath — and thus historian tagname / - /// UNS projection path — changes with the rename). Batch 3 appends the script-substring scan here. + /// A raw tag affected by a rename, carrying the fields needed to (a) read its platform intents and + /// (b) recompute its OLD RawPath (still the in-DB state at warning time — the rename is not yet saved). + /// + private readonly record struct AffectedTag( + string TagId, string TagConfig, string DeviceId, string? TagGroupId, string Name); + + /// + /// Builds the non-blocking rename warnings answerable from this batch's schema for the tags beneath the + /// renamed node (whose RawPath — and thus historian tagname / UNS projection path / script literal — + /// changes with the rename): (a) historized tags WITHOUT a historianTagname override (their + /// history forks, since the default tagname is the moving RawPath; pinned tags are unaffected), + /// (b) equipment-referenced tags, and (c) tags whose OLD RawPath appears as a literal substring in any + /// body (tolerates false positives by design — no AST). /// private static async Task> BuildRenameWarningsAsync( - OtOpcUaConfigDbContext db, IReadOnlyList<(string TagId, string TagConfig)> tags, CancellationToken ct) + OtOpcUaConfigDbContext db, IReadOnlyList tags, CancellationToken ct) { var warnings = new List(); if (tags.Count == 0) return warnings; - var historized = tags.Count(t => TagConfigIntent.Parse(t.TagConfig).IsHistorized); - if (historized > 0) + // (a) Historized WITHOUT a historianTagname override: the default tagname is the RawPath, which is + // moving, so history forks on the next deploy. A tag WITH the override is pinned and does not warn. + var forking = tags.Count(t => { - warnings.Add(historized == 1 - ? "1 historized tag beneath this node will change its RawPath (historian tagname). Update the historian if it keys on the old path." - : $"{historized} historized tags beneath this node will change their RawPath (historian tagname). Update the historian if it keys on the old paths."); + var intent = TagConfigIntent.Parse(t.TagConfig); + return intent.IsHistorized && intent.HistorianTagname is null; + }); + if (forking > 0) + { + warnings.Add(forking == 1 + ? "1 historized tag beneath this node has no historianTagname override; its history forks to a new tagname when its RawPath changes on the next deploy. Set a historianTagname to pin it." + : $"{forking} historized tags beneath this node have no historianTagname override; their history forks to new tagnames when their RawPaths change on the next deploy. Set a historianTagname to pin them."); } + // (b) UNS-referenced: name the referencing equipment. var tagIds = tags.Select(t => t.TagId).ToList(); var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking() .Where(r => tagIds.Contains(r.TagId)) @@ -1313,12 +1330,94 @@ public sealed class RawTreeService(IDbContextFactory dbF warnings.Add($"Tags beneath this node are referenced by equipment {display}; the UNS projection path will change with the rename."); } - // Batch 3 extension point: scan scripts for substrings of the affected tags' RawPaths and append - // a "N script(s) reference tags beneath this node" warning to this same list. + // (c) Script literals: substring-scan every Script body for the affected tags' OLD RawPaths. + var scriptWarning = await BuildScriptReferenceWarningAsync(db, tags, ct); + if (scriptWarning is not null) warnings.Add(scriptWarning); return warnings; } + /// + /// Scans every body for the OLD RawPath of any affected tag as a plain ordinal + /// substring (no AST — false positives tolerated by design; {{equip}}-relative refs are NOT + /// scanned, they resolve through references and the deploy gate catches breakage). Returns a warning + /// naming the matched scripts, or when none match. + /// + private static async Task BuildScriptReferenceWarningAsync( + OtOpcUaConfigDbContext db, IReadOnlyList tags, CancellationToken ct) + { + var oldPaths = await ComputeOldRawPathsAsync(db, tags, ct); + if (oldPaths.Count == 0) return null; + + var scripts = await db.Scripts.AsNoTracking() + .Select(s => new { s.ScriptId, s.Name, s.SourceCode }) + .ToListAsync(ct); + + var matched = scripts + .Where(s => oldPaths.Any(p => s.SourceCode.Contains(p, StringComparison.Ordinal))) + .OrderBy(s => s.Name, StringComparer.Ordinal) + .Select(s => s.Name) + .ToList(); + if (matched.Count == 0) return null; + + var names = string.Join(", ", matched.Select(n => $"'{n}'")); + return matched.Count == 1 + ? $"1 script ({names}) references a tag beneath this node by its raw path; the reference will break unless the script is updated." + : $"{matched.Count} scripts ({names}) reference tags beneath this node by their raw paths; the references will break unless the scripts are updated."; + } + + /// + /// Recomputes the OLD RawPath of each affected tag from the current (pre-save) in-DB raw topology, + /// dropping any tag whose ancestry chain is broken (a null RawPath). Loads only the topology the affected + /// tags need: their devices + drivers, the drivers' clusters' folders, and the devices' tag groups. + /// + private static async Task> ComputeOldRawPathsAsync( + OtOpcUaConfigDbContext db, IReadOnlyList tags, CancellationToken ct) + { + var deviceIds = tags.Select(t => t.DeviceId).Distinct(StringComparer.Ordinal).ToList(); + + var deviceRows = await db.Devices.AsNoTracking() + .Where(d => deviceIds.Contains(d.DeviceId)) + .Select(d => new { d.DeviceId, d.DriverInstanceId, d.Name }) + .ToListAsync(ct); + + var driverIds = deviceRows.Select(d => d.DriverInstanceId).Distinct(StringComparer.Ordinal).ToList(); + var driverRows = await db.DriverInstances.AsNoTracking() + .Where(d => driverIds.Contains(d.DriverInstanceId)) + .Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name, d.ClusterId }) + .ToListAsync(ct); + + var clusterIds = driverRows.Select(d => d.ClusterId).Distinct(StringComparer.Ordinal).ToList(); + var folderRows = await db.RawFolders.AsNoTracking() + .Where(f => clusterIds.Contains(f.ClusterId)) + .Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name }) + .ToListAsync(ct); + + var groupRows = await db.TagGroups.AsNoTracking() + .Where(g => deviceIds.Contains(g.DeviceId)) + .Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name }) + .ToListAsync(ct); + + var folders = folderRows.ToDictionary( + f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal); + var drivers = driverRows.ToDictionary( + d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal); + var devices = deviceRows.ToDictionary( + d => d.DeviceId, d => (d.DriverInstanceId, d.Name), StringComparer.Ordinal); + var groups = groupRows.ToDictionary( + g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal); + + var resolver = new RawPathResolver(folders, drivers, devices, groups); + + var paths = new List(tags.Count); + foreach (var t in tags) + { + var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name); + if (path is not null) paths.Add(path); + } + return paths; + } + /// Renders a friendly "Name (Id), …" list for the referencing equipment ids. private static async Task DescribeEquipmentAsync( OtOpcUaConfigDbContext db, IReadOnlyList equipmentIds, CancellationToken ct) @@ -1336,11 +1435,11 @@ public sealed class RawTreeService(IDbContextFactory dbF // --- Subtree tag collectors (walk the in-DB subtree for rename impact) --- - private static async Task> CollectTagsBeneathFolderAsync( + private static async Task> CollectTagsBeneathFolderAsync( OtOpcUaConfigDbContext db, string rawFolderId, CancellationToken ct) { var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct); - if (folder is null) return Array.Empty<(string, string)>(); + if (folder is null) return Array.Empty(); // All folders in the cluster, walked in-memory to the descendant set (incl. self) via ParentRawFolderId. var clusterFolders = await db.RawFolders.AsNoTracking() @@ -1359,44 +1458,44 @@ public sealed class RawTreeService(IDbContextFactory dbF .Where(d => d.RawFolderId != null && folderIds.Contains(d.RawFolderId)) .Select(d => d.DriverInstanceId) .ToListAsync(ct); - if (driverIds.Count == 0) return Array.Empty<(string, string)>(); + if (driverIds.Count == 0) return Array.Empty(); var deviceIds = await db.Devices.AsNoTracking() .Where(dev => driverIds.Contains(dev.DriverInstanceId)) .Select(dev => dev.DeviceId) .ToListAsync(ct); - if (deviceIds.Count == 0) return Array.Empty<(string, string)>(); + if (deviceIds.Count == 0) return Array.Empty(); return await TagsForDevicesAsync(db, deviceIds, ct); } - private static async Task> CollectTagsBeneathDriverAsync( + private static async Task> CollectTagsBeneathDriverAsync( OtOpcUaConfigDbContext db, string driverInstanceId, CancellationToken ct) { var deviceIds = await db.Devices.AsNoTracking() .Where(dev => dev.DriverInstanceId == driverInstanceId) .Select(dev => dev.DeviceId) .ToListAsync(ct); - if (deviceIds.Count == 0) return Array.Empty<(string, string)>(); + if (deviceIds.Count == 0) return Array.Empty(); return await TagsForDevicesAsync(db, deviceIds, ct); } - private static async Task> CollectTagsBeneathDeviceAsync( + private static async Task> CollectTagsBeneathDeviceAsync( OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct) { return (await db.Tags.AsNoTracking() .Where(t => t.DeviceId == deviceId) - .Select(t => new { t.TagId, t.TagConfig }) + .Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name }) .ToListAsync(ct)) - .Select(t => (t.TagId, t.TagConfig)) + .Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name)) .ToList(); } - private static async Task> CollectTagsBeneathGroupAsync( + private static async Task> CollectTagsBeneathGroupAsync( OtOpcUaConfigDbContext db, string tagGroupId, CancellationToken ct) { var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); - if (group is null) return Array.Empty<(string, string)>(); + if (group is null) return Array.Empty(); var deviceGroups = await db.TagGroups.AsNoTracking() .Where(g => g.DeviceId == group.DeviceId) @@ -1412,20 +1511,20 @@ public sealed class RawTreeService(IDbContextFactory dbF return (await db.Tags.AsNoTracking() .Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId)) - .Select(t => new { t.TagId, t.TagConfig }) + .Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name }) .ToListAsync(ct)) - .Select(t => (t.TagId, t.TagConfig)) + .Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name)) .ToList(); } - private static async Task> TagsForDevicesAsync( + private static async Task> TagsForDevicesAsync( OtOpcUaConfigDbContext db, IReadOnlyList deviceIds, CancellationToken ct) { return (await db.Tags.AsNoTracking() .Where(t => deviceIds.Contains(t.DeviceId)) - .Select(t => new { t.TagId, t.TagConfig }) + .Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name }) .ToListAsync(ct)) - .Select(t => (t.TagId, t.TagConfig)) + .Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name)) .ToList(); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index 0a12e7ce..d0ed2e32 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -18,8 +18,17 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// every AdminUI page uses — so the service is safe to register as Scoped and used per /// Blazor circuit. /// -public sealed class UnsTreeService(IDbContextFactory dbFactory) : IUnsTreeService +public sealed class UnsTreeService( + IDbContextFactory dbFactory, + IEffectiveNameGuard? guard = null) : IUnsTreeService { + // The v3 Batch-3 effective-name uniqueness guard (references / VirtualTags / ScriptedAlarms share the + // equipment's UNS NodeId space). WP2 registers the real implementation; production DI always injects it. + // A null (unregistered) guard falls back to a no-op — collisions are then caught only at the deploy + // gate — which also lets unit tests that don't exercise the guard construct the service with just a + // db factory. Tests that DO exercise the guard pass a fake. + private readonly IEffectiveNameGuard _guard = guard ?? NoOpEffectiveNameGuard.Instance; + /// public async Task> LoadStructureAsync(CancellationToken ct = default) { @@ -97,6 +106,371 @@ public sealed class UnsTreeService(IDbContextFactory dbF .ToListAsync(ct); } + // --------------------------------------------------------------------------------------------- + // v3 UNS reference-only equipment (Batch 3): the Tags tab lists UnsTagReference rows. + // --------------------------------------------------------------------------------------------- + + /// + public async Task> LoadReferencesForEquipmentAsync( + string equipmentId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var refs = await db.UnsTagReferences.AsNoTracking() + .Where(r => r.EquipmentId == equipmentId) + .OrderBy(r => r.SortOrder).ThenBy(r => r.UnsTagReferenceId) + .Select(r => new + { + r.UnsTagReferenceId, + r.TagId, + r.DisplayNameOverride, + r.SortOrder, + r.RowVersion, + }) + .ToListAsync(ct); + + if (refs.Count == 0) return Array.Empty(); + + // Load the backing raw tags (name + inherited datatype/access), then compute each RawPath from the + // equipment cluster's raw topology through the shared RawPathResolver (identity authority). + var tagIds = refs.Select(r => r.TagId).Distinct().ToList(); + var tags = (await db.Tags.AsNoTracking() + .Where(t => tagIds.Contains(t.TagId)) + .Select(t => new { t.TagId, t.DeviceId, t.TagGroupId, t.Name, t.DataType, t.AccessLevel }) + .ToListAsync(ct)) + .ToDictionary(t => t.TagId, StringComparer.Ordinal); + + var cluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct); + var resolver = await BuildClusterRawPathResolverAsync(db, cluster, ct); + + var rows = new List(refs.Count); + foreach (var r in refs) + { + if (!tags.TryGetValue(r.TagId, out var tag)) + { + // Backing tag missing (should not happen — raw-tag delete is reference-blocked); surface a + // clearly-degraded row rather than dropping it so the operator can remove the dangling ref. + rows.Add(new EquipmentReferenceRow( + r.UnsTagReferenceId, r.DisplayNameOverride ?? "(missing tag)", "(missing tag)", + "", TagAccessLevel.Read, r.DisplayNameOverride, r.SortOrder, r.RowVersion)); + continue; + } + + var rawPath = resolver?.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name) ?? tag.Name; + var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride; + rows.Add(new EquipmentReferenceRow( + r.UnsTagReferenceId, effectiveName, rawPath, tag.DataType, tag.AccessLevel, + r.DisplayNameOverride, r.SortOrder, r.RowVersion)); + } + + return rows; + } + + /// + public async Task AddReferencesAsync( + string equipmentId, IReadOnlyList tagIds, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(tagIds); + var distinctTagIds = tagIds.Where(t => !string.IsNullOrEmpty(t)).Distinct(StringComparer.Ordinal).ToList(); + if (distinctTagIds.Count == 0) return new UnsMutationResult(false, "Pick at least one raw tag to reference."); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct)) + return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found."); + + var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct); + if (equipmentCluster is null) + return new UnsMutationResult(false, $"Equipment '{equipmentId}' does not resolve to a cluster."); + + // Load the backing tags + the cluster each lives in (Tag → Device → DriverInstance.ClusterId). + var tags = await db.Tags.AsNoTracking() + .Where(t => distinctTagIds.Contains(t.TagId)) + .Join(db.Devices.AsNoTracking(), t => t.DeviceId, dev => dev.DeviceId, (t, dev) => new { t, dev.DriverInstanceId }) + .Join(db.DriverInstances.AsNoTracking(), x => x.DriverInstanceId, d => d.DriverInstanceId, + (x, d) => new { x.t.TagId, x.t.Name, DriverCluster = d.ClusterId }) + .ToListAsync(ct); + + var tagById = tags.ToDictionary(t => t.TagId, StringComparer.Ordinal); + + // Existing references on the equipment (to reject re-referencing the same tag). + var alreadyReferenced = (await db.UnsTagReferences.AsNoTracking() + .Where(r => r.EquipmentId == equipmentId) + .Select(r => r.TagId) + .ToListAsync(ct)) + .ToHashSet(StringComparer.Ordinal); + + var maxSort = await db.UnsTagReferences.AsNoTracking() + .Where(r => r.EquipmentId == equipmentId) + .Select(r => (int?)r.SortOrder) + .MaxAsync(ct) ?? -1; + + // Track effective names proposed within THIS batch so two raw tags sharing a Name are caught (the + // guard sees only persisted rows; a same-batch collision would otherwise slip through). + var batchNames = new HashSet(StringComparer.Ordinal); + var pending = new List(); + + foreach (var tagId in distinctTagIds) + { + if (!tagById.TryGetValue(tagId, out var tag)) + return new UnsMutationResult(false, $"Raw tag '{tagId}' not found."); + + if (!string.Equals(tag.DriverCluster, equipmentCluster, StringComparison.Ordinal)) + return new UnsMutationResult(false, + $"Raw tag '{tag.Name}' is in cluster '{tag.DriverCluster}' but the equipment is in cluster '{equipmentCluster}' — cross-cluster references are not allowed."); + + if (alreadyReferenced.Contains(tagId)) + return new UnsMutationResult(false, $"Raw tag '{tag.Name}' is already referenced by this equipment."); + + // Effective name at add = the raw tag's Name (no override yet). + if (!batchNames.Add(tag.Name)) + return new UnsMutationResult(false, + $"Two selected raw tags share the effective name '{tag.Name}'; set a display-name override so they stay unique within the equipment."); + + var collision = await _guard.CheckAsync(equipmentId, tag.Name, EffectiveNameSourceKind.Reference, null, ct); + if (collision is not null) return new UnsMutationResult(false, collision); + + pending.Add(new UnsTagReference + { + UnsTagReferenceId = $"UTR-{Guid.NewGuid():N}"[..16], + EquipmentId = equipmentId, + TagId = tagId, + DisplayNameOverride = null, + SortOrder = ++maxSort, + }); + } + + db.UnsTagReferences.AddRange(pending); + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateException) + { + // The UX_UnsTagReference_Equip_Tag unique index tripped: a concurrent add slipped a + // same-(equipment,tag) row past the pre-check. (Effective-name collisions have no DB + // uniqueness — those are caught by the authoring guard and the deploy gate, not here.) + return new UnsMutationResult(false, "Add failed — a reference at one of these tags was created concurrently. Reload and retry."); + } + } + + /// + public async Task RemoveReferenceAsync( + string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct); + if (entity is null) return new UnsMutationResult(true, null); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.UnsTagReferences.Remove(entity); + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this reference while you were viewing it."); + } + catch (Exception ex) + { + return new UnsMutationResult(false, $"Remove failed: {ex.Message}."); + } + } + + /// + public async Task SetReferenceOverrideAsync( + string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default) + { + var normalized = string.IsNullOrWhiteSpace(displayNameOverride) ? null : displayNameOverride.Trim(); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct); + if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + + // Effective name = the override when set, else the backing raw tag's Name. + var rawName = await db.Tags.AsNoTracking() + .Where(t => t.TagId == entity.TagId) + .Select(t => t.Name) + .FirstOrDefaultAsync(ct); + var effectiveName = normalized ?? rawName ?? string.Empty; + + var collision = await _guard.CheckAsync( + entity.EquipmentId, effectiveName, EffectiveNameSourceKind.Reference, entity.UnsTagReferenceId, ct); + if (collision is not null) return new UnsMutationResult(false, collision); + + entity.DisplayNameOverride = normalized; + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this reference while you were editing."); + } + } + + /// + public async Task LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var clusterId = await ResolveEquipmentClusterAsync(db, equipmentId, ct); + if (clusterId is null) return null; + + var cluster = await db.ServerClusters.AsNoTracking() + .FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct); + if (cluster is null) return null; + + // Root child count = cluster-root folders + cluster-root drivers (the RawTree lazily loads the rest + // via IRawTreeService.LoadChildrenAsync, keyed on this node's Cluster kind + EntityId). + var rootFolders = await db.RawFolders.AsNoTracking() + .CountAsync(f => f.ClusterId == clusterId && f.ParentRawFolderId == null, ct); + var rootDrivers = await db.DriverInstances.AsNoTracking() + .CountAsync(d => d.ClusterId == clusterId && d.RawFolderId == null, ct); + + return new RawNode + { + Kind = RawNodeKind.Cluster, + Key = $"clu:{clusterId}", + DisplayName = cluster.Name, + ClusterId = clusterId, + EntityId = clusterId, + HasLazyChildren = true, + ChildCount = rootFolders + rootDrivers, + }; + } + + /// + public async Task> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(node); + if (node.EntityId is null) return Array.Empty(); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + switch (node.Kind) + { + case RawNodeKind.Device: + // Every tag under the device (across all its tag groups). + return await db.Tags.AsNoTracking() + .Where(t => t.DeviceId == node.EntityId) + .Select(t => t.TagId) + .ToListAsync(ct); + + case RawNodeKind.TagGroup: + { + // The group + all descendant groups, then every tag in that group set. + var group = await db.TagGroups.AsNoTracking() + .FirstOrDefaultAsync(g => g.TagGroupId == node.EntityId, ct); + if (group is null) return Array.Empty(); + + var deviceGroups = await db.TagGroups.AsNoTracking() + .Where(g => g.DeviceId == group.DeviceId) + .Select(g => new { g.TagGroupId, g.ParentTagGroupId }) + .ToListAsync(ct); + + var childrenByParent = deviceGroups + .Where(g => g.ParentTagGroupId is not null) + .GroupBy(g => g.ParentTagGroupId!, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(x => x.TagGroupId).ToList(), StringComparer.Ordinal); + + var groupIds = DescendantGroupsInclusive(node.EntityId, childrenByParent); + return await db.Tags.AsNoTracking() + .Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId)) + .Select(t => t.TagId) + .ToListAsync(ct); + } + + default: + // The picker only offers "select all" on Device / TagGroup containers. + return Array.Empty(); + } + } + + /// Returns plus all transitive descendant group ids (iterative walk). + private static HashSet DescendantGroupsInclusive( + string rootId, IReadOnlyDictionary> childrenByParent) + { + var result = new HashSet(StringComparer.Ordinal); + var stack = new Stack(); + stack.Push(rootId); + while (stack.Count > 0) + { + var id = stack.Pop(); + if (!result.Add(id)) continue; + if (childrenByParent.TryGetValue(id, out var kids)) + { + foreach (var kid in kids) stack.Push(kid); + } + } + return result; + } + + /// + /// Builds a over a cluster's raw topology (folders / drivers / devices / + /// tag-groups), so a referenced tag's RawPath can be computed the same way the deploy validator does. + /// Returns when the cluster is unknown/null (callers then fall back to bare name). + /// + private static async Task BuildClusterRawPathResolverAsync( + OtOpcUaConfigDbContext db, string? clusterId, CancellationToken ct) + { + if (string.IsNullOrEmpty(clusterId)) return null; + + var folders = (await db.RawFolders.AsNoTracking() + .Where(f => f.ClusterId == clusterId) + .Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name }) + .ToListAsync(ct)) + .ToDictionary(f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal); + + var drivers = (await db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == clusterId) + .Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name }) + .ToListAsync(ct)) + .ToDictionary(d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal); + + var driverIds = drivers.Keys.ToList(); + + var devices = (await db.Devices.AsNoTracking() + .Where(dev => driverIds.Contains(dev.DriverInstanceId)) + .Select(dev => new { dev.DeviceId, dev.DriverInstanceId, dev.Name }) + .ToListAsync(ct)) + .ToDictionary(dev => dev.DeviceId, dev => (dev.DriverInstanceId, dev.Name), StringComparer.Ordinal); + + var deviceIds = devices.Keys.ToList(); + + var groups = (await db.TagGroups.AsNoTracking() + .Where(g => deviceIds.Contains(g.DeviceId)) + .Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name }) + .ToListAsync(ct)) + .ToDictionary(g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal); + + return new RawPathResolver(folders, drivers, devices, groups); + } + + /// + /// A no-op used only when no guard was injected (unit tests that do + /// not exercise the guard; a mis-wired DI). It always reports "free" — the deploy gate remains the + /// backstop. Production always injects the real WP2 guard. + /// + private sealed class NoOpEffectiveNameGuard : IEffectiveNameGuard + { + public static readonly NoOpEffectiveNameGuard Instance = new(); + + public Task CheckAsync( + string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind, + string? excludeSourceId, CancellationToken ct = default) => Task.FromResult(null); + } + /// public async Task LoadAreaAsync(string unsAreaId, CancellationToken ct = default) { @@ -446,11 +820,8 @@ public sealed class UnsTreeService(IDbContextFactory dbF return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet."); } - var guard = await CheckDriverClusterGuardAsync(db, input, ct); - if (guard is not null) - { - return guard.Value; - } + // v3: equipment no longer binds a driver — the reference-only Tags tab points at raw tags — so + // the old decision-#122 driver-cluster guard is gone. var uuid = Guid.NewGuid(); var equipmentId = $"EQ-{uuid.ToString("N")[..12]}"; @@ -514,14 +885,7 @@ public sealed class UnsTreeService(IDbContextFactory dbF continue; } - // Reuse the driver/line cluster guard: it reports an unknown DriverInstanceId and a - // driver/line cluster mismatch, and is a no-op for driver-less rows. - var guard = await CheckDriverClusterGuardAsync(db, row, ct); - if (guard is not null) - { - errors.Add($"Row '{row.MachineCode}': {guard.Value.Error}"); - continue; - } + // v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard. var uuid = Guid.NewGuid(); var equipmentId = $"EQ-{uuid.ToString("N")[..12]}"; @@ -578,11 +942,7 @@ public sealed class UnsTreeService(IDbContextFactory dbF return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet."); } - var guard = await CheckDriverClusterGuardAsync(db, input, ct); - if (guard is not null) - { - return guard.Value; - } + // v3: equipment no longer binds a driver — no decision-#122 driver-cluster guard. // Equipment↔driver binding retired in v3 — no DriverInstanceId column remains. entity.UnsLineId = input.UnsLineId; @@ -866,29 +1226,82 @@ public sealed class UnsTreeService(IDbContextFactory dbF } /// - /// When the bound script uses the reserved {{equip}} token, the owning equipment must have - /// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result - /// when the token is present but no base can be derived; null otherwise (token absent, or a - /// base exists). The script source is fetched from the supplied (in-scope) context. + /// v3 (M1): when the bound script uses {{equip}}/<RefName>, every <RefName> must + /// resolve to one of the owning equipment's UnsTagReference effective names (its + /// DisplayNameOverride else the backing raw tag's Name) — the same set the compose seams + /// substitute against and the deploy gate (DraftValidator.ValidateEquipReferenceResolution) + /// enforces. Returns a readable rejection naming the missing ref(s) when any is unresolved; null + /// otherwise (no slash-token, or all resolve). Replaces the v2 DeriveEquipmentBase(empty)→null + /// check, which rejected ALL {{equip}} VirtualTags with a now-impossible "add a driver tag" message. /// private static async Task ValidateEquipTokenAsync( OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct) { var src = await db.Scripts.Where(s => s.ScriptId == scriptId) .Select(s => s.SourceCode).FirstOrDefaultAsync(ct); - if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null; + var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src); + if (used.Count == 0) return null; + var effectiveNames = await LoadEquipReferenceNamesAsync(db, equipmentId, ct); + return CheckEquipReferencesResolve(used, effectiveNames, equipmentId); + } - // The equipment↔Tag binding was retired in v3 (Tag is Raw-only), so no equipment-bound driver - // tags exist to derive an equipment tag base from. The {{equip}} token can't be resolved here. - var fullNames = Array.Empty(); - if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null) + /// + /// Scripted-alarm variant of : validates {{equip}}/<RefName> + /// tokens in BOTH the predicate script AND the message template against the equipment's reference set — + /// the authoring mirror of the deploy gate, which covers all three (VT scripts, SA predicates, SA templates). + /// Keeps the editor⇔authoring⇔deploy invariant tight on the SA surface. + /// + private static async Task ValidateEquipTokenForAlarmAsync( + OtOpcUaConfigDbContext db, string equipmentId, string? predicateScriptId, string? messageTemplate, CancellationToken ct) + { + var used = new HashSet(StringComparer.Ordinal); + if (!string.IsNullOrEmpty(predicateScriptId)) { - return new UnsMutationResult(false, - $"Equipment '{equipmentId}' has no single tag base, so the " - + $"{EquipmentScriptPaths.EquipToken} token can't be resolved. Add at least one driver tag under this " - + $"equipment (all sharing one object prefix), or remove {EquipmentScriptPaths.EquipToken} from the script."); + var src = await db.Scripts.Where(s => s.ScriptId == predicateScriptId) + .Select(s => s.SourceCode).FirstOrDefaultAsync(ct); + foreach (var u in EquipmentScriptPaths.ExtractEquipReferenceNames(src)) used.Add(u); } - return null; + foreach (var u in EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(messageTemplate)) used.Add(u); + if (used.Count == 0) return null; + var effectiveNames = await LoadEquipReferenceNamesAsync(db, equipmentId, ct); + return CheckEquipReferencesResolve(used, effectiveNames, equipmentId); + } + + /// The equipment's reference effective-name set: DisplayNameOverride else the backing raw + /// tag's Name (empty override treated as absent, matching EquipmentReferenceMap.Build), ordinal. + private static async Task> LoadEquipReferenceNamesAsync( + OtOpcUaConfigDbContext db, string equipmentId, CancellationToken ct) + { + var refs = await db.UnsTagReferences.AsNoTracking() + .Where(r => r.EquipmentId == equipmentId) + .Select(r => new { r.TagId, r.DisplayNameOverride }) + .ToListAsync(ct); + var tagIds = refs.Select(r => r.TagId).ToList(); + var tagNameById = await db.Tags.AsNoTracking() + .Where(t => tagIds.Contains(t.TagId)) + .Select(t => new { t.TagId, t.Name }) + .ToDictionaryAsync(t => t.TagId, t => t.Name, ct); + var effectiveNames = new HashSet(StringComparer.Ordinal); + foreach (var r in refs) + { + var eff = string.IsNullOrEmpty(r.DisplayNameOverride) + ? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null) + : r.DisplayNameOverride; + if (!string.IsNullOrEmpty(eff)) effectiveNames.Add(eff!); + } + return effectiveNames; + } + + /// Names the unresolved {{equip}}/<RefName> tokens (or null when all resolve). + private static UnsMutationResult? CheckEquipReferencesResolve( + IReadOnlyCollection used, HashSet effectiveNames, string equipmentId) + { + var missing = used.Where(u => !effectiveNames.Contains(u)).ToList(); + if (missing.Count == 0) return null; + return new UnsMutationResult(false, + $"Script uses {string.Join(", ", missing.Select(m => $"'{EquipmentScriptPaths.EquipTokenPrefix}{m}'"))} " + + $"but equipment '{equipmentId}' has no reference with that effective name. " + + "Add a matching reference on the Tags tab, or correct the script."); } /// @@ -920,6 +1333,11 @@ public sealed class UnsTreeService(IDbContextFactory dbF return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment."); } + // v3 Batch 3: the effective name must also be unique against this equipment's references + scripted + // alarms (they share the equipment's UNS NodeId space) — the authoring mirror of the deploy gate. + var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, null, ct); + if (nameGuard is not null) return new UnsMutationResult(false, nameGuard); + var equipGuard = await ValidateEquipTokenAsync(db, equipmentId, input.ScriptId, ct); if (equipGuard is not null) return equipGuard.Value; @@ -969,6 +1387,10 @@ public sealed class UnsTreeService(IDbContextFactory dbF return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment."); } + // v3 Batch 3: effective-name uniqueness across references + scripted alarms (excluding this VT). + var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, virtualTagId, ct); + if (nameGuard is not null) return new UnsMutationResult(false, nameGuard); + var equipGuard = await ValidateEquipTokenAsync(db, entity.EquipmentId, input.ScriptId, ct); if (equipGuard is not null) return equipGuard.Value; @@ -1193,58 +1615,6 @@ public sealed class UnsTreeService(IDbContextFactory dbF return null; } - /// - /// An equipment may only bind to a driver in the same cluster as its line. - /// Policy: - /// - /// Driver-less (DriverInstanceId empty) → always allowed (returns null). - /// Driver bound but the line/area does not resolve to a cluster → rejected (unresolvable - /// line cannot guarantee cluster alignment, so the bind is unsafe). - /// Driver bound, line resolves, clusters differ → rejected. - /// Driver bound, line resolves, clusters match → allowed (returns null). - /// - /// - private static async Task CheckDriverClusterGuardAsync( - OtOpcUaConfigDbContext db, - EquipmentInput input, - CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(input.DriverInstanceId)) - { - return null; - } - - var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == input.UnsLineId, ct); - var area = line is null ? null : await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct); - var lineCluster = area?.ClusterId; - - if (lineCluster is null) - { - return new UnsMutationResult( - false, - $"Cannot bind driver '{input.DriverInstanceId}': UNS line '{input.UnsLineId}' does not resolve to a cluster (decision #122)."); - } - - var driverCluster = await db.DriverInstances - .Where(d => d.DriverInstanceId == input.DriverInstanceId) - .Select(d => d.ClusterId) - .FirstOrDefaultAsync(ct); - - if (driverCluster is null) - { - return new UnsMutationResult(false, $"Driver '{input.DriverInstanceId}' not found."); - } - - if (driverCluster != lineCluster) - { - return new UnsMutationResult( - false, - $"Driver '{input.DriverInstanceId}' is in cluster '{driverCluster}' but the line is in cluster '{lineCluster}' (decision #122)."); - } - - return null; - } - /// public async Task> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default) { @@ -1277,6 +1647,14 @@ public sealed class UnsTreeService(IDbContextFactory dbF if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == equipmentId && a.Name == input.Name, ct)) return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment."); + // v3 Batch 3: effective-name uniqueness across references + VirtualTags on this equipment. + var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, null, ct); + if (nameGuard is not null) return new UnsMutationResult(false, nameGuard); + + // v3 Batch 3 (WP4): {{equip}}/ in the predicate script or message template must resolve. + var equipGuard = await ValidateEquipTokenForAlarmAsync(db, equipmentId, input.PredicateScriptId, input.MessageTemplate, ct); + if (equipGuard is not null) return equipGuard.Value; + db.ScriptedAlarms.Add(new ScriptedAlarm { ScriptedAlarmId = input.ScriptedAlarmId, @@ -1307,6 +1685,14 @@ public sealed class UnsTreeService(IDbContextFactory dbF if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == entity.EquipmentId && a.Name == input.Name && a.ScriptedAlarmId != scriptedAlarmId, ct)) return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment."); + // v3 Batch 3: effective-name uniqueness across references + VirtualTags (excluding this alarm). + var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, scriptedAlarmId, ct); + if (nameGuard is not null) return new UnsMutationResult(false, nameGuard); + + // v3 Batch 3 (WP4): {{equip}}/ in the predicate script or message template must resolve. + var equipGuard = await ValidateEquipTokenForAlarmAsync(db, entity.EquipmentId, input.PredicateScriptId, input.MessageTemplate, ct); + if (equipGuard is not null) return equipGuard.Value; + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; entity.Name = input.Name; entity.AlarmType = input.AlarmType; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js index 714a7e37..e90c63bc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js @@ -23,6 +23,11 @@ const KIND_MAP = { Method: 0, Field: 3, Property: 9, Event: 10, Class: 5, Module: 8, Variable: 4, Text: 18 }; + // The owning-equipment id is stamped onto each editor's model at createEditor time so the globally + // registered csharp providers (which only receive the model) can thread it into the {{equip}}/ + // completion + diagnostics. Null on the shared ScriptEdit page (equip-ref features inert there). + function equipmentIdOf(model) { return (model && model.__otEquipmentId) || null; } + function registerCSharpProviders() { monaco.languages.registerCompletionItemProvider("csharp", { triggerCharacters: [".", "(", "\""], @@ -31,7 +36,7 @@ const resp = await fetch("/api/script-analysis/completions", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column }) + body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) }) }); if (!resp.ok) return { suggestions: [] }; const data = await resp.json(); @@ -72,7 +77,7 @@ const resp = await fetch("/api/script-analysis/hover", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column }) + body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) }) }); if (!resp.ok) return null; const data = await resp.json(); @@ -149,7 +154,7 @@ const resp = await fetch("/api/script-analysis/diagnostics", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: model.getValue() }) + body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) }) }); if (!resp.ok) return []; const data = await resp.json(); @@ -192,6 +197,9 @@ // inside ctx.GetTag("…") / ctx.SetVirtualTag("…") without requiring an explicit Ctrl+Space. quickSuggestions: { other: true, comments: false, strings: true } }); + // Stamp the owning-equipment id on the model so the global csharp providers can thread it into the + // {{equip}}/ completion + diagnostics (null on the shared ScriptEdit page). + try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {} let diagTimer = null; const scheduleDiagnostics = function () { if (diagTimer) clearTimeout(diagTimer); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs index 1625aec6..24d46012 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs @@ -315,6 +315,11 @@ public static class AddressSpaceComposer /// The scripted alarms. /// The per-equipment virtual (calculated) tags. null = none. /// The scripts joined to by ScriptId for the expression. null = none. + /// The UNS tag references (equipment → raw tag). Feed the {{equip}}/<RefName> reference map. null = none. + /// The raw tags backing (RawPath computed via the shared resolver). null = none. + /// The raw-tree folders (RawPath ancestry). null = none. + /// The raw-tree devices (RawPath ancestry). null = none. + /// The raw-tree tag-groups (RawPath ancestry). null = none. /// The composition result. public static AddressSpaceComposition Compose( IReadOnlyList unsAreas, @@ -323,7 +328,12 @@ public static class AddressSpaceComposer IReadOnlyList driverInstances, IReadOnlyList scriptedAlarms, IReadOnlyList? virtualTags = null, - IReadOnlyList