Compare commits

...

8 Commits

Author SHA1 Message Date
Joseph Doherty 691a2f2f80 docs(plans): create-time bindings — live rig gate PASS (Object-node pick, prefill, 30/30 bound, deploy-gate A/B) + task status
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:20:50 -04:00
Joseph Doherty a26edffd4c docs: create-time connection bindings (Central UI, DeploymentManager preview flatten, CLI cross-ref)
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:14:55 -04:00
Joseph Doherty 80b9ab2d57 feat(centralui): Create saves bindings (upsert) and lands on Configure with a created/bound/bindError notice
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:12:53 -04:00
Joseph Doherty 74c7bd2fa8 feat(centralui): Create Instance — Connection Bindings card (preview flatten, per-group connection/Object/Browse, Bind-all prefill)
Tasks 4 and 5 of the plan land together: the card's state fields are write-only
until the markup consumes them, and the project treats unused-field warnings as
errors, so neither task builds on its own.

Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:11:53 -04:00
Joseph Doherty ab6e3432bd feat(centralui): NodeBrowserDialog.SelectableNodeClasses — opt-in Object node selection (default unchanged)
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:07:12 -04:00
Joseph Doherty 8d17af37ac feat(centralui): CreateBindingPlanner — prefill/tail-strip/binding-list logic for create-time bindings
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:05:41 -04:00
Joseph Doherty 6257c9e20e feat(deploymentmanager): IFlatteningPipeline.PreviewAsync — flatten a template+site without an instance row (create-page bindings)
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
2026-08-21 06:04:13 -04:00
Joseph Doherty a7301bb638 docs(plans): create-time connection bindings — design (inline card on Create Instance, object-node Browse, land on Configure) + 9-task plan
Claude-Session: https://claude.ai/code/session_01Rs5tk49eMrNcwmi8FAohHX
2026-08-21 05:59:00 -04:00
19 changed files with 1999 additions and 62 deletions
+1
View File
@@ -127,6 +127,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
- Deployment identity: unique deployment ID + revision hash for idempotency.
- Per-instance operation lock covers all mutating commands (deploy, disable, enable, delete).
- **Bulk site deployment (`DeploymentService.DeploySiteAsync`, arch-review WP2.5)** redeploys every out-of-date instance at a site through one flatten session (a template chain shared by N instances is walked once) in three phases — serial Prepare, bounded-parallel Send (`SiteDeploymentMaxParallelism`, default 4), serial Finalize — still **not all-or-nothing**: any one instance's failure doesn't block the rest. **Disabled instances are SKIPPED by bulk deploy as of 2026-08-19 (arch-review P7-2)** — they are reported in the summary's additive `SkippedInstances` list, never flattened/sent/re-enabled (single-instance deploy remains the explicit operator path that deploys+enables a Disabled instance); **Up-to-date instances are ALSO skipped as of 2026-08-20 (arch-review P7-6)** — a redeploy is not idempotent at the site (it resets static overrides and clears native alarm state), so `DeploySiteAsync` pre-filters through `IStaleInstanceProbe` (trustworthy since P7-4 fixed its memo timing) and reports unchanged instances in `SkippedInstances` with reason `up-to-date`; the filter is conservative (every uncertainty deploys: missing snapshot, `ShouldQuerySiteBeforeRedeploy`, null/faulting probe). Additive `MgmtDeploySiteCommand.Force` / CLI `deploy site --force` restores the unconditional push for what central cannot see (restored volume, rebuilt node) — **Disabled still wins under `--force`**. Also P7-5: the three phases share one scoped DbContext and a failed `SaveChanges` leaves the entity tracked, so one permanent failure used to cascade to every later instance and strand records `InProgress`; `MarkDeploymentFailedAsync` now detaches pending changes and retries the `Failed` write once on the clean tracker; Prepare's per-instance reconcile also runs under a short `SiteDeploymentReconcileTimeout` (5s) with a once-per-batch unreachable-site circuit-breaker, so a down site no longer costs 30s × N serial before the first send (P7-3). The `PendingDeployment` staging row (5-minute TTL) is written per-instance immediately before that instance's round-trip in the Send phase, not upfront in Prepare, so a large batch's tail instances don't have their TTL clock already half-spent by the time they're reached.
- **Create Instance binds module groups at creation** (2026-08-21): the create page's Connection Bindings card flattens template+site through the additive `IFlatteningPipeline.PreviewAsync` (an unsaved instance — no validation, no hash, nothing persisted, instance repo never touched) and reuses `BindingGroupBuilder`; `NodeBrowserDialog.SelectableNodeClasses` lets it pick **Object** nodes (default stays Variable-only for every existing caller). Create = `CreateInstanceAsync` → `SetConnectionBindingsAsync` (the existing upsert, no new server surface) → always navigate to Configure with `?created=1&bound=N|&bindError=…`, because the row exists whether or not the second call succeeded. Partial binding is legal: a group missing a connection or an Object is skipped and stays unbound. Design: `docs/plans/2026-08-21-create-instance-bindings-design.md`.
- Site-side apply is all-or-nothing per instance.
- System-wide artifact version skew across sites is supported.
- Last-write-wins for concurrent template editing (no optimistic concurrency on templates).
@@ -0,0 +1,116 @@
# Create Instance — bind connections at creation (design)
**Date:** 2026-08-21 · **Status:** **implemented** 2026-08-21 — plan `docs/plans/2026-08-21-create-instance-bindings.md`
**Mockup:** Claude artifact "Create Instance Bindings" (https://claude.ai/code/artifact/125dc749-c923-4977-a02a-eeac826e6f26)
**Builds on:** `docs/plans/2026-08-20-grouped-connection-bindings-design.md` (Configure page groups, `BindingGroupBuilder`, per-module Object apply)
## 1. Problem
`/deployment/instances/create` stops at Name / Template / Site / Area. The operator then has to
find the new instance in Topology, open Configure, and bind every module group before the first
Deploy — for a composed template like the MES Line Controller that is 8 groups / 270 rows, and
the most common question at creation is exactly "which MxAccess object (or OPC UA node) does this
instance hang off?". The answer should be given at creation, in the same shape the Configure page
now uses.
## 2. Decisions (made 2026-08-21)
| Decision | Choice | Alternatives considered |
|---|---|---|
| Shape | **Inline bindings card** on the create page — one row per module group (connection, Object, Browse…), with a "Bind all to" root picker; per-member editing stays on Configure. | Two-step wizard with the full grouped table (more screen, duplicates Configure); create-then-redirect to Configure with a banner (no new UI, but still two places). |
| What Browse… picks | **The object node.** Selecting an `Object` node writes its id into the group's Object box; members become `<object>.<memberTail>` via the existing `ApplyObject`. Picking a Variable is still allowed; the page strips the member tail. | Leaf-only selection + inference (works today, but non-obvious). |
| After Create | **Land on the new instance's Configure page** with a "created, N bound" notice, so Test Bindings / Deploy are one click away. | Back to Topology (today). |
## 3. Design
### 3.1 Preview flatten (DeploymentManager, additive)
The Configure page gets its rows from `IFlatteningPipeline.FlattenAndValidateAsync(instanceId)`,
which loads the instance from the DB. At creation there is no row yet, but
`FlatteningService.Flatten(Instance, chain, compositionMap, composedChains, dataConnections)` takes
an in-memory `Instance`. Add
```csharp
Task<Result<FlattenedConfiguration>> PreviewAsync(int templateId, int siteId, CancellationToken ct = default);
```
to `IFlatteningPipeline`, implemented in `FlatteningPipeline` by factoring the chain/composition
loading out of `FlattenAndValidateAsync` into a private helper and calling `Flatten` with an
unsaved `new Instance { TemplateId, SiteId, Name = "(preview)" }` (no overrides, no bindings).
No validation pass (no scripts, no binding completeness) — only the attribute list is needed.
Same `FlattenSession` memo, so it is one template-chain walk per call.
### 3.2 Create page (CentralUI `InstanceCreate.razor` + new code-behind)
- The **Connection Bindings** card appears once Template *and* Site are set; changing either
re-runs the preview (debounced by the select's change event — no typing involved) and resets
the card. Rows = `PreviewAsync(...).Attributes.Where(DefaultDataSourceReference ?? DataSourceReference != null)`
→ `BindingRow(canonicalName, default, null, null)` → `BindingGroupBuilder.Build(rows, siteConnectionIds)`.
- **Group rows** are the Configure page's group-header row (connection select, Object input,
Browse…, bound/unbound pill) without the member table. A third pill on the Object cell says
where the value came from: `from template` (the composed defaults already share a prefix —
`InferredObject` non-null before any edit), `suggested` (set by Prefill), nothing (typed).
- **Bind all to** = connection + root Object + Browse… + **Prefill groups**. Prefill sets the
connection on every group, puts the root on `(instance)`, and for module groups with a blank
Object whose defaults are bare member names suggests `{root}.{groupKey}`. It never overwrites
a typed Object; groups with `from template` keep theirs. The suggestion is deliberately
*not* applied silently — sibling galaxy objects (Z28061's `Left_002` vs `Z28061.Left_002`) are
common, so the operator must be able to see and correct it before Create.
- **Browse…** per group opens `NodeBrowserDialog` for that group's connection, rooted at the root
Object when set (`InitialNodeId`), with the new `SelectableNodeClasses = [Object, Variable]`.
Object pick → Object box as-is. Variable pick → if its id ends with `.<memberTail>` of any
member in the group, strip the tail; otherwise use as-is.
- **Skip bindings** checkbox collapses the card and restores today's behaviour.
- **Create instance**: validate name/template/site/scope as today; `CreateInstanceAsync`; then,
unless skipped, compute the binding list by running `ApplyObject` on every group with a
connection and a non-blank Object (rows with connection but blank Object are left unbound —
no partial `<member>`-only binding is written) and call `SetConnectionBindingsAsync` (an upsert).
Navigate to `/deployment/instances/{id}/configure?created=1&bound=N`. If the bindings call
fails **after** the instance was created, still navigate to Configure and pass the error
(`?bindError=…`, length-capped) so the operator is not left on a form for an instance that now
exists.
- State table (empty template, site with no connections, preview failure, in-flight) is in the
mockup §3; all non-fatal states keep Create enabled and create without bindings.
Pure logic lives in a new `Services/CreateBindingPlanner.cs` (Prefill, tail-stripping, binding
list assembly) so it is unit-testable without rendering, mirroring `BindingGroupBuilder`.
### 3.3 NodeBrowserDialog (CentralUI, additive)
New parameter `IReadOnlyCollection<BrowseNodeClass> SelectableNodeClasses` defaulting to
`[Variable]`, consulted by `Select`/`SelectBrowseNode`; rows of a selectable non-Variable class
get an `object` badge and the footer prompt reads "Select an object". Existing callers
(Configure, template editors) are unchanged.
### 3.4 Configure page
Reads `created`/`bound`/`bindError` query parameters and shows a one-shot notice above the
Connection Bindings panel. Nothing else changes.
### 3.5 CLI
No new command: `instance create` followed by `instance bind-module` / `set-bindings` already
covers the same operation. The CLI README gets a one-line cross-reference from the create
command to the binding commands.
## 4. Out of scope
- Per-member editing at creation (Configure's job).
- Making OPC UA object concatenation work for servers with opaque node ids — unchanged caveat
from the Configure design; the default reference is visible on Configure for those.
- A template-level "preferred connection" default (would save the Bind-all step) — possible
follow-up, needs a template schema change.
- Test Bindings at creation (needs a saved instance; it is one click away on Configure).
## 5. Testing
- `FlatteningPipelineTests`: `PreviewAsync` returns the same attribute set (canonical names +
default references) as `FlattenAndValidateAsync` on a freshly created, un-overridden instance of
the same template/site; unknown template/site → Failure.
- `CreateBindingPlannerTests`: Prefill (blank-only, `from template` preserved, nested module
keys), Variable-pick tail stripping, binding list excludes connection-without-Object rows,
Skip ⇒ empty list.
- `NodeBrowserDialog` bUnit test (if the project already renders it) or `TreeRow` selection
test: Object selectable only when listed.
- Live check on the docker rig with a composed template: create → lands on Configure with the
expected groups bound; Deploy gate accepts; Test Bindings reads.
@@ -0,0 +1,876 @@
# Create Instance Bindings Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Let the operator bind every module group of a new instance to an MxAccess object / OPC UA node on the Create Instance page, so one Create yields an instance that is deployable (or lands on Configure with only the genuinely undecided groups unbound).
**Architecture:** A new `IFlatteningPipeline.PreviewAsync(templateId, siteId)` flattens an unsaved `Instance` so the create page can build the same `BindingGroup`s the Configure page shows. A pure `CreateBindingPlanner` (CentralUI `Services/`) holds Prefill / tail-stripping / binding-list assembly and is unit-pinned like `BindingGroupBuilder`. `InstanceCreate.razor` gains the Connection Bindings card (group-header rows only), `NodeBrowserDialog` gains an additive `SelectableNodeClasses` parameter so an Object node can be picked, and Create = `CreateInstanceAsync` → `SetConnectionBindingsAsync` (upsert) → navigate to Configure, which shows a one-shot notice. Design: `docs/plans/2026-08-21-create-instance-bindings-design.md`; mockup: artifact "Create Instance Bindings".
**Tech Stack:** .NET 10, Blazor Server + Bootstrap (no component libs), xunit + NSubstitute + bUnit, existing `BindingGroupBuilder`, `FlatteningService`.
**Worktree rule:** implement in a dedicated `git worktree` off `main`, never in the primary checkout. Do NOT use `git stash` (shared across worktrees). `docker/deploy.sh` refuses to run from a worktree — for the live check use `docker/build.sh` from the worktree then `docker compose -f <main>/docker/docker-compose.yml up -d --force-recreate`.
---
### Task 1: `IFlatteningPipeline.PreviewAsync` (DeploymentManager)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/IFlatteningPipeline.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs` (chain/composition loading lines ~84-135 factored into a helper)
- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs` (create)
`FlatteningPipeline` is the only implementor of the interface (verified), so the interface addition breaks nothing else; NSubstitute mocks elsewhere pick it up automatically.
**Step 1: Write the failing tests**
```csharp
using NSubstitute;
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.Types.Enums;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>Pins PreviewAsync: same attribute set as a freshly created, un-overridden instance.</summary>
public class FlatteningPipelinePreviewTests
{
private const int TemplateId = 10, ModuleTemplateId = 11, SiteId = 20, InstanceId = 30;
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>();
private readonly FlatteningPipeline _sut;
public FlatteningPipelinePreviewTests()
{
_sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>()).Returns([]);
_sut = new FlatteningPipeline(_templateRepo, _siteRepo, new FlatteningService(), new ValidationService(),
new RevisionHashService(), _sharedSchemaRepo, new TemplateGraphWatermark());
// Parent template with one direct data-sourced attribute composing a module with one.
var module = new Template("Receiver") { Id = ModuleTemplateId };
module.Attributes.Add(new TemplateAttribute("MoveInFlag") { DataType = DataType.Boolean, DataSourceReference = "MoveInFlag" });
var parent = new Template("Line") { Id = TemplateId };
parent.Attributes.Add(new TemplateAttribute("Running") { DataType = DataType.Boolean, DataSourceReference = "Running" });
var composition = new TemplateComposition { TemplateId = TemplateId, ComposedTemplateId = ModuleTemplateId, ModuleInstanceName = "Left" };
_templateRepo.GetTemplateWithChildrenAsync(TemplateId, Arg.Any<CancellationToken>()).Returns(parent);
_templateRepo.GetTemplateWithChildrenAsync(ModuleTemplateId, Arg.Any<CancellationToken>()).Returns(module);
_templateRepo.GetCompositionsByTemplateIdAsync(TemplateId, Arg.Any<CancellationToken>()).Returns([composition]);
_templateRepo.GetCompositionsByTemplateIdAsync(ModuleTemplateId, Arg.Any<CancellationToken>()).Returns([]);
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>()).Returns([]);
_templateRepo.GetInstanceByIdAsync(InstanceId, Arg.Any<CancellationToken>())
.Returns(new Instance("Line-01") { Id = InstanceId, TemplateId = TemplateId, SiteId = SiteId });
_siteRepo.GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any<CancellationToken>())
.Returns([new DataConnection("MxGateway", "MxGateway", SiteId) { Id = 1 }]);
}
[Fact]
public async Task Preview_matches_flatten_of_fresh_instance()
{
var preview = await _sut.PreviewAsync(TemplateId, SiteId);
var real = await _sut.FlattenAndValidateAsync(InstanceId, validateScripts: false);
Assert.True(preview.IsSuccess, preview.Error);
var expected = real.Value.Configuration.Attributes.Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference)).OrderBy(x => x.Item1).ToList();
var actual = preview.Value.Attributes.Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference)).OrderBy(x => x.Item1).ToList();
Assert.Equal(expected, actual);
Assert.Contains(actual, a => a.Item1 == "Left.MoveInFlag");
}
[Fact]
public async Task Preview_unknown_template_fails()
{
var r = await _sut.PreviewAsync(999, SiteId);
Assert.True(r.IsFailure);
}
[Fact]
public async Task Preview_never_touches_instance_repository()
{
await _sut.PreviewAsync(TemplateId, SiteId);
await _templateRepo.DidNotReceive().GetInstanceByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
}
}
```
Check the `TemplateComposition` constructor/property names against `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateComposition.cs` and adjust the object initializer (the entity may use a ctor).
**Step 2: Run to verify failure**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FlatteningPipelinePreviewTests`
Expected: build error — `PreviewAsync` not defined.
**Step 3: Implement**
`IFlatteningPipeline.cs` — add:
```csharp
/// <summary>
/// Flattens <paramref name="templateId"/> as if an instance with no overrides and no
/// bindings existed at <paramref name="siteId"/>. Attribute list only — no validation,
/// no revision hash, nothing persisted. Used by the Create Instance page to show the
/// module groups before the instance row exists.
/// </summary>
Task<Result<FlattenedConfiguration>> PreviewAsync(int templateId, int siteId, CancellationToken cancellationToken = default);
```
`FlatteningPipeline.cs` — extract the chain + composition walk (from `var templateChain = await session.GetChainAsync(instance.TemplateId, LoadTemplate);` through the end of the `while (pendingChains.Count > 0)` loop) into
```csharp
private async Task<(IReadOnlyList<Template> Chain,
Dictionary<int, IReadOnlyList<TemplateComposition>> CompositionMap,
Dictionary<int, IReadOnlyList<Template>> ComposedChains)?>
LoadTemplateGraphAsync(int templateId, FlattenSession session, CancellationToken cancellationToken)
```
returning `null` when the chain is empty; call it from `FlattenAndValidateAsync` (keep the existing "Template chain is empty." failure), then add
```csharp
public async Task<Result<FlattenedConfiguration>> PreviewAsync(int templateId, int siteId, CancellationToken cancellationToken = default)
{
var session = CreateSession();
var graph = await LoadTemplateGraphAsync(templateId, session, cancellationToken);
if (graph is null)
return Result<FlattenedConfiguration>.Failure($"Template {templateId} not found or its chain is empty.");
var dataConnections = await session.GetDataConnectionsAsync(siteId, id => LoadDataConnectionsAsync(id, cancellationToken));
var preview = new Instance("(preview)") { TemplateId = templateId, SiteId = siteId };
return _flatteningService.Flatten(preview, graph.Value.Chain, graph.Value.CompositionMap, graph.Value.ComposedChains, dataConnections);
}
```
(Use the fully-qualified `Commons.Entities.Templates.*` names the file already uses.)
**Step 4: Run tests**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter "FlatteningPipeline"`
Expected: all PASS (preview tests + the existing ConnectionBinding / NativeAlarmCapability / FlattenSessionCache suites, which exercise the refactored path).
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.DeploymentManager tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelinePreviewTests.cs
git commit -m "feat(deploymentmanager): IFlatteningPipeline.PreviewAsync — flatten a template+site without an instance row (create-page bindings)"
```
---
### Task 2: `CreateBindingPlanner` — Prefill + tail stripping + binding list (pure)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 3
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CreateBindingPlanner.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs` (create)
**Step 1: Write the failing tests**
```csharp
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
public class CreateBindingPlannerTests
{
private static IReadOnlyList<BindingGroup> Groups() => BindingGroupBuilder.Build(
[
new BindingRow("Running", "Running", null, null), // (instance)
new BindingRow("Left.MoveInFlag", "MoveInFlag", null, null), // bare default → suggest
new BindingRow("Left.MoveOutFlag", "MoveOutFlag", null, null),
new BindingRow("Recv.Count", "MESReceiver_023.Count", null, null), // prefixed default → from template
new BindingRow("Recv.State", "MESReceiver_023.State", null, null),
]);
[Fact]
public void Prefill_fills_blank_objects_only()
{
var inputs = new Dictionary<string, CreateBindingPlanner.GroupInput>
{
["Left"] = new(0, "Typed_Left"),
};
var result = CreateBindingPlanner.Prefill(Groups(), inputs, connectionId: 7, rootObject: "Z28063");
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "Z28063"), result[BindingGroupBuilder.InstanceGroupKey]);
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "Typed_Left"), result["Left"]); // typed wins
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "MESReceiver_023"), result["Recv"]); // from template
Assert.Equal(CreateBindingPlanner.ObjectSource.Typed, CreateBindingPlanner.SourceOf(Groups()[1], result["Left"]));
Assert.Equal(CreateBindingPlanner.ObjectSource.FromTemplate, CreateBindingPlanner.SourceOf(Groups()[2], result["Recv"]));
}
[Fact]
public void Prefill_suggests_root_dot_module_for_bare_defaults()
{
var result = CreateBindingPlanner.Prefill(Groups(), new Dictionary<string, CreateBindingPlanner.GroupInput>(), 7, "Z28063");
Assert.Equal("Z28063.Left", result["Left"].Object);
Assert.Equal(CreateBindingPlanner.ObjectSource.Suggested, CreateBindingPlanner.SourceOf(Groups()[1], result["Left"]));
}
[Fact]
public void Prefill_with_blank_root_sets_connection_only()
{
var result = CreateBindingPlanner.Prefill(Groups(), new Dictionary<string, CreateBindingPlanner.GroupInput>(), 7, "");
Assert.Equal(new CreateBindingPlanner.GroupInput(7, null), result[BindingGroupBuilder.InstanceGroupKey]);
Assert.Equal(new CreateBindingPlanner.GroupInput(7, null), result["Left"]);
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "MESReceiver_023"), result["Recv"]);
}
[Theory]
[InlineData("Z28063.Left.MoveInFlag", "Z28063.Left")] // leaf under the group → strip tail
[InlineData("Z28063.Left", "Z28063.Left")] // object node → as-is
[InlineData("ns=2;s=Plant.Left.MoveOutFlag", "ns=2;s=Plant.Left")]
[InlineData("Other.Thing", "Other.Thing")] // no member tail matches → as-is
public void ObjectFromPick_strips_member_tail(string picked, string expected)
=> Assert.Equal(expected, CreateBindingPlanner.ObjectFromPick(Groups()[1], picked));
[Fact]
public void BuildBindings_skips_groups_without_connection_or_object()
{
var inputs = new Dictionary<string, CreateBindingPlanner.GroupInput>
{
[BindingGroupBuilder.InstanceGroupKey] = new(7, "Z28063"),
["Left"] = new(7, null), // connection, no object → skipped (stays unbound)
["Recv"] = new(0, "MESReceiver_023"), // object, no connection → skipped
};
var bindings = CreateBindingPlanner.BuildBindings(Groups(), inputs);
var single = Assert.Single(bindings);
Assert.Equal(("Running", 7, "Z28063.Running"), (single.AttributeName, single.DataConnectionId, single.DataSourceReferenceOverride));
}
[Fact]
public void BuildBindings_rewrites_every_member_of_a_bound_group()
{
var inputs = new Dictionary<string, CreateBindingPlanner.GroupInput> { ["Left"] = new(7, "Z28063.Left") };
var bindings = CreateBindingPlanner.BuildBindings(Groups(), inputs);
Assert.Equal(2, bindings.Count);
Assert.Contains(bindings, b => b.AttributeName == "Left.MoveInFlag" && b.DataSourceReferenceOverride == "Z28063.Left.MoveInFlag");
}
}
```
**Step 2: Run to verify failure**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter CreateBindingPlannerTests`
Expected: build error — `CreateBindingPlanner` not defined.
**Step 3: Implement**
```csharp
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Pure logic behind the Create Instance page's Connection Bindings card
/// (design 2026-08-21): Prefill from a root object, Object inference from a
/// browser pick, and the binding list sent with Create. Groups come from
/// <see cref="BindingGroupBuilder.Build"/> over a template+site preview flatten.
/// </summary>
public static class CreateBindingPlanner
{
/// <summary>Per-group operator input. <c>ConnectionId</c> 0 = none.</summary>
public sealed record GroupInput(int ConnectionId, string? Object);
public enum ObjectSource { None, Typed, Suggested, FromTemplate }
/// <summary>
/// Sets <paramref name="connectionId"/> on every group and fills BLANK Objects only:
/// the instance group gets <paramref name="rootObject"/>; a module group whose
/// defaults already share a prefix keeps it (<see cref="BindingGroup.InferredObject"/>);
/// otherwise, when a root is given, <c>{root}.{groupKey}</c> is suggested. A typed
/// Object is never overwritten.
/// </summary>
public static IReadOnlyDictionary<string, GroupInput> Prefill(
IReadOnlyList<BindingGroup> groups,
IReadOnlyDictionary<string, GroupInput> current,
int connectionId,
string? rootObject)
{
var root = (rootObject ?? string.Empty).Trim().TrimEnd('.');
var result = new Dictionary<string, GroupInput>(StringComparer.Ordinal);
foreach (var g in groups)
{
current.TryGetValue(g.Key, out var existing);
var obj = existing?.Object;
if (string.IsNullOrWhiteSpace(obj))
{
obj = g.IsInstanceGroup ? (root.Length == 0 ? null : root)
: g.InferredObject is { Length: > 0 } inferred ? inferred
: root.Length == 0 ? null
: root + "." + g.Key;
}
result[g.Key] = new GroupInput(connectionId, obj);
}
return result;
}
/// <summary>Where a group's current Object value came from, for the row's provenance pill.</summary>
public static ObjectSource SourceOf(BindingGroup group, GroupInput? input)
{
var obj = input?.Object?.Trim().TrimEnd('.');
if (string.IsNullOrEmpty(obj)) return ObjectSource.None;
if (!group.IsInstanceGroup && string.Equals(obj, group.InferredObject, StringComparison.Ordinal)) return ObjectSource.FromTemplate;
return input!.Suggested ? ObjectSource.Suggested : ObjectSource.Typed;
}
/// <summary>
/// Object for a browser pick: an Object node is used as-is; a Variable whose id ends
/// with <c>.{memberTail}</c> of any member of the group has that tail stripped.
/// </summary>
public static string ObjectFromPick(BindingGroup group, string pickedNodeId)
{
foreach (var m in group.Members)
{
var suffix = "." + m.MemberTail;
if (pickedNodeId.EndsWith(suffix, StringComparison.Ordinal) && pickedNodeId.Length > suffix.Length)
return pickedNodeId[..^suffix.Length];
}
return pickedNodeId;
}
/// <summary>
/// The bindings to send with Create: every member of every group that has BOTH a
/// connection and a non-blank Object, rewritten via <see cref="BindingGroupBuilder.ApplyObject"/>.
/// Groups missing either are left unbound for Configure.
/// </summary>
public static IReadOnlyList<ConnectionBinding> BuildBindings(
IReadOnlyList<BindingGroup> groups,
IReadOnlyDictionary<string, GroupInput> inputs)
{
var list = new List<ConnectionBinding>();
foreach (var g in groups)
{
if (!inputs.TryGetValue(g.Key, out var input) || input.ConnectionId <= 0 || string.IsNullOrWhiteSpace(input.Object))
continue;
foreach (var c in BindingGroupBuilder.ApplyObject(g, input.ConnectionId, input.Object))
list.Add(new ConnectionBinding(c.CanonicalName, c.ConnectionId!.Value, c.Override));
}
return list;
}
}
```
`SourceOf` needs to know whether Prefill produced the value: make `GroupInput` carry a third positional `bool Suggested = false`, and have `Prefill` set `Suggested: true` only on the `root + "." + g.Key` branch. Update the test expectations that construct `GroupInput(7, "Z28063.Left")` accordingly (`new(7, "Z28063.Left", true)` where the suggestion is asserted). Keep the record positional so the tests' `Assert.Equal` on records works.
**Step 4: Run tests**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "CreateBindingPlannerTests|BindingGroupBuilderTests"`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CreateBindingPlanner.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs
git commit -m "feat(centralui): CreateBindingPlanner — prefill/tail-strip/binding-list logic for create-time bindings"
```
---
### Task 3: `NodeBrowserDialog.SelectableNodeClasses` (additive)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/NodeBrowserDialog.razor` (parameters ~128-150; `SelectBrowseNode`/`Select` ~356-370; footer prompt ~119)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor` (line 14 `@if (Node.NodeClass == BrowseNodeClass.Variable)`; parameter block ~74)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs` (append)
**Step 1: Write the failing tests** (append to `NodeBrowserDialogSelectionTests`; the fixture's `SearchAsync` stub already returns one Variable — add an Object match to the stub's `Matches` array: `new AddressSpaceMatch(new BrowseNode("ns=2;s=Pump1", "Pump1", BrowseNodeClass.Object, HasChildren: true), Path: "Devices/Pump1")`)
```csharp
[Fact]
public void Object_node_is_not_selectable_by_default()
{
var cut = Render<NodeBrowserDialog>(p => p.Add(c => c.SiteId, "plant-a").Add(c => c.ConnectionName, "PLC-OPC"));
cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-OPC", null));
cut.Render();
cut.Find("[data-test=node-search-input]").Input("Pump1");
cut.Find("[data-test=node-search-button]").Click();
// Only the Variable renders a select button; the Object is plain text.
Assert.Single(cut.FindAll("[data-test=node-search-result] button.btn-link"));
}
[Fact]
public void Object_node_is_selectable_when_listed()
{
BrowseNode? captured = null;
var cut = Render<NodeBrowserDialog>(p => p
.Add(c => c.SiteId, "plant-a").Add(c => c.ConnectionName, "PLC-OPC")
.Add(c => c.SelectableNodeClasses, new[] { BrowseNodeClass.Object, BrowseNodeClass.Variable })
.Add(c => c.OnNodeSelected, EventCallback.Factory.Create<BrowseNode>(this, n => captured = n)));
cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-OPC", null));
cut.Render();
cut.Find("[data-test=node-search-input]").Input("Pump1");
cut.Find("[data-test=node-search-button]").Click();
var buttons = cut.FindAll("[data-test=node-search-result] button.btn-link");
Assert.Equal(2, buttons.Count);
buttons.First(b => b.TextContent.Trim() == "Pump1").Click();
cut.Find(".modal-footer .btn-primary").Click();
Assert.Equal("ns=2;s=Pump1", captured!.NodeId);
Assert.Equal(BrowseNodeClass.Object, captured.NodeClass);
}
```
**Step 2: Run to verify failure**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter NodeBrowserDialogSelectionTests`
Expected: second test fails to build (`SelectableNodeClasses` missing); first test already passes (pin).
**Step 3: Implement**
`NodeBrowserDialog.razor`:
- Parameter: `[Parameter] public IReadOnlyCollection<BrowseNodeClass> SelectableNodeClasses { get; set; } = new[] { BrowseNodeClass.Variable };` with a doc comment "Additive: node classes the picker accepts. Default Variable-only (every existing caller). The create page passes Object+Variable so a module's container can be chosen."
- Helper: `private bool IsSelectable(BrowseNodeClass c) => SelectableNodeClasses.Contains(c);`
- `SelectBrowseNode` / `Select`: replace `!= BrowseNodeClass.Variable` guards with `!IsSelectable(node.NodeClass)`.
- Search-result markup (~line 55-70): render the `<button>` when `IsSelectable(node.NodeClass)`, else a plain `<span>@node.DisplayName</span>`; when the class is `Object` and selectable, add `<span class="badge bg-body-secondary text-body-secondary border ms-1">object</span>`.
- Pass `IsSelectable` to the tree: `<TreeRow ... Selectable="IsSelectable" />` and in the footer prompt, if `SelectableNodeClasses.Contains(BrowseNodeClass.Object)` show `<span class="text-muted small me-auto">Select an object or tag</span>`.
`TreeRow.razor`:
- Add `[Parameter] public Func<BrowseNodeClass, bool>? Selectable { get; set; }` and `private bool IsSelectable => Selectable?.Invoke(Node.NodeClass) ?? Node.NodeClass == BrowseNodeClass.Variable;`
- Line 14: `@if (IsSelectable)`; keep the non-selectable branch as today; pass `Selectable="Selectable"` on the recursive `<TreeRow>` (line 52). For an `Object` node that IS selectable, the existing expand toggle must still render — keep the toggle markup outside the `@if`.
**Step 4: Run tests**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "NodeBrowserDialog"`
Expected: PASS (selection + search suites).
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/NodeBrowserDialogSelectionTests.cs
git commit -m "feat(centralui): NodeBrowserDialog.SelectableNodeClasses — opt-in Object node selection (default unchanged)"
```
---
### Task 4: Create page — code-behind state + preview load
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by 1, 2)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor` (`@code` block: move fields to partial class; add `@inject IFlatteningPipeline FlatteningPipeline`, `@inject ISiteRepository` is already there)
**Step 1: Implement** (no pure logic here — the testable parts live in Tasks 1-2; this task is wiring)
`InstanceCreate.razor.cs` (`public partial class InstanceCreate`):
```csharp
// --- Connection Bindings card state (design 2026-08-21) ---
private IReadOnlyList<BindingGroup> _groups = Array.Empty<BindingGroup>();
private int _rowCount;
private List<DataConnection> _siteConnections = new();
private readonly Dictionary<string, CreateBindingPlanner.GroupInput> _inputs = new(StringComparer.Ordinal);
private int _rootConnectionId;
private string? _rootObject;
private bool _skipBindings;
private bool _previewLoading;
private string? _previewError;
private string? _siteIdentifier;
private bool BindingsCardVisible => _createTemplateId > 0 && _createSiteId > 0;
private int UnboundGroups => _groups.Count(g => !IsGroupBound(g));
private bool IsGroupBound(BindingGroup g)
=> _inputs.TryGetValue(g.Key, out var i) && i.ConnectionId > 0 && !string.IsNullOrWhiteSpace(i.Object);
private int BoundRowCount => _groups.Where(IsGroupBound).Sum(g => g.Members.Count);
/// <summary>Called from the Template and Site selects' @onchange; re-runs the preview.</summary>
private async Task OnTemplateOrSiteChangedAsync()
{
_groups = Array.Empty<BindingGroup>(); _inputs.Clear(); _previewError = null; _rowCount = 0;
if (!BindingsCardVisible) return;
_previewLoading = true;
try
{
var site = _sites.First(s => s.Id == _createSiteId);
_siteIdentifier = site.SiteIdentifier;
_siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_createSiteId)).ToList();
var preview = await FlatteningPipeline.PreviewAsync(_createTemplateId, _createSiteId);
if (preview.IsFailure) { _previewError = preview.Error; return; }
var rows = preview.Value.Attributes
.Where(a => a.DefaultDataSourceReference is not null || a.DataSourceReference is not null)
.Select(a => new BindingRow(a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference, null, null))
.ToList();
_rowCount = rows.Count;
_groups = BindingGroupBuilder.Build(rows, _siteConnections.Select(c => c.Id).ToHashSet());
// Groups whose defaults already carry an object prefix start "from template".
foreach (var g in _groups)
if (!g.IsInstanceGroup && g.InferredObject is { Length: > 0 })
_inputs[g.Key] = new CreateBindingPlanner.GroupInput(0, g.InferredObject);
}
catch (Exception ex) { _previewError = ex.Message; }
finally { _previewLoading = false; }
}
private void PrefillGroups()
{
foreach (var kv in CreateBindingPlanner.Prefill(_groups, _inputs, _rootConnectionId, _rootObject))
_inputs[kv.Key] = kv.Value;
}
private void SetGroupConnection(BindingGroup g, int id)
=> _inputs[g.Key] = (_inputs.GetValueOrDefault(g.Key) ?? new(0, null)) with { ConnectionId = id };
private void SetGroupObject(BindingGroup g, string? obj)
=> _inputs[g.Key] = (_inputs.GetValueOrDefault(g.Key) ?? new(0, null)) with { Object = obj, Suggested = false };
```
Move the existing `@code` fields (`_sites`, `_templates`, `_allAreas`, `_loading`, `_createName`, `_createTemplateId`, `_createSiteId`, `_createAreaId`, `_formError`) into the partial class so both files see them; leave `OnInitializedAsync`, `CreateInstance`, `GoBack`, `GetCurrentUserAsync` in the `.razor` for now (Task 6 rewrites `CreateInstance`). The Template/Site `<select>`s switch from `@bind` to `value=`/`@onchange` so the preview can run on change:
```razor
<select class="form-select form-select-sm" value="@_createTemplateId"
@onchange="async e => { _createTemplateId = int.TryParse(e.Value?.ToString(), out var v) ? v : 0; await OnTemplateOrSiteChangedAsync(); }">
```
(same for Site, which additionally resets `_createAreaId = 0`). Also run `OnTemplateOrSiteChangedAsync()` at the end of `OnInitializedAsync` when `SiteId` came from the query string and a template is preselected (it won't be — keep the call; it no-ops when either is 0).
**Step 2: Build**
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI`
Expected: 0 errors.
**Step 3: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs
git commit -m "feat(centralui): Create Instance — preview-flatten state + group inputs behind the bindings card"
```
---
### Task 5: Create page — Connection Bindings card markup + Browse
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by 3, 4)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs` (browser plumbing)
- Reference only: `InstanceConfigure.razor` lines 88-175 (group header markup to mirror), `InstanceConfigure.razor.css` (`.binding-panel`, `.binding-group-header` — reuse by copying the two rules into a new `InstanceCreate.razor.css`)
**Step 1: Markup** — insert after the form card, before the error/buttons (move the Create/Cancel button group into the new card's footer when the card is visible; keep it in the form card when it is not):
```razor
@if (BindingsCardVisible)
{
<div class="card mb-3 binding-panel" data-test="create-bindings-card">
<div class="card-header py-2 d-flex align-items-center gap-3 flex-wrap">
<strong>Connection Bindings</strong>
@if (_previewLoading) { <span class="spinner-border spinner-border-sm" role="status"></span> }
else { <span class="text-muted small">@_rowCount data-sourced attribute(s) in @_groups.Count group(s)</span> }
<span class="ms-auto"></span>
@if (UnboundGroups > 0 && !_skipBindings) { <span class="badge text-bg-warning">@UnboundGroups group(s) unbound</span> }
<span class="badge border text-muted">preview — nothing saved yet</span>
</div>
@if (_previewError is not null)
{
<div class="alert alert-warning rounded-0 mb-0 py-2 small" data-test="create-bindings-preview-error">
Could not preview this template at the selected site (@_previewError). You can create the instance and bind on Configure.
</div>
}
else if (!_previewLoading && _rowCount == 0)
{
<div class="card-body text-muted small">Nothing to bind in this template.</div>
}
else if (_siteConnections.Count == 0)
{
<div class="card-body text-muted small">This site has no data connections — add one under Sites, or create now and bind later.</div>
}
else if (!_skipBindings)
{
<div class="card-body border-bottom py-2 d-flex align-items-center gap-2 flex-wrap small">
<span>Bind all to</span>
<select class="form-select form-select-sm" style="width:auto" data-test="create-root-connection" @bind="_rootConnectionId">
<option value="0">Select connection...</option>
@foreach (var c in _siteConnections) { <option value="@c.Id">@c.Name (@c.Protocol)</option> }
</select>
<input class="form-control form-control-sm font-monospace" style="max-width:260px" data-test="create-root-object"
@bind="_rootObject" placeholder="root object, e.g. Z28063" />
<button class="btn btn-outline-primary btn-sm" disabled="@(!IsBrowsable(_rootConnectionId))" @onclick="() => OpenBrowser(null)">Browse…</button>
<button class="btn btn-outline-primary btn-sm" data-test="create-prefill" disabled="@(_rootConnectionId == 0)" @onclick="PrefillGroups">Prefill groups</button>
<span class="text-muted">Fills the connection on every group and suggests an Object for blanks; never overwrites an Object you typed.</span>
</div>
@foreach (var g in _groups)
{
var input = _inputs.GetValueOrDefault(g.Key);
var source = CreateBindingPlanner.SourceOf(g, input);
<div class="binding-group-header d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-test="create-binding-group">
<span class="fw-semibold">@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" value="@(input?.ConnectionId ?? 0)"
@onchange="e => SetGroupConnection(g, int.TryParse(e.Value?.ToString(), out var v) ? v : 0)">
<option value="0">— 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" value="@input?.Object"
placeholder="object, e.g. @(g.IsInstanceGroup ? "Z28063" : g.Key)"
@onchange="e => SetGroupObject(g, e.Value?.ToString())" />
<button class="btn btn-outline-primary btn-sm" disabled="@(!IsBrowsable(input?.ConnectionId ?? 0))" @onclick="() => OpenBrowser(g)">Browse…</button>
@if (source == CreateBindingPlanner.ObjectSource.Suggested) { <span class="badge text-bg-secondary" title="Suggested from the root object; edit if the object is a sibling">suggested</span> }
else if (source == CreateBindingPlanner.ObjectSource.FromTemplate) { <span class="badge text-bg-secondary" title="The composed template's defaults already carry this object">from template</span> }
@if (IsGroupBound(g)) { <span class="badge text-bg-success">bound</span> }
else { <span class="badge text-bg-danger">@g.Members.Count unbound</span> }
</div>
}
}
<div class="card-footer d-flex align-items-center gap-2 flex-wrap">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-success" @onclick="CreateInstance" disabled="@_previewLoading">Create instance</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
<label class="form-check form-check-inline mb-0 small ms-2"><input class="form-check-input" type="checkbox" @bind="_skipBindings" /> Skip bindings — I'll bind on Configure</label>
<span class="text-muted small ms-auto">
@if (_skipBindings) { <text>Creates the instance without bindings.</text> }
else { <text>Creates the instance, saves @BoundRowCount binding(s), then opens Configure.@(UnboundGroups > 0 ? $" {UnboundGroups} group(s) stay unbound — Deploy is rejected until they are bound." : "")</text> }
</span>
</div>
</div>
}
<NodeBrowserDialog @ref="_browserRef" SiteId="@(_siteIdentifier ?? "")" ConnectionName="@_browserConnectionName"
InitialNodeId="@_browserInitial" SelectableNodeClasses="@ObjectAndVariable" OnSelected="OnBrowserSelected" />
```
Code-behind additions:
```csharp
private static readonly BrowseNodeClass[] ObjectAndVariable = [BrowseNodeClass.Object, BrowseNodeClass.Variable];
private NodeBrowserDialog? _browserRef;
private string _browserConnectionName = "";
private string? _browserInitial;
private BindingGroup? _browserTarget; // null = the root picker
private bool IsBrowsable(int connectionId)
{
var protocol = _siteConnections.FirstOrDefault(c => c.Id == connectionId)?.Protocol;
return string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase)
|| string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase);
}
private async Task OpenBrowser(BindingGroup? target)
{
_browserTarget = target;
var connId = target is null ? _rootConnectionId : _inputs.GetValueOrDefault(target.Key)?.ConnectionId ?? 0;
_browserConnectionName = _siteConnections.First(c => c.Id == connId).Name;
_browserInitial = string.IsNullOrWhiteSpace(_rootObject) ? null : _rootObject;
if (_browserRef is not null) await _browserRef.ShowAsync(_siteIdentifier ?? "", _browserConnectionName, _browserInitial);
}
private void OnBrowserSelected(string nodeId)
{
if (_browserTarget is null) _rootObject = nodeId;
else SetGroupObject(_browserTarget, CreateBindingPlanner.ObjectFromPick(_browserTarget, nodeId));
}
```
Check `NodeBrowserDialog.ShowAsync`'s exact signature in the dialog (it is `ShowAsync(siteId, connectionName, initialNodeId)` per the Configure page / tests) and the `@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol` for `BrowseNodeClass`.
**Step 2: Build + existing suites**
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI && dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "NodeBrowserDialog|BindingGroupBuilder|CreateBindingPlanner"`
Expected: 0 errors, PASS.
**Step 3: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.css
git commit -m "feat(centralui): Create Instance — Connection Bindings card (per-group connection/Object/Browse, Bind-all prefill)"
```
---
### Task 6: Create → bindings → navigate to Configure; Configure one-shot notice
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (blocked by 5)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor` (`CreateInstance`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor` (query params + notice above the Connection Bindings panel, ~line 88)
**Step 1: Rewrite `CreateInstance`** (keep the validation + site-scope re-check as is):
```csharp
try
{
var user = await GetCurrentUserAsync();
var result = await InstanceService.CreateInstanceAsync(
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
if (result.IsFailure) { _formError = result.Error; return; }
var id = result.Value.Id;
var bindings = _skipBindings || _previewError is not null
? Array.Empty<ConnectionBinding>()
: CreateBindingPlanner.BuildBindings(_groups, _inputs);
if (bindings.Count == 0) { NavigationManager.NavigateTo($"/deployment/instances/{id}/configure?created=1"); return; }
// The instance now exists whatever happens next — always land on Configure,
// carrying the binding error so the operator is not left on a stale form.
var bind = await InstanceService.SetConnectionBindingsAsync(id, bindings, user);
var query = bind.IsSuccess
? $"created=1&bound={bindings.Count}"
: $"created=1&bindError={Uri.EscapeDataString(Truncate(bind.Error, 300))}";
NavigationManager.NavigateTo($"/deployment/instances/{id}/configure?{query}");
}
catch (Exception ex) { _formError = $"Create failed: {ex.Message}"; }
```
with `private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n] + "…";`. Note `SetConnectionBindingsAsync` can throw (network/actor timeout) after the instance exists — wrap just that call in its own try/catch that sets `bindError` to the exception message, so the outer catch only covers create.
**Step 2: Configure notice** — add `[SupplyParameterFromQuery] public int? Created { get; set; }`, `public int? Bound { get; set; }`, `public string? BindError { get; set; }` and, directly above the `binding-panel` card:
```razor
@if (Created == 1)
{
<div class="alert @(BindError is null ? "alert-success" : "alert-warning") py-2 small" data-test="configure-created-notice">
Instance created.
@if (BindError is not null) { <text>Bindings were NOT saved: @BindError — bind the groups below.</text> }
else if (Bound is > 0) { <text>@Bound binding(s) saved. Test Bindings, then Deploy.</text> }
else { <text>Bind the groups below, then Deploy.</text> }
</div>
}
```
**Step 3: Build**
Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI`
Expected: 0 errors.
**Step 4: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
git commit -m "feat(centralui): Create saves bindings (upsert) and lands on Configure with a created/bound/bindError notice"
```
---
### Task 7: Live check on the docker rig
**Classification:** standard
**Estimated implement time:** ~5 min (plus image build)
**Parallelizable with:** Task 8
**Files:** none (verification). Uses `docker/build.sh` from the worktree, `docker compose -f /Users/dohertj2/Desktop/ScadaBridge/docker/docker-compose.yml up -d --force-recreate`, CLI per `.claude/skills/scadabridge-cluster-ops`.
**Steps:**
1. Build + recreate the rig from the worktree (not `deploy.sh`).
2. Pick a composed template on the rig (CLI `template list`; the SoakNotify/gcb test templates from the 2026-08-20 session, or create a 2-module composed template via CLI if none exists).
3. In the browser (`http://localhost:9000`, `multi-role`/`password`): `/deployment/instances/create` → choose the template + site-a → the card shows N groups. Set Bind all to the site's OPC UA connection, root `ns=2;s=Demo` (or whatever the rig's OPC UA server exposes — check an existing bound instance's reference on Configure), Prefill, correct one module Object via Browse… selecting an **Object** node, Create.
4. Expect: lands on `/deployment/instances/{id}/configure?created=1&bound=N` with the green notice; the Connection Bindings panel shows the same groups bound; `Test Bindings (N)` enabled; `deploy instance` via CLI passes the binding-completeness gate (or reports only the groups deliberately left unbound).
5. Also create one instance with **Skip bindings** ticked → Configure shows "Bind the groups below" and every row unbound (today's behaviour preserved).
6. Delete the test instances (`instance delete`). Record findings in the plan's status section.
---
### Task 8: Docs
**Classification:** trivial
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 7
**Files:**
- Modify: `docs/requirements/Component-CentralUI.md` (Create Instance / Connection Bindings section — add the create-time card, Prefill semantics, Browse object selection, navigate-to-Configure)
- Modify: `docs/requirements/Component-DeploymentManager.md` (`IFlatteningPipeline.PreviewAsync`, one paragraph: template+site preview, no validation, no persistence)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (one line under `instance create`: bindings can be set at creation in the UI; CLI equivalent is `instance set-bindings` / `bind-module`)
- Modify: `docs/plans/2026-08-21-create-instance-bindings-design.md` (status → implemented, link to this plan)
- Modify: `CLAUDE.md` Templates & Deployment bullet list — one sentence after the grouped-bindings material: "Create Instance page binds module groups at creation via `IFlatteningPipeline.PreviewAsync` (template+site flatten of an unsaved instance); `NodeBrowserDialog.SelectableNodeClasses` lets it pick Object nodes (default stays Variable-only)."
Commit: `docs: create-time connection bindings (Central UI, DeploymentManager preview flatten, CLI cross-ref)`.
---
### Task 9: Solution build + impacted suites
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by all)
Run:
```bash
dotnet build ZB.MOM.WW.ScadaBridge.slnx
dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests
dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter Flatten
```
Expected: 0 errors, all green (the known env-only Playwright E2E project is not in this list). Then `git log --oneline main..HEAD` shows 7 commits; hand off via superpowers-extended-cc:finishing-a-development-branch.
---
## Status — implemented 2026-08-21
All 9 tasks done on branch `create-instance-bindings`. **Tasks 4 and 5 landed in one
commit**: Task 4's card-state fields are write-only until Task 5's markup reads them and
the CentralUI project treats unused-field warnings as errors, so Task 4's "build → 0
errors" gate is unachievable in isolation (plan defect). Task 4's instruction to move the
existing `@code` fields into the partial was skipped — a partial class already shares
private members across files. The card also drops the `binding-panel` class the Configure
panel carries: it has no CSS rule anywhere in the repo.
### Task 7 — live check on the docker rig (PASS)
Image built with `docker/build.sh` from the worktree, rig recreated with
`docker compose -f <main>/docker/docker-compose.yml up -d --force-recreate`; central
`/health/ready` Healthy in ~20s. Driven in Chrome at `http://localhost:9000` as
`multi-role`. Template **Motor Controller** (id 4, 3 compositions) at **Test Plant A**
(site 1, `OPC PLC Simulator`, OpcUa).
1. **Card appears on template+site.** `30 data-sourced attribute(s) in 4 group(s)` —
`(instance)` 13, `CoolingTank` 7 *(incl. DrivePump, DrivePump.AlarmSensor,
DrivePump.TempSensor)*, `CoolingTank2` 7, `Pump` 3 *(incl. AlarmSensor, TempSensor)*.
Identical grouping to the Configure page, nested modules folded. `4 group(s) unbound`,
footer "saves 0 binding(s)".
2. **Object-node selection works.** Root `Browse…` opened the picker with the new
"Select an object or tag" prompt; every root node (`OpcPlc`, `Boilers`, `Locations`, …)
rendered as a *clickable* link — all Objects, plain text before this change. Picked
`nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=5`; it landed in the root Object box.
3. **Prefill.** Every group took the connection; `(instance)` took the root verbatim (no
pill); the three module groups got `{root}.{groupKey}` with a **suggested** pill. All
four → **bound**, the unbound badge cleared, footer "saves 30 binding(s)".
4. **Per-group Browse.** `Pump` → picked the `Locations` Object (`i=31915`); the value
replaced the suggestion and the **suggested** pill disappeared (a browse pick is a
typed value, per `SetGroupObject`).
5. **Create.** Landed on `/deployment/instances/3660/configure?created=1&bound=30` with
the green notice *"Instance created. 30 binding(s) saved. Test Bindings, then Deploy."*;
Configure's panel showed the same 4 groups all **bound**, Pump carrying `i=31915`, and
**Test Bindings (30)** enabled.
6. **Skip bindings.** Second instance with the box ticked → group rows collapse, footer
"Creates the instance without bindings", lands on `?created=1` (no `bound`) with
*"Instance created. Bind the groups below, then Deploy."*, `30 unbound`, Test Bindings (0).
Pre-2026-08-21 behaviour preserved exactly.
7. **Deploy gate A/B — the decisive check.**
- id 3660 (bound at creation): `4 errors — ScriptCompilation (4)`, **zero ConnectionBinding**.
- id 3661 (skipped): `34 errors — ScriptCompilation (4) + ConnectionBinding (30)`.
The create-time bindings fully satisfy `ValidateConnectionBindingCompleteness`. The 4
ScriptCompilation errors are a **pre-existing property of the rig's Motor Controller
template** (identical on both instances, unrelated to this work).
8. Both test instances deleted (`instance delete --id 3660 / 3661`).
### Task 9 — solution build + suites (PASS)
```
dotnet build ZB.MOM.WW.ScadaBridge.slnx 0 Warning(s), 0 Error(s)
dotnet test tests/…DeploymentManager.Tests Passed 148, Failed 0
dotnet test tests/…CentralUI.Tests Passed 1130, Failed 0
dotnet test tests/…TemplateEngine.Tests --filter Flatten Passed 108, Failed 0
```
@@ -0,0 +1,78 @@
{
"planPath": "docs/plans/2026-08-21-create-instance-bindings.md",
"tasks": [
{
"id": 1,
"subject": "Task 1: IFlatteningPipeline.PreviewAsync (DeploymentManager)",
"status": "completed"
},
{
"id": 2,
"subject": "Task 2: CreateBindingPlanner \u2014 Prefill + tail stripping + binding list (pure)",
"status": "completed"
},
{
"id": 3,
"subject": "Task 3: NodeBrowserDialog.SelectableNodeClasses (additive)",
"status": "completed"
},
{
"id": 4,
"subject": "Task 4: Create page \u2014 code-behind state + preview load",
"status": "completed",
"blockedBy": [
1,
2
]
},
{
"id": 5,
"subject": "Task 5: Create page \u2014 Connection Bindings card markup + Browse",
"status": "completed",
"blockedBy": [
3,
4
]
},
{
"id": 6,
"subject": "Task 6: Create \u2192 bindings \u2192 navigate to Configure; Configure one-shot notice",
"status": "completed",
"blockedBy": [
5
]
},
{
"id": 7,
"subject": "Task 7: Live check on the docker rig",
"status": "completed",
"blockedBy": [
6
]
},
{
"id": 8,
"subject": "Task 8: Docs",
"status": "completed",
"blockedBy": [
6
]
},
{
"id": 9,
"subject": "Task 9: Solution build + impacted suites",
"status": "completed",
"blockedBy": [
1,
2,
3,
4,
5,
6,
7,
8
]
}
],
"lastUpdated": "2026-08-21T10:20:00Z"
}
+6
View File
@@ -118,6 +118,12 @@ Central cluster only. Sites have no user interface.
### Instance Management (Deployment Role)
- Create instances from templates at a specific site.
- **Connection Bindings at creation** (`InstanceCreate`, design `docs/plans/2026-08-21-create-instance-bindings-design.md`). Once a template AND a site are chosen, the create form grows a Connection Bindings card showing the SAME module groups the Configure page shows, so one Create can yield a deployable instance instead of always landing on Configure with 100% of the rows unbound. The groups come from `IFlatteningPipeline.PreviewAsync(templateId, siteId)` — a flatten of an *unsaved* instance with no overrides and no bindings — fed through the same `BindingGroupBuilder` Configure uses. **Group headers only**: per-member editing stays on Configure.
- **Bind all to** — a connection + root object, then **Prefill groups** sets the connection on every group and fills *blank* Objects only: the `(instance)` group gets the root verbatim; a module group whose template defaults already carry a shared prefix keeps it (pill: *from template*); any other module group is suggested `{root}.{groupKey}` (pill: *suggested*). An Object the operator typed is never overwritten. Provenance pills exist because a suggestion is a guess about the address space — a sibling-object layout needs correcting before Create.
- **Browse…** per group (and for the root) opens the shared `NodeBrowserDialog` with `SelectableNodeClasses = {Object, Variable}` — the create page binds a module to its **container** node, which the Variable-only default could not select. Picking a leaf *under* the module still works: `CreateBindingPlanner.ObjectFromPick` strips a trailing `.{memberTail}` matching any member of that group. Enabled only for browsable protocols (OPC UA, MxGateway), the same gate Configure applies.
- **Partial binding is legal.** A group with both a connection and a non-blank Object contributes `{object}.{memberTail}` for every member; a group missing either is simply skipped and stays unbound for Configure. The footer states the count that will be saved and how many groups stay unbound (and that Deploy is rejected until they are bound). A **Skip bindings** checkbox restores the pre-2026-08-21 behaviour exactly.
- **Create is two calls, and the instance survives the second failing.** `CreateInstanceAsync` then `SetConnectionBindingsAsync` (an upsert — the same command the Configure page's Save uses; no new server-side surface). The page then ALWAYS navigates to `/deployment/instances/{id}/configure?created=1` plus either `&bound=N` or `&bindError=<message>`, and Configure renders a one-shot notice from those query params. Leaving the operator on a stale create form after the row already exists would invite a duplicate-name retry.
- A preview flatten that fails (or throws) degrades to a warning inside the card and suppresses the binding save — the instance is still creatable, and Configure remains the authority.
- 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. **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.
@@ -252,6 +252,18 @@ remains the authoritative compile**: instance deployment re-flattens with
`validateScripts: true` (the default), and a script that fails to compile blocks
the deployment (see Site-Side Apply Atomicity).
### Preview flatten (no instance row)
`IFlatteningPipeline.PreviewAsync(templateId, siteId)` flattens a template against
a site **as if** an instance with no overrides and no bindings existed there. It
returns the `FlattenedConfiguration` alone — no validation, no revision hash,
nothing persisted, and the instance repository is never touched. It exists for the
Central UI's Create Instance page, which needs the instance's module groups (and
therefore a real flatten, composed members included) *before* the instance row is
created; see `Component-CentralUI.md` → Instance Management. It shares the template
chain + nested-composition walk with `FlattenAndValidateAsync` (`LoadTemplateGraphAsync`),
so the two cannot drift, and each call gets a private `FlattenSession`.
## Deployable Artifacts
A deployment to a site includes the flattened instance configuration plus any system-wide artifacts that have changed:
+5
View File
@@ -561,6 +561,11 @@ scadabridge --url <url> instance create --name <string> --template-id <int> --si
| `--site-id` | yes | Site where the instance will run |
| `--area-id` | no | Area within the site |
The command creates the instance with **no** connection bindings. The Central UI's
Create Instance page can bind the module groups at creation time (design
2026-08-21); the CLI equivalent is a follow-up `instance set-bindings` /
`instance bind-module` against the new instance id.
#### `instance native-alarm-source set`
Override an inherited native alarm source binding for a single instance (upsert). Blank
@@ -56,12 +56,24 @@
@* A button, not an href="javascript:void(0)" anchor:
this selects a node rather than navigating, and the
pseudo-href was a CSP/a11y smell. Matches the tree
label in TreeRow.razor. *@
<button type="button"
class="btn btn-link p-0 text-start align-baseline @(node.NodeId == _selectedNodeId ? "fw-bold text-primary" : "")"
@onclick="() => SelectBrowseNode(node)">
@node.DisplayName
</button>
label in TreeRow.razor. A node class the caller does
not accept renders as plain text — nothing to click. *@
@if (IsSelectable(node.NodeClass))
{
<button type="button"
class="btn btn-link p-0 text-start align-baseline @(node.NodeId == _selectedNodeId ? "fw-bold text-primary" : "")"
@onclick="() => SelectBrowseNode(node)">
@node.DisplayName
</button>
@if (node.NodeClass == BrowseNodeClass.Object)
{
<span class="badge bg-body-secondary text-body-secondary border ms-1">object</span>
}
}
else
{
<span class="text-muted">@node.DisplayName</span>
}
@if (!string.IsNullOrEmpty(node.DataType))
{
<span class="badge bg-body-secondary text-body-secondary border ms-1">@node.DataType</span>
@@ -90,7 +102,7 @@
<ul class="list-unstyled mb-0">
@foreach (var node in _rootNodes)
{
<TreeRow Node="node" OnToggle="ToggleAsync" OnSelect="Select" OnLoadMore="LoadMoreAsync" SelectedNodeId="@_selectedNodeId" />
<TreeRow Node="node" OnToggle="ToggleAsync" OnSelect="Select" OnLoadMore="LoadMoreAsync" SelectedNodeId="@_selectedNodeId" Selectable="IsSelectable" />
}
</ul>
}
@@ -113,6 +125,10 @@
}
</div>
<div class="modal-footer">
@if (SelectableNodeClasses.Contains(BrowseNodeClass.Object))
{
<span class="text-muted small me-3">Select an object or tag</span>
}
<span class="me-auto text-muted">Selected: <code>@(_selectedNodeId ?? "(none)")</code></span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
@@ -147,6 +163,15 @@
/// always fail. Default true preserves OPC UA behaviour.</summary>
[Parameter] public bool ShowSearch { get; set; } = true;
/// <summary>
/// Additive: node classes the picker accepts. Default Variable-only (every
/// existing caller). The create page passes Object+Variable so a module's
/// container can be chosen (design 2026-08-21).
/// </summary>
[Parameter] public IReadOnlyCollection<BrowseNodeClass> SelectableNodeClasses { get; set; } = new[] { BrowseNodeClass.Variable };
private bool IsSelectable(BrowseNodeClass nodeClass) => SelectableNodeClasses.Contains(nodeClass);
private bool _isVisible;
private string? _selectedNodeId;
private BrowseNode? _selectedNode;
@@ -355,7 +380,7 @@
// Select button confirms and OnSelected fires with this node id.
private void SelectBrowseNode(BrowseNode node)
{
if (node.NodeClass != BrowseNodeClass.Variable) return;
if (!IsSelectable(node.NodeClass)) return;
_selectedNodeId = node.NodeId;
_manualNodeId = node.NodeId;
_selectedNode = node;
@@ -363,7 +388,7 @@
private void Select(TreeNode node)
{
if (node.NodeClass != BrowseNodeClass.Variable) return;
if (!IsSelectable(node.NodeClass)) return;
_selectedNodeId = node.NodeId;
_manualNodeId = node.NodeId;
_selectedNode = node.Source;
@@ -11,7 +11,7 @@
}
</span>
@if (Node.NodeClass == BrowseNodeClass.Variable)
@if (IsSelectable)
{
@* Display name (+ type badge) on the first line, the node id clipped
beneath it: an OPC UA node id runs 60–120 characters and inline it
@@ -49,7 +49,7 @@
<ul class="list-unstyled ms-4">
@foreach (var child in Node.Children)
{
<TreeRow Node="child" OnToggle="OnToggle" OnSelect="OnSelect" OnLoadMore="OnLoadMore" SelectedNodeId="@SelectedNodeId" />
<TreeRow Node="child" OnToggle="OnToggle" OnSelect="OnSelect" OnLoadMore="OnLoadMore" SelectedNodeId="@SelectedNodeId" Selectable="Selectable" />
}
@if (!string.IsNullOrEmpty(Node.ContinuationToken))
{
@@ -74,4 +74,14 @@
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnSelect { get; set; }
[Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnLoadMore { get; set; }
[Parameter] public string? SelectedNodeId { get; set; }
/// <summary>
/// Additive: which node classes the picker accepts, supplied by
/// <see cref="NodeBrowserDialog.SelectableNodeClasses"/>. Null keeps the historical
/// Variable-only behaviour. The expand toggle renders regardless, so a selectable
/// Object node can still be drilled into.
/// </summary>
[Parameter] public Func<BrowseNodeClass, bool>? Selectable { get; set; }
private bool IsSelectable => Selectable?.Invoke(Node.NodeClass) ?? Node.NodeClass == BrowseNodeClass.Variable;
}
@@ -81,6 +81,28 @@
</div>
</div>
@* One-shot notice for an instance that just arrived from the Create page
(design 2026-08-21). Query-string only — nothing is persisted, so it
disappears on any later visit. *@
@if (Created == 1)
{
<div class="alert @(BindError is null ? "alert-success" : "alert-warning") py-2 small" data-test="configure-created-notice">
Instance created.
@if (BindError is not null)
{
<text>Bindings were NOT saved: @BindError — bind the groups below.</text>
}
else if (Bound is > 0)
{
<text>@Bound binding(s) saved. Test Bindings, then Deploy.</text>
}
else
{
<text>Bind the groups below, then Deploy.</text>
}
</div>
}
@* 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
@@ -710,6 +732,13 @@
@code {
[Parameter] public int Id { get; set; }
// Set by the Create Instance page's post-create redirect (design 2026-08-21):
// Created=1 always, then either the count of bindings saved or the error that
// stopped them being saved. Read-only display — never persisted.
[SupplyParameterFromQuery] public int? Created { get; set; }
[SupplyParameterFromQuery] public int? Bound { get; set; }
[SupplyParameterFromQuery] public string? BindError { get; set; }
private Instance? _instance;
private string _templateName = "";
private string _siteName = "";
@@ -3,13 +3,19 @@
@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.Protocol
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@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
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject InstanceService InstanceService
@inject IFlatteningPipeline FlatteningPipeline
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@@ -33,7 +39,9 @@
</div>
<div class="mb-2">
<label class="form-label small">Template</label>
<select class="form-select form-select-sm" @bind="_createTemplateId">
<select class="form-select form-select-sm" data-test="create-template"
value="@_createTemplateId"
@onchange="async e => { _createTemplateId = int.TryParse(e.Value?.ToString(), out var v) ? v : 0; await OnTemplateOrSiteChangedAsync(); }">
<option value="0">Select template...</option>
@foreach (var t in _templates)
{
@@ -43,7 +51,9 @@
</div>
<div class="mb-2">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_createSiteId">
<select class="form-select form-select-sm" data-test="create-site"
value="@_createSiteId"
@onchange="async e => { _createSiteId = int.TryParse(e.Value?.ToString(), out var v) ? v : 0; _createAreaId = 0; await OnTemplateOrSiteChangedAsync(); }">
<option value="0">Select site...</option>
@foreach (var s in _sites)
{
@@ -65,12 +75,155 @@
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="btn-group btn-group-sm mt-3" role="group" aria-label="Create instance actions">
<button class="btn btn-success" @onclick="CreateInstance">Create</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
@if (!BindingsCardVisible)
{
<div class="btn-group btn-group-sm mt-3" role="group" aria-label="Create instance actions">
<button class="btn btn-success" @onclick="CreateInstance">Create</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
}
</div>
</div>
@* Connection Bindings — the same module groups the Configure page shows, built
from a template+site PREVIEW flatten (no instance row exists yet). Group
headers only: per-member editing stays on Configure. Nothing is saved until
Create. Design: docs/plans/2026-08-21-create-instance-bindings-design.md *@
@if (BindingsCardVisible)
{
<div class="card mb-3" data-test="create-bindings-card">
<div class="card-header py-2 d-flex align-items-center gap-3 flex-wrap">
<strong>Connection Bindings</strong>
@if (_previewLoading)
{
<span class="spinner-border spinner-border-sm" role="status"></span>
}
else
{
<span class="text-muted small">@_rowCount data-sourced attribute(s) in @_groups.Count group(s)</span>
}
<span class="ms-auto"></span>
@if (UnboundGroups > 0 && !_skipBindings)
{
<span class="badge text-bg-warning" data-test="create-unbound-groups">@UnboundGroups group(s) unbound</span>
}
<span class="badge border text-muted">preview — nothing saved yet</span>
</div>
@if (_previewError is not null)
{
<div class="alert alert-warning rounded-0 mb-0 py-2 small" data-test="create-bindings-preview-error">
Could not preview this template at the selected site (@_previewError). You can create the instance and bind on Configure.
</div>
}
else if (!_previewLoading && _rowCount == 0)
{
<div class="card-body text-muted small">Nothing to bind in this template.</div>
}
else if (_siteConnections.Count == 0)
{
<div class="card-body text-muted small">This site has no data connections — add one under Sites, or create now and bind later.</div>
}
else if (!_skipBindings)
{
<div class="card-body border-bottom py-2 d-flex align-items-center gap-2 flex-wrap small">
<span>Bind all to</span>
<select class="form-select form-select-sm" style="width: auto;" data-test="create-root-connection" @bind="_rootConnectionId">
<option value="0">Select connection...</option>
@foreach (var c in _siteConnections)
{
<option value="@c.Id">@c.Name (@c.Protocol)</option>
}
</select>
<input class="form-control form-control-sm font-monospace" style="max-width: 260px;" data-test="create-root-object"
@bind="_rootObject" placeholder="root object, e.g. Z28063" />
<button class="btn btn-outline-primary btn-sm" data-test="create-root-browse"
disabled="@(!IsBrowsable(_rootConnectionId))" @onclick="() => OpenBrowser(null)">Browse…</button>
<button class="btn btn-outline-primary btn-sm" data-test="create-prefill"
disabled="@(_rootConnectionId == 0)" @onclick="PrefillGroups">Prefill groups</button>
<span class="text-muted">Fills the connection on every group and suggests an Object for blanks; never overwrites an Object you typed.</span>
</div>
@foreach (var g in _groups)
{
var input = _inputs.GetValueOrDefault(g.Key);
var source = CreateBindingPlanner.SourceOf(g, input);
<div class="binding-group-header d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-test="create-binding-group">
<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="create-group-connection"
value="@(input?.ConnectionId ?? 0)"
@onchange="e => SetGroupConnection(g, int.TryParse(e.Value?.ToString(), out var v) ? v : 0)">
<option value="0">— 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="create-group-object"
value="@input?.Object"
placeholder="@(g.IsInstanceGroup ? "object, e.g. Z28063" : $"object, e.g. {g.Key}")"
title="Object path this module's members hang off; every member binds as <object>.<member>."
@onchange="e => SetGroupObject(g, e.Value?.ToString())" />
<button class="btn btn-outline-primary btn-sm" data-test="create-group-browse"
disabled="@(!IsBrowsable(input?.ConnectionId ?? 0))" @onclick="() => OpenBrowser(g)">Browse…</button>
@if (source == CreateBindingPlanner.ObjectSource.Suggested)
{
<span class="badge text-bg-secondary" title="Suggested from the root object; edit if the object is a sibling">suggested</span>
}
else if (source == CreateBindingPlanner.ObjectSource.FromTemplate)
{
<span class="badge text-bg-secondary" title="The composed template's defaults already carry this object">from template</span>
}
@if (IsGroupBound(g))
{
<span class="badge text-bg-success">bound</span>
}
else
{
<span class="badge text-bg-danger">@g.Members.Count unbound</span>
}
</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="Create instance actions">
<button class="btn btn-success" @onclick="CreateInstance" disabled="@_previewLoading">Create instance</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
<label class="form-check form-check-inline mb-0 small ms-2">
<input class="form-check-input" type="checkbox" data-test="create-skip-bindings" @bind="_skipBindings" />
Skip bindings — I'll bind on Configure
</label>
<span class="text-muted small ms-auto">
@if (_skipBindings)
{
<text>Creates the instance without bindings.</text>
}
else
{
<text>Creates the instance, saves @BoundRowCount binding(s), then opens Configure.@(UnboundGroups > 0 ? $" {UnboundGroups} group(s) stay unbound — Deploy is rejected until they are bound." : "")</text>
}
</span>
</div>
</div>
@* Address-space picker. SelectableNodeClasses adds Object so a module's
container node can be chosen, not just a leaf tag. *@
<NodeBrowserDialog @ref="_browserRef"
SiteId="@(_siteIdentifier ?? "")"
ConnectionName="@_browserConnectionName"
InitialNodeId="@_browserInitial"
SelectableNodeClasses="ObjectAndVariable"
OnSelected="OnBrowserSelected" />
}
}
</div>
@@ -113,6 +266,11 @@
{
_createAreaId = aid;
}
// The query string can preselect a site; a template never is, so this
// no-ops today — kept so a future preselect gets the card for free.
await OnTemplateOrSiteChangedAsync();
}
catch (Exception ex)
{
@@ -135,26 +293,60 @@
return;
}
string user;
int id;
try
{
var user = await GetCurrentUserAsync();
user = await GetCurrentUserAsync();
var result = await InstanceService.CreateInstanceAsync(
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/deployment/topology");
}
else
if (result.IsFailure)
{
_formError = result.Error;
return;
}
id = result.Value.Id;
}
catch (Exception ex)
{
_formError = $"Create failed: {ex.Message}";
return;
}
// From here the instance EXISTS. Whatever the binding save does, land on
// Configure rather than leaving the operator on a stale create form —
// carrying any binding error in the query so it is visible there.
var bindings = _skipBindings || _previewError is not null
? (IReadOnlyList<ConnectionBinding>)Array.Empty<ConnectionBinding>()
: CreateBindingPlanner.BuildBindings(_groups, _inputs);
if (bindings.Count == 0)
{
NavigationManager.NavigateTo($"/deployment/instances/{id}/configure?created=1");
return;
}
string? bindError = null;
try
{
var bind = await InstanceService.SetConnectionBindingsAsync(id, bindings, user);
if (bind.IsFailure) bindError = bind.Error;
}
catch (Exception ex)
{
// Network / actor timeout AFTER the instance was created.
bindError = ex.Message;
}
var query = bindError is null
? $"created=1&bound={bindings.Count}"
: $"created=1&bindError={Uri.EscapeDataString(Truncate(bindError, 300))}";
NavigationManager.NavigateTo($"/deployment/instances/{id}/configure?{query}");
}
/// <summary>Keeps a failure message short enough to ride in the query string.</summary>
private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n] + "…";
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
// CentralUI-024: delegates to the shared helper so the claim type stays
@@ -0,0 +1,160 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
/// <summary>
/// Code-behind for <c>InstanceCreate.razor</c> — the Connection Bindings card
/// (design 2026-08-21). The card flattens the chosen template AGAINST the chosen
/// site through <c>IFlatteningPipeline.PreviewAsync</c> (no instance row exists yet),
/// groups the data-sourced attributes with the same <see cref="BindingGroupBuilder"/>
/// the Configure page uses, and collects one connection + Object per group. The pure
/// prefill / tail-strip / binding-list logic lives in <see cref="CreateBindingPlanner"/>
/// so it is unit-pinned without rendering the page.
/// </summary>
public partial class InstanceCreate
{
// --- Connection Bindings card state (design 2026-08-21) ---
private IReadOnlyList<BindingGroup> _groups = Array.Empty<BindingGroup>();
private int _rowCount;
private List<DataConnection> _siteConnections = new();
private readonly Dictionary<string, CreateBindingPlanner.GroupInput> _inputs = new(StringComparer.Ordinal);
private int _rootConnectionId;
private string? _rootObject;
private bool _skipBindings;
private bool _previewLoading;
private string? _previewError;
private string? _siteIdentifier;
private bool BindingsCardVisible => _createTemplateId > 0 && _createSiteId > 0;
private int UnboundGroups => _groups.Count(g => !IsGroupBound(g));
private bool IsGroupBound(BindingGroup g)
=> _inputs.TryGetValue(g.Key, out var i) && i.ConnectionId > 0 && !string.IsNullOrWhiteSpace(i.Object);
private int BoundRowCount => _groups.Where(IsGroupBound).Sum(g => g.Members.Count);
/// <summary>
/// Called from the Template and Site selects' <c>@onchange</c>; re-runs the preview
/// flatten and rebuilds the groups. No-ops until both a template and a site are chosen.
/// </summary>
private async Task OnTemplateOrSiteChangedAsync()
{
_groups = Array.Empty<BindingGroup>();
_inputs.Clear();
_previewError = null;
_rowCount = 0;
if (!BindingsCardVisible) return;
_previewLoading = true;
try
{
var site = _sites.FirstOrDefault(s => s.Id == _createSiteId);
_siteIdentifier = site?.SiteIdentifier;
_siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_createSiteId)).ToList();
var preview = await FlatteningPipeline.PreviewAsync(_createTemplateId, _createSiteId);
if (preview.IsFailure)
{
_previewError = preview.Error;
return;
}
// Same predicate as the deploy gate (ValidateConnectionBindingCompleteness:
// DataSourceReference != null), so every row the gate will check has a row here.
var rows = preview.Value.Attributes
.Where(a => a.DefaultDataSourceReference is not null || a.DataSourceReference is not null)
.Select(a => new BindingRow(a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference, null, null))
.ToList();
_rowCount = rows.Count;
_groups = BindingGroupBuilder.Build(rows, _siteConnections.Select(c => c.Id).ToHashSet());
// Groups whose defaults already carry an object prefix start "from template" —
// the connection is still the operator's to pick.
foreach (var g in _groups)
{
if (!g.IsInstanceGroup && g.InferredObject is { Length: > 0 })
_inputs[g.Key] = new CreateBindingPlanner.GroupInput(0, g.InferredObject);
}
}
catch (Exception ex)
{
_previewError = ex.Message;
}
finally
{
_previewLoading = false;
}
}
private void PrefillGroups()
{
foreach (var kv in CreateBindingPlanner.Prefill(_groups, _inputs, _rootConnectionId, _rootObject))
_inputs[kv.Key] = kv.Value;
}
private void SetGroupConnection(BindingGroup g, int id)
=> _inputs[g.Key] = (_inputs.GetValueOrDefault(g.Key) ?? new CreateBindingPlanner.GroupInput(0, null))
with { ConnectionId = id };
private void SetGroupObject(BindingGroup g, string? obj)
=> _inputs[g.Key] = (_inputs.GetValueOrDefault(g.Key) ?? new CreateBindingPlanner.GroupInput(0, null))
with { Object = obj, Suggested = false };
// ── Address-space browser ───────────────────────────────────────────────
/// <summary>Object as well as Variable: a module group binds to a container node.</summary>
private static readonly BrowseNodeClass[] ObjectAndVariable = [BrowseNodeClass.Object, BrowseNodeClass.Variable];
private NodeBrowserDialog? _browserRef;
private string _browserConnectionName = "";
private string? _browserInitial;
private BindingGroup? _browserTarget; // null = the "Bind all to" root picker
/// <summary>
/// True when the selected data connection supports address-space browsing. OPC UA
/// and MxGateway both implement <c>IBrowsableDataConnection</c> site-side; other
/// protocols return a NotBrowsable failure, so the button stays disabled for them.
/// (Mirrors the Configure page's identical gate.)
/// </summary>
private bool IsBrowsable(int connectionId)
{
if (connectionId <= 0) return false;
var protocol = _siteConnections.FirstOrDefault(c => c.Id == connectionId)?.Protocol;
return string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase)
|| string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Opens the picker for one group, or for the root object when
/// <paramref name="target"/> is null. Remembers the target so
/// <see cref="OnBrowserSelected"/> writes the pick back to the right input.
/// </summary>
private async Task OpenBrowser(BindingGroup? target)
{
var connId = target is null ? _rootConnectionId : _inputs.GetValueOrDefault(target.Key)?.ConnectionId ?? 0;
var conn = _siteConnections.FirstOrDefault(c => c.Id == connId);
if (conn is null) return;
_browserTarget = target;
_browserConnectionName = conn.Name;
_browserInitial = string.IsNullOrWhiteSpace(_rootObject) ? null : _rootObject;
if (_browserRef is not null)
await _browserRef.ShowAsync(_siteIdentifier ?? "", conn.Name, _browserInitial);
}
/// <summary>
/// A group pick is normalised through <see cref="CreateBindingPlanner.ObjectFromPick"/>,
/// so selecting a leaf tag under the module still yields the module's object.
/// </summary>
private void OnBrowserSelected(string nodeId)
{
if (_browserTarget is null) _rootObject = nodeId;
else SetGroupObject(_browserTarget, CreateBindingPlanner.ObjectFromPick(_browserTarget, nodeId));
_browserTarget = null;
}
}
@@ -0,0 +1,14 @@
/* Connection Bindings — group header rows (mirrors InstanceConfigure.razor.css,
design 2026-08-21). Create shows group headers only, so no .binding-group rule. */
.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;
}
@@ -0,0 +1,129 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Pure logic behind the Create Instance page's Connection Bindings card
/// (design 2026-08-21): Prefill from a root object, Object inference from a
/// browser pick, and the binding list sent with Create. Groups come from
/// <see cref="BindingGroupBuilder.Build"/> over a template+site preview flatten.
/// No Blazor dependencies, so it is unit-testable without rendering the page.
/// </summary>
public static class CreateBindingPlanner
{
/// <summary>
/// Per-group operator input. <paramref name="ConnectionId"/> 0 = none.
/// <paramref name="Suggested"/> marks an Object that <see cref="Prefill"/> derived
/// from the root object rather than one the operator typed or the template carried —
/// it drives the row's provenance pill only, never the binding itself.
/// </summary>
public sealed record GroupInput(int ConnectionId, string? Object, bool Suggested = false);
/// <summary>Where a group's current Object value came from.</summary>
public enum ObjectSource { None, Typed, Suggested, FromTemplate }
/// <summary>
/// Sets <paramref name="connectionId"/> on every group and fills BLANK Objects only:
/// the instance group gets <paramref name="rootObject"/>; a module group whose
/// defaults already share a prefix keeps it (<see cref="BindingGroup.InferredObject"/>);
/// otherwise, when a root is given, <c>{root}.{groupKey}</c> is suggested. A typed
/// Object is never overwritten.
/// </summary>
/// <param name="groups">The groups built from the preview flatten.</param>
/// <param name="current">The card's current per-group inputs.</param>
/// <param name="connectionId">Data connection to apply to every group.</param>
/// <param name="rootObject">Root object path; blank leaves bare-default groups unset.</param>
/// <returns>The complete new input map — one entry per group.</returns>
public static IReadOnlyDictionary<string, GroupInput> Prefill(
IReadOnlyList<BindingGroup> groups,
IReadOnlyDictionary<string, GroupInput> current,
int connectionId,
string? rootObject)
{
var root = (rootObject ?? string.Empty).Trim().TrimEnd('.');
var result = new Dictionary<string, GroupInput>(StringComparer.Ordinal);
foreach (var g in groups)
{
current.TryGetValue(g.Key, out var existing);
var obj = existing?.Object;
var suggested = false;
if (string.IsNullOrWhiteSpace(obj))
{
if (g.IsInstanceGroup)
{
obj = root.Length == 0 ? null : root;
}
else if (g.InferredObject is { Length: > 0 } inferred)
{
obj = inferred;
}
else if (root.Length == 0)
{
obj = null;
}
else
{
obj = root + "." + g.Key;
suggested = true;
}
}
result[g.Key] = new GroupInput(connectionId, obj, suggested);
}
return result;
}
/// <summary>Where a group's current Object value came from, for the row's provenance pill.</summary>
/// <param name="group">The group the input belongs to.</param>
/// <param name="input">The group's current input, or null when untouched.</param>
/// <returns>The provenance of <paramref name="input"/>'s Object.</returns>
public static ObjectSource SourceOf(BindingGroup group, GroupInput? input)
{
var obj = input?.Object?.Trim().TrimEnd('.');
if (string.IsNullOrEmpty(obj)) return ObjectSource.None;
if (!group.IsInstanceGroup && string.Equals(obj, group.InferredObject, StringComparison.Ordinal))
return ObjectSource.FromTemplate;
return input!.Suggested ? ObjectSource.Suggested : ObjectSource.Typed;
}
/// <summary>
/// Object for a browser pick: an Object node is used as-is; a Variable whose id ends
/// with <c>.{memberTail}</c> of any member of the group has that tail stripped, so
/// picking a leaf under the module still binds the whole group.
/// </summary>
/// <param name="group">The group whose Browse… button was used.</param>
/// <param name="pickedNodeId">Node id the operator selected.</param>
/// <returns>The object path to put in the group's Object input.</returns>
public static string ObjectFromPick(BindingGroup group, string pickedNodeId)
{
foreach (var m in group.Members)
{
var suffix = "." + m.MemberTail;
if (pickedNodeId.EndsWith(suffix, StringComparison.Ordinal) && pickedNodeId.Length > suffix.Length)
return pickedNodeId[..^suffix.Length];
}
return pickedNodeId;
}
/// <summary>
/// The bindings to send with Create: every member of every group that has BOTH a
/// connection and a non-blank Object, rewritten via <see cref="BindingGroupBuilder.ApplyObject"/>.
/// Groups missing either are left unbound for Configure.
/// </summary>
/// <param name="groups">The groups built from the preview flatten.</param>
/// <param name="inputs">The card's per-group inputs.</param>
/// <returns>The binding list for <c>SetConnectionBindingsAsync</c>.</returns>
public static IReadOnlyList<ConnectionBinding> BuildBindings(
IReadOnlyList<BindingGroup> groups,
IReadOnlyDictionary<string, GroupInput> inputs)
{
var list = new List<ConnectionBinding>();
foreach (var g in groups)
{
if (!inputs.TryGetValue(g.Key, out var input) || input.ConnectionId <= 0 || string.IsNullOrWhiteSpace(input.Object))
continue;
foreach (var c in BindingGroupBuilder.ApplyObject(g, input.ConnectionId, input.Object))
list.Add(new ConnectionBinding(c.CanonicalName, c.ConnectionId!.Value, c.Override));
}
return list;
}
}
@@ -81,47 +81,11 @@ public class FlatteningPipeline : IFlatteningPipeline
if (instance == null)
return Result<FlatteningPipelineResult>.Failure($"Instance with ID {instanceId} not found.");
Task<Commons.Entities.Templates.Template?> LoadTemplate(int id) =>
_templateRepo.GetTemplateWithChildrenAsync(id, cancellationToken);
// Build template chain
var templateChain = await session.GetChainAsync(instance.TemplateId, LoadTemplate);
if (templateChain.Count == 0)
var graph = await LoadTemplateGraphAsync(instance.TemplateId, session, cancellationToken);
if (graph is null)
return Result<FlatteningPipelineResult>.Failure("Template chain is empty.");
// Build composition maps, walking nested compositions so the flattener
// can resolve composed-of-composed attributes / alarms / scripts (e.g.
// a parent that composes Pump where Pump itself composes AlarmSensor
// produces "Pump.AlarmSensor.SensorReading").
var compositionMap = new Dictionary<int, IReadOnlyList<Commons.Entities.Templates.TemplateComposition>>();
var composedChains = new Dictionary<int, IReadOnlyList<Commons.Entities.Templates.Template>>();
var processedTemplateIds = new HashSet<int>();
var pendingChains = new Queue<IReadOnlyList<Commons.Entities.Templates.Template>>();
pendingChains.Enqueue(templateChain);
while (pendingChains.Count > 0)
{
var chain = pendingChains.Dequeue();
foreach (var template in chain)
{
if (!processedTemplateIds.Add(template.Id)) continue;
var compositions = await session.GetCompositionsAsync(
template.Id,
id => _templateRepo.GetCompositionsByTemplateIdAsync(id, cancellationToken));
if (compositions.Count == 0) continue;
compositionMap[template.Id] = compositions;
foreach (var comp in compositions)
{
if (composedChains.ContainsKey(comp.ComposedTemplateId)) continue;
var composedChain = await session.GetChainAsync(comp.ComposedTemplateId, LoadTemplate);
composedChains[comp.ComposedTemplateId] = composedChain;
pendingChains.Enqueue(composedChain);
}
}
}
var (templateChain, compositionMap, composedChains) = graph.Value;
// Load data connections for the site
var dataConnections = await session.GetDataConnectionsAsync(
@@ -216,6 +180,86 @@ public class FlatteningPipeline : IFlatteningPipeline
new FlatteningPipelineResult(config, hash, validation));
}
/// <inheritdoc />
public async Task<Result<FlattenedConfiguration>> PreviewAsync(
int templateId,
int siteId,
CancellationToken cancellationToken = default)
{
var session = CreateSession();
var graph = await LoadTemplateGraphAsync(templateId, session, cancellationToken);
if (graph is null)
return Result<FlattenedConfiguration>.Failure($"Template {templateId} not found or its chain is empty.");
var dataConnections = await session.GetDataConnectionsAsync(
siteId,
id => LoadDataConnectionsAsync(id, cancellationToken));
// A stand-in instance with no overrides and no bindings: the flattener sees
// exactly what a freshly created instance would, and nothing is persisted.
var preview = new Commons.Entities.Instances.Instance("(preview)")
{
TemplateId = templateId,
SiteId = siteId
};
return _flatteningService.Flatten(
preview, graph.Value.Chain, graph.Value.CompositionMap, graph.Value.ComposedChains, dataConnections);
}
/// <summary>
/// Loads the template inheritance chain for <paramref name="templateId"/> plus the
/// composition maps, walking nested compositions so the flattener can resolve
/// composed-of-composed members (e.g. a parent composing Pump where Pump itself
/// composes AlarmSensor produces "Pump.AlarmSensor.SensorReading").
/// Returns <see langword="null"/> when the chain is empty (unknown template).
/// </summary>
private async Task<(IReadOnlyList<Commons.Entities.Templates.Template> Chain,
Dictionary<int, IReadOnlyList<Commons.Entities.Templates.TemplateComposition>> CompositionMap,
Dictionary<int, IReadOnlyList<Commons.Entities.Templates.Template>> ComposedChains)?>
LoadTemplateGraphAsync(int templateId, FlattenSession session, CancellationToken cancellationToken)
{
Task<Commons.Entities.Templates.Template?> LoadTemplate(int id) =>
_templateRepo.GetTemplateWithChildrenAsync(id, cancellationToken);
var templateChain = await session.GetChainAsync(templateId, LoadTemplate);
if (templateChain.Count == 0)
return null;
var compositionMap = new Dictionary<int, IReadOnlyList<Commons.Entities.Templates.TemplateComposition>>();
var composedChains = new Dictionary<int, IReadOnlyList<Commons.Entities.Templates.Template>>();
var processedTemplateIds = new HashSet<int>();
var pendingChains = new Queue<IReadOnlyList<Commons.Entities.Templates.Template>>();
pendingChains.Enqueue(templateChain);
while (pendingChains.Count > 0)
{
var chain = pendingChains.Dequeue();
foreach (var template in chain)
{
if (!processedTemplateIds.Add(template.Id)) continue;
var compositions = await session.GetCompositionsAsync(
template.Id,
id => _templateRepo.GetCompositionsByTemplateIdAsync(id, cancellationToken));
if (compositions.Count == 0) continue;
compositionMap[template.Id] = compositions;
foreach (var comp in compositions)
{
if (composedChains.ContainsKey(comp.ComposedTemplateId)) continue;
var composedChain = await session.GetChainAsync(comp.ComposedTemplateId, LoadTemplate);
composedChains[comp.ComposedTemplateId] = composedChain;
pendingChains.Enqueue(composedChain);
}
}
}
return (templateChain, compositionMap, composedChains);
}
private async Task<IReadOnlyDictionary<int, DataConnection>> LoadDataConnectionsAsync(
int siteId,
CancellationToken cancellationToken)
@@ -39,6 +39,21 @@ public interface IFlatteningPipeline
bool validateScripts = true,
FlattenSession? session = null);
/// <summary>
/// Flattens <paramref name="templateId"/> as if an instance with no overrides and no
/// bindings existed at <paramref name="siteId"/>. Attribute list only — no validation,
/// no revision hash, nothing persisted. Used by the Create Instance page to show the
/// module groups before the instance row exists.
/// </summary>
/// <param name="templateId">Id of the template to preview.</param>
/// <param name="siteId">Id of the target site, whose data connections resolve references.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The flattened configuration of the hypothetical instance, or a failure result.</returns>
Task<Result<FlattenedConfiguration>> PreviewAsync(
int templateId,
int siteId,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a fresh <see cref="FlattenSession"/> bound to this pipeline's
/// watermark, for a caller that wants to share one across a batch of
@@ -29,6 +29,9 @@ public class NodeBrowserDialogSelectionTests : BunitContext
new AddressSpaceMatch(
new BrowseNode("ns=2;s=Pump1.Speed", "Speed", BrowseNodeClass.Variable, HasChildren: false, DataType: "Double"),
Path: "Devices/Pump1/Speed"),
new AddressSpaceMatch(
new BrowseNode("ns=2;s=Pump1", "Pump1", BrowseNodeClass.Object, HasChildren: true),
Path: "Devices/Pump1"),
},
CapReached: false, Failure: null));
}
@@ -100,4 +103,45 @@ public class NodeBrowserDialogSelectionTests : BunitContext
Assert.Equal("Area1.Pump.Speed", captured!.NodeId);
Assert.Equal("Float", captured.DataType);
}
// SelectableNodeClasses (design 2026-08-21): the create page needs to pick a
// module's CONTAINER object, not just a leaf tag. Default stays Variable-only
// so every existing caller is unaffected.
[Fact]
public void Object_node_is_not_selectable_by_default()
{
var cut = Render<NodeBrowserDialog>(p => p
.Add(c => c.SiteId, "plant-a")
.Add(c => c.ConnectionName, "PLC-OPC"));
cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-OPC", null));
cut.Render();
cut.Find("[data-test=node-search-input]").Input("Pump1");
cut.Find("[data-test=node-search-button]").Click();
// Only the Variable renders a select button; the Object is plain text.
Assert.Single(cut.FindAll("[data-test=node-search-result] button.btn-link"));
}
[Fact]
public void Object_node_is_selectable_when_listed()
{
BrowseNode? captured = null;
var cut = Render<NodeBrowserDialog>(p => p
.Add(c => c.SiteId, "plant-a")
.Add(c => c.ConnectionName, "PLC-OPC")
.Add(c => c.SelectableNodeClasses, (IReadOnlyCollection<BrowseNodeClass>)new[] { BrowseNodeClass.Object, BrowseNodeClass.Variable })
.Add(c => c.OnNodeSelected, EventCallback.Factory.Create<BrowseNode>(this, n => captured = n)));
cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-OPC", null));
cut.Render();
cut.Find("[data-test=node-search-input]").Input("Pump1");
cut.Find("[data-test=node-search-button]").Click();
var buttons = cut.FindAll("[data-test=node-search-result] button.btn-link");
Assert.Equal(2, buttons.Count);
buttons.First(b => b.TextContent.Trim() == "Pump1").Click();
cut.Find(".modal-footer .btn-primary").Click();
Assert.Equal("ns=2;s=Pump1", captured!.NodeId);
Assert.Equal(BrowseNodeClass.Object, captured.NodeClass);
}
}
@@ -0,0 +1,84 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Pins the pure logic behind the Create Instance page's Connection Bindings card
/// (design 2026-08-21): Prefill from a root object, Object inference from a browser
/// pick, and the binding list sent with Create.
/// </summary>
public class CreateBindingPlannerTests
{
private static IReadOnlyList<BindingGroup> Groups() => BindingGroupBuilder.Build(
[
new BindingRow("Running", "Running", null, null), // (instance)
new BindingRow("Left.MoveInFlag", "MoveInFlag", null, null), // bare default → suggest
new BindingRow("Left.MoveOutFlag", "MoveOutFlag", null, null),
new BindingRow("Recv.Count", "MESReceiver_023.Count", null, null), // prefixed default → from template
new BindingRow("Recv.State", "MESReceiver_023.State", null, null),
]);
[Fact]
public void Prefill_fills_blank_objects_only()
{
var inputs = new Dictionary<string, CreateBindingPlanner.GroupInput>
{
["Left"] = new(0, "Typed_Left"),
};
var result = CreateBindingPlanner.Prefill(Groups(), inputs, connectionId: 7, rootObject: "Z28063");
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "Z28063"), result[BindingGroupBuilder.InstanceGroupKey]);
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "Typed_Left"), result["Left"]); // typed wins
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "MESReceiver_023"), result["Recv"]); // from template
Assert.Equal(CreateBindingPlanner.ObjectSource.Typed, CreateBindingPlanner.SourceOf(Groups()[1], result["Left"]));
Assert.Equal(CreateBindingPlanner.ObjectSource.FromTemplate, CreateBindingPlanner.SourceOf(Groups()[2], result["Recv"]));
}
[Fact]
public void Prefill_suggests_root_dot_module_for_bare_defaults()
{
var result = CreateBindingPlanner.Prefill(Groups(), new Dictionary<string, CreateBindingPlanner.GroupInput>(), 7, "Z28063");
Assert.Equal("Z28063.Left", result["Left"].Object);
Assert.Equal(CreateBindingPlanner.ObjectSource.Suggested, CreateBindingPlanner.SourceOf(Groups()[1], result["Left"]));
}
[Fact]
public void Prefill_with_blank_root_sets_connection_only()
{
var result = CreateBindingPlanner.Prefill(Groups(), new Dictionary<string, CreateBindingPlanner.GroupInput>(), 7, "");
Assert.Equal(new CreateBindingPlanner.GroupInput(7, null), result[BindingGroupBuilder.InstanceGroupKey]);
Assert.Equal(new CreateBindingPlanner.GroupInput(7, null), result["Left"]);
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "MESReceiver_023"), result["Recv"]);
}
[Theory]
[InlineData("Z28063.Left.MoveInFlag", "Z28063.Left")] // leaf under the group → strip tail
[InlineData("Z28063.Left", "Z28063.Left")] // object node → as-is
[InlineData("ns=2;s=Plant.Left.MoveOutFlag", "ns=2;s=Plant.Left")]
[InlineData("Other.Thing", "Other.Thing")] // no member tail matches → as-is
public void ObjectFromPick_strips_member_tail(string picked, string expected)
=> Assert.Equal(expected, CreateBindingPlanner.ObjectFromPick(Groups()[1], picked));
[Fact]
public void BuildBindings_skips_groups_without_connection_or_object()
{
var inputs = new Dictionary<string, CreateBindingPlanner.GroupInput>
{
[BindingGroupBuilder.InstanceGroupKey] = new(7, "Z28063"),
["Left"] = new(7, null), // connection, no object → skipped (stays unbound)
["Recv"] = new(0, "MESReceiver_023"), // object, no connection → skipped
};
var bindings = CreateBindingPlanner.BuildBindings(Groups(), inputs);
var single = Assert.Single(bindings);
Assert.Equal(("Running", 7, "Z28063.Running"), (single.AttributeName, single.DataConnectionId, single.DataSourceReferenceOverride));
}
[Fact]
public void BuildBindings_rewrites_every_member_of_a_bound_group()
{
var inputs = new Dictionary<string, CreateBindingPlanner.GroupInput> { ["Left"] = new(7, "Z28063.Left") };
var bindings = CreateBindingPlanner.BuildBindings(Groups(), inputs);
Assert.Equal(2, bindings.Count);
Assert.Contains(bindings, b => b.AttributeName == "Left.MoveInFlag" && b.DataSourceReferenceOverride == "Z28063.Left.MoveInFlag");
}
}
@@ -0,0 +1,97 @@
using NSubstitute;
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.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// Pins <c>PreviewAsync</c>: the Create Instance page must see exactly the attribute
/// set a freshly created, un-overridden instance flattens to — including composed
/// module members — without any instance row existing.
/// </summary>
public class FlatteningPipelinePreviewTests
{
private const int TemplateId = 10, ModuleTemplateId = 11, SiteId = 20, InstanceId = 30;
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>();
private readonly FlatteningPipeline _sut;
public FlatteningPipelinePreviewTests()
{
_sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>()).Returns([]);
_sut = new FlatteningPipeline(_templateRepo, _siteRepo, new FlatteningService(), new ValidationService(),
new RevisionHashService(), _sharedSchemaRepo, new TemplateGraphWatermark());
// Parent template with one direct data-sourced attribute, composing a module with one.
var module = new Template("Receiver") { Id = ModuleTemplateId };
module.Attributes.Add(new TemplateAttribute("MoveInFlag")
{
DataType = DataType.Boolean,
DataSourceReference = "MoveInFlag"
});
var parent = new Template("Line") { Id = TemplateId };
parent.Attributes.Add(new TemplateAttribute("Running")
{
DataType = DataType.Boolean,
DataSourceReference = "Running"
});
var composition = new TemplateComposition("Left")
{
TemplateId = TemplateId,
ComposedTemplateId = ModuleTemplateId
};
_templateRepo.GetTemplateWithChildrenAsync(TemplateId, Arg.Any<CancellationToken>()).Returns(parent);
_templateRepo.GetTemplateWithChildrenAsync(ModuleTemplateId, Arg.Any<CancellationToken>()).Returns(module);
_templateRepo.GetCompositionsByTemplateIdAsync(TemplateId, Arg.Any<CancellationToken>()).Returns([composition]);
_templateRepo.GetCompositionsByTemplateIdAsync(ModuleTemplateId, Arg.Any<CancellationToken>()).Returns([]);
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>()).Returns([]);
_templateRepo.GetInstanceByIdAsync(InstanceId, Arg.Any<CancellationToken>())
.Returns(new Instance("Line-01") { Id = InstanceId, TemplateId = TemplateId, SiteId = SiteId });
_siteRepo.GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any<CancellationToken>())
.Returns([new DataConnection("MxGateway", "MxGateway", SiteId) { Id = 1 }]);
}
[Fact]
public async Task Preview_matches_flatten_of_fresh_instance()
{
var preview = await _sut.PreviewAsync(TemplateId, SiteId);
var real = await _sut.FlattenAndValidateAsync(InstanceId, validateScripts: false);
// Result.Error throws on a success, so the message is only read on failure.
Assert.True(preview.IsSuccess, preview.IsSuccess ? "" : preview.Error);
Assert.True(real.IsSuccess, real.IsSuccess ? "" : real.Error);
var expected = real.Value.Configuration.Attributes
.Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference))
.OrderBy(x => x.Item1, StringComparer.Ordinal).ToList();
var actual = preview.Value.Attributes
.Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference))
.OrderBy(x => x.Item1, StringComparer.Ordinal).ToList();
Assert.Equal(expected, actual);
Assert.Contains(actual, a => a.Item1 == "Left.MoveInFlag");
}
[Fact]
public async Task Preview_unknown_template_fails()
{
var r = await _sut.PreviewAsync(999, SiteId);
Assert.True(r.IsFailure);
}
[Fact]
public async Task Preview_never_touches_instance_repository()
{
await _sut.PreviewAsync(TemplateId, SiteId);
await _templateRepo.DidNotReceive().GetInstanceByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
}
}