Files
ScadaBridge/docs/plans/2026-08-20-grouped-connection-bindings-design.md
T

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

  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.