Compare commits

...

10 Commits

Author SHA1 Message Date
Joseph Doherty a24fa38eae docs(plans): grouped connection bindings — all 9 tasks complete 2026-08-20 16:12:13 -04:00
Joseph Doherty 546250096d fix(centralui): grouped bindings review fixes — Apply with the header at none/mixed keeps each member's connection (never unbinds; upsert Save could not persist it), object inference runs over effective references (bare-default members make an overridden group mixed, default-only prefixes infer), bindings to a deleted connection count as unbound, 'n changed · unsaved' diffs against last-saved state; bUnit behaviour tests for fallback banner, grouping and mixed-group Apply+Save 2026-08-20 16:09:34 -04:00
Joseph Doherty 5e04e81ba0 docs: grouped connection bindings — Central UI Configure page, ResolvedAttribute.DefaultDataSourceReference, CLI instance bind-module; correct the stale 'set-bindings REPLACES all' claim (server upserts) in README + help text 2026-08-20 16:01:33 -04:00
Joseph Doherty 77f4af0d64 feat(centralui): grouped Connection Bindings panel — rows sourced from the flattened config (composed module members were invisible; flatten failure falls back to direct attributes with a banner), per-module connection + Object apply, unbound/mixed pills, show-only-unbound filter, Test Bindings covers composed rows, per-row Browse retained 2026-08-20 15:59:51 -04:00
Joseph Doherty 02a374259b feat(cli): instance bind-module — rebind one composed module's members to a new object path via GetInstance → SetConnectionBindings upsert; --dry-run 2026-08-20 15:55:09 -04:00
Joseph Doherty 2fb1c8ee14 feat(centralui): BindingGroupBuilder.ApplyObject — per-module bulk rewrite to <object>.<member>, blank clears 2026-08-20 15:54:47 -04:00
Joseph Doherty 5da7003e88 feat(centralui): BindingGroupBuilder — group flattened binding rows by module, infer the shared MxAccess object prefix, count unbound 2026-08-20 15:53:59 -04:00
Joseph Doherty 3acb79d97e feat(templateengine): ResolvedAttribute.DefaultDataSourceReference — pre-binding default for the Configure page; excluded from the revision hash (pinned) 2026-08-20 15:53:05 -04:00
Joseph Doherty 4cfd9337a3 docs(plans): grouped connection bindings implementation plan (9 tasks) 2026-08-20 15:49:36 -04:00
Joseph Doherty eef77b6654 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 2026-08-20 15:44:40 -04:00
20 changed files with 2747 additions and 115 deletions
@@ -0,0 +1,128 @@
# 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.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
{
"planPath": "docs/plans/2026-08-20-grouped-connection-bindings.md",
"tasks": [
{
"id": 1,
"subject": "Task 1: ResolvedAttribute.DefaultDataSourceReference (TemplateEngine)",
"status": "completed"
},
{
"id": 2,
"subject": "Task 2: BindingGroupBuilder \u2014 grouping + object-prefix inference",
"status": "completed"
},
{
"id": 3,
"subject": "Task 3: BindingGroupBuilder.ApplyObject",
"status": "completed",
"blockedBy": [
2
]
},
{
"id": 4,
"subject": "Task 4: Configure page \u2014 source binding rows from the flattened config",
"status": "completed",
"blockedBy": [
1,
3
]
},
{
"id": 5,
"subject": "Task 5: Configure page \u2014 grouped markup with per-module Object apply",
"status": "completed",
"blockedBy": [
4
]
},
{
"id": 6,
"subject": "Task 6: Live check on the docker rig",
"status": "completed",
"blockedBy": [
5
]
},
{
"id": 7,
"subject": "Task 7: CLI instance bind-module",
"status": "completed"
},
{
"id": 8,
"subject": "Task 8: Docs",
"status": "completed",
"blockedBy": [
5
]
},
{
"id": 9,
"subject": "Task 9: Solution build + impacted suites",
"status": "completed",
"blockedBy": [
1,
2,
3,
4,
5,
6,
7,
8
]
}
],
"lastUpdated": "2026-08-20T22:30:00Z"
}
+1
View File
@@ -84,6 +84,7 @@ scadabridge instance list [--site-id <id>] [--template-id <id>] [--search <term>
scadabridge instance get --id <id>
scadabridge instance create --name <name> --template-id <id> --site-id <id> [--area-id <id>]
scadabridge instance set-bindings --id <id> --bindings <json>
scadabridge instance bind-module --id <id> --module <name> --connection <id> --object <path> [--dry-run]
scadabridge instance set-overrides --id <id> --overrides <json>
scadabridge instance import-overrides --id <id> --file <path.csv>
scadabridge instance alarm-override set --instance-id <id> --alarm <name> [--trigger-config <json>] [--priority <n>]
+5 -1
View File
@@ -119,7 +119,11 @@ Central cluster only. Sites have no user interface.
### Instance Management (Deployment Role)
- Create instances from templates at a specific site.
- Assign instances to areas.
- Bind data connections — **per-attribute binding** where each attribute with a data source reference individually selects its data connection from the site's available connections. **Bulk assignment** supported: select multiple attributes and assign a data connection to all of them at once. Each row also exposes:
- Bind data connections — **per-attribute binding** where each attribute with a data source reference individually selects its data connection from the site's available connections. **The binding rows come from the instance's flattened configuration** (`IFlatteningPipeline.FlattenAndValidateAsync(id, validateScripts:false)`), so every data-sourced attribute the deploy gate will check appears — composed feature-module members (`LeftMESReceiver.MoveInFlag`, `LeftReactorSide.LeakTest.DeltaVac`, …) included. (Until 2026-08-20 the panel was built from the template's *direct* attributes only, which left 264 of a composed QA instance's 280 bindings invisible and made the instance undeployable from the UI; design `docs/plans/2026-08-20-grouped-connection-bindings-design.md`.) Only a genuine flatten *failure* falls back to the direct-attribute list, with a visible warning banner; a validation failure (e.g. unbound attributes — the very thing the panel exists to fix) is not a reason to fall back.
- **Grouped by module.** Rows are grouped by the first segment of the canonical name — `(instance)` for direct attributes first, then one collapsible group per composed module in name order; nested modules fold into their first segment (`LeftReactorSide` shows *incl. LeakTest*) and members keep their nested tail (`LeakTest.DeltaVac`). The pure grouping/inference/apply logic is `BindingGroupBuilder` (CentralUI `Services/`), unit-tested without rendering the page.
- **Group header = connection + Object + Apply.** Each group header carries a connection select and an **Object** input; **Apply to n** rewrites every member of the group to `<object>.<memberTail>` on that connection (a blank Object clears the overrides so the template default applies; surrounding whitespace and a trailing `.` are tolerated). A header connection left at `— none —`/`— mixed —` **keeps each member's current connection** — Apply never unbinds a group (the Save path is an upsert, so an unbinding could not persist anyway). The Object input is pre-filled with the **inferred** shared prefix over every member's *effective* reference (override if set, else the default): `MESReceiver_022.MoveInFlag` + `MESReceiver_022.ScanStateCmd` ⇒ `MESReceiver_022`; nested object paths such as `Left_002.DelmiaReceiver` infer too; a direct-attribute group whose defaults already carry a shared prefix infers it with no overrides at all. Once any member has an override, the group shows a **mixed** pill — and the input's placeholder invites an object to unify it — when members disagree, an override does not end in its member tail, *or a member still sits on a bare default the deploy gate would reject*; a group with no overrides and bare defaults is merely "unset", not mixed. A binding to a connection that no longer exists at the site counts as **unbound** (it renders as `— none —` and the gate rejects it). Apply edits the in-memory maps only — **Save Bindings** persists every row (hidden rows included) through the existing `SetConnectionBindings` upsert; until then the header shows an `n changed · unsaved` pill counting members whose (connection, override) differs from the last loaded/saved state, so per-row edits and group Applies both surface.
- **Pills and filters.** The card header totals unbound rows and mixed groups; each group shows `n unbound` (red) or `bound`, plus `mixed`; unbound rows render highlighted. A **Show only unbound** filter hides bound rows and fully-bound groups; **Expand all** opens every group. The legacy all-groups connection select (**Assign all to…**) is retained. A composed member whose template default is the bare member name (`MoveInFlag`) carries a ⚠ marker — such a default cannot be a real address and the deploy gate will reject the instance until an override (or a group Object) is set.
- **Template default** column shows `ResolvedAttribute.DefaultDataSourceReference` — the reference as resolved from the template chain *before* the per-instance override — so the operator always sees both the default and the effective override. **Test Bindings** (button label carries the testable-row count) reads every bound row with an effective path, composed rows included.
- **Override** — optional per-attribute OPC UA node id (or other protocol address). When set, replaces the template's `DataSourceReference` at flattening time; when blank, the template default is used. The greyed placeholder shows the template default for context.
- **Browse…** — opens the OPC UA Tag Browser dialog, populated live from the site's OPC UA server via `BrowseOpcUaNodeCommand`. Visible only when the row's connection uses the OPC UA protocol; disabled until a connection is picked on that row. The dialog lazy-loads the address space, supports manual node-id entry as a fallback, and remains usable when the site or its OPC UA session is offline (the manual-paste field stays active even on error). The dialog adds:
- **Load more** — when a browse level is truncated, a "Load more" affordance fetches the next page using the server's continuation point (`BrowseNext`); an expired continuation point falls back to a fresh browse.
@@ -292,7 +292,7 @@ Each flattened configuration output includes a **revision hash** (computed from
- Staleness detection: comparing the deployed revision to the current template-derived revision without a full diff.
- Diff correlation: ensuring diffs are computed against a consistent baseline.
The override flows into the flattened attribute's `DataSourceReference` and therefore participates in the revision hash — changes to an instance's binding overrides re-deploy as expected.
The override flows into the flattened attribute's `DataSourceReference` and therefore participates in the revision hash — changes to an instance's binding overrides re-deploy as expected. The pre-override value is preserved alongside it as **`ResolvedAttribute.DefaultDataSourceReference`** (2026-08-20, grouped connection bindings): populated by `FlatteningService.ApplyConnectionBindings` for *every* data-sourced attribute, bound or not (equal to `DataSourceReference` when no override is set; null for non-data-sourced attributes). It is central-side presentation data for the Configure page's *Template default* column and is **deliberately excluded from the revision hash** — `RevisionHashService` projects through `HashableAttribute`, which does not carry it — so adding it changed no deployed revision (pinned by `DefaultDataSourceReferenceTests`).
The revision hash covers the resolved attributes, alarms, scripts, connections, **and native alarm source bindings** (`ResolvedNativeAlarmSource`: canonical name, connection name, source reference, condition filter, and locked state). Native alarm sources were folded into the hash in migration TemplateEngine-011; the hashable projection is populated null-when-empty and the canonical serializer omits null properties, so every native-source-free configuration keeps its byte-identical pre-migration hash. **Migration note:** native-source-bearing instances will report stale once after upgrade; redeploy clears it — deliberate.
@@ -23,6 +23,7 @@ public static class InstanceCommands
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSetBindings(urlOption, formatOption, usernameOption, passwordOption));
command.Add(CreateBindModuleCommand(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSetOverrides(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildImportOverrides(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildAlarmOverride(urlOption, formatOption, usernameOption, passwordOption));
@@ -61,9 +62,9 @@ public static class InstanceCommands
"[attributeName, dataConnectionId, dataSourceReferenceOverride] " +
"(the 3rd element overrides the attribute's data-source reference; " +
"pass null or omit it to use the template default). " +
"NOTE: this REPLACES all bindings for the instance — include the " +
"override on every entry that needs one, or omitting it clears any " +
"previously-set override.",
"NOTE: this UPSERTS — listed attributes are updated or added, unlisted " +
"ones keep their binding; omitting the 3rd element on a listed entry " +
"clears that attribute's previously-set override.",
Required = true
};
@@ -86,6 +87,168 @@ public static class InstanceCommands
return cmd;
}
private static Command CreateBindModuleCommand(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var moduleOption = new Option<string>("--module")
{
Description = "Module (canonical-name prefix), e.g. RightMESReceiver; use '(instance)' for the instance's direct attributes",
Required = true
};
var connOption = new Option<int>("--connection") { Description = "Data connection ID", Required = true };
var objectOption = new Option<string>("--object")
{
Description = "MxAccess/OPC object path the module's members hang off, e.g. MESReceiver_022 or Left_002.DelmiaReceiver; " +
"an empty string clears the module's overrides (template defaults apply)",
Required = true
};
var dryRunOption = new Option<bool>("--dry-run") { Description = "Print the resulting bindings without saving" };
var cmd = new Command("bind-module")
{
Description = "Rebind every member of one composed module to a new object path (upserts only that module's bindings; other modules are untouched)"
};
cmd.Add(idOption);
cmd.Add(moduleOption);
cmd.Add(connOption);
cmd.Add(objectOption);
cmd.Add(dryRunOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var module = result.GetValue(moduleOption)!;
var conn = result.GetValue(connOption);
var obj = result.GetValue(objectOption);
var dryRun = result.GetValue(dryRunOption);
// Step 1: fetch the instance. The success handler only captures the body so
// the second round-trip can run as a proper await outside the callback.
string? instanceJson = null;
var exit = await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new GetInstanceCommand(id),
onSuccess: json => { instanceJson = json; return 0; });
if (exit != 0 || instanceJson is null)
return exit == 0 ? 1 : exit;
List<ConnectionBinding> existing;
try
{
existing = ParseExistingBindings(instanceJson);
}
catch (System.Text.Json.JsonException ex)
{
OutputFormatter.WriteError($"Could not read the instance's connection bindings: {ex.Message}", "INVALID_RESPONSE");
return 1;
}
// Step 2: rewrite just this module's rows client-side.
if (!BuildModuleRewrite(existing, module, conn, obj, out var rewritten, out var error))
{
OutputFormatter.WriteError(error!, "UNKNOWN_MODULE");
return 1;
}
if (dryRun)
{
OutputFormatter.WriteJson(rewritten);
return 0;
}
// Step 3: SetConnectionBindings upserts by attribute name, so sending only the
// module's rows leaves every other module's bindings as they were.
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new SetConnectionBindingsCommand(id, rewritten!));
});
return cmd;
}
/// <summary>
/// Pure core of <c>instance bind-module</c>: selects the existing bindings that belong
/// to <paramref name="module"/> (canonical names starting with <c>{module}.</c>, or
/// dot-less names for <c>(instance)</c>) and rewrites each override to
/// <c>{objectPath}.{memberTail}</c> on <paramref name="connectionId"/>. An empty
/// <paramref name="objectPath"/> clears the override. Only the module's rows are
/// returned, so the upsert leaves every other module untouched.
/// </summary>
/// <param name="existing">The instance's current bindings (from <c>GetInstance</c>).</param>
/// <param name="module">Module prefix, or <c>(instance)</c> / <c>.</c> for direct attributes.</param>
/// <param name="connectionId">Data connection to bind the module's members to.</param>
/// <param name="objectPath">New object path; empty/whitespace clears the overrides.</param>
/// <param name="rewritten">The module's rewritten bindings, or null on failure.</param>
/// <param name="error">Descriptive error listing the available modules, or null on success.</param>
/// <returns>True when at least one binding matched the module; false otherwise.</returns>
internal static bool BuildModuleRewrite(
IReadOnlyList<ConnectionBinding> existing,
string module,
int connectionId,
string? objectPath,
out List<ConnectionBinding>? rewritten,
out string? error)
{
rewritten = null;
error = null;
var isInstance = module is "(instance)" or ".";
var prefix = module + ".";
var obj = (objectPath ?? string.Empty).Trim().TrimEnd('.');
var rows = existing
.Where(b => isInstance
? !b.AttributeName.Contains('.')
: b.AttributeName.StartsWith(prefix, StringComparison.Ordinal))
.Select(b =>
{
var tail = isInstance ? b.AttributeName : b.AttributeName[prefix.Length..];
return new ConnectionBinding(b.AttributeName, connectionId, obj.Length == 0 ? null : obj + "." + tail);
})
.ToList();
if (rows.Count == 0)
{
var available = existing
.Select(b => b.AttributeName.Contains('.') ? b.AttributeName[..b.AttributeName.IndexOf('.')] : "(instance)")
.Distinct()
.OrderBy(s => s, StringComparer.Ordinal);
error = $"Instance has no bindings for module '{module}'. Available: {string.Join(", ", available)}. " +
"A module with no bindings yet must be seeded with 'instance set-bindings' (or bound once in the Central UI).";
return false;
}
rewritten = rows;
return true;
}
/// <summary>
/// Reads the <c>connectionBindings</c> array out of a <c>GetInstance</c> response body
/// (camelCase, as the management API emits it) into typed bindings. A missing or
/// <c>null</c> array yields an empty list; malformed JSON throws
/// <see cref="System.Text.Json.JsonException"/> for the caller to report.
/// </summary>
/// <param name="json">The <c>GetInstance</c> success body.</param>
/// <returns>The instance's current bindings.</returns>
internal static List<ConnectionBinding> ParseExistingBindings(string json)
{
var result = new List<ConnectionBinding>();
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object
|| !doc.RootElement.TryGetProperty("connectionBindings", out var array)
|| array.ValueKind != System.Text.Json.JsonValueKind.Array)
return result;
foreach (var item in array.EnumerateArray())
{
if (item.ValueKind != System.Text.Json.JsonValueKind.Object) continue;
if (!item.TryGetProperty("attributeName", out var nameEl) || nameEl.ValueKind != System.Text.Json.JsonValueKind.String) continue;
if (!item.TryGetProperty("dataConnectionId", out var connEl) || !connEl.TryGetInt32(out var connectionId)) continue;
string? overrideRef = null;
if (item.TryGetProperty("dataSourceReferenceOverride", out var ovEl) && ovEl.ValueKind == System.Text.Json.JsonValueKind.String)
overrideRef = ovEl.GetString();
result.Add(new ConnectionBinding(nameEl.GetString()!, connectionId, overrideRef));
}
return result;
}
/// <summary>
/// Parses the <c>--bindings</c> argument — a JSON array of binding entries — into a
/// typed list. Each entry is either a two-element
+33 -3
View File
@@ -683,9 +683,39 @@ scadabridge --url <url> instance set-bindings --id 42 \
--bindings '[["Speed",7,"ns=2;s=Reactor.Speed"],["Mode",7]]'
```
> **Note:** this command **replaces** all bindings for the instance. Include the
> override on every entry that needs one — omitting the third element clears any
> previously-set override for that attribute.
> **Note:** this command **upserts** — each listed attribute is updated (or added);
> attributes *not* listed keep their existing binding. Within a listed entry, omitting
> the third element clears any previously-set override for that attribute.
#### `instance bind-module`
Rebind every member of one composed module (or the instance's direct attributes) to a
new object path in a single command — the CLI counterpart of the Configure page's
per-group **Apply**. Reads the instance's current bindings (`GetInstance`), rewrites the
module's rows client-side to `<object>.<memberTail>`, and upserts only those rows
(`SetConnectionBindings`), so every other module is left untouched.
```sh
scadabridge --url <url> instance bind-module --id <int> --module <name> --connection <int> --object <path> [--dry-run]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--id` | yes | Instance ID |
| `--module` | yes | Module key = first segment of the canonical attribute name (e.g. `RightMESReceiver`, which also covers nested `RightMESReceiver.LeakTest.*`). Use `(instance)` for the direct (dot-less) attributes |
| `--connection` | yes | Data connection ID every member is bound to |
| `--object` | yes | Object path the members hang off (`MESReceiver_022`, `Left_002.DelmiaReceiver`); a trailing `.` is tolerated. An **empty string clears** the overrides so the template defaults apply |
| `--dry-run` | no | Print the resulting bindings without saving |
```sh
# Repoint the right-hand MES receiver module of instance 1 to the MESReceiver_022 object.
scadabridge --url <url> instance bind-module --id 1 --module RightMESReceiver --connection 1 --object MESReceiver_022
```
The module must already have at least one binding row on the instance — the command
rewrites existing rows, it does not discover the template's members. A module with no
bindings yet must be seeded once with `instance set-bindings` (or bound once in the Central
UI); an unknown module fails with `UNKNOWN_MODULE` and lists the modules that do have rows.
#### `instance set-overrides`
@@ -12,6 +12,8 @@
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@@ -79,106 +81,194 @@
</div>
</div>
@* Connection Bindings *@
<div class="card mb-3">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
@* Connection Bindings — grouped by module (design 2026-08-20). Rows come
from the flattened configuration so composed module members appear;
each group header carries a connection + Object apply that rewrites
every member to <object>.<memberTail>. *@
<div class="card mb-3 binding-panel">
<div class="card-header py-2 d-flex align-items-center gap-3 flex-wrap">
<strong>Connection Bindings</strong>
@if (_bindingDataSourceAttrs.Count > 0 && _siteConnections.Count > 0)
<span class="text-muted small">@_bindingRows.Count data-sourced attribute(s) in @_bindingGroups.Count group(s)</span>
<span class="ms-auto"></span>
@if (TotalUnbound > 0)
{
<div class="d-flex align-items-center gap-1">
<select class="form-select form-select-sm" style="width: auto;" data-test="binding-bulk-select" @bind="_bulkConnectionId">
<option value="0">Assign all to...</option>
@foreach (var c in _siteConnections)
{
<option value="@c.Id">@c.Name (@c.Protocol)</option>
}
</select>
<button class="btn btn-outline-primary btn-sm" @onclick="ApplyBulkBinding"
disabled="@(_bulkConnectionId == 0)">Apply</button>
</div>
<span class="badge text-bg-warning" data-test="binding-total-unbound">@TotalUnbound unbound</span>
}
@if (MixedGroups > 0)
{
<span class="badge text-bg-secondary">@MixedGroups group(s) mixed</span>
}
</div>
<div class="card-body p-0">
@if (_bindingDataSourceAttrs.Count == 0)
@if (_bindingSourceWarning is not null)
{
<div class="alert alert-warning rounded-0 mb-0 py-2 small" data-test="binding-source-warning">@_bindingSourceWarning</div>
}
@if (_bindingRows.Count == 0)
{
<div class="card-body text-muted small">No data-sourced attributes in this template.</div>
}
else
{
<div class="card-body border-bottom py-2 d-flex align-items-center gap-3 flex-wrap small">
@if (_siteConnections.Count > 0)
{
<label class="d-flex align-items-center gap-2 mb-0">All groups
<select class="form-select form-select-sm" style="width: auto;" data-test="binding-bulk-select" @bind="_bulkConnectionId">
<option value="0">Assign all to...</option>
@foreach (var c in _siteConnections)
{
<option value="@c.Id">@c.Name (@c.Protocol)</option>
}
</select>
<button class="btn btn-outline-primary btn-sm" @onclick="ApplyBulkBinding"
disabled="@(_bulkConnectionId == 0)">Apply connection</button>
</label>
}
<span class="ms-auto"></span>
<label class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" @bind="_showOnlyUnbound" /> Show only unbound
</label>
<button class="btn btn-outline-secondary btn-sm" data-test="binding-expand-all" @onclick="ExpandAll">Expand all</button>
</div>
@foreach (var g in VisibleGroups)
{
<p class="text-muted small p-3 mb-0">No data-sourced attributes in this template.</p>
}
else
{
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>Attribute</th>
<th>Tag Path</th>
<th style="width: 280px;">Connection</th>
<th>Override</th>
<th style="width: 110px;"></th>
</tr>
</thead>
<tbody>
@foreach (var attr in _bindingDataSourceAttrs)
var open = _expandedGroups.Contains(g.Key);
<div class="binding-group border-bottom">
<div class="binding-group-header d-flex align-items-center gap-2 px-3 py-2 @(open ? "bg-light" : "")" data-test="binding-group-header">
<button class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="() => ToggleGroup(g.Key)"
aria-expanded="@(open ? "true" : "false")" title="@(open ? "Collapse" : "Expand")">@(open ? "▼" : "▶")</button>
<span class="fw-semibold binding-group-name">@g.Key
@if (g.IsInstanceGroup)
{
<small class="text-muted fw-normal ms-1">direct attributes</small>
}
else if (g.NestedModules.Count > 0)
{
<small class="text-muted fw-normal ms-1">incl. @string.Join(", ", g.NestedModules)</small>
}
</span>
<span class="text-muted small">@g.Members.Count</span>
<select class="form-select form-select-sm" style="width: 160px;" data-test="binding-group-connection"
value="@GroupConnectionInput(g)"
@onchange="e => _groupConnectionInput[g.Key] = int.TryParse(e.Value?.ToString(), out var v) ? v : 0">
<option value="0">@(g.ConnectionState == BindingConnectionState.Mixed ? "— mixed —" : "— none —")</option>
@foreach (var c in _siteConnections)
{
<option value="@c.Id">@c.Name</option>
}
</select>
<input class="form-control form-control-sm font-monospace" style="max-width: 260px;" data-test="binding-group-object"
value="@GroupObjectInput(g)"
placeholder="@(g.IsObjectMixed ? "mixed — enter object to unify" : "object, e.g. MESReceiver_022")"
title="Object path the module's members hang off; Apply rewrites every member to <object>.<member>. Blank clears the overrides."
@onchange="e => _groupObjectInput[g.Key] = e.Value?.ToString()" />
<button class="btn btn-outline-primary btn-sm text-nowrap" data-test="binding-group-apply"
@onclick="() => ApplyGroupObject(g)" disabled="@_saving">Apply to @g.Members.Count</button>
@if (g.UnboundCount > 0)
{
var connId = GetBindingConnectionId(attr.Name);
var canBrowse = connId > 0;
var isBrowsable = IsBrowsable(connId);
<tr>
<td class="small">@attr.Name</td>
@* Tag paths are OPC UA node ids (nsu=…;s=…) — unbounded
text that would otherwise set the table's width.
.cell-clip is display:block, hence the span. *@
<td class="small text-muted font-monospace">
<span class="cell-clip" title="@attr.DataSourceReference">@attr.DataSourceReference</span>
</td>
<td>
<select class="form-select form-select-sm"
value="@connId"
@onchange="(e) => OnBindingChanged(attr.Name, e)">
<option value="0">— none —</option>
@foreach (var c in _siteConnections)
{
<option value="@c.Id">@c.Name</option>
}
</select>
</td>
<td>
<input class="form-control form-control-sm"
value="@GetOverrideForAttr(attr.Name)"
@onchange="(e) => OnOverrideForAttrChanged(attr.Name, e)"
placeholder="@(attr.DataSourceReference ?? "(no default)")" />
</td>
<td>
@if (isBrowsable)
{
<button class="btn btn-sm btn-outline-primary"
disabled="@(!canBrowse)"
title="@(canBrowse ? "Browse address space" : "Pick a connection first")"
@onclick="() => OpenBrowser(attr.Name)">
Browse…
</button>
}
</td>
</tr>
<span class="badge text-bg-danger" data-test="binding-group-unbound">@g.UnboundCount unbound</span>
}
else
{
<span class="badge text-bg-success">bound</span>
}
@if (g.IsObjectMixed || g.ConnectionState == BindingConnectionState.Mixed)
{
<span class="badge text-bg-secondary" data-test="binding-group-mixed">mixed</span>
}
@if (GroupChangedCount(g) is var changed && changed > 0)
{
<span class="badge border text-muted">@changed changed · unsaved</span>
}
</tbody>
</table>
<div class="p-2">
<div class="btn-group btn-group-sm" role="group" aria-label="Binding actions">
<button class="btn btn-success" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
@* Test Bindings: one-shot live read of every bound attribute
whose row has a connection picked AND an effective tag
path. Disabled when no testable rows. Protocol-agnostic —
any connection whose adapter implements ReadBatchAsync
(OPC UA and MxGateway today) round-trips through
ReadTagValuesCommand. *@
<button class="btn btn-outline-primary"
@onclick="OpenTestBindings"
disabled="@(!HasTestableBindings())">
Test Bindings
</button>
</div>
@if (open)
{
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Member</th>
<th>Template default</th>
<th style="width: 200px;">Connection</th>
<th>Reference override</th>
<th style="width: 110px;"></th>
</tr>
</thead>
<tbody>
@foreach (var m in VisibleMembers(g))
{
var name = m.Row.CanonicalName;
var connId = GetBindingConnectionId(name);
var canBrowse = connId > 0;
var isBrowsable = IsBrowsable(connId);
var bare = !g.IsInstanceGroup && string.Equals(m.Row.DefaultReference, m.MemberTail, StringComparison.Ordinal);
<tr class="@(m.IsUnbound ? "table-danger" : "")">
<td class="small font-monospace ps-4">@m.MemberTail</td>
@* Defaults can be OPC UA node ids (nsu=…;s=…) — unbounded
text that would otherwise set the table's width. *@
<td class="small text-muted font-monospace">
<span class="cell-clip" title="@m.Row.DefaultReference">@m.Row.DefaultReference</span>
@if (bare)
{
<span class="ms-1 text-warning" data-test="binding-bare-default"
title="Composed members share a bare default name; set an override (or apply an Object on the group) or the deploy gate rejects the instance.">⚠</span>
}
</td>
<td>
<select class="form-select form-select-sm"
value="@connId"
@onchange="(e) => OnBindingChanged(name, e)">
<option value="0">— none —</option>
@foreach (var c in _siteConnections)
{
<option value="@c.Id">@c.Name</option>
}
</select>
</td>
<td>
<input class="form-control form-control-sm font-monospace"
value="@GetOverrideForAttr(name)"
@onchange="(e) => OnOverrideForAttrChanged(name, e)"
placeholder="@(m.Row.DefaultReference ?? "(no default)")" />
</td>
<td>
@if (isBrowsable)
{
<button class="btn btn-sm btn-outline-primary"
disabled="@(!canBrowse)"
title="@(canBrowse ? "Browse address space" : "Pick a connection first")"
@onclick="() => OpenBrowser(name)">
Browse…
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
</div>
<div class="card-footer d-flex align-items-center gap-2 flex-wrap">
<div class="btn-group btn-group-sm" role="group" aria-label="Binding actions">
<button class="btn btn-success" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
@* Test Bindings: one-shot live read of every bound attribute
whose row has a connection picked AND an effective tag
path — composed rows included. Protocol-agnostic — any
connection whose adapter implements ReadBatchAsync
(OPC UA and MxGateway today) round-trips through
ReadTagValuesCommand. *@
<button class="btn btn-outline-primary"
@onclick="OpenTestBindings"
disabled="@(!HasTestableBindings())">
Test Bindings (@BuildTestableRows().Count)
</button>
</div>
<span class="text-muted small ms-2">Saved bindings reach the site on the next Deploy.@(TotalUnbound > 0 ? $" {TotalUnbound} row(s) are unbound — Deploy will be rejected until they are bound." : "")</span>
</div>
}
</div>
@* Attribute Overrides *@
@@ -629,8 +719,17 @@
private string? _errorMessage;
private ToastNotification _toast = default!;
// Bindings
private List<TemplateAttribute> _bindingDataSourceAttrs = new();
// ── Bindings (design 2026-08-20: rows = flattened attributes, grouped by module) ──
private List<BindingRow> _bindingRows = new();
private IReadOnlyList<BindingGroup> _bindingGroups = Array.Empty<BindingGroup>();
private string? _bindingSourceWarning;
/// <summary>Template default per canonical name (placeholder text).</summary>
private readonly Dictionary<string, string?> _bindingDefaults = new(StringComparer.Ordinal);
/// <summary>
/// The flattened configuration the binding rows were built from; reused by the
/// alarm attribute picker so the page flattens once, not twice.
/// </summary>
private FlattenedConfiguration? _flattenedForBindings;
private List<DataConnection> _siteConnections = new();
private Dictionary<string, int> _bindingSelections = new();
/// <summary>
@@ -758,9 +857,9 @@
_reassignAreaId = _instance.AreaId ?? 0;
_areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? "";
// Bindings
// Bindings — existing rows first, then the flattened row source
// (composed module members included) projected over them.
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(_instance.TemplateId);
_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).ToList();
_siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_instance.SiteId)).ToList();
var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(Id);
foreach (var b in existingBindings)
@@ -769,6 +868,7 @@
if (!string.IsNullOrEmpty(b.DataSourceReferenceOverride))
_bindingOverrides[b.AttributeName] = b.DataSourceReferenceOverride;
}
await LoadBindingRowsAsync(attrs);
// Overrides
_overrideAttrs = attrs.Where(a => !a.IsLocked).ToList();
@@ -846,15 +946,149 @@
var val = int.TryParse(e.Value?.ToString(), out var id) ? id : 0;
if (val == 0) _bindingSelections.Remove(attrName);
else _bindingSelections[attrName] = val;
RebuildBindingGroups();
}
/// <summary>All-groups connection select: binds every row to one connection (overrides untouched).</summary>
private void ApplyBulkBinding()
{
if (_bulkConnectionId == 0) return;
foreach (var attr in _bindingDataSourceAttrs)
_bindingSelections[attr.Name] = _bulkConnectionId;
foreach (var row in _bindingRows)
_bindingSelections[row.CanonicalName] = _bulkConnectionId;
RebuildBindingGroups();
}
/// <summary>
/// Builds the binding rows from the flattened configuration so composed module
/// members appear. Falls back to the template's direct attributes — with a visible
/// warning — only when the flatten itself fails; a validation failure (e.g. unbound
/// attributes, which is exactly what this panel exists to fix) is NOT a reason to
/// fall back (the pipeline reports validation inside a Success result).
/// </summary>
private async Task LoadBindingRowsAsync(IReadOnlyList<TemplateAttribute> directAttrs)
{
_bindingSourceWarning = null;
IEnumerable<(string Name, string? Default)> source;
try
{
var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id, validateScripts: false);
if (flat.IsFailure)
{
_bindingSourceWarning = $"Could not flatten this instance ({flat.Error}); showing the template's direct attributes only.";
source = DirectAttributeRows(directAttrs);
}
else
{
_flattenedForBindings = flat.Value.Configuration;
source = flat.Value.Configuration.Attributes
// Same predicate as the deploy gate (ValidateConnectionBindingCompleteness:
// DataSourceReference != null), so every row the gate will check has a row here.
.Where(a => a.DefaultDataSourceReference is not null || a.DataSourceReference is not null)
.Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference));
}
}
catch (Exception ex)
{
_bindingSourceWarning = $"Could not flatten this instance ({ex.Message}); showing the template's direct attributes only.";
source = DirectAttributeRows(directAttrs);
}
_bindingDefaults.Clear();
_bindingRows = source.Select(s =>
{
_bindingDefaults[s.Name] = s.Default;
return new BindingRow(s.Name, s.Default, null, null);
}).ToList();
RebuildBindingGroups();
SnapshotSavedBindingState();
}
private static IEnumerable<(string Name, string? Default)> DirectAttributeRows(IReadOnlyList<TemplateAttribute> directAttrs)
=> directAttrs.Where(a => a.DataSourceReference is not null).Select(a => (a.Name, a.DataSourceReference));
/// <summary>Re-projects the current selections/overrides into the rows, then regroups.</summary>
private void RebuildBindingGroups()
{
_bindingRows = _bindingRows
.Select(r => r with
{
ConnectionId = _bindingSelections.TryGetValue(r.CanonicalName, out var c) ? c : null,
Override = _bindingOverrides.GetValueOrDefault(r.CanonicalName)
})
.ToList();
// A binding to a connection that no longer exists at the site renders as
// "— none —" and the deploy gate rejects it — count it as unbound.
_bindingGroups = BindingGroupBuilder.Build(_bindingRows, _siteConnections.Select(c => c.Id).ToHashSet());
}
/// <summary>Per-row (connection, override) as last loaded/saved — the baseline for the "n changed · unsaved" pill.</summary>
private Dictionary<string, (int? Conn, string? Override)> _savedBindingState = new(StringComparer.Ordinal);
private void SnapshotSavedBindingState()
=> _savedBindingState = _bindingRows.ToDictionary(
r => r.CanonicalName,
r => (_bindingSelections.TryGetValue(r.CanonicalName, out var c) ? c : (int?)null, _bindingOverrides.GetValueOrDefault(r.CanonicalName)),
StringComparer.Ordinal);
/// <summary>Members whose current state differs from the last loaded/saved state (per-row edits and group Applies alike).</summary>
private int GroupChangedCount(BindingGroup g)
=> g.Members.Count(m =>
{
var name = m.Row.CanonicalName;
var current = (_bindingSelections.TryGetValue(name, out var c) ? c : (int?)null, _bindingOverrides.GetValueOrDefault(name));
return !_savedBindingState.TryGetValue(name, out var saved) || saved != current;
});
private int TotalUnbound => _bindingGroups.Sum(g => g.UnboundCount);
private int MixedGroups => _bindingGroups.Count(g => g.IsObjectMixed || g.ConnectionState == BindingConnectionState.Mixed);
// ── Group UI state (expand/collapse, per-group header inputs, unsaved counters) ──
private readonly HashSet<string> _expandedGroups = new(StringComparer.Ordinal);
private readonly Dictionary<string, string?> _groupObjectInput = new(StringComparer.Ordinal);
private readonly Dictionary<string, int?> _groupConnectionInput = new(StringComparer.Ordinal);
private bool _showOnlyUnbound;
private void ToggleGroup(string key) { if (!_expandedGroups.Remove(key)) _expandedGroups.Add(key); }
private void ExpandAll() { foreach (var g in _bindingGroups) _expandedGroups.Add(g.Key); }
/// <summary>Header Object input: the operator's pending edit, else the inferred shared prefix.</summary>
private string? GroupObjectInput(BindingGroup g)
=> _groupObjectInput.TryGetValue(g.Key, out var v) ? v : g.InferredObject;
/// <summary>Header connection select: the operator's pending edit, else the group's common connection.</summary>
private int GroupConnectionInput(BindingGroup g)
=> _groupConnectionInput.TryGetValue(g.Key, out var v) ? v ?? 0 : g.CommonConnectionId ?? 0;
/// <summary>
/// Per-module bulk apply: rewrites every member of the group to
/// <c>{object}.{memberTail}</c> on the header's connection (blank object clears
/// the overrides; a header left at "— none —"/"— mixed —" keeps each member's
/// current connection — Apply never unbinds a group). Edits only the in-memory
/// selection/override maps; Save Bindings persists them.
/// </summary>
private void ApplyGroupObject(BindingGroup g)
{
var connId = GroupConnectionInput(g);
var changes = BindingGroupBuilder.ApplyObject(g, connId > 0 ? connId : null, GroupObjectInput(g));
foreach (var c in changes)
{
if (c.ConnectionId is > 0) _bindingSelections[c.CanonicalName] = c.ConnectionId.Value;
else _bindingSelections.Remove(c.CanonicalName);
if (!string.IsNullOrEmpty(c.Override)) _bindingOverrides[c.CanonicalName] = c.Override;
else _bindingOverrides.Remove(c.CanonicalName);
}
_groupObjectInput.Remove(g.Key);
_groupConnectionInput.Remove(g.Key);
_expandedGroups.Add(g.Key);
RebuildBindingGroups();
}
private IEnumerable<BindingGroupMember> VisibleMembers(BindingGroup g)
=> _showOnlyUnbound ? g.Members.Where(m => m.IsUnbound) : g.Members;
private IEnumerable<BindingGroup> VisibleGroups
=> _showOnlyUnbound ? _bindingGroups.Where(g => g.UnboundCount > 0) : _bindingGroups;
// ── Task 18: per-attribute override input + OPC UA tag browser ──────────
private string? GetOverrideForAttr(string attrName)
@@ -867,11 +1101,12 @@
_bindingOverrides.Remove(attrName);
else
_bindingOverrides[attrName] = val;
RebuildBindingGroups();
}
/// <summary>Looks up the template default <c>DataSourceReference</c> for an attribute.</summary>
private string? GetTemplateDefault(string attrName)
=> _bindingDataSourceAttrs.FirstOrDefault(a => a.Name == attrName)?.DataSourceReference;
=> _bindingDefaults.GetValueOrDefault(attrName);
/// <summary>
/// True when the row's selected data connection supports address-space browsing
@@ -913,6 +1148,7 @@
if (_browserAttrInEdit is null) return;
_bindingOverrides[_browserAttrInEdit] = nodeId;
_browserAttrInEdit = null;
RebuildBindingGroups();
}
// ── Test Bindings (one-shot live read of bound tags) ────────────────────
@@ -927,9 +1163,11 @@
private List<TestBindingsDialog.BindingRowToTest> BuildTestableRows()
{
var rows = new List<TestBindingsDialog.BindingRowToTest>();
foreach (var attr in _bindingDataSourceAttrs)
foreach (var g in _bindingGroups)
foreach (var m in g.Members)
{
var connId = GetBindingConnectionId(attr.Name);
var name = m.Row.CanonicalName;
var connId = GetBindingConnectionId(name);
if (connId <= 0) continue;
var conn = _siteConnections.FirstOrDefault(c => c.Id == connId);
@@ -941,11 +1179,11 @@
// or unsupported connection short-circuits to a typed banner in the
// dialog rather than being filtered out here — mirrors IsBrowsable.
var effectivePath = _bindingOverrides.GetValueOrDefault(attr.Name)
?? GetTemplateDefault(attr.Name);
var effectivePath = _bindingOverrides.GetValueOrDefault(name)
?? GetTemplateDefault(name);
if (string.IsNullOrWhiteSpace(effectivePath)) continue;
rows.Add(new TestBindingsDialog.BindingRowToTest(attr.Name, conn.Name, effectivePath));
rows.Add(new TestBindingsDialog.BindingRowToTest(name, conn.Name, effectivePath));
}
return rows;
}
@@ -977,7 +1215,10 @@
var user = await GetCurrentUserAsync();
var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user);
if (result.IsSuccess)
{
SnapshotSavedBindingState();
_toast.ShowSuccess($"Saved {bindings.Count} connection binding(s).");
}
else
_toast.ShowError($"Save failed: {result.Error}");
}
@@ -1649,10 +1890,18 @@
try
{
var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id);
if (flat.IsFailure) return fallback;
// Reuse the flatten the bindings panel already performed (design
// 2026-08-20) so the page flattens once; flatten afresh only when
// that path fell back.
var configuration = _flattenedForBindings;
if (configuration is null)
{
var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id, validateScripts: false);
if (flat.IsFailure) return fallback;
configuration = flat.Value.Configuration;
}
return flat.Value.Configuration.Attributes
return configuration.Attributes
.Select(a => new AlarmAttributeChoice(
a.CanonicalName,
MapDataType(a.DataType),
@@ -0,0 +1,17 @@
/* Connection Bindings — grouped panel (design 2026-08-20) */
.binding-group-header {
flex-wrap: wrap;
}
.binding-group-header .form-select,
.binding-group-header .form-control {
min-width: 0;
}
.binding-group-name {
min-width: 10rem;
}
.binding-group:last-of-type {
border-bottom: 0 !important;
}
@@ -0,0 +1,166 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>One data-sourced flattened attribute as the Configure page sees it.</summary>
/// <param name="CanonicalName">Path-qualified flattened name, e.g. <c>LeftMESReceiver.MoveInFlag</c>.</param>
/// <param name="DefaultReference">Template/composed default reference (pre-override), may be null.</param>
/// <param name="ConnectionId">Bound data connection id, or null when unbound.</param>
/// <param name="Override">Per-instance reference override, or null when the default applies.</param>
public sealed record BindingRow(string CanonicalName, string? DefaultReference, int? ConnectionId, string? Override);
public enum BindingConnectionState { None, Single, Mixed }
/// <summary>A member of a <see cref="BindingGroup"/>: the row plus its tail relative to the group.</summary>
public sealed record BindingGroupMember(BindingRow Row, string MemberTail)
{
public bool IsUnbound => Row.ConnectionId is null or <= 0;
/// <summary>Effective reference: override if set, else the default.</summary>
public string? EffectiveReference => string.IsNullOrWhiteSpace(Row.Override) ? Row.DefaultReference : Row.Override;
}
/// <summary>
/// One module's worth of binding rows. <see cref="Key"/> is the first segment of the
/// canonical name (<see cref="BindingGroupBuilder.InstanceGroupKey"/> for direct
/// attributes); nested modules fold into their first segment.
/// </summary>
public sealed record BindingGroup(
string Key,
IReadOnlyList<BindingGroupMember> Members,
IReadOnlyList<string> NestedModules,
string? InferredObject,
bool IsObjectMixed,
BindingConnectionState ConnectionState,
int? CommonConnectionId,
int UnboundCount)
{
public bool IsInstanceGroup => Key == BindingGroupBuilder.InstanceGroupKey;
}
/// <summary>
/// Pure grouping / prefix-inference / bulk-apply logic behind the Configure page's
/// Connection Bindings panel (design 2026-08-20). No Blazor dependencies so it is
/// unit-testable without rendering the page.
/// </summary>
public static class BindingGroupBuilder
{
public const string InstanceGroupKey = "(instance)";
/// <param name="rows">The data-sourced flattened attributes with their current binding state.</param>
/// <param name="knownConnectionIds">
/// The site's data connection ids. When supplied, a row whose connection id is not
/// in the set is treated as UNBOUND — a binding to a deleted connection renders as
/// "— none —" and the deploy gate rejects it, so the pills must agree.
/// </param>
public static IReadOnlyList<BindingGroup> Build(IEnumerable<BindingRow> rows, IReadOnlyCollection<int>? knownConnectionIds = null)
{
if (knownConnectionIds is not null)
{
var known = knownConnectionIds as ISet<int> ?? new HashSet<int>(knownConnectionIds);
rows = rows.Select(r => r.ConnectionId is int id && !known.Contains(id) ? r with { ConnectionId = null } : r);
}
var byKey = new Dictionary<string, List<BindingGroupMember>>(StringComparer.Ordinal);
var nested = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
foreach (var row in rows.OrderBy(r => r.CanonicalName, StringComparer.Ordinal))
{
var dot = row.CanonicalName.IndexOf('.');
string key, tail;
if (dot < 0) { key = InstanceGroupKey; tail = row.CanonicalName; }
else
{
key = row.CanonicalName[..dot];
tail = row.CanonicalName[(dot + 1)..];
var tailDot = tail.LastIndexOf('.');
if (tailDot > 0)
{
if (!nested.TryGetValue(key, out var set)) nested[key] = set = new SortedSet<string>(StringComparer.Ordinal);
set.Add(tail[..tailDot]);
}
}
if (!byKey.TryGetValue(key, out var list)) byKey[key] = list = new List<BindingGroupMember>();
list.Add(new BindingGroupMember(row, tail));
}
var ordered = byKey.Keys
.OrderBy(k => k == InstanceGroupKey ? 0 : 1)
.ThenBy(k => k, StringComparer.Ordinal);
var result = new List<BindingGroup>();
foreach (var key in ordered)
{
var members = byKey[key];
var (obj, mixed) = InferObject(members);
var conns = members.Select(m => m.Row.ConnectionId is > 0 ? m.Row.ConnectionId : null).Distinct().ToList();
var state = conns.Count == 1 && conns[0] is not null ? BindingConnectionState.Single
: conns.All(c => c is null) ? BindingConnectionState.None
: BindingConnectionState.Mixed;
result.Add(new BindingGroup(
key, members,
nested.TryGetValue(key, out var n) ? n.ToList() : [],
obj, mixed, state,
state == BindingConnectionState.Single ? conns[0] : null,
members.Count(m => m.IsUnbound)));
}
return result;
}
/// <summary>
/// Object prefix shared by every member's EFFECTIVE reference (override if set, else
/// the default): each must be <c>{prefix}.{memberTail}</c>.
/// <list type="bullet">
/// <item>No member has an override: infer from the defaults alone — (prefix,false) when
/// they all agree, otherwise (null,false). Nothing has been edited yet, so a bare
/// composed default is "unset", not "mixed".</item>
/// <item>Some member has an override: (prefix,false) only when EVERY member's effective
/// reference shares it; (null,true) when members disagree, an override doesn't end in
/// its tail, or a member still sits on a bare default the gate would reject.</item>
/// </list>
/// </summary>
private static (string? Object, bool Mixed) InferObject(IReadOnlyList<BindingGroupMember> members)
{
var anyOverride = members.Any(m => !string.IsNullOrWhiteSpace(m.Row.Override));
string? candidate = null;
foreach (var m in members)
{
var effective = m.EffectiveReference;
var prefix = PrefixOf(effective, m.MemberTail);
if (prefix is null || (candidate is not null && !string.Equals(candidate, prefix, StringComparison.Ordinal)))
return (null, anyOverride);
candidate = prefix;
}
return (candidate, false);
}
/// <summary>Returns <c>prefix</c> when <paramref name="reference"/> is <c>{prefix}.{tail}</c>, else null.</summary>
private static string? PrefixOf(string? reference, string tail)
{
if (string.IsNullOrWhiteSpace(reference)) return null;
var suffix = "." + tail;
if (!reference.EndsWith(suffix, StringComparison.Ordinal)) return null;
var prefix = reference[..^suffix.Length];
return prefix.Length == 0 ? null : prefix;
}
/// <summary>Result of a bulk apply for one member.</summary>
public sealed record BindingChange(string CanonicalName, int? ConnectionId, string? Override);
/// <summary>
/// Rewrites every member of <paramref name="group"/> to
/// <c>{objectPath}.{memberTail}</c> on <paramref name="connectionId"/>. A blank
/// <paramref name="objectPath"/> clears the overrides (template default applies).
/// A null <paramref name="connectionId"/> (header reading "— mixed —"/"— none —")
/// KEEPS each member's current connection — it never unbinds a group, since the
/// Save path is an upsert and a removal could not persist anyway. Surrounding
/// whitespace and a trailing '.' are tolerated.
/// </summary>
public static IReadOnlyList<BindingChange> ApplyObject(BindingGroup group, int? connectionId, string? objectPath)
{
var obj = (objectPath ?? string.Empty).Trim().TrimEnd('.');
return group.Members
.Select(m => new BindingChange(
m.Row.CanonicalName,
connectionId ?? (m.Row.ConnectionId is > 0 ? m.Row.ConnectionId : null),
obj.Length == 0 ? null : obj + "." + m.MemberTail))
.ToList();
}
}
@@ -78,6 +78,17 @@ public sealed record ResolvedAttribute
/// </summary>
public string? DataSourceReference { get; init; }
/// <summary>
/// The data-source reference as resolved from the template chain BEFORE any
/// per-instance <c>InstanceConnectionBinding.DataSourceReferenceOverride</c> was
/// applied — i.e. the template/composed default the Configure page shows as the
/// placeholder. Equals <see cref="DataSourceReference"/> when no override is set;
/// null for attributes that are not data-sourced.
/// Central-side presentation data only: deliberately excluded from the revision
/// hash (<c>RevisionHashService</c> projects through <c>HashableAttribute</c>).
/// </summary>
public string? DefaultDataSourceReference { get; init; }
/// <summary>
/// If data-sourced: the resolved connection ID from the instance binding.
/// </summary>
@@ -369,6 +369,15 @@ public class FlatteningService
Dictionary<string, ResolvedAttribute> attributes,
IReadOnlyDictionary<int, DataConnection> dataConnections)
{
// Capture the pre-binding default for EVERY data-sourced attribute (bound or
// not) so the Configure page can show the template default as a placeholder.
foreach (var key in attributes.Keys.ToList())
{
var attr = attributes[key];
if (attr.DataSourceReference is not null && attr.DefaultDataSourceReference is null)
attributes[key] = attr with { DefaultDataSourceReference = attr.DataSourceReference };
}
foreach (var binding in bindings)
{
if (!attributes.TryGetValue(binding.AttributeName, out var existing))
@@ -385,7 +394,8 @@ public class FlatteningService
BoundDataConnectionId = connection.Id,
BoundDataConnectionName = connection.Name,
BoundDataConnectionProtocol = connection.Protocol,
DataSourceReference = binding.DataSourceReferenceOverride ?? existing.DataSourceReference
DataSourceReference = binding.DataSourceReferenceOverride ?? existing.DataSourceReference,
DefaultDataSourceReference = existing.DefaultDataSourceReference ?? existing.DataSourceReference
};
}
}
@@ -1,3 +1,4 @@
using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
@@ -148,4 +149,99 @@ public class InstanceArgumentParsingTests
Assert.Null(overrides);
Assert.NotNull(error);
}
// --- instance bind-module (grouped connection bindings, 2026-08-20) ---
[Fact]
public void BuildModuleRewrite_RewritesOnlyTheModule_AndKeepsConnection()
{
var existing = new[]
{
new ConnectionBinding("RightMESReceiver.MoveInFlag", 1, "MESReceiver_024.MoveInFlag"),
new ConnectionBinding("RightMESReceiver.ScanStateCmd", 1, "MESReceiver_024.ScanStateCmd"),
new ConnectionBinding("LeftMESReceiver.MoveInFlag", 1, "MESReceiver_023.MoveInFlag"),
};
var ok = InstanceCommands.BuildModuleRewrite(existing, "RightMESReceiver", connectionId: 1, objectPath: "MESReceiver_022", out var rewritten, out var error);
Assert.True(ok, error);
Assert.Equal(2, rewritten!.Count);
Assert.All(rewritten, b => Assert.StartsWith("MESReceiver_022.", b.DataSourceReferenceOverride!));
Assert.Contains(rewritten, b => b.AttributeName == "RightMESReceiver.MoveInFlag" && b.DataSourceReferenceOverride == "MESReceiver_022.MoveInFlag");
Assert.DoesNotContain(rewritten, b => b.AttributeName.StartsWith("Left"));
}
[Fact]
public void BuildModuleRewrite_InstanceGroup_MatchesDotlessNames()
{
var existing = new[] { new ConnectionBinding("ChlorineFlow", 1, "Z28061.ChlorineFlow"), new ConnectionBinding("M.A", 1, "O.A") };
Assert.True(InstanceCommands.BuildModuleRewrite(existing, "(instance)", 1, "Z28063", out var r, out _));
var b = Assert.Single(r!);
Assert.Equal("Z28063.ChlorineFlow", b.DataSourceReferenceOverride);
}
[Fact]
public void BuildModuleRewrite_EmptyObject_ClearsOverride()
{
var existing = new[] { new ConnectionBinding("M.A", 1, "O.A") };
Assert.True(InstanceCommands.BuildModuleRewrite(existing, "M", 2, "", out var r, out _));
var b = Assert.Single(r!);
Assert.Null(b.DataSourceReferenceOverride);
Assert.Equal(2, b.DataConnectionId);
}
[Fact]
public void BuildModuleRewrite_TrailingDotOnObject_IsTrimmed()
{
var existing = new[] { new ConnectionBinding("M.A", 1, "O.A") };
Assert.True(InstanceCommands.BuildModuleRewrite(existing, "M", 1, " X. ", out var r, out _));
Assert.Equal("X.A", Assert.Single(r!).DataSourceReferenceOverride);
}
[Fact]
public void BuildModuleRewrite_UnknownModule_ListsAvailable()
{
var existing = new[] { new ConnectionBinding("M.A", 1, "O.A"), new ConnectionBinding("N.B", 1, "O.B"), new ConnectionBinding("Direct", 1, null) };
Assert.False(InstanceCommands.BuildModuleRewrite(existing, "Q", 1, "X", out var rewritten, out var error));
Assert.Null(rewritten);
Assert.Contains("M", error);
Assert.Contains("N", error);
Assert.Contains("(instance)", error);
}
[Fact]
public void ParseExistingBindings_ReadsCamelCaseInstanceJson()
{
const string json = """
{"id":7,"uniqueName":"X","connectionBindings":[
{"id":1,"instanceId":7,"attributeName":"M.A","dataConnectionId":3,"dataSourceReferenceOverride":"O.A"},
{"id":2,"instanceId":7,"attributeName":"Direct","dataConnectionId":4,"dataSourceReferenceOverride":null},
{"id":3,"instanceId":7,"attributeName":"NoOverrideProp","dataConnectionId":5}
]}
""";
var bindings = InstanceCommands.ParseExistingBindings(json);
Assert.Equal(3, bindings.Count);
Assert.Equal(new ConnectionBinding("M.A", 3, "O.A"), bindings[0]);
Assert.Equal(new ConnectionBinding("Direct", 4), bindings[1]);
Assert.Equal(new ConnectionBinding("NoOverrideProp", 5), bindings[2]);
}
[Fact]
public void ParseExistingBindings_MissingArray_ReturnsEmpty()
{
Assert.Empty(InstanceCommands.ParseExistingBindings("""{"id":7}"""));
Assert.Empty(InstanceCommands.ParseExistingBindings("""{"id":7,"connectionBindings":null}"""));
}
[Fact]
public void BindModule_ParsesOptions()
{
var instance = InstanceCommands.Build(
new Option<string>("--url"), new Option<string>("--format"),
new Option<string>("--username"), new Option<string>("--password"));
var bindModule = instance.Subcommands.Single(c => c.Name == "bind-module");
var result = bindModule.Parse(["--id", "1", "--module", "RightMESReceiver", "--connection", "1", "--object", "MESReceiver_022", "--dry-run"]);
Assert.Empty(result.Errors);
var missingObject = bindModule.Parse(["--id", "1", "--module", "M", "--connection", "1"]);
Assert.NotEmpty(missingObject.Errors);
}
}
@@ -158,7 +158,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
cut.WaitForAssertion(() =>
{
var testButton = cut.FindAll("button").Single(b => b.TextContent.Trim() == "Test Bindings");
var testButton = cut.FindAll("button").Single(b => b.TextContent.Trim().StartsWith("Test Bindings", StringComparison.Ordinal));
Assert.False(testButton.HasAttribute("disabled"),
$"Test Bindings should be enabled for a {protocol} binding.");
});
@@ -0,0 +1,178 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
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.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using InstanceConfigurePage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.InstanceConfigure;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Behavioural (bUnit) coverage of the grouped Connection Bindings panel (design
/// 2026-08-20), complementing the structural pins: the rows come from the flattened
/// configuration, a flatten FAILURE falls back to the direct attributes with a banner,
/// a group Apply whose header connection is left at "— mixed —" keeps every member's
/// own connection (review finding: it used to unbind the group, which an upsert Save
/// could never persist), and Save posts the composed rows.
/// </summary>
public class InstanceConfigureBindingBehaviourTests : BunitContext
{
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
public InstanceConfigureBindingBehaviourTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddSingleton(_templateRepo);
Services.AddSingleton(_siteRepo);
Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For<IAuditService>()));
Services.AddSingleton(_pipeline);
Services.AddSingleton(Substitute.For<IBrowseService>());
Services.AddSingleton(Substitute.For<IBindingTester>());
var claims = new[]
{
new Claim(JwtTokenService.UsernameClaimType, "deployer"),
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
};
var authProvider = new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")));
Services.AddSingleton<AuthenticationStateProvider>(authProvider);
Services.AddSingleton(new SiteScopeService(authProvider));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
private void SeedInstance(IReadOnlyList<InstanceConnectionBinding> bindings)
{
var instance = new Instance("Z28061") { Id = 42, TemplateId = 1, SiteId = 1,
State = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.InstanceState.NotDeployed };
_templateRepo.GetInstanceByIdAsync(42, Arg.Any<CancellationToken>()).Returns(instance);
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>()).Returns(new Template("Reactor") { Id = 1 });
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { new("Plant A", "plant-a") { Id = 1 } });
_templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>()).Returns(new List<Area>());
// Direct attributes only — the composed members are NOT here; they only exist in the flattened config.
_templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<TemplateAttribute> { new("ChlorineFlow") { Id = 1, DataSourceReference = "ChlorineFlow" } });
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<DataConnection> { new("GW-1", "MxGateway", 1) { Id = 1 }, new("GW-2", "MxGateway", 1) { Id = 2 } });
_templateRepo.GetBindingsByInstanceIdAsync(42, Arg.Any<CancellationToken>()).Returns(bindings);
_templateRepo.GetOverridesByInstanceIdAsync(42, Arg.Any<CancellationToken>()).Returns(new List<InstanceAttributeOverride>());
_templateRepo.GetAlarmsByTemplateIdAsync(1, Arg.Any<CancellationToken>()).Returns(new List<TemplateAlarm>());
_templateRepo.GetAlarmOverridesByInstanceIdAsync(42, Arg.Any<CancellationToken>()).Returns(new List<InstanceAlarmOverride>());
}
private static Result<FlatteningPipelineResult> Flattened(params ResolvedAttribute[] attrs) =>
Result<FlatteningPipelineResult>.Success(new FlatteningPipelineResult(
new FlattenedConfiguration { InstanceUniqueName = "Z28061", Attributes = attrs },
"hash",
new ValidationResult()));
private static ResolvedAttribute DataSourced(string canonical, string dflt, int? conn = null, string? effective = null) =>
new() { CanonicalName = canonical, DataType = "Boolean", DataSourceReference = effective ?? dflt,
DefaultDataSourceReference = dflt, BoundDataConnectionId = conn };
[Fact]
public void Rows_ComeFromFlattenedConfiguration_ComposedMembersGrouped()
{
SeedInstance(new List<InstanceConnectionBinding>());
_pipeline.FlattenAndValidateAsync(42, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession>())
.Returns(Flattened(
DataSourced("ChlorineFlow", "ChlorineFlow"),
DataSourced("LeftMESReceiver.MoveInFlag", "MoveInFlag"),
DataSourced("LeftMESReceiver.ScanStateCmd", "ScanStateCmd"),
DataSourced("RightMESReceiver.MoveInFlag", "MoveInFlag")));
var cut = Render<InstanceConfigurePage>(p => p.Add(c => c.Id, 42));
cut.WaitForAssertion(() =>
{
var headers = cut.FindAll("[data-test=\"binding-group-header\"]");
Assert.Equal(3, headers.Count);
Assert.Contains("(instance)", headers[0].TextContent);
Assert.Contains("LeftMESReceiver", headers[1].TextContent);
Assert.Contains("RightMESReceiver", headers[2].TextContent);
Assert.Equal("4 unbound", cut.Find("[data-test=\"binding-total-unbound\"]").TextContent.Trim());
Assert.Empty(cut.FindAll("[data-test=\"binding-source-warning\"]"));
});
}
[Fact]
public void FlattenFailure_FallsBackToDirectAttributes_WithBanner()
{
SeedInstance(new List<InstanceConnectionBinding>());
_pipeline.FlattenAndValidateAsync(42, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession>())
.Returns(Result<FlatteningPipelineResult>.Failure("Template chain is empty."));
var cut = Render<InstanceConfigurePage>(p => p.Add(c => c.Id, 42));
cut.WaitForAssertion(() =>
{
Assert.Contains("Template chain is empty.", cut.Find("[data-test=\"binding-source-warning\"]").TextContent);
var headers = cut.FindAll("[data-test=\"binding-group-header\"]");
Assert.Single(headers);
Assert.Contains("(instance)", headers[0].TextContent);
});
}
[Fact]
public async Task ApplyOnMixedConnectionGroup_KeepsEachMembersConnection_AndSavePostsComposedRows()
{
SeedInstance(new List<InstanceConnectionBinding>
{
new("LeftMESReceiver.MoveInFlag") { Id = 1, InstanceId = 42, DataConnectionId = 1, DataSourceReferenceOverride = "Old.MoveInFlag" },
new("LeftMESReceiver.ScanStateCmd") { Id = 2, InstanceId = 42, DataConnectionId = 2, DataSourceReferenceOverride = "Old.ScanStateCmd" },
});
_pipeline.FlattenAndValidateAsync(42, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession>())
.Returns(Flattened(
DataSourced("LeftMESReceiver.MoveInFlag", "MoveInFlag", 1, "Old.MoveInFlag"),
DataSourced("LeftMESReceiver.ScanStateCmd", "ScanStateCmd", 2, "Old.ScanStateCmd")));
var cut = Render<InstanceConfigurePage>(p => p.Add(c => c.Id, 42));
cut.WaitForAssertion(() => Assert.Single(cut.FindAll("[data-test=\"binding-group-header\"]")));
// Connections differ (1 vs 2) → header select reads "— mixed —" (value 0); inferred object is "Old".
var header = cut.Find("[data-test=\"binding-group-header\"]");
Assert.Contains("— mixed —", header.QuerySelector("[data-test=\"binding-group-connection\"]")!.TextContent);
Assert.Equal("Old", header.QuerySelector("[data-test=\"binding-group-object\"]")!.GetAttribute("value"));
await cut.InvokeAsync(() => cut.Find("[data-test=\"binding-group-object\"]").Change("MESReceiver_022"));
await cut.InvokeAsync(() => cut.Find("[data-test=\"binding-group-apply\"]").Click());
cut.WaitForAssertion(() =>
{
// Still bound — Apply with the header at "— mixed —" must not unbind anyone.
Assert.Empty(cut.FindAll("[data-test=\"binding-group-unbound\"]"));
Assert.Contains("2 changed", cut.Find("[data-test=\"binding-group-header\"]").TextContent);
// Object unified → header pre-fills the new prefix; the group still reads
// "mixed" because the CONNECTIONS were deliberately left different (1 vs 2).
Assert.Equal("MESReceiver_022", cut.Find("[data-test=\"binding-group-object\"]").GetAttribute("value"));
Assert.Single(cut.FindAll("[data-test=\"binding-group-mixed\"]"));
Assert.Contains("— mixed —", cut.Find("[data-test=\"binding-group-connection\"]").TextContent);
});
// Save posts both composed rows with their ORIGINAL connections and the rewritten overrides.
var existing = await _templateRepo.GetBindingsByInstanceIdAsync(42);
_templateRepo.GetBindingsByInstanceIdAsync(42, Arg.Any<CancellationToken>()).Returns(existing);
await cut.InvokeAsync(() => cut.FindAll("button").Single(b => b.TextContent.Trim() == "Save Bindings").Click());
await _templateRepo.Received(1).UpdateInstanceConnectionBindingAsync(
Arg.Is<InstanceConnectionBinding>(b => b.AttributeName == "LeftMESReceiver.MoveInFlag" && b.DataConnectionId == 1 && b.DataSourceReferenceOverride == "MESReceiver_022.MoveInFlag"),
Arg.Any<CancellationToken>());
await _templateRepo.Received(1).UpdateInstanceConnectionBindingAsync(
Arg.Is<InstanceConnectionBinding>(b => b.AttributeName == "LeftMESReceiver.ScanStateCmd" && b.DataConnectionId == 2 && b.DataSourceReferenceOverride == "MESReceiver_022.ScanStateCmd"),
Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,62 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Design 2026-08-20: the Connection Bindings panel renders one collapsible group
/// per module with a header-level connection + Object apply, unbound/mixed pills and
/// a show-only-unbound filter. Structural pins over the page source.
/// </summary>
public class InstanceConfigureBindingGroupsTests
{
private static string Markup => InstanceConfigureBindingRowsTests.ReadMarkup();
[Fact]
public void GroupHeader_HasConnectionObjectAndApply()
{
var m = Markup;
Assert.Contains("data-test=\"binding-group-header\"", m);
Assert.Contains("data-test=\"binding-group-connection\"", m);
Assert.Contains("data-test=\"binding-group-object\"", m);
Assert.Contains("data-test=\"binding-group-apply\"", m);
Assert.Contains("ApplyGroupObject(", m);
Assert.Contains("BindingGroupBuilder.ApplyObject(", m);
}
[Fact]
public void GroupHeader_ShowsUnboundAndMixedPills()
{
var m = Markup;
Assert.Contains("data-test=\"binding-group-unbound\"", m);
Assert.Contains("data-test=\"binding-group-mixed\"", m);
Assert.Contains("data-test=\"binding-total-unbound\"", m);
}
[Fact]
public void Toolbar_HasUnboundFilterAndExpandAll()
{
var m = Markup;
Assert.Contains("_showOnlyUnbound", m);
Assert.Contains("data-test=\"binding-expand-all\"", m);
Assert.Contains("data-test=\"binding-bulk-select\"", m); // existing all-groups connection select retained
}
[Fact]
public void ComposedBareDefault_IsFlagged()
{
Assert.Contains("data-test=\"binding-bare-default\"", Markup);
}
[Fact]
public void OldFlatTable_IsGone()
{
Assert.DoesNotContain("_bindingDataSourceAttrs", Markup);
}
[Fact]
public void PerRowBrowseButton_IsRetained()
{
// The OPC UA / MxGateway tag browser per row predates the grouped panel and must survive it.
var m = Markup;
Assert.Contains("OpenBrowser(name)", m);
Assert.Contains("IsBrowsable(", m);
}
}
@@ -0,0 +1,49 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Design 2026-08-20: the Connection Bindings panel must be driven by the flattened
/// configuration (every composed module member), not by the template's direct
/// attributes — 264 of Z28061's 280 bindings were invisible on QA. Structural pins
/// over the page source, matching the existing InstanceConfigure coverage style.
/// </summary>
public class InstanceConfigureBindingRowsTests
{
internal static string ReadMarkup()
{
var dir = AppContext.BaseDirectory;
for (var i = 0; i < 6 && dir is not null; i++) dir = Directory.GetParent(dir)?.FullName;
return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
"Components", "Pages", "Deployment", "InstanceConfigure.razor"));
}
private static string Markup => ReadMarkup();
[Fact]
public void BindingRows_ComeFromFlattenedConfiguration_NotTemplateAttributes()
{
var m = Markup;
Assert.Contains("LoadBindingRowsAsync", m);
Assert.Contains("BindingGroupBuilder.Build(", m);
// The old direct-attribute projection must be gone from the bindings path.
Assert.DoesNotContain("_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference))", m);
Assert.Contains("DefaultDataSourceReference", m);
}
[Fact]
public void BindingRows_FallBackToDirectAttributes_WithBanner_WhenFlattenFails()
{
var m = Markup;
Assert.Contains("_bindingSourceWarning", m);
Assert.Contains("data-test=\"binding-source-warning\"", m);
}
[Fact]
public void SaveAndTest_CoverEveryRow()
{
var m = Markup.Replace("\r\n", "\n");
// Save posts the whole selection dictionary (hidden-row survival property).
Assert.Contains("_bindingSelections\n .Select(kv => new ConnectionBinding(", m);
// Test Bindings iterates the grouped rows, not the legacy attr list.
Assert.Contains("foreach (var g in _bindingGroups)", m);
}
}
@@ -0,0 +1,201 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
public class BindingGroupBuilderTests
{
private static BindingRow Row(string canonical, string? dflt, int? conn, string? ovr) =>
new(canonical, dflt, conn, ovr);
[Fact]
public void Groups_DirectAttributesFirst_ThenModules_InNameOrder()
{
var rows = new[]
{
Row("RightMESReceiver.MoveInFlag", "MoveInFlag", 1, "MESReceiver_022.MoveInFlag"),
Row("ChlorineFlow", "ChlorineFlow", 1, "Z28061.ChlorineFlow"),
Row("LeftMESReceiver.MoveInFlag", "MoveInFlag", 1, "MESReceiver_023.MoveInFlag"),
};
var groups = BindingGroupBuilder.Build(rows);
Assert.Equal(new[] { BindingGroupBuilder.InstanceGroupKey, "LeftMESReceiver", "RightMESReceiver" },
groups.Select(g => g.Key));
}
[Fact]
public void NestedModule_FoldsIntoFirstSegment_AndKeepsNestedTail()
{
var rows = new[]
{
Row("LeftReactorSide.AIR_SVC", "AIR_SVC", 1, "Left_002.AIR_SVC"),
Row("LeftReactorSide.LeakTest.DeltaVac", "DeltaVac", 1, "Left_002.LeakTest.DeltaVac"),
};
var g = Assert.Single(BindingGroupBuilder.Build(rows));
Assert.Equal("LeftReactorSide", g.Key);
Assert.Equal(new[] { "AIR_SVC", "LeakTest.DeltaVac" }, g.Members.Select(m => m.MemberTail));
Assert.Equal(new[] { "LeakTest" }, g.NestedModules);
Assert.Equal("Left_002", g.InferredObject);
}
[Fact]
public void InferredObject_NestedObjectPath()
{
var rows = new[]
{
Row("LeftDelmiaReceiver.ReadyFlag", "ReadyFlag", 1, "Left_002.DelmiaReceiver.ReadyFlag"),
Row("LeftDelmiaReceiver.PartNumber", "PartNumber", 1, "Left_002.DelmiaReceiver.PartNumber"),
};
Assert.Equal("Left_002.DelmiaReceiver", Assert.Single(BindingGroupBuilder.Build(rows)).InferredObject);
}
[Fact]
public void InferredObject_NullWhenMixed_AndStateMixed()
{
var rows = new[]
{
Row("M.A", "A", 1, "Obj1.A"),
Row("M.B", "B", 1, "Obj2.B"),
};
var g = Assert.Single(BindingGroupBuilder.Build(rows));
Assert.Null(g.InferredObject);
Assert.True(g.IsObjectMixed);
}
[Fact]
public void InferredObject_NullWhenNoOverrides_NotMixed()
{
var rows = new[] { Row("M.A", "A", 1, null), Row("M.B", "B", 1, null) };
var g = Assert.Single(BindingGroupBuilder.Build(rows));
Assert.Null(g.InferredObject);
Assert.False(g.IsObjectMixed);
}
[Fact]
public void InstanceGroup_InfersObjectFromOverrides()
{
var rows = new[] { Row("ChlorineFlow", "ChlorineFlow", 1, "Z28061.ChlorineFlow"), Row("MachineID", "MachineID", 1, "Z28061.MachineID") };
var g = Assert.Single(BindingGroupBuilder.Build(rows));
Assert.Equal(BindingGroupBuilder.InstanceGroupKey, g.Key);
Assert.Equal("Z28061", g.InferredObject);
}
[Fact]
public void Counts_UnboundAndConnectionState()
{
var rows = new[]
{
Row("M.A", "A", 1, "O.A"),
Row("M.B", "B", null, null),
Row("M.C", "C", 2, "O.C"),
};
var g = Assert.Single(BindingGroupBuilder.Build(rows));
Assert.Equal(3, g.Members.Count);
Assert.Equal(1, g.UnboundCount);
Assert.Equal(BindingConnectionState.Mixed, g.ConnectionState);
Assert.Null(g.CommonConnectionId);
}
[Fact]
public void ConnectionState_SingleWhenAllAgree()
{
var rows = new[] { Row("M.A", "A", 1, null), Row("M.B", "B", 1, null) };
var g = Assert.Single(BindingGroupBuilder.Build(rows));
Assert.Equal(BindingConnectionState.Single, g.ConnectionState);
Assert.Equal(1, g.CommonConnectionId);
}
[Fact]
public void MemberTail_OverrideThatDoesNotEndWithTail_CountsAsMixed()
{
// An operator typed a reference that does not end in the member tail —
// the prefix cannot be inferred for that row, so the group is mixed.
var rows = new[] { Row("M.A", "A", 1, "Obj.A"), Row("M.B", "B", 1, "Obj.Other") };
Assert.True(Assert.Single(BindingGroupBuilder.Build(rows)).IsObjectMixed);
}
[Fact]
public void ApplyObject_RewritesEveryMember_AndSetsConnection()
{
var g = Assert.Single(BindingGroupBuilder.Build(new[]
{
Row("RightMESReceiver.MoveInFlag", "MoveInFlag", 1, "MESReceiver_024.MoveInFlag"),
Row("RightMESReceiver.ScanStateCmd", "ScanStateCmd", null, null),
}));
var changes = BindingGroupBuilder.ApplyObject(g, connectionId: 1, objectPath: " MESReceiver_022. ");
Assert.Equal(2, changes.Count);
Assert.Equal(("RightMESReceiver.MoveInFlag", 1, "MESReceiver_022.MoveInFlag"), (changes[0].CanonicalName, changes[0].ConnectionId, changes[0].Override));
Assert.Equal(("RightMESReceiver.ScanStateCmd", 1, "MESReceiver_022.ScanStateCmd"), (changes[1].CanonicalName, changes[1].ConnectionId, changes[1].Override));
}
[Fact]
public void ApplyObject_NestedTail_KeepsNesting()
{
var g = Assert.Single(BindingGroupBuilder.Build(new[] { Row("LeftReactorSide.LeakTest.DeltaVac", "DeltaVac", 1, null) }));
var c = Assert.Single(BindingGroupBuilder.ApplyObject(g, 1, "Left_002"));
Assert.Equal("Left_002.LeakTest.DeltaVac", c.Override);
}
[Fact]
public void ApplyObject_EmptyObject_ClearsOverrides_KeepsConnection()
{
var g = Assert.Single(BindingGroupBuilder.Build(new[] { Row("M.A", "A", 1, "Obj.A") }));
var c = Assert.Single(BindingGroupBuilder.ApplyObject(g, 2, ""));
Assert.Null(c.Override);
Assert.Equal(2, c.ConnectionId);
}
// ── Review fixes 2026-08-20 ──
[Fact]
public void ApplyObject_NullConnection_KeepsEachMembersCurrentConnection()
{
// A header reading "— mixed —"/"— none —" (0) must not unbind the group:
// Save is an upsert, so a removal could never persist anyway.
var g = Assert.Single(BindingGroupBuilder.Build(new[]
{
Row("M.A", "A", 1, "Old.A"),
Row("M.B", "B", 2, "Old.B"),
Row("M.C", "C", null, null),
}));
var changes = BindingGroupBuilder.ApplyObject(g, connectionId: null, objectPath: "New");
Assert.Equal(new int?[] { 1, 2, null }, changes.Select(c => c.ConnectionId));
Assert.All(changes, c => Assert.StartsWith("New.", c.Override!));
}
[Fact]
public void InferredObject_MixedWhenSomeMembersStillOnBareDefault()
{
// M.B has no override and its default ("B") is not "<prefix>.B" — the gate
// will reject it, so the group must read mixed rather than unified on "O".
var rows = new[] { Row("M.A", "A", 1, "O.A"), Row("M.B", "B", 1, null) };
var g = Assert.Single(BindingGroupBuilder.Build(rows));
Assert.Null(g.InferredObject);
Assert.True(g.IsObjectMixed);
}
[Fact]
public void InferredObject_UsesEffectiveReference_DefaultThatCarriesPrefixIsNotMixed()
{
var rows = new[] { Row("M.A", "A", 1, "O.A"), Row("M.B", "B", 1, null) with { DefaultReference = "O.B" } };
var g = Assert.Single(BindingGroupBuilder.Build(rows));
Assert.Equal("O", g.InferredObject);
Assert.False(g.IsObjectMixed);
}
[Fact]
public void InferredObject_NoOverrides_InfersFromDefaultsWhenTheyShareAPrefix()
{
var rows = new[] { Row("A", "Z28061.A", 1, null), Row("B", "Z28061.B", 1, null) };
var g = Assert.Single(BindingGroupBuilder.Build(rows));
Assert.Equal("Z28061", g.InferredObject);
Assert.False(g.IsObjectMixed);
}
[Fact]
public void IsUnbound_WhenConnectionIdIsNotAKnownConnection()
{
var rows = new[] { Row("M.A", "A", 99, "O.A") };
var g = Assert.Single(BindingGroupBuilder.Build(rows, knownConnectionIds: new[] { 1, 2 }));
Assert.Equal(1, g.UnboundCount);
Assert.Equal(BindingConnectionState.None, g.ConnectionState);
}
}
@@ -0,0 +1,81 @@
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.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening;
/// <summary>
/// Design 2026-08-20 (grouped connection bindings): the flattened attribute
/// carries the data-source reference as it was BEFORE the instance binding
/// override, so the Configure page can show the template default next to the
/// effective reference. The field is deliberately NOT part of the revision hash.
/// </summary>
public class DefaultDataSourceReferenceTests
{
[Fact]
public void RevisionHash_IgnoresDefaultDataSourceReference()
{
var svc = new RevisionHashService();
var a = new ResolvedAttribute
{
CanonicalName = "LeftMESReceiver.MoveInFlag",
DataType = "Boolean",
DataSourceReference = "MESReceiver_022.MoveInFlag",
BoundDataConnectionId = 1
};
var without = new FlattenedConfiguration { InstanceUniqueName = "Z28061", Attributes = [a] };
var with = without with { Attributes = [a with { DefaultDataSourceReference = "MoveInFlag" }] };
Assert.Equal(svc.ComputeHash(without), svc.ComputeHash(with));
}
[Fact]
public void ResolvedAttribute_DefaultDataSourceReference_IsNullByDefault()
{
var a = new ResolvedAttribute { CanonicalName = "X" };
Assert.Null(a.DefaultDataSourceReference);
}
[Fact]
public void Flatten_PopulatesDefault_ForBoundAndUnboundDataSourcedAttributes()
{
var template = new Template("Base") { Id = 1 };
template.Attributes.Add(new TemplateAttribute("MoveInFlag") { DataType = DataType.Boolean, DataSourceReference = "MoveInFlag" });
template.Attributes.Add(new TemplateAttribute("Unbound") { DataType = DataType.Boolean, DataSourceReference = "UnboundRef" });
template.Attributes.Add(new TemplateAttribute("Static") { DataType = DataType.Boolean, Value = "false" });
var instance = new Instance("Z28061") { Id = 1, TemplateId = 1, SiteId = 1 };
instance.ConnectionBindings.Add(new InstanceConnectionBinding("MoveInFlag")
{
DataConnectionId = 1,
DataSourceReferenceOverride = "MESReceiver_022.MoveInFlag"
});
var connections = new Dictionary<int, DataConnection>
{
[1] = new("GW", "MxGateway", 1) { Id = 1, PrimaryConfiguration = "http://localhost" }
};
var result = new FlatteningService().Flatten(
instance,
[template],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
connections);
Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
var bound = result.Value.Attributes.Single(a => a.CanonicalName == "MoveInFlag");
Assert.Equal("MESReceiver_022.MoveInFlag", bound.DataSourceReference);
Assert.Equal("MoveInFlag", bound.DefaultDataSourceReference);
var unbound = result.Value.Attributes.Single(a => a.CanonicalName == "Unbound");
Assert.Equal("UnboundRef", unbound.DataSourceReference);
Assert.Equal("UnboundRef", unbound.DefaultDataSourceReference);
var stat = result.Value.Attributes.Single(a => a.CanonicalName == "Static");
Assert.Null(stat.DefaultDataSourceReference);
}
}