diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index 3caf440..7f8531f 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -7,10 +7,12 @@ @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 InstanceService InstanceService +@inject IFlatteningPipeline FlatteningPipeline @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager NavigationManager @@ -353,6 +355,12 @@ 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; @@ -405,6 +413,8 @@ { _existingAlarmOverrides[o.AlarmCanonicalName] = o; } + + _flattenedAttributes = await BuildFlattenedAttributesAsync(); } catch (Exception ex) { @@ -547,9 +557,7 @@ : (existing?.TriggerConfigurationOverride ?? alarm.TriggerConfiguration); _editingPriorityText = existing?.PriorityLevelOverride?.ToString(); - _editingAvailableAttributes = _overrideAttrs - .Select(a => new AlarmAttributeChoice(a.Name, MapDataType(a.DataType), "Direct")) - .ToList(); + _editingAvailableAttributes = _flattenedAttributes; } private void CancelEditOverride() @@ -689,6 +697,47 @@ _ => "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() diff --git a/src/ScadaLink.DeploymentManager/FlatteningPipeline.cs b/src/ScadaLink.DeploymentManager/FlatteningPipeline.cs index cbd7a3a..1c6028b 100644 --- a/src/ScadaLink.DeploymentManager/FlatteningPipeline.cs +++ b/src/ScadaLink.DeploymentManager/FlatteningPipeline.cs @@ -50,23 +50,34 @@ public class FlatteningPipeline : IFlatteningPipeline if (templateChain.Count == 0) return Result.Failure("Template chain is empty."); - // Build composition maps + // Build composition maps, walking nested compositions so the flattener + // can resolve composed-of-composed attributes / alarms / scripts (e.g. + // a parent that composes Pump where Pump itself composes AlarmSensor + // produces "Pump.AlarmSensor.SensorReading"). var compositionMap = new Dictionary>(); var composedChains = new Dictionary>(); + var processedTemplateIds = new HashSet(); + var pendingChains = new Queue>(); - foreach (var template in templateChain) + pendingChains.Enqueue(templateChain); + while (pendingChains.Count > 0) { - var compositions = await _templateRepo.GetCompositionsByTemplateIdAsync(template.Id, cancellationToken); - if (compositions.Count > 0) + var chain = pendingChains.Dequeue(); + foreach (var template in chain) { + if (!processedTemplateIds.Add(template.Id)) continue; + + var compositions = await _templateRepo.GetCompositionsByTemplateIdAsync(template.Id, cancellationToken); + if (compositions.Count == 0) continue; + compositionMap[template.Id] = compositions; foreach (var comp in compositions) { - if (!composedChains.ContainsKey(comp.ComposedTemplateId)) - { - composedChains[comp.ComposedTemplateId] = - await BuildTemplateChainAsync(comp.ComposedTemplateId, cancellationToken); - } + if (composedChains.ContainsKey(comp.ComposedTemplateId)) continue; + + var composedChain = await BuildTemplateChainAsync(comp.ComposedTemplateId, cancellationToken); + composedChains[comp.ComposedTemplateId] = composedChain; + pendingChains.Enqueue(composedChain); } } } diff --git a/src/ScadaLink.TemplateEngine/Validation/ValidationService.cs b/src/ScadaLink.TemplateEngine/Validation/ValidationService.cs index 04c7f22..cd2053c 100644 --- a/src/ScadaLink.TemplateEngine/Validation/ValidationService.cs +++ b/src/ScadaLink.TemplateEngine/Validation/ValidationService.cs @@ -220,11 +220,16 @@ public class ValidationService internal static string? ExtractAttributeNameFromTriggerConfig(string triggerConfigJson) { + // Accept both keys to stay consistent with FlatteningService.PrefixTriggerAttribute, + // AlarmActor.ParseEvalConfig and AlarmTriggerConfigCodec. Old data may still use + // "attribute"; the UI codec writes the canonical "attributeName". try { using var doc = JsonDocument.Parse(triggerConfigJson); if (doc.RootElement.TryGetProperty("attributeName", out var prop)) return prop.GetString(); + if (doc.RootElement.TryGetProperty("attribute", out var legacyProp)) + return legacyProp.GetString(); } catch (JsonException) {