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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
207
src/ScadaLink.CentralUI/Components/Shared/SchemaBuilder.razor
Normal file
207
src/ScadaLink.CentralUI/Components/Shared/SchemaBuilder.razor
Normal 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)
|
||||
}
|
||||
};
|
||||
}
|
||||
204
src/ScadaLink.CentralUI/Components/Shared/SchemaBuilderModel.cs
Normal file
204
src/ScadaLink.CentralUI/Components/Shared/SchemaBuilderModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
177
src/ScadaLink.CentralUI/ScriptAnalysis/JsonSchemaShapeParser.cs
Normal file
177
src/ScadaLink.CentralUI/ScriptAnalysis/JsonSchemaShapeParser.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1300
src/ScadaLink.ConfigurationDatabase/Migrations/20260512211204_MigrateParametersToJsonSchema.Designer.cs
generated
Normal file
1300
src/ScadaLink.ConfigurationDatabase/Migrations/20260512211204_MigrateParametersToJsonSchema.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user