diff --git a/docs/plans/2026-08-20-grouped-connection-bindings.md b/docs/plans/2026-08-20-grouped-connection-bindings.md new file mode 100644 index 00000000..7328f77f --- /dev/null +++ b/docs/plans/2026-08-20-grouped-connection-bindings.md @@ -0,0 +1,1109 @@ +# Grouped Connection Bindings Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Make the Configure Instance page show and bulk-edit connection bindings for every flattened attribute (composed module members included), grouped by module with a per-group "Object" apply, plus a CLI `instance bind-module` command that performs the same rewrite. + +**Architecture:** The page switches its bindings row source from the template's direct attributes to the flattened configuration it already computes (`IFlatteningPipeline.FlattenAndValidateAsync(id, validateScripts:false)`). A new additive `ResolvedAttribute.DefaultDataSourceReference` carries the pre-binding default. A pure `BindingGroupBuilder` helper (CentralUI) does grouping, object-prefix inference and bulk apply so the logic is unit-tested without rendering the page. The CLI command chains `GetInstance` → `SetConnectionBindings` (existing upsert) client-side; no new server command. + +**Tech Stack:** .NET 10, Blazor Server (Bootstrap 5, no component libs), xUnit, System.CommandLine (CLI). Design: `docs/plans/2026-08-20-grouped-connection-bindings-design.md`. + +**Conventions:** work in a dedicated worktree (see memory `work-in-separate-worktree`); targeted test runs per task (`dotnet test tests/ --filter ...`), full build only at the end; commit per task. Never `git stash`. + +--- + +### Task 1: `ResolvedAttribute.DefaultDataSourceReference` (TemplateEngine) + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 2, Task 3, Task 7 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs:57-100` (`ResolvedAttribute`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:367-391` (`ApplyConnectionBindings`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DefaultDataSourceReferenceTests.cs` (create) + +**Step 1: Write the failing tests** + +```csharp +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; + +namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening; + +/// +/// Design 2026-08-20 (grouped connection bindings): the flattened attribute +/// carries the data-source reference as it was BEFORE the instance binding +/// override, so the Configure page can show the template default next to the +/// effective reference. The field is deliberately NOT part of the revision hash. +/// +public class DefaultDataSourceReferenceTests +{ + [Fact] + public void RevisionHash_IgnoresDefaultDataSourceReference() + { + var svc = new RevisionHashService(); + var a = new ResolvedAttribute { CanonicalName = "LeftMESReceiver.MoveInFlag", DataType = "Boolean", + DataSourceReference = "MESReceiver_022.MoveInFlag", BoundDataConnectionId = 1 }; + var without = new FlattenedConfiguration { InstanceUniqueName = "Z28061", Attributes = [a] }; + var with = without with { Attributes = [a with { DefaultDataSourceReference = "MoveInFlag" }] }; + + Assert.Equal(svc.ComputeHash(without), svc.ComputeHash(with)); + } + + [Fact] + public void ResolvedAttribute_DefaultDataSourceReference_IsNullByDefault() + { + var a = new ResolvedAttribute { CanonicalName = "X" }; + Assert.Null(a.DefaultDataSourceReference); + } +} +``` + +Add a third test to the EXISTING flattening integration fixture that exercises `ApplyConnectionBindings` (find it with `grep -rn "DataSourceReferenceOverride" tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/*.cs | head`; reuse its builder). Assert that for a bound attribute with override `"MESReceiver_022.MoveInFlag"` and template reference `"MoveInFlag"`: `DataSourceReference == "MESReceiver_022.MoveInFlag"` and `DefaultDataSourceReference == "MoveInFlag"`; and for an unbound data-sourced attribute both equal the template reference. + +**Step 2: Run to verify failure** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter "FullyQualifiedName~DefaultDataSourceReference" -v q` +Expected: build error `'ResolvedAttribute' does not contain a definition for 'DefaultDataSourceReference'`. + +**Step 3: Implement** + +In `FlattenedConfiguration.cs`, after `DataSourceReference` (line ~79): + +```csharp + /// + /// The data-source reference as resolved from the template chain BEFORE any + /// per-instance InstanceConnectionBinding.DataSourceReferenceOverride was + /// applied — i.e. the template/composed default the Configure page shows as the + /// placeholder. Equals when no override is set. + /// Central-side presentation data only: deliberately excluded from the revision + /// hash (RevisionHashService projects through HashableAttribute). + /// + public string? DefaultDataSourceReference { get; init; } +``` + +In `FlatteningService.cs`: the default must be populated for **every** data-sourced attribute, not only bound ones. At the top of `ApplyConnectionBindings`, before the `foreach`, add: + +```csharp + foreach (var key in attributes.Keys.ToList()) + { + var attr = attributes[key]; + if (attr.DataSourceReference is not null && attr.DefaultDataSourceReference is null) + attributes[key] = attr with { DefaultDataSourceReference = attr.DataSourceReference }; + } +``` + +Then in the existing `with { ... }` block, add `DefaultDataSourceReference = existing.DefaultDataSourceReference ?? existing.DataSourceReference,` (the pre-loop already set it, this is belt-and-braces). + +**Step 4: Run to verify pass** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter "FullyQualifiedName~Flattening" -v q` +Expected: all pass (the full Flattening namespace, not just the new file — hash/diff tests must stay green). + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/ +git commit -m "feat(templateengine): ResolvedAttribute.DefaultDataSourceReference — pre-binding default for the Configure page; excluded from the revision hash (pinned)" +``` + +--- + +### Task 2: `BindingGroupBuilder` — grouping + object-prefix inference (pure) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1, Task 7 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.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 BindingGroupBuilderTests +{ + private static BindingRow Row(string canonical, string? dflt, int? conn, string? ovr) => + new(canonical, dflt, conn, ovr); + + [Fact] + public void Groups_DirectAttributesFirst_ThenModules_InNameOrder() + { + var rows = new[] + { + Row("RightMESReceiver.MoveInFlag", "MoveInFlag", 1, "MESReceiver_022.MoveInFlag"), + Row("ChlorineFlow", "ChlorineFlow", 1, "Z28061.ChlorineFlow"), + Row("LeftMESReceiver.MoveInFlag", "MoveInFlag", 1, "MESReceiver_023.MoveInFlag"), + }; + var groups = BindingGroupBuilder.Build(rows); + Assert.Equal(new[] { BindingGroupBuilder.InstanceGroupKey, "LeftMESReceiver", "RightMESReceiver" }, + groups.Select(g => g.Key)); + } + + [Fact] + public void NestedModule_FoldsIntoFirstSegment_AndKeepsNestedTail() + { + var rows = new[] + { + Row("LeftReactorSide.AIR_SVC", "AIR_SVC", 1, "Left_002.AIR_SVC"), + Row("LeftReactorSide.LeakTest.DeltaVac", "DeltaVac", 1, "Left_002.LeakTest.DeltaVac"), + }; + var g = Assert.Single(BindingGroupBuilder.Build(rows)); + Assert.Equal("LeftReactorSide", g.Key); + Assert.Equal(new[] { "AIR_SVC", "LeakTest.DeltaVac" }, g.Members.Select(m => m.MemberTail)); + Assert.Equal(new[] { "LeakTest" }, g.NestedModules); + Assert.Equal("Left_002", g.InferredObject); + } + + [Fact] + public void InferredObject_NestedObjectPath() + { + var rows = new[] + { + Row("LeftDelmiaReceiver.ReadyFlag", "ReadyFlag", 1, "Left_002.DelmiaReceiver.ReadyFlag"), + Row("LeftDelmiaReceiver.PartNumber", "PartNumber", 1, "Left_002.DelmiaReceiver.PartNumber"), + }; + Assert.Equal("Left_002.DelmiaReceiver", Assert.Single(BindingGroupBuilder.Build(rows)).InferredObject); + } + + [Fact] + public void InferredObject_NullWhenMixed_AndStateMixed() + { + var rows = new[] + { + Row("M.A", "A", 1, "Obj1.A"), + Row("M.B", "B", 1, "Obj2.B"), + }; + var g = Assert.Single(BindingGroupBuilder.Build(rows)); + Assert.Null(g.InferredObject); + Assert.True(g.IsObjectMixed); + } + + [Fact] + public void InferredObject_NullWhenNoOverrides_NotMixed() + { + var rows = new[] { Row("M.A", "A", 1, null), Row("M.B", "B", 1, null) }; + var g = Assert.Single(BindingGroupBuilder.Build(rows)); + Assert.Null(g.InferredObject); + Assert.False(g.IsObjectMixed); + } + + [Fact] + public void InstanceGroup_InfersObjectFromOverrides() + { + var rows = new[] { Row("ChlorineFlow", "ChlorineFlow", 1, "Z28061.ChlorineFlow"), Row("MachineID", "MachineID", 1, "Z28061.MachineID") }; + var g = Assert.Single(BindingGroupBuilder.Build(rows)); + Assert.Equal(BindingGroupBuilder.InstanceGroupKey, g.Key); + Assert.Equal("Z28061", g.InferredObject); + } + + [Fact] + public void Counts_UnboundAndConnectionState() + { + var rows = new[] + { + Row("M.A", "A", 1, "O.A"), + Row("M.B", "B", null, null), + Row("M.C", "C", 2, "O.C"), + }; + var g = Assert.Single(BindingGroupBuilder.Build(rows)); + Assert.Equal(3, g.Members.Count); + Assert.Equal(1, g.UnboundCount); + Assert.Equal(BindingConnectionState.Mixed, g.ConnectionState); + Assert.Null(g.CommonConnectionId); + } + + [Fact] + public void ConnectionState_SingleWhenAllAgree() + { + var rows = new[] { Row("M.A", "A", 1, null), Row("M.B", "B", 1, null) }; + var g = Assert.Single(BindingGroupBuilder.Build(rows)); + Assert.Equal(BindingConnectionState.Single, g.ConnectionState); + Assert.Equal(1, g.CommonConnectionId); + } + + [Fact] + public void MemberTail_OverrideThatDoesNotEndWithTail_CountsAsMixed() + { + // An operator typed a reference that does not end in the member tail — + // the prefix cannot be inferred for that row, so the group is mixed. + var rows = new[] { Row("M.A", "A", 1, "Obj.A"), Row("M.B", "B", 1, "Obj.Other") }; + Assert.True(Assert.Single(BindingGroupBuilder.Build(rows)).IsObjectMixed); + } +} +``` + +**Step 2: Run to verify failure** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~BindingGroupBuilderTests" -v q` +Expected: build error, `BindingGroupBuilder` not found. + +**Step 3: Implement** + +```csharp +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// One data-sourced flattened attribute as the Configure page sees it. +/// Path-qualified flattened name, e.g. LeftMESReceiver.MoveInFlag. +/// Template/composed default reference (pre-override), may be null. +/// Bound data connection id, or null when unbound. +/// Per-instance reference override, or null when the default applies. +public sealed record BindingRow(string CanonicalName, string? DefaultReference, int? ConnectionId, string? Override); + +public enum BindingConnectionState { None, Single, Mixed } + +/// A member of a : the row plus its tail relative to the group. +public sealed record BindingGroupMember(BindingRow Row, string MemberTail) +{ + public bool IsUnbound => Row.ConnectionId is null or <= 0; + /// Effective reference: override if set, else the default. + public string? EffectiveReference => string.IsNullOrWhiteSpace(Row.Override) ? Row.DefaultReference : Row.Override; +} + +/// +/// One module's worth of binding rows. is the first segment of the +/// canonical name ( for direct +/// attributes); nested modules fold into their first segment. +/// +public sealed record BindingGroup( + string Key, + IReadOnlyList Members, + IReadOnlyList NestedModules, + string? InferredObject, + bool IsObjectMixed, + BindingConnectionState ConnectionState, + int? CommonConnectionId, + int UnboundCount) +{ + public bool IsInstanceGroup => Key == BindingGroupBuilder.InstanceGroupKey; +} + +/// +/// Pure grouping / prefix-inference / bulk-apply logic behind the Configure page's +/// Connection Bindings panel (design 2026-08-20). No Blazor dependencies so it is +/// unit-testable without rendering the page. +/// +public static class BindingGroupBuilder +{ + public const string InstanceGroupKey = "(instance)"; + + public static IReadOnlyList Build(IEnumerable rows) + { + var byKey = new Dictionary>(StringComparer.Ordinal); + var nested = new Dictionary>(StringComparer.Ordinal); + + foreach (var row in rows.OrderBy(r => r.CanonicalName, StringComparer.Ordinal)) + { + var dot = row.CanonicalName.IndexOf('.'); + string key, tail; + if (dot < 0) { key = InstanceGroupKey; tail = row.CanonicalName; } + else + { + key = row.CanonicalName[..dot]; + tail = row.CanonicalName[(dot + 1)..]; + var tailDot = tail.LastIndexOf('.'); + if (tailDot > 0) + { + if (!nested.TryGetValue(key, out var set)) nested[key] = set = new SortedSet(StringComparer.Ordinal); + set.Add(tail[..tailDot]); + } + } + if (!byKey.TryGetValue(key, out var list)) byKey[key] = list = new List(); + list.Add(new BindingGroupMember(row, tail)); + } + + var ordered = byKey.Keys + .OrderBy(k => k == InstanceGroupKey ? 0 : 1) + .ThenBy(k => k, StringComparer.Ordinal); + + var result = new List(); + foreach (var key in ordered) + { + var members = byKey[key]; + var (obj, mixed) = InferObject(members); + var conns = members.Select(m => m.Row.ConnectionId is > 0 ? m.Row.ConnectionId : null).Distinct().ToList(); + var state = conns.Count == 1 && conns[0] is not null ? BindingConnectionState.Single + : conns.All(c => c is null) ? BindingConnectionState.None + : BindingConnectionState.Mixed; + result.Add(new BindingGroup( + key, members, + nested.TryGetValue(key, out var n) ? n.ToList() : [], + obj, mixed, state, + state == BindingConnectionState.Single ? conns[0] : null, + members.Count(m => m.IsUnbound))); + } + return result; + } + + /// + /// Longest object prefix shared by every member that has an override: each override + /// must be {prefix}.{memberTail}. Returns (null,false) when no member has an + /// override; (null,true) when members disagree or an override doesn't end in its tail. + /// + private static (string? Object, bool Mixed) InferObject(IReadOnlyList members) + { + string? candidate = null; + var any = false; + foreach (var m in members) + { + if (string.IsNullOrWhiteSpace(m.Row.Override)) continue; + any = true; + var suffix = "." + m.MemberTail; + if (!m.Row.Override.EndsWith(suffix, StringComparison.Ordinal)) return (null, true); + var prefix = m.Row.Override[..^suffix.Length]; + if (prefix.Length == 0) return (null, true); + if (candidate is null) candidate = prefix; + else if (!string.Equals(candidate, prefix, StringComparison.Ordinal)) return (null, true); + } + return any ? (candidate, false) : (null, false); + } +} +``` + +**Step 4: Run to verify pass** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~BindingGroupBuilderTests" -v q` +Expected: 9 passed. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs +git commit -m "feat(centralui): BindingGroupBuilder — group flattened binding rows by module, infer the shared MxAccess object prefix, count unbound" +``` + +--- + +### Task 3: `BindingGroupBuilder.ApplyObject` (pure) + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 1, Task 7 +**Depends on:** Task 2 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs` + +**Step 1: Write the failing tests** (append to the test class) + +```csharp + [Fact] + public void ApplyObject_RewritesEveryMember_AndSetsConnection() + { + var g = Assert.Single(BindingGroupBuilder.Build(new[] + { + Row("RightMESReceiver.MoveInFlag", "MoveInFlag", 1, "MESReceiver_024.MoveInFlag"), + Row("RightMESReceiver.ScanStateCmd", "ScanStateCmd", null, null), + })); + var changes = BindingGroupBuilder.ApplyObject(g, connectionId: 1, objectPath: " MESReceiver_022. "); + Assert.Equal(2, changes.Count); + Assert.Equal(("RightMESReceiver.MoveInFlag", 1, "MESReceiver_022.MoveInFlag"), (changes[0].CanonicalName, changes[0].ConnectionId, changes[0].Override)); + Assert.Equal(("RightMESReceiver.ScanStateCmd", 1, "MESReceiver_022.ScanStateCmd"), (changes[1].CanonicalName, changes[1].ConnectionId, changes[1].Override)); + } + + [Fact] + public void ApplyObject_NestedTail_KeepsNesting() + { + var g = Assert.Single(BindingGroupBuilder.Build(new[] { Row("LeftReactorSide.LeakTest.DeltaVac", "DeltaVac", 1, null) })); + var c = Assert.Single(BindingGroupBuilder.ApplyObject(g, 1, "Left_002")); + Assert.Equal("Left_002.LeakTest.DeltaVac", c.Override); + } + + [Fact] + public void ApplyObject_EmptyObject_ClearsOverrides_KeepsConnection() + { + var g = Assert.Single(BindingGroupBuilder.Build(new[] { Row("M.A", "A", 1, "Obj.A") })); + var c = Assert.Single(BindingGroupBuilder.ApplyObject(g, 2, "")); + Assert.Null(c.Override); + Assert.Equal(2, c.ConnectionId); + } +``` + +**Step 2: Run to verify failure** — `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~ApplyObject" -v q` → build error. + +**Step 3: Implement** (add to `BindingGroupBuilder`) + +```csharp + /// Result of a bulk apply for one member. + public sealed record BindingChange(string CanonicalName, int? ConnectionId, string? Override); + + /// + /// Rewrites every member of to + /// {objectPath}.{memberTail} on . A blank + /// clears the overrides (template default applies) and + /// leaves the connection as given. Surrounding whitespace and a trailing '.' are tolerated. + /// + public static IReadOnlyList ApplyObject(BindingGroup group, int? connectionId, string? objectPath) + { + var obj = (objectPath ?? string.Empty).Trim().TrimEnd('.'); + return group.Members + .Select(m => new BindingChange(m.Row.CanonicalName, connectionId, + obj.Length == 0 ? null : obj + "." + m.MemberTail)) + .ToList(); + } +``` + +**Step 4: Run to verify pass** — same filter, expect 3 passed; then run the whole `BindingGroupBuilderTests` class (12 passed). + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs +git commit -m "feat(centralui): BindingGroupBuilder.ApplyObject — per-module bulk rewrite to ., blank clears" +``` + +--- + +### Task 4: Configure page — source binding rows from the flattened config + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (same file as Task 5) +**Depends on:** Task 1, Task 3 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor` (code block: fields ~line 624-660, load ~line 760-771, `GetTemplateDefault` ~874, `BuildTestableRows` ~930, `SaveBindings` ~963, `BuildFlattenedAttributesAsync` ~1644) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingRowsTests.cs` (create; structural, same style as `InstanceConfigureListOverrideTests`) + +**Step 1: Write the failing structural test** + +```csharp +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; + +/// +/// Design 2026-08-20: the Connection Bindings panel must be driven by the flattened +/// configuration (every composed module member), not by the template's direct +/// attributes — 264 of Z28061's 280 bindings were invisible on QA. Structural pins +/// over the page source, matching the existing InstanceConfigure coverage style. +/// +public class InstanceConfigureBindingRowsTests +{ + private static string Markup + { + get + { + var dir = AppContext.BaseDirectory; + for (var i = 0; i < 6 && dir is not null; i++) dir = Directory.GetParent(dir)?.FullName; + return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI", + "Components", "Pages", "Deployment", "InstanceConfigure.razor")); + } + } + + [Fact] + public void BindingRows_ComeFromFlattenedConfiguration_NotTemplateAttributes() + { + var m = Markup; + Assert.Contains("LoadBindingRowsAsync", m); + Assert.Contains("BindingGroupBuilder.Build(", m); + // The old direct-attribute projection must be gone from the bindings path. + Assert.DoesNotContain("_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference))", m); + Assert.Contains("DefaultDataSourceReference", m); + } + + [Fact] + public void BindingRows_FallBackToDirectAttributes_WithBanner_WhenFlattenFails() + { + var m = Markup; + Assert.Contains("_bindingSourceWarning", m); + Assert.Contains("data-test=\"binding-source-warning\"", m); + } + + [Fact] + public void SaveAndTest_CoverEveryRow() + { + var m = Markup; + // Save posts the whole selection dictionary (hidden-row survival property). + Assert.Contains("_bindingSelections\n .Select(kv => new ConnectionBinding(", m.Replace("\r\n", "\n")); + // Test Bindings iterates the grouped rows, not the legacy attr list. + Assert.Contains("foreach (var g in _bindingGroups)", m); + } +} +``` + +**Step 2: Run to verify failure** — `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~InstanceConfigureBindingRowsTests" -v q` → 3 failed. + +**Step 3: Implement** (code block only in this task; markup is Task 5) + +Add `@using ZB.MOM.WW.ScadaBridge.CentralUI.Services` near the other usings (check it isn't already imported via `_Imports.razor`). + +Replace the `_bindingDataSourceAttrs` field and its load with: + +```csharp + // ── Bindings (design 2026-08-20: rows = flattened attributes, grouped by module) ── + private List _bindingRows = new(); + private IReadOnlyList _bindingGroups = Array.Empty(); + private string? _bindingSourceWarning; + /// Template default per canonical name (placeholder text). + private readonly Dictionary _bindingDefaults = new(StringComparer.Ordinal); + private FlattenedConfiguration? _flattenedForBindings; +``` + +In `OnInitializedAsync`, replace the two lines at ~762-763 with `await LoadBindingRowsAsync(attrs);` placed AFTER `existingBindings` are read into `_bindingSelections`/`_bindingOverrides` (move the `existingBindings` block above it), and add: + +```csharp + /// + /// Builds the binding rows from the flattened configuration so composed module + /// members appear. Falls back to the template's direct attributes — with a visible + /// warning — only when the flatten itself fails; a validation failure (e.g. unbound + /// attributes, which is exactly what this panel exists to fix) is NOT a reason to + /// fall back. + /// + private async Task LoadBindingRowsAsync(IReadOnlyList directAttrs) + { + _bindingSourceWarning = null; + IEnumerable<(string Name, string? Default)> source; + try + { + var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id, validateScripts: false); + if (flat.IsFailure) + { + _bindingSourceWarning = $"Could not flatten this instance ({flat.Error}); showing the template's direct attributes only."; + source = directAttrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).Select(a => (a.Name, a.DataSourceReference)); + } + else + { + _flattenedForBindings = flat.Value.Configuration; + source = flat.Value.Configuration.Attributes + .Where(a => !string.IsNullOrEmpty(a.DefaultDataSourceReference) || !string.IsNullOrEmpty(a.DataSourceReference)) + .Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference)); + } + } + catch (Exception ex) + { + _bindingSourceWarning = $"Could not flatten this instance ({ex.Message}); showing the template's direct attributes only."; + source = directAttrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).Select(a => (a.Name, a.DataSourceReference)); + } + + _bindingDefaults.Clear(); + _bindingRows = source.Select(s => + { + _bindingDefaults[s.Name] = s.Default; + return new BindingRow(s.Name, s.Default, + _bindingSelections.TryGetValue(s.Name, out var c) ? c : null, + _bindingOverrides.GetValueOrDefault(s.Name)); + }).ToList(); + RebuildBindingGroups(); + } + + private void RebuildBindingGroups() + { + // Re-project current selections/overrides into rows, then regroup. + _bindingRows = _bindingRows + .Select(r => r with + { + ConnectionId = _bindingSelections.TryGetValue(r.CanonicalName, out var c) ? c : null, + Override = _bindingOverrides.GetValueOrDefault(r.CanonicalName) + }) + .ToList(); + _bindingGroups = BindingGroupBuilder.Build(_bindingRows); + } + + private int TotalUnbound => _bindingGroups.Sum(g => g.UnboundCount); + private int MixedGroups => _bindingGroups.Count(g => g.IsObjectMixed || g.ConnectionState == BindingConnectionState.Mixed); +``` + +Update `GetTemplateDefault(string attrName)` to `=> _bindingDefaults.GetValueOrDefault(attrName);`. + +`OnBindingChanged` / `OnOverrideForAttrChanged`: after they mutate `_bindingSelections`/`_bindingOverrides`, call `RebuildBindingGroups();`. + +`BuildTestableRows`: replace `foreach (var attr in _bindingDataSourceAttrs)` with + +```csharp + foreach (var g in _bindingGroups) + foreach (var m in g.Members) + { + var name = m.Row.CanonicalName; + var connId = GetBindingConnectionId(name); + if (connId <= 0) continue; + var conn = _siteConnections.FirstOrDefault(c => c.Id == connId); + if (conn is null) continue; + var effectivePath = _bindingOverrides.GetValueOrDefault(name) ?? GetTemplateDefault(name); + if (string.IsNullOrWhiteSpace(effectivePath)) continue; + rows.Add(new TestBindingsDialog.BindingRowToTest(name, conn.Name, effectivePath)); + } +``` + +`ApplyBulkBinding` (existing all-rows connection select): iterate `_bindingRows` instead of `_bindingDataSourceAttrs`, then `RebuildBindingGroups()`. + +`BuildFlattenedAttributesAsync` (alarm picker): if `_flattenedForBindings` is already populated, use it instead of flattening a second time. + +`SaveBindings` body is unchanged. Temporarily keep the page compiling by leaving the markup's `@foreach (var attr in _bindingDataSourceAttrs)` loop pointed at a shim: `private IEnumerable _bindingDataSourceAttrs => Enumerable.Empty();` — Task 5 removes it with the old markup. (Do not commit a page that renders an empty table without the warning: Tasks 4 and 5 may be committed together if the implementer prefers; the structural tests for Task 4 must pass first.) + +**Step 4: Run** — `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI -v q` (0 errors) then the Task 4 filter → 3 passed, then `--filter "FullyQualifiedName~InstanceConfigure"` → all existing InstanceConfigure tests still pass. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingRowsTests.cs +git commit -m "feat(centralui): Configure page sources connection-binding rows from the flattened config — composed module members were invisible; flatten failure falls back to direct attributes with a banner" +``` + +--- + +### Task 5: Configure page — grouped markup with per-module Object apply + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none +**Depends on:** Task 4 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor` (markup ~lines 82-182 + code block) +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.css` (create if absent — check `ls` first; if the page has no scoped css, put the 6 rules in `wwwroot/css/site.css` under a `/* instance-configure bindings */` comment) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingGroupsTests.cs` (create) + +Reference mockup: artifact `https://claude.ai/code/artifact/6af84fbc-5714-4937-a98c-6e2245984044` (file `scratchpad/bindings-mockup.html` in the design session). Bootstrap 5 only, no component libraries. + +**Step 1: Write the failing structural tests** + +```csharp +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; + +public class InstanceConfigureBindingGroupsTests +{ + private static string Markup => InstanceConfigureBindingRowsTests.ReadMarkup(); // make ReadMarkup internal static in Task 4's test file + + [Fact] + public void GroupHeader_HasConnectionObjectAndApply() + { + var m = Markup; + Assert.Contains("data-test=\"binding-group-header\"", m); + Assert.Contains("data-test=\"binding-group-connection\"", m); + Assert.Contains("data-test=\"binding-group-object\"", m); + Assert.Contains("data-test=\"binding-group-apply\"", m); + Assert.Contains("ApplyGroupObject(", m); + Assert.Contains("BindingGroupBuilder.ApplyObject(", m); + } + + [Fact] + public void GroupHeader_ShowsUnboundAndMixedPills() + { + var m = Markup; + Assert.Contains("data-test=\"binding-group-unbound\"", m); + Assert.Contains("data-test=\"binding-group-mixed\"", m); + Assert.Contains("data-test=\"binding-total-unbound\"", m); + } + + [Fact] + public void Toolbar_HasUnboundFilterAndExpandAll() + { + var m = Markup; + Assert.Contains("_showOnlyUnbound", m); + Assert.Contains("data-test=\"binding-expand-all\"", m); + Assert.Contains("data-test=\"binding-bulk-select\"", m); // existing all-groups connection select retained + } + + [Fact] + public void ComposedBareDefault_IsFlagged() + { + Assert.Contains("data-test=\"binding-bare-default\"", Markup); + } + + [Fact] + public void OldFlatTable_IsGone() + { + Assert.DoesNotContain("_bindingDataSourceAttrs", Markup); + } +} +``` + +**Step 2: Run to verify failure** — filter `InstanceConfigureBindingGroupsTests` → 5 failed. + +**Step 3: Implement** + +Code-block additions: + +```csharp + private readonly HashSet _expandedGroups = new(StringComparer.Ordinal); + private readonly Dictionary _groupObjectInput = new(StringComparer.Ordinal); + private readonly Dictionary _groupConnectionInput = new(StringComparer.Ordinal); + private readonly Dictionary _groupChangedCounts = new(StringComparer.Ordinal); + private bool _showOnlyUnbound; + + private void ToggleGroup(string key) { if (!_expandedGroups.Remove(key)) _expandedGroups.Add(key); } + private void ExpandAll() { foreach (var g in _bindingGroups) _expandedGroups.Add(g.Key); } + + private string? GroupObjectInput(BindingGroup g) + => _groupObjectInput.TryGetValue(g.Key, out var v) ? v : g.InferredObject; + private int GroupConnectionInput(BindingGroup g) + => _groupConnectionInput.TryGetValue(g.Key, out var v) ? v ?? 0 : g.CommonConnectionId ?? 0; + + private void ApplyGroupObject(BindingGroup g) + { + var connId = GroupConnectionInput(g); + var changes = BindingGroupBuilder.ApplyObject(g, connId > 0 ? connId : null, GroupObjectInput(g)); + var changed = 0; + foreach (var c in changes) + { + var before = (_bindingSelections.GetValueOrDefault(c.CanonicalName), _bindingOverrides.GetValueOrDefault(c.CanonicalName)); + if (c.ConnectionId is > 0) _bindingSelections[c.CanonicalName] = c.ConnectionId.Value; else _bindingSelections.Remove(c.CanonicalName); + if (!string.IsNullOrEmpty(c.Override)) _bindingOverrides[c.CanonicalName] = c.Override; else _bindingOverrides.Remove(c.CanonicalName); + if (before != (_bindingSelections.GetValueOrDefault(c.CanonicalName), _bindingOverrides.GetValueOrDefault(c.CanonicalName))) changed++; + } + _groupChangedCounts[g.Key] = _groupChangedCounts.GetValueOrDefault(g.Key) + changed; + _groupObjectInput.Remove(g.Key); _groupConnectionInput.Remove(g.Key); + RebuildBindingGroups(); + } + + private IEnumerable VisibleMembers(BindingGroup g) + => _showOnlyUnbound ? g.Members.Where(m => m.IsUnbound) : g.Members; + private IEnumerable VisibleGroups + => _showOnlyUnbound ? _bindingGroups.Where(g => g.UnboundCount > 0) : _bindingGroups; +``` + +In `SaveBindings`, on success: `_groupChangedCounts.Clear();`. + +Markup — replace the Connection Bindings card body (from the `@* Connection Bindings *@` comment through its closing ``) with (abbreviated to the essential structure; keep Bootstrap classes, keep existing `data-test="binding-bulk-select"`): + +```razor +@* Connection Bindings — grouped by module (design 2026-08-20) *@ +
+
+ Connection Bindings + @_bindingRows.Count data-sourced attributes in @_bindingGroups.Count groups + + @if (TotalUnbound > 0) { @TotalUnbound unbound } + @if (MixedGroups > 0) { @MixedGroups group(s) mixed } +
+ @if (_bindingSourceWarning is not null) + { +
@_bindingSourceWarning
+ } +
+ @if (_siteConnections.Count > 0) + { + + } + + + +
+ + @if (_bindingRows.Count == 0) + { +
This template has no data-sourced attributes.
+ } + else + { + @foreach (var g in VisibleGroups) + { + var open = _expandedGroups.Contains(g.Key); +
+
+ + @g.Key + @if (g.IsInstanceGroup) { direct attributes } + else if (g.NestedModules.Count > 0) { incl. @string.Join(", ", g.NestedModules) } + + @g.Members.Count + + + + @if (g.UnboundCount > 0) { @g.UnboundCount unbound } + else { bound } + @if (g.IsObjectMixed || g.ConnectionState == BindingConnectionState.Mixed) { mixed } + @if (_groupChangedCounts.TryGetValue(g.Key, out var changed) && changed > 0) { @changed changed · unsaved } +
+ @if (open) + { +
+ + + + @foreach (var m in VisibleMembers(g)) + { + var name = m.Row.CanonicalName; + var bare = !g.IsInstanceGroup && string.Equals(m.Row.DefaultReference, m.MemberTail, StringComparison.Ordinal); + + + + + + + } + +
MemberTemplate defaultConnectionReference override
@m.MemberTail@m.Row.DefaultReference + @if (bare) { ⚠ } + + +
+
+ } +
+ } + + } +
+``` + +Keep the `TestBindingsDialog` component reference that already exists below the card. Remove the `_bindingDataSourceAttrs` shim from Task 4. Add minimal CSS (`.binding-group-header .form-select, .binding-group-header .form-control { min-width: 0 }` plus `.binding-group-header { flex-wrap: wrap }` for narrow widths). + +**Step 4: Run** — `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI -v q` (0 errors/0 warnings introduced), then `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~InstanceConfigure" -v q` → all pass (Task 4 + Task 5 + pre-existing). + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.css tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingGroupsTests.cs +git commit -m "feat(centralui): grouped Connection Bindings panel — per-module connection + Object apply, unbound/mixed pills, show-only-unbound filter, Test Bindings covers composed rows" +``` + +--- + +### Task 6: Live check on the docker rig + +**Classification:** small +**Estimated implement time:** ~5 min (mostly build time) +**Parallelizable with:** Task 7, Task 8 +**Depends on:** Task 5 + +**Files:** none edited. Uses `docker/deploy.sh` and the `scadabridge-cluster-ops` skill (URL `http://localhost:9000`, `multi-role`/`password`). + +**Steps** +1. `bash docker/deploy.sh` (rebuilds `scadabridge:latest`, recreates the 8 nodes). Wait for `curl -s localhost:9000/health/ready` → 200. +2. Pick a composed instance: `curl -s -u multi-role:password -H 'Content-Type: application/json' localhost:9000/management -d '{"command":"ListInstances","payload":{}}' | python3 -c 'import sys,json;[print(i["id"],i["uniqueName"],i["templateId"]) for i in json.load(sys.stdin)]'`. If none has a composed template, create one from the rig's existing feature-module template (see `docker/README.md` seed data) or accept the `(instance)` group only and note it. +3. Open `http://localhost:9000/deployment/instances//configure` with the Chrome tools (`claude-in-chrome` skill) and screenshot: groups render, expanding shows members, typing an Object + Apply rewrites the rows and shows `n changed · unsaved`, Save succeeds, reload shows the inferred Object pre-filled, Test Bindings reads the composed rows. +4. Record the screenshot path and any defect in the commit message of the next task or fix inline (small fixes → amend into Task 5's scope with their own commit). + +No commit unless a fix was needed. + +--- + +### Task 7: CLI `instance bind-module` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1, Task 2, Task 3, Task 6 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs` (add `CreateBindModuleCommand`, register it next to `set-bindings` in the `instance` command's `Add(...)` list; add the pure `internal static` helper `BuildModuleRewrite`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/InstanceArgumentParsingTests.cs` (append) — pure helper tests; and the frozen command-surface/authz tests if they enumerate CLI commands (`grep -rn "set-bindings" tests/ZB.MOM.WW.ScadaBridge.CLI.Tests | head` — if a lock-in list exists, add `bind-module` to it). + +**Step 1: Write the failing tests** + +```csharp + [Fact] + public void BuildModuleRewrite_RewritesOnlyTheModule_AndKeepsConnection() + { + var existing = new[] + { + new ConnectionBinding("RightMESReceiver.MoveInFlag", 1, "MESReceiver_024.MoveInFlag"), + new ConnectionBinding("RightMESReceiver.ScanStateCmd", 1, "MESReceiver_024.ScanStateCmd"), + new ConnectionBinding("LeftMESReceiver.MoveInFlag", 1, "MESReceiver_023.MoveInFlag"), + }; + var ok = InstanceCommands.BuildModuleRewrite(existing, "RightMESReceiver", connectionId: 1, objectPath: "MESReceiver_022", out var rewritten, out var error); + Assert.True(ok, error); + Assert.Equal(2, rewritten!.Count); + Assert.All(rewritten, b => Assert.StartsWith("MESReceiver_022.", b.DataSourceReferenceOverride!)); + Assert.DoesNotContain(rewritten, b => b.AttributeName.StartsWith("Left")); + } + + [Fact] + public void BuildModuleRewrite_InstanceGroup_MatchesDotlessNames() + { + var existing = new[] { new ConnectionBinding("ChlorineFlow", 1, "Z28061.ChlorineFlow"), new ConnectionBinding("M.A", 1, "O.A") }; + Assert.True(InstanceCommands.BuildModuleRewrite(existing, "(instance)", 1, "Z28063", out var r, out _)); + var b = Assert.Single(r!); + Assert.Equal("Z28063.ChlorineFlow", b.DataSourceReferenceOverride); + } + + [Fact] + public void BuildModuleRewrite_EmptyObject_ClearsOverride() + { + var existing = new[] { new ConnectionBinding("M.A", 1, "O.A") }; + Assert.True(InstanceCommands.BuildModuleRewrite(existing, "M", 2, "", out var r, out _)); + var b = Assert.Single(r!); + Assert.Null(b.DataSourceReferenceOverride); + Assert.Equal(2, b.DataConnectionId); + } + + [Fact] + public void BuildModuleRewrite_UnknownModule_ListsAvailable() + { + var existing = new[] { new ConnectionBinding("M.A", 1, "O.A"), new ConnectionBinding("N.B", 1, "O.B") }; + Assert.False(InstanceCommands.BuildModuleRewrite(existing, "Q", 1, "X", out _, out var error)); + Assert.Contains("M", error); + Assert.Contains("N", error); + } + + [Fact] + public void BindModule_ParsesOptions() + { + var root = Program.BuildRootCommand(); // or however the existing parsing tests obtain the root — mirror InstanceArgumentParsingTests + var result = root.Parse("instance bind-module --id 1 --module RightMESReceiver --connection 1 --object MESReceiver_022 --dry-run"); + Assert.Empty(result.Errors); + } +``` + +(Adjust the last test to the root-command accessor the existing file already uses.) + +**Step 2: Run to verify failure** — `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests --filter "FullyQualifiedName~InstanceArgumentParsingTests" -v q` → build error. + +**Step 3: Implement** + +```csharp + /// + /// Pure core of instance bind-module: selects the existing bindings that belong + /// to (canonical names starting with {module}., or + /// dot-less names for (instance)) and rewrites each override to + /// {objectPath}.{memberTail} on . An empty + /// clears the override. Only the module's rows are + /// returned, so the upsert leaves every other module untouched. + /// + internal static bool BuildModuleRewrite( + IReadOnlyList existing, string module, int connectionId, string? objectPath, + out List? rewritten, out string? error) + { + rewritten = null; error = null; + var isInstance = module is "(instance)" or "."; + var prefix = module + "."; + var obj = (objectPath ?? string.Empty).Trim().TrimEnd('.'); + + var rows = existing.Where(b => isInstance + ? !b.AttributeName.Contains('.') + : b.AttributeName.StartsWith(prefix, StringComparison.Ordinal)) + .Select(b => + { + var tail = isInstance ? b.AttributeName : b.AttributeName[prefix.Length..]; + return new ConnectionBinding(b.AttributeName, connectionId, obj.Length == 0 ? null : obj + "." + tail); + }) + .ToList(); + + if (rows.Count == 0) + { + var available = existing.Select(b => b.AttributeName.Contains('.') ? b.AttributeName[..b.AttributeName.IndexOf('.')] : "(instance)") + .Distinct().OrderBy(s => s, StringComparer.Ordinal); + error = $"Instance has no bindings for module '{module}'. Available: {string.Join(", ", available)}. " + + "A module with no bindings yet must be seeded with 'instance set-bindings' (or bound once in the Central UI)."; + return false; + } + rewritten = rows; + return true; + } + + private static Command CreateBindModuleCommand(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var idOption = new Option("--id") { Description = "Instance ID", Required = true }; + var moduleOption = new Option("--module") { Description = "Module (canonical-name prefix), e.g. RightMESReceiver; use '(instance)' for direct attributes", Required = true }; + var connOption = new Option("--connection") { Description = "Data connection ID", Required = true }; + var objectOption = new Option("--object") { Description = "MxAccess/OPC object path the module's members hang off, e.g. MESReceiver_022 or Left_002.DelmiaReceiver; empty clears overrides", Required = true }; + var dryRunOption = new Option("--dry-run") { Description = "Print the resulting bindings without saving" }; + + var cmd = new Command("bind-module") { Description = "Rebind every member of one composed module to a new object path (upserts only that module's bindings)" }; + cmd.Add(idOption); cmd.Add(moduleOption); cmd.Add(connOption); cmd.Add(objectOption); cmd.Add(dryRunOption); + cmd.SetAction(async (ParseResult result) => + { + var id = result.GetValue(idOption); + var module = result.GetValue(moduleOption)!; + var conn = result.GetValue(connOption); + var obj = result.GetValue(objectOption); + var dryRun = result.GetValue(dryRunOption); + + return await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + new GetInstanceCommand(id), + onSuccess: json => + { + var existing = ParseExistingBindings(json); // System.Text.Json over "connectionBindings": [{attributeName,dataConnectionId,dataSourceReferenceOverride}] + if (!BuildModuleRewrite(existing, module, conn, obj, out var rewritten, out var error)) + { + OutputFormatter.WriteError(error!, "UNKNOWN_MODULE"); + return 1; + } + if (dryRun) + { + OutputFormatter.WriteJson(rewritten!); // or the formatter's equivalent success writer + return 0; + } + return CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + new SetConnectionBindingsCommand(id, rewritten!)).GetAwaiter().GetResult(); + }); + }); + return cmd; + } +``` + +Implement `ParseExistingBindings(string json)` with `JsonDocument` (camelCase property names as the management API emits them). Register: find where `CreateSetBindingsCommand(...)` is added to the `instance` command and add `CreateBindModuleCommand(...)` right after it. Check `OutputFormatter` for the success-writer name (`grep -n "public static" src/ZB.MOM.WW.ScadaBridge.CLI/OutputFormatter.cs`). + +**Step 4: Run** — `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests -v q` (whole CLI suite — it has frozen command-surface tests that may need the new command added). + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ +git commit -m "feat(cli): instance bind-module — rebind one composed module's members to a new object path via GetInstance → SetConnectionBindings upsert; --dry-run" +``` + +--- + +### Task 8: Docs + +**Classification:** trivial +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 6, Task 7 +**Depends on:** Task 5 + +**Files:** +- Modify: `docs/requirements/Component-CentralUI.md` (Configure Instance / connection bindings section — `grep -n "Connection Bindings\|Test Bindings" docs/requirements/Component-CentralUI.md`) +- Modify: `docs/requirements/Component-TemplateEngine.md` (flattened configuration / `ResolvedAttribute` — `grep -n "ResolvedAttribute\|DataSourceReference" docs/requirements/Component-TemplateEngine.md`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (instance commands table — add `bind-module` after `set-bindings`) +- Modify: `docs/plans/2026-08-20-grouped-connection-bindings-design.md` (Status → implemented, commit range) + +**Content** +- CentralUI: rows come from the flattened configuration (composed members included); grouping by first canonical segment with nested modules folded; group header = connection + Object + Apply (`.`, blank clears); pills `n unbound` / `mixed` / `n changed · unsaved`; show-only-unbound; Save posts every row; Test Bindings covers composed rows; flatten failure → direct-attribute fallback with banner. State the gap it closed (264/280 rows invisible on QA, 2026-08-20). +- TemplateEngine: `ResolvedAttribute.DefaultDataSourceReference` (pre-override default, central-side presentation, excluded from the revision hash, pinned by `DefaultDataSourceReferenceTests`). +- CLI README: syntax, the `(instance)` module key, `--dry-run`, the "module must already have bindings; seed with set-bindings" rule, and an example mirroring QA: `scadabridge instance bind-module --id 1 --module RightMESReceiver --connection 1 --object MESReceiver_022`. + +Commit: + +```bash +git add docs/requirements/Component-CentralUI.md docs/requirements/Component-TemplateEngine.md src/ZB.MOM.WW.ScadaBridge.CLI/README.md docs/plans/2026-08-20-grouped-connection-bindings-design.md +git commit -m "docs: grouped connection bindings — Central UI Configure page, ResolvedAttribute.DefaultDataSourceReference, CLI instance bind-module" +``` + +--- + +### Task 9: Solution build + impacted suites + +**Classification:** small +**Estimated implement time:** ~5 min (build time) +**Parallelizable with:** none +**Depends on:** all + +Run, in the worktree: + +```bash +dotnet build ZB.MOM.WW.ScadaBridge.slnx -v q 2>&1 | tail -3 # expect 0 errors; no new warnings in touched projects +dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests -v q +dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests -v q +dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests -v q +dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests -v q # consumes FlattenedConfiguration +``` + +All green → `git log --oneline main..HEAD` and hand off to `superpowers-extended-cc:finishing-a-development-branch` (fast-forward onto `main`; no EF migrations in this plan). Remind the user that QA (vq03) only sees the page after a host redeploy. diff --git a/docs/plans/2026-08-20-grouped-connection-bindings.md.tasks.json b/docs/plans/2026-08-20-grouped-connection-bindings.md.tasks.json new file mode 100644 index 00000000..88f6aca0 --- /dev/null +++ b/docs/plans/2026-08-20-grouped-connection-bindings.md.tasks.json @@ -0,0 +1,15 @@ +{ + "planPath": "docs/plans/2026-08-20-grouped-connection-bindings.md", + "tasks": [ + {"id": 1, "subject": "Task 1: ResolvedAttribute.DefaultDataSourceReference (TemplateEngine)", "status": "pending"}, + {"id": 2, "subject": "Task 2: BindingGroupBuilder — grouping + object-prefix inference", "status": "pending"}, + {"id": 3, "subject": "Task 3: BindingGroupBuilder.ApplyObject", "status": "pending", "blockedBy": [2]}, + {"id": 4, "subject": "Task 4: Configure page — source binding rows from the flattened config", "status": "pending", "blockedBy": [1, 3]}, + {"id": 5, "subject": "Task 5: Configure page — grouped markup with per-module Object apply", "status": "pending", "blockedBy": [4]}, + {"id": 6, "subject": "Task 6: Live check on the docker rig", "status": "pending", "blockedBy": [5]}, + {"id": 7, "subject": "Task 7: CLI instance bind-module", "status": "pending"}, + {"id": 8, "subject": "Task 8: Docs", "status": "pending", "blockedBy": [5]}, + {"id": 9, "subject": "Task 9: Solution build + impacted suites", "status": "pending", "blockedBy": [1,2,3,4,5,6,7,8]} + ], + "lastUpdated": "2026-08-20T19:45:00Z" +}