fix(alarms): surface composed-member attributes across flatten/validate/UI

Three layers were each blind to nested composition in different ways:

- FlatteningPipeline only loaded compositions for templates in the parent's
  inheritance chain, so depth-2 composed attributes (e.g.
  Pump.AlarmSensor.SensorReading) never materialized. Walk composed chains
  breadth-first so the flattener's nested step has the data it needs.

- InstanceConfigure's alarm trigger picker was fed only direct, non-locked
  attributes, hiding inherited and composed-member paths. Feed it the full
  flattened attribute list via FlatteningPipeline.

- ValidationService.ExtractAttributeNameFromTriggerConfig only recognized
  "attributeName", silently passing alarms still using the legacy
  "attribute" key. Accept both keys, matching FlatteningService,
  AlarmActor, and AlarmTriggerConfigCodec.
This commit is contained in:
Joseph Doherty
2026-05-13 05:33:32 -04:00
parent 164d914ba8
commit 352c93d5a2
3 changed files with 77 additions and 12 deletions

View File

@@ -50,23 +50,34 @@ public class FlatteningPipeline : IFlatteningPipeline
if (templateChain.Count == 0)
return Result<FlatteningPipelineResult>.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<int, IReadOnlyList<Commons.Entities.Templates.TemplateComposition>>();
var composedChains = new Dictionary<int, IReadOnlyList<Commons.Entities.Templates.Template>>();
var processedTemplateIds = new HashSet<int>();
var pendingChains = new Queue<IReadOnlyList<Commons.Entities.Templates.Template>>();
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);
}
}
}