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

156
Cargo.lock generated
View File

@@ -46,6 +46,16 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 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]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -81,6 +91,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-future"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
@@ -139,6 +155,36 @@ dependencies = [
"tracing", "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]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -193,6 +239,12 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "bytesize"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.62" version = "1.2.62"
@@ -264,6 +316,16 @@ dependencies = [
"tiny-keccak", "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]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -336,6 +398,21 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -1082,6 +1159,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.46" version = "0.1.46"
@@ -1195,6 +1278,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"axum", "axum",
"axum-test",
"figment", "figment",
"picloud-executor-core", "picloud-executor-core",
"picloud-manager-core", "picloud-manager-core",
@@ -1300,6 +1384,7 @@ dependencies = [
name = "picloud-shared" name = "picloud-shared"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait",
"chrono", "chrono",
"serde", "serde",
"serde_json", "serde_json",
@@ -1361,6 +1446,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -1370,6 +1461,16 @@ dependencies = [
"zerocopy", "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]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@@ -1610,6 +1711,15 @@ dependencies = [
"webpki-roots 1.0.7", "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]] [[package]]
name = "rhai" name = "rhai"
version = "1.24.0" version = "1.24.0"
@@ -1674,6 +1784,21 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -2249,6 +2374,37 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "tiny-keccak" name = "tiny-keccak"
version = "2.0.2" version = "2.0.2"

View File

@@ -11,23 +11,27 @@ use axum::{
routing::get, routing::get,
Json, Router, Json, Router,
}; };
use picloud_shared::{Script, ScriptId, ScriptValidator, ValidationError}; use picloud_shared::{ExecutionLog, Script, ScriptId, ScriptValidator, ValidationError};
use serde::Deserialize; 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 /// State shared by control-plane handlers. Separates concerns so the
/// manager can validate at upload time without depending on the /// manager can validate at upload time without depending on the
/// concrete executor-core types. /// concrete executor-core types.
pub struct AdminState<R> { pub struct AdminState<R, L> {
pub repo: Arc<R>, pub repo: Arc<R>,
pub logs: Arc<L>,
pub validator: Arc<dyn ScriptValidator>, pub validator: Arc<dyn ScriptValidator>,
} }
impl<R> Clone for AdminState<R> { impl<R, L> Clone for AdminState<R, L> {
fn clone(&self) -> Self { fn clone(&self) -> Self {
Self { Self {
repo: self.repo.clone(), repo: self.repo.clone(),
logs: self.logs.clone(),
validator: self.validator.clone(), validator: self.validator.clone(),
} }
} }
@@ -35,15 +39,23 @@ impl<R> Clone for AdminState<R> {
/// Build the admin router. The caller (binary) chooses where to mount /// Build the admin router. The caller (binary) chooses where to mount
/// it (typically `Router::new().nest("/api/admin", admin_router(state))`). /// it (typically `Router::new().nest("/api/admin", admin_router(state))`).
pub fn admin_router<R: ScriptRepository + 'static>(state: AdminState<R>) -> Router { pub fn admin_router<R, L>(state: AdminState<R, L>) -> Router
where
R: ScriptRepository + 'static,
L: ExecutionLogRepository + 'static,
{
Router::new() Router::new()
.route("/scripts", get(list_scripts::<R>).post(create_script::<R>)) .route(
"/scripts",
get(list_scripts::<R, L>).post(create_script::<R, L>),
)
.route( .route(
"/scripts/{id}", "/scripts/{id}",
get(get_script::<R>) get(get_script::<R, L>)
.put(update_script::<R>) .put(update_script::<R, L>)
.delete(delete_script::<R>), .delete(delete_script::<R, L>),
) )
.route("/scripts/{id}/logs", get(list_logs::<R, L>))
.with_state(state) .with_state(state)
} }
@@ -85,14 +97,14 @@ where
// Handlers // Handlers
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
async fn list_scripts<R: ScriptRepository>( async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R>>, State(state): State<AdminState<R, L>>,
) -> Result<Json<Vec<Script>>, ApiError> { ) -> Result<Json<Vec<Script>>, ApiError> {
Ok(Json(state.repo.list().await?)) Ok(Json(state.repo.list().await?))
} }
async fn get_script<R: ScriptRepository>( async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R>>, State(state): State<AdminState<R, L>>,
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
) -> Result<Json<Script>, ApiError> { ) -> Result<Json<Script>, ApiError> {
state state
@@ -103,8 +115,8 @@ async fn get_script<R: ScriptRepository>(
.ok_or(ApiError::NotFound(id)) .ok_or(ApiError::NotFound(id))
} }
async fn create_script<R: ScriptRepository>( async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R>>, State(state): State<AdminState<R, L>>,
Json(input): Json<CreateScriptRequest>, Json(input): Json<CreateScriptRequest>,
) -> Result<(StatusCode, Json<Script>), ApiError> { ) -> Result<(StatusCode, Json<Script>), ApiError> {
state.validator.validate(&input.source)?; state.validator.validate(&input.source)?;
@@ -121,8 +133,8 @@ async fn create_script<R: ScriptRepository>(
Ok((StatusCode::CREATED, Json(created))) Ok((StatusCode::CREATED, Json(created)))
} }
async fn update_script<R: ScriptRepository>( async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R>>, State(state): State<AdminState<R, L>>,
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
Json(input): Json<UpdateScriptRequest>, Json(input): Json<UpdateScriptRequest>,
) -> Result<Json<Script>, ApiError> { ) -> Result<Json<Script>, ApiError> {
@@ -145,14 +157,39 @@ async fn update_script<R: ScriptRepository>(
Ok(Json(updated)) Ok(Json(updated))
} }
async fn delete_script<R: ScriptRepository>( async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R>>, State(state): State<AdminState<R, L>>,
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
state.repo.delete(id).await?; state.repo.delete(id).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[derive(Debug, Deserialize)]
pub struct LogsQuery {
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
const fn default_limit() -> i64 {
50
}
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Path(id): Path<ScriptId>,
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
// Cap to keep the dashboard responsive; the data plane writes are
// unbounded over time so a paged read is the only sane default.
let limit = q.limit.clamp(1, 200);
let offset = q.offset.max(0);
let logs = state.logs.list_for_script(id, limit, offset).await?;
Ok(Json(logs))
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Errors // Errors
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@@ -5,12 +5,14 @@
//! manager will publish change events. //! manager will publish change events.
pub mod api; pub mod api;
pub mod log_sink;
pub mod migrations; pub mod migrations;
pub mod repo; pub mod repo;
pub mod scheduler; pub mod scheduler;
pub use api::{admin_router, AdminState}; pub use api::{admin_router, AdminState};
pub use log_sink::PostgresExecutionLogSink;
pub use repo::{ pub use repo::{
NewScript, PostgresScriptRepository, RepoResolver, ScriptPatch, ScriptRepository, ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
ScriptRepositoryError, RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError,
}; };

View File

@@ -0,0 +1,57 @@
use async_trait::async_trait;
use picloud_shared::{ExecutionLog, ExecutionLogSink, LogSinkError};
use sqlx::PgPool;
/// Persists `ExecutionLog` rows to the `execution_logs` table.
///
/// In cluster mode this impl lives in the manager and is reachable
/// from orchestrator nodes via an HTTP wrapper; in single-process MVP
/// mode the orchestrator's `DataPlaneState` holds it directly.
pub struct PostgresExecutionLogSink {
pool: PgPool,
}
impl PostgresExecutionLogSink {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ExecutionLogSink for PostgresExecutionLogSink {
async fn record(&self, log: ExecutionLog) -> Result<(), LogSinkError> {
let headers = serde_json::to_value(&log.request_headers)
.map_err(|e| LogSinkError::Backend(format!("encode headers: {e}")))?;
let response_code = log.response_code.map(i32::from);
let duration_ms = i32::try_from(log.duration_ms).unwrap_or(i32::MAX);
sqlx::query(
"INSERT INTO execution_logs ( \
id, script_id, request_id, \
request_path, request_headers, request_body, \
response_code, response_body, \
logs, duration_ms, status, created_at \
) VALUES ( \
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 \
)",
)
.bind(log.id)
.bind(log.script_id.into_inner())
.bind(log.request_id.into_inner())
.bind(&log.request_path)
.bind(headers)
.bind(&log.request_body)
.bind(response_code)
.bind(&log.response_body)
.bind(&log.script_logs)
.bind(duration_ms)
.bind(log.status.as_str())
.bind(log.created_at)
.execute(&self.pool)
.await
.map_err(|e| LogSinkError::Backend(e.to_string()))?;
Ok(())
}
}

View File

@@ -1,6 +1,8 @@
use std::collections::BTreeMap;
use async_trait::async_trait; use async_trait::async_trait;
use picloud_orchestrator_core::{ResolverError, ScriptResolver}; use picloud_orchestrator_core::{ResolverError, ScriptResolver};
use picloud_shared::{Script, ScriptId}; use picloud_shared::{ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId};
use sqlx::PgPool; use sqlx::PgPool;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -217,3 +219,102 @@ impl<R: ScriptRepository> ScriptResolver for RepoResolver<R> {
.map_err(|e| ResolverError::Backend(e.to_string())) .map_err(|e| ResolverError::Backend(e.to_string()))
} }
} }
// ----------------------------------------------------------------------------
// Execution log repository (read side)
// ----------------------------------------------------------------------------
/// Read-side access to the `execution_logs` table. Writes go through
/// `PostgresExecutionLogSink` so the read and write paths can diverge
/// in cluster mode without disturbing this trait.
#[async_trait]
pub trait ExecutionLogRepository: Send + Sync {
async fn list_for_script(
&self,
script_id: ScriptId,
limit: i64,
offset: i64,
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError>;
}
pub struct PostgresExecutionLogRepository {
pool: PgPool,
}
impl PostgresExecutionLogRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ExecutionLogRepository for PostgresExecutionLogRepository {
async fn list_for_script(
&self,
script_id: ScriptId,
limit: i64,
offset: i64,
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ExecutionLogRow>(
"SELECT id, script_id, request_id, \
request_path, request_headers, request_body, \
response_code, response_body, \
logs, duration_ms, status, created_at \
FROM execution_logs \
WHERE script_id = $1 \
ORDER BY created_at DESC \
LIMIT $2 OFFSET $3",
)
.bind(script_id.into_inner())
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
}
#[derive(sqlx::FromRow)]
struct ExecutionLogRow {
id: uuid::Uuid,
script_id: uuid::Uuid,
request_id: uuid::Uuid,
request_path: Option<String>,
request_headers: serde_json::Value,
request_body: Option<serde_json::Value>,
response_code: Option<i32>,
response_body: Option<serde_json::Value>,
logs: serde_json::Value,
duration_ms: i32,
status: String,
created_at: chrono::DateTime<chrono::Utc>,
}
impl From<ExecutionLogRow> for ExecutionLog {
fn from(r: ExecutionLogRow) -> Self {
let headers: BTreeMap<String, String> =
serde_json::from_value(r.request_headers).unwrap_or_default();
let status = match r.status.as_str() {
"success" => ExecutionStatus::Success,
"timeout" => ExecutionStatus::Timeout,
"budget_exceeded" => ExecutionStatus::BudgetExceeded,
_ => ExecutionStatus::Error,
};
Self {
id: r.id,
script_id: r.script_id.into(),
request_id: RequestId::from(r.request_id),
request_path: r.request_path.unwrap_or_default(),
request_headers: headers,
request_body: r.request_body.unwrap_or(serde_json::Value::Null),
response_code: r.response_code.and_then(|c| u16::try_from(c).ok()),
response_body: r.response_body,
script_logs: r.logs,
duration_ms: u64::try_from(r.duration_ms).unwrap_or(0),
status,
created_at: r.created_at,
}
}
}

View File

@@ -14,20 +14,22 @@ use axum::{
routing::post, routing::post,
Json, Router, Json, Router,
}; };
use picloud_executor_core::{ExecError, ExecRequest, InvocationType}; use chrono::Utc;
use picloud_shared::{ExecutionId, RequestId, ScriptId}; use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
use picloud_shared::{
ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
};
use serde_json::Value as Json_; use serde_json::Value as Json_;
use uuid::Uuid;
use crate::client::ExecutorClient; use crate::client::ExecutorClient;
use crate::resolver::{ResolverError, ScriptResolver}; use crate::resolver::{ResolverError, ScriptResolver};
/// State shared by data-plane handlers. /// State shared by data-plane handlers.
///
/// Both fields are `Arc` because handlers run concurrently; the
/// underlying impls are `Send + Sync` (enforced by their traits).
pub struct DataPlaneState<E, R> { pub struct DataPlaneState<E, R> {
pub executor: Arc<E>, pub executor: Arc<E>,
pub resolver: Arc<R>, pub resolver: Arc<R>,
pub log_sink: Arc<dyn ExecutionLogSink>,
} }
impl<E, R> Clone for DataPlaneState<E, R> { impl<E, R> Clone for DataPlaneState<E, R> {
@@ -35,6 +37,7 @@ impl<E, R> Clone for DataPlaneState<E, R> {
Self { Self {
executor: self.executor.clone(), executor: self.executor.clone(),
resolver: self.resolver.clone(), resolver: self.resolver.clone(),
log_sink: self.log_sink.clone(),
} }
} }
} }
@@ -71,11 +74,35 @@ where
.ok_or(ApiError::NotFound(id))?; .ok_or(ApiError::NotFound(id))?;
let req = build_exec_request(id, &script.name, &headers, &body)?; let req = build_exec_request(id, &script.name, &headers, &body)?;
let request_id = req.request_id;
let request_path = req.path.clone();
let request_headers = req.headers.clone();
let request_body = req.body.clone();
let timeout = Duration::from_secs(u64::from(script.timeout_seconds)); let timeout = Duration::from_secs(u64::from(script.timeout_seconds));
let resp = state.executor.execute(&script.source, req, timeout).await?; let started = Utc::now();
let outcome = state.executor.execute(&script.source, req, timeout).await;
let finished = Utc::now();
Ok(exec_response_to_http(resp)) // Build and dispatch the audit log regardless of outcome. We await
// the sink — recording the trail is part of correctness for an
// audit-visible platform — but a sink failure must not mask the
// user-facing result, so we only log a warning if it fails.
let log = build_execution_log(
id,
request_id,
request_path,
request_headers,
request_body,
&outcome,
started,
finished,
);
if let Err(e) = state.log_sink.record(log).await {
tracing::warn!(error = %e, script_id = %id, "failed to persist execution log");
}
Ok(exec_response_to_http(outcome?))
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -114,7 +141,7 @@ fn build_exec_request(
}) })
} }
fn exec_response_to_http(resp: picloud_executor_core::ExecResponse) -> Response { fn exec_response_to_http(resp: ExecResponse) -> Response {
let status = let status =
StatusCode::from_u16(resp.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); StatusCode::from_u16(resp.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
@@ -124,7 +151,6 @@ fn exec_response_to_http(resp: picloud_executor_core::ExecResponse) -> Response
http_headers.insert(name, value); http_headers.insert(name, value);
} }
} }
// Default content type to JSON; the script can override via `headers`.
http_headers http_headers
.entry(axum::http::header::CONTENT_TYPE) .entry(axum::http::header::CONTENT_TYPE)
.or_insert_with(|| HeaderValue::from_static("application/json")); .or_insert_with(|| HeaderValue::from_static("application/json"));
@@ -132,6 +158,66 @@ fn exec_response_to_http(resp: picloud_executor_core::ExecResponse) -> Response
(status, http_headers, Json(resp.body)).into_response() (status, http_headers, Json(resp.body)).into_response()
} }
#[allow(clippy::too_many_arguments)]
fn build_execution_log(
script_id: ScriptId,
request_id: RequestId,
request_path: String,
request_headers: BTreeMap<String, String>,
request_body: Json_,
outcome: &Result<ExecResponse, ExecError>,
started: chrono::DateTime<Utc>,
finished: chrono::DateTime<Utc>,
) -> ExecutionLog {
let duration_ms = u64::try_from(
finished
.signed_duration_since(started)
.num_milliseconds()
.max(0),
)
.unwrap_or(0);
let (status, response_code, response_body, script_logs) = match outcome {
Ok(resp) => {
let logs = serde_json::to_value(&resp.logs).unwrap_or(Json_::Array(vec![]));
(
ExecutionStatus::Success,
Some(resp.status_code),
Some(resp.body.clone()),
logs,
)
}
Err(e) => {
let status = match e {
ExecError::Timeout(_) => ExecutionStatus::Timeout,
ExecError::OperationBudgetExceeded => ExecutionStatus::BudgetExceeded,
_ => ExecutionStatus::Error,
};
(
status,
None,
Some(serde_json::json!({ "error": e.to_string() })),
Json_::Array(vec![]),
)
}
};
ExecutionLog {
id: Uuid::new_v4(),
script_id,
request_id,
request_path,
request_headers,
request_body,
response_code,
response_body,
script_logs,
duration_ms,
status,
created_at: started,
}
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Errors // Errors
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@@ -8,6 +8,9 @@ license.workspace = true
[lints] [lints]
workspace = true workspace = true
[lib]
path = "src/lib.rs"
[[bin]] [[bin]]
name = "picloud" name = "picloud"
path = "src/main.rs" path = "src/main.rs"
@@ -31,3 +34,8 @@ thiserror.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
figment.workspace = true figment.workspace = true
[dev-dependencies]
axum-test = "17"
serde.workspace = true
serde_json.workspace = true

114
crates/picloud/src/lib.rs Normal file
View File

@@ -0,0 +1,114 @@
//! Library half of the picloud all-in-one. `main.rs` is a thin wrapper
//! that opens the pool, runs migrations, calls `build_app`, and binds
//! the listener. Tests use the same `build_app` against an
//! ephemeral test database.
use std::sync::Arc;
use std::time::Duration;
use axum::{routing::get, Router};
use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{
admin_router, AdminState, PostgresExecutionLogRepository, PostgresExecutionLogSink,
PostgresScriptRepository, RepoResolver,
};
use picloud_orchestrator_core::{data_plane_router, DataPlaneState, LocalExecutorClient};
use picloud_shared::{ExecutionLogSink, ScriptValidator};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tower_http::trace::TraceLayer;
/// Compose the manager + orchestrator routes on top of a shared
/// Postgres pool, returning an Axum router ready to be served.
pub fn build_app(pool: PgPool) -> Router {
let engine = Arc::new(Engine::new(Limits::default()));
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool));
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
script_repo.clone(),
)));
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
let admin = AdminState {
repo: Arc::new(PostgresScriptRepoHandle(script_repo)),
logs: log_repo,
validator: engine as Arc<dyn ScriptValidator>,
};
let data_plane = DataPlaneState {
executor,
resolver,
log_sink,
};
Router::new()
.route("/healthz", get(healthz))
.route("/", get(root))
.nest("/api/admin", admin_router(admin))
.nest("/api", data_plane_router(data_plane))
.layer(TraceLayer::new_for_http())
}
/// Open a Postgres pool with the binary's standard timeout settings.
/// Exposed so tests reach for the same configuration when needed.
pub async fn init_db(url: &str) -> anyhow::Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(Duration::from_secs(5))
.connect(url)
.await?;
Ok(pool)
}
async fn healthz() -> &'static str {
"ok"
}
async fn root() -> &'static str {
"picloud — see /api/admin/* (manager) and /api/execute/* (orchestrator)"
}
// ----------------------------------------------------------------------------
// Bridge: a single `PostgresScriptRepository` Arc is shared between the
// admin router (writes) and the resolver (reads). The resolver wants
// owned `impl ScriptRepository`, so we wrap the Arc in a delegating
// handle here rather than instantiating two repos against the same pool.
// ----------------------------------------------------------------------------
struct PostgresScriptRepoHandle(Arc<PostgresScriptRepository>);
#[async_trait::async_trait]
impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
async fn get(
&self,
id: picloud_shared::ScriptId,
) -> Result<Option<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.get(id).await
}
async fn list(
&self,
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list().await
}
async fn create(
&self,
input: picloud_manager_core::NewScript,
) -> Result<picloud_shared::Script, picloud_manager_core::ScriptRepositoryError> {
self.0.create(input).await
}
async fn update(
&self,
id: picloud_shared::ScriptId,
patch: picloud_manager_core::ScriptPatch,
) -> Result<picloud_shared::Script, picloud_manager_core::ScriptRepositoryError> {
self.0.update(id, patch).await
}
async fn delete(
&self,
id: picloud_shared::ScriptId,
) -> Result<(), picloud_manager_core::ScriptRepositoryError> {
self.0.delete(id).await
}
}

View File

@@ -1,32 +1,11 @@
//! PiCloud all-in-one binary — manager + orchestrator + executor in //! PiCloud all-in-one binary — see `lib.rs` for the actual app
//! one process. The only binary built for MVP. //! composition; this file is only the runtime shell (env config,
//! //! logger, migrations, listener).
//! On startup it opens the Postgres pool, runs migrations, builds the
//! Rhai engine, then nests both core routers behind a single Axum
//! listener:
//!
//! /api/admin/* → manager-core (script CRUD)
//! /api/execute/{id} → orchestrator-core (data plane)
//! /healthz → liveness probe
//!
//! Cluster-mode (v1.3+) keeps this layout — splits each nested router
//! into its own binary, swaps `LocalExecutorClient` for the remote one,
//! and points Caddy at the new upstreams.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::{routing::get, Router}; use picloud::{build_app, init_db};
use picloud_executor_core::{Engine, Limits}; use picloud_manager_core::migrations;
use picloud_manager_core::{
admin_router, migrations, AdminState, PostgresScriptRepository, RepoResolver,
};
use picloud_orchestrator_core::{data_plane_router, DataPlaneState, LocalExecutorClient};
use picloud_shared::ScriptValidator;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
#[tokio::main] #[tokio::main]
@@ -61,45 +40,6 @@ fn init_tracing() {
.init(); .init();
} }
async fn init_db(url: &str) -> anyhow::Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(Duration::from_secs(5))
.connect(url)
.await?;
Ok(pool)
}
fn build_app(pool: PgPool) -> Router {
// Core services. The `Arc`s let the routers and any background
// tasks share the same instances cheaply.
let engine = Arc::new(Engine::new(Limits::default()));
let repo = Arc::new(PostgresScriptRepository::new(pool));
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(repo.clone())));
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
let admin = AdminState {
repo: Arc::new(PostgresScriptRepoHandle(repo)),
validator: engine as Arc<dyn ScriptValidator>,
};
let data_plane = DataPlaneState { executor, resolver };
Router::new()
.route("/healthz", get(healthz))
.route("/", get(root))
.nest("/api/admin", admin_router(admin))
.nest("/api", data_plane_router(data_plane))
.layer(TraceLayer::new_for_http())
}
async fn healthz() -> &'static str {
"ok"
}
async fn root() -> &'static str {
"picloud — see /api/admin/* (manager) and /api/execute/* (orchestrator)"
}
async fn shutdown_signal() { async fn shutdown_signal() {
let ctrl_c = async { let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await; let _ = tokio::signal::ctrl_c().await;
@@ -119,46 +59,3 @@ async fn shutdown_signal() {
() = terminate => tracing::info!("SIGTERM received, draining"), () = terminate => tracing::info!("SIGTERM received, draining"),
} }
} }
// ----------------------------------------------------------------------------
// Bridge: PostgresScriptRepository is constructed once and shared via
// Arc; `RepoResolver` wants ownership of an impl of `ScriptRepository`.
// We pass a thin wrapper that delegates to the Arc'd repo, so a single
// connection pool backs both the admin router and the resolver.
// ----------------------------------------------------------------------------
struct PostgresScriptRepoHandle(Arc<PostgresScriptRepository>);
#[async_trait::async_trait]
impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
async fn get(
&self,
id: picloud_shared::ScriptId,
) -> Result<Option<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.get(id).await
}
async fn list(
&self,
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list().await
}
async fn create(
&self,
input: picloud_manager_core::NewScript,
) -> Result<picloud_shared::Script, picloud_manager_core::ScriptRepositoryError> {
self.0.create(input).await
}
async fn update(
&self,
id: picloud_shared::ScriptId,
patch: picloud_manager_core::ScriptPatch,
) -> Result<picloud_shared::Script, picloud_manager_core::ScriptRepositoryError> {
self.0.update(id, patch).await
}
async fn delete(
&self,
id: picloud_shared::ScriptId,
) -> Result<(), picloud_manager_core::ScriptRepositoryError> {
self.0.delete(id).await
}
}

301
crates/picloud/tests/api.rs Normal file
View File

@@ -0,0 +1,301 @@
//! Integration tests over the full HTTP surface.
//!
//! These tests are `#[ignore]`d by default because they require a
//! running Postgres reachable via `DATABASE_URL`. To run them:
//!
//! docker compose up -d postgres
//! DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
//! cargo test -p picloud --test api -- --include-ignored
//!
//! Each `#[sqlx::test]` test runs against a freshly created database
//! with `manager-core`'s migrations applied; tests are isolated and
//! can run in parallel.
#![allow(clippy::needless_pass_by_value)]
use axum_test::TestServer;
use serde_json::{json, Value};
use sqlx::PgPool;
fn server(pool: PgPool) -> TestServer {
TestServer::new(picloud::build_app(pool)).expect("TestServer should build")
}
// ============================================================================
// Health
// ============================================================================
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn healthz_responds_ok(pool: PgPool) {
let r = server(pool).get("/healthz").await;
r.assert_status_ok();
assert_eq!(r.text(), "ok");
}
// ============================================================================
// Script CRUD
// ============================================================================
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_script_returns_201_with_full_record(pool: PgPool) {
let s = server(pool);
let r = s
.post("/api/admin/scripts")
.json(&json!({
"name": "echo",
"description": "test",
"source": "#{ statusCode: 200, body: 42 }",
}))
.await;
r.assert_status(axum::http::StatusCode::CREATED);
let body: Value = r.json();
assert_eq!(body["name"], "echo");
assert_eq!(body["version"], 1);
assert_eq!(body["timeout_seconds"], 30);
assert!(body["id"].as_str().is_some());
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
let r = server(pool)
.post("/api/admin/scripts")
.json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" }))
.await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
let body: Value = r.json();
assert!(body["error"].as_str().unwrap().contains("invalid script"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn duplicate_name_returns_409(pool: PgPool) {
let s = server(pool);
s.post("/api/admin/scripts")
.json(&json!({ "name": "dup", "source": "42" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s
.post("/api/admin/scripts")
.json(&json!({ "name": "dup", "source": "43" }))
.await;
r.assert_status(axum::http::StatusCode::CONFLICT);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn list_returns_all_scripts(pool: PgPool) {
let s = server(pool);
for name in ["alpha", "bravo", "charlie"] {
s.post("/api/admin/scripts")
.json(&json!({ "name": name, "source": "1" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
}
let r = s.get("/api/admin/scripts").await;
r.assert_status_ok();
let body: Vec<Value> = r.json();
assert_eq!(body.len(), 3);
let names: Vec<&str> = body.iter().map(|s| s["name"].as_str().unwrap()).collect();
assert_eq!(names, vec!["alpha", "bravo", "charlie"]);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn update_bumps_version_and_persists_changes(pool: PgPool) {
let s = server(pool);
let created: Value = s
.post("/api/admin/scripts")
.json(&json!({ "name": "u", "source": "1" }))
.await
.json();
let id = created["id"].as_str().unwrap();
let r = s
.put(&format!("/api/admin/scripts/{id}"))
.json(&json!({ "source": "#{ statusCode: 200, body: \"v2\" }", "timeout_seconds": 60 }))
.await;
r.assert_status_ok();
let updated: Value = r.json();
assert_eq!(updated["version"], 2);
assert_eq!(updated["timeout_seconds"], 60);
assert!(updated["source"].as_str().unwrap().contains("v2"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn update_with_invalid_source_returns_422(pool: PgPool) {
let s = server(pool);
let created: Value = s
.post("/api/admin/scripts")
.json(&json!({ "name": "u", "source": "1" }))
.await
.json();
let id = created["id"].as_str().unwrap();
let r = s
.put(&format!("/api/admin/scripts/{id}"))
.json(&json!({ "source": "@@@ broken @@@" }))
.await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn delete_then_get_returns_404(pool: PgPool) {
let s = server(pool);
let created: Value = s
.post("/api/admin/scripts")
.json(&json!({ "name": "d", "source": "1" }))
.await
.json();
let id = created["id"].as_str().unwrap();
s.delete(&format!("/api/admin/scripts/{id}"))
.await
.assert_status(axum::http::StatusCode::NO_CONTENT);
s.get(&format!("/api/admin/scripts/{id}"))
.await
.assert_status_not_found();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn get_nonexistent_returns_404(pool: PgPool) {
let r = server(pool)
.get("/api/admin/scripts/00000000-0000-0000-0000-000000000000")
.await;
r.assert_status_not_found();
}
// ============================================================================
// Execution + audit logs
// ============================================================================
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execute_echoes_body_back(pool: PgPool) {
let s = server(pool);
let created: Value = s
.post("/api/admin/scripts")
.json(&json!({
"name": "echo",
"source": "#{ statusCode: 200, body: ctx.request.body }",
}))
.await
.json();
let id = created["id"].as_str().unwrap();
let r = s
.post(&format!("/api/execute/{id}"))
.json(&json!({ "n": 42 }))
.await;
r.assert_status_ok();
let body: Value = r.json();
assert_eq!(body, json!({ "n": 42 }));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execute_passes_through_status_and_headers(pool: PgPool) {
let s = server(pool);
let created: Value = s
.post("/api/admin/scripts")
.json(&json!({
"name": "header-test",
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
}))
.await
.json();
let id = created["id"].as_str().unwrap();
let r = s.post(&format!("/api/execute/{id}")).json(&json!({})).await;
r.assert_status(axum::http::StatusCode::CREATED);
assert_eq!(r.header("x-tag"), "on");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execute_nonexistent_returns_404(pool: PgPool) {
let r = server(pool)
.post("/api/execute/00000000-0000-0000-0000-000000000000")
.json(&json!({}))
.await;
r.assert_status_not_found();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execution_logs_capture_invocations(pool: PgPool) {
let s = server(pool);
let created: Value = s
.post("/api/admin/scripts")
.json(&json!({
"name": "logger",
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
}))
.await
.json();
let id = created["id"].as_str().unwrap();
// No logs yet.
let r = s.get(&format!("/api/admin/scripts/{id}/logs")).await;
r.assert_status_ok();
let logs: Vec<Value> = r.json();
assert!(logs.is_empty());
// Two invocations.
s.post(&format!("/api/execute/{id}"))
.json(&json!({ "first": true }))
.await
.assert_status_ok();
s.post(&format!("/api/execute/{id}"))
.json(&json!({ "second": true }))
.await
.assert_status_ok();
let logs: Vec<Value> = s.get(&format!("/api/admin/scripts/{id}/logs")).await.json();
assert_eq!(logs.len(), 2);
// Most-recent-first ordering.
assert_eq!(logs[0]["request_body"], json!({ "second": true }));
assert_eq!(logs[1]["request_body"], json!({ "first": true }));
// Status + response shape captured.
assert_eq!(logs[0]["status"], "success");
assert_eq!(logs[0]["response_code"], 200);
assert_eq!(logs[0]["response_body"], json!("done"));
// Script-side log entries captured.
let entries = logs[0]["script_logs"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["level"], "info");
assert_eq!(entries[0]["message"], "called");
assert_eq!(entries[0]["data"], json!({ "marker": 7 }));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execution_errors_are_still_logged(pool: PgPool) {
let s = server(pool);
let created: Value = s
.post("/api/admin/scripts")
.json(&json!({
"name": "boom",
"source": "1 / 0",
}))
.await
.json();
let id = created["id"].as_str().unwrap();
let r = s.post(&format!("/api/execute/{id}")).json(&json!({})).await;
r.assert_status(axum::http::StatusCode::BAD_GATEWAY);
let logs: Vec<Value> = s.get(&format!("/api/admin/scripts/{id}/logs")).await.json();
assert_eq!(logs.len(), 1);
assert_eq!(logs[0]["status"], "error");
assert!(logs[0]["response_body"]["error"].is_string());
}

View File

@@ -9,6 +9,7 @@ license.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
async-trait.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true

View File

@@ -0,0 +1,54 @@
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{RequestId, ScriptId};
/// One row in the `execution_logs` table. Same shape flows through the
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionLog {
pub id: Uuid,
pub script_id: ScriptId,
pub request_id: RequestId,
pub request_path: String,
pub request_headers: BTreeMap<String, String>,
pub request_body: serde_json::Value,
pub response_code: Option<u16>,
pub response_body: Option<serde_json::Value>,
/// `log::*` entries captured during the execution, serialized as a
/// JSON array of `{timestamp, level, message, data}` objects.
pub script_logs: serde_json::Value,
pub duration_ms: u64,
pub status: ExecutionStatus,
pub created_at: DateTime<Utc>,
}
/// Matches the CHECK constraint on `execution_logs.status`. Keep the
/// serde rename in sync with the migration.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionStatus {
Success,
Error,
Timeout,
BudgetExceeded,
}
impl ExecutionStatus {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Success => "success",
Self::Error => "error",
Self::Timeout => "timeout",
Self::BudgetExceeded => "budget_exceeded",
}
}
}

View File

@@ -5,11 +5,15 @@
//! entity, error roots, transport DTOs). //! entity, error roots, transport DTOs).
pub mod error; pub mod error;
pub mod execution_log;
pub mod ids; pub mod ids;
pub mod log_sink;
pub mod script; pub mod script;
pub mod validator; pub mod validator;
pub use error::Error; pub use error::Error;
pub use execution_log::{ExecutionLog, ExecutionStatus};
pub use ids::{ExecutionId, RequestId, ScriptId}; pub use ids::{ExecutionId, RequestId, ScriptId};
pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use script::Script; pub use script::Script;
pub use validator::{ScriptValidator, ValidationError}; pub use validator::{ScriptValidator, ValidationError};

View File

@@ -0,0 +1,22 @@
//! Abstraction over how execution logs are recorded.
//!
//! Lives in `shared` so the orchestrator can append logs without
//! depending on `manager-core`. In single-process MVP mode, the
//! manager's Postgres sink is plugged in directly. In cluster mode
//! (v1.3+) the orchestrator's impl will post over HTTP to the manager.
use async_trait::async_trait;
use thiserror::Error;
use crate::execution_log::ExecutionLog;
#[derive(Debug, Error)]
pub enum LogSinkError {
#[error("sink backend error: {0}")]
Backend(String),
}
#[async_trait]
pub trait ExecutionLogSink: Send + Sync {
async fn record(&self, log: ExecutionLog) -> Result<(), LogSinkError>;
}

View File

@@ -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 // The dashboard primarily targets `/api/admin/*` (manager). The
// (script invocations) go to `/api/execute/*` (orchestrator). The // data-plane (`/api/execute/*`, orchestrator) is reachable through
// dashboard only talks to the control plane — data-plane invocations // the same Caddy upstream so the "Test invoke" panel can hit it
// from the dashboard go through the same path as any external caller. // without any cross-origin gymnastics.
export interface Script { export interface Script {
id: string; id: string;
@@ -17,29 +17,132 @@ export interface Script {
updated_at: string; 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, { const res = await fetch(path, {
...init, ...init,
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) } headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }
}); });
const text = await res.text();
const parsed: unknown = text ? safeJson(text) : null;
if (!res.ok) { if (!res.ok) {
const body = await res.text().catch(() => ''); const message =
throw new Error(`${res.status} ${res.statusText}: ${body}`); (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 = { export const api = {
health: () => fetch('/healthz').then((r) => r.text()), health: () => fetch('/healthz').then((r) => r.text()),
scripts: { scripts: {
list: () => request<Script[]>('/api/admin/scripts'), list: () => adminRequest<Script[]>('/api/admin/scripts'),
get: (id: string) => request<Script>(`/api/admin/scripts/${id}`), get: (id: string) => adminRequest<Script>(`/api/admin/scripts/${id}`),
create: (input: Partial<Script>) => create: (input: CreateScriptInput) =>
request<Script>('/api/admin/scripts', { method: 'POST', body: JSON.stringify(input) }), adminRequest<Script>('/api/admin/scripts', {
update: (id: string, input: Partial<Script>) => method: 'POST',
request<Script>(`/api/admin/scripts/${id}`, { method: 'PUT', body: JSON.stringify(input) }), body: JSON.stringify(input)
}),
update: (id: string, input: UpdateScriptInput) =>
adminRequest<Script>(`/api/admin/scripts/${id}`, {
method: 'PUT',
body: JSON.stringify(input)
}),
remove: (id: string) => 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 };
} }
}; };

View 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'
};

View File

@@ -1,23 +1,57 @@
<script lang="ts"> <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 scripts = $state<Script[] | null>(null);
let error = $state<string | null>(null); let listError = $state<string | null>(null);
let loading = $state(true); 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() { async function load() {
loading = true; loading = true;
error = null; listError = null;
try { try {
scripts = await api.scripts.list(); scripts = await api.scripts.list();
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : String(e); listError = e instanceof Error ? e.message : String(e);
scripts = null; scripts = null;
} finally { } finally {
loading = false; 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(() => { $effect(() => {
void load(); void load();
}); });
@@ -26,28 +60,61 @@
<section> <section>
<header class="page-header"> <header class="page-header">
<h1>Scripts</h1> <h1>Scripts</h1>
<button type="button" disabled>New script</button> <button type="button" onclick={() => (showCreate = !showCreate)}>
{showCreate ? 'Cancel' : 'New script'}
</button>
</header> </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} {#if loading}
<p class="muted">Loading…</p> <p class="muted">Loading…</p>
{:else if error} {:else if listError}
<div class="error"> <div class="error">
<strong>Could not load scripts.</strong> <strong>Could not load scripts.</strong>
<p>{error}</p> <p>{listError}</p>
<p class="hint">
This is expected until <code>/api/admin/scripts</code> is implemented on the manager.
</p>
<button type="button" onclick={() => void load()}>Retry</button> <button type="button" onclick={() => void load()}>Retry</button>
</div> </div>
{:else if scripts && scripts.length === 0} {: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} {:else if scripts}
<ul class="list"> <ul class="list">
{#each scripts as script (script.id)} {#each scripts as script (script.id)}
<li> <li>
<strong>{script.name}</strong> <a href="/scripts/{script.id}">
<span class="muted">v{script.version}</span> <div class="primary">
<strong>{script.name}</strong>
<span class="muted">v{script.version}</span>
</div>
<div class="secondary muted">
{script.description ?? '—'}
</div>
</a>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -92,17 +159,57 @@
color: #fecaca; color: #fecaca;
padding: 1rem; padding: 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
margin: 1rem 0;
} }
.error code { .create-form {
background: rgba(255, 255, 255, 0.1); background: #1e293b;
padding: 0.1rem 0.3rem; border-radius: 0.5rem;
border-radius: 0.25rem; padding: 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
} }
.hint { .create-form .row {
color: #fca5a5; display: grid;
font-size: 0.875rem; 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 { .list {
@@ -114,11 +221,28 @@
gap: 0.5rem; gap: 0.5rem;
} }
.list li { .list a {
padding: 0.75rem 1rem; display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 1rem;
background: #1e293b; background: #1e293b;
border-radius: 0.375rem; border-radius: 0.375rem;
text-decoration: none;
color: inherit;
}
.list a:hover {
background: #283549;
}
.primary {
display: flex; display: flex;
justify-content: space-between; gap: 0.5rem;
align-items: baseline;
}
.secondary {
font-size: 0.875rem;
} }
</style> </style>

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>