Files
ScadaBridge/docs/plans/2026-08-21-create-instance-bindings-design.md

121 lines
8.0 KiB
Markdown

# Create Instance — bind connections at creation (design)
**Date:** 2026-08-21 · **Status:** **implemented** 2026-08-21 — plan `docs/plans/2026-08-21-create-instance-bindings.md`
**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 `<object>.<memberTail>` 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<Result<FlattenedConfiguration>> 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)
- **Superseded 2026-08-21 (`docs/plans/2026-08-21-template-picker.md`):** the Template `<select>`
described below is now a read-only folder-path field + `TemplatePickerDialog`, and the page takes
a `?templateId=` query preselect (what the Templates tree's "New Instance…" navigates with). The
card's trigger is unchanged — a pick still calls `OnTemplateOrSiteChangedAsync()`.
- The **Connection Bindings** card appears once Template *and* Site are set; changing either
re-runs the preview (a pick or a site-select change — 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 `.<memberTail>` 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 `<member>`-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<BrowseNodeClass> 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.