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:
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user