Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/SharedScriptForm.razor
T
Joseph Doherty 7b0b9c7365 refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
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.
2026-05-28 09:37:45 -04:00

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">&larr; 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;
}
}