7b0b9c7365
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
370 lines
16 KiB
Plaintext
370 lines
16 KiB
Plaintext
@page "/design/shared-scripts/create"
|
|
@page "/design/shared-scripts/{Id:int}/edit"
|
|
@using ZB.MOM.WW.ScadaBridge.Security
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
|
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
|
|
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
|
@inject SharedScriptService SharedScriptService
|
|
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
|
|
@inject AuthenticationStateProvider AuthStateProvider
|
|
@inject NavigationManager NavigationManager
|
|
|
|
<div class="container-fluid mt-3">
|
|
<div class="d-flex align-items-center mb-3">
|
|
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back</button>
|
|
<h4 class="mb-0">@(Id.HasValue ? $"Edit Shared Script: {_formName}" : "New Shared Script")</h4>
|
|
</div>
|
|
|
|
@if (_loading)
|
|
{
|
|
<LoadingSpinner IsLoading="true" />
|
|
}
|
|
else
|
|
{
|
|
<div class="card mb-3">
|
|
<div class="card-body">
|
|
<div class="mb-3">
|
|
<label class="form-label small">Name</label>
|
|
<input type="text" class="form-control form-control-sm" @bind="_formName"
|
|
disabled="@(Id.HasValue)" />
|
|
</div>
|
|
|
|
@* Tabs: Code, Parameters, Return. Panels stay mounted (toggled
|
|
via display:none) so the Monaco editor and the JSONJoy React
|
|
island don't tear down on tab switch. *@
|
|
<ul class="nav nav-tabs" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button type="button"
|
|
class="nav-link @(_formTab == "code" ? "active" : "")"
|
|
role="tab"
|
|
aria-selected="@(_formTab == "code" ? "true" : "false")"
|
|
@onclick='() => _formTab = "code"'>Code</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button type="button"
|
|
class="nav-link @(_formTab == "parameters" ? "active" : "")"
|
|
role="tab"
|
|
aria-selected="@(_formTab == "parameters" ? "true" : "false")"
|
|
@onclick='() => _formTab = "parameters"'>Parameters</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button type="button"
|
|
class="nav-link @(_formTab == "return" ? "active" : "")"
|
|
role="tab"
|
|
aria-selected="@(_formTab == "return" ? "true" : "false")"
|
|
@onclick='() => _formTab = "return"'>Return type</button>
|
|
</li>
|
|
</ul>
|
|
<div class="border border-top-0 rounded-bottom p-3">
|
|
<div style="display: @(_formTab == "code" ? "block" : "none")">
|
|
<MonacoEditor @ref="_editor" Value="@_formCode" ValueChanged="@(v => _formCode = v)"
|
|
Language="csharp" Height="320px"
|
|
DeclaredParameters="@ScriptParameterNames.Parse(_formParameters)"
|
|
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_formParameters)"
|
|
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
|
|
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
|
|
</div>
|
|
<div style="display: @(_formTab == "parameters" ? "block" : "none")">
|
|
<SchemaBuilder Mode="object"
|
|
Value="@_formParameters"
|
|
ValueChanged="@(v => _formParameters = v)" />
|
|
</div>
|
|
<div style="display: @(_formTab == "return" ? "block" : "none")">
|
|
<SchemaBuilder Mode="value"
|
|
Value="@_formReturn"
|
|
ValueChanged="@(v => _formReturn = v)" />
|
|
</div>
|
|
</div>
|
|
|
|
@if (_formError != null)
|
|
{
|
|
<div class="text-danger small mt-2">@_formError</div>
|
|
}
|
|
@if (_syntaxCheckResult != null)
|
|
{
|
|
<div class="@(_syntaxCheckPassed ? "text-success" : "text-danger") small mt-1">@_syntaxCheckResult</div>
|
|
}
|
|
<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>
|
|
|
|
@code {
|
|
[Parameter] public int? Id { get; set; }
|
|
|
|
private bool _loading;
|
|
private string _formTab = "code"; // "code" | "parameters" | "return"
|
|
private string _formName = string.Empty;
|
|
private string _formCode = string.Empty;
|
|
private string? _formParameters;
|
|
private string? _formReturn;
|
|
private string? _formError;
|
|
private string? _syntaxCheckResult;
|
|
private bool _syntaxCheckPassed;
|
|
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;
|
|
|
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
|
private Task<string> GetCurrentUserAsync()
|
|
=> AuthStateProvider.GetCurrentUsernameAsync();
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
if (Id.HasValue)
|
|
{
|
|
_loading = true;
|
|
try
|
|
{
|
|
var scripts = await SharedScriptService.GetAllSharedScriptsAsync();
|
|
var script = scripts.FirstOrDefault(s => s.Id == Id.Value);
|
|
if (script != null)
|
|
{
|
|
_formName = script.Name;
|
|
_formCode = script.Code;
|
|
_formParameters = script.ParameterDefinitions;
|
|
_formReturn = script.ReturnDefinition;
|
|
}
|
|
else
|
|
{
|
|
_formError = $"Shared script with ID {Id.Value} not found.";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_formError = $"Failed to load script: {ex.Message}";
|
|
}
|
|
_loading = false;
|
|
}
|
|
}
|
|
|
|
private void GoBack()
|
|
{
|
|
NavigationManager.NavigateTo("/design/shared-scripts");
|
|
}
|
|
|
|
private void CheckCompilation()
|
|
{
|
|
var syntaxError = ValidateSyntaxLocally(_formCode);
|
|
if (syntaxError == null)
|
|
{
|
|
_syntaxCheckResult = "Syntax check passed.";
|
|
_syntaxCheckPassed = true;
|
|
}
|
|
else
|
|
{
|
|
_syntaxCheckResult = syntaxError;
|
|
_syntaxCheckPassed = false;
|
|
}
|
|
}
|
|
|
|
private async Task SaveScript()
|
|
{
|
|
_formError = null;
|
|
_syntaxCheckResult = null;
|
|
|
|
try
|
|
{
|
|
if (Id.HasValue)
|
|
{
|
|
var user = await GetCurrentUserAsync();
|
|
var result = await SharedScriptService.UpdateSharedScriptAsync(
|
|
Id.Value, _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
|
|
if (result.IsSuccess)
|
|
{
|
|
NavigationManager.NavigateTo("/design/shared-scripts");
|
|
}
|
|
else
|
|
{
|
|
_formError = result.Error;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var user = await GetCurrentUserAsync();
|
|
var result = await SharedScriptService.CreateSharedScriptAsync(
|
|
_formName.Trim(), _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
|
|
if (result.IsSuccess)
|
|
{
|
|
NavigationManager.NavigateTo("/design/shared-scripts");
|
|
}
|
|
else
|
|
{
|
|
_formError = result.Error;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_formError = $"Save failed: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
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.
|
|
/// </summary>
|
|
private static string? ValidateSyntaxLocally(string code)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(code)) return "Script code cannot be empty.";
|
|
int brace = 0, bracket = 0, paren = 0;
|
|
foreach (var ch in code)
|
|
{
|
|
switch (ch) { case '{': brace++; break; case '}': brace--; break; case '[': bracket++; break; case ']': bracket--; break; case '(': paren++; break; case ')': paren--; break; }
|
|
if (brace < 0) return "Syntax error: unmatched closing brace '}'.";
|
|
if (bracket < 0) return "Syntax error: unmatched closing bracket ']'.";
|
|
if (paren < 0) return "Syntax error: unmatched closing parenthesis ')'.";
|
|
}
|
|
if (brace != 0) return "Syntax error: unmatched opening brace '{'.";
|
|
if (bracket != 0) return "Syntax error: unmatched opening bracket '['.";
|
|
if (paren != 0) return "Syntax error: unmatched opening parenthesis '('.";
|
|
return null;
|
|
}
|
|
}
|