Files
scadalink-design/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor
Joseph Doherty 751248feb6 feat(alarms): HiLo trigger type with per-band level, hysteresis, messages, overrides
Adds a new HiLo alarm trigger type with four configurable setpoints
(LoLo / Lo / Hi / HiHi). Each setpoint carries an optional priority,
deadband (for hysteresis), and operator message. The site runtime emits
AlarmStateChanged with an AlarmLevel field so consumers can differentiate
warning vs critical bands.

Plumbing:
  - new AlarmLevel enum + AlarmStateChanged.Level/Message init properties
  - AlarmTriggerEditor (Blazor) gets a HiLo render with severity tinting
  - AlarmTriggerConfigCodec extracted from the editor for testability
  - sitestream.proto carries level + message over gRPC
  - SemanticValidator enforces numeric attribute, setpoint ordering,
    non-negative deadband
  - on-trigger scripts get an Alarm global (Name/Level/Priority/Message)
    so notification routing can branch by severity
  - per-instance InstanceAlarmOverride entity + EF migration + flattening
    step + CLI commands; HiLo overrides merge setpoint-by-setpoint, binary
    types whole-replace
  - DebugView shows a Level badge + per-band message tooltip
  - App.razor auto-reloads on permanent Blazor circuit failure
  - docker/regen-proto.sh automates the proto regen workflow (the linux/arm64
    protoc segfault means generated files are checked in for now)
2026-05-13 03:23:32 -04:00

1503 lines
67 KiB
Plaintext

@page "/design/templates/{Id:int}"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Enums
@using ScadaLink.TemplateEngine
@using ScadaLink.TemplateEngine.Services
@using ScadaLink.TemplateEngine.Validation
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject TemplateService TemplateService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm"
aria-label="Back to Templates"
@onclick="GoBack">← Templates</button>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_loadError != null)
{
<div class="alert alert-danger">@_loadError</div>
}
else if (_selectedTemplate == null)
{
<div class="alert alert-warning">Template not found.</div>
}
else
{
@RenderTemplateDetail()
}
</div>
@code {
[Parameter] public int Id { get; set; }
private List<Template> _templates = new();
private Template? _selectedTemplate;
private List<TemplateAttribute> _attributes = new();
private List<TemplateAlarm> _alarms = new();
private List<TemplateScript> _scripts = new();
private List<TemplateComposition> _compositions = new();
// Populated only when _selectedTemplate.IsDerived — keyed by member name.
private Dictionary<string, TemplateAttribute> _baseAttributesByName = new(StringComparer.Ordinal);
private Dictionary<string, TemplateScript> _baseScriptsByName = new(StringComparer.Ordinal);
private Template? _baseTemplate;
private Template? _ownerTemplate;
private TemplateComposition? _ownerComposition;
private bool _loading = true;
private string? _loadError;
private string _activeTab = "attributes";
// Edit properties
private string _editName = string.Empty;
private string? _editDescription;
private int _editParentId;
// Validation
private bool _validating;
private Commons.Types.Flattening.ValidationResult? _validationResult;
// Member add/edit forms. _edit*Id null = adding; non-null = editing that row.
private bool _showAttrForm;
private int? _editAttrId;
private string _attrName = string.Empty;
private string? _attrValue;
private DataType _attrDataType;
private bool _attrIsLocked;
private string? _attrDataSourceRef;
private string? _attrFormError;
private bool _showAlarmForm;
private int? _editAlarmId;
private string _alarmName = string.Empty;
private int _alarmPriority;
private AlarmTriggerType _alarmTriggerType;
private string? _alarmTriggerConfig;
private bool _alarmIsLocked;
private string? _alarmFormError;
private bool _showScriptForm;
private int? _editScriptId;
private string _scriptName = string.Empty;
private string _scriptCode = string.Empty;
private string? _scriptTriggerType;
private string? _scriptTriggerConfig;
private string? _scriptParameters;
private string? _scriptReturn;
private bool _scriptIsLocked;
private string? _scriptFormError;
private string _scriptModalTab = "code"; // "code" | "parameters" | "return"
private MonacoEditor? _scriptEditor;
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker> _scriptMarkers
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker>();
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext> _editorChildren
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>();
/// <summary>
/// Editor's Parent.* context. Empty for base templates (no owner exists);
/// exactly one entry for derived templates — the slot-owner resolved from
/// the template's OwnerCompositionId.
/// </summary>
private List<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext> _editorParents
= new();
private ScadaLink.CentralUI.ScriptAnalysis.CompositionContext? ActiveEditorParent =>
_editorParents.FirstOrDefault();
private ToastNotification _toast = default!;
protected override async Task OnParametersSetAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_loadError = null;
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
_selectedTemplate = await TemplateEngineRepository.GetTemplateWithChildrenAsync(Id)
?? _templates.FirstOrDefault(t => t.Id == Id);
if (_selectedTemplate == null) { _loading = false; return; }
_editName = _selectedTemplate.Name;
_editDescription = _selectedTemplate.Description;
_editParentId = _selectedTemplate.ParentTemplateId ?? 0;
_attributes = (await TemplateEngineRepository.GetAttributesByTemplateIdAsync(Id)).ToList();
_alarms = (await TemplateEngineRepository.GetAlarmsByTemplateIdAsync(Id)).ToList();
_scripts = (await TemplateEngineRepository.GetScriptsByTemplateIdAsync(Id)).ToList();
_compositions = (await TemplateEngineRepository.GetCompositionsByTemplateIdAsync(Id)).ToList();
// Derived-template context: base + slot owner power the override
// banner, inherited badges, and LockedInDerived rendering.
_baseAttributesByName = new(StringComparer.Ordinal);
_baseScriptsByName = new(StringComparer.Ordinal);
_baseTemplate = null;
_ownerTemplate = null;
_ownerComposition = null;
if (_selectedTemplate.IsDerived && _selectedTemplate.ParentTemplateId.HasValue)
{
_baseTemplate = await TemplateEngineRepository.GetTemplateByIdAsync(_selectedTemplate.ParentTemplateId.Value);
if (_baseTemplate != null)
{
foreach (var a in _baseTemplate.Attributes)
_baseAttributesByName[a.Name] = a;
foreach (var s in _baseTemplate.Scripts)
_baseScriptsByName[s.Name] = s;
}
if (_selectedTemplate.OwnerCompositionId.HasValue)
{
foreach (var t in _templates)
{
var c = t.Compositions.FirstOrDefault(x => x.Id == _selectedTemplate.OwnerCompositionId.Value);
if (c != null)
{
_ownerTemplate = t;
_ownerComposition = c;
break;
}
}
}
}
// Editor metadata: child compositions + every parent that
// composes this template. Powers Attributes["X"] /
// Children["Y"].Attributes["Z"] / Parent.Attributes["W"]
// completion + SCADA006 / SCADA007 diagnostics in the Monaco
// editor.
_editorChildren = await BuildChildContextsAsync(_compositions);
_editorParents = await BuildParentContextsAsync(Id);
_validationResult = null;
}
catch (Exception ex)
{
_loadError = $"Failed to load template: {ex.Message}";
}
_loading = false;
}
private void GoBack()
{
NavigationManager.NavigateTo("/design/templates");
}
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
private RenderFragment RenderTemplateDetail() => __builder =>
{
@if (_selectedTemplate!.IsDerived && _baseTemplate != null)
{
<div class="alert alert-info py-2 mb-3 d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-diagram-3 me-1"></i>
<strong>Derived</strong> from
<a href="/design/templates/@_baseTemplate.Id"><code>@_baseTemplate.Name</code></a>
@if (_ownerTemplate != null && _ownerComposition != null)
{
<span class="ms-1">— composed inside <a href="/design/templates/@_ownerTemplate.Id"><code>@_ownerTemplate.Name</code></a> as <code>@_ownerComposition.InstanceName</code>.</span>
}
</div>
</div>
}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="d-inline mb-0">@_selectedTemplate.Name</h4>
@if (_selectedTemplate.ParentTemplateId.HasValue && !_selectedTemplate.IsDerived)
{
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
}
</div>
<div>
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
@if (_validating)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Validate
</button>
<button class="btn btn-outline-danger btn-sm" @onclick="DeleteTemplate">Delete</button>
</div>
</div>
@* Validation results *@
@if (_validationResult != null)
{
<div class="mb-3">
@if (_validationResult.Errors.Count > 0)
{
<div class="alert alert-danger py-2">
<strong>Validation Errors (@_validationResult.Errors.Count)</strong>
<ul class="mb-0 small">
@foreach (var err in _validationResult.Errors)
{
<li class="mb-1">
<strong>@err.Category</strong> @err.Message
@if (err.EntityName != null)
{
<span class="text-muted">(@err.EntityName)</span>
}
</li>
}
</ul>
</div>
}
@if (_validationResult.Warnings.Count > 0)
{
<div class="alert alert-warning py-2">
<strong>Warnings (@_validationResult.Warnings.Count)</strong>
<ul class="mb-0 small">
@foreach (var warn in _validationResult.Warnings)
{
<li class="mb-1">
<strong>@warn.Category</strong> <span class="text-muted">@warn.Message</span>
</li>
}
</ul>
</div>
}
@if (_validationResult.Errors.Count == 0 && _validationResult.Warnings.Count == 0)
{
<div class="alert alert-success py-2">Validation passed with no errors or warnings.</div>
}
</div>
}
@* Template info edit *@
<div class="card mb-3">
<div class="card-header">Template Properties</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_editName" />
</div>
<div class="col-12">
<label class="form-label">Description</label>
<input type="text" class="form-control" @bind="_editDescription" />
</div>
<div class="col-12">
<label class="form-label">Parent Template</label>
<input type="text" readonly class="form-control form-control-plaintext"
value="@(_selectedTemplate.ParentTemplateId is int pid
? _templates.FirstOrDefault(t => t.Id == pid)?.Name ?? $"#{pid}"
: "(none)")" />
</div>
<div class="col-12 text-end">
<button class="btn btn-primary" @onclick="UpdateTemplateProperties">Save Properties</button>
</div>
</div>
</div>
</div>
@* Tabs: Attributes, Alarms, Scripts, Compositions *@
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link @(_activeTab == "attributes" ? "active" : "")"
role="tab"
aria-selected="@(_activeTab == "attributes" ? "true" : "false")"
aria-controls="tmpl-tab-attributes"
@onclick='() => _activeTab = "attributes"'>
Attributes <span class="badge bg-secondary">@_attributes.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(_activeTab == "alarms" ? "active" : "")"
role="tab"
aria-selected="@(_activeTab == "alarms" ? "true" : "false")"
aria-controls="tmpl-tab-alarms"
@onclick='() => _activeTab = "alarms"'>
Alarms <span class="badge bg-secondary">@_alarms.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(_activeTab == "scripts" ? "active" : "")"
role="tab"
aria-selected="@(_activeTab == "scripts" ? "true" : "false")"
aria-controls="tmpl-tab-scripts"
@onclick='() => _activeTab = "scripts"'>
Scripts <span class="badge bg-secondary">@_scripts.Count</span>
</button>
</li>
</ul>
@if (_activeTab == "attributes")
{
<div role="tabpanel" id="tmpl-tab-attributes">@RenderAttributesTab()</div>
}
else if (_activeTab == "alarms")
{
<div role="tabpanel" id="tmpl-tab-alarms">@RenderAlarmsTab()</div>
}
else if (_activeTab == "scripts")
{
<div role="tabpanel" id="tmpl-tab-scripts">@RenderScriptsTab()</div>
}
};
private async Task DeleteTemplate()
{
if (_selectedTemplate == null) return;
var confirmed = await Dialog.ConfirmAsync(
"Delete Template",
$"Delete template '{_selectedTemplate.Name}'? This will fail if instances or child templates reference it.",
danger: true);
if (!confirmed) return;
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteTemplateAsync(_selectedTemplate.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Template '{_selectedTemplate.Name}' deleted.");
NavigationManager.NavigateTo("/design/templates");
}
else
{
_toast.ShowError(result.Error);
}
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
private async Task UpdateTemplateProperties()
{
if (_selectedTemplate == null) return;
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.UpdateTemplateAsync(
_selectedTemplate.Id, _editName.Trim(), _editDescription?.Trim(),
_editParentId == 0 ? null : _editParentId, user);
if (result.IsSuccess)
{
_toast.ShowSuccess("Template properties updated.");
_selectedTemplate = result.Value;
}
else
{
_toast.ShowError(result.Error);
}
}
catch (Exception ex)
{
_toast.ShowError($"Update failed: {ex.Message}");
}
}
private async Task RunValidation()
{
if (_selectedTemplate == null) return;
_validating = true;
_validationResult = null;
try
{
var validationService = new ValidationService();
var flatConfig = new Commons.Types.Flattening.FlattenedConfiguration
{
InstanceUniqueName = $"validation-{_selectedTemplate.Name}",
TemplateId = _selectedTemplate.Id,
Attributes = _attributes.Select(a => new Commons.Types.Flattening.ResolvedAttribute
{
CanonicalName = a.Name,
Value = a.Value,
DataType = a.DataType.ToString(),
IsLocked = a.IsLocked,
DataSourceReference = a.DataSourceReference
}).ToList(),
Alarms = _alarms.Select(a => new Commons.Types.Flattening.ResolvedAlarm
{
CanonicalName = a.Name,
PriorityLevel = a.PriorityLevel,
IsLocked = a.IsLocked,
TriggerType = a.TriggerType.ToString(),
TriggerConfiguration = a.TriggerConfiguration
}).ToList(),
Scripts = _scripts.Select(s => new Commons.Types.Flattening.ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
IsLocked = s.IsLocked,
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
}).ToList()
};
_validationResult = validationService.Validate(flatConfig);
var collisions = await TemplateService.DetectCollisionsAsync(_selectedTemplate.Id);
if (collisions.Count > 0)
{
var collisionErrors = collisions.Select(c =>
Commons.Types.Flattening.ValidationEntry.Error(
Commons.Types.Flattening.ValidationCategory.NamingCollision, c)).ToArray();
var collisionResult = new Commons.Types.Flattening.ValidationResult { Errors = collisionErrors };
_validationResult = Commons.Types.Flattening.ValidationResult.Merge(_validationResult, collisionResult);
}
}
catch (Exception ex)
{
_toast.ShowError($"Validation error: {ex.Message}");
}
_validating = false;
}
// ---- Attributes Tab ----
private RenderFragment RenderAttributesTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Attributes</h5>
<button class="btn btn-primary btn-sm" @onclick="BeginAddAttribute">Add Attribute</button>
</div>
@if (_showAttrForm)
{
var editing = _editAttrId.HasValue;
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">@(editing ? "Edit Attribute" : "Add Attribute")</h6>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAttributeForm"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_attrName" readonly="@editing" />
</div>
<div class="col-12">
<label class="form-label">Data Type</label>
<select class="form-select" @bind="_attrDataType">
@foreach (var dt in Enum.GetValues<DataType>())
{
<option value="@dt">@dt</option>
}
</select>
</div>
<div class="col-12">
<label class="form-label">Value</label>
<input type="text" class="form-control" @bind="_attrValue" />
</div>
<div class="col-12">
<label class="form-label">Data Source Ref</label>
<input type="text" class="form-control" @bind="_attrDataSourceRef" placeholder="Tag path" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_attrIsLocked" id="attrLocked" />
<label class="form-check-label" for="attrLocked">Locked</label>
</div>
</div>
@if (_attrFormError != null)
{
<div class="col-12"><div class="text-danger small">@_attrFormError</div></div>
}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelAttributeForm">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="SaveAttribute">@(editing ? "Save" : "Add")</button>
</div>
</div>
</div>
</div>
}
var derived = _selectedTemplate!.IsDerived;
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Type</th>
<th>Value</th>
<th>Data Source</th>
<th>Lock</th>
@if (derived)
{
<th>Source</th>
}
else
{
<th title="When true, derived templates may not override this row.">Lock in derived</th>
}
<th style="width: 60px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var attr in _attributes)
{
_baseAttributesByName.TryGetValue(attr.Name, out var baseAttr);
var lockedByBase = derived && baseAttr != null && baseAttr.LockedInDerived;
var effectiveValue = (derived && attr.IsInherited && baseAttr != null) ? baseAttr.Value : attr.Value;
var effectiveDataSource = (derived && attr.IsInherited && baseAttr != null) ? baseAttr.DataSourceReference : attr.DataSourceReference;
<tr>
<td>@attr.Name</td>
<td><span class="badge bg-light text-dark">@attr.DataType</span></td>
<td class="small">@(effectiveValue ?? "—")</td>
<td class="small text-muted">@(effectiveDataSource ?? "—")</td>
<td>
@if (attr.IsLocked)
{
<span class="badge bg-danger" aria-label="Locked">Locked</span>
}
else
{
<span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>
}
</td>
@if (derived)
{
<td>
@if (lockedByBase)
{
<span class="badge bg-warning text-dark" title="Locked by base template — cannot override.">🔒 Base-locked</span>
}
else if (attr.IsInherited)
{
<span class="badge bg-secondary">Inherited</span>
}
else if (baseAttr != null)
{
<span class="badge bg-primary">Override</span>
}
else
{
<span class="badge bg-light text-dark">Local</span>
}
</td>
}
else
{
<td>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
checked="@attr.LockedInDerived"
@onchange="(e) => ToggleAttrLockedInDerived(attr, e)" />
</div>
</td>
}
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {attr.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
@if (derived && baseAttr != null && !lockedByBase)
{
@if (attr.IsInherited)
{
<li>
<button class="dropdown-item" @onclick="() => OverrideAttribute(attr)">Override…</button>
</li>
}
else
{
<li>
<button class="dropdown-item" @onclick="() => RevertAttributeToBase(attr)">Revert to base</button>
</li>
}
}
@if (!(derived && baseAttr != null))
{
<li>
<button class="dropdown-item" @onclick="() => BeginEditAttribute(attr)">Edit…</button>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteAttribute(attr)">Delete</button>
</li>
}
</ul>
</div>
</td>
</tr>
}
</tbody>
</table>
};
private async Task ToggleAttrLockedInDerived(TemplateAttribute attr, ChangeEventArgs e)
{
var newValue = e.Value is bool b && b;
if (attr.LockedInDerived == newValue) return;
var user = await GetCurrentUserAsync();
var proposed = new TemplateAttribute(attr.Name)
{
Value = attr.Value,
DataType = attr.DataType,
IsLocked = attr.IsLocked,
Description = attr.Description,
DataSourceReference = attr.DataSourceReference,
IsInherited = attr.IsInherited,
LockedInDerived = newValue,
};
var result = await TemplateService.UpdateAttributeAsync(attr.Id, proposed, user);
if (result.IsSuccess) { attr.LockedInDerived = newValue; _toast.ShowSuccess("Lock-in-derived updated."); }
else _toast.ShowError(result.Error);
}
private async Task OverrideAttribute(TemplateAttribute attr)
{
var newVal = await Dialog.PromptAsync(
$"Override '{attr.Name}'",
"New value (overrides the base):",
placeholder: attr.Value ?? "");
if (newVal == null) return;
var user = await GetCurrentUserAsync();
var proposed = new TemplateAttribute(attr.Name)
{
Value = newVal,
DataType = attr.DataType,
IsLocked = attr.IsLocked,
Description = attr.Description,
DataSourceReference = attr.DataSourceReference,
IsInherited = false,
LockedInDerived = false,
};
var result = await TemplateService.UpdateAttributeAsync(attr.Id, proposed, user);
if (result.IsSuccess) { _toast.ShowSuccess($"Override saved for '{attr.Name}'."); await LoadAsync(); }
else _toast.ShowError(result.Error);
}
private async Task RevertAttributeToBase(TemplateAttribute attr)
{
var confirmed = await Dialog.ConfirmAsync(
"Revert to base",
$"Discard the override for '{attr.Name}' and follow the base value again?");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
_baseAttributesByName.TryGetValue(attr.Name, out var baseAttr);
var proposed = new TemplateAttribute(attr.Name)
{
Value = baseAttr?.Value,
DataType = attr.DataType,
IsLocked = attr.IsLocked,
Description = baseAttr?.Description,
DataSourceReference = baseAttr?.DataSourceReference,
IsInherited = true,
LockedInDerived = false,
};
var result = await TemplateService.UpdateAttributeAsync(attr.Id, proposed, user);
if (result.IsSuccess) { _toast.ShowSuccess($"'{attr.Name}' reverted to base."); await LoadAsync(); }
else _toast.ShowError(result.Error);
}
// ---- Alarms Tab ----
private RenderFragment RenderAlarmsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Alarms</h5>
<button class="btn btn-primary btn-sm" @onclick="BeginAddAlarm">Add Alarm</button>
</div>
@if (_showAlarmForm)
{
var editing = _editAlarmId.HasValue;
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">@(editing ? "Edit Alarm" : "Add Alarm")</h6>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAlarmForm"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_alarmName" readonly="@editing" />
</div>
<div class="col-12">
<label class="form-label">Trigger Type</label>
<select class="form-select" @bind="_alarmTriggerType" disabled="@editing">
@foreach (var tt in Enum.GetValues<AlarmTriggerType>())
{
<option value="@tt">@tt</option>
}
</select>
</div>
<div class="col-12">
<label class="form-label">Priority</label>
<input type="number" class="form-control" @bind="_alarmPriority" min="0" max="1000" />
</div>
<div class="col-12">
<label class="form-label">Trigger Configuration</label>
<AlarmTriggerEditor TriggerType="@_alarmTriggerType"
Value="@_alarmTriggerConfig"
ValueChanged="@(v => _alarmTriggerConfig = v)"
AvailableAttributes="@BuildAlarmAttributeChoices()"
FallbackPriority="@_alarmPriority" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_alarmIsLocked" id="alarmLocked" />
<label class="form-check-label" for="alarmLocked">Locked</label>
</div>
</div>
@if (_alarmFormError != null)
{
<div class="col-12"><div class="text-danger small">@_alarmFormError</div></div>
}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelAlarmForm">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="SaveAlarm">@(editing ? "Save" : "Add")</button>
</div>
</div>
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Priority</th>
<th>Config</th>
<th>Lock</th>
<th style="width: 60px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var alarm in _alarms)
{
<tr>
<td>@alarm.Name</td>
<td><span class="badge bg-light text-dark">@alarm.TriggerType</span></td>
<td>@alarm.PriorityLevel</td>
<td class="small text-muted text-truncate" style="max-width: 200px;">@(alarm.TriggerConfiguration ?? "—")</td>
<td>
@if (alarm.IsLocked)
{
<span class="badge bg-danger" aria-label="Locked">Locked</span>
}
else
{
<span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>
}
</td>
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {alarm.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @onclick="() => BeginEditAlarm(alarm)">Edit…</button>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteAlarm(alarm)">Delete</button>
</li>
</ul>
</div>
</td>
</tr>
}
</tbody>
</table>
};
// ---- Scripts Tab ----
private RenderFragment RenderScriptsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Scripts</h5>
<button class="btn btn-primary btn-sm" @onclick="BeginAddScript">Add Script</button>
</div>
@if (_showScriptForm)
{
var editingScript = _editScriptId.HasValue;
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">@(editingScript ? "Edit Script" : "Add Script")</h6>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelScriptForm"></button>
</div>
<div class="modal-body">
<div class="row g-3 mb-3">
<div class="col-12">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_scriptName" readonly="@editingScript" />
</div>
<div class="col-md-6">
<label class="form-label">Trigger Type</label>
<input type="text" class="form-control" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" />
</div>
<div class="col-md-6">
<label class="form-label">Trigger Config (JSON)</label>
<input type="text" class="form-control" @bind="_scriptTriggerConfig" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" />
<label class="form-check-label" for="scriptLocked">Locked</label>
</div>
</div>
</div>
@* Tabs: Code, Parameters, Return. Both editor panels stay
mounted (toggled via display:none) so Monaco and the
JSONJoy React island don't tear down on tab switch. *@
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link @(_scriptModalTab == "code" ? "active" : "")"
role="tab"
aria-selected="@(_scriptModalTab == "code" ? "true" : "false")"
@onclick='() => _scriptModalTab = "code"'>Code</button>
</li>
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link @(_scriptModalTab == "parameters" ? "active" : "")"
role="tab"
aria-selected="@(_scriptModalTab == "parameters" ? "true" : "false")"
@onclick='() => _scriptModalTab = "parameters"'>Parameters</button>
</li>
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link @(_scriptModalTab == "return" ? "active" : "")"
role="tab"
aria-selected="@(_scriptModalTab == "return" ? "true" : "false")"
@onclick='() => _scriptModalTab = "return"'>Return type</button>
</li>
</ul>
<div class="border border-top-0 rounded-bottom p-3">
<div style="display: @(_scriptModalTab == "code" ? "block" : "none")">
<MonacoEditor @ref="_scriptEditor" Value="@_scriptCode" ValueChanged="@(v => _scriptCode = v)"
Language="csharp" Height="360px"
DeclaredParameters="@ScriptParameterNames.Parse(_scriptParameters)"
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_scriptParameters)"
SiblingScripts="@(_scripts.Select(s => ScadaLink.CentralUI.ScriptAnalysis.ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition)).ToArray())"
SelfAttributes="@(_attributes.Select(a => new ScadaLink.CentralUI.ScriptAnalysis.AttributeShape(a.Name, MapDataType(a.DataType))).ToArray())"
Children="@_editorChildren"
Parent="@ActiveEditorParent"
MarkersChanged="@(m => { _scriptMarkers = m; StateHasChanged(); })" />
<ProblemsPanel Markers="@_scriptMarkers" OnNavigate="@(m => _scriptEditor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
</div>
<div style="display: @(_scriptModalTab == "parameters" ? "block" : "none")">
<SchemaBuilder Mode="object"
Value="@_scriptParameters"
ValueChanged="@(v => _scriptParameters = v)" />
</div>
<div style="display: @(_scriptModalTab == "return" ? "block" : "none")">
<SchemaBuilder Mode="value"
Value="@_scriptReturn"
ValueChanged="@(v => _scriptReturn = v)" />
</div>
</div>
@if (_scriptFormError != null)
{
<div class="text-danger small mt-2">@_scriptFormError</div>
}
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelScriptForm">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="SaveScript">@(editingScript ? "Save" : "Add")</button>
</div>
</div>
</div>
</div>
}
var derivedScripts = _selectedTemplate!.IsDerived;
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Code (preview)</th>
<th>Lock</th>
@if (derivedScripts)
{
<th>Source</th>
}
else
{
<th title="When true, derived templates may not override this script.">Lock in derived</th>
}
<th style="width: 60px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var script in _scripts)
{
_baseScriptsByName.TryGetValue(script.Name, out var baseScript);
var lockedByBase = derivedScripts && baseScript != null && baseScript.LockedInDerived;
var effectiveCode = (derivedScripts && script.IsInherited && baseScript != null) ? baseScript.Code : script.Code;
<tr>
<td>@script.Name</td>
<td class="small">@(script.TriggerType ?? "—")</td>
<td class="small text-muted text-truncate font-monospace"
style="max-width: 300px;"
title="@effectiveCode">@effectiveCode[..Math.Min(80, effectiveCode.Length)]@(effectiveCode.Length > 80 ? "..." : "")</td>
<td>
@if (script.IsLocked)
{
<span class="badge bg-danger" aria-label="Locked">Locked</span>
}
else
{
<span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>
}
</td>
@if (derivedScripts)
{
<td>
@if (lockedByBase)
{
<span class="badge bg-warning text-dark" title="Locked by base template — cannot override.">🔒 Base-locked</span>
}
else if (script.IsInherited)
{
<span class="badge bg-secondary">Inherited</span>
}
else if (baseScript != null)
{
<span class="badge bg-primary">Override</span>
}
else
{
<span class="badge bg-light text-dark">Local</span>
}
</td>
}
else
{
<td>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
checked="@script.LockedInDerived"
@onchange="(e) => ToggleScriptLockedInDerived(script, e)" />
</div>
</td>
}
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {script.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
@if (derivedScripts && baseScript != null && !lockedByBase && !script.IsInherited)
{
<li>
<button class="dropdown-item" @onclick="() => RevertScriptToBase(script)">Revert to base</button>
</li>
}
@if (!(derivedScripts && baseScript != null))
{
<li>
<button class="dropdown-item" @onclick="() => BeginEditScript(script)">Edit…</button>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteScript(script)">Delete</button>
</li>
}
</ul>
</div>
</td>
</tr>
}
</tbody>
</table>
};
private async Task ToggleScriptLockedInDerived(TemplateScript script, ChangeEventArgs e)
{
var newValue = e.Value is bool b && b;
if (script.LockedInDerived == newValue) return;
var user = await GetCurrentUserAsync();
var proposed = new TemplateScript(script.Name, script.Code)
{
IsLocked = script.IsLocked,
TriggerType = script.TriggerType,
TriggerConfiguration = script.TriggerConfiguration,
ParameterDefinitions = script.ParameterDefinitions,
ReturnDefinition = script.ReturnDefinition,
MinTimeBetweenRuns = script.MinTimeBetweenRuns,
IsInherited = script.IsInherited,
LockedInDerived = newValue,
};
var result = await TemplateService.UpdateScriptAsync(script.Id, proposed, user);
if (result.IsSuccess) { script.LockedInDerived = newValue; _toast.ShowSuccess("Lock-in-derived updated."); }
else _toast.ShowError(result.Error);
}
private async Task RevertScriptToBase(TemplateScript script)
{
var confirmed = await Dialog.ConfirmAsync(
"Revert to base",
$"Discard the override for script '{script.Name}' and follow the base body again?");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
_baseScriptsByName.TryGetValue(script.Name, out var baseScript);
var proposed = new TemplateScript(script.Name, baseScript?.Code ?? script.Code)
{
IsLocked = script.IsLocked,
TriggerType = baseScript?.TriggerType ?? script.TriggerType,
TriggerConfiguration = baseScript?.TriggerConfiguration ?? script.TriggerConfiguration,
ParameterDefinitions = baseScript?.ParameterDefinitions ?? script.ParameterDefinitions,
ReturnDefinition = baseScript?.ReturnDefinition ?? script.ReturnDefinition,
MinTimeBetweenRuns = baseScript?.MinTimeBetweenRuns ?? script.MinTimeBetweenRuns,
IsInherited = true,
LockedInDerived = false,
};
var result = await TemplateService.UpdateScriptAsync(script.Id, proposed, user);
if (result.IsSuccess) { _toast.ShowSuccess($"Script '{script.Name}' reverted to base."); await LoadAsync(); }
else _toast.ShowError(result.Error);
}
// ---- CRUD handlers ----
private void BeginAddAttribute()
{
_showAttrForm = true;
_editAttrId = null;
_attrFormError = null;
_attrName = string.Empty;
_attrValue = null;
_attrDataType = default;
_attrIsLocked = false;
_attrDataSourceRef = null;
}
private void BeginEditAttribute(TemplateAttribute attr)
{
_showAttrForm = true;
_editAttrId = attr.Id;
_attrFormError = null;
_attrName = attr.Name;
_attrValue = attr.Value;
_attrDataType = attr.DataType;
_attrIsLocked = attr.IsLocked;
_attrDataSourceRef = attr.DataSourceReference;
}
private void CancelAttributeForm()
{
_showAttrForm = false;
_editAttrId = null;
_attrFormError = null;
}
private async Task SaveAttribute()
{
if (_selectedTemplate == null) return;
_attrFormError = null;
if (string.IsNullOrWhiteSpace(_attrName)) { _attrFormError = "Name is required."; return; }
var user = await GetCurrentUserAsync();
if (_editAttrId is int id)
{
var existing = _attributes.FirstOrDefault(a => a.Id == id);
if (existing == null) { _attrFormError = "Attribute no longer exists."; return; }
var proposed = new TemplateAttribute(existing.Name)
{
DataType = _attrDataType,
Value = _attrValue?.Trim(),
IsLocked = _attrIsLocked,
DataSourceReference = _attrDataSourceRef?.Trim(),
Description = existing.Description,
IsInherited = existing.IsInherited,
LockedInDerived = existing.LockedInDerived,
};
var result = await TemplateService.UpdateAttributeAsync(id, proposed, user);
if (result.IsSuccess)
{
_showAttrForm = false;
_editAttrId = null;
_toast.ShowSuccess($"Attribute '{existing.Name}' updated.");
await LoadAsync();
}
else
{
_attrFormError = result.Error;
}
return;
}
var attr = new TemplateAttribute(_attrName.Trim())
{
DataType = _attrDataType,
Value = _attrValue?.Trim(),
IsLocked = _attrIsLocked,
DataSourceReference = _attrDataSourceRef?.Trim()
};
var addResult = await TemplateService.AddAttributeAsync(_selectedTemplate.Id, attr, user);
if (addResult.IsSuccess)
{
_showAttrForm = false;
_toast.ShowSuccess($"Attribute '{_attrName}' added.");
await LoadAsync();
}
else
{
_attrFormError = addResult.Error;
}
}
private async Task DeleteAttribute(TemplateAttribute attr)
{
var confirmed = await Dialog.ConfirmAsync("Delete Attribute", $"Delete attribute '{attr.Name}'?", danger: true);
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteAttributeAsync(attr.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Attribute '{attr.Name}' deleted.");
await LoadAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
private void BeginAddAlarm()
{
_showAlarmForm = true;
_editAlarmId = null;
_alarmFormError = null;
_alarmName = string.Empty;
_alarmPriority = 500;
_alarmTriggerType = default;
_alarmTriggerConfig = null;
_alarmIsLocked = false;
}
/// <summary>
/// Builds the attribute choice list shown in the alarm trigger editor's
/// picker. Combines direct + inherited attributes (from <c>_attributes</c>)
/// with composed children's attributes (from <c>_editorChildren</c>,
/// path-qualified as <c>[ChildInstance].[AttributeName]</c>).
/// </summary>
private IReadOnlyList<AlarmAttributeChoice> BuildAlarmAttributeChoices()
{
var list = new List<AlarmAttributeChoice>(capacity: _attributes.Count + 8);
foreach (var a in _attributes)
{
list.Add(new AlarmAttributeChoice(
a.Name,
MapDataType(a.DataType),
a.IsInherited ? "Inherited" : "Direct"));
}
foreach (var child in _editorChildren)
{
foreach (var shape in child.Attributes)
{
list.Add(new AlarmAttributeChoice(
$"{child.Name}.{shape.Name}",
shape.Type,
"Composed"));
}
}
return list;
}
private void BeginEditAlarm(TemplateAlarm alarm)
{
_showAlarmForm = true;
_editAlarmId = alarm.Id;
_alarmFormError = null;
_alarmName = alarm.Name;
_alarmPriority = alarm.PriorityLevel;
_alarmTriggerType = alarm.TriggerType;
_alarmTriggerConfig = alarm.TriggerConfiguration;
_alarmIsLocked = alarm.IsLocked;
}
private void CancelAlarmForm()
{
_showAlarmForm = false;
_editAlarmId = null;
_alarmFormError = null;
}
private async Task SaveAlarm()
{
if (_selectedTemplate == null) return;
_alarmFormError = null;
if (string.IsNullOrWhiteSpace(_alarmName)) { _alarmFormError = "Name is required."; return; }
var user = await GetCurrentUserAsync();
if (_editAlarmId is int id)
{
var existing = _alarms.FirstOrDefault(a => a.Id == id);
if (existing == null) { _alarmFormError = "Alarm no longer exists."; return; }
var proposed = new TemplateAlarm(existing.Name)
{
TriggerType = existing.TriggerType, // fixed
PriorityLevel = _alarmPriority,
TriggerConfiguration = _alarmTriggerConfig?.Trim(),
IsLocked = _alarmIsLocked,
Description = existing.Description,
OnTriggerScriptId = existing.OnTriggerScriptId,
};
var result = await TemplateService.UpdateAlarmAsync(id, proposed, user);
if (result.IsSuccess)
{
_showAlarmForm = false;
_editAlarmId = null;
_toast.ShowSuccess($"Alarm '{existing.Name}' updated.");
await LoadAsync();
}
else
{
_alarmFormError = result.Error;
}
return;
}
var alarm = new TemplateAlarm(_alarmName.Trim())
{
TriggerType = _alarmTriggerType,
PriorityLevel = _alarmPriority,
TriggerConfiguration = _alarmTriggerConfig?.Trim(),
IsLocked = _alarmIsLocked
};
var addResult = await TemplateService.AddAlarmAsync(_selectedTemplate.Id, alarm, user);
if (addResult.IsSuccess)
{
_showAlarmForm = false;
_toast.ShowSuccess($"Alarm '{_alarmName}' added.");
await LoadAsync();
}
else
{
_alarmFormError = addResult.Error;
}
}
private async Task DeleteAlarm(TemplateAlarm alarm)
{
var confirmed = await Dialog.ConfirmAsync("Delete Alarm", $"Delete alarm '{alarm.Name}'?", danger: true);
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteAlarmAsync(alarm.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Alarm '{alarm.Name}' deleted.");
await LoadAsync();
}
else { _toast.ShowError(result.Error); }
}
private void BeginAddScript()
{
_showScriptForm = true;
_editScriptId = null;
_scriptFormError = null;
_scriptName = string.Empty;
_scriptCode = string.Empty;
_scriptTriggerType = null;
_scriptTriggerConfig = null;
_scriptParameters = null;
_scriptReturn = null;
_scriptIsLocked = false;
_scriptModalTab = "code";
}
private void BeginEditScript(TemplateScript script)
{
_showScriptForm = true;
_editScriptId = script.Id;
_scriptFormError = null;
_scriptName = script.Name;
_scriptCode = script.Code;
_scriptTriggerType = script.TriggerType;
_scriptTriggerConfig = script.TriggerConfiguration;
_scriptParameters = script.ParameterDefinitions;
_scriptReturn = script.ReturnDefinition;
_scriptIsLocked = script.IsLocked;
_scriptModalTab = "code";
}
private void CancelScriptForm()
{
_showScriptForm = false;
_editScriptId = null;
_scriptFormError = null;
}
private async Task SaveScript()
{
if (_selectedTemplate == null) return;
_scriptFormError = null;
if (string.IsNullOrWhiteSpace(_scriptName)) { _scriptFormError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_scriptCode)) { _scriptFormError = "Code is required."; return; }
var user = await GetCurrentUserAsync();
if (_editScriptId is int id)
{
var existing = _scripts.FirstOrDefault(s => s.Id == id);
if (existing == null) { _scriptFormError = "Script no longer exists."; return; }
var proposed = new TemplateScript(existing.Name, _scriptCode)
{
TriggerType = _scriptTriggerType?.Trim(),
TriggerConfiguration = _scriptTriggerConfig?.Trim(),
ParameterDefinitions = _scriptParameters,
ReturnDefinition = _scriptReturn,
IsLocked = _scriptIsLocked,
MinTimeBetweenRuns = existing.MinTimeBetweenRuns,
IsInherited = existing.IsInherited,
LockedInDerived = existing.LockedInDerived,
};
var result = await TemplateService.UpdateScriptAsync(id, proposed, user);
if (result.IsSuccess)
{
_showScriptForm = false;
_editScriptId = null;
_toast.ShowSuccess($"Script '{existing.Name}' updated.");
await LoadAsync();
}
else
{
_scriptFormError = result.Error;
}
return;
}
var script = new TemplateScript(_scriptName.Trim(), _scriptCode)
{
TriggerType = _scriptTriggerType?.Trim(),
TriggerConfiguration = _scriptTriggerConfig?.Trim(),
ParameterDefinitions = _scriptParameters,
ReturnDefinition = _scriptReturn,
IsLocked = _scriptIsLocked
};
var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
if (addResult.IsSuccess)
{
_showScriptForm = false;
_toast.ShowSuccess($"Script '{_scriptName}' added.");
await LoadAsync();
}
else
{
_scriptFormError = addResult.Error;
}
}
private async Task DeleteScript(TemplateScript script)
{
var confirmed = await Dialog.ConfirmAsync("Delete Script", $"Delete script '{script.Name}'?", danger: true);
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteScriptAsync(script.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Script '{script.Name}' deleted.");
await LoadAsync();
}
else { _toast.ShowError(result.Error); }
}
// ---- Editor metadata builders ----
private async Task<IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>> BuildChildContextsAsync(
IReadOnlyList<ScadaLink.Commons.Entities.Templates.TemplateComposition> comps)
{
var result = new List<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>();
foreach (var comp in comps)
{
var composed = await TemplateEngineRepository.GetTemplateWithChildrenAsync(comp.ComposedTemplateId);
if (composed == null) continue;
result.Add(BuildCompositionContext(comp.InstanceName, composed));
}
return result;
}
private async Task<List<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>> BuildParentContextsAsync(int templateId)
{
// Post derive-on-compose: only derived templates have a parent context,
// and exactly one — the template that owns their composition slot.
// Base templates suppress Parent.* assistance.
if (_selectedTemplate?.IsDerived != true || _ownerTemplate == null)
return new List<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>();
// Resolve the owner with eager-loaded members so the context has shapes.
var owner = await TemplateEngineRepository.GetTemplateByIdAsync(_ownerTemplate.Id);
if (owner == null)
return new List<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>();
return new List<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>
{
BuildCompositionContext(owner.Name, owner)
};
}
private static ScadaLink.CentralUI.ScriptAnalysis.CompositionContext BuildCompositionContext(
string label,
ScadaLink.Commons.Entities.Templates.Template t)
{
var attrs = t.Attributes
.Select(a => new ScadaLink.CentralUI.ScriptAnalysis.AttributeShape(a.Name, MapDataType(a.DataType)))
.ToList();
var scripts = t.Scripts
.Select(s => ScadaLink.CentralUI.ScriptAnalysis.ScriptShapeParser.Parse(
s.Name, s.ParameterDefinitions, s.ReturnDefinition))
.ToList();
return new ScadaLink.CentralUI.ScriptAnalysis.CompositionContext(label, attrs, scripts);
}
private static string MapDataType(ScadaLink.Commons.Types.Enums.DataType dt) => dt switch
{
ScadaLink.Commons.Types.Enums.DataType.Boolean => "Boolean",
ScadaLink.Commons.Types.Enums.DataType.Int32 => "Integer",
ScadaLink.Commons.Types.Enums.DataType.Float => "Float",
ScadaLink.Commons.Types.Enums.DataType.Double => "Float",
ScadaLink.Commons.Types.Enums.DataType.String => "String",
ScadaLink.Commons.Types.Enums.DataType.DateTime => "String",
ScadaLink.Commons.Types.Enums.DataType.Binary => "Object",
_ => "Object"
};
}