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:
MechaCat02
2026-05-23 00:16:32 +02:00
parent 4f044e7b81
commit 777f4af628
18 changed files with 1750 additions and 178 deletions

View File

@@ -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>