8.0 KiB
Grouped Connection Bindings — design
Date: 2026-08-20 · Status: implemented 2026-08-20 on feat/grouped-connection-bindings (plan 2026-08-20-grouped-connection-bindings.md, 9 tasks) · 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
- 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. - 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. - CLI parity:
scadabridge instance bind-moduleperforms the same per-module rewrite through the existingSetConnectionBindingsupsert. 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;nullwhen 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 likeLeft_002.DelmiaReceiver);ApplyObjectincl. clear, trim, trailing-dot; unbound counts.InstanceConfigurebUnit 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:
DefaultDataSourceReferencepopulated pre-binding; revision hash unchanged with the field present. - CLI:
bind-modulerewrite, 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.