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:
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picloud-dashboard",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
32
dashboard/src/lib/route-utils.ts
Normal file
32
dashboard/src/lib/route-utils.ts
Normal 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.`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
Reference in New Issue
Block a user