@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 @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

@* Bundle D (#23 M7-T12) 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 : "—")
@* Connection Bindings *@
Connection Bindings @if (_bindingDataSourceAttrs.Count > 0 && _siteConnections.Count > 0) {
}
@if (_bindingDataSourceAttrs.Count == 0) {

No data-sourced attributes in this template.

} else { @foreach (var attr in _bindingDataSourceAttrs) { var connId = GetBindingConnectionId(attr.Name); var canBrowse = connId > 0; var isBrowsable = IsBrowsable(connId); }
Attribute Tag Path Connection Override
@attr.Name @attr.DataSourceReference @if (isBrowsable) { }
@* 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. *@
}
@* Attribute Overrides *@
Attribute Overrides @* M7-T16: 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) { }
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)) {
@listErr
} @if (HasOverrideRow(attr.Name)) { } } else { }
}
@* Alarm Overrides *@
Alarm Overrides Click Edit to override an alarm's trigger configuration or priority. HiLo overrides merge into the inherited setpoints; other trigger types replace the whole config.
@if (_overridableAlarms.Count == 0) {

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

} else { @foreach (var alarm in _overridableAlarms) { }
Alarm Trigger Inherited Config Override Actions
@alarm.Name @alarm.TriggerType @(alarm.TriggerConfiguration ?? "—") @if (HasOverride(alarm.Name)) { @OverrideSummary(alarm.Name) } else { inherited } @if (HasOverride(alarm.Name)) { }
}
@* Override edit modal *@ @if (_editingAlarm != null) { } @* Native Alarm Source Overrides *@
Native Alarm Source Overrides Retarget an inherited native alarm source binding for this instance. Leave a field blank to keep the inherited value.
@if (_nativeSources.Count == 0) {

No native alarm sources on this template.

} else { @foreach (var src in _nativeSources) { }
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; } 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 private List _bindingDataSourceAttrs = new(); 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(); // M7-T16: 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(); // 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 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) { _bindingSelections[b.AttributeName] = b.DataConnectionId; if (!string.IsNullOrEmpty(b.DataSourceReferenceOverride)) _bindingOverrides[b.AttributeName] = b.DataSourceReferenceOverride; } // 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; } private void ApplyBulkBinding() { if (_bulkConnectionId == 0) return; foreach (var attr in _bindingDataSourceAttrs) _bindingSelections[attr.Name] = _bulkConnectionId; } // ── 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; } /// Looks up the template default DataSourceReference for an attribute. private string? GetTemplateDefault(string attrName) => _bindingDataSourceAttrs.FirstOrDefault(a => a.Name == attrName)?.DataSourceReference; /// /// 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; } // ── 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 attr in _bindingDataSourceAttrs) { var connId = GetBindingConnectionId(attr.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(attr.Name) ?? GetTemplateDefault(attr.Name); if (string.IsNullOrWhiteSpace(effectivePath)) continue; rows.Add(new TestBindingsDialog.BindingRowToTest(attr.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) _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; } // ── M7-T16: 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; } 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); await TemplateEngineRepository.SaveChangesAsync(); _existingNativeOverrides[sourceName] = ovr; } else { existing.ConnectionNameOverride = conn; existing.SourceReferenceOverride = sref; existing.ConditionFilterOverride = filt; await TemplateEngineRepository.UpdateInstanceNativeAlarmSourceOverrideAsync(existing); await TemplateEngineRepository.SaveChangesAsync(); _existingNativeOverrides[sourceName] = existing; } _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; } 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(); /// /// 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 { var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id); if (flat.IsFailure) return fallback; return flat.Value.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" }; }