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

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

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

View File

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

View File

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

View File

@@ -9,8 +9,17 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
public interface ISharedScriptCatalog
{
Task<IReadOnlyList<ScriptShape>> GetShapesAsync();
/// <summary>
/// Returns the source code and metadata for a named shared script, or
/// null if no shared script with that name exists. Used by Test Run to
/// compile and execute nested CallShared invocations.
/// </summary>
Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
}
public record SharedScriptSource(string Name, string Code, string? ParameterDefinitions, string? ReturnDefinition);
public class SharedScriptCatalog : ISharedScriptCatalog
{
private readonly SharedScriptService _service;
@@ -24,4 +33,12 @@ public class SharedScriptCatalog : ISharedScriptCatalog
.Select(s => ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition))
.ToList();
}
public async Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(name)) return null;
var scripts = await _service.GetAllSharedScriptsAsync(cancellationToken);
var s = scripts.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.Ordinal));
return s == null ? null : new SharedScriptSource(s.Name, s.Code, s.ParameterDefinitions, s.ReturnDefinition);
}
}

View File

@@ -0,0 +1,56 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Globals type seen by inbound API method scripts during analysis. Mirrors
/// the surface the runtime exposes (see ScadaLink.InboundAPI.InboundScriptContext
/// and RouteHelper). The methods here are never invoked — Roslyn only reads
/// their signatures to type-check API method scripts and offer completions.
/// </summary>
public class InboundScriptHost
{
public ScriptParameters Parameters { get; init; } = new();
public RouteHelper Route { get; } = new();
public System.Threading.CancellationToken CancellationToken { get; }
/// <summary>Editor mirror of ScadaLink.InboundAPI.RouteHelper.</summary>
public class RouteHelper
{
public RouteTarget To(string instanceCode) => new();
}
/// <summary>Editor mirror of ScadaLink.InboundAPI.RouteTarget.</summary>
public class RouteTarget
{
public System.Threading.Tasks.Task<object?> Call(
string scriptName,
object? parameters = null,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.FromResult<object?>(null);
public System.Threading.Tasks.Task<object?> GetAttribute(
string attributeName,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.FromResult<object?>(null);
public System.Threading.Tasks.Task<IReadOnlyDictionary<string, object?>> GetAttributes(
IEnumerable<string> attributeNames,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.FromResult<IReadOnlyDictionary<string, object?>>(
new Dictionary<string, object?>());
public System.Threading.Tasks.Task SetAttribute(
string attributeName,
string value,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.CompletedTask;
public System.Threading.Tasks.Task SetAttributes(
IReadOnlyDictionary<string, string> attributeValues,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.CompletedTask;
}
}

View File

@@ -0,0 +1,118 @@
using System.Data.Common;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// User-facing surface for <c>ExternalSystem.Call</c> /
/// <c>ExternalSystem.CachedCall</c> inside a Test Run. Mirrors
/// ExternalSystemHelper in ScadaLink.SiteRuntime.Scripts.ScriptRuntimeContext
/// so the same user code compiles against both. When constructed with a null
/// client (the editor's metadata-only analysis pass) every call throws
/// <see cref="ScriptSandboxException"/>; with a real client wired in (a Test
/// Run) calls hit the live HTTP path.
/// </summary>
public class SandboxExternalHelper
{
private readonly IExternalSystemClient? _client;
private readonly string _instanceName;
public SandboxExternalHelper(IExternalSystemClient? client, string instanceName)
{
_client = client;
_instanceName = instanceName;
}
public Task<ExternalCallResult> Call(
string systemName,
string methodName,
IReadOnlyDictionary<string, object?>? parameters = null,
CancellationToken cancellationToken = default)
{
if (_client == null)
throw new ScriptSandboxException(
$"External.Call(\"{systemName}\", \"{methodName}\") — external system client not configured for Test Run.");
return _client.CallAsync(systemName, methodName, parameters, cancellationToken);
}
public Task<ExternalCallResult> CachedCall(
string systemName,
string methodName,
IReadOnlyDictionary<string, object?>? parameters = null,
CancellationToken cancellationToken = default)
{
if (_client == null)
throw new ScriptSandboxException(
$"External.CachedCall(\"{systemName}\", \"{methodName}\") — external system client not configured for Test Run.");
return _client.CachedCallAsync(systemName, methodName, parameters, _instanceName, cancellationToken);
}
}
public class SandboxDatabaseHelper
{
private readonly IDatabaseGateway? _gateway;
private readonly string _instanceName;
public SandboxDatabaseHelper(IDatabaseGateway? gateway, string instanceName)
{
_gateway = gateway;
_instanceName = instanceName;
}
public Task<DbConnection> Connection(string name, CancellationToken cancellationToken = default)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"Database.Connection(\"{name}\") — database gateway not configured for Test Run.");
return _gateway.GetConnectionAsync(name, cancellationToken);
}
public Task CachedWrite(
string name,
string sql,
IReadOnlyDictionary<string, object?>? parameters = null,
CancellationToken cancellationToken = default)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"Database.CachedWrite(\"{name}\") — database gateway not configured for Test Run.");
return _gateway.CachedWriteAsync(name, sql, parameters, _instanceName, cancellationToken);
}
}
public class SandboxNotifyHelper
{
private readonly INotificationDeliveryService? _service;
private readonly string _instanceName;
public SandboxNotifyHelper(INotificationDeliveryService? service, string instanceName)
{
_service = service;
_instanceName = instanceName;
}
public SandboxNotifyTarget To(string listName) =>
new(listName, _service, _instanceName);
}
public class SandboxNotifyTarget
{
private readonly string _listName;
private readonly INotificationDeliveryService? _service;
private readonly string _instanceName;
internal SandboxNotifyTarget(string listName, INotificationDeliveryService? service, string instanceName)
{
_listName = listName;
_service = service;
_instanceName = instanceName;
}
public Task<NotificationResult> Send(string subject, string message, CancellationToken cancellationToken = default)
{
if (_service == null)
throw new ScriptSandboxException(
$"Notify.To(\"{_listName}\").Send(...) — notification service not configured for Test Run.");
return _service.SendAsync(_listName, subject, message, _instanceName, cancellationToken);
}
}

View File

@@ -0,0 +1,67 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Runtime globals for an inbound API method Test Run. Mirrors
/// <see cref="InboundScriptHost"/>'s public surface so the same user code that
/// compiles for diagnostics also compiles against this type — but every
/// <c>Route</c> accessor throws <see cref="ScriptSandboxException"/> instead of
/// reaching a deployed site. Cross-site routing needs the cluster transport and
/// a live instance, neither of which exists in a central Test Run; pure logic
/// and <c>Parameters</c> still work, matching how <see cref="SandboxScriptHost"/>
/// throws on <c>Attributes</c> for shared scripts.
/// </summary>
public class SandboxInboundScriptHost
{
public ScriptParameters Parameters { get; init; } = new();
public CancellationToken CancellationToken { get; init; }
public RouteAccessor Route { get; } = new();
/// <summary>Mirror of ScadaLink.InboundAPI.RouteHelper.</summary>
public class RouteAccessor
{
public RouteTarget To(string instanceCode) => new(instanceCode);
}
/// <summary>Mirror of ScadaLink.InboundAPI.RouteTarget — every call throws.</summary>
public class RouteTarget
{
private readonly string _instanceCode;
internal RouteTarget(string instanceCode) => _instanceCode = instanceCode;
public Task<object?> Call(
string scriptName,
object? parameters = null,
CancellationToken cancellationToken = default) =>
throw Unavailable($"Call(\"{scriptName}\")");
public Task<object?> GetAttribute(
string attributeName,
CancellationToken cancellationToken = default) =>
throw Unavailable($"GetAttribute(\"{attributeName}\")");
public Task<IReadOnlyDictionary<string, object?>> GetAttributes(
IEnumerable<string> attributeNames,
CancellationToken cancellationToken = default) =>
throw Unavailable("GetAttributes(...)");
public Task SetAttribute(
string attributeName,
string value,
CancellationToken cancellationToken = default) =>
throw Unavailable($"SetAttribute(\"{attributeName}\")");
public Task SetAttributes(
IReadOnlyDictionary<string, string> attributeValues,
CancellationToken cancellationToken = default) =>
throw Unavailable("SetAttributes(...)");
private ScriptSandboxException Unavailable(string operation) =>
new($"Route.To(\"{_instanceCode}\").{operation} is not available in Test Run — " +
"cross-site routing needs a deployed site reachable over the cluster transport.");
}
}

View File

@@ -0,0 +1,67 @@
using ScadaLink.Commons.Messages.InboundApi;
using ScadaLink.Communication;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Backs the Test Run sandbox <c>Instance</c> when the run is bound to a real
/// deployed instance. Routes attribute reads/writes and sibling-script calls to
/// the instance cross-site via <see cref="CommunicationService"/> — the same
/// transport the inbound API's <c>Route.To()</c> uses. All calls run under the
/// Test Run's cancellation token, so the sandbox timeout still applies.
/// </summary>
public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
{
private readonly CommunicationService _comms;
private readonly string _siteId;
private readonly string _instanceUniqueName;
private readonly CancellationToken _runToken;
public SandboxInstanceGateway(
CommunicationService comms,
string siteId,
string instanceUniqueName,
CancellationToken runToken)
{
_comms = comms;
_siteId = siteId;
_instanceUniqueName = instanceUniqueName;
_runToken = runToken;
}
public async Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct)
{
var request = new RouteToGetAttributesRequest(
Guid.NewGuid().ToString(), _instanceUniqueName,
new[] { canonicalName }, DateTimeOffset.UtcNow);
var response = await _comms.RouteToGetAttributesAsync(_siteId, request, _runToken);
if (!response.Success)
throw new ScriptSandboxException(
$"GetAttribute(\"{canonicalName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
return response.Values.TryGetValue(canonicalName, out var value) ? value : null;
}
public async Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct)
{
var request = new RouteToSetAttributesRequest(
Guid.NewGuid().ToString(), _instanceUniqueName,
new Dictionary<string, string> { [canonicalName] = value }, DateTimeOffset.UtcNow);
var response = await _comms.RouteToSetAttributesAsync(_siteId, request, _runToken);
if (!response.Success)
throw new ScriptSandboxException(
$"SetAttribute(\"{canonicalName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
}
public async Task<object?> CallScriptAsync(
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct)
{
var request = new RouteToCallRequest(
Guid.NewGuid().ToString(), _instanceUniqueName,
canonicalScriptName, parameters, DateTimeOffset.UtcNow);
var response = await _comms.RouteToCallAsync(_siteId, request, _runToken);
if (!response.Success)
throw new ScriptSandboxException(
$"CallScript(\"{canonicalScriptName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
return response.ReturnValue;
}
}

View File

@@ -0,0 +1,45 @@
using System.Text.Json;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Request from the UI to execute a script in the central sandbox.
/// Parameters arrive as JSON values and are converted to .NET primitives
/// before being placed in the Parameters dictionary supplied to the script.
/// <see cref="Kind"/> selects which globals surface the script is compiled
/// and run against — template/shared scripts see <see cref="SandboxScriptHost"/>,
/// inbound API method scripts see <see cref="SandboxInboundScriptHost"/>.
/// <see cref="BindInstanceUniqueName"/>, when set, binds the run to a deployed
/// instance so <c>Instance</c>/<c>Attributes</c> access routes to it cross-site
/// instead of throwing. Ignored for inbound API scripts.
/// </summary>
public record SandboxRunRequest(
string Code,
Dictionary<string, JsonElement>? Parameters,
int? TimeoutSeconds,
ScriptKind Kind = ScriptKind.Template,
string? BindInstanceUniqueName = null);
public enum SandboxErrorKind
{
None,
CompileError,
SandboxLimitation,
RuntimeError,
Timeout
}
/// <summary>
/// Result of a Test Run. <see cref="Markers"/> carries Roslyn diagnostics
/// when <see cref="ErrorKind"/> is CompileError so the UI can display them
/// the same way it does for the editor's live problems panel.
/// </summary>
public record SandboxRunResult(
bool Success,
string? ReturnValueJson,
string? ReturnTypeName,
string ConsoleOutput,
string? Error,
SandboxErrorKind ErrorKind,
long DurationMs,
IReadOnlyList<DiagnosticMarker>? Markers);

View File

@@ -0,0 +1,236 @@
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Scripts;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Runtime globals for the Test Run sandbox. Mirrors the real site-runtime
/// <c>ScriptGlobals</c> surface (ScadaLink.SiteRuntime.Scripts) member-for-member
/// so the same user code that runs at a site also compiles and runs here.
///
/// Instance-context members — <c>Instance.GetAttribute/SetAttribute/CallScript</c>,
/// <c>Attributes</c>, <c>Children</c>, <c>Parent</c> — need a live deployed
/// instance. With no instance bound they throw <see cref="ScriptSandboxException"/>;
/// with one bound (see <see cref="SandboxInstanceContext"/>) they route to it.
///
/// <c>ExternalSystem</c>, <c>Database</c>, <c>Notify</c>, and
/// <c>Scripts.CallShared</c> run against central's real services and fire for
/// real — they do not depend on a bound instance.
/// </summary>
public class SandboxScriptHost
{
public ScriptParameters Parameters { get; init; } = new();
public CancellationToken CancellationToken { get; init; }
public AlarmContext? Alarm { get; init; }
public ScriptScope Scope { get; init; } = ScriptScope.Root;
public SandboxInstanceContext Instance { get; init; } = new();
public SandboxExternalHelper ExternalSystem => Instance.ExternalSystem;
public SandboxDatabaseHelper Database => Instance.Database;
public SandboxNotifyHelper Notify => Instance.Notify;
public SandboxScriptCallHelper Scripts => Instance.Scripts;
public SandboxAttributeAccessor Attributes => new(Instance, Scope.SelfPath);
public SandboxChildrenAccessor Children => new(Instance, Scope.SelfPath);
public SandboxCompositionAccessor? Parent =>
Scope.ParentPath == null ? null : new SandboxCompositionAccessor(Instance, Scope.ParentPath);
}
/// <summary>
/// Backs the sandbox <c>Instance</c> when a Test Run is bound to a real
/// deployed instance. Null when unbound. The implementation routes to the
/// instance cross-site over the cluster transport.
/// </summary>
public interface ISandboxInstanceGateway
{
Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct);
Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct);
Task<object?> CallScriptAsync(
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct);
}
/// <summary>
/// Sandbox mirror of <c>ScadaLink.SiteRuntime.Scripts.ScriptRuntimeContext</c> —
/// the <c>Instance</c> global. Attribute and sibling-script access needs a real
/// deployed instance: with no gateway wired it throws; with one (a bound
/// instance) it routes cross-site. <c>ExternalSystem</c>/<c>Database</c>/
/// <c>Notify</c>/<c>Scripts</c> run against central's real services regardless
/// of binding.
/// </summary>
public class SandboxInstanceContext
{
private readonly ISandboxInstanceGateway? _gateway;
public SandboxExternalHelper ExternalSystem { get; }
public SandboxDatabaseHelper Database { get; }
public SandboxNotifyHelper Notify { get; }
public SandboxScriptCallHelper Scripts { get; }
public SandboxInstanceContext(
ISandboxInstanceGateway? gateway = null,
SandboxExternalHelper? external = null,
SandboxDatabaseHelper? database = null,
SandboxNotifyHelper? notify = null,
SandboxScriptCallHelper? scripts = null)
{
_gateway = gateway;
ExternalSystem = external ?? new SandboxExternalHelper(null, "<sandbox>");
Database = database ?? new SandboxDatabaseHelper(null, "<sandbox>");
Notify = notify ?? new SandboxNotifyHelper(null, "<sandbox>");
Scripts = scripts ?? new SandboxScriptCallHelper(null);
}
public Task<object?> GetAttribute(string attributeName)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"GetAttribute(\"{attributeName}\") needs a deployed instance — " +
"bind one in Test Run to read live attribute values.");
return _gateway.GetAttributeAsync(attributeName, CancellationToken.None);
}
public void SetAttribute(string attributeName, string value)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"SetAttribute(\"{attributeName}\") needs a deployed instance — " +
"bind one in Test Run to write attribute values.");
_gateway.SetAttributeAsync(attributeName, value, CancellationToken.None).GetAwaiter().GetResult();
}
public Task<object?> CallScript(string scriptName, object? parameters = null)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"CallScript(\"{scriptName}\") needs a deployed instance — " +
"bind one in Test Run to call sibling scripts.");
return _gateway.CallScriptAsync(scriptName, ScriptArgs.Normalize(parameters), CancellationToken.None);
}
}
/// <summary>
/// Sandbox mirror of <c>ScriptRuntimeContext.ScriptCallHelper</c> —
/// <c>Scripts.CallShared(...)</c>. Compiles and runs the named shared script in
/// the same sandbox via the wired delegate.
/// </summary>
public class SandboxScriptCallHelper
{
private readonly Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? _callShared;
public SandboxScriptCallHelper(
Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? callShared)
{
_callShared = callShared;
}
public Task<object?> CallShared(
string scriptName,
object? parameters = null,
CancellationToken cancellationToken = default)
{
if (_callShared == null)
throw new ScriptSandboxException(
$"Scripts.CallShared(\"{scriptName}\") — shared-script catalog not configured for Test Run.");
return _callShared(scriptName, ScriptArgs.Normalize(parameters), cancellationToken);
}
}
/// <summary>
/// Sandbox mirror of <c>ScadaLink.SiteRuntime.Scripts.AttributeAccessor</c> —
/// scope-aware <c>Attributes["X"]</c> access anchored at a canonical-name prefix.
/// </summary>
public class SandboxAttributeAccessor
{
private readonly SandboxInstanceContext _ctx;
public string ScopePrefix { get; }
public SandboxAttributeAccessor(SandboxInstanceContext ctx, string prefix)
{
_ctx = ctx;
ScopePrefix = prefix;
}
public string Resolve(string key) =>
ScopePrefix.Length == 0 ? key : ScopePrefix + "." + key;
public object? this[string key]
{
get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult();
set => _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
}
public Task<object?> GetAsync(string key) => _ctx.GetAttribute(Resolve(key));
public Task SetAsync(string key, object? value)
{
_ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
return Task.CompletedTask;
}
}
/// <summary>
/// Sandbox mirror of <c>ScadaLink.SiteRuntime.Scripts.CompositionAccessor</c> —
/// a view of one composition: its attributes plus an invokable <c>CallScript</c>.
/// </summary>
public class SandboxCompositionAccessor
{
private readonly SandboxInstanceContext _ctx;
public string Path { get; }
public SandboxAttributeAccessor Attributes { get; }
public SandboxCompositionAccessor(SandboxInstanceContext ctx, string path)
{
_ctx = ctx;
Path = path;
Attributes = new SandboxAttributeAccessor(ctx, path);
}
public string ResolveScript(string scriptName) =>
Path.Length == 0 ? scriptName : Path + "." + scriptName;
public Task<object?> CallScript(string scriptName, object? parameters = null)
=> _ctx.CallScript(ResolveScript(scriptName), parameters);
}
/// <summary>
/// Sandbox mirror of <c>ScadaLink.SiteRuntime.Scripts.ChildrenAccessor</c> —
/// dictionary-style access to child compositions.
/// </summary>
public class SandboxChildrenAccessor
{
private readonly SandboxInstanceContext _ctx;
private readonly string _selfPath;
public SandboxChildrenAccessor(SandboxInstanceContext ctx, string selfPath)
{
_ctx = ctx;
_selfPath = selfPath;
}
public SandboxCompositionAccessor this[string compositionName]
{
get
{
var path = _selfPath.Length == 0
? compositionName
: _selfPath + "." + compositionName;
return new SandboxCompositionAccessor(_ctx, path);
}
}
}
/// <summary>
/// Distinct exception so the Test Run pipeline can label sandbox-only
/// limitations differently from genuine runtime errors in user code.
/// </summary>
public class ScriptSandboxException : Exception
{
public ScriptSandboxException(string message) : base(message) { }
}

View File

@@ -1,12 +1,25 @@
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Which runtime globals surface a script is analyzed against. Template and
/// shared scripts see <see cref="SandboxScriptHost"/> (mirroring the site
/// runtime's ScriptGlobals); inbound API method scripts see
/// <see cref="InboundScriptHost"/> (with <c>Route</c> and <c>Parameters</c>).
/// </summary>
public enum ScriptKind
{
Template,
InboundApi
}
public record DiagnoseRequest(
string Code,
IReadOnlyList<string>? DeclaredParameters = null,
IReadOnlyList<ScriptShape>? SiblingScripts = null,
IReadOnlyList<AttributeShape>? SelfAttributes = null,
IReadOnlyList<CompositionContext>? Children = null,
CompositionContext? Parent = null);
CompositionContext? Parent = null,
ScriptKind Kind = ScriptKind.Template);
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
@@ -31,7 +44,8 @@ public record CompletionsRequest(
IReadOnlyList<ScriptShape>? SiblingScripts = null,
IReadOnlyList<AttributeShape>? SelfAttributes = null,
IReadOnlyList<CompositionContext>? Children = null,
CompositionContext? Parent = null);
CompositionContext? Parent = null,
ScriptKind Kind = ScriptKind.Template);
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);

View File

@@ -31,6 +31,9 @@ public static class ScriptAnalysisEndpoints
group.MapPost("/inlay-hints", (InlayHintsRequest req, ScriptAnalysisService svc) =>
Results.Ok(svc.InlayHints(req)));
group.MapPost("/run", async (SandboxRunRequest req, ScriptAnalysisService svc, HttpContext http) =>
Results.Ok(await svc.RunInSandboxAsync(req, http.RequestAborted)));
return endpoints;
}
}

View File

@@ -1,5 +1,8 @@
using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Scripting;
@@ -7,13 +10,16 @@ using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Compiles user scripts as Roslyn C# Scripting fragments against
/// <see cref="ScriptHost"/> globals and surfaces diagnostics + completions
/// in the shape Monaco's provider APIs expect.
/// <see cref="SandboxScriptHost"/> globals (template/shared) or
/// <see cref="InboundScriptHost"/> (inbound API) and surfaces diagnostics +
/// completions in the shape Monaco's provider APIs expect.
///
/// Diagnostics are cached by code hash via IMemoryCache — Monaco debounces
/// keystrokes at 500 ms but a typing-then-pausing flow can still re-issue
@@ -23,9 +29,10 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
///
/// Beyond plain C# analysis, layers SCADA-specific extensions:
/// - In-string completion of Parameters["..."] keys (from the request's
/// DeclaredParameters), CallShared("...") names (from
/// <see cref="ISharedScriptCatalog"/>), and CallScript("...") names
/// (from the request's SiblingScripts).
/// DeclaredParameters), Scripts.CallShared("...") names (from
/// <see cref="ISharedScriptCatalog"/>), and Instance.CallScript("...") /
/// Children["X"].CallScript("...") / Parent.CallScript("...") names
/// (from the request's SiblingScripts / Children / Parent).
/// - Forbidden-API diagnostic for the documented script trust model,
/// resolved against the SemanticModel so user identifiers that happen
/// to share names with forbidden types (e.g. <c>var File = ...</c>)
@@ -39,7 +46,9 @@ public class ScriptAnalysisService
typeof(Enumerable).Assembly,
typeof(System.Collections.Generic.Dictionary<,>).Assembly,
typeof(System.ComponentModel.DescriptionAttribute).Assembly,
typeof(ScriptHost).Assembly)
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
typeof(Commons.Types.ScriptParameters).Assembly,
typeof(SandboxScriptHost).Assembly)
.AddImports(
"System",
"System.Collections.Generic",
@@ -61,26 +70,46 @@ public class ScriptAnalysisService
private readonly ISharedScriptCatalog _sharedScripts;
private readonly IMemoryCache _cache;
private readonly IServiceProvider _services;
public ScriptAnalysisService(ISharedScriptCatalog sharedScripts, IMemoryCache cache)
public ScriptAnalysisService(
ISharedScriptCatalog sharedScripts,
IMemoryCache cache,
IServiceProvider services)
{
_sharedScripts = sharedScripts;
_cache = cache;
_services = services;
}
/// <summary>Globals type a script of the given kind is compiled against.</summary>
private static Type GlobalsTypeFor(ScriptKind kind) =>
kind == ScriptKind.InboundApi ? typeof(InboundScriptHost) : typeof(SandboxScriptHost);
/// <summary>
/// Re-enables the nullable annotation context for an analysis compilation.
/// Roslyn scripting defaults to a disabled nullable context, which makes any
/// <c>?</c> annotation in a user script raise CS8632. Annotations-only keeps
/// <c>string?</c> legal without surfacing the nullable-flow warnings.
/// </summary>
private static Compilation WithNullableAnnotations(Compilation compilation) =>
compilation is CSharpCompilation cs
? cs.WithOptions(cs.Options.WithNullableContextOptions(NullableContextOptions.Annotations))
: compilation;
public DiagnoseResponse Diagnose(DiagnoseRequest request)
{
if (string.IsNullOrEmpty(request.Code))
return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
var cacheKey = "diag:" + HashCode(request.Code);
var cacheKey = "diag:" + (int)request.Kind + ":" + HashCode(request.Code);
if (_cache.TryGetValue(cacheKey, out DiagnoseResponse? cached) && cached is not null)
return cached;
Script<object> script;
try
{
script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: typeof(ScriptHost));
script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind));
}
catch (Exception ex)
{
@@ -91,7 +120,7 @@ public class ScriptAnalysisService
return Cache(cacheKey, failure);
}
var compilation = script.GetCompilation();
var compilation = WithNullableAnnotations(script.GetCompilation());
var markers = compilation
.GetDiagnostics()
.Where(d => d.Severity >= DiagnosticSeverity.Info && d.Location.IsInSource)
@@ -104,8 +133,6 @@ public class ScriptAnalysisService
var model = compilation.GetSemanticModel(tree);
markers.AddRange(FindForbiddenApiUsages(tree, model));
markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters));
markers.AddRange(FindArgumentCountMismatches(tree, request.SiblingScripts));
markers.AddRange(FindArgumentTypeMismatches(tree, request.SiblingScripts));
markers.AddRange(FindUnknownAttributeKeys(tree, request));
markers.AddRange(FindUnknownChildren(tree, request.Children));
}
@@ -113,6 +140,341 @@ public class ScriptAnalysisService
return Cache(cacheKey, new DiagnoseResponse(markers));
}
private const int SandboxMaxTimeoutSeconds = 10;
private const int SandboxDefaultTimeoutSeconds = 5;
private const int SandboxMaxConsoleChars = 32_000;
private const int SandboxMaxReturnJsonChars = 32_000;
private const int SandboxMaxCallSharedDepth = 16;
/// <summary>
/// Compiles and runs a script in the central process. The globals surface
/// depends on <see cref="SandboxRunRequest.Kind"/>: template and shared
/// scripts run against <see cref="SandboxScriptHost"/>, inbound API method
/// scripts against <see cref="SandboxInboundScriptHost"/>.
/// Pure logic + the supplied Parameters always work.
/// For the SandboxScriptHost surface, <c>Attributes</c> still throws while
/// <c>External</c>, <c>Database</c>, and <c>Notify</c> are wired to
/// central's real <see cref="IExternalSystemClient"/>,
/// <see cref="IDatabaseGateway"/>, and
/// <see cref="INotificationDeliveryService"/> — calls fire for real and
/// have production-equivalent side effects (HTTP, SQL, SMTP).
/// <c>CallShared</c> compiles and executes the named shared script in the
/// same sandbox, with a recursion limit of
/// <see cref="SandboxMaxCallSharedDepth"/>. <c>CallScript</c> still throws
/// because a shared script has no template siblings in this context.
/// For the SandboxInboundScriptHost surface, every <c>Route</c> call throws
/// because cross-site routing needs a deployed site.
/// Console.Out / Console.Error are redirected per-call so writes from
/// the script land in the result.
/// </summary>
public async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Code))
{
return new SandboxRunResult(
Success: false,
ReturnValueJson: null,
ReturnTypeName: null,
ConsoleOutput: "",
Error: "Script code is empty.",
ErrorKind: SandboxErrorKind.CompileError,
DurationMs: 0,
Markers: Array.Empty<DiagnosticMarker>());
}
var timeoutSeconds = Math.Clamp(
request.TimeoutSeconds ?? SandboxDefaultTimeoutSeconds,
1, SandboxMaxTimeoutSeconds);
var options = DefaultOptions.WithReferences(DefaultOptions.MetadataReferences.Concat(new[]
{
Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(SandboxScriptHost).Assembly.Location)
}));
var globalsType = request.Kind == ScriptKind.InboundApi
? typeof(SandboxInboundScriptHost)
: typeof(SandboxScriptHost);
Script<object> script;
try
{
script = CSharpScript.Create(request.Code, options, globalsType: globalsType);
}
catch (Exception ex)
{
return new SandboxRunResult(false, null, null, "", ex.Message,
SandboxErrorKind.CompileError, 0,
new[] { new DiagnosticMarker(8, 1, 1, 1, 2, ex.Message, "SCRIPT_BUILD") });
}
var compileDiagnostics = script.Compile(ct);
var errorDiagnostics = compileDiagnostics
.Where(d => d.Severity == DiagnosticSeverity.Error && d.Location.IsInSource)
.ToList();
if (errorDiagnostics.Count > 0)
{
var markers = errorDiagnostics.Select(ToMarker).ToList();
return new SandboxRunResult(false, null, null, "",
string.Join("\n", errorDiagnostics.Select(d => d.GetMessage())),
SandboxErrorKind.CompileError, 0, markers);
}
var parameters = ConvertJsonParameters(request.Parameters);
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
// Optional instance binding: when the Test Run targets a deployed
// instance, Instance.GetAttribute/SetAttribute/CallScript and the
// Attributes/Children/Parent accessors route to it cross-site.
ISandboxInstanceGateway? instanceGateway = null;
var instanceLabel = "test-run";
if (request.Kind != ScriptKind.InboundApi
&& !string.IsNullOrWhiteSpace(request.BindInstanceUniqueName))
{
var bindName = request.BindInstanceUniqueName.Trim();
var locator = _services.GetService<IInstanceLocator>();
var comms = _services.GetService<ScadaLink.Communication.CommunicationService>();
if (locator == null || comms == null)
return new SandboxRunResult(false, null, null, "",
"Instance binding is unavailable — cross-site communication is not configured on this node.",
SandboxErrorKind.SandboxLimitation, 0, null);
var siteId = await locator.GetSiteIdForInstanceAsync(bindName, ct);
if (siteId == null)
return new SandboxRunResult(false, null, null, "",
$"Cannot bind to instance '{bindName}' — it is not deployed or has no assigned site.",
SandboxErrorKind.SandboxLimitation, 0, null);
instanceGateway = new SandboxInstanceGateway(comms, siteId, bindName, linkedCts.Token);
instanceLabel = bindName;
}
var externalClient = _services.GetService<IExternalSystemClient>();
var databaseGateway = _services.GetService<IDatabaseGateway>();
var notifyService = _services.GetService<INotificationDeliveryService>();
var external = new SandboxExternalHelper(externalClient, instanceLabel);
var database = new SandboxDatabaseHelper(databaseGateway, instanceLabel);
var notify = new SandboxNotifyHelper(notifyService, instanceLabel);
var compileCache = new Dictionary<string, Script<object>>(StringComparer.Ordinal);
var compileCacheLock = new object();
var depth = 0;
Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? callSharedFunc = null;
// Scripts.CallShared and the Instance helpers share one context across
// the root script and any nested shared scripts — mirroring the site
// runtime, where a shared script runs against the caller's Instance.
var scriptsHelper = new SandboxScriptCallHelper(
(name, ps, nestedCt) => callSharedFunc!(name, ps, nestedCt));
var instanceContext = new SandboxInstanceContext(
gateway: instanceGateway,
external: external,
database: database,
notify: notify,
scripts: scriptsHelper);
callSharedFunc = async (name, ps, nestedCt) =>
{
if (string.IsNullOrEmpty(name))
throw new ScriptSandboxException("Scripts.CallShared called with an empty script name.");
if (depth >= SandboxMaxCallSharedDepth)
throw new ScriptSandboxException(
$"Scripts.CallShared(\"{name}\") exceeded the sandbox recursion limit of {SandboxMaxCallSharedDepth} nested calls.");
Script<object>? compiled;
lock (compileCacheLock) compileCache.TryGetValue(name, out compiled);
if (compiled == null)
{
var src = await _sharedScripts.GetByNameAsync(name, nestedCt);
if (src == null)
throw new ScriptSandboxException(
$"Scripts.CallShared(\"{name}\") — no shared script with that name is registered in central.");
Script<object> built;
try
{
built = CSharpScript.Create(src.Code, options, globalsType: typeof(SandboxScriptHost));
}
catch (Exception ex)
{
throw new ScriptSandboxException($"Scripts.CallShared(\"{name}\") compile failed: {ex.Message}");
}
var nestedDiag = built.Compile(nestedCt);
var nestedErrors = nestedDiag
.Where(d => d.Severity == DiagnosticSeverity.Error && d.Location.IsInSource)
.ToList();
if (nestedErrors.Count > 0)
throw new ScriptSandboxException(
$"Scripts.CallShared(\"{name}\") compile failed: {string.Join("; ", nestedErrors.Select(d => d.GetMessage()))}");
lock (compileCacheLock)
{
if (!compileCache.TryGetValue(name, out compiled))
{
compileCache[name] = built;
compiled = built;
}
}
}
var nestedHost = new SandboxScriptHost
{
Parameters = new Commons.Types.ScriptParameters(ps ?? new Dictionary<string, object?>()),
CancellationToken = nestedCt,
Instance = instanceContext,
};
Interlocked.Increment(ref depth);
try
{
var nestedState = await compiled!.RunAsync(nestedHost, nestedCt).ConfigureAwait(false);
return nestedState.ReturnValue;
}
finally
{
Interlocked.Decrement(ref depth);
}
};
// Inbound API scripts see a different globals surface (Parameters +
// Route); template and shared scripts see the SandboxScriptHost surface
// mirroring the site runtime's ScriptGlobals.
object host = request.Kind == ScriptKind.InboundApi
? new SandboxInboundScriptHost
{
Parameters = new Commons.Types.ScriptParameters(parameters),
CancellationToken = linkedCts.Token,
}
: new SandboxScriptHost
{
Parameters = new Commons.Types.ScriptParameters(parameters),
CancellationToken = linkedCts.Token,
Instance = instanceContext,
};
var originalOut = Console.Out;
var originalError = Console.Error;
var captured = new StringWriter();
var stopwatch = Stopwatch.StartNew();
try
{
Console.SetOut(captured);
Console.SetError(captured);
// Run on a thread-pool thread with no SynchronizationContext: a
// bound script's Instance.SetAttribute / Attributes[...] block
// synchronously on cross-site I/O (the API surface is sync by
// contract), which would deadlock against the Blazor circuit's
// captured context if the script ran inline.
var state = await Task.Run(
() => script.RunAsync(host, linkedCts.Token), linkedCts.Token)
.ConfigureAwait(false);
stopwatch.Stop();
var (returnJson, returnType) = SerializeReturn(state.ReturnValue);
return new SandboxRunResult(
Success: true,
ReturnValueJson: returnJson,
ReturnTypeName: returnType,
ConsoleOutput: TruncateConsole(captured.ToString()),
Error: null,
ErrorKind: SandboxErrorKind.None,
DurationMs: stopwatch.ElapsedMilliseconds,
Markers: null);
}
catch (ScriptSandboxException sandboxEx)
{
stopwatch.Stop();
return new SandboxRunResult(false, null, null,
TruncateConsole(captured.ToString()), sandboxEx.Message,
SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
stopwatch.Stop();
return new SandboxRunResult(false, null, null,
TruncateConsole(captured.ToString()),
$"Script execution exceeded the {timeoutSeconds}-second sandbox timeout.",
SandboxErrorKind.Timeout, stopwatch.ElapsedMilliseconds, null);
}
catch (Exception ex)
{
stopwatch.Stop();
var inner = ex is Microsoft.CodeAnalysis.Scripting.CompilationErrorException ? ex : (ex.InnerException ?? ex);
if (inner is ScriptSandboxException sx)
{
return new SandboxRunResult(false, null, null,
TruncateConsole(captured.ToString()), sx.Message,
SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
}
return new SandboxRunResult(false, null, null,
TruncateConsole(captured.ToString()),
$"{inner.GetType().Name}: {inner.Message}",
SandboxErrorKind.RuntimeError, stopwatch.ElapsedMilliseconds, null);
}
finally
{
Console.SetOut(originalOut);
Console.SetError(originalError);
}
}
private static Dictionary<string, object?> ConvertJsonParameters(
Dictionary<string, JsonElement>? parameters)
{
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
if (parameters == null) return result;
foreach (var (key, value) in parameters)
{
result[key] = JsonElementToObject(value);
}
return result;
}
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.Undefined => null,
JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(),
JsonValueKind.Object => element.EnumerateObject()
.ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
_ => null
};
}
private static (string Json, string TypeName) SerializeReturn(object? value)
{
if (value == null) return ("null", "null");
var typeName = value.GetType().Name;
try
{
var json = JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true });
if (json.Length > SandboxMaxReturnJsonChars)
json = json[..SandboxMaxReturnJsonChars] + "\n… (truncated)";
return (json, typeName);
}
catch (Exception ex)
{
return ($"\"<unserializable: {ex.Message}>\"", typeName);
}
}
private static string TruncateConsole(string text)
{
if (text.Length <= SandboxMaxConsoleChars) return text;
return text[..SandboxMaxConsoleChars] + "\n… (truncated)";
}
private DiagnoseResponse Cache(string key, DiagnoseResponse value)
{
_cache.Set(key, value, new MemoryCacheEntryOptions
@@ -137,7 +499,7 @@ public class ScriptAnalysisService
Script<object> script;
try
{
script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: typeof(ScriptHost));
script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind));
}
catch
{
@@ -242,54 +604,32 @@ public class ScriptAnalysisService
}
}
// CallShared("...") / CallScript("...") / Children["X"].CallScript("...") / Parent.CallScript("...")
// Scripts.CallShared("...") / Instance.CallScript("...") /
// Children["X"].CallScript("...") / Parent.CallScript("...")
if (owner is InvocationExpressionSyntax inv)
{
var calleeIdName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
var calleeMa = inv.Expression as MemberAccessExpressionSyntax;
var calleeName = calleeIdName ?? calleeMa?.Name.Identifier.ValueText;
if (calleeName == "CallShared")
var call = ClassifyScriptCall(inv);
switch (call.Kind)
{
var shapes = await _sharedScripts.GetShapesAsync();
return shapes.Select(s => MakeCallCompletion(s, "shared script")).ToList();
}
if (calleeName == "CallScript")
{
// Children["X"].CallScript("..." or Parent.CallScript("...
if (calleeMa != null)
case ScriptCallKind.Shared:
{
// Children["X"].CallScript
if (calleeMa.Expression is ElementAccessExpressionSyntax childElem
&& childElem.Expression is IdentifierNameSyntax cid
&& cid.Identifier.ValueText == "Children"
&& childElem.ArgumentList.Arguments.Count == 1
&& childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit
&& cLit.IsKind(SyntaxKind.StringLiteralExpression))
{
var compName = cLit.Token.ValueText;
var comp = (request.Children ?? Array.Empty<CompositionContext>())
.FirstOrDefault(c => c.Name == compName);
if (comp != null)
return comp.Scripts.Select(s => MakeCallCompletion(s, $"script on {compName}")).ToList();
return new List<CompletionItem>();
}
// Parent.CallScript
if (calleeMa.Expression is IdentifierNameSyntax pid
&& pid.Identifier.ValueText == "Parent"
&& request.Parent != null)
{
return request.Parent.Scripts
.Select(s => MakeCallCompletion(s, "parent script"))
.ToList();
}
var shapes = await _sharedScripts.GetShapesAsync();
return shapes.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
}
case ScriptCallKind.Sibling:
return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
case ScriptCallKind.Parent:
return (request.Parent?.Scripts ?? Array.Empty<ScriptShape>())
.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
case ScriptCallKind.Child:
{
var comp = (request.Children ?? Array.Empty<CompositionContext>())
.FirstOrDefault(c => c.Name == call.CompositionName);
return comp != null
? comp.Scripts.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList()
: new List<CompletionItem>();
}
// Plain CallScript("...") — siblings
return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
.Select(s => MakeCallCompletion(s, "sibling script"))
.ToList();
}
}
@@ -298,24 +638,25 @@ public class ScriptAnalysisService
/// <summary>
/// Builds a Monaco snippet that fills the call after the name, e.g.
/// <c>Greet", ${1:name}, ${2:count})</c>. The JS provider extends the
/// completion range over the auto-closed <c>")</c> if Monaco inserted
/// one, so the snippet replaces the rest of the call cleanly.
/// <c>Greet", new { name = ${1:name}, count = ${2:count} })</c>. The JS
/// provider extends the completion range over the auto-closed <c>")</c> if
/// Monaco inserted one, so the snippet replaces the rest of the call cleanly.
/// </summary>
private static CompletionItem MakeCallCompletion(ScriptShape shape, string detail)
{
// The runtime call API takes the arguments as an anonymous object; the
// snippet emits one member per declared parameter.
string insertText;
int insertRules;
const int insertAsSnippet = 4;
if (shape.Parameters.Count == 0)
{
insertText = shape.Name + "\")";
insertRules = 4;
}
else
{
var args = string.Join(", ", shape.Parameters.Select((p, i) => $"${{{i + 1}:{p.Name}}}"));
insertText = $"{shape.Name}\", {args})";
insertRules = 4;
var entries = string.Join(", ", shape.Parameters.Select((p, i) =>
$"{p.Name} = ${{{i + 1}:{p.Name}}}"));
insertText = $"{shape.Name}\", new {{ {entries} }})";
}
var paramList = string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}"));
var returnType = shape.ReturnType ?? "void";
@@ -324,7 +665,7 @@ public class ScriptAnalysisService
InsertText: insertText,
Detail: $"{detail} ({paramList}) -> {returnType}",
Kind: "Method",
InsertTextRules: insertRules);
InsertTextRules: insertAsSnippet);
}
public FormatResponse Format(FormatRequest request)
@@ -348,51 +689,14 @@ public class ScriptAnalysisService
}
}
public InlayHintsResponse InlayHints(InlayHintsRequest request)
{
if (string.IsNullOrEmpty(request.Code))
return new InlayHintsResponse(Array.Empty<InlayHint>());
var script = TryParse(request.Code);
if (script == null) return new InlayHintsResponse(Array.Empty<InlayHint>());
var (tree, _) = script.Value;
IReadOnlyList<ScriptShape>? sharedShapes = null;
IReadOnlyList<ScriptShape> SharedShapes() =>
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
var hints = new List<InlayHint>();
foreach (var inv in tree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>())
{
var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
if (callee is not ("CallShared" or "CallScript")) continue;
if (inv.ArgumentList.Arguments.Count < 1) continue;
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue;
var scriptName = nameArg.Token.ValueText;
if (string.IsNullOrEmpty(scriptName)) continue;
ScriptShape? shape = callee == "CallShared"
? SharedShapes().FirstOrDefault(s => s.Name == scriptName)
: request.SiblingScripts?.FirstOrDefault(s => s.Name == scriptName);
if (shape == null) continue;
for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++)
{
var arg = inv.ArgumentList.Arguments[i];
var p = shape.Parameters[i - 1];
var pos = arg.Span.Start;
var lineSpan = tree.GetLineSpan(new TextSpan(pos, 0)).Span;
hints.Add(new InlayHint(
Line: lineSpan.Start.Line + 1,
Column: lineSpan.Start.Character + 1,
Label: $"{p.Name}:"));
}
}
return new InlayHintsResponse(hints);
}
/// <summary>
/// Parameter-name inlay hints are obsolete under the runtime call API:
/// Scripts.CallShared / Instance.CallScript pass arguments as an explicit
/// <c>IReadOnlyDictionary</c> literal (<c>{ ["p"] = … }</c>), which is
/// already self-labelling — there are no positional arguments to annotate.
/// </summary>
public InlayHintsResponse InlayHints(InlayHintsRequest request) =>
new(Array.Empty<InlayHint>());
public HoverResponse Hover(HoverRequest request)
{
@@ -429,19 +733,15 @@ public class ScriptAnalysisService
if (owner is not InvocationExpressionSyntax inv) return new HoverResponse(null);
var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
var call = ClassifyScriptCall(inv);
if (call.Kind == ScriptCallKind.None) return new HoverResponse(null);
var rawName = token.ValueText;
if (string.IsNullOrEmpty(rawName)) return new HoverResponse(null);
ScriptShape? shape = null;
if (calleeName == "CallShared")
shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
.FirstOrDefault(s => s.Name == rawName);
else if (calleeName == "CallScript" && request.SiblingScripts != null)
shape = request.SiblingScripts.FirstOrDefault(s => s.Name == rawName);
var shape = ResolveCalledShape(
call, rawName, request.SiblingScripts, request.Children, request.Parent);
if (shape == null) return new HoverResponse(null);
return new HoverResponse(FormatHover(shape, calleeName!));
return new HoverResponse(FormatHover(shape, call));
}
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest request)
@@ -471,24 +771,20 @@ public class ScriptAnalysisService
}
if (inv == null) return empty;
var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
if (calleeName is not ("CallShared" or "CallScript")) return empty;
var call = ClassifyScriptCall(inv);
if (call.Kind == ScriptCallKind.None) return empty;
// First argument is the name literal; pull it out.
if (inv.ArgumentList.Arguments.Count < 1) return empty;
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
var scriptName = nameArg?.Token.ValueText ?? "";
ScriptShape? shape = null;
if (calleeName == "CallShared")
shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
.FirstOrDefault(s => s.Name == scriptName);
else if (request.SiblingScripts != null)
shape = request.SiblingScripts.FirstOrDefault(s => s.Name == scriptName);
var shape = ResolveCalledShape(
call, scriptName, request.SiblingScripts, request.Children, request.Parent);
if (shape == null) return empty;
var paramLabels = shape.Parameters.Select(p => $"{p.Name}: {p.Type}").ToList();
var label = $"{calleeName}(\"{shape.Name}\"" +
var label = $"{CallLabel(call)}(\"{shape.Name}\"" +
(paramLabels.Count > 0 ? ", " + string.Join(", ", paramLabels) : "") + ")";
// ActiveParameter: count commas in ArgumentList before the cursor; subtract 1 because
@@ -514,7 +810,7 @@ public class ScriptAnalysisService
if (string.IsNullOrEmpty(code)) return null;
try
{
var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(ScriptHost));
var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(SandboxScriptHost));
var compilation = s.GetCompilation();
var tree = compilation.SyntaxTrees.FirstOrDefault();
return tree == null ? null : (tree, compilation);
@@ -525,14 +821,13 @@ public class ScriptAnalysisService
}
}
private static string FormatHover(ScriptShape shape, string callee)
private static string FormatHover(ScriptShape shape, ScriptCallInfo call)
{
var ps = shape.Parameters.Count == 0
? "(no parameters)"
: string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}{(p.Required ? "" : "?")}"));
var rt = shape.ReturnType ?? "void";
var kind = callee == "CallShared" ? "shared script" : "sibling script";
return $"**{kind}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```";
return $"**{CallDetail(call)}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```";
}
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
@@ -583,52 +878,85 @@ public class ScriptAnalysisService
}
}
private IEnumerable<DiagnosticMarker> FindArgumentCountMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
private enum ScriptCallKind { None, Shared, Sibling, Child, Parent }
/// <summary>A classified script-call invocation: which kind, and (for a child) the composition name.</summary>
private readonly record struct ScriptCallInfo(ScriptCallKind Kind, string? CompositionName);
/// <summary>
/// Classifies an invocation against the runtime call surface:
/// <c>Scripts.CallShared(...)</c>, <c>Instance.CallScript(...)</c>,
/// <c>Children["X"].CallScript(...)</c>, and <c>Parent.CallScript(...)</c>.
/// The first argument of each is the called script's name literal.
/// </summary>
private static ScriptCallInfo ClassifyScriptCall(InvocationExpressionSyntax inv)
{
var root = tree.GetRoot();
if (inv.Expression is not MemberAccessExpressionSyntax ma)
return new ScriptCallInfo(ScriptCallKind.None, null);
IReadOnlyList<ScriptShape>? sharedShapes = null;
IReadOnlyList<ScriptShape> SharedShapes() =>
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
var method = ma.Name.Identifier.ValueText;
foreach (var inv in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
if (method == "CallShared"
&& ma.Expression is IdentifierNameSyntax sid && sid.Identifier.ValueText == "Scripts")
return new ScriptCallInfo(ScriptCallKind.Shared, null);
if (method == "CallScript")
{
var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
if (callee is not ("CallShared" or "CallScript")) continue;
if (inv.ArgumentList.Arguments.Count < 1) continue;
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue;
var scriptName = nameArg.Token.ValueText;
if (string.IsNullOrEmpty(scriptName)) continue;
ScriptShape? shape = callee == "CallShared"
? SharedShapes().FirstOrDefault(s => s.Name == scriptName)
: siblings?.FirstOrDefault(s => s.Name == scriptName);
if (shape == null) continue;
var passedCount = inv.ArgumentList.Arguments.Count - 1; // exclude name
var expectedRequired = shape.Parameters.Count(p => p.Required);
var expectedTotal = shape.Parameters.Count;
if (passedCount < expectedRequired || passedCount > expectedTotal)
if (ma.Expression is IdentifierNameSyntax iid)
{
var span = inv.GetLocation().GetLineSpan().Span;
var expected = expectedRequired == expectedTotal
? expectedTotal.ToString()
: $"{expectedRequired}{expectedTotal}";
yield return new DiagnosticMarker(
Severity: 8,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"{callee}('{scriptName}') expects {expected} argument(s) but got {passedCount}.",
Code: "SCADA004");
if (iid.Identifier.ValueText == "Instance")
return new ScriptCallInfo(ScriptCallKind.Sibling, null);
if (iid.Identifier.ValueText == "Parent")
return new ScriptCallInfo(ScriptCallKind.Parent, null);
}
if (ma.Expression is ElementAccessExpressionSyntax childElem
&& childElem.Expression is IdentifierNameSyntax cid && cid.Identifier.ValueText == "Children"
&& childElem.ArgumentList.Arguments.Count == 1
&& childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit
&& cLit.IsKind(SyntaxKind.StringLiteralExpression))
return new ScriptCallInfo(ScriptCallKind.Child, cLit.Token.ValueText);
}
return new ScriptCallInfo(ScriptCallKind.None, null);
}
/// <summary>Human-readable call expression, e.g. <c>Scripts.CallShared</c>.</summary>
private static string CallLabel(ScriptCallInfo call) => call.Kind switch
{
ScriptCallKind.Shared => "Scripts.CallShared",
ScriptCallKind.Sibling => "Instance.CallScript",
ScriptCallKind.Parent => "Parent.CallScript",
ScriptCallKind.Child => $"Children[\"{call.CompositionName}\"].CallScript",
_ => "call"
};
/// <summary>Short description of what the call targets, for completions/hover.</summary>
private static string CallDetail(ScriptCallInfo call) => call.Kind switch
{
ScriptCallKind.Shared => "shared script",
ScriptCallKind.Sibling => "sibling script",
ScriptCallKind.Parent => "parent script",
ScriptCallKind.Child => $"script on {call.CompositionName}",
_ => "script"
};
/// <summary>Resolves the called script's shape from the metadata in scope for its kind.</summary>
private ScriptShape? ResolveCalledShape(
ScriptCallInfo call,
string scriptName,
IReadOnlyList<ScriptShape>? siblings,
IReadOnlyList<CompositionContext>? children,
CompositionContext? parent) => call.Kind switch
{
ScriptCallKind.Shared => _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
.FirstOrDefault(s => s.Name == scriptName),
ScriptCallKind.Sibling => siblings?.FirstOrDefault(s => s.Name == scriptName),
ScriptCallKind.Parent => parent?.Scripts.FirstOrDefault(s => s.Name == scriptName),
ScriptCallKind.Child => children?.FirstOrDefault(c => c.Name == call.CompositionName)
?.Scripts.FirstOrDefault(s => s.Name == scriptName),
_ => null
};
/// <summary>
/// SCADA006 — flag <c>Attributes["typo"]</c>,
/// <c>Children["X"].Attributes["typo"]</c>, and
@@ -758,112 +1086,6 @@ public class ScriptAnalysisService
return new(AttributeContextKind.None, null);
}
private IEnumerable<DiagnosticMarker> FindArgumentTypeMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
{
var root = tree.GetRoot();
IReadOnlyList<ScriptShape>? sharedShapes = null;
IReadOnlyList<ScriptShape> SharedShapes() =>
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
foreach (var inv in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
{
var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
if (callee is not ("CallShared" or "CallScript")) continue;
if (inv.ArgumentList.Arguments.Count < 1) continue;
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue;
var scriptName = nameArg.Token.ValueText;
if (string.IsNullOrEmpty(scriptName)) continue;
ScriptShape? shape = callee == "CallShared"
? SharedShapes().FirstOrDefault(s => s.Name == scriptName)
: siblings?.FirstOrDefault(s => s.Name == scriptName);
if (shape == null) continue;
for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++)
{
var arg = inv.ArgumentList.Arguments[i].Expression;
var p = shape.Parameters[i - 1];
var literalType = LiteralTypeOf(arg);
if (literalType == null) continue; // Not a literal we can check.
if (TypeAccepts(p.Type, literalType.Value)) continue;
var span = arg.GetLocation().GetLineSpan().Span;
yield return new DiagnosticMarker(
Severity: 8,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"Argument {i} of {callee}('{scriptName}') expects {p.Type} but got {literalType}.",
Code: "SCADA005");
}
}
}
private enum LiteralKind { String, Integer, Float, Boolean, Null }
private static LiteralKind? LiteralTypeOf(ExpressionSyntax expr)
{
if (expr is LiteralExpressionSyntax lit)
{
if (lit.IsKind(SyntaxKind.StringLiteralExpression)) return LiteralKind.String;
if (lit.IsKind(SyntaxKind.TrueLiteralExpression) || lit.IsKind(SyntaxKind.FalseLiteralExpression))
return LiteralKind.Boolean;
if (lit.IsKind(SyntaxKind.NullLiteralExpression)) return LiteralKind.Null;
if (lit.IsKind(SyntaxKind.NumericLiteralExpression))
{
var text = lit.Token.Text;
return text.Contains('.') || text.EndsWith("f", StringComparison.OrdinalIgnoreCase)
|| text.EndsWith("d", StringComparison.OrdinalIgnoreCase)
? LiteralKind.Float
: LiteralKind.Integer;
}
}
if (expr is InterpolatedStringExpressionSyntax) return LiteralKind.String;
return null;
}
/// <summary>
/// True when a literal of <paramref name="literal"/> is acceptable for a
/// parameter declared as <paramref name="declared"/>. Object/List always
/// accept (we don't introspect collection literals); Null is acceptable
/// for any non-value type.
/// </summary>
private static bool TypeAccepts(string declared, LiteralKind literal)
{
var d = NormalizeDeclaredType(declared);
if (literal == LiteralKind.Null) return d is "Object" or "List" or "String";
return d switch
{
"Boolean" => literal == LiteralKind.Boolean,
"Integer" => literal == LiteralKind.Integer,
"Float" => literal is LiteralKind.Float or LiteralKind.Integer,
"String" => literal == LiteralKind.String,
"Object" or "List" => true,
_ => true // unknown SCADA type — assume compatible
};
}
/// <summary>
/// Normalizes legacy / .NET type names from stored ParameterDefinitions
/// JSON to the canonical Inbound API set. Mirrors the frontend
/// ParameterListEditor's normalization so SCADA005 doesn't false-negative
/// on data still in the legacy shape.
/// </summary>
private static string NormalizeDeclaredType(string declared) =>
declared.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",
_ => declared
};
private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
{
var root = tree.GetRoot();

View File

@@ -1,53 +0,0 @@
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Globals type seen by user scripts during analysis. Mirrors the surface
/// the runtime exposes (see ScadaLink.SiteRuntime.Scripts.ScriptGlobals).
/// The methods and indexers here are never invoked — Roslyn only reads
/// their signatures to know what's in scope while compiling for diagnostics
/// and completions.
/// </summary>
public class ScriptHost
{
public IReadOnlyDictionary<string, object?> Parameters { get; init; } =
new Dictionary<string, object?>();
/// <summary>Invokes another shared script by name and returns its result.</summary>
public object? CallShared(string name, params object?[] args) => null;
/// <summary>Invokes another script on the same template and returns its result.</summary>
public object? CallScript(string name, params object?[] args) => null;
// Scope-aware accessors. SCADA-specific completion + diagnostics live in
// ScriptAnalysisService; these stubs exist so the bare Roslyn pass doesn't
// produce CS0103 errors on Attributes / Children / Parent.
public AttributeBag Attributes { get; } = new();
public ChildrenBag Children { get; } = new();
public CompositionBag? Parent { get; } = new();
public class AttributeBag
{
public object? this[string name]
{
get => null;
set { /* no-op for analyzer */ }
}
public System.Threading.Tasks.Task<object?> GetAsync(string name) =>
System.Threading.Tasks.Task.FromResult<object?>(null);
public System.Threading.Tasks.Task SetAsync(string name, object? value) =>
System.Threading.Tasks.Task.CompletedTask;
}
public class CompositionBag
{
public AttributeBag Attributes { get; } = new();
public System.Threading.Tasks.Task<object?> CallScript(string name, params object?[] args) =>
System.Threading.Tasks.Task.FromResult<object?>(null);
}
public class ChildrenBag
{
public CompositionBag this[string compositionName] => new();
}
}

View File

@@ -4,10 +4,23 @@
.sidebar {
min-width: 220px;
max-width: 220px;
min-height: 100vh;
height: 100vh;
background-color: var(--bs-dark);
}
/* Keep the sidebar pinned to the viewport on lg+ so it stays visible even
when the main content scrolls past 100vh. The wrapper is the flex child
of MainLayout; align-self prevents the flex row from stretching it. */
@media (min-width: 992px) {
#sidebar-collapse {
position: sticky;
top: 0;
height: 100vh;
align-self: flex-start;
z-index: 1020;
}
}
.sidebar .nav-link {
color: var(--bs-gray-500);
padding: 0.4rem 1rem;
@@ -51,7 +64,7 @@
.sidebar {
min-width: 100%;
max-width: 100%;
min-height: auto;
height: auto;
}
}

View File

@@ -40,20 +40,23 @@
async function lookupContext(model) {
const empty = {
declaredParameters: [], siblingScripts: [], declaredParameterShapes: [],
selfAttributes: [], children: [], parent: null
selfAttributes: [], children: [], parent: null, scriptKind: 0
};
for (const key in editors) {
if (editors[key].editor.getModel() === model) {
try {
const got = await editors[key].dotNetRef.invokeMethodAsync("GetContext");
if (got) {
const kind = got.ScriptKind != null ? got.ScriptKind
: (got.scriptKind != null ? got.scriptKind : 0);
return {
declaredParameters: got.DeclaredParameters || got.declaredParameters || [],
siblingScripts: got.SiblingScripts || got.siblingScripts || [],
declaredParameterShapes: got.DeclaredParameterShapes || got.declaredParameterShapes || [],
selfAttributes: got.SelfAttributes || got.selfAttributes || [],
children: got.Children || got.children || [],
parent: got.Parent || got.parent || null
parent: got.Parent || got.parent || null,
scriptKind: kind
};
}
} catch (e) { /* fall through */ }
@@ -82,7 +85,8 @@
siblingScripts: ctx.siblingScripts,
selfAttributes: ctx.selfAttributes,
children: ctx.children,
parent: ctx.parent
parent: ctx.parent,
kind: ctx.scriptKind
})
});
if (!resp.ok) return { suggestions: [] };
@@ -269,7 +273,8 @@
body: JSON.stringify({
code: model.getValue(),
declaredParameters: ctx.declaredParameters,
siblingScripts: ctx.siblingScripts
siblingScripts: ctx.siblingScripts,
kind: ctx.scriptKind
})
});
if (!resp.ok) return [];