57c8973b0d
Cross-task regression caught by the combined sweep: TopologyLiveUpdatesTimerDisposeRaceTests (T12) builds a real DeploymentService, whose constructor grew an IStaleInstanceProbe parameter in T13 (P7-6 up-to-date pre-filter) — 4 DI-resolution failures on the integration branch that neither task saw in isolation. Registered a substitute, matching TopologyPageTests. Bundle review Minor: ApiMethodForm/SharedScriptForm copied TemplateScriptDialog's sandbox pattern exactly, whose finally re-renders unconditionally; the plan asked the continuation to be guarded on disposal, so a _disposed flag now suppresses the post-cancel StateHasChanged (the reference dialog keeps its original shape — out of T11's scope).
441 lines
20 KiB
Plaintext
441 lines
20 KiB
Plaintext
@page "/design/api-methods/create"
|
|
@page "/design/api-methods/{Id:int}/edit"
|
|
@using ZB.MOM.WW.ScadaBridge.Security
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
|
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
|
@inject IInboundApiRepository InboundApiRepository
|
|
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
|
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
|
|
@inject NavigationManager NavigationManager
|
|
@implements IDisposable
|
|
|
|
<div class="container-fluid mt-3">
|
|
<button class="btn btn-outline-secondary btn-sm mb-2" @onclick="GoBack">← Back</button>
|
|
|
|
<h4 class="mb-3">@(Id.HasValue ? "Edit API Method" : "Add API Method")</h4>
|
|
|
|
@if (_loading)
|
|
{
|
|
<LoadingSpinner IsLoading="true" />
|
|
}
|
|
else
|
|
{
|
|
<div class="card">
|
|
<div class="card-body">
|
|
@* The body used to stack six unrelated groups with nothing but a label
|
|
between them. Fieldset + legend gives each stack a real boundary —
|
|
same idiom as TransportExport. *@
|
|
<fieldset class="mb-4">
|
|
<legend class="h6">Method</legend>
|
|
<div class="mb-3">
|
|
<label class="form-label">Name</label>
|
|
<input type="text" class="form-control" @bind="_name" disabled="@Id.HasValue" />
|
|
</div>
|
|
<div class="mb-0">
|
|
<label class="form-label">Timeout (seconds)</label>
|
|
<input type="number" class="form-control" @bind="_timeoutSeconds" min="1" />
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-4">
|
|
<legend class="h6">Approved API Keys</legend>
|
|
<div class="mb-0">
|
|
@if (_allKeys.Count == 0)
|
|
{
|
|
<div class="form-text">
|
|
No API keys configured.
|
|
<a href="/admin/api-keys">Create one</a> to authorize callers for this method.
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
|
@foreach (var key in _allKeys)
|
|
{
|
|
var checkboxId = $"approved-key-{key.KeyId}";
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
|
checked="@_selectedKeyIds.Contains(key.KeyId)"
|
|
@onchange="e => ToggleKey(key.KeyId, (bool)e.Value!)" />
|
|
<label class="form-check-label" for="@checkboxId">
|
|
@key.Name
|
|
@if (!key.Enabled)
|
|
{
|
|
<span class="badge bg-secondary ms-1">Disabled</span>
|
|
}
|
|
</label>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="form-text">
|
|
Callers must present a checked key in the <code>X-API-Key</code> header to invoke this method.
|
|
</div>
|
|
}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-4">
|
|
<legend class="h6">Contract</legend>
|
|
<div class="mb-3">
|
|
<label class="form-label">Parameters</label>
|
|
<SchemaBuilder Mode="object"
|
|
Value="@_params"
|
|
ValueChanged="@(v => _params = v)" />
|
|
</div>
|
|
<div class="mb-0">
|
|
<label class="form-label">Return value</label>
|
|
<SchemaBuilder Mode="value"
|
|
Value="@_returns"
|
|
ValueChanged="@(v => _returns = v)" />
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="mb-4">
|
|
<legend class="h6">Script</legend>
|
|
<div class="mb-0">
|
|
<MonacoEditor @ref="_editor" Value="@_script" ValueChanged="@(v => _script = v)"
|
|
Language="csharp" Height="320px"
|
|
ScriptKind="ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.ScriptKind.InboundApi"
|
|
DeclaredParameters="@ScriptParameterNames.Parse(_params)"
|
|
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_params)"
|
|
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
|
|
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
|
|
</div>
|
|
</fieldset>
|
|
|
|
@if (_formError != null)
|
|
{
|
|
<div class="text-danger small mb-2">@_formError</div>
|
|
}
|
|
|
|
<div class="btn-group" role="group">
|
|
<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="card-body">
|
|
@* Lives inside the body, not between header and body, where it rendered
|
|
as a third band. Wording unchanged — pinned by TestRunWarningTests. *@
|
|
<div class="alert alert-warning py-1 mb-3 small">
|
|
<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="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-secondary-subtle text-secondary-emphasis ms-1">@_runResult.ReturnTypeName</span>
|
|
</label>
|
|
<pre class="bg-body-secondary 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 = true;
|
|
private string _name = "", _script = "";
|
|
private int _timeoutSeconds = 30;
|
|
private string? _params, _returns;
|
|
private string? _formError;
|
|
private MonacoEditor? _editor;
|
|
private IReadOnlyList<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers
|
|
= Array.Empty<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker>();
|
|
|
|
// Inbound-API key re-arch: the approved-keys list is driven by the IInboundApiKeyAdmin
|
|
// seam, not ApiMethod.ApprovedApiKeyIds. The ApiMethod entity itself (name/script/params/etc.)
|
|
// still lives on IInboundApiRepository — only the key↔method approval relationship moved to
|
|
// per-key method-scopes. Keys are identified by an opaque string KeyId.
|
|
private ApiMethod? _existing;
|
|
private List<InboundApiKeyInfo> _allKeys = new();
|
|
private HashSet<string> _selectedKeyIds = new(StringComparer.Ordinal);
|
|
// Keys approved for this method when the form loaded (empty on create), for diffing on save.
|
|
private HashSet<string> _initialKeyIds = new(StringComparer.Ordinal);
|
|
|
|
private bool _showTestRun;
|
|
private bool _running;
|
|
private Dictionary<string, object?> _paramValues = new();
|
|
private ScriptAnalysis.SandboxRunResult? _runResult;
|
|
private CancellationTokenSource? _runCts;
|
|
private bool _disposed;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
try
|
|
{
|
|
// All keys come from the seam (hash-free projection).
|
|
_allKeys = (await ApiKeyAdmin.ListAsync()).ToList();
|
|
}
|
|
catch (Exception ex) { _formError = ex.Message; }
|
|
|
|
if (Id.HasValue)
|
|
{
|
|
try
|
|
{
|
|
_existing = await InboundApiRepository.GetApiMethodByIdAsync(Id.Value);
|
|
if (_existing != null)
|
|
{
|
|
_name = _existing.Name;
|
|
_script = _existing.Script;
|
|
_timeoutSeconds = _existing.TimeoutSeconds;
|
|
_params = _existing.ParameterDefinitions;
|
|
_returns = _existing.ReturnDefinition;
|
|
// Seed approved keys from the seam: which keys' scopes contain this method.
|
|
var keysForMethod = await ApiKeyAdmin.GetKeysForMethodAsync(_existing.Name);
|
|
_initialKeyIds = new HashSet<string>(keysForMethod, StringComparer.Ordinal);
|
|
_selectedKeyIds = new HashSet<string>(_initialKeyIds, StringComparer.Ordinal);
|
|
}
|
|
}
|
|
catch (Exception ex) { _formError = ex.Message; }
|
|
}
|
|
_loading = false;
|
|
}
|
|
|
|
private void ToggleKey(string keyId, bool isChecked)
|
|
{
|
|
if (isChecked) _selectedKeyIds.Add(keyId);
|
|
else _selectedKeyIds.Remove(keyId);
|
|
}
|
|
|
|
private async Task Save()
|
|
{
|
|
_formError = null;
|
|
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_script))
|
|
{
|
|
_formError = "Name and script required.";
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Save the ApiMethod entity FIRST so the method Name exists before we reconcile
|
|
// key scopes against it. The method entity stays in SQL Server; we leave the
|
|
// (now-legacy) ApprovedApiKeyIds column untouched — it is dropped in C5.
|
|
string methodName;
|
|
if (_existing != null)
|
|
{
|
|
_existing.Script = _script;
|
|
_existing.TimeoutSeconds = _timeoutSeconds;
|
|
_existing.ParameterDefinitions = _params?.Trim();
|
|
_existing.ReturnDefinition = _returns?.Trim();
|
|
await InboundApiRepository.UpdateApiMethodAsync(_existing);
|
|
methodName = _existing.Name;
|
|
}
|
|
else
|
|
{
|
|
var m = new ApiMethod(_name.Trim(), _script)
|
|
{
|
|
TimeoutSeconds = _timeoutSeconds,
|
|
ParameterDefinitions = _params?.Trim(),
|
|
ReturnDefinition = _returns?.Trim()
|
|
};
|
|
await InboundApiRepository.AddApiMethodAsync(m);
|
|
methodName = m.Name;
|
|
}
|
|
await InboundApiRepository.SaveChangesAsync();
|
|
|
|
// Reconcile per-key method-scopes for the affected keys (added/removed vs. load time).
|
|
if (!await ReconcileKeyScopesAsync(methodName)) return;
|
|
|
|
NavigationManager.NavigateTo("/design/external-systems");
|
|
}
|
|
catch (Exception ex) { _formError = ex.Message; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pushes this method's name into / out of each affected key's scope set. Returns false
|
|
/// (leaving a form error) when a revoke would empty a key's scopes — the server rejects
|
|
/// empty scope sets, so we abort rather than push one. Scopes are read fresh per affected
|
|
/// key so we never clobber unrelated method-scopes.
|
|
/// </summary>
|
|
private async Task<bool> ReconcileKeyScopesAsync(string methodName)
|
|
{
|
|
var affected = _selectedKeyIds.Except(_initialKeyIds, StringComparer.Ordinal)
|
|
.Concat(_initialKeyIds.Except(_selectedKeyIds, StringComparer.Ordinal))
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
|
|
// Read each affected key's CURRENT full scope set so add/remove preserves other methods.
|
|
var currentMethodsByKey = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
|
foreach (var keyId in affected)
|
|
{
|
|
currentMethodsByKey[keyId] = await ApiKeyAdmin.GetMethodsForKeyAsync(keyId);
|
|
}
|
|
|
|
var keyNamesById = _allKeys.ToDictionary(k => k.KeyId, k => k.Name, StringComparer.Ordinal);
|
|
|
|
var plan = ApiMethodKeyScopeReconciler.Reconcile(
|
|
methodName, _selectedKeyIds, _initialKeyIds, currentMethodsByKey, keyNamesById);
|
|
|
|
// Empty-last-scope guard: a key cannot end up with zero scopes (server rejects it).
|
|
if (plan.EmptyScopeKeyNames.Count > 0)
|
|
{
|
|
var names = string.Join(", ", plan.EmptyScopeKeyNames);
|
|
_formError =
|
|
$"Cannot revoke this method from key(s) [{names}] — it would leave them with no methods. " +
|
|
"Disable or delete the key instead, or grant it another method first.";
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
foreach (var update in plan.Updates)
|
|
{
|
|
var ok = await ApiKeyAdmin.SetMethodsAsync(update.KeyId, update.NewMethods);
|
|
if (!ok)
|
|
throw new InvalidOperationException(
|
|
$"Key '{NameFor(update.KeyId)}' was not found in the key store.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Method '{methodName}' was saved, but updating approved-key scopes failed partway: {ex.Message} " +
|
|
"Some keys may be partially updated — review them on the API Keys page and retry.", ex);
|
|
}
|
|
|
|
// Selection is now the baseline (matters if save is retried without reload).
|
|
_initialKeyIds = new HashSet<string>(_selectedKeyIds, StringComparer.Ordinal);
|
|
return true;
|
|
}
|
|
|
|
// Returns the display name for a keyId if available from the loaded key list, else the id itself.
|
|
private string NameFor(string keyId) =>
|
|
_allKeys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal))?.Name ?? keyId;
|
|
|
|
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
|
|
|
|
/// <summary>Cancels an in-flight Test Run when the component is torn down.</summary>
|
|
public void Dispose()
|
|
{
|
|
_disposed = true;
|
|
_runCts?.Cancel();
|
|
_runCts?.Dispose();
|
|
_runCts = null;
|
|
}
|
|
|
|
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;
|
|
// The run may have been cancelled BY Dispose — the component is gone, don't re-render it.
|
|
if (!_disposed) 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"
|
|
};
|
|
}
|