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 };
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user