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)
1503 lines
67 KiB
Plaintext
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"
|
|
};
|
|
}
|