From 777f4af628f6b2b1937185e5a3ce96c6bfac3657 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 23 May 2026 00:16:32 +0200 Subject: [PATCH] feat: persist execution logs + dashboard detail view + integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Cargo.lock | 156 ++++++ crates/manager-core/src/api.rs | 75 ++- crates/manager-core/src/lib.rs | 6 +- crates/manager-core/src/log_sink.rs | 57 ++ crates/manager-core/src/repo.rs | 103 +++- crates/orchestrator-core/src/api.rs | 104 +++- crates/picloud/Cargo.toml | 8 + crates/picloud/src/lib.rs | 114 ++++ crates/picloud/src/main.rs | 113 +--- crates/picloud/tests/api.rs | 301 +++++++++++ crates/shared/Cargo.toml | 1 + crates/shared/src/execution_log.rs | 54 ++ crates/shared/src/lib.rs | 4 + crates/shared/src/log_sink.rs | 22 + dashboard/src/lib/api.ts | 135 ++++- dashboard/src/lib/styles.ts | 17 + dashboard/src/routes/+page.svelte | 170 +++++- .../src/routes/scripts/[id]/+page.svelte | 488 ++++++++++++++++++ 18 files changed, 1750 insertions(+), 178 deletions(-) create mode 100644 crates/manager-core/src/log_sink.rs create mode 100644 crates/picloud/src/lib.rs create mode 100644 crates/picloud/tests/api.rs create mode 100644 crates/shared/src/execution_log.rs create mode 100644 crates/shared/src/log_sink.rs create mode 100644 dashboard/src/lib/styles.ts create mode 100644 dashboard/src/routes/scripts/[id]/+page.svelte diff --git a/Cargo.lock b/Cargo.lock index 6954294..c26daab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,16 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -81,6 +91,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + [[package]] name = "autocfg" version = "1.5.1" @@ -139,6 +155,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-test" +version = "17.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb1dfb84bd48bad8e4aa1acb82ed24c2bb5e855b659959b4e03b4dca118fcac" +dependencies = [ + "anyhow", + "assert-json-diff", + "auto-future", + "axum", + "bytes", + "bytesize", + "cookie", + "http", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + [[package]] name = "base64" version = "0.22.1" @@ -193,6 +239,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + [[package]] name = "cc" version = "1.2.62" @@ -264,6 +316,16 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -336,6 +398,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1082,6 +1159,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1195,6 +1278,7 @@ dependencies = [ "anyhow", "async-trait", "axum", + "axum-test", "figment", "picloud-executor-core", "picloud-manager-core", @@ -1300,6 +1384,7 @@ dependencies = [ name = "picloud-shared" version = "0.1.0" dependencies = [ + "async-trait", "chrono", "serde", "serde_json", @@ -1361,6 +1446,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1370,6 +1461,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1610,6 +1711,15 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "reserve-port" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94070964579245eb2f76e62a7668fe87bd9969ed6c41256f3bf614e3323dd3cc" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "rhai" version = "1.24.0" @@ -1674,6 +1784,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "rand 0.9.4", + "thiserror 2.0.18", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2249,6 +2374,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" diff --git a/crates/manager-core/src/api.rs b/crates/manager-core/src/api.rs index d6cb3b7..1f1ae76 100644 --- a/crates/manager-core/src/api.rs +++ b/crates/manager-core/src/api.rs @@ -11,23 +11,27 @@ use axum::{ routing::get, Json, Router, }; -use picloud_shared::{Script, ScriptId, ScriptValidator, ValidationError}; +use picloud_shared::{ExecutionLog, Script, ScriptId, ScriptValidator, ValidationError}; use serde::Deserialize; -use crate::repo::{NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError}; +use crate::repo::{ + ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError, +}; /// State shared by control-plane handlers. Separates concerns so the /// manager can validate at upload time without depending on the /// concrete executor-core types. -pub struct AdminState { +pub struct AdminState { pub repo: Arc, + pub logs: Arc, pub validator: Arc, } -impl Clone for AdminState { +impl Clone for AdminState { fn clone(&self) -> Self { Self { repo: self.repo.clone(), + logs: self.logs.clone(), validator: self.validator.clone(), } } @@ -35,15 +39,23 @@ impl Clone for AdminState { /// Build the admin router. The caller (binary) chooses where to mount /// it (typically `Router::new().nest("/api/admin", admin_router(state))`). -pub fn admin_router(state: AdminState) -> Router { +pub fn admin_router(state: AdminState) -> Router +where + R: ScriptRepository + 'static, + L: ExecutionLogRepository + 'static, +{ Router::new() - .route("/scripts", get(list_scripts::).post(create_script::)) + .route( + "/scripts", + get(list_scripts::).post(create_script::), + ) .route( "/scripts/{id}", - get(get_script::) - .put(update_script::) - .delete(delete_script::), + get(get_script::) + .put(update_script::) + .delete(delete_script::), ) + .route("/scripts/{id}/logs", get(list_logs::)) .with_state(state) } @@ -85,14 +97,14 @@ where // Handlers // ---------------------------------------------------------------------------- -async fn list_scripts( - State(state): State>, +async fn list_scripts( + State(state): State>, ) -> Result>, ApiError> { Ok(Json(state.repo.list().await?)) } -async fn get_script( - State(state): State>, +async fn get_script( + State(state): State>, Path(id): Path, ) -> Result, ApiError> { state @@ -103,8 +115,8 @@ async fn get_script( .ok_or(ApiError::NotFound(id)) } -async fn create_script( - State(state): State>, +async fn create_script( + State(state): State>, Json(input): Json, ) -> Result<(StatusCode, Json + +
+ ← Scripts + + {#if scriptLoading} +

Loading…

+ {:else if scriptError} +
{scriptError}
+ {:else if script} + + +
+ +
+

Source

+ + {#if saveError} +
{saveError}
+ {/if} +
+ +
+
+ + +
+

Test invoke

+ + +
+ +
+ {#if testError} +
{testError}
+ {/if} + {#if testResult} +
+
+ HTTP {testResult.status} +
+
{JSON.stringify(testResult.body, null, 2)}
+
+ {/if} +
+
+ + +
+
+

Recent executions

+ +
+ {#if logsError} +
{logsError}
+ {:else if logs && logs.length === 0} +

No executions yet — try the Test invoke panel above.

+ {:else if logs} +
    + {#each logs as log (log.id)} +
  • +
    + + + {log.status} + + {new Date(log.created_at).toLocaleString()} + + {log.response_code ?? '—'} · {log.duration_ms}ms + + +
    +
    + Request body +
    {JSON.stringify(log.request_body, null, 2)}
    +
    +
    + Response body +
    {JSON.stringify(log.response_body, null, 2)}
    +
    + {#if log.script_logs && log.script_logs.length > 0} +
    + Script logs +
      + {#each log.script_logs as entry} +
    • + + {entry.level} + + {entry.message} + {#if entry.data !== null && entry.data !== undefined} +
      {JSON.stringify(entry.data)}
      + {/if} +
    • + {/each} +
    +
    + {/if} +
    +
    +
  • + {/each} +
+ {/if} +
+ {/if} +
+ +