Compare commits
10 Commits
4388fa41e4
...
a24fa38eae
| Author | SHA1 | Date | |
|---|---|---|---|
| a24fa38eae | |||
| 546250096d | |||
| 5e04e81ba0 | |||
| 77f4af0d64 | |||
| 02a374259b | |||
| 2fb1c8ee14 | |||
| 5da7003e88 | |||
| 3acb79d97e | |||
| 4cfd9337a3 | |||
| eef77b6654 |
@@ -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"
|
||||
}
|
||||
@@ -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>]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
+354
-105
@@ -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),
|
||||
|
||||
+17
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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.");
|
||||
});
|
||||
|
||||
+178
@@ -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>());
|
||||
}
|
||||
}
|
||||
+62
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
+81
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user