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

@@ -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<AlarmAttributeChoice> _editingAvailableAttributes = Array.Empty<AlarmAttributeChoice>();
// 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<AlarmAttributeChoice> _flattenedAttributes = Array.Empty<AlarmAttributeChoice>();
// Area
private List<Area> _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"
};
/// <summary>
/// Same mapping for the string form emitted by <see cref="Commons.Types.Flattening.ResolvedAttribute.DataType"/>.
/// </summary>
private static string MapDataType(string dt) =>
Enum.TryParse<DataType>(dt, out var parsed) ? MapDataType(parsed) : dt;
/// <summary>
/// Builds the alarm picker choice list from the flattened configuration so
/// composed-member paths (e.g. <c>AlarmSensor.SensorReading</c>) and
/// inherited attributes appear alongside direct ones. Falls back to the
/// direct-only list if flattening fails for any reason.
/// </summary>
private async Task<IReadOnlyList<AlarmAttributeChoice>> BuildFlattenedAttributesAsync()
{
var fallback = (IReadOnlyList<AlarmAttributeChoice>)_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()

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);
}
}
}

View File

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