@page "/deployment/instances/{Id:int}/configure" @using ScadaLink.Security @using ScadaLink.Commons.Entities.Instances @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Entities.Templates @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Messages.Management @using ScadaLink.Commons.Types.Enums @using ScadaLink.TemplateEngine.Flattening @using ScadaLink.TemplateEngine.Services @using ScadaLink.DeploymentManager @attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] @inject ITemplateEngineRepository TemplateEngineRepository @inject ISiteRepository SiteRepository @inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope @inject InstanceService InstanceService @inject IFlatteningPipeline FlatteningPipeline @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager NavigationManager

Configure Instance

@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) { }
Attribute Tag Path Connection
@attr.Name @attr.DataSourceReference
}
@* Attribute Overrides *@
Attribute Overrides
@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 @(attr.Value ?? "—")
}
@* 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) { } @* Area Assignment *@
Area Assignment
}
@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(); private int _bulkConnectionId; // Overrides private List _overrideAttrs = new(); private Dictionary _overrideValues = new(); // 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(); // 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(); _siteName = sites.FirstOrDefault(s => s.Id == _instance.SiteId)?.Name ?? $"#{_instance.SiteId}"; // 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; // Overrides _overrideAttrs = attrs.Where(a => !a.IsLocked).ToList(); var existingOverrides = await TemplateEngineRepository.GetOverridesByInstanceIdAsync(Id); foreach (var o in existingOverrides) _overrideValues[o.AttributeName] = o.OverrideValue; // 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; } _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; } private async Task SaveBindings() { _saving = true; try { var bindings = _bindingSelections.Select(kv => new ConnectionBinding(kv.Key, kv.Value)).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; } private async Task SaveOverrides() { _saving = true; try { var user = await GetCurrentUserAsync(); foreach (var (attrName, value) in _overrideValues) await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user); _toast.ShowSuccess($"Saved {_overrideValues.Count} override(s)."); } catch (Exception ex) { _toast.ShowError($"Save overrides failed: {ex.Message}"); } _saving = false; } // ── 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; } /// /// 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" }; }