@page "/deployment/instances/{Id:int}/configure" @using ZB.MOM.WW.ScadaBridge.Security @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.Commons.Types @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening @using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services @using ZB.MOM.WW.ScadaBridge.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 @inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope @inject InstanceService InstanceService @inject IFlatteningPipeline FlatteningPipeline @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager NavigationManager

Configure Instance

@* Drill-in: deep-link into the central Audit Log pre-filtered to this instance. Instance is UI-only on the filter bar (AuditEvent has no Instance column), so we use the ?instance= UI-text seam — the filter bar's Instance free-text input is pre-populated. *@ @if (_instance != null) { Recent audit activity }
@if (_loading) { } else if (_errorMessage != null) {
@_errorMessage
} else if (_instance != null) { @* Instance Identity *@
Instance
@_instance.UniqueName
Template
@_templateName
Site
@_siteName
Status
@_instance.State
Area
@(_instance.AreaId.HasValue ? _areaName : "—")
@* One-shot notice for an instance that just arrived from the Create page (design 2026-08-21). Query-string only — nothing is persisted, so it disappears on any later visit. *@ @if (Created == 1) {
Instance created. @if (BindError is not null) { Bindings were NOT saved: @BindError — bind the groups below. } else if (Bound is > 0) { @Bound binding(s) saved. Test Bindings, then Deploy. } else { Bind the groups below, then Deploy. }
} @* 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 .. *@
Connection Bindings @_bindingRows.Count data-sourced attribute(s) in @_bindingGroups.Count group(s) @if (TotalUnbound > 0) { @TotalUnbound unbound } @if (MixedGroups > 0) { @MixedGroups group(s) mixed }
@if (_bindingSourceWarning is not null) {
@_bindingSourceWarning
} @if (_bindingRows.Count == 0) {
No data-sourced attributes in this template.
} else {
@if (_siteConnections.Count > 0) { }
@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 (GroupChangedCount(g) is var changed && changed > 0) { @changed changed · unsaved }
@if (open) {
@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); @* Defaults can be OPC UA node ids (nsu=…;s=…) — unbounded text that would otherwise set the table's width. *@ }
Member Template default Connection Reference override
@m.MemberTail @m.Row.DefaultReference @if (bare) { ⚠ } @if (isBrowsable) { }
}
} }
@* Attribute Overrides *@
Attribute Overrides @* Bulk import of attribute overrides from a CSV (AttributeName,Value[,ElementType]). Selecting a file parses + validates it against this instance's overridable attributes and — all-or-nothing — applies every row through the SAME SetAttributeOverrideAsync path the manual editor uses, or shows the per-line error list and applies nothing. *@ @if (_overrideAttrs.Count > 0) { }
@if (_csvImportResult is not null) {
@if (_csvImportSucceeded) { @_csvImportResult } else {
@_csvImportResult
    @foreach (var err in _csvImportErrors) {
  • @err
  • }
}
} @if (_overrideAttrs.Count == 0) {

No overridable (non-locked) attributes in this template.

} else { @foreach (var attr in _overrideAttrs) { @* Template values are unbounded — canonical JSON for a List attribute. Clip, full value on the title. *@ }
Attribute Type Template Value Override Value
@attr.Name @attr.DataType @if (attr.DataType == DataType.List) { @* Element type is fixed by the base attribute — shown read-only here (the List editor renders it hidden via ShowElementType="false"). *@ of @(attr.ElementDataType ?? DataType.String) } @(attr.Value ?? "—") @if (attr.DataType == DataType.List) { @* Whole-list replacement: the shared editor renders the element-type select hidden (fixed by the base) plus the repeatable rows. Clearing removes the override row. *@ @if (_overrideErrors.TryGetValue(attr.Name, out var listErr)) { @* Bounded: this alert lives inside a table cell, so an unbounded message would widen the column. *@
@listErr
} @if (HasOverrideRow(attr.Name)) { } } else { }
}
@* Alarm Overrides *@
Alarm Overrides
@if (_overridableAlarms.Count == 0) {

No overridable (non-locked) alarms on this template.

} else { @foreach (var alarm in _overridableAlarms) { // Comma-joined list of overridden JSON keys — unbounded key // count, so it is clipped with the full text on the title. var overrideSummary = HasOverride(alarm.Name) ? OverrideSummary(alarm.Name) : null; }
Alarm Trigger Inherited Config Override Actions
@alarm.Name @alarm.TriggerType @(alarm.TriggerConfiguration ?? "—") @if (HasOverride(alarm.Name)) { @* .cell-clip is display:block, so the badge and the clipped summary share a flex line. *@ ● @overrideSummary } else { inherited }
@if (HasOverride(alarm.Name)) { }
}
@* Override edit modal *@ @if (_editingAlarm != null) { } @* Native Alarm Source Overrides *@
Native Alarm Source Overrides @* Kept: the blank-means-inherited rule is the only in-context decoder for the three override inputs below. *@ Leave a field blank to keep the inherited value.
@* Bulk import of native alarm source retargets from a CSV (SourceName,Connection,SourceReference,Filter) — the SAME file format and batch rules as the CLI `instance native-alarm-source import --file`. Selecting a file parses it with the shared NativeAlarmSourceOverrideCsvParser and validates every row against this template's sources; all-or-nothing, it either upserts each row through the SAME repository path the inline Save uses, or shows the per-line error list and applies nothing. Merge semantics — sources absent from the file keep their existing override. *@ @if (_nativeSources.Count > 0) { }
@if (_nasCsvImportResult is not null) {
@if (_nasCsvImportSucceeded) { @_nasCsvImportResult } else {
@_nasCsvImportResult
    @foreach (var err in _nasCsvImportErrors) {
  • @err
  • }
}
} @if (_nativeSources.Count == 0) {

No native alarm sources on this template.

} else { @foreach (var src in _nativeSources) { @* Two identifiers — connection then source reference — stacked rather than slammed together with a slash. *@ }
Source Inherited Connection override Source reference override Filter override Actions
@src.Name @if (HasNativeOverride(src.Name)) { ● }
@src.ConnectionName
@src.SourceReference
@if (HasNativeOverride(src.Name)) { }
}
@* Area Assignment *@
Area Assignment
@* OPC UA Tag Browser dialog (Task 18) — rendered once; OpenBrowser tracks which binding row's override input receives the picked node id. *@ @* Test Bindings dialog — one-shot live read of every bound attribute. Method-arg ShowAsync(siteId, rows) — no Razor parameter propagation race (same pattern as NodeBrowserDialog). *@ } @code { [Parameter] public int Id { get; set; } // Set by the Create Instance page's post-create redirect (design 2026-08-21): // Created=1 always, then either the count of bindings saved or the error that // stopped them being saved. Read-only display — never persisted. [SupplyParameterFromQuery] public int? Created { get; set; } [SupplyParameterFromQuery] public int? Bound { get; set; } [SupplyParameterFromQuery] public string? BindError { get; set; } private Instance? _instance; private string _templateName = ""; private string _siteName = ""; private string _areaName = ""; private bool _loading = true; private bool _saving; private string? _errorMessage; private ToastNotification _toast = default!; // ── 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); /// /// The flattened configuration the binding rows were built from; reused by the /// alarm attribute picker so the page flattens once, not twice. /// private FlattenedConfiguration? _flattenedForBindings; private List _siteConnections = new(); private Dictionary _bindingSelections = new(); /// /// Per-attribute DataSourceReferenceOverride values (Task 18). Mirrors /// by attribute name. Loaded from the /// existing rows on init; round-tripped /// through on SaveBindings. /// private Dictionary _bindingOverrides = new(); private int _bulkConnectionId; // OPC UA tag browser (Task 18) — single dialog rendered at page bottom; // _browserAttrInEdit tracks which row gets the picked node id on Select. private NodeBrowserDialog? _browserRef; private string? _browserAttrInEdit; private string _browserSiteIdentifier = ""; private string _browserConnectionName = ""; private string? _browserInitial; private string _siteIdentifier = ""; // Test Bindings dialog — single instance, args passed via ShowAsync (no // Razor parameter propagation race; same pattern as the OPC UA browser). private TestBindingsDialog? _testBindingsRef; // Overrides private List _overrideAttrs = new(); private Dictionary _overrideValues = new(); // MV-14: existing override rows keyed by attribute name — tracks which // attributes already have a persisted override (so List rows know whether a // Clear is available) and carries the row Id for repository-direct delete. private Dictionary _existingOverrides = new(); // MV-14: per-List working rows (whole-list replacement), keyed by attribute // name. Seeded on load from the effective value; encoded to canonical JSON on // save. Element type is fixed by the base attribute. private Dictionary> _listRows = new(); // MV-14: per-attribute validation errors surfaced inline (e.g. an // un-parseable List element caught on the pre-submit round-trip). private Dictionary _overrideErrors = new(); // CSV bulk-import result summary. _csvImportResult is the headline // ("Imported N overrides." or "Import rejected — N error(s)."); on failure the // per-line messages are listed from _csvImportErrors. Null until an import runs. private string? _csvImportResult; private bool _csvImportSucceeded; private IReadOnlyList _csvImportErrors = Array.Empty(); // Reject pathologically large uploads before buffering — a few hundred KB of // override CSV is already extreme (thousands of attributes). private const long MaxCsvImportBytes = 512 * 1024; // Alarm overrides — read-only state pulled from the repo. The edit modal // is the only mutation path (one alarm at a time). private List _overridableAlarms = new(); private Dictionary _existingAlarmOverrides = new(); // Native alarm source overrides — the template's source bindings plus any // per-instance override rows. Editing is inline (connection / source-ref / // filter; blank = inherited). private List _nativeSources = new(); private Dictionary _existingNativeOverrides = new(); private Dictionary _nasConnEdit = new(); private Dictionary _nasRefEdit = new(); private Dictionary _nasFilterEdit = new(); // Native-alarm-source CSV bulk-import result summary — same shape as the // attribute importer's (_csvImport*) trio. Null until an import runs. private string? _nasCsvImportResult; private bool _nasCsvImportSucceeded; private IReadOnlyList _nasCsvImportErrors = Array.Empty(); // Override edit modal state — non-null while the modal is open. private TemplateAlarm? _editingAlarm; private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor private string? _editingInheritedValue; // the inherited config snapshot we diff against on save private string? _editingPriorityText; private string? _editingError; private IReadOnlyList _editingAvailableAttributes = Array.Empty(); // Cached flattened attribute list (direct + inherited + composed members, // path-qualified canonical names). Populated once after the instance loads // and fed to the alarm trigger editor so composed-member paths like // "AlarmSensor.SensorReading" resolve in the picker. private IReadOnlyList _flattenedAttributes = Array.Empty(); // Area private List _siteAreas = new(); private int _reassignAreaId; protected override async Task OnInitializedAsync() { try { _instance = await TemplateEngineRepository.GetInstanceByIdAsync(Id); if (_instance == null) { _errorMessage = $"Instance #{Id} not found."; _loading = false; return; } // Site scoping (CentralUI-002): a scoped Deployment user must not be // able to configure or deploy an instance on a site outside their // grant by navigating straight to its URL. if (!await SiteScope.IsSiteAllowedAsync(_instance.SiteId)) { _instance = null; _errorMessage = "You are not permitted to manage instances on this site."; _loading = false; return; } // Identity var template = await TemplateEngineRepository.GetTemplateByIdAsync(_instance.TemplateId); _templateName = template?.Name ?? $"#{_instance.TemplateId}"; var sites = await SiteRepository.GetAllSitesAsync(); var site = sites.FirstOrDefault(s => s.Id == _instance.SiteId); _siteName = site?.Name ?? $"#{_instance.SiteId}"; // Task 18: cache the site's machine identifier — the OPC UA browse // dialog routes by SiteIdentifier (string), not the numeric site id. _siteIdentifier = site?.SiteIdentifier ?? ""; // Areas _siteAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_instance.SiteId)).ToList(); _reassignAreaId = _instance.AreaId ?? 0; _areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? ""; // Bindings — existing rows first, then the flattened row source // (composed module members included) projected over them. var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(_instance.TemplateId); _siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_instance.SiteId)).ToList(); var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(Id); foreach (var b in existingBindings) { _bindingSelections[b.AttributeName] = b.DataConnectionId; if (!string.IsNullOrEmpty(b.DataSourceReferenceOverride)) _bindingOverrides[b.AttributeName] = b.DataSourceReferenceOverride; } await LoadBindingRowsAsync(attrs); // Overrides _overrideAttrs = attrs.Where(a => !a.IsLocked).ToList(); var existingOverrides = await TemplateEngineRepository.GetOverridesByInstanceIdAsync(Id); foreach (var o in existingOverrides) { _overrideValues[o.AttributeName] = o.OverrideValue; _existingOverrides[o.AttributeName] = o; } // MV-14: seed the per-List working rows. A List attribute's editor is // initialized from the effective value — the existing override JSON if // present, otherwise the template default — decoded into string rows // using the element type fixed by the base attribute. A malformed // stored value falls back to empty rows (the editor still opens). foreach (var attr in _overrideAttrs.Where(a => a.DataType == DataType.List)) { var effective = _existingOverrides.TryGetValue(attr.Name, out var ovr) ? ovr.OverrideValue : attr.Value; _listRows[attr.Name] = DecodeListRows(effective, attr.ElementDataType); } // Alarm overrides — load all non-locked template alarms and // existing override rows. Pre-seed the dirty maps from existing // values so the inputs render with what's currently saved. var alarms = await TemplateEngineRepository.GetAlarmsByTemplateIdAsync(_instance.TemplateId); _overridableAlarms = alarms.Where(a => !a.IsLocked).ToList(); var alarmOverrides = await TemplateEngineRepository.GetAlarmOverridesByInstanceIdAsync(Id); foreach (var o in alarmOverrides) { _existingAlarmOverrides[o.AlarmCanonicalName] = o; } // Native alarm source bindings + per-instance overrides. Seed the // inline edit maps from existing override rows (blank = inherited). _nativeSources = (await TemplateEngineRepository.GetNativeAlarmSourcesByTemplateIdAsync(_instance.TemplateId)).ToList(); _existingNativeOverrides = new(); var nativeOverrides = await TemplateEngineRepository.GetNativeAlarmSourceOverridesByInstanceIdAsync(Id); foreach (var o in nativeOverrides) { _existingNativeOverrides[o.SourceCanonicalName] = o; } foreach (var s in _nativeSources) { var ovr = _existingNativeOverrides.GetValueOrDefault(s.Name); _nasConnEdit[s.Name] = ovr?.ConnectionNameOverride; _nasRefEdit[s.Name] = ovr?.SourceReferenceOverride; _nasFilterEdit[s.Name] = ovr?.ConditionFilterOverride; } _flattenedAttributes = await BuildFlattenedAttributesAsync(); } catch (Exception ex) { _errorMessage = $"Failed to load instance: {ex.Message}"; } _loading = false; } private void GoBack() => NavigationManager.NavigateTo("/deployment/topology"); // CentralUI-024: delegates to the shared helper so the claim type stays // resolved through JwtTokenService rather than a duplicated magic string. private Task GetCurrentUserAsync() => AuthStateProvider.GetCurrentUsernameAsync(); // ── Bindings ──────────────────────────────────────────── private int GetBindingConnectionId(string attrName) => _bindingSelections.GetValueOrDefault(attrName, 0); private void OnBindingChanged(string attrName, ChangeEventArgs e) { var val = int.TryParse(e.Value?.ToString(), out var id) ? id : 0; if (val == 0) _bindingSelections.Remove(attrName); else _bindingSelections[attrName] = val; RebuildBindingGroups(); } /// All-groups connection select: binds every row to one connection (overrides untouched). private void ApplyBulkBinding() { if (_bulkConnectionId == 0) return; foreach (var row in _bindingRows) _bindingSelections[row.CanonicalName] = _bulkConnectionId; RebuildBindingGroups(); } /// /// 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). /// 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 = DirectAttributeRows(directAttrs); } else { _flattenedForBindings = flat.Value.Configuration; source = flat.Value.Configuration.Attributes // Same predicate as the deploy gate (ValidateConnectionBindingCompleteness: // DataSourceReference != null), so every row the gate will check has a row here. .Where(a => a.DefaultDataSourceReference is not null || a.DataSourceReference is not null) .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(); SnapshotSavedBindingState(); } private static IEnumerable<(string Name, string? Default)> DirectAttributeRows(IReadOnlyList directAttrs) => directAttrs.Where(a => a.DataSourceReference is not null).Select(a => (a.Name, a.DataSourceReference)); /// Re-projects the current selections/overrides into the rows, then regroups. 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(); // A binding to a connection that no longer exists at the site renders as // "— none —" and the deploy gate rejects it — count it as unbound. _bindingGroups = BindingGroupBuilder.Build(_bindingRows, _siteConnections.Select(c => c.Id).ToHashSet()); } /// Per-row (connection, override) as last loaded/saved — the baseline for the "n changed · unsaved" pill. private Dictionary _savedBindingState = new(StringComparer.Ordinal); private void SnapshotSavedBindingState() => _savedBindingState = _bindingRows.ToDictionary( r => r.CanonicalName, r => (_bindingSelections.TryGetValue(r.CanonicalName, out var c) ? c : (int?)null, _bindingOverrides.GetValueOrDefault(r.CanonicalName)), StringComparer.Ordinal); /// Members whose current state differs from the last loaded/saved state (per-row edits and group Applies alike). private int GroupChangedCount(BindingGroup g) => g.Members.Count(m => { var name = m.Row.CanonicalName; var current = (_bindingSelections.TryGetValue(name, out var c) ? c : (int?)null, _bindingOverrides.GetValueOrDefault(name)); return !_savedBindingState.TryGetValue(name, out var saved) || saved != current; }); 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 _expandedGroups = new(StringComparer.Ordinal); private readonly Dictionary _groupObjectInput = new(StringComparer.Ordinal); private readonly Dictionary _groupConnectionInput = 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); } /// Header Object input: the operator's pending edit, else the inferred shared prefix. private string? GroupObjectInput(BindingGroup g) => _groupObjectInput.TryGetValue(g.Key, out var v) ? v : g.InferredObject; /// Header connection select: the operator's pending edit, else the group's common connection. private int GroupConnectionInput(BindingGroup g) => _groupConnectionInput.TryGetValue(g.Key, out var v) ? v ?? 0 : g.CommonConnectionId ?? 0; /// /// Per-module bulk apply: rewrites every member of the group to /// {object}.{memberTail} on the header's connection (blank object clears /// the overrides; a header left at "— none —"/"— mixed —" keeps each member's /// current connection — Apply never unbinds a group). Edits only the in-memory /// selection/override maps; Save Bindings persists them. /// private void ApplyGroupObject(BindingGroup g) { var connId = GroupConnectionInput(g); var changes = BindingGroupBuilder.ApplyObject(g, connId > 0 ? connId : null, GroupObjectInput(g)); foreach (var c in changes) { 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); } _groupObjectInput.Remove(g.Key); _groupConnectionInput.Remove(g.Key); _expandedGroups.Add(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; // ── Task 18: per-attribute override input + OPC UA tag browser ────────── private string? GetOverrideForAttr(string attrName) => _bindingOverrides.GetValueOrDefault(attrName); private void OnOverrideForAttrChanged(string attrName, ChangeEventArgs e) { var val = e.Value?.ToString(); if (string.IsNullOrWhiteSpace(val)) _bindingOverrides.Remove(attrName); else _bindingOverrides[attrName] = val; RebuildBindingGroups(); } /// Looks up the template default DataSourceReference for an attribute. private string? GetTemplateDefault(string attrName) => _bindingDefaults.GetValueOrDefault(attrName); /// /// True when the row's selected data connection supports address-space browsing /// (the tag picker). OPC UA and MxGateway both implement /// IBrowsableDataConnection site-side; other protocols return a /// NotBrowsable failure, so the button is hidden for them. /// 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 OPC UA tag browser dialog for the given attribute row. Remembers /// which attribute is being edited so can /// write the picked node id back to the right override input. /// private async Task OpenBrowser(string attrName) { var connId = GetBindingConnectionId(attrName); var conn = _siteConnections.FirstOrDefault(c => c.Id == connId); if (conn is null) return; _browserAttrInEdit = attrName; _browserConnectionName = conn.Name; _browserSiteIdentifier = _siteIdentifier; _browserInitial = _bindingOverrides.GetValueOrDefault(attrName) ?? GetTemplateDefault(attrName); if (_browserRef is not null) await _browserRef.ShowAsync(_siteIdentifier, conn.Name, _browserInitial); } private void OnBrowserSelected(string nodeId) { if (_browserAttrInEdit is null) return; _bindingOverrides[_browserAttrInEdit] = nodeId; _browserAttrInEdit = null; RebuildBindingGroups(); } // ── Test Bindings (one-shot live read of bound tags) ──────────────────── /// /// Builds the list of testable rows: attributes that have a connection /// picked AND a non-empty effective tag path. Protocol-agnostic — every /// data-connection adapter implements ReadBatchAsync, so the read /// routes through ReadTagValuesCommand regardless of protocol /// (OPC UA and MxGateway today). /// private List BuildTestableRows() { var rows = new List(); 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; // Protocol-agnostic: ReadTagValuesCommand routes through the // site-side IDataConnection.ReadBatchAsync contract, which every // adapter implements (OPC UA and MxGateway today). A not-connected // or unsupported connection short-circuits to a typed banner in the // dialog rather than being filtered out here — mirrors IsBrowsable. var effectivePath = _bindingOverrides.GetValueOrDefault(name) ?? GetTemplateDefault(name); if (string.IsNullOrWhiteSpace(effectivePath)) continue; rows.Add(new TestBindingsDialog.BindingRowToTest(name, conn.Name, effectivePath)); } return rows; } private bool HasTestableBindings() => BuildTestableRows().Count > 0; private async Task OpenTestBindings() { if (_testBindingsRef is null) return; var rows = BuildTestableRows(); if (rows.Count == 0) return; await _testBindingsRef.ShowAsync(_siteIdentifier, rows, _instance?.UniqueName ?? ""); } private async Task SaveBindings() { _saving = true; try { // Task 18: include the per-attribute DataSourceReferenceOverride on // the wire record so it round-trips through SetConnectionBindingsAsync // into the InstanceConnectionBinding entity. var bindings = _bindingSelections .Select(kv => new ConnectionBinding( kv.Key, kv.Value, _bindingOverrides.GetValueOrDefault(kv.Key))) .ToList(); var user = await GetCurrentUserAsync(); var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user); if (result.IsSuccess) { SnapshotSavedBindingState(); _toast.ShowSuccess($"Saved {bindings.Count} connection binding(s)."); } else _toast.ShowError($"Save failed: {result.Error}"); } catch (Exception ex) { _toast.ShowError($"Save failed: {ex.Message}"); } _saving = false; } // ── Overrides ─────────────────────────────────────────── private string? GetOverrideValue(string attrName) => _overrideValues.GetValueOrDefault(attrName); private void OnOverrideChanged(string attrName, ChangeEventArgs e) { var val = e.Value?.ToString(); if (string.IsNullOrEmpty(val)) _overrideValues.Remove(attrName); else _overrideValues[attrName] = val; } // ── MV-14: structured List (multi-value) overrides ────────── /// Working rows for a List attribute's override (whole-list replacement). private List GetListRows(string attrName) => _listRows.TryGetValue(attrName, out var rows) ? rows : (_listRows[attrName] = new()); private void OnListRowsChanged(string attrName, List rows) { _listRows[attrName] = rows; // A fresh edit clears any stale validation error for this attribute. _overrideErrors.Remove(attrName); } /// True if a persisted override row exists for the attribute (so Clear is offered). private bool HasOverrideRow(string attrName) => _existingOverrides.ContainsKey(attrName); /// /// Decodes a stored List JSON value into editable string rows using the /// element type fixed by the base attribute. A malformed stored value (e.g. /// hand-edited or an element-type mismatch) falls back to empty rows rather /// than crashing the editor — mirrors TemplateEdit.DecodeListRows. /// private static List DecodeListRows(string? value, DataType? elementType) { if (string.IsNullOrEmpty(value)) return new(); try { var decoded = AttributeValueCodec.Decode(value, DataType.List, elementType ?? DataType.String); if (decoded is System.Collections.IEnumerable items) return items.Cast() .Select(x => AttributeValueCodec.Encode(x) ?? string.Empty) .ToList(); } catch (FormatException) { // Malformed stored value — start from empty so the editor still opens. } return new(); } /// /// Removes a List attribute's override row entirely (repository-direct, the /// same pattern as native-alarm-source overrides) and resets the editor to /// the inherited template value. /// private async Task ClearListOverride(string attrName) { _saving = true; try { if (_existingOverrides.TryGetValue(attrName, out var ovr)) { await TemplateEngineRepository.DeleteInstanceAttributeOverrideAsync(ovr.Id); await TemplateEngineRepository.SaveChangesAsync(); _existingOverrides.Remove(attrName); } _overrideValues.Remove(attrName); _overrideErrors.Remove(attrName); // Reset the editor to the inherited template default. var attr = _overrideAttrs.FirstOrDefault(a => a.Name == attrName); _listRows[attrName] = DecodeListRows(attr?.Value, attr?.ElementDataType); _toast.ShowSuccess($"Cleared override on '{attrName}'."); } catch (Exception ex) { _toast.ShowError($"Clear failed: {ex.Message}"); } _saving = false; } private async Task SaveOverrides() { _saving = true; try { _overrideErrors.Clear(); var user = await GetCurrentUserAsync(); // Build the set of override values to persist. Scalars come straight // from the single-input map (unchanged). List attributes encode their // working rows to canonical JSON; each is round-tripped through Decode // first to surface any un-parseable element (mirrors TemplateEdit) — // an invalid element aborts the whole save and is shown inline. var toSave = new Dictionary(_overrideValues); var listAttrs = _overrideAttrs.Where(a => a.DataType == DataType.List).ToList(); var hasError = false; foreach (var attr in listAttrs) { var elementType = attr.ElementDataType ?? DataType.String; var json = AttributeValueCodec.Encode(GetListRows(attr.Name)); try { AttributeValueCodec.Decode(json, DataType.List, elementType); } catch (FormatException ex) { _overrideErrors[attr.Name] = ex.Message; hasError = true; continue; } toSave[attr.Name] = json; } if (hasError) { _toast.ShowError("Some List overrides have invalid elements — see the highlighted rows."); _saving = false; return; } var failures = new List(); foreach (var (attrName, value) in toSave) { var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user); if (result.IsSuccess) _existingOverrides[attrName] = result.Value!; else failures.Add($"{attrName}: {result.Error}"); } if (failures.Count > 0) _toast.ShowError($"Failed to save {failures.Count} override(s): {string.Join("; ", failures)}"); var savedCount = toSave.Count - failures.Count; if (savedCount > 0) _toast.ShowSuccess($"Saved {savedCount} override(s)."); } catch (Exception ex) { _toast.ShowError($"Save overrides failed: {ex.Message}"); } _saving = false; } // ── CSV bulk override import ──────────────────── /// /// Handles a selected override CSV. Reads the file text (size-capped), parses it /// with the shared , validates every row against /// the instance's overridable attributes via , /// and — all-or-nothing — either applies the parsed overrides through the SAME /// InstanceService.SetAttributeOverrideAsync path the manual editor uses, or /// shows the per-line error list and applies nothing. The override editor is /// refreshed in-place so the applied values are immediately visible. /// private async Task OnCsvImportSelectedAsync(InputFileChangeEventArgs e) { _saving = true; _csvImportResult = null; _csvImportSucceeded = false; _csvImportErrors = Array.Empty(); try { var file = e.File; if (file.Size > MaxCsvImportBytes) { ShowCsvImportFailure( $"File too large ({file.Size:N0} bytes). The maximum is {MaxCsvImportBytes:N0} bytes.", Array.Empty()); return; } string text; using (var reader = new StreamReader(file.OpenReadStream(MaxCsvImportBytes))) { text = await reader.ReadToEndAsync(); } var parsed = OverrideCsvParser.Parse(text); var outcome = BuildCsvOverrideImport(parsed, _overrideAttrs); if (outcome.HasErrors) { ShowCsvImportFailure( $"Import rejected — {outcome.Errors.Count} error(s); no overrides applied.", outcome.Errors); return; } if (outcome.Overrides.Count == 0) { ShowCsvImportFailure("No override rows found in the file.", Array.Empty()); return; } // Apply through the EXISTING per-attribute submit path (no new server // method) — identical to SaveOverrides. Update the editor's in-memory // state so the applied values render immediately. var user = await GetCurrentUserAsync(); var failures = new List(); foreach (var (attrName, value) in outcome.Overrides) { var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user); if (result.IsSuccess) { _existingOverrides[attrName] = result.Value!; if (value is null) _overrideValues.Remove(attrName); else _overrideValues[attrName] = value; RefreshEditorRowForImport(attrName, value); } else { failures.Add($"{attrName}: {result.Error}"); } } if (failures.Count > 0) { ShowCsvImportFailure( $"Applied {outcome.Overrides.Count - failures.Count} of {outcome.Overrides.Count}; " + $"{failures.Count} failed.", failures); } else { _csvImportSucceeded = true; _csvImportResult = $"Imported {outcome.Overrides.Count} override(s)."; _toast.ShowSuccess(_csvImportResult); } } catch (Exception ex) { ShowCsvImportFailure($"Import failed: {ex.Message}", Array.Empty()); } finally { _saving = false; } } private void ShowCsvImportFailure(string headline, IReadOnlyList errors) { _csvImportSucceeded = false; _csvImportResult = headline; _csvImportErrors = errors; _toast.ShowError(headline); } /// /// Re-seeds the List editor's working rows for an imported List attribute so the /// applied value renders immediately. Scalar inputs read _overrideValues /// directly, so nothing extra is needed for them. /// private void RefreshEditorRowForImport(string attrName, string? value) { var attr = _overrideAttrs.FirstOrDefault(a => a.Name == attrName); if (attr?.DataType == DataType.List) _listRows[attrName] = DecodeListRows(value, attr.ElementDataType); } // ── Alarm overrides ───────────────────────────────────── private bool HasOverride(string alarmName) => _existingAlarmOverrides.ContainsKey(alarmName); /// /// Human-readable summary of the currently-saved override. Lists the /// HiLo keys that differ from the inherited config plus a priority chip. /// Used by the row's "Override" column. /// private string OverrideSummary(string alarmName) { if (!_existingAlarmOverrides.TryGetValue(alarmName, out var ovr)) return ""; var parts = new List(); if (!string.IsNullOrWhiteSpace(ovr.TriggerConfigurationOverride)) { try { using var doc = System.Text.Json.JsonDocument.Parse(ovr.TriggerConfigurationOverride); if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object) { parts.AddRange(doc.RootElement.EnumerateObject().Select(p => p.Name)); } } catch (System.Text.Json.JsonException) { parts.Add("(invalid JSON)"); } } if (ovr.PriorityLevelOverride.HasValue) parts.Add($"priority={ovr.PriorityLevelOverride.Value}"); return parts.Count == 0 ? "(empty)" : string.Join(", ", parts); } /// /// Opens the override editor modal pre-populated with the merged /// (inherited + existing override) config so the user sees the effective /// state — not just the override delta. /// private void BeginEditOverride(TemplateAlarm alarm) { _editingAlarm = alarm; _editingError = null; _editingInheritedValue = alarm.TriggerConfiguration; var existing = _existingAlarmOverrides.GetValueOrDefault(alarm.Name); // HiLo: merge inherited + override so the editor shows the effective // setpoints. Binary: pre-fill with the override if present, else the // inherited config — same idea. _editingOverrideValue = alarm.TriggerType == AlarmTriggerType.HiLo ? FlatteningService.MergeHiLoConfig(alarm.TriggerConfiguration, existing?.TriggerConfigurationOverride) : (existing?.TriggerConfigurationOverride ?? alarm.TriggerConfiguration); _editingPriorityText = existing?.PriorityLevelOverride?.ToString(); _editingAvailableAttributes = _flattenedAttributes; } private void CancelEditOverride() { _editingAlarm = null; _editingError = null; } private async Task SaveOverrideFromModal() { if (_editingAlarm == null) return; _saving = true; try { int? priority = null; if (!string.IsNullOrWhiteSpace(_editingPriorityText)) { if (!int.TryParse(_editingPriorityText, out var p)) { _editingError = "Priority must be an integer."; _saving = false; return; } priority = p; } // Compute the override JSON. For HiLo, diff against inherited so we // store only the changed keys (matches the merge-on-flatten flow). // For binary, whole-replace if the edited config differs from // inherited. string? overrideJson; if (_editingAlarm.TriggerType == AlarmTriggerType.HiLo) { overrideJson = FlatteningService.DiffHiLoConfig(_editingInheritedValue, _editingOverrideValue); } else { overrideJson = _editingOverrideValue == _editingInheritedValue ? null : _editingOverrideValue; } var user = await GetCurrentUserAsync(); var alarmName = _editingAlarm.Name; // No diff + no priority → clear any existing override and close. if (string.IsNullOrWhiteSpace(overrideJson) && !priority.HasValue) { if (_existingAlarmOverrides.ContainsKey(alarmName)) { var del = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user); if (!del.IsSuccess) { _editingError = del.Error; _saving = false; return; } _existingAlarmOverrides.Remove(alarmName); _toast.ShowSuccess($"Cleared override on '{alarmName}'."); } else { _toast.ShowSuccess("No change."); } } else { var result = await InstanceService.SetAlarmOverrideAsync( Id, alarmName, overrideJson, priority, user); if (!result.IsSuccess) { _editingError = result.Error; _saving = false; return; } _existingAlarmOverrides[alarmName] = result.Value!; _toast.ShowSuccess($"Saved override on '{alarmName}'."); } _editingAlarm = null; _editingError = null; } catch (Exception ex) { _editingError = ex.Message; } _saving = false; } private async Task ClearFromModal() { if (_editingAlarm == null) return; var name = _editingAlarm.Name; await ClearAlarmOverride(name); _editingAlarm = null; } private async Task ClearAlarmOverride(string alarmName) { _saving = true; try { var user = await GetCurrentUserAsync(); var result = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user); if (result.IsSuccess) { _existingAlarmOverrides.Remove(alarmName); _toast.ShowSuccess($"Cleared override on '{alarmName}'."); } else { _toast.ShowError($"Clear failed: {result.Error}"); } } catch (Exception ex) { _toast.ShowError($"Clear failed: {ex.Message}"); } _saving = false; } // ── Native alarm source overrides (repository-direct; blank field = inherited) ── private bool HasNativeOverride(string sourceName) => _existingNativeOverrides.ContainsKey(sourceName); private IEnumerable AlarmCapableConnections() => _siteConnections.Where(c => AlarmCapableProtocols.IsAlarmCapable(c.Protocol)); private async Task SaveNativeOverride(string sourceName) { _saving = true; try { var conn = Blank(_nasConnEdit.GetValueOrDefault(sourceName)); var sref = Blank(_nasRefEdit.GetValueOrDefault(sourceName)); var filt = Blank(_nasFilterEdit.GetValueOrDefault(sourceName)); // All blank → no override; clear any existing row. if (conn == null && sref == null && filt == null) { await ClearNativeOverrideCore(sourceName); _toast.ShowSuccess($"No override on '{sourceName}' (inherited)."); return; } await UpsertNativeOverrideCore(sourceName, conn, sref, filt); await TemplateEngineRepository.SaveChangesAsync(); _toast.ShowSuccess($"Saved native alarm source override on '{sourceName}'."); } catch (Exception ex) { _toast.ShowError($"Save failed: {ex.Message}"); } _saving = false; } private async Task ClearNativeOverride(string sourceName) { _saving = true; try { await ClearNativeOverrideCore(sourceName); _toast.ShowSuccess($"Cleared override on '{sourceName}'."); } catch (Exception ex) { _toast.ShowError($"Clear failed: {ex.Message}"); } _saving = false; } /// /// Upserts one native alarm source override row and re-seeds the inline editor's /// state for that source. Does NOT call SaveChangesAsync — the caller does, /// so a bulk import commits the whole batch in one go. Shared by the inline Save /// and the CSV import so both take the identical persistence path. /// private async Task UpsertNativeOverrideCore(string sourceName, string? conn, string? sref, string? filt) { var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName); if (existing == null) { var ovr = new InstanceNativeAlarmSourceOverride(sourceName) { InstanceId = Id, ConnectionNameOverride = conn, SourceReferenceOverride = sref, ConditionFilterOverride = filt }; await TemplateEngineRepository.AddInstanceNativeAlarmSourceOverrideAsync(ovr); _existingNativeOverrides[sourceName] = ovr; } else { existing.ConnectionNameOverride = conn; existing.SourceReferenceOverride = sref; existing.ConditionFilterOverride = filt; await TemplateEngineRepository.UpdateInstanceNativeAlarmSourceOverrideAsync(existing); _existingNativeOverrides[sourceName] = existing; } // Keep the inline editor showing what was just persisted. _nasConnEdit[sourceName] = conn; _nasRefEdit[sourceName] = sref; _nasFilterEdit[sourceName] = filt; } private async Task ClearNativeOverrideCore(string sourceName) { var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName); if (existing != null) { await TemplateEngineRepository.DeleteInstanceNativeAlarmSourceOverrideAsync(existing.Id); await TemplateEngineRepository.SaveChangesAsync(); } _existingNativeOverrides.Remove(sourceName); _nasConnEdit[sourceName] = null; _nasRefEdit[sourceName] = null; _nasFilterEdit[sourceName] = null; } private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim(); // ── Native alarm source CSV bulk import ───────────────── /// /// Handles a selected native-alarm-source-override CSV. Reads the file text /// (size-capped the same way as the attribute importer), parses it with the shared /// — the exact parser the CLI /// instance native-alarm-source import --file command uses — validates every /// row via , and — all-or-nothing — /// either upserts each row through the SAME repository path the inline Save uses, /// or shows the per-line error list and applies nothing. Merge semantics, matching /// the CLI: sources absent from the file keep their existing override, a blank /// field keeps the inherited value, and a row with all three fields blank clears /// that source's override. /// private async Task OnNativeAlarmSourceCsvImportSelectedAsync(InputFileChangeEventArgs e) { _saving = true; _nasCsvImportResult = null; _nasCsvImportSucceeded = false; _nasCsvImportErrors = Array.Empty(); try { var file = e.File; if (file.Size > MaxCsvImportBytes) { ShowNativeCsvImportFailure( $"File too large ({file.Size:N0} bytes). The maximum is {MaxCsvImportBytes:N0} bytes.", Array.Empty()); return; } string text; using (var reader = new StreamReader(file.OpenReadStream(MaxCsvImportBytes))) { text = await reader.ReadToEndAsync(); } var parsed = NativeAlarmSourceOverrideCsvParser.Parse(text); var outcome = BuildNativeAlarmSourceCsvImport(parsed, _nativeSources); if (outcome.HasErrors) { ShowNativeCsvImportFailure( $"Import rejected — {outcome.Errors.Count} error(s); no overrides applied.", outcome.Errors); return; } if (outcome.Rows.Count == 0) { ShowNativeCsvImportFailure("No override rows found in the file.", Array.Empty()); return; } // Every row was validated up front, so the batch applies as a unit through // the same repository upsert/clear the inline editor uses. foreach (var row in outcome.Rows) { if (row.IsClear) await ClearNativeOverrideCore(row.SourceName); else await UpsertNativeOverrideCore( row.SourceName, row.Connection, row.SourceReference, row.Filter); } await TemplateEngineRepository.SaveChangesAsync(); _nasCsvImportSucceeded = true; _nasCsvImportResult = $"Imported {outcome.Rows.Count} native alarm source override(s)."; _toast.ShowSuccess(_nasCsvImportResult); } catch (Exception ex) { ShowNativeCsvImportFailure($"Import failed: {ex.Message}", Array.Empty()); } finally { _saving = false; } } private void ShowNativeCsvImportFailure(string headline, IReadOnlyList errors) { _nasCsvImportSucceeded = false; _nasCsvImportResult = headline; _nasCsvImportErrors = errors; _toast.ShowError(headline); } /// /// Mirrors TemplateEdit.MapDataType — converts the persisted DataType enum /// to the canonical SCADA type string the AlarmTriggerEditor compares /// against (Boolean / Integer / Float / String / Object). /// private static string MapDataType(DataType dt) => dt switch { DataType.Boolean => "Boolean", DataType.Int32 => "Integer", DataType.Float => "Float", DataType.Double => "Float", DataType.String => "String", DataType.DateTime => "String", DataType.Binary => "Object", _ => "Object" }; /// /// Same mapping for the string form emitted by . /// private static string MapDataType(string dt) => Enum.TryParse(dt, out var parsed) ? MapDataType(parsed) : dt; /// /// Builds the alarm picker choice list from the flattened configuration so /// composed-member paths (e.g. AlarmSensor.SensorReading) and /// inherited attributes appear alongside direct ones. Falls back to the /// direct-only list if flattening fails for any reason. /// private async Task> BuildFlattenedAttributesAsync() { var fallback = (IReadOnlyList)_overrideAttrs .Select(a => new AlarmAttributeChoice(a.Name, MapDataType(a.DataType), "Direct")) .ToList(); try { // 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 configuration.Attributes .Select(a => new AlarmAttributeChoice( a.CanonicalName, MapDataType(a.DataType), a.Source switch { "Composed" => "Composed", "Inherited" => "Inherited", _ => "Direct" // Template / Override })) .ToList(); } catch { return fallback; } } // ── Area ──────────────────────────────────────────────── private async Task ReassignArea() { _saving = true; try { var user = await GetCurrentUserAsync(); var result = await InstanceService.AssignToAreaAsync(Id, _reassignAreaId == 0 ? null : _reassignAreaId, user); if (result.IsSuccess) { _areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? ""; _toast.ShowSuccess("Area reassigned."); } else _toast.ShowError($"Reassign failed: {result.Error}"); } catch (Exception ex) { _toast.ShowError($"Reassign failed: {ex.Message}"); } _saving = false; } private static string GetStateBadge(InstanceState state) => state switch { InstanceState.Enabled => "bg-success", InstanceState.Disabled => "bg-secondary", InstanceState.NotDeployed => "bg-light text-dark", _ => "bg-secondary" }; }