docs(plans): grouped connection bindings design — Configure page sources binding rows from the flattened config (composed members were invisible), per-module Object bulk apply, CLI instance bind-module parity
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
# Grouped Connection Bindings — design
|
||||
|
||||
**Date:** 2026-08-20 · **Status:** approved, implementation pending · **Components:** Central UI (#15), Template Engine (flattening), CLI
|
||||
|
||||
## Problem
|
||||
|
||||
The Configure Instance page (`/deployment/instances/{id}/configure`) builds its Connection
|
||||
Bindings table from `GetAttributesByTemplateIdAsync(templateId)` — the template's **own**
|
||||
attributes only. Members contributed by composed feature modules (`LeftMESReceiver.*`,
|
||||
`LeftReactorSide.LeakTest.*`, …) never appear, yet the deploy gate
|
||||
(`ValidateConnectionBindingCompleteness`) requires every *flattened* data-sourced attribute to be
|
||||
bound. For a composed template the UI therefore cannot make an instance deployable at all; on QA
|
||||
(wonder-app-vq03) 264 of Z28061's 280 bindings are invisible on the page. The only working route
|
||||
is the management `SetConnectionBindings` upsert (CLI `instance set-bindings` or a raw POST),
|
||||
which today means hand-editing 26 rows per receiver module.
|
||||
|
||||
Observed 2026-08-20 while remapping Z28061/Z28062/Z28063 MES and Delmia receivers on QA.
|
||||
|
||||
Saving from the page is already safe: `_bindingSelections` is seeded from *all* existing bindings
|
||||
and `SaveBindings` posts the whole dictionary, so the hidden rows survive a UI save. The gap is
|
||||
visibility and editing, not data loss.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Source the bindings table from the flattened configuration.** The page already runs
|
||||
`IFlatteningPipeline.FlattenAndValidateAsync(id, validateScripts: false)` for the alarm picker;
|
||||
use the same result for bindings. One walk, the same walk the deploy gate uses, so the UI can
|
||||
never disagree with the gate.
|
||||
2. **Group rows by module** and give each group a connection picker + an **Object** box whose
|
||||
Apply rewrites every member's override to `<object>.<member-tail>`. This is exactly the bulk
|
||||
operation performed by hand on QA.
|
||||
3. **CLI parity:** `scadabridge instance bind-module` performs the same per-module rewrite through
|
||||
the existing `SetConnectionBindings` upsert. No new server command.
|
||||
|
||||
Alternatives rejected: walking the composition graph inside the page (duplicates the flattener's
|
||||
inheritance/nesting/lock rules and drifts from it); a new "binding rows" management query (the
|
||||
page already injects the pipeline; adds an RPC for nothing).
|
||||
|
||||
## Template Engine (additive)
|
||||
|
||||
`ResolvedAttribute` gains `string? DefaultDataSourceReference` — the data-source reference
|
||||
**before** the instance binding is applied (the template/composed default).
|
||||
`FlatteningService.ApplyConnectionBindings` sets it from `existing.DataSourceReference`; for
|
||||
attributes with no binding it equals `DataSourceReference`. Central-side only — the flattened
|
||||
config is not a wire contract here, and the field is **excluded from the revision hash** (the
|
||||
hash input is unchanged, otherwise every deployed instance would read as stale after upgrade).
|
||||
Pinned by a test that hashes a config with and without the field populated.
|
||||
|
||||
## Central UI — Connection Bindings panel
|
||||
|
||||
**Row source.** Flattened attributes where `DataSourceReference` (post-binding) or
|
||||
`DefaultDataSourceReference` is non-empty. Row key = `CanonicalName` (path-qualified), which is
|
||||
what `InstanceConnectionBinding.AttributeName` already stores for composed members.
|
||||
If the flatten itself fails (not validation — a thrown/failed pipeline result) the panel falls back
|
||||
to today's direct-attribute list and shows a warning banner naming the failure, so the page never
|
||||
goes blank.
|
||||
|
||||
**Grouping.** A pure helper, `BindingGroupBuilder` (CentralUI, no page dependencies), maps rows to
|
||||
groups:
|
||||
|
||||
- module key = canonical name minus its last segment; a nested module (`LeftReactorSide.LeakTest`)
|
||||
folds into its **first** segment's group (`LeftReactorSide`) and its member tail keeps the nested
|
||||
part (`LeakTest.DeltaVac`). Direct attributes (no dot) form the `(instance)` group, listed first.
|
||||
- per group: member count, unbound count (rows with no connection), connection state
|
||||
(`single` / `mixed` / `none`), and the **inferred object prefix** — the longest common prefix of
|
||||
the members' effective references ending at a `.` boundary after stripping each member tail;
|
||||
`null` when members disagree (`mixed`) or no row has an override.
|
||||
- `ApplyObject(group, connectionId, object)` returns the new `(connectionId, override)` for every
|
||||
member: `object + "." + memberTail`; an empty object clears the overrides (back to default) and
|
||||
leaves connections as chosen. Whitespace trimmed; a trailing `.` on the object is tolerated.
|
||||
|
||||
**Layout.** Groups collapsed by default, each header: caret · module name (with `incl. LeakTest`
|
||||
style note when nested modules were folded) · count · connection `<select>` · Object text box ·
|
||||
Apply · state pill (`bound` / `n unbound` / `mixed`) · `n changed · unsaved` pill once edited.
|
||||
Expanded group = table `Member | Template default | Connection | Reference override`; the
|
||||
template default for composed members is the bare member name, marked ⚠ with a tooltip
|
||||
("composed members need an override or the deploy gate rejects the instance"). Unbound rows are
|
||||
tinted. Panel header shows total rows/groups plus the aggregate `n unbound` and `n groups mixed`
|
||||
pills. Toolbar keeps the existing all-groups connection select ("Apply connection"), adds
|
||||
**Show only unbound** and **Expand all**. Footer hint states the unbound count and that Deploy is
|
||||
rejected while it is non-zero.
|
||||
|
||||
**Save / Test.** `SaveBindings` unchanged in shape (full dictionary → `SetConnectionBindingsAsync`),
|
||||
now covering every row. `Test Bindings` builds its rows from the same list, so composed members are
|
||||
live-read too. Bindings still reach the site only on Deploy; the hint says so.
|
||||
|
||||
**Authorization / mutation.** No new commands; `RequireDeployment` policy unchanged.
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
scadabridge instance bind-module --id <instanceId> --module <ModuleName> --connection <dataConnectionId> --object <ObjectPath> [--dry-run]
|
||||
```
|
||||
|
||||
Client-side: `GetInstance` → select bindings whose `attributeName` starts with `<ModuleName>.`
|
||||
(for `--module "(instance)"` or `--module .` select the dot-less names) → rewrite each override to
|
||||
`<ObjectPath>.<tail>` → `SetConnectionBindings` (upsert; untouched modules are not sent). `--object ""`
|
||||
clears overrides. `--dry-run` prints the resulting rows and exits 0 without saving. Errors: unknown
|
||||
module (no matching attribute) → non-zero with the list of available modules. Because the CLI
|
||||
reads *existing bindings* rather than the flattened template, a module with **no** bindings yet
|
||||
(Z28063's case) needs the attribute list — so the CLI also falls back to the flattened
|
||||
configuration through the existing `PreviewFlattenedConfig`-style query if one exists, otherwise
|
||||
documents that `set-bindings` seeds the first binding set. *(Plan task decides which query is
|
||||
available; prefer reuse over a new command.)*
|
||||
|
||||
## Testing
|
||||
|
||||
- `BindingGroupBuilderTests`: grouping incl. nested fold and `(instance)` group; prefix inference
|
||||
(single / mixed / none / nested object paths like `Left_002.DelmiaReceiver`); `ApplyObject`
|
||||
incl. clear, trim, trailing-dot; unbound counts.
|
||||
- `InstanceConfigure` bUnit tests: composed rows render; group Apply rewrites rows and marks
|
||||
unsaved; Save posts every row (regression for the hidden-row survival property); flatten
|
||||
failure shows the fallback banner with the direct-attribute table.
|
||||
- Flattener: `DefaultDataSourceReference` populated pre-binding; revision hash unchanged with the
|
||||
field present.
|
||||
- CLI: `bind-module` rewrite, dry-run, unknown module, clear.
|
||||
|
||||
## Docs to update
|
||||
|
||||
`Component-CentralUI.md` (Configure Instance → Connection Bindings), `Component-TemplateEngine.md`
|
||||
(`ResolvedAttribute` field + hash exclusion), `src/ZB.MOM.WW.ScadaBridge.CLI/README.md`
|
||||
(`instance bind-module`). Mockup used for approval: artifact
|
||||
`https://claude.ai/code/artifact/6af84fbc-5714-4937-a98c-6e2245984044`.
|
||||
|
||||
## Rollout
|
||||
|
||||
Central UI code — reaches wonder-app-vq03/vd03 only on their next host redeploy (config changes
|
||||
there are live, UI code is not). No migration, no wire change, no site impact.
|
||||
Reference in New Issue
Block a user