feat(scripts): realign Test Run with runtime API, add anonymous-object calls and instance binding
The Test Run sandbox and Monaco analysis modelled a script API that had drifted from the site runtime's ScriptGlobals, so real scripts failed to compile in Test Run. Realign both to the runtime surface (Instance/Scripts/ExternalSystem/Attributes/Children/Parent) and drop the duplicate ScriptHost stub so the two cannot diverge again. - Script calls (Scripts.CallShared, Instance.CallScript, Route.To().Call) accept an anonymous object instead of a hand-built dictionary, via a shared ScriptArgs normalizer; existing dictionary calls still compile. - Test Run can optionally bind to a deployed instance, so Instance/ Attributes/CallScript route to it cross-site; adds site-side RouteToGetAttributes/RouteToSetAttributes handlers. - Adds Test Run panels to the API method and template script editors. - Fixes the TestDatabaseQuery seed script, which queried a table that never existed. Also commits unrelated in-progress work already in the tree: the health monitoring report loop, site streaming changes, and the Admin/Design data-connection and SMTP page reorganization.
This commit is contained in:
@@ -22,10 +22,10 @@
|
||||
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/connections">Connections</NavLink>
|
||||
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
||||
<NavLink class="nav-link" href="/admin/smtp">SMTP Configuration</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
@@ -41,10 +41,10 @@
|
||||
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
||||
<NavLink class="nav-link" href="/design/connections">Connections</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/smtp">SMTP Configuration</NavLink>
|
||||
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
@page "/design/smtp"
|
||||
@page "/admin/smtp"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@using SmtpConfigurationEntity = ScadaLink.Commons.Entities.Notifications.SmtpConfiguration
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject INotificationRepository NotificationRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.InboundApi
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@using ScriptAnalysis = ScadaLink.CentralUI.ScriptAnalysis
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
@@ -78,6 +80,7 @@
|
||||
<label class="form-label">Script</label>
|
||||
<MonacoEditor @ref="_editor" Value="@_script" ValueChanged="@(v => _script = v)"
|
||||
Language="csharp" Height="320px"
|
||||
ScriptKind="ScadaLink.CentralUI.ScriptAnalysis.ScriptKind.InboundApi"
|
||||
DeclaredParameters="@ScriptParameterNames.Parse(_params)"
|
||||
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_params)"
|
||||
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
|
||||
@@ -91,10 +94,92 @@
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
<button class="btn btn-outline-primary" @onclick="ToggleTestRunPanel">
|
||||
@(_showTestRun ? "Hide Test Run" : "Test Run")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showTestRun)
|
||||
{
|
||||
<div class="card mt-3" id="test-run-panel">
|
||||
<div class="card-header py-2">
|
||||
<span class="fw-semibold">Test Run</span>
|
||||
</div>
|
||||
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
||||
<strong>Heads up:</strong>
|
||||
runs the script as typed (unsaved edits included) against the supplied
|
||||
<code>Parameters</code>. <code>Route</code> calls throw — cross-site
|
||||
routing needs a deployed site reachable over the cluster transport.
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Parameter values</label>
|
||||
<ParameterValueForm ParameterDefinitions="@_params"
|
||||
Values="_paramValues"
|
||||
ValuesChanged="@(v => _paramValues = v)" />
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center mb-3">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RunInSandboxAsync" disabled="@_running">
|
||||
@if (_running)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
<span>Running…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Run</span>
|
||||
}
|
||||
</button>
|
||||
@if (_runResult != null)
|
||||
{
|
||||
<span class="text-muted small">@_runResult.DurationMs ms</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_runResult != null)
|
||||
{
|
||||
@if (_runResult.Success)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-success mb-1">
|
||||
Return value <span class="badge bg-light text-dark ms-1">@_runResult.ReturnTypeName</span>
|
||||
</label>
|
||||
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-danger mb-1">
|
||||
<span class="badge bg-danger me-1">@ErrorKindLabel(_runResult.ErrorKind)</span>
|
||||
</label>
|
||||
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_runResult.Error</pre>
|
||||
@if (_runResult.Markers is { Count: > 0 })
|
||||
{
|
||||
<ul class="small text-danger mt-2 mb-0">
|
||||
@foreach (var m in _runResult.Markers)
|
||||
{
|
||||
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_runResult.ConsoleOutput))
|
||||
{
|
||||
<div class="mb-0">
|
||||
<label class="form-label small mb-1">Console output</label>
|
||||
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ConsoleOutput</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -114,6 +199,12 @@
|
||||
private List<ApiKey> _allKeys = new();
|
||||
private HashSet<int> _selectedKeyIds = new();
|
||||
|
||||
private bool _showTestRun;
|
||||
private bool _running;
|
||||
private Dictionary<string, object?> _paramValues = new();
|
||||
private ScriptAnalysis.SandboxRunResult? _runResult;
|
||||
private CancellationTokenSource? _runCts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
@@ -200,4 +291,53 @@
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
|
||||
|
||||
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
|
||||
|
||||
private async Task RunInSandboxAsync()
|
||||
{
|
||||
_runCts?.Cancel();
|
||||
_runCts = new CancellationTokenSource();
|
||||
_running = true;
|
||||
_runResult = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var jsonParams = _paramValues.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
|
||||
var request = new ScriptAnalysis.SandboxRunRequest(
|
||||
_script, jsonParams, TimeoutSeconds: _timeoutSeconds,
|
||||
Kind: ScriptAnalysis.ScriptKind.InboundApi);
|
||||
_runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { /* superseded by next Run click */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_runResult = new ScriptAnalysis.SandboxRunResult(
|
||||
Success: false,
|
||||
ReturnValueJson: null,
|
||||
ReturnTypeName: null,
|
||||
ConsoleOutput: "",
|
||||
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
|
||||
ErrorKind: ScriptAnalysis.SandboxErrorKind.RuntimeError,
|
||||
DurationMs: 0,
|
||||
Markers: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ErrorKindLabel(ScriptAnalysis.SandboxErrorKind kind) => kind switch
|
||||
{
|
||||
ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error",
|
||||
ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
|
||||
ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error",
|
||||
ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout",
|
||||
_ => "Error"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@page "/admin/connections/create"
|
||||
@page "/admin/connections/{Id:int}/edit"
|
||||
@page "/admin/data-connections/create"
|
||||
@page "/admin/data-connections/{Id:int}/edit"
|
||||
@page "/design/connections/create"
|
||||
@page "/design/connections/{Id:int}/edit"
|
||||
@page "/design/data-connections/create"
|
||||
@page "/design/data-connections/{Id:int}/edit"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@@ -10,7 +10,7 @@
|
||||
@using ScadaLink.Commons.Serialization
|
||||
@using ScadaLink.Commons.Validators
|
||||
@using ScadaLink.CentralUI.Components.Forms
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
await SiteRepository.AddDataConnectionAsync(conn);
|
||||
}
|
||||
await SiteRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/admin/connections");
|
||||
NavigationManager.NavigateTo("/design/connections");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -237,5 +237,5 @@
|
||||
_formFailoverRetryCount = 3;
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/admin/connections");
|
||||
private void GoBack() => NavigationManager.NavigateTo("/design/connections");
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
@page "/admin/connections"
|
||||
@page "/admin/data-connections"
|
||||
@page "/design/connections"
|
||||
@page "/design/data-connections"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IDialogService Dialog
|
||||
@@ -101,7 +101,7 @@
|
||||
{
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/connections/{node.Connection!.Id}/edit")'>
|
||||
@onclick='() => NavigationManager.NavigateTo($"/design/connections/{node.Connection!.Id}/edit")'>
|
||||
Edit
|
||||
</button>
|
||||
</li>
|
||||
@@ -128,7 +128,7 @@
|
||||
else
|
||||
{
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/connections/{node.Connection!.Id}/edit")'>
|
||||
@onclick='() => NavigationManager.NavigateTo($"/design/connections/{node.Connection!.Id}/edit")'>
|
||||
Edit
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
@@ -253,7 +253,7 @@
|
||||
|
||||
private void AddConnectionForSite(int siteId)
|
||||
{
|
||||
NavigationManager.NavigateTo($"/admin/connections/create?siteId={siteId}");
|
||||
NavigationManager.NavigateTo($"/design/connections/create?siteId={siteId}");
|
||||
}
|
||||
|
||||
private void OnSearchChanged()
|
||||
@@ -14,8 +14,6 @@
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Integration Definitions</h4>
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
href="/design/smtp">Email configuration →</a>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
@@ -67,15 +65,6 @@
|
||||
Inbound API Methods <span class="badge bg-secondary">@_apiMethods.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(_tab == "apikeys" ? "active" : "")"
|
||||
role="tab"
|
||||
aria-selected="@(_tab == "apikeys" ? "true" : "false")"
|
||||
aria-controls="int-tab-apikeys"
|
||||
@onclick='() => _tab = "apikeys"'>
|
||||
API Keys <span class="badge bg-secondary">@_apiKeys.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@if (_tab == "extsys")
|
||||
@@ -94,10 +83,6 @@
|
||||
{
|
||||
<div role="tabpanel" id="int-tab-inbound">@RenderInboundApiMethods()</div>
|
||||
}
|
||||
else if (_tab == "apikeys")
|
||||
{
|
||||
<div role="tabpanel" id="int-tab-apikeys">@RenderApiKeys()</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -122,14 +107,6 @@
|
||||
? _dbConnections
|
||||
: _dbConnections.Where(dc => dc.Name?.Contains(_dbConnSearch, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
|
||||
// API Keys
|
||||
private List<ApiKey> _apiKeys = new();
|
||||
private string _apiKeySearch = "";
|
||||
private IEnumerable<ApiKey> FilteredApiKeys =>
|
||||
string.IsNullOrWhiteSpace(_apiKeySearch)
|
||||
? _apiKeys
|
||||
: _apiKeys.Where(k => k.Name?.Contains(_apiKeySearch, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
|
||||
// Notification Lists
|
||||
private List<NotificationList> _notificationLists = new();
|
||||
private Dictionary<int, List<NotificationRecipient>> _recipients = new();
|
||||
@@ -171,7 +148,6 @@
|
||||
}
|
||||
|
||||
_apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
||||
_apiKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex) { _errorMessage = ex.Message; }
|
||||
_loading = false;
|
||||
@@ -478,67 +454,4 @@
|
||||
catch (Exception ex) { _toast.ShowError(ex.Message); }
|
||||
}
|
||||
|
||||
// ==== API Keys ====
|
||||
private RenderFragment RenderApiKeys() => __builder =>
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h5 class="mb-0">API Keys</h5>
|
||||
</div>
|
||||
|
||||
@if (_apiKeys.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<p class="mb-3">No API keys configured. Add your first API key from the Admin section.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3" style="max-width: 320px;">
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="Filter by name…"
|
||||
@bind="_apiKeySearch" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
@if (!FilteredApiKeys.Any())
|
||||
{
|
||||
<p class="text-muted small">No API keys match the filter.</p>
|
||||
}
|
||||
|
||||
<div class="row g-3">
|
||||
@foreach (var key in FilteredApiKeys)
|
||||
{
|
||||
<div class="col-lg-6 col-12" @key="key.Id">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title mb-0">@key.Name</h5>
|
||||
<span class="badge @(key.IsEnabled ? "bg-success" : "bg-secondary")">
|
||||
@(key.IsEnabled ? "Enabled" : "Disabled")
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm"
|
||||
@onclick="() => ToggleApiKeyEnabled(key)">
|
||||
@(key.IsEnabled ? "Disable" : "Enable")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
private async Task ToggleApiKeyEnabled(ApiKey key)
|
||||
{
|
||||
try
|
||||
{
|
||||
key.IsEnabled = !key.IsEnabled;
|
||||
await InboundApiRepository.UpdateApiKeyAsync(key);
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess($"API key '{key.Name}' {(key.IsEnabled ? "enabled" : "disabled")}.");
|
||||
}
|
||||
catch (Exception ex) { _toast.ShowError(ex.Message); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject SharedScriptService SharedScriptService
|
||||
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@@ -62,10 +63,92 @@
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveScript">Save</button>
|
||||
<button class="btn btn-outline-info btn-sm me-1" @onclick="CheckCompilation">Check Syntax</button>
|
||||
<button class="btn btn-outline-primary btn-sm me-1" @onclick="ToggleTestRunPanel">
|
||||
@(_showTestRun ? "Hide Test Run" : "Test Run")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showTestRun)
|
||||
{
|
||||
<div class="card mb-3" id="test-run-panel">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold">Test Run <span class="badge bg-warning text-dark ms-1">Real I/O</span></span>
|
||||
</div>
|
||||
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
||||
<strong>Heads up:</strong>
|
||||
<code>External</code>, <code>Database</code>, and <code>Notify</code> calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent.
|
||||
<code>CallShared</code> executes the named shared script (saved version) in the same sandbox.
|
||||
<code>Attributes</code> and <code>CallScript</code> still throw.
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Parameter values</label>
|
||||
<ParameterValueForm ParameterDefinitions="@_formParameters"
|
||||
Values="_paramValues"
|
||||
ValuesChanged="@(v => _paramValues = v)" />
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center mb-3">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RunInSandboxAsync" disabled="@_running">
|
||||
@if (_running)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
<span>Running…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Run</span>
|
||||
}
|
||||
</button>
|
||||
@if (_runResult != null)
|
||||
{
|
||||
<span class="text-muted small">@_runResult.DurationMs ms</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_runResult != null)
|
||||
{
|
||||
@if (_runResult.Success)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-success mb-1">
|
||||
Return value <span class="badge bg-light text-dark ms-1">@_runResult.ReturnTypeName</span>
|
||||
</label>
|
||||
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-danger mb-1">
|
||||
<span class="badge bg-danger me-1">@ErrorKindLabel(_runResult.ErrorKind)</span>
|
||||
</label>
|
||||
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_runResult.Error</pre>
|
||||
@if (_runResult.Markers is { Count: > 0 })
|
||||
{
|
||||
<ul class="small text-danger mt-2 mb-0">
|
||||
@foreach (var m in _runResult.Markers)
|
||||
{
|
||||
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_runResult.ConsoleOutput))
|
||||
{
|
||||
<div class="mb-0">
|
||||
<label class="form-label small mb-1">Console output</label>
|
||||
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ConsoleOutput</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -83,6 +166,12 @@
|
||||
private MonacoEditor? _editor;
|
||||
private IReadOnlyList<ScriptAnalysis.DiagnosticMarker> _markers = Array.Empty<ScriptAnalysis.DiagnosticMarker>();
|
||||
|
||||
private bool _showTestRun;
|
||||
private bool _running;
|
||||
private Dictionary<string, object?> _paramValues = new();
|
||||
private ScriptAnalysis.SandboxRunResult? _runResult;
|
||||
private CancellationTokenSource? _runCts;
|
||||
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
@@ -180,6 +269,56 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleTestRunPanel()
|
||||
{
|
||||
_showTestRun = !_showTestRun;
|
||||
}
|
||||
|
||||
private async Task RunInSandboxAsync()
|
||||
{
|
||||
_runCts?.Cancel();
|
||||
_runCts = new CancellationTokenSource();
|
||||
_running = true;
|
||||
_runResult = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var jsonParams = _paramValues.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
|
||||
var request = new ScriptAnalysis.SandboxRunRequest(_formCode, jsonParams, TimeoutSeconds: null);
|
||||
_runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { /* superseded by next Run click */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_runResult = new ScriptAnalysis.SandboxRunResult(
|
||||
Success: false,
|
||||
ReturnValueJson: null,
|
||||
ReturnTypeName: null,
|
||||
ConsoleOutput: "",
|
||||
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
|
||||
ErrorKind: ScriptAnalysis.SandboxErrorKind.RuntimeError,
|
||||
DurationMs: 0,
|
||||
Markers: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ErrorKindLabel(ScriptAnalysis.SandboxErrorKind kind) => kind switch
|
||||
{
|
||||
ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error",
|
||||
ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
|
||||
ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error",
|
||||
ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout",
|
||||
_ => "Error"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Basic syntax check: balanced braces/brackets/parens.
|
||||
/// Mirrors the internal SharedScriptService.ValidateSyntax logic.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/design/templates/{Id:int}"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Instances
|
||||
@using ScadaLink.Commons.Entities.Templates
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@using ScadaLink.Commons.Types.Enums
|
||||
@@ -8,7 +9,9 @@
|
||||
@using ScadaLink.TemplateEngine.Validation
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ICentralUiRepository CentralUiRepository
|
||||
@inject TemplateService TemplateService
|
||||
@inject ScadaLink.CentralUI.ScriptAnalysis.ScriptAnalysisService AnalysisService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IDialogService Dialog
|
||||
@@ -106,6 +109,15 @@
|
||||
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext> _editorChildren
|
||||
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>();
|
||||
|
||||
// Script modal Test Run state.
|
||||
private bool _showScriptTestRun;
|
||||
private bool _scriptRunning;
|
||||
private Dictionary<string, object?> _scriptParamValues = new();
|
||||
private ScadaLink.CentralUI.ScriptAnalysis.SandboxRunResult? _scriptRunResult;
|
||||
private CancellationTokenSource? _scriptRunCts;
|
||||
private List<Instance> _deployedInstances = new();
|
||||
private string _scriptBindInstance = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Editor's Parent.* context. Empty for base templates (no owner exists);
|
||||
/// exactly one entry for derived templates — the slot-owner resolved from
|
||||
@@ -185,6 +197,13 @@
|
||||
_editorChildren = await BuildChildContextsAsync(_compositions);
|
||||
_editorParents = await BuildParentContextsAsync(Id);
|
||||
|
||||
// Deployed, running instances of this template — selectable as the
|
||||
// bind target for a script Test Run.
|
||||
_deployedInstances = (await CentralUiRepository.GetInstancesFilteredAsync(templateId: Id))
|
||||
.Where(i => i.State == InstanceState.Enabled)
|
||||
.OrderBy(i => i.UniqueName)
|
||||
.ToList();
|
||||
|
||||
_validationResult = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -926,8 +945,117 @@
|
||||
{
|
||||
<div class="text-danger small mt-2">@_scriptFormError</div>
|
||||
}
|
||||
|
||||
@if (_showScriptTestRun)
|
||||
{
|
||||
<div class="card mt-3" id="script-test-run-panel">
|
||||
<div class="card-header py-2">
|
||||
<span class="fw-semibold">Test Run <span class="badge bg-warning text-dark ms-1">Real I/O</span></span>
|
||||
</div>
|
||||
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
||||
<strong>Heads up:</strong>
|
||||
runs the script as typed (unsaved edits included) against the supplied
|
||||
<code>Parameters</code>.
|
||||
<code>External</code>, <code>Database</code>, and <code>Notify</code> calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent.
|
||||
<code>CallShared</code> executes the named shared script (saved version) in the same sandbox.
|
||||
<code>Instance</code>, <code>Attributes</code>, <code>Children</code>, <code>Parent</code>, and <code>CallScript</code> throw unless a bound instance is selected below — then they route to that live instance (attribute writes are permanent too).
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Bind to instance <span class="text-muted">(optional)</span></label>
|
||||
@if (_deployedInstances.Count == 0)
|
||||
{
|
||||
<div class="form-text">
|
||||
No running instances of this template.
|
||||
<code>Instance</code>/<code>Attributes</code>/<code>CallScript</code> will throw.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<select class="form-select form-select-sm" @bind="_scriptBindInstance">
|
||||
<option value="">— None (Instance/Attributes throw) —</option>
|
||||
@foreach (var inst in _deployedInstances)
|
||||
{
|
||||
<option value="@inst.UniqueName">@inst.UniqueName</option>
|
||||
}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Routes <code>Instance.GetAttribute/SetAttribute</code>,
|
||||
<code>Attributes</code>, <code>Children</code>, <code>Parent</code>, and
|
||||
<code>CallScript</code> to the selected live instance.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Parameter values</label>
|
||||
<ParameterValueForm ParameterDefinitions="@_scriptParameters"
|
||||
Values="_scriptParamValues"
|
||||
ValuesChanged="@(v => _scriptParamValues = v)" />
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center mb-3">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RunScriptInSandboxAsync" disabled="@_scriptRunning">
|
||||
@if (_scriptRunning)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
<span>Running…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Run</span>
|
||||
}
|
||||
</button>
|
||||
@if (_scriptRunResult != null)
|
||||
{
|
||||
<span class="text-muted small">@_scriptRunResult.DurationMs ms</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_scriptRunResult != null)
|
||||
{
|
||||
@if (_scriptRunResult.Success)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-success mb-1">
|
||||
Return value <span class="badge bg-light text-dark ms-1">@_scriptRunResult.ReturnTypeName</span>
|
||||
</label>
|
||||
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_scriptRunResult.ReturnValueJson</pre>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-danger mb-1">
|
||||
<span class="badge bg-danger me-1">@ScriptErrorKindLabel(_scriptRunResult.ErrorKind)</span>
|
||||
</label>
|
||||
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_scriptRunResult.Error</pre>
|
||||
@if (_scriptRunResult.Markers is { Count: > 0 })
|
||||
{
|
||||
<ul class="small text-danger mt-2 mb-0">
|
||||
@foreach (var m in _scriptRunResult.Markers)
|
||||
{
|
||||
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_scriptRunResult.ConsoleOutput))
|
||||
{
|
||||
<div class="mb-0">
|
||||
<label class="form-label small mb-1">Console output</label>
|
||||
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_scriptRunResult.ConsoleOutput</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-primary btn-sm me-auto" @onclick="ToggleScriptTestRunPanel">
|
||||
@(_showScriptTestRun ? "Hide Test Run" : "Test Run")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelScriptForm">Cancel</button>
|
||||
<button class="btn btn-success btn-sm" @onclick="SaveScript">@(editingScript ? "Save" : "Add")</button>
|
||||
</div>
|
||||
@@ -1341,6 +1469,7 @@
|
||||
_scriptReturn = null;
|
||||
_scriptIsLocked = false;
|
||||
_scriptModalTab = "code";
|
||||
ResetScriptTestRun();
|
||||
}
|
||||
|
||||
private void BeginEditScript(TemplateScript script)
|
||||
@@ -1356,6 +1485,7 @@
|
||||
_scriptReturn = script.ReturnDefinition;
|
||||
_scriptIsLocked = script.IsLocked;
|
||||
_scriptModalTab = "code";
|
||||
ResetScriptTestRun();
|
||||
}
|
||||
|
||||
private void CancelScriptForm()
|
||||
@@ -1363,8 +1493,69 @@
|
||||
_showScriptForm = false;
|
||||
_editScriptId = null;
|
||||
_scriptFormError = null;
|
||||
ResetScriptTestRun();
|
||||
}
|
||||
|
||||
private void ResetScriptTestRun()
|
||||
{
|
||||
_showScriptTestRun = false;
|
||||
_scriptRunning = false;
|
||||
_scriptParamValues = new();
|
||||
_scriptBindInstance = string.Empty;
|
||||
_scriptRunResult = null;
|
||||
_scriptRunCts?.Cancel();
|
||||
_scriptRunCts = null;
|
||||
}
|
||||
|
||||
private void ToggleScriptTestRunPanel() => _showScriptTestRun = !_showScriptTestRun;
|
||||
|
||||
private async Task RunScriptInSandboxAsync()
|
||||
{
|
||||
_scriptRunCts?.Cancel();
|
||||
_scriptRunCts = new CancellationTokenSource();
|
||||
_scriptRunning = true;
|
||||
_scriptRunResult = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var jsonParams = _scriptParamValues.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
|
||||
var request = new ScadaLink.CentralUI.ScriptAnalysis.SandboxRunRequest(
|
||||
_scriptCode, jsonParams, TimeoutSeconds: null,
|
||||
BindInstanceUniqueName: string.IsNullOrEmpty(_scriptBindInstance) ? null : _scriptBindInstance);
|
||||
_scriptRunResult = await AnalysisService.RunInSandboxAsync(request, _scriptRunCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { /* superseded by next Run click */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_scriptRunResult = new ScadaLink.CentralUI.ScriptAnalysis.SandboxRunResult(
|
||||
Success: false,
|
||||
ReturnValueJson: null,
|
||||
ReturnTypeName: null,
|
||||
ConsoleOutput: "",
|
||||
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
|
||||
ErrorKind: ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.RuntimeError,
|
||||
DurationMs: 0,
|
||||
Markers: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scriptRunning = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ScriptErrorKindLabel(ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind kind) => kind switch
|
||||
{
|
||||
ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error",
|
||||
ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
|
||||
ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error",
|
||||
ScadaLink.CentralUI.ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout",
|
||||
_ => "Error"
|
||||
};
|
||||
|
||||
private async Task SaveScript()
|
||||
{
|
||||
if (_selectedTemplate == null) return;
|
||||
|
||||
@@ -51,10 +51,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Per-site detail cards *@
|
||||
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key))
|
||||
@* Per-site detail cards — central cluster pinned to the top, then sites alphabetically *@
|
||||
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key == CentralHealthReportLoop.CentralSiteId ? 0 : 1).ThenBy(s => s.Key))
|
||||
{
|
||||
var siteName = GetSiteName(siteId);
|
||||
var isCentral = siteId == CentralHealthReportLoop.CentralSiteId;
|
||||
var siteName = isCentral ? "Central Cluster" : GetSiteName(siteId);
|
||||
var detailsCollapseId = $"site-details-{siteId}";
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
||||
@@ -67,10 +68,12 @@
|
||||
{
|
||||
<span class="badge bg-danger me-2" aria-label="State: Offline">@OfflineGlyph Offline</span>
|
||||
}
|
||||
<strong class="fs-5">@siteName (@siteId)</strong>
|
||||
<strong class="fs-5">@siteName@(isCentral ? "" : $" ({siteId})")</strong>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" /> | Seq: @state.LastSequenceNumber
|
||||
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" />
|
||||
| Last heartbeat: <TimestampDisplay Value="@state.LastHeartbeatAt" Format="HH:mm:ss" />
|
||||
| Seq: @state.LastSequenceNumber
|
||||
</small>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
[Parameter] public bool ReadOnly { get; set; } = false;
|
||||
[Parameter] public bool ShowToolbar { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime globals surface the script is analyzed against. Defaults to
|
||||
/// template/shared-script globals; set to <c>InboundApi</c> on the API
|
||||
/// method editor so <c>Route</c> and <c>Parameters</c> type-check.
|
||||
/// </summary>
|
||||
[Parameter] public ScriptAnalysis.ScriptKind ScriptKind { get; set; } = ScriptAnalysis.ScriptKind.Template;
|
||||
|
||||
/// <summary>
|
||||
/// Parameter names declared on the form (derived from the SchemaBuilder's
|
||||
/// JSON Schema), surfaced as completions inside Parameters["..."] literals
|
||||
@@ -148,7 +155,8 @@
|
||||
?? Array.Empty<ScriptAnalysis.ParameterShape>(),
|
||||
SelfAttributes?.ToArray() ?? Array.Empty<ScriptAnalysis.AttributeShape>(),
|
||||
Children?.ToArray() ?? Array.Empty<ScriptAnalysis.CompositionContext>(),
|
||||
Parent);
|
||||
Parent,
|
||||
ScriptKind);
|
||||
|
||||
private async Task FormatAsync()
|
||||
{
|
||||
@@ -189,5 +197,6 @@
|
||||
ScriptAnalysis.ParameterShape[] DeclaredParameterShapes,
|
||||
ScriptAnalysis.AttributeShape[] SelfAttributes,
|
||||
ScriptAnalysis.CompositionContext[] Children,
|
||||
ScriptAnalysis.CompositionContext? Parent);
|
||||
ScriptAnalysis.CompositionContext? Parent,
|
||||
ScriptAnalysis.ScriptKind ScriptKind);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
@using ScadaLink.CentralUI.ScriptAnalysis
|
||||
@using System.Text.Json
|
||||
|
||||
@*
|
||||
Renders an input row per declared parameter so the user can supply values
|
||||
for a script test run. Primitive types get typed inputs (text / number /
|
||||
checkbox); Object and List fall back to a JSON textarea with inline parse
|
||||
errors. The companion SchemaBuilder edits the schema; this edits values.
|
||||
*@
|
||||
|
||||
@if (Shapes.Count == 0)
|
||||
{
|
||||
<div class="text-muted small fst-italic">No parameters declared.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex flex-column gap-2">
|
||||
@foreach (var shape in Shapes)
|
||||
{
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label small mb-0" for="@FieldId(shape)">
|
||||
<code>@shape.Name</code>
|
||||
<span class="text-muted ms-1">@shape.Type@(shape.Required ? "" : "?")</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
@RenderInput(shape)
|
||||
@if (_parseErrors.TryGetValue(shape.Name, out var err))
|
||||
{
|
||||
<div class="text-danger small mt-1">@err</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ParameterDefinitions { get; set; }
|
||||
[Parameter] public Dictionary<string, object?> Values { get; set; } = new();
|
||||
[Parameter] public EventCallback<Dictionary<string, object?>> ValuesChanged { get; set; }
|
||||
|
||||
private IReadOnlyList<ParameterShape> Shapes =>
|
||||
ScriptParameterNames.ParseShapes(ParameterDefinitions);
|
||||
|
||||
private readonly Dictionary<string, string> _rawText = new();
|
||||
private readonly Dictionary<string, string> _parseErrors = new();
|
||||
|
||||
private static string FieldId(ParameterShape shape) => $"param-{shape.Name}";
|
||||
|
||||
private RenderFragment RenderInput(ParameterShape shape) => __builder =>
|
||||
{
|
||||
switch (shape.Type)
|
||||
{
|
||||
case "Boolean":
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@FieldId(shape)"
|
||||
checked="@AsBool(shape.Name)"
|
||||
@onchange="e => SetBool(shape.Name, (bool)(e.Value ?? false))" />
|
||||
</div>
|
||||
break;
|
||||
|
||||
case "Integer":
|
||||
<input class="form-control form-control-sm" type="number" step="1" id="@FieldId(shape)"
|
||||
value="@AsRaw(shape.Name)"
|
||||
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: true)" />
|
||||
break;
|
||||
|
||||
case "Float":
|
||||
<input class="form-control form-control-sm" type="number" step="any" id="@FieldId(shape)"
|
||||
value="@AsRaw(shape.Name)"
|
||||
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: false)" />
|
||||
break;
|
||||
|
||||
case "String":
|
||||
<input class="form-control form-control-sm" type="text" id="@FieldId(shape)"
|
||||
value="@AsRaw(shape.Name)"
|
||||
@oninput="e => SetString(shape.Name, (string?)e.Value)" />
|
||||
break;
|
||||
|
||||
default: // Object, List, List<...>, unknown
|
||||
<textarea class="form-control form-control-sm font-monospace" rows="3" id="@FieldId(shape)"
|
||||
placeholder='@($"JSON {shape.Type.ToLowerInvariant()}")'
|
||||
@oninput="e => SetJson(shape.Name, (string?)e.Value)">@AsRaw(shape.Name)</textarea>
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private string AsRaw(string name) =>
|
||||
_rawText.TryGetValue(name, out var raw) ? raw : "";
|
||||
|
||||
private bool AsBool(string name) =>
|
||||
Values.TryGetValue(name, out var v) && v is bool b && b;
|
||||
|
||||
private async Task SetString(string name, string? raw)
|
||||
{
|
||||
_rawText[name] = raw ?? "";
|
||||
_parseErrors.Remove(name);
|
||||
Values[name] = raw ?? "";
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
}
|
||||
|
||||
private async Task SetBool(string name, bool value)
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values[name] = value;
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
}
|
||||
|
||||
private async Task SetNumeric(string name, string? raw, bool integerOnly)
|
||||
{
|
||||
_rawText[name] = raw ?? "";
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values.Remove(name);
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
return;
|
||||
}
|
||||
if (integerOnly && long.TryParse(raw, out var i))
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values[name] = i;
|
||||
}
|
||||
else if (!integerOnly && double.TryParse(raw,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var d))
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values[name] = d;
|
||||
}
|
||||
else
|
||||
{
|
||||
_parseErrors[name] = integerOnly ? "Not a valid integer." : "Not a valid number.";
|
||||
Values.Remove(name);
|
||||
}
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
}
|
||||
|
||||
private async Task SetJson(string name, string? raw)
|
||||
{
|
||||
_rawText[name] = raw ?? "";
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
_parseErrors.Remove(name);
|
||||
Values.Remove(name);
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
Values[name] = JsonElementToObject(doc.RootElement.Clone());
|
||||
_parseErrors.Remove(name);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_parseErrors[name] = $"JSON parse error: {ex.Message}";
|
||||
Values.Remove(name);
|
||||
}
|
||||
await ValuesChanged.InvokeAsync(Values);
|
||||
}
|
||||
|
||||
private static object? JsonElementToObject(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var i) ? (object)i : element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(),
|
||||
JsonValueKind.Object => element.EnumerateObject()
|
||||
.ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user