diff --git a/docs/plans/2026-08-21-create-instance-bindings-design.md b/docs/plans/2026-08-21-create-instance-bindings-design.md new file mode 100644 index 00000000..8ebd1788 --- /dev/null +++ b/docs/plans/2026-08-21-create-instance-bindings-design.md @@ -0,0 +1,116 @@ +# Create Instance — bind connections at creation (design) + +**Date:** 2026-08-21 · **Status:** approved in conversation, awaiting implementation plan +**Mockup:** Claude artifact "Create Instance Bindings" (https://claude.ai/code/artifact/125dc749-c923-4977-a02a-eeac826e6f26) +**Builds on:** `docs/plans/2026-08-20-grouped-connection-bindings-design.md` (Configure page groups, `BindingGroupBuilder`, per-module Object apply) + +## 1. Problem + +`/deployment/instances/create` stops at Name / Template / Site / Area. The operator then has to +find the new instance in Topology, open Configure, and bind every module group before the first +Deploy — for a composed template like the MES Line Controller that is 8 groups / 270 rows, and +the most common question at creation is exactly "which MxAccess object (or OPC UA node) does this +instance hang off?". The answer should be given at creation, in the same shape the Configure page +now uses. + +## 2. Decisions (made 2026-08-21) + +| Decision | Choice | Alternatives considered | +|---|---|---| +| Shape | **Inline bindings card** on the create page — one row per module group (connection, Object, Browse…), with a "Bind all to" root picker; per-member editing stays on Configure. | Two-step wizard with the full grouped table (more screen, duplicates Configure); create-then-redirect to Configure with a banner (no new UI, but still two places). | +| What Browse… picks | **The object node.** Selecting an `Object` node writes its id into the group's Object box; members become `.` via the existing `ApplyObject`. Picking a Variable is still allowed; the page strips the member tail. | Leaf-only selection + inference (works today, but non-obvious). | +| After Create | **Land on the new instance's Configure page** with a "created, N bound" notice, so Test Bindings / Deploy are one click away. | Back to Topology (today). | + +## 3. Design + +### 3.1 Preview flatten (DeploymentManager, additive) + +The Configure page gets its rows from `IFlatteningPipeline.FlattenAndValidateAsync(instanceId)`, +which loads the instance from the DB. At creation there is no row yet, but +`FlatteningService.Flatten(Instance, chain, compositionMap, composedChains, dataConnections)` takes +an in-memory `Instance`. Add + +```csharp +Task> PreviewAsync(int templateId, int siteId, CancellationToken ct = default); +``` + +to `IFlatteningPipeline`, implemented in `FlatteningPipeline` by factoring the chain/composition +loading out of `FlattenAndValidateAsync` into a private helper and calling `Flatten` with an +unsaved `new Instance { TemplateId, SiteId, Name = "(preview)" }` (no overrides, no bindings). +No validation pass (no scripts, no binding completeness) — only the attribute list is needed. +Same `FlattenSession` memo, so it is one template-chain walk per call. + +### 3.2 Create page (CentralUI `InstanceCreate.razor` + new code-behind) + +- The **Connection Bindings** card appears once Template *and* Site are set; changing either + re-runs the preview (debounced by the select's change event — no typing involved) and resets + the card. Rows = `PreviewAsync(...).Attributes.Where(DefaultDataSourceReference ?? DataSourceReference != null)` + → `BindingRow(canonicalName, default, null, null)` → `BindingGroupBuilder.Build(rows, siteConnectionIds)`. +- **Group rows** are the Configure page's group-header row (connection select, Object input, + Browse…, bound/unbound pill) without the member table. A third pill on the Object cell says + where the value came from: `from template` (the composed defaults already share a prefix — + `InferredObject` non-null before any edit), `suggested` (set by Prefill), nothing (typed). +- **Bind all to** = connection + root Object + Browse… + **Prefill groups**. Prefill sets the + connection on every group, puts the root on `(instance)`, and for module groups with a blank + Object whose defaults are bare member names suggests `{root}.{groupKey}`. It never overwrites + a typed Object; groups with `from template` keep theirs. The suggestion is deliberately + *not* applied silently — sibling galaxy objects (Z28061's `Left_002` vs `Z28061.Left_002`) are + common, so the operator must be able to see and correct it before Create. +- **Browse…** per group opens `NodeBrowserDialog` for that group's connection, rooted at the root + Object when set (`InitialNodeId`), with the new `SelectableNodeClasses = [Object, Variable]`. + Object pick → Object box as-is. Variable pick → if its id ends with `.` of any + member in the group, strip the tail; otherwise use as-is. +- **Skip bindings** checkbox collapses the card and restores today's behaviour. +- **Create instance**: validate name/template/site/scope as today; `CreateInstanceAsync`; then, + unless skipped, compute the binding list by running `ApplyObject` on every group with a + connection and a non-blank Object (rows with connection but blank Object are left unbound — + no partial ``-only binding is written) and call `SetConnectionBindingsAsync` (an upsert). + Navigate to `/deployment/instances/{id}/configure?created=1&bound=N`. If the bindings call + fails **after** the instance was created, still navigate to Configure and pass the error + (`?bindError=…`, length-capped) so the operator is not left on a form for an instance that now + exists. +- State table (empty template, site with no connections, preview failure, in-flight) is in the + mockup §3; all non-fatal states keep Create enabled and create without bindings. + +Pure logic lives in a new `Services/CreateBindingPlanner.cs` (Prefill, tail-stripping, binding +list assembly) so it is unit-testable without rendering, mirroring `BindingGroupBuilder`. + +### 3.3 NodeBrowserDialog (CentralUI, additive) + +New parameter `IReadOnlyCollection SelectableNodeClasses` defaulting to +`[Variable]`, consulted by `Select`/`SelectBrowseNode`; rows of a selectable non-Variable class +get an `object` badge and the footer prompt reads "Select an object". Existing callers +(Configure, template editors) are unchanged. + +### 3.4 Configure page + +Reads `created`/`bound`/`bindError` query parameters and shows a one-shot notice above the +Connection Bindings panel. Nothing else changes. + +### 3.5 CLI + +No new command: `instance create` followed by `instance bind-module` / `set-bindings` already +covers the same operation. The CLI README gets a one-line cross-reference from the create +command to the binding commands. + +## 4. Out of scope + +- Per-member editing at creation (Configure's job). +- Making OPC UA object concatenation work for servers with opaque node ids — unchanged caveat + from the Configure design; the default reference is visible on Configure for those. +- A template-level "preferred connection" default (would save the Bind-all step) — possible + follow-up, needs a template schema change. +- Test Bindings at creation (needs a saved instance; it is one click away on Configure). + +## 5. Testing + +- `FlatteningPipelineTests`: `PreviewAsync` returns the same attribute set (canonical names + + default references) as `FlattenAndValidateAsync` on a freshly created, un-overridden instance of + the same template/site; unknown template/site → Failure. +- `CreateBindingPlannerTests`: Prefill (blank-only, `from template` preserved, nested module + keys), Variable-pick tail stripping, binding list excludes connection-without-Object rows, + Skip ⇒ empty list. +- `NodeBrowserDialog` bUnit test (if the project already renders it) or `TreeRow` selection + test: Object selectable only when listed. +- Live check on the docker rig with a composed template: create → lands on Configure with the + expected groups bound; Deploy gate accepts; Test Bindings reads. diff --git a/docs/plans/2026-08-21-create-instance-bindings.md b/docs/plans/2026-08-21-create-instance-bindings.md new file mode 100644 index 00000000..1a34e8d9 --- /dev/null +++ b/docs/plans/2026-08-21-create-instance-bindings.md @@ -0,0 +1,815 @@ +# Create Instance Bindings Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Let the operator bind every module group of a new instance to an MxAccess object / OPC UA node on the Create Instance page, so one Create yields an instance that is deployable (or lands on Configure with only the genuinely undecided groups unbound). + +**Architecture:** A new `IFlatteningPipeline.PreviewAsync(templateId, siteId)` flattens an unsaved `Instance` so the create page can build the same `BindingGroup`s the Configure page shows. A pure `CreateBindingPlanner` (CentralUI `Services/`) holds Prefill / tail-stripping / binding-list assembly and is unit-pinned like `BindingGroupBuilder`. `InstanceCreate.razor` gains the Connection Bindings card (group-header rows only), `NodeBrowserDialog` gains an additive `SelectableNodeClasses` parameter so an Object node can be picked, and Create = `CreateInstanceAsync` → `SetConnectionBindingsAsync` (upsert) → navigate to Configure, which shows a one-shot notice. Design: `docs/plans/2026-08-21-create-instance-bindings-design.md`; mockup: artifact "Create Instance Bindings". + +**Tech Stack:** .NET 10, Blazor Server + Bootstrap (no component libs), xunit + NSubstitute + bUnit, existing `BindingGroupBuilder`, `FlatteningService`. + +**Worktree rule:** implement in a dedicated `git worktree` off `main`, never in the primary checkout. Do NOT use `git stash` (shared across worktrees). `docker/deploy.sh` refuses to run from a worktree — for the live check use `docker/build.sh` from the worktree then `docker compose -f
/docker/docker-compose.yml up -d --force-recreate`. + +--- + +### Task 1: `IFlatteningPipeline.PreviewAsync` (DeploymentManager) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 2, Task 3 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/IFlatteningPipeline.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs` (chain/composition loading lines ~84-135 factored into a helper) +- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs` (create) + +`FlatteningPipeline` is the only implementor of the interface (verified), so the interface addition breaks nothing else; NSubstitute mocks elsewhere pick it up automatically. + +**Step 1: Write the failing tests** + +```csharp +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; +using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services; +using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; + +namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; + +/// Pins PreviewAsync: same attribute set as a freshly created, un-overridden instance. +public class FlatteningPipelinePreviewTests +{ + private const int TemplateId = 10, ModuleTemplateId = 11, SiteId = 20, InstanceId = 30; + private readonly ITemplateEngineRepository _templateRepo = Substitute.For(); + private readonly ISiteRepository _siteRepo = Substitute.For(); + private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For(); + private readonly FlatteningPipeline _sut; + + public FlatteningPipelinePreviewTests() + { + _sharedSchemaRepo.ListAsync(Arg.Any()).Returns([]); + _sut = new FlatteningPipeline(_templateRepo, _siteRepo, new FlatteningService(), new ValidationService(), + new RevisionHashService(), _sharedSchemaRepo, new TemplateGraphWatermark()); + + // Parent template with one direct data-sourced attribute composing a module with one. + var module = new Template("Receiver") { Id = ModuleTemplateId }; + module.Attributes.Add(new TemplateAttribute("MoveInFlag") { DataType = DataType.Boolean, DataSourceReference = "MoveInFlag" }); + var parent = new Template("Line") { Id = TemplateId }; + parent.Attributes.Add(new TemplateAttribute("Running") { DataType = DataType.Boolean, DataSourceReference = "Running" }); + var composition = new TemplateComposition { TemplateId = TemplateId, ComposedTemplateId = ModuleTemplateId, ModuleInstanceName = "Left" }; + + _templateRepo.GetTemplateWithChildrenAsync(TemplateId, Arg.Any()).Returns(parent); + _templateRepo.GetTemplateWithChildrenAsync(ModuleTemplateId, Arg.Any()).Returns(module); + _templateRepo.GetCompositionsByTemplateIdAsync(TemplateId, Arg.Any()).Returns([composition]); + _templateRepo.GetCompositionsByTemplateIdAsync(ModuleTemplateId, Arg.Any()).Returns([]); + _templateRepo.GetAllSharedScriptsAsync(Arg.Any()).Returns([]); + _templateRepo.GetInstanceByIdAsync(InstanceId, Arg.Any()) + .Returns(new Instance("Line-01") { Id = InstanceId, TemplateId = TemplateId, SiteId = SiteId }); + _siteRepo.GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any()) + .Returns([new DataConnection("MxGateway", "MxGateway", SiteId) { Id = 1 }]); + } + + [Fact] + public async Task Preview_matches_flatten_of_fresh_instance() + { + var preview = await _sut.PreviewAsync(TemplateId, SiteId); + var real = await _sut.FlattenAndValidateAsync(InstanceId, validateScripts: false); + + Assert.True(preview.IsSuccess, preview.Error); + var expected = real.Value.Configuration.Attributes.Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference)).OrderBy(x => x.Item1).ToList(); + var actual = preview.Value.Attributes.Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference)).OrderBy(x => x.Item1).ToList(); + Assert.Equal(expected, actual); + Assert.Contains(actual, a => a.Item1 == "Left.MoveInFlag"); + } + + [Fact] + public async Task Preview_unknown_template_fails() + { + var r = await _sut.PreviewAsync(999, SiteId); + Assert.True(r.IsFailure); + } + + [Fact] + public async Task Preview_never_touches_instance_repository() + { + await _sut.PreviewAsync(TemplateId, SiteId); + await _templateRepo.DidNotReceive().GetInstanceByIdAsync(Arg.Any(), Arg.Any()); + } +} +``` + +Check the `TemplateComposition` constructor/property names against `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateComposition.cs` and adjust the object initializer (the entity may use a ctor). + +**Step 2: Run to verify failure** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FlatteningPipelinePreviewTests` +Expected: build error — `PreviewAsync` not defined. + +**Step 3: Implement** + +`IFlatteningPipeline.cs` — add: + +```csharp + /// + /// Flattens as if an instance with no overrides and no + /// bindings existed at . Attribute list only — no validation, + /// no revision hash, nothing persisted. Used by the Create Instance page to show the + /// module groups before the instance row exists. + /// + Task> PreviewAsync(int templateId, int siteId, CancellationToken cancellationToken = default); +``` + +`FlatteningPipeline.cs` — extract the chain + composition walk (from `var templateChain = await session.GetChainAsync(instance.TemplateId, LoadTemplate);` through the end of the `while (pendingChains.Count > 0)` loop) into + +```csharp + private async Task<(IReadOnlyList