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

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