feat: persist execution logs + dashboard detail view + integration tests
Three threads landing together because they share a public surface
(the new execution_log shape) and verifying any one in isolation
would mean re-doing the work later.
== (A) execution log persistence ==
* shared::ExecutionLog + ExecutionStatus carry the audit-trail
shape that flows from the orchestrator through the sink and
back out via the manager's logs endpoint.
* shared::ExecutionLogSink trait — abstraction the orchestrator
writes through. In single-process MVP mode the manager's
Postgres-backed impl is plugged in directly; in cluster mode
(v1.3+) the orchestrator's impl will post over HTTP to the
manager. Trait lives in `shared` so neither *-core crate has
to know about the other.
* manager-core::PostgresExecutionLogSink writes to the
execution_logs table (already in the initial migration);
PostgresExecutionLogRepository reads them back, paginated.
AdminState now carries both a script repo and a log repo, so
`admin_router` exposes `GET /scripts/{id}/logs?limit=&offset=`
capped at 200 rows per page to keep the dashboard responsive.
* orchestrator-core::DataPlaneState gains `log_sink`. The
execute handler builds an ExecutionLog on every outcome —
success, error, timeout, budget-exceeded — and awaits the
sink. Sink failures are logged at warn and DO NOT mask the
user-facing result, since "we couldn't write the audit row"
is a separate concern from "the script ran".
* picloud binary refactored into a lib (`build_app(pool)` is
the seam) + thin bin shell. Same Postgres pool backs the
script repo, the log repo, and the sink — no double pool.
== (B) dashboard ==
* Typed API client extended with `scripts.logs(id, opts)`,
`scripts.update/remove`, and `execute(id, body, headers)`.
Plain `fetch` wrapper now surfaces server-side error
messages via a typed ApiError so the UI can render them.
* `/` — create-script form now actually creates; on success
the list reloads. List entries link to detail.
* `/scripts/[id]` — new detail route: source editor with save
(calls update, version bumps); Test invoke panel that sends
arbitrary JSON body + headers to /api/execute and shows the
response; Recent executions panel reading from /logs with
expandable per-row request/response/script-log views.
Delete button with confirm. SPA-routed; Caddy serves
`build/` with the same index.html fallback.
== (C) integration tests ==
* crates/picloud/tests/api.rs — 14 sqlx::test cases driving
`build_app` through an axum_test::TestServer against a fresh
Postgres DB per test. Covers: health, full script CRUD,
duplicate-name conflict, invalid-source rejection on both
create and update, execute echoing the body, status+header
passthrough, 404 on missing scripts, error-path executions
landing in the audit log with the right status.
* Tests are `#[ignore]` by default so plain `cargo test
--workspace` stays green without infrastructure. Opt-in via:
`docker compose up -d postgres && \
DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
cargo test -p picloud --test api -- --include-ignored`
Verified live through Caddy on :8000: three logged invocations
land in the logs endpoint with the right structured `data` on
each `log::info`/`log::warn`, error-path executions are still
captured with status=error, dashboard list + SPA detail route
both reachable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
// Thin client for the PiCloud control-plane API.
|
||||
// Thin client for the PiCloud control-plane and data-plane APIs.
|
||||
//
|
||||
// All admin/CRUD calls hit `/api/admin/*` (manager). Data-plane calls
|
||||
// (script invocations) go to `/api/execute/*` (orchestrator). The
|
||||
// dashboard only talks to the control plane — data-plane invocations
|
||||
// from the dashboard go through the same path as any external caller.
|
||||
// The dashboard primarily targets `/api/admin/*` (manager). The
|
||||
// data-plane (`/api/execute/*`, orchestrator) is reachable through
|
||||
// the same Caddy upstream so the "Test invoke" panel can hit it
|
||||
// without any cross-origin gymnastics.
|
||||
|
||||
export interface Script {
|
||||
id: string;
|
||||
@@ -17,29 +17,132 @@ export interface Script {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
export type ExecutionStatus = 'success' | 'error' | 'timeout' | 'budget_exceeded';
|
||||
|
||||
export interface ScriptLogEntry {
|
||||
timestamp: string;
|
||||
level: 'trace' | 'info' | 'warn' | 'error';
|
||||
message: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface ExecutionLog {
|
||||
id: string;
|
||||
script_id: string;
|
||||
request_id: string;
|
||||
request_path: string;
|
||||
request_headers: Record<string, string>;
|
||||
request_body: unknown;
|
||||
response_code: number | null;
|
||||
response_body: unknown;
|
||||
script_logs: ScriptLogEntry[];
|
||||
duration_ms: number;
|
||||
status: ExecutionStatus;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateScriptInput {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
source: string;
|
||||
timeout_seconds?: number;
|
||||
memory_limit_mb?: number;
|
||||
}
|
||||
|
||||
export interface UpdateScriptInput {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
source?: string;
|
||||
timeout_seconds?: number;
|
||||
memory_limit_mb?: number;
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
public readonly body: unknown
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function adminRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }
|
||||
});
|
||||
const text = await res.text();
|
||||
const parsed: unknown = text ? safeJson(text) : null;
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`${res.status} ${res.statusText}: ${body}`);
|
||||
const message =
|
||||
(parsed && typeof parsed === 'object' && 'error' in parsed
|
||||
? String((parsed as { error: unknown }).error)
|
||||
: text) || `${res.status} ${res.statusText}`;
|
||||
throw new ApiError(res.status, message, parsed);
|
||||
}
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
function safeJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () => fetch('/healthz').then((r) => r.text()),
|
||||
|
||||
scripts: {
|
||||
list: () => request<Script[]>('/api/admin/scripts'),
|
||||
get: (id: string) => request<Script>(`/api/admin/scripts/${id}`),
|
||||
create: (input: Partial<Script>) =>
|
||||
request<Script>('/api/admin/scripts', { method: 'POST', body: JSON.stringify(input) }),
|
||||
update: (id: string, input: Partial<Script>) =>
|
||||
request<Script>(`/api/admin/scripts/${id}`, { method: 'PUT', body: JSON.stringify(input) }),
|
||||
list: () => adminRequest<Script[]>('/api/admin/scripts'),
|
||||
get: (id: string) => adminRequest<Script>(`/api/admin/scripts/${id}`),
|
||||
create: (input: CreateScriptInput) =>
|
||||
adminRequest<Script>('/api/admin/scripts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
update: (id: string, input: UpdateScriptInput) =>
|
||||
adminRequest<Script>(`/api/admin/scripts/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
remove: (id: string) =>
|
||||
request<void>(`/api/admin/scripts/${id}`, { method: 'DELETE' })
|
||||
adminRequest<null>(`/api/admin/scripts/${id}`, { method: 'DELETE' }),
|
||||
logs: (id: string, opts: { limit?: number; offset?: number } = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
|
||||
if (opts.offset !== undefined) params.set('offset', String(opts.offset));
|
||||
const qs = params.toString();
|
||||
return adminRequest<ExecutionLog[]>(
|
||||
`/api/admin/scripts/${id}/logs${qs ? `?${qs}` : ''}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
execute: async (
|
||||
id: string,
|
||||
body: unknown,
|
||||
extraHeaders?: Record<string, string>
|
||||
): Promise<ExecutionResult> => {
|
||||
const res = await fetch(`/api/execute/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...(extraHeaders ?? {}) },
|
||||
body: body === undefined ? '{}' : JSON.stringify(body)
|
||||
});
|
||||
const text = await res.text();
|
||||
const parsedBody: unknown = text ? safeJson(text) : null;
|
||||
const headers: Record<string, string> = {};
|
||||
res.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
return { status: res.status, headers, body: parsedBody };
|
||||
}
|
||||
};
|
||||
|
||||
17
dashboard/src/lib/styles.ts
Normal file
17
dashboard/src/lib/styles.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Shared inline styles intentionally avoided — CSS lives co-located in
|
||||
// each route's component. This file is kept so that future shared
|
||||
// constants (e.g. status colors) have a home.
|
||||
|
||||
export const statusColor: Record<string, string> = {
|
||||
success: '#22c55e',
|
||||
error: '#ef4444',
|
||||
timeout: '#f59e0b',
|
||||
budget_exceeded: '#a855f7'
|
||||
};
|
||||
|
||||
export const logLevelColor: Record<string, string> = {
|
||||
trace: '#64748b',
|
||||
info: '#38bdf8',
|
||||
warn: '#f59e0b',
|
||||
error: '#ef4444'
|
||||
};
|
||||
@@ -1,23 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { api, type Script } from '$lib/api';
|
||||
import { api, ApiError, type Script } from '$lib/api';
|
||||
|
||||
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||||
|
||||
let scripts = $state<Script[] | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let listError = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
let showCreate = $state(false);
|
||||
let createName = $state('');
|
||||
let createDescription = $state('');
|
||||
let createSource = $state(SAMPLE_SOURCE);
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
listError = null;
|
||||
try {
|
||||
scripts = await api.scripts.list();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
listError = e instanceof Error ? e.message : String(e);
|
||||
scripts = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate(event: Event) {
|
||||
event.preventDefault();
|
||||
creating = true;
|
||||
createError = null;
|
||||
try {
|
||||
await api.scripts.create({
|
||||
name: createName.trim(),
|
||||
description: createDescription.trim() || null,
|
||||
source: createSource
|
||||
});
|
||||
showCreate = false;
|
||||
createName = '';
|
||||
createDescription = '';
|
||||
createSource = SAMPLE_SOURCE;
|
||||
await load();
|
||||
} catch (e) {
|
||||
createError = e instanceof Error ? e.message : String(e);
|
||||
if (e instanceof ApiError && e.status === 422) {
|
||||
createError = `Syntax error: ${createError}`;
|
||||
}
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void load();
|
||||
});
|
||||
@@ -26,28 +60,61 @@
|
||||
<section>
|
||||
<header class="page-header">
|
||||
<h1>Scripts</h1>
|
||||
<button type="button" disabled>New script</button>
|
||||
<button type="button" onclick={() => (showCreate = !showCreate)}>
|
||||
{showCreate ? 'Cancel' : 'New script'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if showCreate}
|
||||
<form class="create-form" onsubmit={submitCreate}>
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input bind:value={createName} required minlength="1" placeholder="echo" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<input bind:value={createDescription} placeholder="optional" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="full">
|
||||
<span>Source (Rhai)</span>
|
||||
<textarea bind:value={createSource} rows="10" spellcheck="false"></textarea>
|
||||
</label>
|
||||
{#if createError}
|
||||
<div class="error">{createError}</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create script'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if error}
|
||||
{:else if listError}
|
||||
<div class="error">
|
||||
<strong>Could not load scripts.</strong>
|
||||
<p>{error}</p>
|
||||
<p class="hint">
|
||||
This is expected until <code>/api/admin/scripts</code> is implemented on the manager.
|
||||
</p>
|
||||
<p>{listError}</p>
|
||||
<button type="button" onclick={() => void load()}>Retry</button>
|
||||
</div>
|
||||
{:else if scripts && scripts.length === 0}
|
||||
<p class="muted">No scripts yet.</p>
|
||||
<p class="muted">No scripts yet. Create one above to get started.</p>
|
||||
{:else if scripts}
|
||||
<ul class="list">
|
||||
{#each scripts as script (script.id)}
|
||||
<li>
|
||||
<strong>{script.name}</strong>
|
||||
<span class="muted">v{script.version}</span>
|
||||
<a href="/scripts/{script.id}">
|
||||
<div class="primary">
|
||||
<strong>{script.name}</strong>
|
||||
<span class="muted">v{script.version}</span>
|
||||
</div>
|
||||
<div class="secondary muted">
|
||||
{script.description ?? '—'}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -92,17 +159,57 @@
|
||||
color: #fecaca;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.error code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.25rem;
|
||||
.create-form {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #fca5a5;
|
||||
font-size: 0.875rem;
|
||||
.create-form .row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.create-form label.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.create-form input,
|
||||
.create-form textarea {
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.create-form textarea {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
min-height: 8rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.list {
|
||||
@@ -114,11 +221,28 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.list li {
|
||||
padding: 0.75rem 1rem;
|
||||
.list a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: #1e293b;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.list a:hover {
|
||||
background: #283549;
|
||||
}
|
||||
|
||||
.primary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
488
dashboard/src/routes/scripts/[id]/+page.svelte
Normal file
488
dashboard/src/routes/scripts/[id]/+page.svelte
Normal file
@@ -0,0 +1,488 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError, type ExecutionLog, type Script } from '$lib/api';
|
||||
import { logLevelColor, statusColor } from '$lib/styles';
|
||||
|
||||
// Route is `/scripts/[id]` so `page.params.id` is always present.
|
||||
let id = $derived(page.params.id ?? '');
|
||||
|
||||
let script = $state<Script | null>(null);
|
||||
let scriptError = $state<string | null>(null);
|
||||
let scriptLoading = $state(true);
|
||||
|
||||
let logs = $state<ExecutionLog[] | null>(null);
|
||||
let logsError = $state<string | null>(null);
|
||||
let logsLoading = $state(true);
|
||||
|
||||
// Source editor state (in-place edit, save updates the script).
|
||||
let editableSource = $state('');
|
||||
let saving = $state(false);
|
||||
let saveError = $state<string | null>(null);
|
||||
|
||||
// Test invoke state.
|
||||
let testBody = $state('{}');
|
||||
let testHeaders = $state('{}');
|
||||
let testInProgress = $state(false);
|
||||
let testResult = $state<{
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: unknown;
|
||||
} | 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;
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
} finally {
|
||||
testInProgress = 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('/');
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : String(e));
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void loadScript();
|
||||
void loadLogs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<a class="back" href="/">← 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>
|
||||
|
||||
<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}
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveSource}
|
||||
disabled={saving || editableSource === script.source}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Test invoke -->
|
||||
<section class="card">
|
||||
<h2>Test invoke</h2>
|
||||
<label>
|
||||
<span>Request body (JSON)</span>
|
||||
<textarea bind:value={testBody} rows="5" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Headers (JSON object)</span>
|
||||
<textarea bind:value={testHeaders} rows="3" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<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}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Execution logs -->
|
||||
<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 above.</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>
|
||||
</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}
|
||||
</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;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
textarea {
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 1px solid #b91c1c;
|
||||
background: #450a0a;
|
||||
color: #fecaca;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.error.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 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.exec-list summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user