74c7bd2fa8
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
161 lines
7.2 KiB
C#
161 lines
7.2 KiB
C#
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;
|
|
}
|
|
}
|