feat(dashboard): four-tab detail UI with routing + sandbox config

Restructures the script detail page from one wall of cards into a
focused four-tab layout. Each tab does one thing well:

  * Edit       source editor + Test invoke panel (the existing
               quick-iteration loop, unchanged in shape)
  * Routing    routes table + add-route form with live kind
               auto-detection + match-preview tool
  * Settings   name / description / timeout, plus a collapsible
               "Advanced sandbox" disclosure with the six Rhai
               limits (admin-ceiling-validated server-side)
  * Executions the existing log viewer, gets the room it deserves

Why four tabs (not two columns, not one tab per surface, not URL
sub-routes):
  * Two columns crowded out the routing UX, which is genuinely
    list+form-shaped and wants vertical space.
  * URL sub-routes (/scripts/{id}/routing) would let users
    bookmark a tab but cost a bunch of router files for a
    feature nobody asked for; tab state in $state is one line
    and gets us 95% of the UX.
  * Sandbox config tucked inside Settings' "Advanced" disclosure
    matches the 95%-never-touch reality without hiding it.

Routing tab highlights:
  * Path input + path-kind selector with live auto-detect: type
    /greet/:name and the kind selector flips to "param"; the
    user can override and the backend trusts the override (lets
    /greet/:name be matched literally if exact is selected).
  * Soft warning when selection mismatches the input shape — not
    blocking, with the rationale in the message.
  * Host kind auto-detect mirrors the path side (*.example.com →
    wildcard; non-empty literal → strict; empty → any).
  * Method dropdown defaults to ANY.
  * 409 conflicts render inline with the conflicting route's
    method/host/path so the user can read both at a glance.
  * Match-preview panel: synthetic URL + method → "matches THIS
    script" or "matches a DIFFERENT script" or "no match", with
    extracted params shown. Powered by /api/v1/admin/routes:match.
  * Full URLs in the routes list use the operator's
    PICLOUD_PUBLIC_BASE_URL from /version.

Settings tab:
  * Name / description / timeout editable from the UI for the
    first time (previously only source was).
  * "Advanced sandbox" details element collapsed by default;
    six number inputs with placeholder "(default)" meaning
    "use platform default for this knob". Save sends a fresh
    sandbox object; ceiling errors surface as the manager's 422
    message inline.

API client (dashboard/src/lib/api.ts):
  * Adds `Route`, `RouteInput`, `CheckRouteResponse`,
    `MatchRouteResponse`, `ScriptSandbox`, `VersionInfo` types.
  * Adds `api.routes.{listForScript,create,remove,check,match}`.
  * Adds `api.version()`.
  * `UpdateScriptInput` gains `sandbox`.

route-utils.ts:
  * `guessPathKind`, `guessHostKind` heuristics.
  * `pathKindMismatchWarning` for the soft-warning UX.

Verified live:
  /admin/                       → 200, dashboard HTML
  /admin/scripts/<id>           → SPA detail with all four tabs
  POST /api/v1/admin/scripts/<id>/routes → 201, route persists
  GET /demo/widget              → 200 (script answers via routing
                                    table after the dashboard's
                                    POST refreshes it)
  /version                      → public_base_url, schema 3, sdk 1.1

No backend changes — Commit 3 is purely additive UI on top of the
infrastructure Commits 1 and 2 already shipped.

Bumps: product 0.4.0 → 0.5.0 (user-visible UX change). Schema, SDK,
API, wire unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-23 22:07:36 +02:00
parent 07e2a62d98
commit ed462726de
7 changed files with 892 additions and 189 deletions

16
Cargo.lock generated
View File

@@ -1273,7 +1273,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "picloud"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"async-trait",
@@ -1297,7 +1297,7 @@ dependencies = [
[[package]]
name = "picloud-executor"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"picloud-executor-core",
@@ -1309,7 +1309,7 @@ dependencies = [
[[package]]
name = "picloud-executor-core"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"chrono",
"picloud-shared",
@@ -1323,7 +1323,7 @@ dependencies = [
[[package]]
name = "picloud-manager"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"picloud-manager-core",
@@ -1335,7 +1335,7 @@ dependencies = [
[[package]]
name = "picloud-manager-core"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"async-trait",
"axum",
@@ -1353,7 +1353,7 @@ dependencies = [
[[package]]
name = "picloud-orchestrator"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"anyhow",
"picloud-orchestrator-core",
@@ -1365,7 +1365,7 @@ dependencies = [
[[package]]
name = "picloud-orchestrator-core"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"async-trait",
"axum",
@@ -1384,7 +1384,7 @@ dependencies = [
[[package]]
name = "picloud-shared"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"async-trait",
"chrono",

View File

@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.4.0"
version = "0.5.0"
edition = "2021"
rust-version = "1.92"
license = "MIT OR Apache-2.0"

View File

@@ -1,6 +1,6 @@
{
"name": "picloud-dashboard",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -5,6 +5,15 @@
// the same Caddy upstream so the "Test invoke" panel can hit it
// without any cross-origin gymnastics.
export interface ScriptSandbox {
max_operations?: number;
max_string_size?: number;
max_array_size?: number;
max_map_size?: number;
max_call_levels?: number;
max_expr_depth?: number;
}
export interface Script {
id: string;
name: string;
@@ -13,10 +22,60 @@ export interface Script {
source: string;
timeout_seconds: number;
memory_limit_mb: number;
sandbox: ScriptSandbox;
created_at: string;
updated_at: string;
}
export type HostKind = 'any' | 'strict' | 'wildcard';
export type PathKind = 'exact' | 'prefix' | 'param';
export interface Route {
id: string;
script_id: string;
host_kind: HostKind;
host: string;
host_param_name: string | null;
path_kind: PathKind;
path: string;
method: string | null;
created_at: string;
}
export interface RouteInput {
host_kind: HostKind;
host?: string;
host_param_name?: string | null;
path_kind: PathKind;
path: string;
method?: string | null;
}
export interface CheckRouteResponse {
ok: boolean;
conflicting_route: Route | null;
conflict_reason: string | null;
}
export interface MatchRouteResponse {
matched: null | {
route_id: string;
script_id: string;
params: Record<string, string>;
rest: string | null;
host_param: [string, string] | null;
};
}
export interface VersionInfo {
product: string;
sdk: string;
api: number;
schema: number;
wire: number;
public_base_url: string;
}
export type ExecutionStatus = 'success' | 'error' | 'timeout' | 'budget_exceeded';
export interface ScriptLogEntry {
@@ -55,6 +114,7 @@ export interface UpdateScriptInput {
source?: string;
timeout_seconds?: number;
memory_limit_mb?: number;
sandbox?: ScriptSandbox;
}
export interface ExecutionResult {
@@ -101,6 +161,30 @@ function safeJson(text: string): unknown {
export const api = {
health: () => fetch('/healthz').then((r) => r.text()),
version: () => adminRequest<VersionInfo>('/version'),
routes: {
listForScript: (scriptId: string) =>
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
create: (scriptId: string, input: RouteInput) =>
adminRequest<Route>(`/api/v1/admin/scripts/${scriptId}/routes`, {
method: 'POST',
body: JSON.stringify(input)
}),
remove: (routeId: string) =>
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
check: (input: RouteInput) =>
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
method: 'POST',
body: JSON.stringify(input)
}),
match: (url: string, method = 'GET') =>
adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', {
method: 'POST',
body: JSON.stringify({ url, method })
})
},
scripts: {
list: () => adminRequest<Script[]>('/api/v1/admin/scripts'),
get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),

View File

@@ -0,0 +1,32 @@
import type { HostKind, PathKind } from './api';
/** Guess a path kind from the literal user input. The dashboard pre-fills
* the kind selector but the user can override (the backend trusts the
* selection — selecting `exact` for `/greet/:name` matches it literally). */
export function guessPathKind(raw: string): PathKind {
if (raw.endsWith('/*')) return 'prefix';
if (/(^|\/):[a-zA-Z_]/.test(raw)) return 'param';
return 'exact';
}
/** Guess a host kind. Empty → any, leading "*." → wildcard, else strict. */
export function guessHostKind(raw: string): HostKind {
if (!raw.trim()) return 'any';
if (raw.startsWith('*.')) return 'wildcard';
return 'strict';
}
/** Warn-not-block: does the user's selection match what we'd guess
* from the input? Used by the routing form for the soft mismatch
* hint. Returns null when no warning is needed. */
export function pathKindMismatchWarning(raw: string, kind: PathKind): string | null {
const guessed = guessPathKind(raw);
if (guessed === kind) return null;
const hint =
guessed === 'param'
? 'this looks like a param pattern (contains `:name`)'
: guessed === 'prefix'
? 'this looks like a prefix pattern (ends with `/*`)'
: 'this looks like a literal path';
return `Selected kind is "${kind}", but ${hint}. Routing will use the selected kind.`;
}

View File

@@ -2,26 +2,74 @@
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { page } from '$app/state';
import { api, ApiError, type ExecutionLog, type Script } from '$lib/api';
import {
api,
ApiError,
type ExecutionLog,
type Route,
type RouteInput,
type Script,
type VersionInfo
} from '$lib/api';
import { logLevelColor, statusColor } from '$lib/styles';
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
// 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 logs = $state<ExecutionLog[] | null>(null);
let logsError = $state<string | null>(null);
let logsLoading = $state(true);
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 ?? {}) };
} catch (e) {
scriptError = e instanceof Error ? e.message : String(e);
script = null;
} finally {
scriptLoading = false;
}
}
// Source editor state (in-place edit, save updates the script).
async function loadInfo() {
try {
info = await api.version();
} catch {
info = null;
}
}
// ---------------- edit tab ----------------
let editableSource = $state('');
let saving = $state(false);
let saveError = $state<string | null>(null);
let savingSource = $state(false);
let saveSourceError = $state<string | null>(null);
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;
}
}
// Test invoke state.
let testBody = $state('{}');
let testHeaders = $state('{}');
let testInProgress = $state(false);
@@ -32,49 +80,6 @@
} | null>(null);
let testError = $state<string | null>(null);
let deleting = $state(false);
async function loadScript() {
scriptLoading = true;
scriptError = null;
try {
script = await api.scripts.get(id);
editableSource = script.source;
} catch (e) {
scriptError = e instanceof Error ? e.message : String(e);
script = null;
} finally {
scriptLoading = false;
}
}
async function loadLogs() {
logsLoading = true;
logsError = null;
try {
logs = await api.scripts.logs(id, { limit: 25 });
} catch (e) {
logsError = e instanceof Error ? e.message : String(e);
logs = null;
} finally {
logsLoading = false;
}
}
async function saveSource() {
if (!script) return;
saving = true;
saveError = null;
try {
script = await api.scripts.update(id, { source: editableSource });
editableSource = script.source;
} catch (e) {
saveError = e instanceof Error ? e.message : String(e);
} finally {
saving = false;
}
}
async function invoke() {
testInProgress = true;
testError = null;
@@ -100,19 +105,193 @@
return;
}
testResult = await api.execute(id, parsedBody, parsedHeaders);
// Refresh logs so the invocation we just made shows up.
await loadLogs();
} catch (e) {
if (e instanceof ApiError) {
testError = `${e.status}: ${e.message}`;
} else {
testError = e instanceof Error ? e.message : String(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');
let newRouteHost = $state('');
let newRouteHostKind = $state<'any' | 'strict' | 'wildcard'>('any');
let newRouteMethod = $state('');
let routeKindAutoUpdate = $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 kind selectors as the user types.
$effect(() => {
if (routeKindAutoUpdate) {
newRoutePathKind = guessPathKind(newRoutePath);
}
});
$effect(() => {
if (routeKindAutoUpdate) {
newRouteHostKind = guessHostKind(newRouteHost);
}
});
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: newRouteHostKind,
host: newRouteHostKind === 'any' ? '' : newRouteHost.trim(),
path_kind: newRoutePathKind,
path: newRoutePath.trim(),
method: newRouteMethod.trim() || null
};
await api.routes.create(id, input);
showAddRoute = false;
newRoutePath = '';
newRouteHost = '';
newRouteMethod = '';
routeKindAutoUpdate = 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;
try {
const r = await api.routes.match(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;
@@ -128,6 +307,8 @@
$effect(() => {
void loadScript();
void loadInfo();
void loadRoutes();
void loadLogs();
});
</script>
@@ -152,26 +333,40 @@
</button>
</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>
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button>
<button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}>
Executions
</button>
</nav>
<!-- ============================================================ EDIT ===== -->
{#if tab === 'edit'}
<div class="grid">
<!-- Source editor -->
<section class="card">
<h2>Source</h2>
<textarea bind:value={editableSource} rows="14" spellcheck="false"></textarea>
{#if saveError}
<div class="error inline">{saveError}</div>
{#if saveSourceError}
<div class="error inline">{saveSourceError}</div>
{/if}
<div class="actions">
<button
type="button"
onclick={saveSource}
disabled={saving || editableSource === script.source}
disabled={savingSource || editableSource === script.source}
>
{saving ? 'Saving…' : 'Save'}
{savingSource ? 'Saving…' : 'Save'}
</button>
</div>
</section>
<!-- Test invoke -->
<section class="card">
<h2>Test invoke</h2>
<label>
@@ -192,16 +387,220 @@
{/if}
{#if testResult}
<div class="result">
<div class="status">
HTTP {testResult.status}
</div>
<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>
<!-- Execution logs -->
<!-- ====================================================== ROUTING ===== -->
{:else if tab === 'routing'}
<section class="card wide">
<header class="card-header">
<h2>Routes</h2>
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
{showAddRoute ? 'Cancel' : '+ Add route'}
</button>
</header>
{#if showAddRoute}
<form class="route-form" onsubmit={submitRoute}>
<label class="full">
<span>Path</span>
<input
bind:value={newRoutePath}
oninput={() => (routeKindAutoUpdate = true)}
placeholder="/greet, /greet/:name, /webhooks/*"
required
autocomplete="off"
/>
</label>
<div class="row">
<label>
<span>Path kind</span>
<select
bind:value={newRoutePathKind}
onchange={() => (routeKindAutoUpdate = 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>
<div class="row">
<label>
<span>Host kind</span>
<select
bind:value={newRouteHostKind}
onchange={() => (routeKindAutoUpdate = false)}
>
<option value="any">ANY</option>
<option value="strict">strict</option>
<option value="wildcard">wildcard</option>
</select>
</label>
<label class:disabled={newRouteHostKind === 'any'}>
<span>Host</span>
<input
bind:value={newRouteHost}
oninput={() => (routeKindAutoUpdate = true)}
disabled={newRouteHostKind === 'any'}
placeholder={newRouteHostKind === 'wildcard'
? '*.example.com'
: 'sub.example.com'}
autocomplete="off"
/>
</label>
</div>
{#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>
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
remove
</button>
</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'}
<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>
@@ -212,7 +611,10 @@
{#if logsError}
<div class="error">{logsError}</div>
{:else if logs && logs.length === 0}
<p class="muted">No executions yet — try the Test invoke panel above.</p>
<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)}
@@ -226,6 +628,7 @@
<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>
@@ -265,6 +668,7 @@
{/if}
</section>
{/if}
{/if}
</section>
<style>
@@ -283,7 +687,6 @@
align-items: flex-start;
margin: 1rem 0 1.5rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
@@ -298,7 +701,6 @@
p.muted {
margin: 0.25rem 0 0;
}
.muted {
color: #64748b;
}
@@ -325,14 +727,53 @@
color: #94a3b8;
border: 1px solid #334155;
}
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;
margin-bottom: 1.5rem;
}
@media (max-width: 720px) {
.grid {
grid-template-columns: 1fr;
@@ -346,22 +787,40 @@
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;
}
textarea {
textarea,
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;
}
textarea {
font-family:
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
font-size: 0.85rem;
resize: vertical;
width: 100%;
box-sizing: border-box;
}
input:disabled,
select:disabled {
opacity: 0.5;
}
label {
display: flex;
flex-direction: column;
@@ -369,20 +828,44 @@
font-size: 0.85rem;
color: #cbd5e1;
}
label.disabled {
opacity: 0.6;
}
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;
padding: 0.75rem;
border-radius: 0.375rem;
}
.error.inline {
.warning {
border: 1px solid #ca8a04;
background: #422006;
color: #fde68a;
}
.inline {
font-size: 0.875rem;
}
@@ -396,12 +879,115 @@
font-weight: 600;
margin-bottom: 0.5rem;
}
.result pre {
.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;
}
.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;
@@ -409,7 +995,6 @@
align-items: center;
margin-bottom: 0.75rem;
}
.exec-list {
list-style: none;
padding: 0;
@@ -418,7 +1003,6 @@
flex-direction: column;
gap: 0.5rem;
}
.exec-list summary {
display: flex;
gap: 0.75rem;
@@ -428,11 +1012,16 @@
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;
@@ -446,7 +1035,6 @@
font-size: 0.875rem;
color: #cbd5e1;
}
.exec-body {
padding: 0.75rem 1rem;
background: #0b1220;
@@ -465,7 +1053,6 @@
white-space: pre-wrap;
word-break: break-word;
}
.log-entries {
list-style: none;
padding: 0;

View File

@@ -124,7 +124,7 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
| | Version |
|---|---|
| Product | `0.4.0` |
| Product | `0.5.0` |
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
| API | `1` |
| Schema | `3` (matches `migrations/0003_routes.sql`) |