Captures my_role off the existing parent-app fetch (no extra HTTP call) and uses canWriteApp / canAdminApp to hide: header Delete, Edit Save + Format, Routing +Add route + per-row remove, and the Settings tab. CodeEditor renders read-only for viewers. An effect bounces a stale Settings tab back to Edit for non-admins. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1288 lines
32 KiB
Svelte
1288 lines
32 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import { base } from '$app/paths';
|
|
import { page } from '$app/state';
|
|
import {
|
|
api,
|
|
ApiError,
|
|
type AppDomain,
|
|
type AppRole,
|
|
type ExecutionLog,
|
|
type Route,
|
|
type RouteInput,
|
|
type Script,
|
|
type VersionInfo
|
|
} from '$lib/api';
|
|
import { currentUser } from '$lib/auth';
|
|
import { canAdminApp, canWriteApp } from '$lib/capabilities';
|
|
import { logLevelColor, statusColor } from '$lib/styles';
|
|
import {
|
|
checkHostAgainstClaims,
|
|
guessPathKind,
|
|
hostSuggestions,
|
|
parseHostInput,
|
|
pathKindMismatchWarning
|
|
} from '$lib/route-utils';
|
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
|
import { format as formatRhai } from '$lib/rhai';
|
|
|
|
/// Pretty-print a JSON string in place, leaving it untouched if the
|
|
/// input doesn't parse. The error state is shown next to the button
|
|
/// so users see why it didn't reformat.
|
|
function formatJson(s: string): { ok: true; text: string } | { ok: false; error: string } {
|
|
try {
|
|
return { ok: true, text: JSON.stringify(JSON.parse(s), null, 2) };
|
|
} catch (e) {
|
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
}
|
|
|
|
// Route is `/scripts/[id]` so `page.params.id` is always present.
|
|
let id = $derived(page.params.id ?? '');
|
|
|
|
let tab = $state<'edit' | 'routing' | 'settings' | 'executions'>('edit');
|
|
|
|
// ---------------- shared script state ----------------
|
|
let script = $state<Script | null>(null);
|
|
let scriptError = $state<string | null>(null);
|
|
let scriptLoading = $state(true);
|
|
let info = $state<VersionInfo | null>(null);
|
|
|
|
let appSlug = $state<string | null>(null);
|
|
let appDomains = $state<AppDomain[]>([]);
|
|
let appMyRole = $state<AppRole | null>(null);
|
|
|
|
const me = $derived($currentUser);
|
|
const canWrite = $derived(canWriteApp(me, appMyRole));
|
|
const canAdmin = $derived(canAdminApp(me, appMyRole));
|
|
|
|
async function loadScript() {
|
|
scriptLoading = true;
|
|
scriptError = null;
|
|
try {
|
|
script = await api.scripts.get(id);
|
|
editableSource = script.source;
|
|
editableName = script.name;
|
|
editableDescription = script.description ?? '';
|
|
editableTimeout = script.timeout_seconds;
|
|
editableSandbox = { ...(script.sandbox ?? {}) };
|
|
// Resolve the owning app for the breadcrumb (slug),
|
|
// route-form host suggestions (domain claims), and UI
|
|
// shadowing (my_role on this app). All non-fatal — the
|
|
// page renders without them, just with reduced fidelity.
|
|
const appId = script.app_id;
|
|
void api.apps
|
|
.get(appId)
|
|
.then((a) => {
|
|
appSlug = a.slug;
|
|
appMyRole = a.my_role ?? null;
|
|
})
|
|
.catch(() => {});
|
|
void api.domains
|
|
.listForApp(appId)
|
|
.then((d) => {
|
|
appDomains = d;
|
|
})
|
|
.catch(() => {});
|
|
} catch (e) {
|
|
scriptError = e instanceof Error ? e.message : String(e);
|
|
script = null;
|
|
} finally {
|
|
scriptLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadInfo() {
|
|
try {
|
|
info = await api.version();
|
|
} catch {
|
|
info = null;
|
|
}
|
|
}
|
|
|
|
// ---------------- edit tab ----------------
|
|
let editableSource = $state('');
|
|
let savingSource = $state(false);
|
|
let saveSourceError = $state<string | null>(null);
|
|
let rhaiFormatError = $state<string | null>(null);
|
|
|
|
function formatRhaiSource() {
|
|
const r = formatRhai(editableSource);
|
|
if (r.ok) {
|
|
editableSource = r.text;
|
|
rhaiFormatError = null;
|
|
} else {
|
|
rhaiFormatError = `Parse error: ${r.error.message} (line ${r.error.line}, position ${r.error.column})`;
|
|
}
|
|
}
|
|
|
|
async function saveSource() {
|
|
if (!script) return;
|
|
savingSource = true;
|
|
saveSourceError = null;
|
|
try {
|
|
script = await api.scripts.update(id, { source: editableSource });
|
|
editableSource = script.source;
|
|
} catch (e) {
|
|
saveSourceError = e instanceof Error ? e.message : String(e);
|
|
} finally {
|
|
savingSource = false;
|
|
}
|
|
}
|
|
|
|
let testBody = $state('{}');
|
|
let testHeaders = $state('{}');
|
|
let testBodyFormatError = $state<string | null>(null);
|
|
let testHeadersFormatError = $state<string | null>(null);
|
|
let testInProgress = $state(false);
|
|
|
|
function formatTestBody() {
|
|
const r = formatJson(testBody);
|
|
if (r.ok) {
|
|
testBody = r.text;
|
|
testBodyFormatError = null;
|
|
} else {
|
|
testBodyFormatError = r.error;
|
|
}
|
|
}
|
|
function formatTestHeaders() {
|
|
const r = formatJson(testHeaders);
|
|
if (r.ok) {
|
|
testHeaders = r.text;
|
|
testHeadersFormatError = null;
|
|
} else {
|
|
testHeadersFormatError = r.error;
|
|
}
|
|
}
|
|
let testResult = $state<{
|
|
status: number;
|
|
headers: Record<string, string>;
|
|
body: unknown;
|
|
} | null>(null);
|
|
let testError = $state<string | null>(null);
|
|
|
|
async function invoke() {
|
|
testInProgress = true;
|
|
testError = null;
|
|
testResult = null;
|
|
try {
|
|
let parsedBody: unknown;
|
|
try {
|
|
parsedBody = JSON.parse(testBody);
|
|
} catch (e) {
|
|
testError = `Body is not valid JSON: ${e instanceof Error ? e.message : String(e)}`;
|
|
return;
|
|
}
|
|
let parsedHeaders: Record<string, string> = {};
|
|
try {
|
|
const obj: unknown = testHeaders.trim() ? JSON.parse(testHeaders) : {};
|
|
if (obj && typeof obj === 'object') {
|
|
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
|
parsedHeaders[k] = String(v);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
testError = `Headers JSON is invalid: ${e instanceof Error ? e.message : String(e)}`;
|
|
return;
|
|
}
|
|
testResult = await api.execute(id, parsedBody, parsedHeaders);
|
|
await loadLogs();
|
|
} catch (e) {
|
|
testError =
|
|
e instanceof ApiError ? `${e.status}: ${e.message}` : e instanceof Error ? e.message : String(e);
|
|
} finally {
|
|
testInProgress = false;
|
|
}
|
|
}
|
|
|
|
// ---------------- routing tab ----------------
|
|
let routes = $state<Route[]>([]);
|
|
let routesError = $state<string | null>(null);
|
|
let routesLoading = $state(true);
|
|
|
|
let showAddRoute = $state(false);
|
|
let newRoutePath = $state('/');
|
|
let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact');
|
|
// Host input is free-form; the kind is derived from what the user
|
|
// typed (see `parsedHost` below). Default `*` = Any, matching the
|
|
// canonical display form for an unrestricted host.
|
|
let newRouteHost = $state('*');
|
|
let newRouteMethod = $state('');
|
|
let pathKindAutoUpdate = $state(true);
|
|
let creatingRoute = $state(false);
|
|
let createRouteError = $state<string | null>(null);
|
|
|
|
let previewUrl = $state('http://localhost:8000/');
|
|
let previewMethod = $state('GET');
|
|
let previewResult = $state<string | null>(null);
|
|
|
|
// Auto-update the path-kind selector as the user types.
|
|
$effect(() => {
|
|
if (pathKindAutoUpdate) {
|
|
newRoutePathKind = guessPathKind(newRoutePath);
|
|
}
|
|
});
|
|
|
|
let parsedHost = $derived(parseHostInput(newRouteHost));
|
|
let hostCheck = $derived(checkHostAgainstClaims(parsedHost, appDomains));
|
|
let hostDatalistId = 'route-host-suggestions';
|
|
let suggestions = $derived(hostSuggestions(appDomains));
|
|
|
|
let pathKindWarning = $derived(
|
|
newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null
|
|
);
|
|
|
|
async function loadRoutes() {
|
|
routesLoading = true;
|
|
routesError = null;
|
|
try {
|
|
routes = await api.routes.listForScript(id);
|
|
} catch (e) {
|
|
routesError = e instanceof Error ? e.message : String(e);
|
|
} finally {
|
|
routesLoading = false;
|
|
}
|
|
}
|
|
|
|
async function submitRoute(event: Event) {
|
|
event.preventDefault();
|
|
creatingRoute = true;
|
|
createRouteError = null;
|
|
try {
|
|
const input: RouteInput = {
|
|
host_kind: parsedHost.kind,
|
|
host: parsedHost.host,
|
|
path_kind: newRoutePathKind,
|
|
path: newRoutePath.trim(),
|
|
method: newRouteMethod.trim() || null
|
|
};
|
|
await api.routes.create(id, input);
|
|
showAddRoute = false;
|
|
newRoutePath = '/';
|
|
newRouteHost = '*';
|
|
newRouteMethod = '';
|
|
pathKindAutoUpdate = true;
|
|
await loadRoutes();
|
|
} catch (e) {
|
|
if (e instanceof ApiError && e.status === 409) {
|
|
const body = e.body as { conflicting_route?: Route; reason?: string } | null;
|
|
createRouteError = `Conflict with existing route ${
|
|
body?.conflicting_route ? formatRoute(body.conflicting_route) : '(unknown)'
|
|
}${body?.reason ? ` — ${body.reason}` : ''}`;
|
|
} else {
|
|
createRouteError = e instanceof Error ? e.message : String(e);
|
|
}
|
|
} finally {
|
|
creatingRoute = false;
|
|
}
|
|
}
|
|
|
|
async function removeRoute(routeId: string) {
|
|
if (!confirm('Delete this route?')) return;
|
|
try {
|
|
await api.routes.remove(routeId);
|
|
await loadRoutes();
|
|
} catch (e) {
|
|
alert(e instanceof Error ? e.message : String(e));
|
|
}
|
|
}
|
|
|
|
async function runPreview() {
|
|
previewResult = null;
|
|
if (!script) return;
|
|
try {
|
|
const r = await api.routes.match(script.app_id, previewUrl, previewMethod);
|
|
if (r.matched) {
|
|
const ours = r.matched.script_id === id;
|
|
const tag = ours ? '✓ matches THIS script' : '⚠ matches a DIFFERENT script';
|
|
previewResult = `${tag}\nroute_id: ${r.matched.route_id}\nscript_id: ${r.matched.script_id}\nparams: ${JSON.stringify(r.matched.params)}\nrest: ${JSON.stringify(r.matched.rest)}`;
|
|
} else {
|
|
previewResult = 'no route matches';
|
|
}
|
|
} catch (e) {
|
|
previewResult = `error: ${e instanceof Error ? e.message : String(e)}`;
|
|
}
|
|
}
|
|
|
|
function formatRoute(r: Route): string {
|
|
const host =
|
|
r.host_kind === 'any'
|
|
? '*'
|
|
: r.host_kind === 'wildcard'
|
|
? `*.${r.host}`
|
|
: r.host;
|
|
return `[${r.method ?? 'ANY'} ${host} ${r.path}]`;
|
|
}
|
|
|
|
function fullUrlForRoute(r: Route): string {
|
|
const baseUrl = info?.public_base_url ?? '';
|
|
const host =
|
|
r.host_kind === 'any'
|
|
? baseUrl
|
|
: r.host_kind === 'wildcard'
|
|
? baseUrl.replace(/\/\/[^/]+/, `//example.${r.host}`)
|
|
: baseUrl.replace(/\/\/[^/]+/, `//${r.host}`);
|
|
return `${host}${r.path.replace(/\/\*$/, '/...')}`;
|
|
}
|
|
|
|
// ---------------- settings tab ----------------
|
|
let editableName = $state('');
|
|
let editableDescription = $state('');
|
|
let editableTimeout = $state(30);
|
|
let editableSandbox = $state<Record<string, number | undefined>>({});
|
|
let savingSettings = $state(false);
|
|
let saveSettingsError = $state<string | null>(null);
|
|
|
|
async function saveSettings() {
|
|
if (!script) return;
|
|
savingSettings = true;
|
|
saveSettingsError = null;
|
|
try {
|
|
const cleanedSandbox: Record<string, number> = {};
|
|
for (const [k, v] of Object.entries(editableSandbox)) {
|
|
if (typeof v === 'number' && v > 0) cleanedSandbox[k] = v;
|
|
}
|
|
script = await api.scripts.update(id, {
|
|
name: editableName.trim(),
|
|
description: editableDescription.trim() === '' ? null : editableDescription.trim(),
|
|
timeout_seconds: editableTimeout,
|
|
sandbox: cleanedSandbox
|
|
});
|
|
editableSandbox = { ...(script.sandbox ?? {}) };
|
|
} catch (e) {
|
|
saveSettingsError = e instanceof Error ? e.message : String(e);
|
|
} finally {
|
|
savingSettings = false;
|
|
}
|
|
}
|
|
|
|
// ---------------- executions tab ----------------
|
|
let logs = $state<ExecutionLog[] | null>(null);
|
|
let logsError = $state<string | null>(null);
|
|
let logsLoading = $state(true);
|
|
|
|
async function loadLogs() {
|
|
logsLoading = true;
|
|
logsError = null;
|
|
try {
|
|
logs = await api.scripts.logs(id, { limit: 50 });
|
|
} catch (e) {
|
|
logsError = e instanceof Error ? e.message : String(e);
|
|
logs = null;
|
|
} finally {
|
|
logsLoading = false;
|
|
}
|
|
}
|
|
|
|
// ---------------- deletion ----------------
|
|
let deleting = $state(false);
|
|
async function remove() {
|
|
if (!script) return;
|
|
if (!confirm(`Delete script "${script.name}"? This cannot be undone.`)) return;
|
|
deleting = true;
|
|
try {
|
|
await api.scripts.remove(id);
|
|
await goto(base + '/');
|
|
} catch (e) {
|
|
alert(e instanceof Error ? e.message : String(e));
|
|
deleting = false;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
void loadScript();
|
|
void loadInfo();
|
|
void loadRoutes();
|
|
void loadLogs();
|
|
});
|
|
|
|
// Defense-in-depth: anyone non-admin who lands on the Settings
|
|
// tab via a stale link gets bounced back to Edit. The tab button
|
|
// itself is also hidden.
|
|
$effect(() => {
|
|
if (!canAdmin && tab === 'settings') {
|
|
tab = 'edit';
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<section>
|
|
<a class="back" href={base + '/'}>← Scripts</a>
|
|
|
|
{#if scriptLoading}
|
|
<p class="muted">Loading…</p>
|
|
{:else if scriptError}
|
|
<div class="error">{scriptError}</div>
|
|
{:else if script}
|
|
<header class="page-header">
|
|
<div>
|
|
{#if appSlug}
|
|
<div class="breadcrumb">
|
|
<a href="{base}/apps">Apps</a> /
|
|
<a href="{base}/apps/{appSlug}">{appSlug}</a> / Scripts /
|
|
<code>{script.name}</code>
|
|
</div>
|
|
{/if}
|
|
<h1>{script.name}</h1>
|
|
<p class="muted">
|
|
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
|
</p>
|
|
</div>
|
|
{#if canAdmin}
|
|
<button type="button" class="danger" onclick={remove} disabled={deleting}>
|
|
{deleting ? 'Deleting…' : 'Delete'}
|
|
</button>
|
|
{/if}
|
|
</header>
|
|
|
|
<nav class="tabs">
|
|
<button class:active={tab === 'edit'} onclick={() => (tab = 'edit')}>Edit</button>
|
|
<button class:active={tab === 'routing'} onclick={() => (tab = 'routing')}>
|
|
Routing
|
|
{#if routes.length > 0}
|
|
<span class="badge-count">{routes.length}</span>
|
|
{/if}
|
|
</button>
|
|
{#if canAdmin}
|
|
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button>
|
|
{/if}
|
|
<button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}>
|
|
Executions
|
|
</button>
|
|
</nav>
|
|
|
|
<!-- ============================================================ EDIT ===== -->
|
|
{#if tab === 'edit'}
|
|
<div class="grid">
|
|
<section class="card">
|
|
<header class="editor-header">
|
|
<h2>Source</h2>
|
|
{#if canWrite}
|
|
<button type="button" class="ghost small" onclick={formatRhaiSource}>
|
|
Format
|
|
</button>
|
|
{/if}
|
|
</header>
|
|
<CodeEditor
|
|
bind:value={editableSource}
|
|
language="rhai"
|
|
minHeight="22rem"
|
|
readOnly={!canWrite}
|
|
/>
|
|
{#if rhaiFormatError}
|
|
<div class="error inline">{rhaiFormatError}</div>
|
|
{/if}
|
|
{#if saveSourceError}
|
|
<div class="error inline">{saveSourceError}</div>
|
|
{/if}
|
|
{#if canWrite}
|
|
<div class="actions">
|
|
<button
|
|
type="button"
|
|
onclick={saveSource}
|
|
disabled={savingSource || editableSource === script.source}
|
|
>
|
|
{savingSource ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>Test invoke</h2>
|
|
<div class="json-block">
|
|
<header class="json-header">
|
|
<span>Request body (JSON)</span>
|
|
<button type="button" class="ghost small" onclick={formatTestBody}>
|
|
Format
|
|
</button>
|
|
</header>
|
|
<CodeEditor bind:value={testBody} language="json" minHeight="9rem" />
|
|
{#if testBodyFormatError}
|
|
<div class="error inline">{testBodyFormatError}</div>
|
|
{/if}
|
|
</div>
|
|
<div class="json-block">
|
|
<header class="json-header">
|
|
<span>Headers (JSON object)</span>
|
|
<button type="button" class="ghost small" onclick={formatTestHeaders}>
|
|
Format
|
|
</button>
|
|
</header>
|
|
<CodeEditor bind:value={testHeaders} language="json" minHeight="6rem" />
|
|
{#if testHeadersFormatError}
|
|
<div class="error inline">{testHeadersFormatError}</div>
|
|
{/if}
|
|
</div>
|
|
<div class="actions">
|
|
<button type="button" onclick={invoke} disabled={testInProgress}>
|
|
{testInProgress ? 'Running…' : 'Send'}
|
|
</button>
|
|
</div>
|
|
{#if testError}
|
|
<div class="error inline">{testError}</div>
|
|
{/if}
|
|
{#if testResult}
|
|
<div class="result">
|
|
<div class="status">HTTP {testResult.status}</div>
|
|
<pre>{JSON.stringify(testResult.body, null, 2)}</pre>
|
|
</div>
|
|
{/if}
|
|
<p class="muted hint">
|
|
Test invoke uses the internal /api/v1/execute/{`{id}`} bypass — your custom routes
|
|
aren't checked. To test routes, use the Match preview on the Routing tab or curl the
|
|
URL directly.
|
|
</p>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- ====================================================== ROUTING ===== -->
|
|
{:else if tab === 'routing'}
|
|
<section class="card wide">
|
|
<header class="card-header">
|
|
<h2>Routes</h2>
|
|
{#if canWrite}
|
|
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
|
|
{showAddRoute ? 'Cancel' : '+ Add route'}
|
|
</button>
|
|
{/if}
|
|
</header>
|
|
|
|
{#if showAddRoute && canWrite}
|
|
<form class="route-form" onsubmit={submitRoute}>
|
|
<label class="full">
|
|
<span>Path</span>
|
|
<input
|
|
bind:value={newRoutePath}
|
|
oninput={() => (pathKindAutoUpdate = true)}
|
|
placeholder="/greet, /greet/:name, /webhooks/*"
|
|
required
|
|
autocomplete="off"
|
|
/>
|
|
</label>
|
|
<div class="row">
|
|
<label>
|
|
<span>Path kind</span>
|
|
<select
|
|
bind:value={newRoutePathKind}
|
|
onchange={() => (pathKindAutoUpdate = false)}
|
|
>
|
|
<option value="exact">exact</option>
|
|
<option value="param">param</option>
|
|
<option value="prefix">prefix</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>Method</span>
|
|
<select bind:value={newRouteMethod}>
|
|
<option value="">ANY</option>
|
|
<option value="GET">GET</option>
|
|
<option value="POST">POST</option>
|
|
<option value="PUT">PUT</option>
|
|
<option value="DELETE">DELETE</option>
|
|
<option value="PATCH">PATCH</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<label class="full">
|
|
<span class="host-label">
|
|
Host
|
|
<span class="kind-chip kind-{parsedHost.kind}">
|
|
{parsedHost.kind}
|
|
</span>
|
|
</span>
|
|
<input
|
|
bind:value={newRouteHost}
|
|
placeholder="* · app.example.com · *.example.com"
|
|
list={hostDatalistId}
|
|
autocomplete="off"
|
|
autocapitalize="off"
|
|
autocorrect="off"
|
|
spellcheck="false"
|
|
/>
|
|
<datalist id={hostDatalistId}>
|
|
{#each suggestions as s (s)}
|
|
<option value={s}></option>
|
|
{/each}
|
|
</datalist>
|
|
<small class="muted">
|
|
<code>*</code> = any host claimed by this app ·
|
|
<code>*.foo.com</code> = wildcard · <code>foo.com</code> =
|
|
strict
|
|
</small>
|
|
</label>
|
|
{#if !hostCheck.ok}
|
|
<div class="warning inline">
|
|
{hostCheck.reason}.
|
|
{#if appDomains.length > 0}
|
|
Claims:
|
|
{#each appDomains as d, i (d.id)}<code>{d.pattern}</code>{#if i < appDomains.length - 1},
|
|
{/if}{/each}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{#if pathKindWarning}
|
|
<div class="warning inline">{pathKindWarning}</div>
|
|
{/if}
|
|
{#if createRouteError}
|
|
<div class="error inline">{createRouteError}</div>
|
|
{/if}
|
|
<div class="actions">
|
|
<button type="submit" disabled={creatingRoute}>
|
|
{creatingRoute ? 'Creating…' : 'Create route'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
{#if routesLoading}
|
|
<p class="muted">Loading routes…</p>
|
|
{:else if routesError}
|
|
<div class="error">{routesError}</div>
|
|
{:else if routes.length === 0}
|
|
<p class="muted">
|
|
No routes yet. Scripts are still callable via
|
|
<code>POST /api/v1/execute/{`{id}`}</code> while you set things up.
|
|
</p>
|
|
{:else}
|
|
<ul class="route-list">
|
|
{#each routes as r (r.id)}
|
|
<li>
|
|
<div class="route-row">
|
|
<span class="kind kind-{r.path_kind}">{r.path_kind}</span>
|
|
<span class="method">{r.method ?? 'ANY'}</span>
|
|
<span class="host">
|
|
{r.host_kind === 'any'
|
|
? '*'
|
|
: r.host_kind === 'wildcard'
|
|
? `*.${r.host}`
|
|
: r.host}
|
|
</span>
|
|
<span class="path">{r.path}</span>
|
|
{#if canWrite}
|
|
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
|
|
remove
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{#if info}
|
|
<div class="route-url muted">→ {fullUrlForRoute(r)}</div>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="card wide">
|
|
<h2>Match preview</h2>
|
|
<p class="muted hint">
|
|
Synthetic check: does this URL+method match a route, and which? Useful for
|
|
verifying your routing before exposing scripts publicly.
|
|
</p>
|
|
<div class="row">
|
|
<label class="grow">
|
|
<span>URL</span>
|
|
<input bind:value={previewUrl} autocomplete="off" />
|
|
</label>
|
|
<label>
|
|
<span>Method</span>
|
|
<select bind:value={previewMethod}>
|
|
<option>GET</option>
|
|
<option>POST</option>
|
|
<option>PUT</option>
|
|
<option>DELETE</option>
|
|
<option>PATCH</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div class="actions">
|
|
<button type="button" onclick={runPreview}>Match</button>
|
|
</div>
|
|
{#if previewResult}
|
|
<pre class="preview">{previewResult}</pre>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- ===================================================== SETTINGS ===== -->
|
|
{:else if tab === 'settings' && canAdmin}
|
|
<section class="card wide">
|
|
<h2>General</h2>
|
|
<label>
|
|
<span>Name</span>
|
|
<input bind:value={editableName} required />
|
|
</label>
|
|
<label>
|
|
<span>Description</span>
|
|
<input bind:value={editableDescription} placeholder="optional" />
|
|
</label>
|
|
<label>
|
|
<span>Timeout (seconds)</span>
|
|
<input type="number" bind:value={editableTimeout} min="1" max="300" />
|
|
</label>
|
|
|
|
<details class="advanced">
|
|
<summary>Advanced sandbox</summary>
|
|
<p class="muted hint">
|
|
Each value is admin-ceiling-capped at write time. Leave empty to use the
|
|
platform default for that knob.
|
|
</p>
|
|
<div class="sandbox-grid">
|
|
{#each ['max_operations', 'max_string_size', 'max_array_size', 'max_map_size', 'max_call_levels', 'max_expr_depth'] as key (key)}
|
|
<label>
|
|
<span>{key}</span>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
bind:value={editableSandbox[key]}
|
|
placeholder="(default)"
|
|
/>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</details>
|
|
|
|
{#if saveSettingsError}
|
|
<div class="error inline">{saveSettingsError}</div>
|
|
{/if}
|
|
<div class="actions">
|
|
<button type="button" onclick={saveSettings} disabled={savingSettings}>
|
|
{savingSettings ? 'Saving…' : 'Save settings'}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- =================================================== EXECUTIONS ===== -->
|
|
{:else if tab === 'executions'}
|
|
<section class="logs">
|
|
<header class="logs-header">
|
|
<h2>Recent executions</h2>
|
|
<button type="button" class="ghost" onclick={loadLogs} disabled={logsLoading}>
|
|
{logsLoading ? 'Refreshing…' : 'Refresh'}
|
|
</button>
|
|
</header>
|
|
{#if logsError}
|
|
<div class="error">{logsError}</div>
|
|
{:else if logs && logs.length === 0}
|
|
<p class="muted">
|
|
No executions yet — try the Test invoke panel on the Edit tab, or curl
|
|
one of your routes.
|
|
</p>
|
|
{:else if logs}
|
|
<ul class="exec-list">
|
|
{#each logs as log (log.id)}
|
|
<li>
|
|
<details>
|
|
<summary>
|
|
<span class="badge" style:background={statusColor[log.status]}>
|
|
{log.status}
|
|
</span>
|
|
<span class="time">{new Date(log.created_at).toLocaleString()}</span>
|
|
<span class="muted">
|
|
{log.response_code ?? '—'} · {log.duration_ms}ms
|
|
</span>
|
|
<span class="muted path-snippet">{log.request_path}</span>
|
|
</summary>
|
|
<div class="exec-body">
|
|
<div>
|
|
<strong>Request body</strong>
|
|
<pre>{JSON.stringify(log.request_body, null, 2)}</pre>
|
|
</div>
|
|
<div>
|
|
<strong>Response body</strong>
|
|
<pre>{JSON.stringify(log.response_body, null, 2)}</pre>
|
|
</div>
|
|
{#if log.script_logs && log.script_logs.length > 0}
|
|
<div>
|
|
<strong>Script logs</strong>
|
|
<ul class="log-entries">
|
|
{#each log.script_logs as entry}
|
|
<li>
|
|
<span
|
|
class="level"
|
|
style:color={logLevelColor[entry.level] ?? '#cbd5e1'}
|
|
>
|
|
{entry.level}
|
|
</span>
|
|
<span class="msg">{entry.message}</span>
|
|
{#if entry.data !== null && entry.data !== undefined}
|
|
<pre class="data">{JSON.stringify(entry.data)}</pre>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</details>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
|
|
<style>
|
|
.back {
|
|
color: #94a3b8;
|
|
text-decoration: none;
|
|
font-size: 0.875rem;
|
|
}
|
|
.back:hover {
|
|
color: #e2e8f0;
|
|
}
|
|
|
|
.page-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin: 1rem 0 1.5rem;
|
|
}
|
|
.breadcrumb {
|
|
font-size: 0.875rem;
|
|
color: #64748b;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.breadcrumb a {
|
|
color: #94a3b8;
|
|
text-decoration: none;
|
|
}
|
|
.breadcrumb a:hover {
|
|
color: #e2e8f0;
|
|
}
|
|
.breadcrumb code {
|
|
background: #1e293b;
|
|
padding: 0.1rem 0.3rem;
|
|
border-radius: 0.25rem;
|
|
}
|
|
h1 {
|
|
margin: 0;
|
|
font-size: 1.5rem;
|
|
}
|
|
h2 {
|
|
margin: 0 0 0.75rem;
|
|
font-size: 1rem;
|
|
color: #cbd5e1;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
p.muted {
|
|
margin: 0.25rem 0 0;
|
|
}
|
|
.muted {
|
|
color: #64748b;
|
|
}
|
|
|
|
button {
|
|
background: #38bdf8;
|
|
color: #0b1220;
|
|
border: none;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 0.375rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
button.danger {
|
|
background: #ef4444;
|
|
color: #fff;
|
|
}
|
|
button.ghost {
|
|
background: transparent;
|
|
color: #94a3b8;
|
|
border: 1px solid #334155;
|
|
}
|
|
button.small {
|
|
padding: 0.2rem 0.6rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.json-block {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
}
|
|
.json-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-size: 0.85rem;
|
|
color: #cbd5e1;
|
|
}
|
|
.editor-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.editor-header h2 {
|
|
margin: 0;
|
|
}
|
|
button.link {
|
|
background: transparent;
|
|
color: #94a3b8;
|
|
padding: 0.25rem 0.5rem;
|
|
font-weight: 500;
|
|
text-decoration: underline;
|
|
}
|
|
button.link.danger {
|
|
color: #fca5a5;
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
border-bottom: 1px solid #1e293b;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.tabs button {
|
|
background: transparent;
|
|
color: #94a3b8;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 0;
|
|
font-weight: 500;
|
|
border-bottom: 2px solid transparent;
|
|
margin-bottom: -1px;
|
|
}
|
|
.tabs button:hover {
|
|
color: #e2e8f0;
|
|
}
|
|
.tabs button.active {
|
|
color: #38bdf8;
|
|
border-bottom-color: #38bdf8;
|
|
}
|
|
.badge-count {
|
|
background: #334155;
|
|
color: #cbd5e1;
|
|
font-size: 0.7rem;
|
|
padding: 0.05rem 0.4rem;
|
|
border-radius: 999px;
|
|
margin-left: 0.4rem;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1rem;
|
|
}
|
|
@media (max-width: 720px) {
|
|
.grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.card {
|
|
background: #1e293b;
|
|
border-radius: 0.5rem;
|
|
padding: 1.25rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.card.wide {
|
|
grid-column: 1 / -1;
|
|
}
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
input,
|
|
select {
|
|
background: #0b1220;
|
|
color: #e2e8f0;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.375rem;
|
|
padding: 0.5rem 0.75rem;
|
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
font-size: 0.9rem;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
input:disabled,
|
|
select:disabled {
|
|
opacity: 0.5;
|
|
}
|
|
label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
font-size: 0.85rem;
|
|
color: #cbd5e1;
|
|
}
|
|
label.full {
|
|
width: 100%;
|
|
}
|
|
label.grow {
|
|
flex: 1;
|
|
}
|
|
|
|
.row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 2fr;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.error,
|
|
.warning {
|
|
border-radius: 0.375rem;
|
|
padding: 0.75rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
.error {
|
|
border: 1px solid #b91c1c;
|
|
background: #450a0a;
|
|
color: #fecaca;
|
|
}
|
|
.warning {
|
|
border: 1px solid #ca8a04;
|
|
background: #422006;
|
|
color: #fde68a;
|
|
}
|
|
.inline {
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.result {
|
|
background: #0b1220;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.375rem;
|
|
padding: 0.75rem;
|
|
}
|
|
.result .status {
|
|
font-weight: 600;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.result pre,
|
|
.preview {
|
|
margin: 0;
|
|
font-size: 0.85rem;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
.preview {
|
|
background: #0b1220;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.375rem;
|
|
padding: 0.75rem;
|
|
font-family:
|
|
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
|
}
|
|
.hint {
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.route-form {
|
|
background: #0f172a;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.5rem;
|
|
padding: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.route-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.route-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.6rem 0.75rem;
|
|
background: #0f172a;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.375rem;
|
|
font-family:
|
|
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
|
font-size: 0.85rem;
|
|
}
|
|
.route-row .kind {
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
padding: 0.15rem 0.45rem;
|
|
border-radius: 0.25rem;
|
|
}
|
|
.kind-exact {
|
|
background: #14532d;
|
|
color: #bbf7d0;
|
|
}
|
|
.kind-prefix {
|
|
background: #1e3a8a;
|
|
color: #bfdbfe;
|
|
}
|
|
.kind-param {
|
|
background: #581c87;
|
|
color: #e9d5ff;
|
|
}
|
|
.host-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
.kind-chip {
|
|
font-size: 0.65rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
padding: 0.1rem 0.4rem;
|
|
border-radius: 0.25rem;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
.kind-chip.kind-any {
|
|
background: #1e293b;
|
|
color: #cbd5e1;
|
|
}
|
|
.kind-chip.kind-strict {
|
|
background: #14532d;
|
|
color: #bbf7d0;
|
|
}
|
|
.kind-chip.kind-wildcard {
|
|
background: #1e3a8a;
|
|
color: #bfdbfe;
|
|
}
|
|
.route-row .method {
|
|
color: #fbbf24;
|
|
font-weight: 700;
|
|
min-width: 3rem;
|
|
}
|
|
.route-row .host {
|
|
color: #94a3b8;
|
|
}
|
|
.route-row .path {
|
|
color: #e2e8f0;
|
|
flex: 1;
|
|
}
|
|
.route-url {
|
|
font-family:
|
|
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
|
font-size: 0.75rem;
|
|
padding: 0 0.75rem 0.4rem;
|
|
}
|
|
|
|
.advanced {
|
|
background: #0f172a;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.5rem;
|
|
padding: 0.75rem 1rem;
|
|
}
|
|
.advanced summary {
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
color: #cbd5e1;
|
|
}
|
|
.sandbox-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 0.75rem;
|
|
margin-top: 0.75rem;
|
|
}
|
|
@media (max-width: 720px) {
|
|
.sandbox-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.logs-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.exec-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.exec-list summary {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: center;
|
|
padding: 0.75rem 1rem;
|
|
background: #1e293b;
|
|
border-radius: 0.375rem;
|
|
cursor: pointer;
|
|
list-style: none;
|
|
font-size: 0.875rem;
|
|
}
|
|
.exec-list summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
.path-snippet {
|
|
font-family:
|
|
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
|
font-size: 0.8rem;
|
|
}
|
|
.badge {
|
|
font-size: 0.7rem;
|
|
padding: 0.125rem 0.5rem;
|
|
border-radius: 999px;
|
|
color: #0b1220;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
.time {
|
|
font-size: 0.875rem;
|
|
color: #cbd5e1;
|
|
}
|
|
.exec-body {
|
|
padding: 0.75rem 1rem;
|
|
background: #0b1220;
|
|
border-radius: 0 0 0.375rem 0.375rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
.exec-body pre {
|
|
background: #02101f;
|
|
border: 1px solid #1e293b;
|
|
border-radius: 0.25rem;
|
|
padding: 0.5rem;
|
|
margin: 0.25rem 0 0;
|
|
font-size: 0.8rem;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
.log-entries {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0.25rem 0 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
.log-entries .level {
|
|
display: inline-block;
|
|
width: 3.5rem;
|
|
text-transform: uppercase;
|
|
font-weight: 700;
|
|
font-size: 0.7rem;
|
|
}
|
|
.log-entries .data {
|
|
margin: 0.25rem 0 0 4rem;
|
|
font-size: 0.75rem;
|
|
}
|
|
</style>
|