diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor index c410c116..3c0e6f2e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor @@ -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 @@
- @foreach (var t in _templates) { @@ -43,7 +51,9 @@
- @foreach (var s in _sites) { @@ -65,12 +75,155 @@ {
@_formError
} -
- - -
+ @if (!BindingsCardVisible) + { +
+ + +
+ }
+ + @* 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) + { +
+
+ Connection Bindings + @if (_previewLoading) + { + + } + else + { + @_rowCount data-sourced attribute(s) in @_groups.Count group(s) + } + + @if (UnboundGroups > 0 && !_skipBindings) + { + @UnboundGroups group(s) unbound + } + preview — nothing saved yet +
+ @if (_previewError is not null) + { +
+ Could not preview this template at the selected site (@_previewError). You can create the instance and bind on Configure. +
+ } + else if (!_previewLoading && _rowCount == 0) + { +
Nothing to bind in this template.
+ } + else if (_siteConnections.Count == 0) + { +
This site has no data connections — add one under Sites, or create now and bind later.
+ } + else if (!_skipBindings) + { +
+ Bind all to + + + + + Fills the connection on every group and suggests an Object for blanks; never overwrites an Object you typed. +
+ @foreach (var g in _groups) + { + var input = _inputs.GetValueOrDefault(g.Key); + var source = CreateBindingPlanner.SourceOf(g, input); +
+ @g.Key + @if (g.IsInstanceGroup) + { + direct attributes + } + else if (g.NestedModules.Count > 0) + { + incl. @string.Join(", ", g.NestedModules) + } + + @g.Members.Count + + + + @if (source == CreateBindingPlanner.ObjectSource.Suggested) + { + suggested + } + else if (source == CreateBindingPlanner.ObjectSource.FromTemplate) + { + from template + } + @if (IsGroupBound(g)) + { + bound + } + else + { + @g.Members.Count unbound + } +
+ } + } + +
+ + @* Address-space picker. SelectableNodeClasses adds Object so a module's + container node can be chosen, not just a leaf tag. *@ + + } } @@ -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) { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs new file mode 100644 index 00000000..643be712 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.cs @@ -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; + +/// +/// Code-behind for InstanceCreate.razor — the Connection Bindings card +/// (design 2026-08-21). The card flattens the chosen template AGAINST the chosen +/// site through IFlatteningPipeline.PreviewAsync (no instance row exists yet), +/// groups the data-sourced attributes with the same +/// the Configure page uses, and collects one connection + Object per group. The pure +/// prefill / tail-strip / binding-list logic lives in +/// so it is unit-pinned without rendering the page. +/// +public partial class InstanceCreate +{ + // --- Connection Bindings card state (design 2026-08-21) --- + private IReadOnlyList _groups = Array.Empty(); + private int _rowCount; + private List _siteConnections = new(); + private readonly Dictionary _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); + + /// + /// Called from the Template and Site selects' @onchange; re-runs the preview + /// flatten and rebuilds the groups. No-ops until both a template and a site are chosen. + /// + private async Task OnTemplateOrSiteChangedAsync() + { + _groups = Array.Empty(); + _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 ─────────────────────────────────────────────── + + /// Object as well as Variable: a module group binds to a container node. + 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 + + /// + /// True when the selected data connection supports address-space browsing. OPC UA + /// and MxGateway both implement IBrowsableDataConnection site-side; other + /// protocols return a NotBrowsable failure, so the button stays disabled for them. + /// (Mirrors the Configure page's identical gate.) + /// + 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); + } + + /// + /// Opens the picker for one group, or for the root object when + /// is null. Remembers the target so + /// writes the pick back to the right input. + /// + 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); + } + + /// + /// A group pick is normalised through , + /// so selecting a leaf tag under the module still yields the module's object. + /// + private void OnBrowserSelected(string nodeId) + { + if (_browserTarget is null) _rootObject = nodeId; + else SetGroupObject(_browserTarget, CreateBindingPlanner.ObjectFromPick(_browserTarget, nodeId)); + _browserTarget = null; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.css new file mode 100644 index 00000000..4acba78c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceCreate.razor.css @@ -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; +}