Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/ApiMethodForm.razor
T
Joseph Doherty e15aa0e430 docs: Flow Debugger design reconciled with the implementation (arch-review F12, F13)
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
2026-09-06 14:35:10 -04:00

457 lines
21 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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">&larr; 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"
};
}