Files
PiCloud/dashboard/src/routes/scripts/[id]/+page.svelte
MechaCat02 3d4c7b160b fix(dashboard): preserve blank lines and improve Rhai parser errors
Two follow-ups on the Rhai formatter shipped in 0.5.1.

* Formatter no longer collapses user-intent blank lines between
  statements. The lexer now records a side-channel list of offsets
  where the source contained two-or-more consecutive newlines; the
  formatter consults it and emits a single blank in the same spot
  (rustfmt's `blank_lines_upper_bound = 1` policy applied strictly —
  the prior forced blank between top-level `fn` decls is dropped, so
  the formatter never *adds* a blank the user didn't write).
* Parse errors now read like Rhai's own diagnostics. `expect()` takes
  an optional `role` hint and each call site supplies a domain phrase
  (`name of a variable`, `function name in function declaration`,
  `'{' to begin a block`, `name of a property`, …). End-of-input is
  reported as `script is incomplete`. The dashboard banner renders
  `Parse error: {message} (line L, position C)` with 1-based
  coordinates, matching Rhai's format exactly.

The FormatError payload also keeps the byte `offset` so callers that
want to drive the editor cursor (CodeMirror works in offsets) still
have it.

Also folds the workspace Cargo.lock version bumps for 0.5.1 — the
lock-file rewrite that should have travelled with the prior commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:26:42 +02:00

1165 lines
29 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { page } from '$app/state';
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';
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);
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;
}
}
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');
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;
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();
});
</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>
<h1>{script.name}</h1>
<p class="muted">
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
</p>
</div>
<button type="button" class="danger" onclick={remove} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete'}
</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">
<section class="card">
<header class="editor-header">
<h2>Source</h2>
<button type="button" class="ghost small" onclick={formatRhaiSource}>
Format
</button>
</header>
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
{#if rhaiFormatError}
<div class="error inline">{rhaiFormatError}</div>
{/if}
{#if saveSourceError}
<div class="error inline">{saveSourceError}</div>
{/if}
<div class="actions">
<button
type="button"
onclick={saveSource}
disabled={savingSource || editableSource === script.source}
>
{savingSource ? 'Saving…' : 'Save'}
</button>
</div>
</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>
<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>
<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;
}
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.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;
}
.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;
}
.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>