e15aa0e430
The Flow Debugger design doc still described the pre-remediation shape: a
debug-stream subscription for the value-change entry, Deployer everywhere, an
unredacted inline trace copy and "~7 audit rows per call". Reconciled with what
shipped on this branch (Tasks 1-12):
- design D6 / §5.4 / §9 / §10: RequireFlowDebugger page policy with Deployer-only
execution (F3), the PullTraceEvents polling path with AttributeValueUpdate
fields 7/8 recorded as RESERVED and unconsumed (F12), the redacting + byte-
capped shared FlowTraceCollector (F1), route-time site scope (F2), the derived
client waits (F4), the 7-10 row estimate, the doubled central audit write per
routed call, and the inbound-row-after-the-run ordering.
- sitestream.proto: fields 7/8 marked reserved-but-populated, numbers not to be
reused (comment only; no regeneration needed).
- Component-InboundAPI: SkipBodyCapture on the RouteCall row (F11),
SchemaRefResolver's public visibility, WithSiteScope / SITE_SCOPE_DENIED (F2),
Replay's wider role set (F3).
- Component-CentralUI: page policy vs Deployer-only controls (F3), route-time
site scope (F2), client wait derivation (F4).
- "7-10 audit rows per call (3 of them trace-only)" everywhere the estimate is
quoted: the method form, both CLI --trace-all-calls help strings, the CLI
README, the ApiMethod entity doc, the manual migration script and the two
Flow Debugger plans.
- arch-review doc: new §7 Resolution listing F1-F13 with the fixing commit, plus
the tracking-store test race fixed alongside them (86451382).
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
457 lines
21 KiB
Plaintext
457 lines
21 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-3">
|
||
<label class="form-label">Timeout (seconds)</label>
|
||
<input type="number" class="form-control" @bind="_timeoutSeconds" min="1" />
|
||
</div>
|
||
<div class="form-check mb-0">
|
||
<input class="form-check-input" type="checkbox" id="api-method-trace-all-calls"
|
||
data-test="api-method-trace-all-calls" @bind="_traceAllCalls" />
|
||
<label class="form-check-label" for="api-method-trace-all-calls">
|
||
Trace all calls (Flow Debugger)
|
||
</label>
|
||
<div class="form-text">
|
||
Every call to this method records its routed site steps as audit rows the
|
||
Flow Debugger can replay. Adds 7–10 audit rows per call; for low-volume
|
||
integration methods.
|
||
</div>
|
||
</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 bool _traceAllCalls;
|
||
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;
|
||
_traceAllCalls = _existing.TraceAllCalls;
|
||
_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.TraceAllCalls = _traceAllCalls;
|
||
_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,
|
||
TraceAllCalls = _traceAllCalls,
|
||
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"
|
||
};
|
||
}
|