06462a0100
A composition-derived template now stores its contained name — the
composition slot's InstanceName (e.g. "Pump"), unique only within its
owner — instead of the dotted global path ("Motor Controller.Pump").
The qualified hierarchical name is computed on read.
- TemplateNaming.QualifiedName: walks the OwnerCompositionId chain to
build the dotted path; null-safe, cycle-guarded.
- TemplateConfiguration: the unique index on Template.Name becomes
filtered (WHERE IsDerived = 0) — base templates stay globally unique;
derived templates' uniqueness is the existing (TemplateId,
InstanceName) index on TemplateComposition.
- Migration ContainedDerivedTemplateNames: rewrites derived rows to the
contained name; Down rebuilds the dotted names via a recursive CTE
before restoring the global index.
- TemplateService: composition create/rename store the contained name;
the dotted-name collision pre-checks and cascade-rename are removed
(a slot rename no longer touches nested derived templates).
- TemplateEdit: title shows the contained name; the qualified path is a
breadcrumb subtitle; "composed inside" uses the owner's qualified name.
TDD: 4 TemplateNaming tests + updated composition tests. TemplateEngine
293, ConfigurationDatabase 114, CentralUI 316 green. Migration applied to
the dev cluster and verified in the browser (Motor Controller.Pump now
titled "Pump"; nested Motor Controller.Pump.TempSensor resolves).
Design: docs/plans/2026-05-18-contained-template-names-design.md
1782 lines
85 KiB
Plaintext
1782 lines
85 KiB
Plaintext
@page "/design/templates/{Id:int}"
|
|
@using ScadaLink.Security
|
|
@using ScadaLink.Commons.Entities.Instances
|
|
@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 ICentralUiRepository CentralUiRepository
|
|
@inject TemplateService TemplateService
|
|
@inject ScadaLink.CentralUI.ScriptAnalysis.ScriptAnalysisService AnalysisService
|
|
@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? _scriptMinTimeValue;
|
|
private string _scriptMinTimeUnit = "sec";
|
|
private string? _scriptParameters;
|
|
private string? _scriptReturn;
|
|
private bool _scriptIsLocked;
|
|
private string? _scriptFormError;
|
|
private string _scriptModalTab = "trigger"; // "trigger" | "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>();
|
|
|
|
// Script modal Test Run state.
|
|
private bool _showScriptTestRun;
|
|
private bool _scriptRunning;
|
|
private Dictionary<string, object?> _scriptParamValues = new();
|
|
private ScadaLink.CentralUI.ScriptAnalysis.SandboxRunResult? _scriptRunResult;
|
|
private CancellationTokenSource? _scriptRunCts;
|
|
private List<Instance> _deployedInstances = new();
|
|
private string _scriptBindInstance = string.Empty;
|
|
|
|
/// <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);
|
|
|
|
// Deployed, running instances of this template — selectable as the
|
|
// bind target for a script Test Run.
|
|
_deployedInstances = (await CentralUiRepository.GetInstancesFilteredAsync(templateId: Id))
|
|
.Where(i => i.State == InstanceState.Enabled)
|
|
.OrderBy(i => i.UniqueName)
|
|
.ToList();
|
|
|
|
_validationResult = null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_loadError = $"Failed to load template: {ex.Message}";
|
|
}
|
|
_loading = false;
|
|
}
|
|
|
|
private void GoBack()
|
|
{
|
|
NavigationManager.NavigateTo("/design/templates");
|
|
}
|
|
|
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
|
private Task<string> GetCurrentUserAsync()
|
|
=> AuthStateProvider.GetCurrentUsernameAsync();
|
|
|
|
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>@QualifiedTemplateName(_ownerTemplate)</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>
|
|
}
|
|
@if (_selectedTemplate.IsDerived)
|
|
{
|
|
@* Derived templates store a contained name; show the full
|
|
qualified path as a breadcrumb subtitle. *@
|
|
<div class="text-muted small font-monospace">@QualifiedTemplateName(_selectedTemplate)</div>
|
|
}
|
|
</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-dialog-scrollable script-editor-modal">
|
|
<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-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: Trigger, Code, Parameters, Return. All panels stay
|
|
mounted (toggled via display:none) so Monaco editors 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 == "trigger" ? "active" : "")"
|
|
role="tab"
|
|
aria-selected="@(_scriptModalTab == "trigger" ? "true" : "false")"
|
|
@onclick='() => _scriptModalTab = "trigger"'>Trigger</button>
|
|
</li>
|
|
<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 == "trigger" ? "block" : "none")">
|
|
<ScriptTriggerEditor TriggerType="@_scriptTriggerType"
|
|
TriggerConfig="@_scriptTriggerConfig"
|
|
Changed="@OnScriptTriggerChanged"
|
|
AvailableAttributes="@BuildAlarmAttributeChoices()" />
|
|
@if (ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(_scriptTriggerType))
|
|
{
|
|
<div class="mt-3">
|
|
<label class="form-label">Min time between runs</label>
|
|
<div class="row g-2" style="max-width: 420px;">
|
|
<div class="col-7">
|
|
<input type="number" min="1" step="1" class="form-control"
|
|
placeholder="(optional)"
|
|
@bind="_scriptMinTimeValue" @bind:event="oninput" />
|
|
</div>
|
|
<div class="col-5">
|
|
<select class="form-select" @bind="_scriptMinTimeUnit">
|
|
<option value="ms">milliseconds</option>
|
|
<option value="sec">seconds</option>
|
|
<option value="min">minutes</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
@if (ScriptTriggerIsWhileTrue())
|
|
{
|
|
<div class="form-text">
|
|
This is the re-fire interval for the
|
|
<strong>WhileTrue</strong> trigger above.
|
|
</div>
|
|
@if (DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit) is null)
|
|
{
|
|
<div class="alert alert-warning py-1 px-2 small mt-1 mb-0">
|
|
The WhileTrue trigger has no interval set — the script
|
|
will fire only once. Set a value here to make it re-fire.
|
|
</div>
|
|
}
|
|
}
|
|
else
|
|
{
|
|
<div class="form-text">
|
|
Optional throttle — skips trigger invocations that fire
|
|
sooner than this.
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
<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>
|
|
}
|
|
|
|
@if (_showScriptTestRun)
|
|
{
|
|
<div class="card mt-3" id="script-test-run-panel">
|
|
<div class="card-header py-2">
|
|
<span class="fw-semibold">Test Run <span class="badge bg-warning text-dark ms-1">Real I/O</span></span>
|
|
</div>
|
|
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
|
<strong>Heads up:</strong>
|
|
runs the script as typed (unsaved edits included) against the supplied
|
|
<code>Parameters</code>.
|
|
<code>External</code>, <code>Database</code>, and <code>Notify</code> calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent.
|
|
<code>CallShared</code> executes the named shared script (saved version) in the same sandbox.
|
|
<code>Instance</code>, <code>Attributes</code>, <code>Children</code>, <code>Parent</code>, and <code>CallScript</code> throw unless a bound instance is selected below — then they route to that live instance (attribute writes are permanent too).
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="mb-3">
|
|
<label class="form-label small">Bind to instance <span class="text-muted">(optional)</span></label>
|
|
@if (_deployedInstances.Count == 0)
|
|
{
|
|
<div class="form-text">
|
|
No running instances of this template.
|
|
<code>Instance</code>/<code>Attributes</code>/<code>CallScript</code> will throw.
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<select class="form-select form-select-sm" @bind="_scriptBindInstance">
|
|
<option value="">— None (Instance/Attributes throw) —</option>
|
|
@foreach (var inst in _deployedInstances)
|
|
{
|
|
<option value="@inst.UniqueName">@inst.UniqueName</option>
|
|
}
|
|
</select>
|
|
<div class="form-text">
|
|
Routes <code>Instance.GetAttribute/SetAttribute</code>,
|
|
<code>Attributes</code>, <code>Children</code>, <code>Parent</code>, and
|
|
<code>CallScript</code> to the selected live instance.
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label small">Parameter values</label>
|
|
<ParameterValueForm ParameterDefinitions="@_scriptParameters"
|
|
Values="_scriptParamValues"
|
|
ValuesChanged="@(v => _scriptParamValues = v)" />
|
|
</div>
|
|
<div class="d-flex gap-2 align-items-center mb-3">
|
|
<button class="btn btn-primary btn-sm" @onclick="RunScriptInSandboxAsync" disabled="@_scriptRunning">
|
|
@if (_scriptRunning)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
|
<span>Running…</span>
|
|
}
|
|
else
|
|
{
|
|
<span>Run</span>
|
|
}
|
|
</button>
|
|
@if (_scriptRunResult != null)
|
|
{
|
|
<span class="text-muted small">@_scriptRunResult.DurationMs ms</span>
|
|
}
|
|
</div>
|
|
|
|
@if (_scriptRunResult != null)
|
|
{
|
|
@if (_scriptRunResult.Success)
|
|
{
|
|
<div class="mb-3">
|
|
<label class="form-label small text-success mb-1">
|
|
Return value <span class="badge bg-light text-dark ms-1">@_scriptRunResult.ReturnTypeName</span>
|
|
</label>
|
|
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_scriptRunResult.ReturnValueJson</pre>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="mb-3">
|
|
<label class="form-label small text-danger mb-1">
|
|
<span class="badge bg-danger me-1">@ScriptErrorKindLabel(_scriptRunResult.ErrorKind)</span>
|
|
</label>
|
|
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_scriptRunResult.Error</pre>
|
|
@if (_scriptRunResult.Markers is { Count: > 0 })
|
|
{
|
|
<ul class="small text-danger mt-2 mb-0">
|
|
@foreach (var m in _scriptRunResult.Markers)
|
|
{
|
|
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
|
|
}
|
|
</ul>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@if (!string.IsNullOrEmpty(_scriptRunResult.ConsoleOutput))
|
|
{
|
|
<div class="mb-0">
|
|
<label class="form-label small mb-1">Console output</label>
|
|
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_scriptRunResult.ConsoleOutput</pre>
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-outline-primary btn-sm me-auto" @onclick="ToggleScriptTestRunPanel">
|
|
@(_showScriptTestRun ? "Hide Test Run" : "Test Run")
|
|
</button>
|
|
<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); }
|
|
}
|
|
|
|
/// <summary>Applies the structured trigger editor's type + config atomically.</summary>
|
|
private void OnScriptTriggerChanged(ScriptTriggerValue v)
|
|
{
|
|
_scriptTriggerType = v.TriggerType;
|
|
_scriptTriggerConfig = v.Config;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when the current script trigger is a WhileTrue Conditional/Expression
|
|
/// trigger — the case where the "Min time between runs" interval is required
|
|
/// (it is the re-fire cadence).
|
|
/// </summary>
|
|
private bool ScriptTriggerIsWhileTrue()
|
|
{
|
|
var kind = ScriptTriggerConfigCodec.ParseKind(_scriptTriggerType);
|
|
return kind is ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression
|
|
&& ScriptTriggerConfigCodec.Parse(_scriptTriggerConfig, kind).Mode
|
|
== ScriptTriggerMode.WhileTrue;
|
|
}
|
|
|
|
private void BeginAddScript()
|
|
{
|
|
_showScriptForm = true;
|
|
_editScriptId = null;
|
|
_scriptFormError = null;
|
|
_scriptName = string.Empty;
|
|
_scriptCode = string.Empty;
|
|
_scriptTriggerType = null;
|
|
_scriptTriggerConfig = null;
|
|
(_scriptMinTimeValue, _scriptMinTimeUnit) = DurationInput.Split(null);
|
|
_scriptParameters = null;
|
|
_scriptReturn = null;
|
|
_scriptIsLocked = false;
|
|
_scriptModalTab = "trigger";
|
|
ResetScriptTestRun();
|
|
}
|
|
|
|
private void BeginEditScript(TemplateScript script)
|
|
{
|
|
_showScriptForm = true;
|
|
_editScriptId = script.Id;
|
|
_scriptFormError = null;
|
|
_scriptName = script.Name;
|
|
_scriptCode = script.Code;
|
|
_scriptTriggerType = script.TriggerType;
|
|
_scriptTriggerConfig = script.TriggerConfiguration;
|
|
(_scriptMinTimeValue, _scriptMinTimeUnit) = DurationInput.Split(script.MinTimeBetweenRuns);
|
|
_scriptParameters = script.ParameterDefinitions;
|
|
_scriptReturn = script.ReturnDefinition;
|
|
_scriptIsLocked = script.IsLocked;
|
|
_scriptModalTab = "trigger";
|
|
ResetScriptTestRun();
|
|
}
|
|
|
|
private void CancelScriptForm()
|
|
{
|
|
_showScriptForm = false;
|
|
_editScriptId = null;
|
|
_scriptFormError = null;
|
|
ResetScriptTestRun();
|
|
}
|
|
|
|
private void ResetScriptTestRun()
|
|
{
|
|
_showScriptTestRun = false;
|
|
_scriptRunning = false;
|
|
_scriptParamValues = new();
|
|
_scriptBindInstance = string.Empty;
|
|
_scriptRunResult = null;
|
|
_scriptRunCts?.Cancel();
|
|
_scriptRunCts = null;
|
|
}
|
|
|
|
private void ToggleScriptTestRunPanel() => _showScriptTestRun = !_showScriptTestRun;
|
|
|
|
private async Task RunScriptInSandboxAsync()
|
|
{
|
|
_scriptRunCts?.Cancel();
|
|
_scriptRunCts = new CancellationTokenSource();
|
|
_scriptRunning = true;
|
|
_scriptRunResult = null;
|
|
StateHasChanged();
|
|
|
|
try
|
|
{
|
|
var jsonParams = _scriptParamValues.ToDictionary(
|
|
kv => kv.Key,
|
|
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
|
|
var request = new ScadaLink.CentralUI.ScriptAnalysis.SandboxRunRequest(
|
|
_scriptCode, jsonParams, TimeoutSeconds: null,
|
|
BindInstanceUniqueName: string.IsNullOrEmpty(_scriptBindInstance) ? null : _scriptBindInstance);
|
|
_scriptRunResult = await AnalysisService.RunInSandboxAsync(request, _scriptRunCts.Token);
|
|
}
|
|
catch (OperationCanceledException) { /* superseded by next Run click */ }
|
|
catch (Exception ex)
|
|
{
|
|
_scriptRunResult = new ScadaLink.CentralUI.ScriptAnalysis.SandboxRunResult(
|
|
Success: false,
|
|
ReturnValueJson: null,
|
|
ReturnTypeName: null,
|
|
ConsoleOutput: "",
|
|
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
|
|
ErrorKind: ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.RuntimeError,
|
|
DurationMs: 0,
|
|
Markers: null);
|
|
}
|
|
finally
|
|
{
|
|
_scriptRunning = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private static string ScriptErrorKindLabel(ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind kind) => kind switch
|
|
{
|
|
ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error",
|
|
ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
|
|
ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error",
|
|
ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout",
|
|
_ => "Error"
|
|
};
|
|
|
|
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 = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit),
|
|
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,
|
|
MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit)
|
|
};
|
|
|
|
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); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes a template's qualified (hierarchical) name from the loaded
|
|
/// template set — the stored name for a base template, the dotted
|
|
/// owner-chain path for a composition-derived one.
|
|
/// </summary>
|
|
private string QualifiedTemplateName(Template template)
|
|
{
|
|
var byId = _templates.ToDictionary(t => t.Id);
|
|
var compById = _templates.SelectMany(t => t.Compositions).ToDictionary(c => c.Id);
|
|
return TemplateNaming.QualifiedName(template, byId, compById);
|
|
}
|
|
|
|
// ---- 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"
|
|
};
|
|
}
|