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:
Joseph Doherty
2026-05-16 03:37:56 -04:00
parent d7b05b40e9
commit 295150751f
50 changed files with 2926 additions and 550 deletions

View File

@@ -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

View File

@@ -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"
};
}

View File

@@ -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");
}

View File

@@ -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()

View File

@@ -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); }
}
}

View File

@@ -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.

View File

@@ -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;

View File

@@ -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">