feat(centralui): grouped Connection Bindings panel — rows sourced from the flattened config (composed module members were invisible; flatten failure falls back to direct attributes with a banner), per-module connection + Object apply, unbound/mixed pills, show-only-unbound filter, Test Bindings covers composed rows, per-row Browse retained
This commit is contained in:
+337
-105
@@ -12,6 +12,8 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@@ -79,106 +81,194 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Connection Bindings *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
@* Connection Bindings — grouped by module (design 2026-08-20). Rows come
|
||||
from the flattened configuration so composed module members appear;
|
||||
each group header carries a connection + Object apply that rewrites
|
||||
every member to <object>.<memberTail>. *@
|
||||
<div class="card mb-3 binding-panel">
|
||||
<div class="card-header py-2 d-flex align-items-center gap-3 flex-wrap">
|
||||
<strong>Connection Bindings</strong>
|
||||
@if (_bindingDataSourceAttrs.Count > 0 && _siteConnections.Count > 0)
|
||||
<span class="text-muted small">@_bindingRows.Count data-sourced attribute(s) in @_bindingGroups.Count group(s)</span>
|
||||
<span class="ms-auto"></span>
|
||||
@if (TotalUnbound > 0)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<select class="form-select form-select-sm" style="width: auto;" data-test="binding-bulk-select" @bind="_bulkConnectionId">
|
||||
<option value="0">Assign all to...</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
<option value="@c.Id">@c.Name (@c.Protocol)</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="ApplyBulkBinding"
|
||||
disabled="@(_bulkConnectionId == 0)">Apply</button>
|
||||
</div>
|
||||
<span class="badge text-bg-warning" data-test="binding-total-unbound">@TotalUnbound unbound</span>
|
||||
}
|
||||
@if (MixedGroups > 0)
|
||||
{
|
||||
<span class="badge text-bg-secondary">@MixedGroups group(s) mixed</span>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (_bindingDataSourceAttrs.Count == 0)
|
||||
@if (_bindingSourceWarning is not null)
|
||||
{
|
||||
<div class="alert alert-warning rounded-0 mb-0 py-2 small" data-test="binding-source-warning">@_bindingSourceWarning</div>
|
||||
}
|
||||
@if (_bindingRows.Count == 0)
|
||||
{
|
||||
<div class="card-body text-muted small">No data-sourced attributes in this template.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card-body border-bottom py-2 d-flex align-items-center gap-3 flex-wrap small">
|
||||
@if (_siteConnections.Count > 0)
|
||||
{
|
||||
<label class="d-flex align-items-center gap-2 mb-0">All groups
|
||||
<select class="form-select form-select-sm" style="width: auto;" data-test="binding-bulk-select" @bind="_bulkConnectionId">
|
||||
<option value="0">Assign all to...</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
<option value="@c.Id">@c.Name (@c.Protocol)</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="ApplyBulkBinding"
|
||||
disabled="@(_bulkConnectionId == 0)">Apply connection</button>
|
||||
</label>
|
||||
}
|
||||
<span class="ms-auto"></span>
|
||||
<label class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" @bind="_showOnlyUnbound" /> Show only unbound
|
||||
</label>
|
||||
<button class="btn btn-outline-secondary btn-sm" data-test="binding-expand-all" @onclick="ExpandAll">Expand all</button>
|
||||
</div>
|
||||
|
||||
@foreach (var g in VisibleGroups)
|
||||
{
|
||||
<p class="text-muted small p-3 mb-0">No data-sourced attributes in this template.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Attribute</th>
|
||||
<th>Tag Path</th>
|
||||
<th style="width: 280px;">Connection</th>
|
||||
<th>Override</th>
|
||||
<th style="width: 110px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var attr in _bindingDataSourceAttrs)
|
||||
var open = _expandedGroups.Contains(g.Key);
|
||||
<div class="binding-group border-bottom">
|
||||
<div class="binding-group-header d-flex align-items-center gap-2 px-3 py-2 @(open ? "bg-light" : "")" data-test="binding-group-header">
|
||||
<button class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="() => ToggleGroup(g.Key)"
|
||||
aria-expanded="@(open ? "true" : "false")" title="@(open ? "Collapse" : "Expand")">@(open ? "▼" : "▶")</button>
|
||||
<span class="fw-semibold binding-group-name">@g.Key
|
||||
@if (g.IsInstanceGroup)
|
||||
{
|
||||
<small class="text-muted fw-normal ms-1">direct attributes</small>
|
||||
}
|
||||
else if (g.NestedModules.Count > 0)
|
||||
{
|
||||
<small class="text-muted fw-normal ms-1">incl. @string.Join(", ", g.NestedModules)</small>
|
||||
}
|
||||
</span>
|
||||
<span class="text-muted small">@g.Members.Count</span>
|
||||
<select class="form-select form-select-sm" style="width: 160px;" data-test="binding-group-connection"
|
||||
value="@GroupConnectionInput(g)"
|
||||
@onchange="e => _groupConnectionInput[g.Key] = int.TryParse(e.Value?.ToString(), out var v) ? v : 0">
|
||||
<option value="0">@(g.ConnectionState == BindingConnectionState.Mixed ? "— mixed —" : "— none —")</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
<option value="@c.Id">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
<input class="form-control form-control-sm font-monospace" style="max-width: 260px;" data-test="binding-group-object"
|
||||
value="@GroupObjectInput(g)"
|
||||
placeholder="@(g.IsObjectMixed ? "mixed — enter object to unify" : "object, e.g. MESReceiver_022")"
|
||||
title="Object path the module's members hang off; Apply rewrites every member to <object>.<member>. Blank clears the overrides."
|
||||
@onchange="e => _groupObjectInput[g.Key] = e.Value?.ToString()" />
|
||||
<button class="btn btn-outline-primary btn-sm text-nowrap" data-test="binding-group-apply"
|
||||
@onclick="() => ApplyGroupObject(g)" disabled="@_saving">Apply to @g.Members.Count</button>
|
||||
@if (g.UnboundCount > 0)
|
||||
{
|
||||
var connId = GetBindingConnectionId(attr.Name);
|
||||
var canBrowse = connId > 0;
|
||||
var isBrowsable = IsBrowsable(connId);
|
||||
<tr>
|
||||
<td class="small">@attr.Name</td>
|
||||
@* Tag paths are OPC UA node ids (nsu=…;s=…) — unbounded
|
||||
text that would otherwise set the table's width.
|
||||
.cell-clip is display:block, hence the span. *@
|
||||
<td class="small text-muted font-monospace">
|
||||
<span class="cell-clip" title="@attr.DataSourceReference">@attr.DataSourceReference</span>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm"
|
||||
value="@connId"
|
||||
@onchange="(e) => OnBindingChanged(attr.Name, e)">
|
||||
<option value="0">— none —</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
<option value="@c.Id">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input class="form-control form-control-sm"
|
||||
value="@GetOverrideForAttr(attr.Name)"
|
||||
@onchange="(e) => OnOverrideForAttrChanged(attr.Name, e)"
|
||||
placeholder="@(attr.DataSourceReference ?? "(no default)")" />
|
||||
</td>
|
||||
<td>
|
||||
@if (isBrowsable)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
disabled="@(!canBrowse)"
|
||||
title="@(canBrowse ? "Browse address space" : "Pick a connection first")"
|
||||
@onclick="() => OpenBrowser(attr.Name)">
|
||||
Browse…
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<span class="badge text-bg-danger" data-test="binding-group-unbound">@g.UnboundCount unbound</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge text-bg-success">bound</span>
|
||||
}
|
||||
@if (g.IsObjectMixed || g.ConnectionState == BindingConnectionState.Mixed)
|
||||
{
|
||||
<span class="badge text-bg-secondary" data-test="binding-group-mixed">mixed</span>
|
||||
}
|
||||
@if (_groupChangedCounts.TryGetValue(g.Key, out var changed) && changed > 0)
|
||||
{
|
||||
<span class="badge border text-muted">@changed changed · unsaved</span>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="p-2">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Binding actions">
|
||||
<button class="btn btn-success" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
|
||||
@* Test Bindings: one-shot live read of every bound attribute
|
||||
whose row has a connection picked AND an effective tag
|
||||
path. Disabled when no testable rows. Protocol-agnostic —
|
||||
any connection whose adapter implements ReadBatchAsync
|
||||
(OPC UA and MxGateway today) round-trips through
|
||||
ReadTagValuesCommand. *@
|
||||
<button class="btn btn-outline-primary"
|
||||
@onclick="OpenTestBindings"
|
||||
disabled="@(!HasTestableBindings())">
|
||||
Test Bindings
|
||||
</button>
|
||||
</div>
|
||||
@if (open)
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Template default</th>
|
||||
<th style="width: 200px;">Connection</th>
|
||||
<th>Reference override</th>
|
||||
<th style="width: 110px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var m in VisibleMembers(g))
|
||||
{
|
||||
var name = m.Row.CanonicalName;
|
||||
var connId = GetBindingConnectionId(name);
|
||||
var canBrowse = connId > 0;
|
||||
var isBrowsable = IsBrowsable(connId);
|
||||
var bare = !g.IsInstanceGroup && string.Equals(m.Row.DefaultReference, m.MemberTail, StringComparison.Ordinal);
|
||||
<tr class="@(m.IsUnbound ? "table-danger" : "")">
|
||||
<td class="small font-monospace ps-4">@m.MemberTail</td>
|
||||
@* Defaults can be OPC UA node ids (nsu=…;s=…) — unbounded
|
||||
text that would otherwise set the table's width. *@
|
||||
<td class="small text-muted font-monospace">
|
||||
<span class="cell-clip" title="@m.Row.DefaultReference">@m.Row.DefaultReference</span>
|
||||
@if (bare)
|
||||
{
|
||||
<span class="ms-1 text-warning" data-test="binding-bare-default"
|
||||
title="Composed members share a bare default name; set an override (or apply an Object on the group) or the deploy gate rejects the instance.">⚠</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm"
|
||||
value="@connId"
|
||||
@onchange="(e) => OnBindingChanged(name, e)">
|
||||
<option value="0">— none —</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
<option value="@c.Id">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input class="form-control form-control-sm font-monospace"
|
||||
value="@GetOverrideForAttr(name)"
|
||||
@onchange="(e) => OnOverrideForAttrChanged(name, e)"
|
||||
placeholder="@(m.Row.DefaultReference ?? "(no default)")" />
|
||||
</td>
|
||||
<td>
|
||||
@if (isBrowsable)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
disabled="@(!canBrowse)"
|
||||
title="@(canBrowse ? "Browse address space" : "Pick a connection first")"
|
||||
@onclick="() => OpenBrowser(name)">
|
||||
Browse…
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="card-footer d-flex align-items-center gap-2 flex-wrap">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Binding actions">
|
||||
<button class="btn btn-success" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
|
||||
@* Test Bindings: one-shot live read of every bound attribute
|
||||
whose row has a connection picked AND an effective tag
|
||||
path — composed rows included. Protocol-agnostic — any
|
||||
connection whose adapter implements ReadBatchAsync
|
||||
(OPC UA and MxGateway today) round-trips through
|
||||
ReadTagValuesCommand. *@
|
||||
<button class="btn btn-outline-primary"
|
||||
@onclick="OpenTestBindings"
|
||||
disabled="@(!HasTestableBindings())">
|
||||
Test Bindings (@BuildTestableRows().Count)
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-muted small ms-2">Saved bindings reach the site on the next Deploy.@(TotalUnbound > 0 ? $" {TotalUnbound} row(s) are unbound — Deploy will be rejected until they are bound." : "")</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Attribute Overrides *@
|
||||
@@ -629,8 +719,17 @@
|
||||
private string? _errorMessage;
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
// Bindings
|
||||
private List<TemplateAttribute> _bindingDataSourceAttrs = new();
|
||||
// ── Bindings (design 2026-08-20: rows = flattened attributes, grouped by module) ──
|
||||
private List<BindingRow> _bindingRows = new();
|
||||
private IReadOnlyList<BindingGroup> _bindingGroups = Array.Empty<BindingGroup>();
|
||||
private string? _bindingSourceWarning;
|
||||
/// <summary>Template default per canonical name (placeholder text).</summary>
|
||||
private readonly Dictionary<string, string?> _bindingDefaults = new(StringComparer.Ordinal);
|
||||
/// <summary>
|
||||
/// The flattened configuration the binding rows were built from; reused by the
|
||||
/// alarm attribute picker so the page flattens once, not twice.
|
||||
/// </summary>
|
||||
private FlattenedConfiguration? _flattenedForBindings;
|
||||
private List<DataConnection> _siteConnections = new();
|
||||
private Dictionary<string, int> _bindingSelections = new();
|
||||
/// <summary>
|
||||
@@ -758,9 +857,9 @@
|
||||
_reassignAreaId = _instance.AreaId ?? 0;
|
||||
_areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? "";
|
||||
|
||||
// Bindings
|
||||
// Bindings — existing rows first, then the flattened row source
|
||||
// (composed module members included) projected over them.
|
||||
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(_instance.TemplateId);
|
||||
_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).ToList();
|
||||
_siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_instance.SiteId)).ToList();
|
||||
var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(Id);
|
||||
foreach (var b in existingBindings)
|
||||
@@ -769,6 +868,7 @@
|
||||
if (!string.IsNullOrEmpty(b.DataSourceReferenceOverride))
|
||||
_bindingOverrides[b.AttributeName] = b.DataSourceReferenceOverride;
|
||||
}
|
||||
await LoadBindingRowsAsync(attrs);
|
||||
|
||||
// Overrides
|
||||
_overrideAttrs = attrs.Where(a => !a.IsLocked).ToList();
|
||||
@@ -846,15 +946,132 @@
|
||||
var val = int.TryParse(e.Value?.ToString(), out var id) ? id : 0;
|
||||
if (val == 0) _bindingSelections.Remove(attrName);
|
||||
else _bindingSelections[attrName] = val;
|
||||
RebuildBindingGroups();
|
||||
}
|
||||
|
||||
/// <summary>All-groups connection select: binds every row to one connection (overrides untouched).</summary>
|
||||
private void ApplyBulkBinding()
|
||||
{
|
||||
if (_bulkConnectionId == 0) return;
|
||||
foreach (var attr in _bindingDataSourceAttrs)
|
||||
_bindingSelections[attr.Name] = _bulkConnectionId;
|
||||
foreach (var row in _bindingRows)
|
||||
_bindingSelections[row.CanonicalName] = _bulkConnectionId;
|
||||
RebuildBindingGroups();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the binding rows from the flattened configuration so composed module
|
||||
/// members appear. Falls back to the template's direct attributes — with a visible
|
||||
/// warning — only when the flatten itself fails; a validation failure (e.g. unbound
|
||||
/// attributes, which is exactly what this panel exists to fix) is NOT a reason to
|
||||
/// fall back (the pipeline reports validation inside a Success result).
|
||||
/// </summary>
|
||||
private async Task LoadBindingRowsAsync(IReadOnlyList<TemplateAttribute> directAttrs)
|
||||
{
|
||||
_bindingSourceWarning = null;
|
||||
IEnumerable<(string Name, string? Default)> source;
|
||||
try
|
||||
{
|
||||
var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id, validateScripts: false);
|
||||
if (flat.IsFailure)
|
||||
{
|
||||
_bindingSourceWarning = $"Could not flatten this instance ({flat.Error}); showing the template's direct attributes only.";
|
||||
source = DirectAttributeRows(directAttrs);
|
||||
}
|
||||
else
|
||||
{
|
||||
_flattenedForBindings = flat.Value.Configuration;
|
||||
source = flat.Value.Configuration.Attributes
|
||||
.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 = DirectAttributeRows(directAttrs);
|
||||
}
|
||||
|
||||
_bindingDefaults.Clear();
|
||||
_bindingRows = source.Select(s =>
|
||||
{
|
||||
_bindingDefaults[s.Name] = s.Default;
|
||||
return new BindingRow(s.Name, s.Default, null, null);
|
||||
}).ToList();
|
||||
RebuildBindingGroups();
|
||||
}
|
||||
|
||||
private static IEnumerable<(string Name, string? Default)> DirectAttributeRows(IReadOnlyList<TemplateAttribute> directAttrs)
|
||||
=> directAttrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).Select(a => (a.Name, a.DataSourceReference));
|
||||
|
||||
/// <summary>Re-projects the current selections/overrides into the rows, then regroups.</summary>
|
||||
private void RebuildBindingGroups()
|
||||
{
|
||||
_bindingRows = _bindingRows
|
||||
.Select(r => r with
|
||||
{
|
||||
ConnectionId = _bindingSelections.TryGetValue(r.CanonicalName, out var c) ? c : null,
|
||||
Override = _bindingOverrides.GetValueOrDefault(r.CanonicalName)
|
||||
})
|
||||
.ToList();
|
||||
_bindingGroups = BindingGroupBuilder.Build(_bindingRows);
|
||||
}
|
||||
|
||||
private int TotalUnbound => _bindingGroups.Sum(g => g.UnboundCount);
|
||||
private int MixedGroups => _bindingGroups.Count(g => g.IsObjectMixed || g.ConnectionState == BindingConnectionState.Mixed);
|
||||
|
||||
// ── Group UI state (expand/collapse, per-group header inputs, unsaved counters) ──
|
||||
private readonly HashSet<string> _expandedGroups = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string?> _groupObjectInput = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, int?> _groupConnectionInput = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, int> _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); }
|
||||
|
||||
/// <summary>Header Object input: the operator's pending edit, else the inferred shared prefix.</summary>
|
||||
private string? GroupObjectInput(BindingGroup g)
|
||||
=> _groupObjectInput.TryGetValue(g.Key, out var v) ? v : g.InferredObject;
|
||||
|
||||
/// <summary>Header connection select: the operator's pending edit, else the group's common connection.</summary>
|
||||
private int GroupConnectionInput(BindingGroup g)
|
||||
=> _groupConnectionInput.TryGetValue(g.Key, out var v) ? v ?? 0 : g.CommonConnectionId ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Per-module bulk apply: rewrites every member of the group to
|
||||
/// <c>{object}.{memberTail}</c> on the header's connection (blank object clears
|
||||
/// the overrides). Edits only the in-memory selection/override maps — Save
|
||||
/// Bindings persists them — and counts rows that actually changed for the
|
||||
/// "n changed · unsaved" pill.
|
||||
/// </summary>
|
||||
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);
|
||||
_expandedGroups.Add(g.Key);
|
||||
RebuildBindingGroups();
|
||||
}
|
||||
|
||||
private IEnumerable<BindingGroupMember> VisibleMembers(BindingGroup g)
|
||||
=> _showOnlyUnbound ? g.Members.Where(m => m.IsUnbound) : g.Members;
|
||||
|
||||
private IEnumerable<BindingGroup> VisibleGroups
|
||||
=> _showOnlyUnbound ? _bindingGroups.Where(g => g.UnboundCount > 0) : _bindingGroups;
|
||||
|
||||
// ── Task 18: per-attribute override input + OPC UA tag browser ──────────
|
||||
|
||||
private string? GetOverrideForAttr(string attrName)
|
||||
@@ -867,11 +1084,12 @@
|
||||
_bindingOverrides.Remove(attrName);
|
||||
else
|
||||
_bindingOverrides[attrName] = val;
|
||||
RebuildBindingGroups();
|
||||
}
|
||||
|
||||
/// <summary>Looks up the template default <c>DataSourceReference</c> for an attribute.</summary>
|
||||
private string? GetTemplateDefault(string attrName)
|
||||
=> _bindingDataSourceAttrs.FirstOrDefault(a => a.Name == attrName)?.DataSourceReference;
|
||||
=> _bindingDefaults.GetValueOrDefault(attrName);
|
||||
|
||||
/// <summary>
|
||||
/// True when the row's selected data connection supports address-space browsing
|
||||
@@ -913,6 +1131,7 @@
|
||||
if (_browserAttrInEdit is null) return;
|
||||
_bindingOverrides[_browserAttrInEdit] = nodeId;
|
||||
_browserAttrInEdit = null;
|
||||
RebuildBindingGroups();
|
||||
}
|
||||
|
||||
// ── Test Bindings (one-shot live read of bound tags) ────────────────────
|
||||
@@ -927,9 +1146,11 @@
|
||||
private List<TestBindingsDialog.BindingRowToTest> BuildTestableRows()
|
||||
{
|
||||
var rows = new List<TestBindingsDialog.BindingRowToTest>();
|
||||
foreach (var attr in _bindingDataSourceAttrs)
|
||||
foreach (var g in _bindingGroups)
|
||||
foreach (var m in g.Members)
|
||||
{
|
||||
var connId = GetBindingConnectionId(attr.Name);
|
||||
var name = m.Row.CanonicalName;
|
||||
var connId = GetBindingConnectionId(name);
|
||||
if (connId <= 0) continue;
|
||||
|
||||
var conn = _siteConnections.FirstOrDefault(c => c.Id == connId);
|
||||
@@ -941,11 +1162,11 @@
|
||||
// or unsupported connection short-circuits to a typed banner in the
|
||||
// dialog rather than being filtered out here — mirrors IsBrowsable.
|
||||
|
||||
var effectivePath = _bindingOverrides.GetValueOrDefault(attr.Name)
|
||||
?? GetTemplateDefault(attr.Name);
|
||||
var effectivePath = _bindingOverrides.GetValueOrDefault(name)
|
||||
?? GetTemplateDefault(name);
|
||||
if (string.IsNullOrWhiteSpace(effectivePath)) continue;
|
||||
|
||||
rows.Add(new TestBindingsDialog.BindingRowToTest(attr.Name, conn.Name, effectivePath));
|
||||
rows.Add(new TestBindingsDialog.BindingRowToTest(name, conn.Name, effectivePath));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -977,7 +1198,10 @@
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_groupChangedCounts.Clear();
|
||||
_toast.ShowSuccess($"Saved {bindings.Count} connection binding(s).");
|
||||
}
|
||||
else
|
||||
_toast.ShowError($"Save failed: {result.Error}");
|
||||
}
|
||||
@@ -1649,10 +1873,18 @@
|
||||
|
||||
try
|
||||
{
|
||||
var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id);
|
||||
if (flat.IsFailure) return fallback;
|
||||
// Reuse the flatten the bindings panel already performed (design
|
||||
// 2026-08-20) so the page flattens once; flatten afresh only when
|
||||
// that path fell back.
|
||||
var configuration = _flattenedForBindings;
|
||||
if (configuration is null)
|
||||
{
|
||||
var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id, validateScripts: false);
|
||||
if (flat.IsFailure) return fallback;
|
||||
configuration = flat.Value.Configuration;
|
||||
}
|
||||
|
||||
return flat.Value.Configuration.Attributes
|
||||
return configuration.Attributes
|
||||
.Select(a => new AlarmAttributeChoice(
|
||||
a.CanonicalName,
|
||||
MapDataType(a.DataType),
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/* Connection Bindings — grouped panel (design 2026-08-20) */
|
||||
.binding-group-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.binding-group-header .form-select,
|
||||
.binding-group-header .form-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.binding-group-name {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.binding-group:last-of-type {
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
Reference in New Issue
Block a user