feat(ui): structured editors for script schemas and alarm triggers

Replace raw-JSON text inputs with rich UI: script parameter/return types use
a JSON Schema builder (SchemaBuilder + JsonSchemaShapeParser, with a migration
to convert existing definitions); alarm trigger config uses a type-aware
editor with a flattened attribute picker (AlarmTriggerEditor). AlarmActor
gains optional direction (rising/falling/either) on RateOfChange triggers.
This commit is contained in:
Joseph Doherty
2026-05-13 00:33:00 -04:00
parent 57f477fd28
commit 783da8e21a
25 changed files with 3609 additions and 861 deletions

View File

@@ -30,11 +30,15 @@
</div>
<div class="mb-3">
<label class="form-label">Parameters</label>
<ParameterListEditor Json="@_params" JsonChanged="@(v => _params = v)" />
<SchemaBuilder Mode="object"
Value="@_params"
ValueChanged="@(v => _params = v)" />
</div>
<div class="mb-3">
<label class="form-label">Return value</label>
<ReturnTypeEditor Json="@_returns" JsonChanged="@(v => _returns = v)" />
<SchemaBuilder Mode="value"
Value="@_returns"
ValueChanged="@(v => _returns = v)" />
</div>
<div class="mb-3">
<label class="form-label">Script</label>

View File

@@ -32,11 +32,15 @@
</div>
<div class="mb-3">
<label class="form-label small">Parameters</label>
<ParameterListEditor Json="@_formParameters" JsonChanged="@(v => _formParameters = v)" />
<SchemaBuilder Mode="object"
Value="@_formParameters"
ValueChanged="@(v => _formParameters = v)" />
</div>
<div class="mb-3">
<label class="form-label small">Return value</label>
<ReturnTypeEditor Json="@_formReturn" JsonChanged="@(v => _formReturn = v)" />
<SchemaBuilder Mode="value"
Value="@_formReturn"
ValueChanged="@(v => _formReturn = v)" />
</div>
<div class="mb-2">
<label class="form-label small">Code</label>

View File

@@ -70,8 +70,9 @@
private bool _validating;
private Commons.Types.Flattening.ValidationResult? _validationResult;
// Member add forms
// 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;
@@ -80,6 +81,7 @@
private string? _attrFormError;
private bool _showAlarmForm;
private int? _editAlarmId;
private string _alarmName = string.Empty;
private int _alarmPriority;
private AlarmTriggerType _alarmTriggerType;
@@ -88,6 +90,7 @@
private string? _alarmFormError;
private bool _showScriptForm;
private int? _editScriptId;
private string _scriptName = string.Empty;
private string _scriptCode = string.Empty;
private string? _scriptTriggerType;
@@ -96,6 +99,7 @@
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>();
@@ -470,49 +474,57 @@
{
<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="() => { _showAttrForm = true; _attrFormError = null; _attrName = string.Empty; _attrValue = null; _attrIsLocked = false; _attrDataSourceRef = null; }">Add Attribute</button>
<button class="btn btn-primary btn-sm" @onclick="BeginAddAttribute">Add Attribute</button>
</div>
@if (_showAttrForm)
{
<div class="card mb-3">
<div class="card-header">Add Attribute</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="_attrName" />
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="col-12">
<label class="form-label">Data Type</label>
<select class="form-select" @bind="_attrDataType">
@foreach (var dt in Enum.GetValues<DataType>())
<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)
{
<option value="@dt">@dt</option>
<div class="col-12"><div class="text-danger small">@_attrFormError</div></div>
}
</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 class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="() => _showAttrForm = false">Cancel</button>
<button class="btn btn-success" @onclick="AddAttribute">Add</button>
<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>
@@ -616,6 +628,10 @@
}
@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>
@@ -701,49 +717,60 @@
{
<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="() => { _showAlarmForm = true; _alarmFormError = null; _alarmName = string.Empty; _alarmPriority = 500; _alarmTriggerConfig = null; _alarmIsLocked = false; }">Add Alarm</button>
<button class="btn btn-primary btn-sm" @onclick="BeginAddAlarm">Add Alarm</button>
</div>
@if (_showAlarmForm)
{
<div class="card mb-3">
<div class="card-header">Add Alarm</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="_alarmName" />
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="col-12">
<label class="form-label">Trigger Type</label>
<select class="form-select" @bind="_alarmTriggerType">
@foreach (var tt in Enum.GetValues<AlarmTriggerType>())
<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()" />
</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)
{
<option value="@tt">@tt</option>
<div class="col-12"><div class="text-danger small">@_alarmFormError</div></div>
}
</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 Config (JSON)</label>
<input type="text" class="form-control" @bind="_alarmTriggerConfig" />
</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 class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="() => _showAlarmForm = false">Cancel</button>
<button class="btn btn-success" @onclick="AddAlarm">Add</button>
<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>
@@ -786,6 +813,10 @@
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>
@@ -804,61 +835,100 @@
{
<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="() => { _showScriptForm = true; _scriptFormError = null; _scriptName = string.Empty; _scriptCode = string.Empty; _scriptTriggerType = null; _scriptTriggerConfig = null; _scriptParameters = null; _scriptReturn = null; _scriptIsLocked = false; }">Add Script</button>
<button class="btn btn-primary btn-sm" @onclick="BeginAddScript">Add Script</button>
</div>
@if (_showScriptForm)
{
<div class="card mb-3">
<div class="card-header">Add Script</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="_scriptName" />
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="col-12">
<label class="form-label">Trigger Type</label>
<input type="text" class="form-control" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" />
</div>
<div class="col-12">
<label class="form-label">Trigger Config (JSON)</label>
<input type="text" class="form-control" @bind="_scriptTriggerConfig" />
</div>
<div class="col-12">
<label class="form-label">Parameters</label>
<ParameterListEditor Json="@_scriptParameters" JsonChanged="@(v => _scriptParameters = v)" />
</div>
<div class="col-12">
<label class="form-label">Return value</label>
<ReturnTypeEditor Json="@_scriptReturn" JsonChanged="@(v => _scriptReturn = v)" />
</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 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="col-12">
<label class="form-label">Code</label>
<MonacoEditor @ref="_scriptEditor" Value="@_scriptCode" ValueChanged="@(v => _scriptCode = v)"
Language="csharp" Height="320px"
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>
@if (_scriptFormError != null)
{
<div class="col-12"><div class="text-danger small">@_scriptFormError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="() => _showScriptForm = false">Cancel</button>
<button class="btn btn-success" @onclick="AddScript">Add</button>
<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>
@@ -952,6 +1022,10 @@
}
@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>
@@ -1013,12 +1087,74 @@
// ---- CRUD handlers ----
private async Task AddAttribute()
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,
@@ -1027,9 +1163,8 @@
DataSourceReference = _attrDataSourceRef?.Trim()
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddAttributeAsync(_selectedTemplate.Id, attr, user);
if (result.IsSuccess)
var addResult = await TemplateService.AddAttributeAsync(_selectedTemplate.Id, attr, user);
if (addResult.IsSuccess)
{
_showAttrForm = false;
_toast.ShowSuccess($"Attribute '{_attrName}' added.");
@@ -1037,7 +1172,7 @@
}
else
{
_attrFormError = result.Error;
_attrFormError = addResult.Error;
}
}
@@ -1058,12 +1193,105 @@
}
}
private async Task AddAlarm()
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,
@@ -1072,9 +1300,8 @@
IsLocked = _alarmIsLocked
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddAlarmAsync(_selectedTemplate.Id, alarm, user);
if (result.IsSuccess)
var addResult = await TemplateService.AddAlarmAsync(_selectedTemplate.Id, alarm, user);
if (addResult.IsSuccess)
{
_showAlarmForm = false;
_toast.ShowSuccess($"Alarm '{_alarmName}' added.");
@@ -1082,7 +1309,7 @@
}
else
{
_alarmFormError = result.Error;
_alarmFormError = addResult.Error;
}
}
@@ -1100,13 +1327,82 @@
else { _toast.ShowError(result.Error); }
}
private async Task AddScript()
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(),
@@ -1116,9 +1412,8 @@
IsLocked = _scriptIsLocked
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
if (result.IsSuccess)
var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
if (addResult.IsSuccess)
{
_showScriptForm = false;
_toast.ShowSuccess($"Script '{_scriptName}' added.");
@@ -1126,7 +1421,7 @@
}
else
{
_scriptFormError = result.Error;
_scriptFormError = addResult.Error;
}
}

View File

@@ -0,0 +1,8 @@
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// One option in the alarm trigger editor's attribute picker.
/// <see cref="Source"/> is one of "Direct", "Inherited", or "Composed" —
/// used to group entries in the dropdown.
/// </summary>
public record AlarmAttributeChoice(string CanonicalName, string DataType, string Source);

View File

@@ -0,0 +1,572 @@
@namespace ScadaLink.CentralUI.Components.Shared
@using System.Globalization
@using System.IO
@using System.Text
@using System.Text.Json
@using ScadaLink.Commons.Types.Enums
@* Rich alarm trigger configuration editor. Replaces the raw JSON text field
used for TemplateAlarm.TriggerConfiguration. The editor emits the same JSON
shape that AlarmActor.ParseEvalConfig consumes:
ValueMatch { attributeName, matchValue } ("!=X" prefix = not equals)
RangeViolation { attributeName, min, max }
RateOfChange { attributeName, thresholdPerSecond, windowSeconds, direction } *@
<div class="border rounded bg-white p-3">
@* ── Monitored attribute ───────────────────────────────────────────── *@
<div class="mb-3">
<label for="alarm-attr-select" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Monitored attribute
</label>
<div class="input-group input-group-sm">
<select id="alarm-attr-select"
class="form-select"
@bind="_attributeName"
@bind:after="OnAttributeChanged">
<option value="">— select attribute —</option>
@{
var groups = AvailableAttributes
.GroupBy(c => c.Source)
.OrderBy(g => SourceOrder(g.Key))
.ToList();
}
@foreach (var grp in groups)
{
<optgroup label="@grp.Key">
@foreach (var choice in grp.OrderBy(c => c.CanonicalName, StringComparer.Ordinal))
{
var label = $"{choice.CanonicalName} ({choice.DataType})";
var disabled = !IsAttributeCompatible(choice);
<option value="@choice.CanonicalName" disabled="@disabled">@label</option>
}
</optgroup>
}
@* If the saved attribute name isn't in the current list, keep it selectable so it's visible. *@
@if (!string.IsNullOrEmpty(_model.AttributeName) && _selectedChoice == null)
{
<optgroup label="Unknown">
<option value="@_model.AttributeName">@_model.AttributeName (not found)</option>
</optgroup>
}
</select>
@if (_selectedDataType is { } dt)
{
<span class="input-group-text bg-light text-muted small">@dt</span>
}
</div>
@if (_selectedChoice != null && !IsAttributeCompatible(_selectedChoice))
{
<div class="form-text text-danger">
Selected attribute is @_selectedChoice.DataType — this trigger type requires a numeric attribute.
</div>
}
else if (_selectedChoice == null && !string.IsNullOrWhiteSpace(_model.AttributeName))
{
<div class="form-text text-warning-emphasis">
"@_model.AttributeName" is not in the current template. Save will still write it as-is.
</div>
}
</div>
@* ── Type-specific block ───────────────────────────────────────────── *@
@switch (TriggerType)
{
case AlarmTriggerType.ValueMatch:
@RenderValueMatch();
break;
case AlarmTriggerType.RangeViolation:
@RenderRangeViolation();
break;
case AlarmTriggerType.RateOfChange:
@RenderRateOfChange();
break;
}
@* ── Hint ──────────────────────────────────────────────────────────── *@
<div class="mt-3 pt-2 border-top small text-muted">
@BuildHint()
</div>
</div>
@code {
// ── Parameters ─────────────────────────────────────────────────────────
[Parameter] public AlarmTriggerType TriggerType { get; set; }
[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
/// <summary>
/// Flattened attribute list (direct + inherited + composed). Used to drive
/// the picker and to determine the selected attribute's data type for
/// type-aware inputs.
/// </summary>
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; } =
Array.Empty<AlarmAttributeChoice>();
// ── Internal state ─────────────────────────────────────────────────────
private TriggerModel _model = new();
private AlarmTriggerType _lastSeenType;
private string? _lastSeenJson;
/// <summary>The choice currently selected from <see cref="AvailableAttributes"/>, if any.</summary>
private AlarmAttributeChoice? _selectedChoice;
private string? _selectedDataType => _selectedChoice?.DataType;
// ── Parse / serialize lifecycle ────────────────────────────────────────
protected override void OnParametersSet()
{
var typeChanged = _lastSeenType != TriggerType;
var jsonChanged = Value != _lastSeenJson;
if (!typeChanged && !jsonChanged) return;
_lastSeenType = TriggerType;
_lastSeenJson = Value;
// Preserve attribute name across type changes — re-parse the JSON in
// the context of the new type. Missing/unparseable keys fall back to
// empty defaults.
var preservedAttr = _model.AttributeName;
_model = Parse(Value, TriggerType);
if (jsonChanged == false && typeChanged && !string.IsNullOrEmpty(preservedAttr))
_model.AttributeName = preservedAttr;
RefreshSelectedChoice();
SyncTextMirrors();
}
private void RefreshSelectedChoice()
{
_selectedChoice = AvailableAttributes.FirstOrDefault(
c => string.Equals(c.CanonicalName, _model.AttributeName, StringComparison.Ordinal));
}
private async Task Emit()
{
var json = Serialize(_model, TriggerType);
_lastSeenJson = json;
await ValueChanged.InvokeAsync(json);
}
// ── Attribute picker ───────────────────────────────────────────────────
/// <summary>
/// String mirror for the attribute picker — required because @bind needs a
/// settable backing field, not a computed expression.
/// </summary>
private string _attributeName = string.Empty;
private async Task OnAttributeChanged()
{
_model.AttributeName = _attributeName;
RefreshSelectedChoice();
await Emit();
}
private static int SourceOrder(string source) => source switch
{
"Direct" => 0,
"Inherited" => 1,
"Composed" => 2,
_ => 3
};
private bool IsAttributeCompatible(AlarmAttributeChoice choice) =>
TriggerType == AlarmTriggerType.ValueMatch
|| IsNumericType(choice.DataType);
private static bool IsNumericType(string dataType) => dataType switch
{
"Integer" or "Int32" or "Int64" or "Float" or "Double" or "Number" => true,
_ => false
};
// ── ValueMatch ─────────────────────────────────────────────────────────
private RenderFragment RenderValueMatch() => __builder =>
{
<div class="row g-2">
<div class="col-md-4">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Operator
</label>
<select class="form-select form-select-sm"
@bind="_operatorText"
@bind:after="OnOperatorChanged">
<option value="eq">equals</option>
<option value="ne">not equals</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Match value
</label>
@{
var t = _selectedChoice?.DataType;
if (t == "Boolean")
{
<select class="form-select form-select-sm"
@bind="_matchValueText"
@bind:after="OnMatchValueChanged">
<option value="True">True</option>
<option value="False">False</option>
</select>
}
else if (IsNumericType(t ?? ""))
{
<input type="number" step="any" class="form-control form-control-sm"
@bind="_matchValueText"
@bind:event="oninput"
@bind:after="OnMatchValueChanged" />
}
else
{
<input type="text" class="form-control form-control-sm"
placeholder="value"
@bind="_matchValueText"
@bind:event="oninput"
@bind:after="OnMatchValueChanged" />
}
}
</div>
</div>
};
// ── RangeViolation ─────────────────────────────────────────────────────
private RenderFragment RenderRangeViolation() => __builder =>
{
<div class="row g-2 align-items-end">
<div class="col-md-5">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Minimum
</label>
<input type="number" step="any" class="form-control form-control-sm"
@bind="_minText"
@bind:event="oninput"
@bind:after="OnMinChanged" />
</div>
<div class="col-md-2 text-center pb-1 text-muted small">to</div>
<div class="col-md-5">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Maximum
</label>
<input type="number" step="any" class="form-control form-control-sm"
@bind="_maxText"
@bind:event="oninput"
@bind:after="OnMaxChanged" />
</div>
</div>
<div class="mt-3" aria-hidden="true">
<svg viewBox="0 0 200 12" preserveAspectRatio="none"
style="width:100%; height:10px; border-radius:5px; overflow:hidden;">
<rect x="0" y="0" width="20" height="12" fill="#f8d7da" />
<rect x="20" y="0" width="160" height="12" fill="#d1e7dd" />
<rect x="180" y="0" width="20" height="12" fill="#f8d7da" />
</svg>
<div class="d-flex justify-content-between small text-muted mt-1">
<span>alarm</span>
<span>normal</span>
<span>alarm</span>
</div>
</div>
};
private async Task OnMinChanged()
{
_model.Min = ParseDouble(_minText);
await Emit();
}
private async Task OnMaxChanged()
{
_model.Max = ParseDouble(_maxText);
await Emit();
}
// ── RateOfChange ───────────────────────────────────────────────────────
private RenderFragment RenderRateOfChange() => __builder =>
{
<div class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Rate threshold
</label>
<div class="input-group input-group-sm">
<input type="number" step="any" class="form-control"
@bind="_thresholdText"
@bind:event="oninput"
@bind:after="OnThresholdChanged" />
<span class="input-group-text">units / sec</span>
</div>
</div>
<div class="col-md-6">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Sampling window
</label>
<div class="input-group input-group-sm">
<input type="number" step="any" min="0" class="form-control"
@bind="_windowText"
@bind:event="oninput"
@bind:after="OnWindowChanged" />
<span class="input-group-text">sec</span>
</div>
</div>
</div>
<div class="mt-3 row g-2">
<div class="col-md-6">
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
Direction
</label>
<select class="form-select form-select-sm"
@bind="_directionText"
@bind:after="OnDirectionChanged">
<option value="rising">Rising only</option>
<option value="falling">Falling only</option>
<option value="either">Either direction</option>
</select>
</div>
</div>
};
private async Task OnThresholdChanged()
{
_model.ThresholdPerSecond = ParseDouble(_thresholdText);
await Emit();
}
private async Task OnWindowChanged()
{
_model.WindowSeconds = ParseDouble(_windowText);
await Emit();
}
private async Task OnDirectionChanged()
{
_model.Direction = _directionText;
await Emit();
}
private string _directionText = "either";
// ── Text mirrors for typed inputs ──────────────────────────────────────
// @bind requires a settable backing field that round-trips text. We keep
// these in sync with the model and re-parse on @bind:after.
private string? _minText;
private string? _maxText;
private string? _thresholdText;
private string? _windowText;
protected override void OnInitialized()
{
SyncTextMirrors();
}
private void SyncTextMirrors()
{
_attributeName = _model.AttributeName ?? string.Empty;
_matchValueText = _model.MatchValue ?? string.Empty;
_operatorText = _model.NotEquals ? "ne" : "eq";
_minText = FormatNullable(_model.Min);
_maxText = FormatNullable(_model.Max);
_thresholdText = FormatNullable(_model.ThresholdPerSecond);
_windowText = FormatNullable(_model.WindowSeconds);
_directionText = _model.Direction;
}
private string _operatorText = "eq";
private string _matchValueText = string.Empty;
private async Task OnOperatorChanged()
{
_model.NotEquals = (_operatorText == "ne");
await Emit();
}
private async Task OnMatchValueChanged()
{
_model.MatchValue = _matchValueText;
await Emit();
}
// ── Hint text ──────────────────────────────────────────────────────────
private string BuildHint()
{
var attr = string.IsNullOrWhiteSpace(_model.AttributeName)
? "the selected attribute"
: $"\"{_model.AttributeName}\"";
return TriggerType switch
{
AlarmTriggerType.ValueMatch =>
$"Triggers when {attr} {(_model.NotEquals ? "is not equal to" : "equals")} \"{_model.MatchValue ?? ""}\".",
AlarmTriggerType.RangeViolation =>
_model.Min.HasValue && _model.Max.HasValue
? $"Triggers when {attr} < {Fmt(_model.Min)} or > {Fmt(_model.Max)}."
: $"Triggers when {attr} goes outside the configured range.",
AlarmTriggerType.RateOfChange =>
$"Triggers when {attr} changes faster than {Fmt(_model.ThresholdPerSecond) ?? "?"} units/sec ({_model.Direction}) over a {Fmt(_model.WindowSeconds) ?? "?"} sec window.",
_ => string.Empty
};
}
private static string Fmt(double? v) =>
v.HasValue ? v.Value.ToString("0.###", CultureInfo.InvariantCulture) : "";
private static string FormatNullable(double? v) =>
v.HasValue ? v.Value.ToString("R", CultureInfo.InvariantCulture) : "";
private static double? ParseDouble(string? s) =>
double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
// ── Model + parse/serialize ────────────────────────────────────────────
private sealed class TriggerModel
{
public string? AttributeName { get; set; }
// ValueMatch
public string? MatchValue { get; set; }
public bool NotEquals { get; set; }
// RangeViolation
public double? Min { get; set; }
public double? Max { get; set; }
// RateOfChange
public double? ThresholdPerSecond { get; set; }
public double? WindowSeconds { get; set; }
public string Direction { get; set; } = "either";
}
/// <summary>
/// Parses an existing trigger configuration JSON in the context of the
/// given trigger type. Returns sensible defaults on parse failure or for
/// missing keys.
/// </summary>
private static TriggerModel Parse(string? json, AlarmTriggerType type)
{
var model = new TriggerModel();
if (string.IsNullOrWhiteSpace(json)) return model;
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
model.AttributeName =
root.TryGetProperty("attributeName", out var a) ? a.GetString()
: root.TryGetProperty("attribute", out var a2) ? a2.GetString()
: null;
switch (type)
{
case AlarmTriggerType.ValueMatch:
{
var raw = root.TryGetProperty("matchValue", out var mv) ? mv.GetString()
: root.TryGetProperty("value", out var mv2) ? mv2.GetString()
: null;
if (raw != null && raw.StartsWith("!=", StringComparison.Ordinal))
{
model.NotEquals = true;
model.MatchValue = raw[2..];
}
else
{
model.MatchValue = raw;
}
break;
}
case AlarmTriggerType.RangeViolation:
model.Min = TryReadDouble(root, "min") ?? TryReadDouble(root, "low");
model.Max = TryReadDouble(root, "max") ?? TryReadDouble(root, "high");
break;
case AlarmTriggerType.RateOfChange:
model.ThresholdPerSecond = TryReadDouble(root, "thresholdPerSecond");
model.WindowSeconds = TryReadDouble(root, "windowSeconds");
var dir = root.TryGetProperty("direction", out var d) ? d.GetString() : null;
model.Direction = NormalizeDirection(dir);
break;
}
}
catch (JsonException)
{
// Malformed JSON — fall through with default model.
}
return model;
}
private static string NormalizeDirection(string? raw) => raw?.ToLowerInvariant() switch
{
"rising" or "up" or "positive" => "rising",
"falling" or "down" or "negative" => "falling",
_ => "either"
};
private static double? TryReadDouble(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind switch
{
JsonValueKind.Number => p.GetDouble(),
JsonValueKind.String when double.TryParse(p.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) => v,
_ => null
};
}
/// <summary>
/// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig
/// expects. Always writes <c>attributeName</c> (canonical key) and only
/// the keys relevant to the current trigger type.
/// </summary>
private static string Serialize(TriggerModel model, AlarmTriggerType type)
{
using var stream = new MemoryStream();
using (var w = new Utf8JsonWriter(stream))
{
w.WriteStartObject();
w.WriteString("attributeName", model.AttributeName ?? "");
switch (type)
{
case AlarmTriggerType.ValueMatch:
var mv = model.MatchValue ?? "";
if (model.NotEquals) mv = "!=" + mv;
w.WriteString("matchValue", mv);
break;
case AlarmTriggerType.RangeViolation:
if (model.Min.HasValue) w.WriteNumber("min", model.Min.Value);
if (model.Max.HasValue) w.WriteNumber("max", model.Max.Value);
break;
case AlarmTriggerType.RateOfChange:
if (model.ThresholdPerSecond.HasValue)
w.WriteNumber("thresholdPerSecond", model.ThresholdPerSecond.Value);
if (model.WindowSeconds.HasValue)
w.WriteNumber("windowSeconds", model.WindowSeconds.Value);
w.WriteString("direction", model.Direction);
break;
}
w.WriteEndObject();
}
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
}
}

View File

@@ -29,7 +29,8 @@
else
{
<label class="form-label">@state.Body</label>
<input class="form-control form-control-sm"
<input @ref="_promptInputRef"
class="form-control form-control-sm"
placeholder="@state.Placeholder"
value="@_promptValue"
@oninput="OnPromptInput" />
@@ -50,8 +51,10 @@
@code {
private ElementReference _modalRef;
private ElementReference _promptInputRef;
private string _promptValue = string.Empty;
private DialogState? _lastSeenState;
private DialogState? _focusedForState;
protected override void OnInitialized()
{
@@ -85,11 +88,26 @@
{
try { await JS.InvokeVoidAsync("document.body.classList.add", "modal-open"); }
catch { /* prerender: no JS — ignore */ }
try { await _modalRef.FocusAsync(); }
catch { /* element not yet attached: ignore */ }
// Focus once per opened dialog. Without this guard, every input
// keystroke triggers a re-render which would re-focus the modal
// element and yank the caret off the prompt input.
if (!ReferenceEquals(current, _focusedForState))
{
_focusedForState = current;
try
{
if (current.Kind == DialogKind.Prompt)
await _promptInputRef.FocusAsync();
else
await _modalRef.FocusAsync();
}
catch { /* element not yet attached: ignore */ }
}
}
else
{
_focusedForState = null;
try { await JS.InvokeVoidAsync("document.body.classList.remove", "modal-open"); }
catch { /* prerender: no JS — ignore */ }
}

View File

@@ -28,9 +28,9 @@
[Parameter] public bool ShowToolbar { get; set; } = true;
/// <summary>
/// Parameter names declared on the form (from the ParameterListEditor),
/// surfaced as completions inside Parameters["..."] literals and used by
/// the unknown-key diagnostic.
/// Parameter names declared on the form (derived from the SchemaBuilder's
/// JSON Schema), surfaced as completions inside Parameters["..."] literals
/// and used by the unknown-key diagnostic.
/// </summary>
[Parameter] public IReadOnlyList<string>? DeclaredParameters { get; set; }

View File

@@ -1,218 +0,0 @@
@namespace ScadaLink.CentralUI.Components.Shared
@using System.Text.Json
@if (_parseError != null)
{
<div class="alert alert-warning py-2 small mb-2">
Could not parse existing parameter JSON: <code>@_parseError</code>
<button class="btn btn-link btn-sm p-0 ms-2" type="button" @onclick="StartFresh">Start fresh</button>
</div>
}
@if (_normalized)
{
<div class="alert alert-info py-2 small mb-2">
Some parameter types were normalized to the current type set. Save to persist the canonical form.
</div>
}
@if (_rows.Count > 0)
{
<div class="table-responsive">
<table class="table table-sm align-middle mb-2">
<thead class="table-light">
<tr>
<th>Name</th>
<th style="width: 160px;">Type</th>
<th style="width: 160px;">Item type</th>
<th class="text-center" style="width: 100px;">Required</th>
<th style="width: 50px;"></th>
</tr>
</thead>
<tbody>
@foreach (var row in _rows)
{
var r = row;
<tr @key="r">
<td>
<input class="form-control form-control-sm" @bind="r.Name" @bind:event="oninput" @bind:after="Emit"
placeholder="e.g. id" aria-label="Parameter name" />
</td>
<td>
<select class="form-select form-select-sm" @bind="r.Type" @bind:after="Emit"
aria-label="Parameter type">
@foreach (var t in Types)
{
<option value="@t">@t</option>
}
</select>
</td>
<td>
@if (r.Type == "List")
{
<select class="form-select form-select-sm" @bind="r.ItemType" @bind:after="Emit"
aria-label="List item type">
@foreach (var t in ItemTypes)
{
<option value="@t">@t</option>
}
</select>
}
else
{
<span class="text-muted small">—</span>
}
</td>
<td class="text-center">
<input type="checkbox" class="form-check-input" @bind="r.Required" @bind:after="Emit"
aria-label="Required" />
</td>
<td>
<button type="button" class="btn btn-link btn-sm p-0 text-danger"
@onclick="() => Remove(r)"
aria-label="@($"Remove parameter {r.Name}")">✕</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else if (_parseError == null)
{
<p class="text-muted small fst-italic mb-2">No parameters defined.</p>
}
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="Add">+ Add parameter</button>
@code {
[Parameter] public string? Json { get; set; }
[Parameter] public EventCallback<string?> JsonChanged { get; set; }
private static readonly string[] Types = { "Boolean", "Integer", "Float", "String", "Object", "List" };
private static readonly string[] ItemTypes = { "Boolean", "Integer", "Float", "String", "Object" };
private List<ParamRow> _rows = new();
private string? _parseError;
private bool _normalized;
private string? _lastSeenJson;
protected override void OnParametersSet()
{
if (Json != _lastSeenJson)
{
_lastSeenJson = Json;
ParseFromJson();
}
}
private void ParseFromJson()
{
_parseError = null;
_normalized = false;
_rows = new();
if (string.IsNullOrWhiteSpace(Json)) return;
try
{
using var doc = JsonDocument.Parse(Json);
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
_parseError = "Expected a JSON array of parameter objects.";
return;
}
foreach (var el in doc.RootElement.EnumerateArray())
{
var name = el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
var rawType = el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String";
var rawItem = el.TryGetProperty("itemType", out var it) ? it.GetString() ?? "String" : "String";
var required = !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False;
var normType = NormalizeType(rawType);
var normItem = NormalizeType(rawItem);
if (normType != rawType || (rawType == "List" && normItem != rawItem))
{
_normalized = true;
}
_rows.Add(new ParamRow
{
Name = name,
Type = normType,
ItemType = normItem,
Required = required
});
}
}
catch (JsonException ex)
{
_parseError = ex.Message;
}
}
private static string NormalizeType(string raw)
{
if (string.IsNullOrEmpty(raw)) return "String";
return raw.ToLowerInvariant() switch
{
"boolean" or "bool" => "Boolean",
"integer" or "int" or "int32" or "int64" or "int16" or "byte" or "sbyte" or "uint32" or "uint64" or "uint16" => "Integer",
"float" or "double" or "single" or "decimal" => "Float",
"string" or "datetime" => "String",
"object" => "Object",
"list" => "List",
_ => raw
};
}
private async Task StartFresh()
{
_parseError = null;
_rows = new();
await Emit();
}
private async Task Add()
{
_rows.Add(new ParamRow { Type = "String", ItemType = "String", Required = true });
await Emit();
}
private async Task Remove(ParamRow row)
{
_rows.Remove(row);
await Emit();
}
private async Task Emit()
{
var json = SerializeToJson();
_lastSeenJson = json;
_normalized = false;
await JsonChanged.InvokeAsync(json);
}
private string? SerializeToJson()
{
if (_rows.Count == 0) return null;
var list = new List<Dictionary<string, object>>();
foreach (var r in _rows)
{
var obj = new Dictionary<string, object>
{
["name"] = r.Name,
["type"] = r.Type,
};
if (r.Type == "List") obj["itemType"] = r.ItemType;
if (!r.Required) obj["required"] = false;
list.Add(obj);
}
return JsonSerializer.Serialize(list);
}
private class ParamRow
{
public string Name { get; set; } = "";
public string Type { get; set; } = "String";
public string ItemType { get; set; } = "String";
public bool Required { get; set; } = true;
}
}

View File

@@ -1,132 +0,0 @@
@namespace ScadaLink.CentralUI.Components.Shared
@using System.Text.Json
@if (_parseError != null)
{
<div class="alert alert-warning py-2 small mb-2">
Could not parse existing return JSON: <code>@_parseError</code>
<button class="btn btn-link btn-sm p-0 ms-2" type="button" @onclick="StartFresh">Start fresh</button>
</div>
}
@if (_normalized)
{
<div class="alert alert-info py-2 small mb-2">
Return type was normalized to the current type set. Save to persist the canonical form.
</div>
}
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Type</label>
<select class="form-select form-select-sm" @bind="_type" @bind:after="Emit" aria-label="Return type">
<option value="">(no return value)</option>
@foreach (var t in Types)
{
<option value="@t">@t</option>
}
</select>
</div>
@if (_type == "List")
{
<div class="col-md-3">
<label class="form-label small">Item type</label>
<select class="form-select form-select-sm" @bind="_itemType" @bind:after="Emit" aria-label="List item type">
@foreach (var t in ItemTypes)
{
<option value="@t">@t</option>
}
</select>
</div>
}
</div>
@code {
[Parameter] public string? Json { get; set; }
[Parameter] public EventCallback<string?> JsonChanged { get; set; }
private static readonly string[] Types = { "Boolean", "Integer", "Float", "String", "Object", "List" };
private static readonly string[] ItemTypes = { "Boolean", "Integer", "Float", "String", "Object" };
private string _type = "";
private string _itemType = "String";
private string? _parseError;
private bool _normalized;
private string? _lastSeenJson;
protected override void OnParametersSet()
{
if (Json != _lastSeenJson)
{
_lastSeenJson = Json;
ParseFromJson();
}
}
private void ParseFromJson()
{
_parseError = null;
_normalized = false;
_type = "";
_itemType = "String";
if (string.IsNullOrWhiteSpace(Json)) return;
try
{
using var doc = JsonDocument.Parse(Json);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
{
_parseError = "Expected a JSON object with a type field.";
return;
}
var rawType = doc.RootElement.TryGetProperty("type", out var t) ? t.GetString() ?? "" : "";
var rawItem = doc.RootElement.TryGetProperty("itemType", out var it) ? it.GetString() ?? "String" : "String";
_type = NormalizeType(rawType);
_itemType = NormalizeType(rawItem);
if (_type != rawType || (rawType == "List" && _itemType != rawItem))
{
_normalized = true;
}
}
catch (JsonException ex)
{
_parseError = ex.Message;
}
}
private static string NormalizeType(string raw)
{
if (string.IsNullOrEmpty(raw)) return "";
return raw.ToLowerInvariant() switch
{
"boolean" or "bool" => "Boolean",
"integer" or "int" or "int32" or "int64" or "int16" or "byte" or "sbyte" or "uint32" or "uint64" or "uint16" => "Integer",
"float" or "double" or "single" or "decimal" => "Float",
"string" or "datetime" => "String",
"object" => "Object",
"list" => "List",
_ => raw
};
}
private async Task StartFresh()
{
_parseError = null;
_type = "";
_itemType = "String";
await Emit();
}
private async Task Emit()
{
string? json = null;
if (!string.IsNullOrEmpty(_type))
{
var obj = new Dictionary<string, object> { ["type"] = _type };
if (_type == "List") obj["itemType"] = _itemType;
json = JsonSerializer.Serialize(obj);
}
_lastSeenJson = json;
_normalized = false;
await JsonChanged.InvokeAsync(json);
}
}

View File

@@ -0,0 +1,207 @@
@namespace ScadaLink.CentralUI.Components.Shared
@* Bootstrap-only JSON Schema editor. Two modes:
- "object" parameters: edits a top-level object schema (named properties).
- "value" return type: edits a single value schema; object/array fall back
to the same property editor as Mode=object.
Recurses through methods (not nested components) so we stay in one file. *@
@if (_root.Type == "object" && Mode == "object")
{
@PropertyList(_root, isRoot: true)
}
else
{
@ValueRoot(_root)
}
@code {
/// <summary><c>"object"</c> for parameters, <c>"value"</c> for return type.</summary>
[Parameter] public string Mode { get; set; } = "object";
/// <summary>JSON Schema text. Empty/null seeds the mode's default.</summary>
[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
private SchemaNode _root = new();
private string? _lastSeenJson;
private bool _initialized;
protected override void OnParametersSet()
{
// OnInitialized fires before this on first mount; OnParametersSet runs
// on every parameter change. Guard against the initial null==null case
// where the early-exit would skip applying the mode-appropriate default.
if (_initialized && Value == _lastSeenJson) return;
_initialized = true;
_lastSeenJson = Value;
_root = SchemaBuilderModel.Parse(
Value,
Mode == "object" ? SchemaBuilderModel.NewObject() : SchemaBuilderModel.NewValue());
}
private async Task Emit()
{
var json = SchemaBuilderModel.Serialize(_root);
_lastSeenJson = json;
await ValueChanged.InvokeAsync(json);
}
private async Task OnTypeChange(SchemaNode node)
{
if (node.Type == "array" && node.Items == null)
node.Items = new SchemaNode { Type = "string" };
await Emit();
}
private async Task AddProperty(SchemaNode parent)
{
parent.Properties.Add(new SchemaProperty { Schema = new SchemaNode { Type = "string" } });
await Emit();
}
private async Task RemoveProperty(SchemaNode parent, SchemaProperty prop)
{
parent.Properties.Remove(prop);
await Emit();
}
// ── Render helpers ─────────────────────────────────────────────────────────
/// <summary>
/// Renders the property list for an object schema node. <paramref name="isRoot"/>
/// just tweaks the wording on the Add button ("parameter" at root vs "field"
/// inside a nested object).
/// </summary>
private RenderFragment PropertyList(SchemaNode node, bool isRoot = false) => __builder =>
{
<div class="border rounded bg-white p-2">
@if (node.Properties.Count == 0)
{
<div class="text-muted small fst-italic px-1 py-2">
@(isRoot ? "No parameters defined." : "No fields defined.")
</div>
}
@foreach (var prop in node.Properties)
{
<div @key="prop.Id" class="border rounded p-2 mb-2 bg-light-subtle">
@PropertyRow(node, prop)
@NestedEditor(prop.Schema)
</div>
}
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="() => AddProperty(node)">
+ Add @(isRoot ? "parameter" : "field")
</button>
</div>
};
/// <summary>
/// One property's compact horizontal row: name, type, (items type if array),
/// required toggle, remove button. Nested object / array-of-object editors
/// render below the row via <see cref="NestedEditor"/>.
/// </summary>
private RenderFragment PropertyRow(SchemaNode parent, SchemaProperty prop) => __builder =>
{
<div class="d-flex flex-wrap align-items-center gap-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 14rem;" placeholder="name"
@bind="prop.Name" @bind:event="oninput" @bind:after="Emit" />
<select class="form-select form-select-sm" style="max-width: 9rem;"
@bind="prop.Schema.Type" @bind:after="() => OnTypeChange(prop.Schema)">
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
{
<option value="@t">@t</option>
}
</select>
@if (prop.Schema.Type == "array")
{
<span class="small text-muted">items:</span>
<select class="form-select form-select-sm" style="max-width: 9rem;"
@bind="prop.Schema.Items!.Type" @bind:after="() => OnTypeChange(prop.Schema.Items!)">
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
{
<option value="@t">@t</option>
}
</select>
}
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" id="req-@prop.Id"
@bind="prop.Required" @bind:after="Emit" />
<label class="form-check-label small" for="req-@prop.Id">required</label>
</div>
<button type="button"
class="btn btn-link btn-sm text-danger p-0 ms-auto"
title="Remove" aria-label="Remove field"
@onclick="() => RemoveProperty(parent, prop)">
<i class="bi bi-x-lg"></i>
</button>
</div>
};
/// <summary>
/// Renders the indented sub-editor for object / array-of-object properties.
/// No-op for scalar properties.
/// </summary>
private RenderFragment NestedEditor(SchemaNode schema) => __builder =>
{
if (schema.Type == "object")
{
<div class="ms-3 mt-2">
@PropertyList(schema)
</div>
}
else if (schema.Type == "array" && schema.Items?.Type == "object")
{
<div class="ms-3 mt-2">
<div class="small text-muted mb-1">item properties:</div>
@PropertyList(schema.Items)
</div>
}
};
/// <summary>
/// Mode=value root: a single type picker. When the user picks <c>object</c>
/// or <c>array</c> we expose the same nested editors used by Mode=object.
/// </summary>
private RenderFragment ValueRoot(SchemaNode node) => __builder =>
{
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<label class="form-label mb-0">Return type:</label>
<select class="form-select form-select-sm" style="max-width: 10rem;"
@bind="node.Type" @bind:after="() => OnTypeChange(node)">
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
{
<option value="@t">@t</option>
}
</select>
@if (node.Type == "array")
{
<label class="form-label mb-0 ms-2">Item type:</label>
<select class="form-select form-select-sm" style="max-width: 10rem;"
@bind="node.Items!.Type" @bind:after="() => OnTypeChange(node.Items!)">
@foreach (var t in SchemaBuilderModel.PrimitiveTypes)
{
<option value="@t">@t</option>
}
</select>
}
</div>
@if (node.Type == "object")
{
<div class="text-muted small mb-1">Properties of return value:</div>
@PropertyList(node)
}
else if (node.Type == "array" && node.Items?.Type == "object")
{
<div class="text-muted small mb-1">Item properties:</div>
@PropertyList(node.Items)
}
};
}

View File

@@ -0,0 +1,204 @@
using System.Text.Json;
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// In-memory JSON Schema tree used by <see cref="SchemaBuilder"/>. The editor
/// mutates this graph directly; <see cref="SchemaBuilderModel"/> handles
/// parse / serialize round-tripping to the canonical JSON Schema text stored
/// in TemplateScript / SharedScript / ApiMethod columns.
/// </summary>
internal sealed class SchemaNode
{
/// <summary>One of: <c>string · integer · number · boolean · object · array</c>.</summary>
public string Type { get; set; } = "string";
/// <summary>For <c>type=array</c>: the schema of the array's items.</summary>
public SchemaNode? Items { get; set; }
/// <summary>For <c>type=object</c>: ordered list of named properties.</summary>
public List<SchemaProperty> Properties { get; } = new();
}
internal sealed class SchemaProperty
{
/// <summary>Stable identity for Blazor <c>@key</c> across renames.</summary>
public Guid Id { get; } = Guid.NewGuid();
public string Name { get; set; } = string.Empty;
public bool Required { get; set; } = true;
public SchemaNode Schema { get; set; } = new();
}
internal static class SchemaBuilderModel
{
public static readonly string[] PrimitiveTypes =
{ "string", "integer", "number", "boolean", "object", "array" };
/// <summary>
/// Parse a JSON Schema string into a <see cref="SchemaNode"/> tree.
/// Returns the supplied <paramref name="fallback"/> when the input is
/// empty or malformed. Also accepts the legacy flat-array parameter
/// shape (<c>[{name,type,required,itemType?}]</c>) for safety during the
/// transition window — translates it into an equivalent object schema.
/// </summary>
public static SchemaNode Parse(string? json, SchemaNode fallback)
{
if (string.IsNullOrWhiteSpace(json)) return fallback;
try
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.ValueKind switch
{
JsonValueKind.Object => ParseSchema(doc.RootElement),
JsonValueKind.Array => ParseLegacyArray(doc.RootElement),
_ => fallback,
};
}
catch
{
return fallback;
}
}
/// <summary>Default empty object schema (parameters mode default).</summary>
public static SchemaNode NewObject() => new() { Type = "object" };
/// <summary>Default scalar schema (return mode default).</summary>
public static SchemaNode NewValue() => new() { Type = "string" };
public static string Serialize(SchemaNode node)
{
using var stream = new System.IO.MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
WriteNode(writer, node);
}
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
}
// ── Parse helpers ─────────────────────────────────────────────────────────
private static SchemaNode ParseSchema(JsonElement el)
{
var node = new SchemaNode { Type = "string" };
if (el.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String)
{
node.Type = NormalizeType(t.GetString());
}
if (node.Type == "array")
{
node.Items = el.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object
? ParseSchema(items)
: new SchemaNode { Type = "string" };
}
else if (node.Type == "object")
{
var requiredSet = new HashSet<string>(StringComparer.Ordinal);
if (el.TryGetProperty("required", out var req) && req.ValueKind == JsonValueKind.Array)
{
foreach (var r in req.EnumerateArray())
{
if (r.ValueKind == JsonValueKind.String)
{
var s = r.GetString();
if (!string.IsNullOrEmpty(s)) requiredSet.Add(s);
}
}
}
if (el.TryGetProperty("properties", out var props) && props.ValueKind == JsonValueKind.Object)
{
foreach (var prop in props.EnumerateObject())
{
node.Properties.Add(new SchemaProperty
{
Name = prop.Name,
Required = requiredSet.Contains(prop.Name),
Schema = prop.Value.ValueKind == JsonValueKind.Object
? ParseSchema(prop.Value)
: new SchemaNode { Type = "string" },
});
}
}
}
return node;
}
private static SchemaNode ParseLegacyArray(JsonElement arr)
{
var root = new SchemaNode { Type = "object" };
foreach (var item in arr.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object) continue;
var name = item.TryGetProperty("name", out var n) ? n.GetString() : null;
if (string.IsNullOrEmpty(name)) continue;
var rawType = item.TryGetProperty("type", out var t) ? t.GetString() : "string";
var required = !item.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False;
var schema = new SchemaNode { Type = NormalizeType(rawType) };
if (schema.Type == "array")
{
var inner = item.TryGetProperty("itemType", out var it) ? it.GetString() : "string";
schema.Items = new SchemaNode { Type = NormalizeType(inner) };
}
root.Properties.Add(new SchemaProperty
{
Name = name,
Required = required,
Schema = schema,
});
}
return root;
}
private static string NormalizeType(string? raw) => raw?.ToLowerInvariant() switch
{
"boolean" or "bool" => "boolean",
"integer" or "int" or "int32" or "int64" => "integer",
"number" or "float" or "double" or "decimal" => "number",
"string" or "datetime" => "string",
"object" => "object",
"array" or "list" => "array",
_ => "string",
};
// ── Serialize helpers ─────────────────────────────────────────────────────
private static void WriteNode(Utf8JsonWriter w, SchemaNode node)
{
w.WriteStartObject();
w.WriteString("type", node.Type);
if (node.Type == "array")
{
w.WritePropertyName("items");
WriteNode(w, node.Items ?? new SchemaNode { Type = "string" });
}
else if (node.Type == "object")
{
w.WritePropertyName("properties");
w.WriteStartObject();
foreach (var p in node.Properties.Where(p => !string.IsNullOrWhiteSpace(p.Name)))
{
w.WritePropertyName(p.Name);
WriteNode(w, p.Schema);
}
w.WriteEndObject();
var required = node.Properties
.Where(p => p.Required && !string.IsNullOrWhiteSpace(p.Name))
.Select(p => p.Name)
.ToArray();
if (required.Length > 0)
{
w.WritePropertyName("required");
w.WriteStartArray();
foreach (var r in required) w.WriteStringValue(r);
w.WriteEndArray();
}
}
w.WriteEndObject();
}
}

View File

@@ -1,51 +1,20 @@
using System.Text.Json;
using ScadaLink.CentralUI.ScriptAnalysis;
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// Parses the parameter-definitions JSON written by ParameterListEditor and
/// Parses the parameter-definitions JSON Schema written by SchemaBuilder and
/// returns the declared parameter names (and shapes). Used by script-edit
/// pages to feed the Monaco editor's Parameters["..."] context.
/// </summary>
public static class ScriptParameterNames
{
public static IReadOnlyList<string> Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<string>();
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return Array.Empty<string>();
return doc.RootElement.EnumerateArray()
.Select(e => e.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "")
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
}
catch
{
return Array.Empty<string>();
}
}
public static IReadOnlyList<string> Parse(string? json) =>
JsonSchemaShapeParser.ParseParameters(json)
.Select(p => p.Name)
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
public static IReadOnlyList<ParameterShape> ParseShapes(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<ParameterShape>();
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return Array.Empty<ParameterShape>();
return doc.RootElement.EnumerateArray()
.Select(el => new ParameterShape(
Name: el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "",
Type: el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String",
Required: !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False))
.Where(p => !string.IsNullOrEmpty(p.Name))
.ToList();
}
catch
{
return Array.Empty<ParameterShape>();
}
}
public static IReadOnlyList<ParameterShape> ParseShapes(string? json) =>
JsonSchemaShapeParser.ParseParameters(json);
}

View File

@@ -11,6 +11,10 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ScadaLink.CentralUI.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.13.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.13.0" />

View File

@@ -0,0 +1,177 @@
using System.Text.Json;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Translates JSON Schema documents stored in
/// <c>TemplateScript.ParameterDefinitions</c> / <c>ReturnDefinition</c> into
/// the flat <see cref="ParameterShape"/> / type-name vocabulary used by the
/// rest of the script-analysis pipeline (completions, inlay hints, signature
/// help, hover).
///
/// Lenient: malformed JSON yields an empty result, never an exception.
///
/// Also accepts the legacy pre-migration flat shape
/// (<c>[{name,type,required,itemType?}]</c> for parameters,
/// <c>{type,itemType?}</c> for return) so partially migrated rows don't crash
/// the editor.
/// </summary>
public static class JsonSchemaShapeParser
{
public static IReadOnlyList<ParameterShape> ParseParameters(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<ParameterShape>();
try
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.ValueKind switch
{
JsonValueKind.Array => ParseLegacyParameterArray(doc.RootElement),
JsonValueKind.Object => ParseJsonSchemaObject(doc.RootElement),
_ => Array.Empty<ParameterShape>(),
};
}
catch
{
return Array.Empty<ParameterShape>();
}
}
public static string? ParseReturnType(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
return ParseReturnSchema(doc.RootElement);
}
catch
{
return null;
}
}
// ---- JSON Schema branch -------------------------------------------------
private static IReadOnlyList<ParameterShape> ParseJsonSchemaObject(JsonElement root)
{
if (!root.TryGetProperty("properties", out var props) || props.ValueKind != JsonValueKind.Object)
return Array.Empty<ParameterShape>();
var requiredSet = new HashSet<string>(StringComparer.Ordinal);
if (root.TryGetProperty("required", out var req) && req.ValueKind == JsonValueKind.Array)
{
foreach (var item in req.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var s = item.GetString();
if (!string.IsNullOrEmpty(s)) requiredSet.Add(s);
}
}
}
var result = new List<ParameterShape>();
foreach (var prop in props.EnumerateObject())
{
var name = prop.Name;
if (string.IsNullOrEmpty(name)) continue;
var type = MapJsonSchemaType(prop.Value);
result.Add(new ParameterShape(name, type, requiredSet.Contains(name)));
}
return result;
}
private static string? ParseReturnSchema(JsonElement schema)
{
if (!schema.TryGetProperty("type", out var typeEl)) return null;
if (typeEl.ValueKind != JsonValueKind.String) return null;
var type = typeEl.GetString();
if (string.IsNullOrEmpty(type)) return null;
// Legacy form: `{type:"List", itemType:"Integer"}` (post-migration this
// should be `{type:"array", items:{type:"integer"}}`, handled below).
if (type.Equals("List", StringComparison.OrdinalIgnoreCase))
{
if (schema.TryGetProperty("itemType", out var it) && it.ValueKind == JsonValueKind.String)
return $"List<{NormalizeLegacyType(it.GetString())}>";
return "List<Object>";
}
if (type.Equals("array", StringComparison.OrdinalIgnoreCase))
{
if (schema.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object)
{
var inner = MapJsonSchemaType(items);
return $"List<{inner}>";
}
return "List<Object>";
}
return MapJsonSchemaTypeName(type);
}
private static string MapJsonSchemaType(JsonElement schema)
{
if (schema.ValueKind != JsonValueKind.Object) return "Object";
if (!schema.TryGetProperty("type", out var typeEl) || typeEl.ValueKind != JsonValueKind.String)
return "Object";
var type = typeEl.GetString() ?? "";
if (type.Equals("array", StringComparison.OrdinalIgnoreCase))
{
if (schema.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object)
return $"List<{MapJsonSchemaType(items)}>";
return "List<Object>";
}
return MapJsonSchemaTypeName(type);
}
private static string MapJsonSchemaTypeName(string type) => type.ToLowerInvariant() switch
{
"boolean" => "Boolean",
"integer" => "Integer",
"number" => "Float",
"string" => "String",
"object" => "Object",
"array" => "List",
// Legacy aliases (in case a row's been edited by hand pre-migration):
"bool" => "Boolean",
"int" or "int32" or "int64" => "Integer",
"float" or "double" or "decimal" => "Float",
_ => type,
};
// ---- Legacy flat-array branch ------------------------------------------
private static IReadOnlyList<ParameterShape> ParseLegacyParameterArray(JsonElement root)
{
var result = new List<ParameterShape>();
foreach (var el in root.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var name = el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
if (string.IsNullOrEmpty(name)) continue;
var rawType = el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String";
var required = !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False;
result.Add(new ParameterShape(name, NormalizeLegacyType(rawType), required));
}
return result;
}
private static string NormalizeLegacyType(string? raw)
{
if (string.IsNullOrEmpty(raw)) return "String";
return raw.ToLowerInvariant() switch
{
"boolean" or "bool" => "Boolean",
"integer" or "int" or "int32" or "int64" => "Integer",
"float" or "double" or "decimal" or "number" => "Float",
"string" or "datetime" => "String",
"object" => "Object",
"list" or "array" => "List",
_ => raw,
};
}
}

View File

@@ -4,7 +4,6 @@ using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Caching.Memory;
@@ -334,11 +333,13 @@ public class ScriptAnalysisService
return new FormatResponse(request.Code);
try
{
using var workspace = new AdhocWorkspace();
var tree = CSharpSyntaxTree.ParseText(
request.Code,
new CSharpParseOptions(LanguageVersion.Latest, kind: SourceCodeKind.Script));
var formatted = Formatter.Format(tree.GetRoot(), workspace);
// NormalizeWhitespace produces canonical layout (indentation + line
// breaks). Formatter.Format alone with an empty workspace only
// normalizes inter-token spacing — it won't split crammed lines.
var formatted = tree.GetRoot().NormalizeWhitespace(indentation: " ", eol: "\n");
return new FormatResponse(formatted.ToFullString());
}
catch

View File

@@ -1,59 +1,17 @@
using System.Text.Json;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Parses the parameter-definitions and return-definition JSON written by
/// ParameterListEditor / ReturnTypeEditor into a <see cref="ScriptShape"/>.
/// Lenient: malformed JSON yields an empty parameter list, not an exception.
/// Parses the parameter-definitions and return-definition JSON Schema written
/// by SchemaBuilder into a <see cref="ScriptShape"/>. Delegates to
/// <see cref="JsonSchemaShapeParser"/>, which also handles legacy flat-shape
/// rows during the transition window.
/// </summary>
public static class ScriptShapeParser
{
public static ScriptShape Parse(string name, string? parametersJson, string? returnJson)
{
var parameters = ParseParameters(parametersJson);
var returnType = ParseReturnType(returnJson);
var parameters = JsonSchemaShapeParser.ParseParameters(parametersJson);
var returnType = JsonSchemaShapeParser.ParseReturnType(returnJson);
return new ScriptShape(name, parameters, returnType);
}
private static IReadOnlyList<ParameterShape> ParseParameters(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<ParameterShape>();
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return Array.Empty<ParameterShape>();
return doc.RootElement.EnumerateArray()
.Select(el => new ParameterShape(
Name: el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "",
Type: el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String",
Required: !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False))
.Where(p => !string.IsNullOrEmpty(p.Name))
.ToList();
}
catch
{
return Array.Empty<ParameterShape>();
}
}
private static string? ParseReturnType(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
if (!doc.RootElement.TryGetProperty("type", out var t)) return null;
var type = t.GetString();
if (string.IsNullOrEmpty(type)) return null;
if (type == "List" && doc.RootElement.TryGetProperty("itemType", out var it))
return $"List<{it.GetString() ?? "Object"}>";
return type;
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,196 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class MigrateParametersToJsonSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Convert legacy flat-shape parameter / return JSON in TemplateScripts,
// SharedScripts, and ApiMethods to JSON Schema.
//
// Parameters [{name,type,required,itemType?}]
// → {"type":"object","properties":{<name>:{"type":<jsType>}},"required":[...]}
//
// Return {type,itemType?}
// → {"type":<jsType>} or {"type":"array","items":{"type":<inner>}}
//
// Idempotent: only rows whose value starts with '[' (parameters) or that
// contain the legacy 'List' sentinel (return) are touched. Already-converted
// rows are skipped.
migrationBuilder.Sql(@"
IF OBJECT_ID('dbo.fn_LegacyTypeToJsonSchemaType', 'FN') IS NOT NULL
DROP FUNCTION dbo.fn_LegacyTypeToJsonSchemaType;
");
migrationBuilder.Sql(@"
CREATE FUNCTION dbo.fn_LegacyTypeToJsonSchemaType(@legacy NVARCHAR(50))
RETURNS NVARCHAR(50)
AS
BEGIN
RETURN
CASE LOWER(ISNULL(@legacy, 'string'))
WHEN 'boolean' THEN 'boolean'
WHEN 'bool' THEN 'boolean'
WHEN 'integer' THEN 'integer'
WHEN 'int' THEN 'integer'
WHEN 'int32' THEN 'integer'
WHEN 'int64' THEN 'integer'
WHEN 'float' THEN 'number'
WHEN 'double' THEN 'number'
WHEN 'decimal' THEN 'number'
WHEN 'number' THEN 'number'
WHEN 'string' THEN 'string'
WHEN 'datetime' THEN 'string'
WHEN 'object' THEN 'object'
WHEN 'list' THEN 'array'
WHEN 'array' THEN 'array'
ELSE 'string'
END;
END;
");
migrationBuilder.Sql(@"
IF OBJECT_ID('dbo.fn_LegacyParametersToJsonSchema', 'FN') IS NOT NULL
DROP FUNCTION dbo.fn_LegacyParametersToJsonSchema;
");
migrationBuilder.Sql(@"
CREATE FUNCTION dbo.fn_LegacyParametersToJsonSchema(@legacy NVARCHAR(MAX))
RETURNS NVARCHAR(MAX)
AS
BEGIN
IF @legacy IS NULL OR LTRIM(@legacy) = '' RETURN NULL;
IF LEFT(LTRIM(@legacy), 1) <> '[' RETURN @legacy; -- already schema-shaped
DECLARE @props NVARCHAR(MAX) = (
SELECT STRING_AGG(
CONCAT(
'""',
STRING_ESCAPE(JSON_VALUE(p.value, '$.name'), 'json'),
'"":',
CASE
WHEN LOWER(ISNULL(JSON_VALUE(p.value, '$.type'), 'string')) IN ('list', 'array')
THEN CONCAT(
'{""type"":""array"",""items"":{""type"":""',
dbo.fn_LegacyTypeToJsonSchemaType(JSON_VALUE(p.value, '$.itemType')),
'""}}')
ELSE CONCAT(
'{""type"":""',
dbo.fn_LegacyTypeToJsonSchemaType(JSON_VALUE(p.value, '$.type')),
'""}')
END),
',')
WITHIN GROUP (ORDER BY p.[key])
FROM OPENJSON(@legacy) p
WHERE JSON_VALUE(p.value, '$.name') IS NOT NULL
AND JSON_VALUE(p.value, '$.name') <> ''
);
DECLARE @required NVARCHAR(MAX) = (
SELECT STRING_AGG(
CONCAT('""', STRING_ESCAPE(JSON_VALUE(p.value, '$.name'), 'json'), '""'),
',')
WITHIN GROUP (ORDER BY p.[key])
FROM OPENJSON(@legacy) p
WHERE JSON_VALUE(p.value, '$.name') IS NOT NULL
AND JSON_VALUE(p.value, '$.name') <> ''
AND LOWER(ISNULL(JSON_VALUE(p.value, '$.required'), 'true')) <> 'false'
);
RETURN
'{""type"":""object"",""properties"":{' + ISNULL(@props, '') + '}'
+ CASE WHEN @required IS NULL OR @required = '' THEN ''
ELSE ',""required"":[' + @required + ']'
END
+ '}';
END;
");
migrationBuilder.Sql(@"
IF OBJECT_ID('dbo.fn_LegacyReturnToJsonSchema', 'FN') IS NOT NULL
DROP FUNCTION dbo.fn_LegacyReturnToJsonSchema;
");
migrationBuilder.Sql(@"
CREATE FUNCTION dbo.fn_LegacyReturnToJsonSchema(@legacy NVARCHAR(MAX))
RETURNS NVARCHAR(MAX)
AS
BEGIN
IF @legacy IS NULL OR LTRIM(@legacy) = '' RETURN NULL;
IF LEFT(LTRIM(@legacy), 1) <> '{' RETURN @legacy;
DECLARE @legacyType NVARCHAR(50) = JSON_VALUE(@legacy, '$.type');
IF @legacyType IS NULL RETURN @legacy;
-- Already JSON Schema (lowercase types, no itemType legacy sentinel): leave it.
IF @legacyType IN ('boolean','integer','number','string','object','array')
AND JSON_VALUE(@legacy, '$.itemType') IS NULL
RETURN @legacy;
IF LOWER(@legacyType) = 'list'
BEGIN
DECLARE @inner NVARCHAR(50) =
dbo.fn_LegacyTypeToJsonSchemaType(JSON_VALUE(@legacy, '$.itemType'));
RETURN CONCAT('{""type"":""array"",""items"":{""type"":""', @inner, '""}}');
END;
RETURN CONCAT('{""type"":""', dbo.fn_LegacyTypeToJsonSchemaType(@legacyType), '""}');
END;
");
migrationBuilder.Sql(@"
UPDATE TemplateScripts
SET ParameterDefinitions = dbo.fn_LegacyParametersToJsonSchema(ParameterDefinitions)
WHERE ParameterDefinitions IS NOT NULL
AND LEFT(LTRIM(ParameterDefinitions), 1) = '[';
UPDATE TemplateScripts
SET ReturnDefinition = dbo.fn_LegacyReturnToJsonSchema(ReturnDefinition)
WHERE ReturnDefinition IS NOT NULL
AND LEFT(LTRIM(ReturnDefinition), 1) = '{';
UPDATE SharedScripts
SET ParameterDefinitions = dbo.fn_LegacyParametersToJsonSchema(ParameterDefinitions)
WHERE ParameterDefinitions IS NOT NULL
AND LEFT(LTRIM(ParameterDefinitions), 1) = '[';
UPDATE SharedScripts
SET ReturnDefinition = dbo.fn_LegacyReturnToJsonSchema(ReturnDefinition)
WHERE ReturnDefinition IS NOT NULL
AND LEFT(LTRIM(ReturnDefinition), 1) = '{';
UPDATE ApiMethods
SET ParameterDefinitions = dbo.fn_LegacyParametersToJsonSchema(ParameterDefinitions)
WHERE ParameterDefinitions IS NOT NULL
AND LEFT(LTRIM(ParameterDefinitions), 1) = '[';
UPDATE ApiMethods
SET ReturnDefinition = dbo.fn_LegacyReturnToJsonSchema(ReturnDefinition)
WHERE ReturnDefinition IS NOT NULL
AND LEFT(LTRIM(ReturnDefinition), 1) = '{';
");
migrationBuilder.Sql(@"
DROP FUNCTION IF EXISTS dbo.fn_LegacyParametersToJsonSchema;
DROP FUNCTION IF EXISTS dbo.fn_LegacyReturnToJsonSchema;
DROP FUNCTION IF EXISTS dbo.fn_LegacyTypeToJsonSchemaType;
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Lossy: JSON Schema can express fields (descriptions, defaults, enums,
// nested objects) that the legacy flat shape cannot represent. Reverse
// migration is not supported.
throw new System.NotSupportedException(
"Reverse migration from JSON Schema to legacy flat shape is not supported because the conversion is lossy.");
}
}
}

View File

@@ -239,8 +239,13 @@ public class AlarmActor : ReceiveActor
var timeDelta = (timestamp - oldest.Timestamp).TotalSeconds;
if (timeDelta <= 0) return false;
var rate = Math.Abs(numericValue - oldest.Value) / timeDelta;
return rate > config.ThresholdPerSecond;
var signedRate = (numericValue - oldest.Value) / timeDelta;
return config.Direction switch
{
RateOfChangeDirection.Rising => signedRate > config.ThresholdPerSecond,
RateOfChangeDirection.Falling => -signedRate > config.ThresholdPerSecond,
_ => Math.Abs(signedRate) > config.ThresholdPerSecond
};
}
catch
{
@@ -309,7 +314,10 @@ public class AlarmActor : ReceiveActor
root.TryGetProperty("thresholdPerSecond", out var tps) ? tps.GetDouble() : 10.0,
root.TryGetProperty("windowSeconds", out var ws)
? TimeSpan.FromSeconds(ws.GetDouble())
: TimeSpan.FromSeconds(1)),
: TimeSpan.FromSeconds(1),
root.TryGetProperty("direction", out var dirEl)
? ParseDirection(dirEl.GetString())
: RateOfChangeDirection.Either),
_ => new ValueMatchEvalConfig(attr, null)
};
@@ -321,12 +329,25 @@ public class AlarmActor : ReceiveActor
}
}
private static RateOfChangeDirection ParseDirection(string? raw) => raw?.ToLowerInvariant() switch
{
"rising" or "up" or "positive" => RateOfChangeDirection.Rising,
"falling" or "down" or "negative" => RateOfChangeDirection.Falling,
_ => RateOfChangeDirection.Either
};
// ── Internal messages ──
internal record AlarmExecutionCompleted(string AlarmName, bool Success);
}
internal enum RateOfChangeDirection { Either, Rising, Falling }
// ── Alarm evaluation config types ──
internal abstract record AlarmEvalConfig(string MonitoredAttributeName);
internal record ValueMatchEvalConfig(string MonitoredAttributeName, string? MatchValue) : AlarmEvalConfig(MonitoredAttributeName);
internal record RangeViolationEvalConfig(string MonitoredAttributeName, double Min, double Max) : AlarmEvalConfig(MonitoredAttributeName);
internal record RateOfChangeEvalConfig(string MonitoredAttributeName, double ThresholdPerSecond, TimeSpan WindowDuration) : AlarmEvalConfig(MonitoredAttributeName);
internal record RateOfChangeEvalConfig(
string MonitoredAttributeName,
double ThresholdPerSecond,
TimeSpan WindowDuration,
RateOfChangeDirection Direction) : AlarmEvalConfig(MonitoredAttributeName);

View File

@@ -195,7 +195,17 @@ public class SemanticValidator
try
{
using var doc = JsonDocument.Parse(parameterDefinitionsJson);
if (doc.RootElement.ValueKind == JsonValueKind.Array)
// JSON Schema: { type:"object", properties:{ name:{...}, ... }, required:[...] }
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
if (doc.RootElement.TryGetProperty("properties", out var props)
&& props.ValueKind == JsonValueKind.Object)
{
return props.EnumerateObject().Select(p => p.Name).ToList();
}
}
// Legacy flat form: [{ name, type, required? }]
else if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
return doc.RootElement.EnumerateArray()
.Select(e => e.TryGetProperty("type", out var t) ? t.GetString() ?? "unknown" : "unknown")