Commit Graph

76 Commits

Author SHA1 Message Date
Joseph Doherty 8c05a9fe0a merge worktree-agent-a2c703868462add4a into v3/batch3-uns-rework 2026-07-16 06:16:42 -04:00
Joseph Doherty 77bc010ba9 v3 Batch 3 WP1: UNS Equipment Tags tab → reference list + raw-tag picker
Replace the Batch-1-stubbed authored-tag flow with a UnsTagReference list on the
equipment page's Tags tab (v3 reference-only equipment): columns are effective name,
computed raw path, inherited datatype/access (read-only), display-name override
(editable), and remove. The retired equipment-authored-tag editor (TagModal.razor)
is deleted; the VirtualTag and ScriptedAlarm flows are untouched.

- "+ Add reference" opens a new AddReferenceModal that reuses RawTree in a new opt-in
  PickerMode (Tag-leaf checkboxes + Device/TagGroup "select all tags below"), scoped
  structurally to the equipment's cluster via LoadReferencePickerRootAsync (a single
  cluster root — cross-cluster tags are unreachable, not merely hidden). Default /raw
  usage is byte-unchanged (PickerMode defaults false).
- UnsTreeService (+ IUnsTreeService) gain the reference mutations:
  LoadReferencesForEquipmentAsync (computes each RawPath via the shared RawPathResolver),
  AddReferencesAsync (cluster-checked, all-or-nothing), RemoveReferenceAsync,
  SetReferenceOverrideAsync, plus the picker helpers LoadReferencePickerRootAsync /
  LoadDescendantTagIdsAsync.
- Consume IEffectiveNameGuard (constructor-injected, no-op fallback when unregistered):
  AddReferences, SetReferenceOverride, and the VirtualTag + ScriptedAlarm create/update
  mutations now call CheckAsync before persisting and surface a collision as the failure.
  WP2 supplies the implementation + DI registration.
- Drop the retired equipment↔driver binding: remove DriverInstanceId from EquipmentInput,
  the ImportEquipmentModal CSV column, the EquipmentPage Details driver select, and the
  dead decision-#122 driver-cluster guard.

Tests: new UnsTreeServiceReferenceTests (add/remove/override, cross-cluster + duplicate
rejection, guard-consumed rejection via a fake guard, RawPath projection, picker helpers);
Equipment/Import test suites rewritten to drop the retired driver-guard cases. AdminUI
suite green (639 passed, 3 skipped); full solution builds 0 errors.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:15:51 -04:00
Joseph Doherty b68c313372 feat(uns-v3): implement EffectiveNameGuard authoring-time uniqueness guard (B3-WP2)
Implements IEffectiveNameGuard (the Wave-A contract): per-equipment effective-name
uniqueness across UnsTagReferences (DisplayNameOverride else backing raw tag Name),
VirtualTags (Name), and ScriptedAlarms (Name). Ordinal comparison, self-row exclusion
by (kind, id), one pooled context per CheckAsync call. Registered Scoped in
EndpointRouteBuilderExtensions so WP1's UnsTreeService consumer goes live.

Verified the deploy-time DraftValidator UnsEffectiveNameCollision rule already
computes reference effective names from the backing raw tag's current Name (catches
rename-induced collisions), spans all three sources, compares ordinal, and names both
colliding sources + the equipment — no hardening needed. DraftSnapshotFactory already
loads Tags/UnsTagReferences/VirtualTags/ScriptedAlarms.

Tests: 8 guard tests (in-memory EF) + 3 deploy-gate tests (rename-induced collision,
override-vs-VT, ordinal case-sensitivity). All green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:01:10 -04:00
Joseph Doherty 6dc5af7aa2 v3 Batch 3 contracts: IEffectiveNameGuard authoring-time uniqueness seam
Wave-A shared contract. WP2 implements + registers the guard; WP1 consumes it
in UnsTreeService reference/VirtualTag/ScriptedAlarm mutations. Ordinal, mirrors
the deploy-time DraftValidator UnsEffectiveNameCollision rule.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 05:53:52 -04:00
Joseph Doherty 96471d2345 refactor(adminui): B2-WP8 DriverTypeNames rewire + retire routed driver-authoring flow
Part 1 — rewire the three DriverType dispatch maps from hand-authored string
literals to the canonical DriverTypeNames constants (single source of truth):
- TagConfigEditorMap, TagConfigValidator (AdminUI)
- EquipmentTagConfigInspector (ControlPlane)
This FIXES the live drift ("TwinCat"->TwinCAT, "Focas"->FOCAS). The maps are
OrdinalIgnoreCase so behavior is identical; the value is no future drift.
Calculation is not added to the inspector (no CalculationTagDefinitionFactory.Inspect).
Added TagConfigDriverTypeNameGuardTests pinning the maps to the constants.

Part 2 — retire the routed /clusters/{id}/drivers driver-authoring flow (/raw
Waves A-C now cover driver/device/tag authoring end-to-end):
- Deleted DriverTypePicker, DriverEditRouter, the 8 *DriverPage shells, and the
  ClusterDrivers list page.
- Removed the "Drivers" tab from ClusterNav. Routes /clusters/{id}/drivers* now
  simply do not exist -> clean 404.
- The Forms/DeviceForms bodies, shared driver sections, and address pickers used
  by the /raw modals are untouched.

Part 3 — updated PageAuthorizationGuardTests census: removed the 10 deleted
ConfigEditor pages + ClusterDrivers, updated group-count comments (ConfigEditor
21->11, AuthenticatedRead 17->16). Removed the now-dead Clusters.Drivers usings
from the 4 *DriverPageFormSerializationTests + the guard test.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 04:39:22 -04:00
Joseph Doherty 1f43449942 feat(adminui): B2-WP6 browse re-target — /raw device browse commits raw tags
Browse device… on a /raw Device/TagGroup opens the discovery-browser modal
against the merged Driver+Device config and commits selected browse leaves as
raw Tag rows under the target, via IRawTreeService.ImportTagsAsync.

- RawBrowseModal.razor: two-tier browse gate (bespoke IDriverBrowser →
  universal DiscoveryDriverBrowser via SupportsOnlineDiscovery → disabled),
  merged config from LoadMergedProbeConfigAsync, multi-select over
  DriverBrowseTree, opt-in 'create matching tag-groups' folder mirror,
  commit via ImportTagsAsync.
- RawBrowseCommitMapper: pure leaf→RawTagImportRow mapper — DriverDataType→
  OPC UA type, per-driver address field (nodeId/tagPath/symbolPath/address/
  attributeRef), target-group + mirror path combine. Unit-tested (39 tests).
- DriverBrowseTree: additive multi-select mode (checkboxes + ancestor-path
  callback), default off preserves single-select pickers.
- RawTree: OnBrowseDevice wired to the modal (Device + TagGroup menus).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 04:14:27 -04:00
Joseph Doherty f986519317 merge wt/wp5 into v3/batch2-raw-ui (Wave B UI) 2026-07-16 03:27:23 -04:00
Joseph Doherty 59ea02d971 feat(adminui): B2-WP5 CSV tag import/export + review grid
Staged CSV tag import (upload -> parse -> review grid with per-row verdicts
-> all-or-nothing commit) and export for the /raw tree Device/TagGroup.

- CsvColumnMap: per-driver typed column <-> TagConfig JSON maps (model-backed
  for the 7 typed drivers, raw-key for Galaxy/Calculation) + the common column
  dictionary; typed columns merge OVER the TagConfigJson fallback (typed wins).
- RawTagCsvMapper: pure parse (review rows + verdicts + typed/fallback
  provenance + intra-batch dup detection) and export (residual TagConfigJson)
  over the T0-2 CsvParser/CsvWriter.
- RawTagCsvExportReader: DbContext-backed read helper (no IRawTreeService
  extension) enumerating a device's tags + reconstructing group paths.
- RawCsvImportModal + RawCsvExportModal (data-URI download).
- Reflection guard test (typed column -> real model property + batch-plan
  dictionary parity) + export->import round-trip property test.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 03:19:14 -04:00
Joseph Doherty ada552fbec feat(adminui): B2-WP4 manual tag entry + raw tag modal + Calculation editor
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 03:08:05 -04:00
Joseph Doherty 76cffe1f49 feat(adminui): B2 Wave-B service prelude — tag CRUD/import + driver/device config + merged probe
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:58:13 -04:00
Joseph Doherty 928e06dd01 review(b2-waveA): /raw auto-expand enterprise roots; friendly create-race + delete failures; auth-guard classifies /raw + dev demo
Wave-A reviewer findings (no Critical/High):
- M-2: GlobalRaw auto-expands Enterprise roots so the Cluster level is visible on load (mirrors GlobalUns.ExpandStructural); clusters stay lazily collapsed.
- L-1: create mutations route SaveChanges through SaveCreateAsync — a lost sibling-name race (filtered-unique-index clash) becomes a friendly failure instead of an uncaught DbUpdateException.
- L-2: SaveDeleteAsync no longer leaks raw EF/SQL text; operator-friendly guidance.
- L-5: RawNode.ChildCount doc clarified (direct children only).
- Regression fix: PageAuthorizationGuardTests classifies the two new routable pages (/raw → ConfigEditor, /dev/context-menu-demo → AuthenticatedRead).
M-1 (ordinal vs SQL-collation sibling uniqueness) assessed benign: name indexes use the DB default collation and the server-side pre-check runs under that same collation, so pre-check and index agree — no case-variant rows can coexist, so runtime ordinal RawPath keying never collides.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:45:06 -04:00
Joseph Doherty 76b8325b84 feat(adminui): B2-WP1 RawTreeService + lazy tree data layer + mutations
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:34:36 -04:00
Joseph Doherty fbf3c26c2a contracts(b2-waveA): RawNode view-model + IRawTreeService read surface + RawRenameResult
Wave A contract-first fan-out types for the /raw project tree:
- RawNode/RawNodeKind: shared tree view-model (Enterprise→Cluster→Folder→Driver→Device→TagGroup→Tag), lazy-load metadata, per-kind ids + DriverType propagation for menu gating.
- IRawTreeService: committed READ surface (LoadRootsAsync + LoadChildrenAsync); WP1 extends with the mutation surface, WP2 consumes read-only.
- RawRenameResult: rename outcome carrying non-blocking warnings (historized/UNS-referenced now; script-scan in Batch 3).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:20:05 -04:00
Joseph Doherty 11576460a7 v3(b1-wp4b): make AdminUI compile against greenfield schema behind Batch-2/3 stubs
Namespace entity + NamespaceKind retired; DriverInstance dropped NamespaceId;
Tag is raw-only (no EquipmentId/DriverInstanceId/FullName/FolderPath); driver
options replaced pre-declared Tags with RawTags. Stub the retired surfaces so
the AdminUI + Host build green; real Raw/UNS authoring arrives in v3 Batch 2/3.

- ClusterNamespaces/NamespaceEdit: bodies replaced with 'Namespaces retired' banner
- DriverIdentitySection: dropped Namespace dropdown + NamespaceId model field
- 8 driver pages: dropped Namespace binding; retired the pre-declared Tags editor
  (replaced with a '/raw Batch 2' note); multi-device pages keep their Devices editor
- UnsTreeService: equipment-tag counts/list -> empty; Create/UpdateTag -> failure
  result ('Tag authoring moved to the Raw tree (/raw) in v3 Batch 2'); Namespace/
  driver-binding queries removed
- IScriptTagCatalog: project surviving Tag columns only (path from TagConfig FullName, else Name)
- OpcUaClientTagConfigEditor: rebind stale FullName -> model's NodeId
- ClusterDrivers: drop retired NamespaceId column

AdminUI + Host build 0/0. AdminUI.Tests / Host.IntegrationTests remain red (WP6).
2026-07-15 21:09:04 -04:00
Joseph Doherty d4dab75323 v3 B1-WP2: TagConfigIntent sheds FullName identity; RawPaths tests; nodeId key normalization 2026-07-15 18:58:57 -04:00
Joseph Doherty 4449baac69 feat(adminui): writable checkbox in driver-typed tag editors (R2-11, 05/UNDER-6)
Models gain an optional bool? Writable (absent stays omitted; explicit false round-trips,
unknown keys preserved); the six editors gain a Writable checkbox (FOCAS disabled+read-only
hint). 9 model round-trip tests green. docker-dev /run verify of one editor deferred-live.
2026-07-13 11:22:12 -04:00
Joseph Doherty 4b86beef28 refactor(core): EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint canonical-parser doc cites (R2-11)
Full walker deletion remains round-1 U-1's task.
2026-07-13 10:50:50 -04:00
Joseph Doherty 9cad9ed0fc docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes
project bookkeeping IDs (task/tracking refs) from shipped code comments,
so the docs read cleanly and CommentChecker is quiet except for known
false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc).
Doc/comment-only; no logic changed; solution builds clean.
2026-07-07 12:38:39 -04:00
Joseph Doherty 0b4b2e4cfd refactor(historian-gateway): retire Wonderware historian projects (gateway is sole backend)
The HistorianGateway driver is now the sole historian read/write+alarm backend, so the
Wonderware sidecar projects are dead code. Removes the 5 Wonderware projects (driver,
.Client, .Client.Contracts, + their 2 test projects) from the solution and tree, and fully
retires the vestigial 'Historian.Wonderware' driver type (UI/probe-only; it had no driver
factory): the Host probe registration, the AdminUI driver-config surface (driver page,
tag-config editor/model/validator entry, address picker/builder, driver-type catalog +
dropdown + edit-router entries), and their tests. Prunes the now-unused Wonderware
connection fields (Host/Port/UseTls/ServerCertThumbprint/SharedSecret) from
AlarmHistorianOptions (keeping Enabled + the SQLite store-and-forward knobs) and refreshes
the stale XML docs that named Wonderware as the production backend.

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
2026-06-26 19:25:21 -04:00
Joseph Doherty 3c908f1df0 review(AdminUI): fix null-TagConfig crash, CTS leak, unencoded historian tag
Review at HEAD 7286d320. AdminUI-002: IsValidJson null/blank -> friendly error (was
ArgumentNullException). AdminUI-003: DriverStatusPanel Reconnect/Restart dispose CTS (build-
verified, live /run deferred). AdminUI-005: HistorianWonderware picker URL-encodes tag name.
AdminUI-008: Format round-trip test. 001 (script-page authz) + 004 (hub [Authorize]) left
Open as cross-cutting w/ Host/Security.
2026-06-19 10:52:23 -04:00
Joseph Doherty e047af0553 refactor(adminui): harden SetFullName (reject blank ref) + cover alarm-flag re-pick
Code-review nits: SetFullName now throws on a blank reference (was silently
persisting FullName:null → silent deploy-time bind failure), and a new test
covers the alarm-typed re-pick combo (SeedDefaultAlarm over an already-edited
alarm leaves it intact).
2026-06-19 02:08:43 -04:00
Joseph Doherty 2dd723e195 fix(adminui): preserve edited alarm fields on Galaxy address re-pick 2026-06-19 02:02:06 -04:00
Joseph Doherty 40e8a23e7c refactor(opcuaserver): rename Phase7* address-space pipeline to AddressSpace*
v2-ci / build (push) Failing after 37s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
The OPC UA address-space build pipeline was named after a v2-roadmap
milestone number rather than its domain. Rename the family to describe
what it does (build/diff/apply the OPC UA address space):

  Phase7Composer          -> AddressSpaceComposer
  Phase7CompositionResult -> AddressSpaceComposition
  Phase7Planner           -> AddressSpacePlanner
  Phase7Plan              -> AddressSpacePlan
  Phase7Applier           -> AddressSpaceApplier
  Phase7ApplyOutcome      -> AddressSpaceApplyOutcome

The 9 Phase7*Tests suites follow suit; Phase7ScriptingEntitiesTests ->
ScriptingEntitiesTests (it tests the scripting migration, not the
pipeline). Log-message prefixes move to the new class names.

Pure mechanical rename, no behavioral change. EF migration classes/IDs
(AddPhase7ScriptingTables, ExtendComputeGenerationDiffWithPhase7) are
immutable and left untouched, as are historical design docs.

Build clean; OpcUaServer 261/261, Runtime 272/272, ScriptingEntities
12/12 green.
2026-06-18 19:16:28 -04:00
Joseph Doherty 0f92e9e238 fix(adminui): reject AbLegacy array length >256 at author-time (review I-3)
v2-ci / build (push) Failing after 1m3s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
2026-06-16 22:31:42 -04:00
Joseph Doherty c2006dfb57 feat(adminui): driver-agnostic isArray/arrayLength Tag-modal control 2026-06-16 21:50:27 -04:00
Joseph Doherty 8b4675b1a5 fix(adminui): canonicalize Modbus driver-type string on "Modbus" (was ModbusTcp) 2026-06-16 19:39:41 -04:00
Joseph Doherty 069a5f3165 feat(adminui): Galaxy picker pre-fills native-alarm fields from IsAlarm 2026-06-16 16:55:05 -04:00
Joseph Doherty c00a547191 feat(adminui): isHistorized + historianTagname as first-class Tag fields 2026-06-16 16:44:46 -04:00
Joseph Doherty c98625fd9f feat(adminui): create-new-script from the inline virtual-tag panel 2026-06-16 16:44:14 -04:00
Joseph Doherty 526eebb3bb feat(adminui): UNS-tree delete for Cluster + Enterprise (refuse-if-children, no rowversion) 2026-06-16 16:35:07 -04:00
Joseph Doherty 6a8020e7e7 feat(adminui): native-alarm HistorizeToAveva opt-out 2026-06-16 16:27:31 -04:00
Joseph Doherty 72d414ada7 feat(adminui): typed TagConfig editors for OpcUaClient + Historian 2026-06-16 16:25:19 -04:00
Joseph Doherty 0945f19a50 feat(adminui): wire Galaxy live-browse picker into the standard TagModal 2026-06-12 22:09:22 -04:00
Joseph Doherty 5dfb797817 refactor(adminui): strip alias/relay machinery from UnsTreeService + EquipmentPage; Galaxy tags use standard TagModal 2026-06-12 21:28:13 -04:00
Joseph Doherty ca2698949b refactor(adminui): delete alias-tag/relay-converter files (build red until spine stripped) 2026-06-12 21:20:41 -04:00
Joseph Doherty d19271fff8 fix(adminui): converter skips name-collisions + disabled relays (review) 2026-06-11 21:44:19 -04:00
Joseph Doherty 943bc5f709 feat(adminui): ConvertRelayVirtualTagsToAliasesAsync (relay VTag -> alias Tag) 2026-06-11 21:32:43 -04:00
Joseph Doherty fe068652b3 fix(adminui): alias update pins invariants + LoadAliasTagAsync + null-driver guard (review) 2026-06-11 21:25:06 -04:00
Joseph Doherty 53116bdd83 feat(adminui): CreateAliasTagAsync/UpdateAliasTagAsync + Galaxy-gateway guard 2026-06-11 21:17:45 -04:00
Joseph Doherty fcc73ccd2d fix(adminui): null Source for alias rows without a FullName (review nits) 2026-06-11 21:12:52 -04:00
Joseph Doherty 4b4738a891 feat(adminui): alias DTO + Galaxy gateway lookup + Source/IsAlias on tag rows 2026-06-11 21:05:02 -04:00
Joseph Doherty f1c4392b0d refactor(uns): drop dead LoadEquipmentChildrenAsync + LinesForCluster; fix stale comment 2026-06-11 15:11:28 -04:00
Joseph Doherty 826ffdc1a0 feat(uns): equipment is a tree leaf linking to the detail page; drop EquipmentModal 2026-06-11 15:01:10 -04:00
Joseph Doherty 7fbfeca451 feat(uns): equipment detail page shell + Details tab + create-redirect 2026-06-11 14:36:48 -04:00
Joseph Doherty 5cae3c5b96 fix(uns): guard scripted-alarm name uniqueness on create/update (code-review) 2026-06-11 14:31:35 -04:00
Joseph Doherty 705ed6234f feat(uns): scripted-alarm CRUD in IUnsTreeService for the equipment Alarms tab 2026-06-11 14:25:59 -04:00
Joseph Doherty 7c22861598 feat(uns): per-equipment tag/virtual-tag list service methods 2026-06-11 14:19:46 -04:00
Joseph Doherty 7d91737dac feat(uns): carry created id on UnsMutationResult for equipment create 2026-06-11 14:16:21 -04:00
Joseph Doherty a7c1d7f7cb test(adminui): cover divergent-prefix {{equip}} rejection; use EquipToken constant in message 2026-06-10 08:08:13 -04:00
Joseph Doherty cadd6c60b7 feat(adminui): reject {{equip}} virtual tags whose equipment has no derivable base 2026-06-10 07:58:38 -04:00