From aedf9c05eda0574e846650e875b28f649529182d Mon Sep 17 00:00:00 2001 From: Frando Date: Thu, 19 Mar 2026 23:47:02 +0100 Subject: [PATCH 01/17] feat: add patchbay-serve binary with push API, ACME TLS, and retention Add a standalone `patchbay-serve` binary for hosting run results remotely. CI pipelines can push test results via `POST /api/push/{project}` (tar.gz) and get a link back for viewing in the devtools UI. - Push endpoint with Bearer token auth, stores runs as {project}/{date}-{uuid} - run.json manifest for CI context (branch, commit, PR link) - /runs index page listing all pushed runs with PR links - Automatic TLS via ACME (--acme-domain + --acme-email) - Background retention watcher (--retention) deletes oldest runs over limit - GitHub Actions snippet and deployment docs in testing.md Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 262 ++++++++++++++++++++++++- docs/guide/testing.md | 144 ++++++++++++++ patchbay-server/Cargo.toml | 16 +- patchbay-server/src/lib.rs | 378 +++++++++++++++++++++++++++++++++++- patchbay-server/src/main.rs | 207 ++++++++++++++++++++ ui/package-lock.json | 9 + 6 files changed, 1000 insertions(+), 16 deletions(-) create mode 100644 patchbay-server/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 1549925..416c1fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,13 +91,29 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "asn1-rs" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" dependencies = [ - "asn1-rs-derive", + "asn1-rs-derive 0.6.0", "asn1-rs-impl", "displaydoc", "nom", @@ -107,6 +123,18 @@ dependencies = [ "time", ] +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "asn1-rs-derive" version = "0.6.0" @@ -175,6 +203,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.8.8" @@ -227,6 +277,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-server" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" +dependencies = [ + "bytes", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -313,6 +380,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -335,8 +404,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -380,6 +451,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -525,13 +605,27 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der-parser" version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", "displaydoc", "nom", "num-bigint", @@ -643,6 +737,12 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -698,6 +798,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -713,6 +819,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -891,6 +1013,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1013,6 +1154,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1039,7 +1181,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] @@ -1271,6 +1413,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -1586,13 +1738,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + [[package]] name = "oid-registry" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", ] [[package]] @@ -1700,7 +1861,7 @@ dependencies = [ "patchbay", "patchbay-server", "patchbay-utils", - "rcgen", + "rcgen 0.14.7", "regex", "serde", "serde_json", @@ -1719,11 +1880,21 @@ dependencies = [ "anyhow", "async-stream", "axum", + "axum-server", + "chrono", + "clap", + "dirs", + "flate2", + "rustls", "serde", "serde_json", + "tar", "tokio", + "tokio-rustls-acme", "tokio-stream", "tracing", + "tracing-subscriber", + "uuid", ] [[package]] @@ -1954,6 +2125,19 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "rcgen" version = "0.14.7" @@ -1964,7 +2148,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", - "x509-parser", + "x509-parser 0.18.1", "yasna", ] @@ -2065,7 +2249,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] @@ -2155,6 +2339,8 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -2179,6 +2365,7 @@ version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2672,6 +2859,35 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls-acme" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f296d48ff72e0df96e2d7ef064ad5904d016a130869e542f00b08c8e05cc18cf" +dependencies = [ + "async-trait", + "axum-server", + "base64", + "chrono", + "futures", + "log", + "num-bigint", + "pem", + "proc-macro2", + "rcgen 0.13.2", + "reqwest", + "ring", + "rustls", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-rustls", + "webpki-roots 0.26.11", + "x509-parser 0.16.0", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -3098,6 +3314,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -3537,18 +3762,35 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry 0.7.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "x509-parser" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", "data-encoding", - "der-parser", + "der-parser 10.0.0", "lazy_static", "nom", - "oid-registry", + "oid-registry 0.8.1", "ring", "rusticata-macros", "thiserror 2.0.18", diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 169e19f..0a7c560 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -217,6 +217,150 @@ those callsites are permanently disabled — including for the file writer. To get TRACE in file output, ensure the global subscriber also enables TRACE (e.g. `RUST_LOG=trace`). +## CI: pushing results to a remote server + +If you run a `patchbay-serve` instance (see [deployment](#deploying-patchbay-serve) +below), you can push test results from GitHub Actions and get a link +posted as a PR comment. + +Set two repository secrets: `PATCHBAY_URL` (e.g. `https://patchbay.example.com`) +and `PATCHBAY_API_KEY`. + +Add this to your workflow **after** the test step: + +```yaml + - name: Push patchbay results + if: always() + env: + PATCHBAY_URL: ${{ secrets.PATCHBAY_URL }} + PATCHBAY_API_KEY: ${{ secrets.PATCHBAY_API_KEY }} + run: | + set -euo pipefail + + PROJECT="${{ github.event.repository.name }}" + TESTDIR="$(cargo metadata --format-version=1 --no-deps | jq -r .target_directory)/testdir-current" + + if [ ! -d "$TESTDIR" ]; then + echo "No testdir output found, skipping push" + exit 0 + fi + + # Create run.json manifest + cat > "$TESTDIR/run.json" <> "$GITHUB_ENV" + echo "Results uploaded: $VIEW_URL" + + - name: Comment on PR + if: always() && github.event.pull_request && env.PATCHBAY_VIEW_URL + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const body = `${marker}\n**patchbay results:** ${process.env.PATCHBAY_VIEW_URL}`; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } +``` + +The PR comment is auto-updated on each push, so you always see the latest run. + +### Deploying patchbay-serve + +Install and run the standalone server: + +```bash +cargo install --git https://github.com/n0-computer/patchbay patchbay-server --bin patchbay-serve +``` + +Minimal setup with push and ACME TLS: + +```bash +patchbay-serve \ + --accept-push \ + --api-key "$(openssl rand -hex 32)" \ + --acme-domain patchbay.example.com \ + --acme-email you@example.com \ + --retention 10GB +``` + +This will: +- Serve the runs index at `https://patchbay.example.com/runs` +- Accept pushed runs at `POST /api/push/{project}` +- Auto-provision TLS via Let's Encrypt +- Store data in `~/.local/share/patchbay-serve/` (runs + ACME certs) +- Delete oldest runs when total size exceeds 10 GB + +Without ACME (e.g. behind a reverse proxy): + +```bash +patchbay-serve \ + --accept-push \ + --api-key "$PATCHBAY_API_KEY" \ + --bind 127.0.0.1:8080 \ + --retention 10GB +``` + +Key flags: + +| Flag | Description | +|------|-------------| +| `--run-dir ` | Override run storage location | +| `--data-dir ` | Override data directory (default: `~/.local/share/patchbay-serve`) | +| `--accept-push` | Enable the push API | +| `--api-key ` | Required with `--accept-push`; also reads `PATCHBAY_API_KEY` env | +| `--acme-domain ` | Enable automatic TLS for domain | +| `--acme-email ` | Contact email for Let's Encrypt (required with `--acme-domain`) | +| `--retention ` | Max total run storage (e.g. `500MB`, `10GB`) | +| `--bind ` | Listen address (default: `0.0.0.0:8080`, ignored with ACME) | + ## Common flags `patchbay-vm test` supports the same flags as `cargo test`: diff --git a/patchbay-server/Cargo.toml b/patchbay-server/Cargo.toml index c20a89b..cafcc03 100644 --- a/patchbay-server/Cargo.toml +++ b/patchbay-server/Cargo.toml @@ -8,12 +8,26 @@ authors.workspace = true repository.workspace = true build = "build.rs" +[[bin]] +name = "patchbay-serve" +path = "src/main.rs" + [dependencies] axum = { version = "0.8", features = ["tokio"] } -tokio = { version = "1", features = ["rt", "macros", "sync", "time", "fs", "io-util"] } +tokio = { version = "1", features = ["rt-multi-thread", "rt", "macros", "sync", "time", "fs", "io-util", "signal"] } tokio-stream = { version = "0.1", features = ["sync"] } +tokio-rustls-acme = { version = "0.7", features = ["axum"] } anyhow = "1" async-stream = "0.3" +clap = { version = "4", features = ["derive", "env"] } +dirs = "6" +flate2 = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +tar = "0.4" tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v4"] } +chrono = "0.4" +axum-server = "0.7" +rustls = "0.23" diff --git a/patchbay-server/src/lib.rs b/patchbay-server/src/lib.rs index dd9d78d..0e38dbf 100644 --- a/patchbay-server/src/lib.rs +++ b/patchbay-server/src/lib.rs @@ -7,17 +7,19 @@ use std::{ convert::Infallible, fs, path::{Path, PathBuf}, + sync::Arc, time::Duration, }; use axum::{ + body::Bytes, extract::{Path as AxPath, Query, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, response::{ sse::{Event, KeepAlive, Sse}, Html, IntoResponse, }, - routing::get, + routing::{get, post}, Router, }; use serde::{Deserialize, Serialize}; @@ -183,19 +185,32 @@ struct EventRecord { rest: serde_json::Value, } +// ── Push configuration ────────────────────────────────────────────── + +/// Configuration for the push endpoint. +#[derive(Clone)] +pub struct PushConfig { + /// API key required in Authorization header. + pub api_key: String, + /// Directory where pushed runs are stored. + pub run_dir: PathBuf, +} + // ── Shared state ──────────────────────────────────────────────────── #[derive(Clone)] struct AppState { base: PathBuf, runs_tx: broadcast::Sender<()>, + push: Option>, } // ── Router construction ───────────────────────────────────────────── fn build_router(state: AppState) -> Router { - Router::new() + let mut r = Router::new() .route("/", get(index_html)) + .route("/runs", get(runs_index_html)) .route("/api/runs", get(get_runs)) .route("/api/runs/subscribe", get(runs_sse)) .route("/api/runs/{run}/state", get(get_run_state)) @@ -206,16 +221,25 @@ fn build_router(state: AppState) -> Router { .route( "/api/invocations/{name}/combined-results", get(get_invocation_combined), - ) - .with_state(state) + ); + if state.push.is_some() { + r = r.route("/api/push/{project}", post(push_run)); + } + r.with_state(state) } /// Creates an axum [`Router`] for serving a lab output directory. pub fn router(base: PathBuf) -> Router { + build_app(base, None) +} + +/// Creates an axum [`Router`] with optional push support. +pub fn build_app(base: PathBuf, push: Option) -> Router { let (runs_tx, _) = broadcast::channel(16); let state = AppState { base: base.clone(), runs_tx: runs_tx.clone(), + push: push.map(Arc::new), }; // Background run scanner: notifies SSE subscribers when new runs appear. @@ -746,3 +770,347 @@ async fn scan_log_files(run_dir: &Path) -> Vec { }); logs } + +// ── Run manifest (run.json) ───────────────────────────────────────── + +/// Manifest included with pushed runs, providing CI context. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RunManifest { + /// Project name (from URL path). + #[serde(default)] + pub project: String, + /// Git branch name. + #[serde(default)] + pub branch: Option, + /// Git commit SHA. + #[serde(default)] + pub commit: Option, + /// PR number. + #[serde(default)] + pub pr: Option, + /// PR URL. + #[serde(default)] + pub pr_url: Option, + /// When this run was created. + #[serde(default)] + pub created_at: Option, + /// Human-readable run title/label. + #[serde(default)] + pub title: Option, +} + +const RUN_JSON: &str = "run.json"; + +fn read_run_json(dir: &Path) -> Option { + let text = fs::read_to_string(dir.join(RUN_JSON)).ok()?; + serde_json::from_str(&text).ok() +} + +// ── Runs index page ───────────────────────────────────────────────── + +/// Metadata for a run entry on the index page. +#[derive(Serialize)] +struct RunIndexEntry { + /// Relative path within run_dir. + path: String, + /// Project name (first path component). + project: String, + /// run.json manifest if present. + manifest: Option, + /// Timestamp from directory name. + date: Option, +} + +/// Discover pushed runs for the index page. +/// Structure: run_dir/{project}/{date}-{uuid}/... +fn discover_pushed_runs(run_dir: &Path) -> Vec { + let mut entries = Vec::new(); + let Ok(projects) = fs::read_dir(run_dir) else { + return entries; + }; + for project_entry in projects.flatten() { + if !project_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let project = project_entry.file_name().to_string_lossy().to_string(); + let Ok(runs) = fs::read_dir(project_entry.path()) else { + continue; + }; + for run_entry in runs.flatten() { + if !run_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let run_name = run_entry.file_name().to_string_lossy().to_string(); + let path = format!("{project}/{run_name}"); + let manifest = read_run_json(&run_entry.path()); + // Extract date from dirname: YYYYMMDD_HHMMSS-uuid + let date = run_name.get(..15).map(|s| s.to_string()); + entries.push(RunIndexEntry { + path, + project: project.clone(), + manifest, + date, + }); + } + } + entries.sort_by(|a, b| b.path.cmp(&a.path)); + entries +} + +async fn runs_index_html(State(state): State) -> Html { + let entries = discover_pushed_runs(&state.base); + + let mut html = String::from( + r#" + + + + +patchbay runs + + + +

patchbay runs

+"#, + ); + + if entries.is_empty() { + html.push_str(r#"
No runs yet. Push results using the API.
"#); + } else { + for entry in &entries { + html.push_str(r#"
"#); + html.push_str(&format!( + r#"{}"#, + html_escape(&entry.project) + )); + + html.push_str(r#"
"#); + if let Some(m) = &entry.manifest { + if let Some(branch) = &m.branch { + html.push_str(&format!( + r#"{} "#, + html_escape(branch) + )); + } + if let Some(commit) = &m.commit { + let short = &commit[..commit.len().min(7)]; + html.push_str(&format!("{short} ")); + } + if let Some(pr) = m.pr { + if let Some(url) = &m.pr_url { + html.push_str(&format!( + r#"PR #{pr} "#, + html_escape(url) + )); + } else { + html.push_str(&format!("PR #{pr} ")); + } + } + if let Some(title) = &m.title { + html.push_str(&html_escape(title)); + } + } + html.push_str("
"); + + if let Some(date) = &entry.date { + html.push_str(&format!(r#"{date}"#)); + } + + // Link into the devtools UI — the run path is the base for discover_runs + html.push_str(&format!( + r#" View →"#, + html_escape(&entry.path) + )); + + html.push_str("
\n"); + } + } + + html.push_str(""); + Html(html) +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +// ── Push endpoint ─────────────────────────────────────────────────── + +async fn push_run( + AxPath(project): AxPath, + headers: HeaderMap, + State(state): State, + body: Bytes, +) -> impl IntoResponse { + let Some(push) = &state.push else { + return (StatusCode::NOT_FOUND, "push not enabled".to_string()); + }; + + // Validate API key + let auth = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let expected = format!("Bearer {}", push.api_key); + if auth != expected { + return (StatusCode::UNAUTHORIZED, "invalid api key".to_string()); + } + + // Validate project name (alphanumeric, hyphens, underscores) + if project.is_empty() + || !project + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return ( + StatusCode::BAD_REQUEST, + "invalid project name".to_string(), + ); + } + + // Create run directory: {run_dir}/{project}/{date}-{uuid} + let now = chrono::Utc::now(); + let date = now.format("%Y%m%d_%H%M%S").to_string(); + let uuid = uuid::Uuid::new_v4(); + let run_name = format!("{date}-{uuid}"); + let run_dir = push.run_dir.join(&project).join(&run_name); + + if let Err(e) = std::fs::create_dir_all(&run_dir) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to create run dir: {e}"), + ); + } + + // Extract tar.gz + let decoder = flate2::read::GzDecoder::new(&body[..]); + let mut archive = tar::Archive::new(decoder); + if let Err(e) = archive.unpack(&run_dir) { + // Clean up on failure + let _ = std::fs::remove_dir_all(&run_dir); + return ( + StatusCode::BAD_REQUEST, + format!("failed to extract archive: {e}"), + ); + } + + // Notify subscribers about new run + let _ = state.runs_tx.send(()); + + let view_path = format!("{project}/{run_name}"); + let result = serde_json::json!({ + "ok": true, + "project": project, + "run": run_name, + "path": view_path, + }); + + (StatusCode::OK, serde_json::to_string(&result).unwrap()) +} + +// ── Retention watcher ─────────────────────────────────────────────── + +/// Background task that enforces a total size limit on the runs directory. +/// Deletes oldest runs (by directory name sort) when total exceeds `max_bytes`. +pub async fn retention_watcher(run_dir: PathBuf, max_bytes: u64) { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + interval.tick().await; + if let Err(e) = enforce_retention(&run_dir, max_bytes) { + tracing::warn!("retention check failed: {e}"); + } + } +} + +fn enforce_retention(run_dir: &Path, max_bytes: u64) -> anyhow::Result<()> { + // Collect all run dirs with their sizes, sorted oldest first + let mut runs: Vec<(PathBuf, u64)> = Vec::new(); + + let projects = fs::read_dir(run_dir)?; + for project_entry in projects.flatten() { + if !project_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let Ok(run_entries) = fs::read_dir(project_entry.path()) else { + continue; + }; + for run_entry in run_entries.flatten() { + if !run_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let size = dir_size(&run_entry.path()); + runs.push((run_entry.path(), size)); + } + } + + // Sort oldest first (by path, which includes date) + runs.sort_by(|a, b| a.0.cmp(&b.0)); + + let total: u64 = runs.iter().map(|(_, s)| s).sum(); + if total <= max_bytes { + return Ok(()); + } + + let mut to_free = total - max_bytes; + for (path, size) in &runs { + if to_free == 0 { + break; + } + tracing::info!("retention: removing {}", path.display()); + let _ = fs::remove_dir_all(path); + to_free = to_free.saturating_sub(*size); + } + + // Clean up empty project directories + if let Ok(projects) = fs::read_dir(run_dir) { + for entry in projects.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + let Ok(mut contents) = fs::read_dir(entry.path()) else { + continue; + }; + if contents.next().is_none() { + let _ = fs::remove_dir(entry.path()); + } + } + } + } + + Ok(()) +} + +fn dir_size(path: &Path) -> u64 { + let mut total = 0; + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let ft = entry.file_type().unwrap_or_else(|_| unreachable!()); + if ft.is_file() { + total += entry.metadata().map(|m| m.len()).unwrap_or(0); + } else if ft.is_dir() { + total += dir_size(&entry.path()); + } + } + } + total +} diff --git a/patchbay-server/src/main.rs b/patchbay-server/src/main.rs new file mode 100644 index 0000000..f38557a --- /dev/null +++ b/patchbay-server/src/main.rs @@ -0,0 +1,207 @@ +//! Standalone patchbay server binary. +//! +//! Serves the devtools UI and optionally accepts pushed run results via HTTP. +//! Supports automatic TLS via ACME (Let's Encrypt). + +use std::net::Ipv6Addr; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{bail, Context, Result}; +use clap::Parser; + +#[derive(Parser)] +#[command(name = "patchbay-serve", about = "Serve patchbay run results")] +struct Cli { + /// Directory containing run results to serve. + #[arg(long)] + run_dir: Option, + + /// Bind address for HTTP server. + #[arg(long, default_value = "0.0.0.0:8080")] + bind: String, + + /// Domain for automatic TLS via ACME (Let's Encrypt). + /// When set, serves HTTPS on port 443 and HTTP redirect on port 80. + #[arg(long)] + acme_domain: Option, + + /// Contact email for ACME/Let's Encrypt (required with --acme-domain). + #[arg(long)] + acme_email: Option, + + /// Enable accepting pushed run results. + #[arg(long, default_value_t = false)] + accept_push: bool, + + /// API key required for push requests (Authorization: Bearer ). + #[arg(long, env = "PATCHBAY_API_KEY")] + api_key: Option, + + /// Data directory for storing pushed runs and ACME state. + /// Defaults to platform data dir (e.g. ~/.local/share/patchbay-serve). + #[arg(long)] + data_dir: Option, + + /// Maximum total size of stored runs (e.g. "10GB", "500MB"). + /// When exceeded, oldest runs are deleted. + #[arg(long)] + retention: Option, +} + +fn parse_size(s: &str) -> Result { + let s = s.trim(); + let (num, mult) = if let Some(n) = s.strip_suffix("TB") { + (n.trim(), 1_000_000_000_000u64) + } else if let Some(n) = s.strip_suffix("GB") { + (n.trim(), 1_000_000_000u64) + } else if let Some(n) = s.strip_suffix("MB") { + (n.trim(), 1_000_000u64) + } else if let Some(n) = s.strip_suffix("KB") { + (n.trim(), 1_000u64) + } else { + (s, 1u64) + }; + let val: f64 = num.parse().context("invalid size number")?; + Ok((val * mult as f64) as u64) +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + + // Resolve data dir + let data_dir = match &cli.data_dir { + Some(d) => d.clone(), + None => dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("patchbay-serve"), + }; + std::fs::create_dir_all(&data_dir) + .with_context(|| format!("create data dir {}", data_dir.display()))?; + + // Resolve run dir: explicit --run-dir, or data_dir/runs if push enabled, or cwd + let run_dir = match &cli.run_dir { + Some(d) => d.clone(), + None if cli.accept_push => data_dir.join("runs"), + None => std::env::current_dir().context("resolve cwd")?, + }; + std::fs::create_dir_all(&run_dir) + .with_context(|| format!("create run dir {}", run_dir.display()))?; + + if cli.accept_push && cli.api_key.is_none() { + bail!("--accept-push requires --api-key to be set"); + } + + if cli.acme_domain.is_some() && cli.acme_email.is_none() { + bail!("--acme-domain requires --acme-email to be set"); + } + + // Parse retention + let retention_bytes = cli + .retention + .as_deref() + .map(parse_size) + .transpose() + .context("invalid --retention value")?; + + let push_config = if cli.accept_push { + Some(patchbay_server::PushConfig { + api_key: cli.api_key.clone().unwrap(), + run_dir: run_dir.clone(), + }) + } else { + None + }; + + // Start retention watcher if configured + if let Some(max_bytes) = retention_bytes { + let retention_dir = run_dir.clone(); + tokio::spawn(async move { + patchbay_server::retention_watcher(retention_dir, max_bytes).await; + }); + } + + let app = patchbay_server::build_app(run_dir, push_config); + + if let Some(domain) = &cli.acme_domain { + let email = cli.acme_email.as_deref().unwrap(); + serve_acme(app, domain, email, &data_dir).await + } else { + tracing::info!("listening on {}", cli.bind); + let listener = tokio::net::TcpListener::bind(&cli.bind).await?; + axum::serve(listener, app).await?; + Ok(()) + } +} + +async fn serve_acme( + app: axum::Router, + domain: &str, + email: &str, + data_dir: &std::path::Path, +) -> Result<()> { + use tokio_rustls_acme::caches::DirCache; + use tokio_rustls_acme::AcmeConfig; + use tokio_stream::StreamExt; + + let acme_dir = data_dir.join("acme"); + std::fs::create_dir_all(&acme_dir)?; + + let mut state = AcmeConfig::new([domain]) + .contact([format!("mailto:{email}")]) + .cache(DirCache::new(acme_dir)) + .directory_lets_encrypt(true) + .state(); + + let rustls_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(state.resolver()); + let acceptor = state.axum_acceptor(Arc::new(rustls_config)); + + // Spawn ACME event handler + tokio::spawn(async move { + loop { + match state.next().await { + Some(Ok(ok)) => tracing::info!("acme event: {:?}", ok), + Some(Err(err)) => tracing::error!("acme error: {:?}", err), + None => break, + } + } + }); + + tracing::info!("listening on [::]:443 with ACME TLS for {domain}"); + + // HTTP redirect on port 80 + let redirect_domain = domain.to_string(); + tokio::spawn(async move { + let redirect = axum::Router::new().fallback(axum::routing::any( + move |req: axum::extract::Request| { + let host = redirect_domain.clone(); + async move { + let uri = req.uri(); + let path = uri.path_and_query().map(|p| p.as_str()).unwrap_or("/"); + axum::response::Redirect::permanent(&format!("https://{host}{path}")) + } + }, + )); + let listener = tokio::net::TcpListener::bind("[::]:80").await.unwrap(); + let _ = axum::serve(listener, redirect).await; + }); + + // Serve HTTPS with axum-server and ACME acceptor + let addr = std::net::SocketAddr::from((Ipv6Addr::UNSPECIFIED, 443)); + axum_server::bind(addr) + .acceptor(acceptor) + .serve(app.into_make_service()) + .await?; + + Ok(()) +} diff --git a/ui/package-lock.json b/ui/package-lock.json index ce15fd8..7a9438d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -56,6 +56,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1234,6 +1235,7 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1251,6 +1253,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1365,6 +1368,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1477,6 +1481,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -1874,6 +1879,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1886,6 +1892,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -1910,6 +1917,7 @@ "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -2058,6 +2066,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", From 94f77b0a971ceb837ee2f37c3daf6993b89ace50 Mon Sep 17 00:00:00 2001 From: Frando Date: Thu, 19 Mar 2026 23:49:57 +0100 Subject: [PATCH 02/17] feat: add systemd unit file, restructure testing docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add patchbay-serve.service with hardening (ProtectSystem, NoNewPrivileges, etc.) - Restructure testing.md: local tests → CI integration → patchbay-serve deployment - Move server docs to end as its own section with install/systemd instructions Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guide/testing.md | 93 +++++++++++++++++--------- patchbay-server/patchbay-serve.service | 28 ++++++++ 2 files changed, 90 insertions(+), 31 deletions(-) create mode 100644 patchbay-server/patchbay-serve.service diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 0a7c560..d371fff 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -217,9 +217,25 @@ those callsites are permanently disabled — including for the file writer. To get TRACE in file output, ensure the global subscriber also enables TRACE (e.g. `RUST_LOG=trace`). -## CI: pushing results to a remote server +## Common flags + +`patchbay-vm test` supports the same flags as `cargo test`: + +| Flag | Short | Description | +|------|-------|-------------| +| `--package ` | `-p` | Test a specific package | +| `--test ` | | Select a test target (binary) | +| `--jobs ` | `-j` | Parallel compilation jobs | +| `--features ` | `-F` | Activate cargo features | +| `--release` | | Build in release mode | +| `--lib` | | Test only the library | +| `--no-fail-fast` | | Run all tests even if some fail | +| `--recreate` | | Stop and recreate the VM | +| `-- ` | | Extra args passed to cargo | -If you run a `patchbay-serve` instance (see [deployment](#deploying-patchbay-serve) +## Running in CI + +If you run a `patchbay-serve` instance (see [patchbay-serve](#patchbay-serve) below), you can push test results from GitHub Actions and get a link posted as a PR comment. @@ -312,43 +328,46 @@ Add this to your workflow **after** the test step: The PR comment is auto-updated on each push, so you always see the latest run. -### Deploying patchbay-serve +## patchbay-serve + +`patchbay-serve` is a standalone server for hosting run results. CI +pipelines push test output to it; the devtools UI lets you browse them. -Install and run the standalone server: +### Install ```bash cargo install --git https://github.com/n0-computer/patchbay patchbay-server --bin patchbay-serve ``` -Minimal setup with push and ACME TLS: +### Quick start ```bash patchbay-serve \ --accept-push \ --api-key "$(openssl rand -hex 32)" \ - --acme-domain patchbay.example.com \ - --acme-email you@example.com \ + --bind 0.0.0.0:8080 \ --retention 10GB ``` -This will: -- Serve the runs index at `https://patchbay.example.com/runs` -- Accept pushed runs at `POST /api/push/{project}` -- Auto-provision TLS via Let's Encrypt -- Store data in `~/.local/share/patchbay-serve/` (runs + ACME certs) -- Delete oldest runs when total size exceeds 10 GB - -Without ACME (e.g. behind a reverse proxy): +With automatic TLS: ```bash patchbay-serve \ --accept-push \ - --api-key "$PATCHBAY_API_KEY" \ - --bind 127.0.0.1:8080 \ + --api-key "$(openssl rand -hex 32)" \ + --acme-domain patchbay.example.com \ + --acme-email you@example.com \ --retention 10GB ``` -Key flags: +This will: +- Serve the runs index at `/runs` +- Accept pushed runs at `POST /api/push/{project}` +- Auto-provision TLS via Let's Encrypt (when `--acme-domain` is set) +- Store data in `~/.local/share/patchbay-serve/` (runs + ACME certs) +- Delete oldest runs when total size exceeds the retention limit + +### Flags | Flag | Description | |------|-------------| @@ -361,18 +380,30 @@ Key flags: | `--retention ` | Max total run storage (e.g. `500MB`, `10GB`) | | `--bind ` | Listen address (default: `0.0.0.0:8080`, ignored with ACME) | -## Common flags +### systemd -`patchbay-vm test` supports the same flags as `cargo test`: +A unit file is included at `patchbay-server/patchbay-serve.service`. +To install: -| Flag | Short | Description | -|------|-------|-------------| -| `--package ` | `-p` | Test a specific package | -| `--test ` | | Select a test target (binary) | -| `--jobs ` | `-j` | Parallel compilation jobs | -| `--features ` | `-F` | Activate cargo features | -| `--release` | | Build in release mode | -| `--lib` | | Test only the library | -| `--no-fail-fast` | | Run all tests even if some fail | -| `--recreate` | | Stop and recreate the VM | -| `-- ` | | Extra args passed to cargo | +```bash +# Create service user and data directory +sudo useradd -r -s /usr/sbin/nologin patchbay +sudo mkdir -p /var/lib/patchbay-serve +sudo chown patchbay:patchbay /var/lib/patchbay-serve + +# Install the binary +cargo install --git https://github.com/n0-computer/patchbay patchbay-server --bin patchbay-serve +sudo cp ~/.cargo/bin/patchbay-serve /usr/local/bin/ + +# Install and configure the unit file +sudo cp patchbay-server/patchbay-serve.service /etc/systemd/system/ +sudo systemctl edit patchbay-serve # set PATCHBAY_API_KEY, --acme-domain, --acme-email +sudo systemctl enable --now patchbay-serve +``` + +Check status: + +```bash +sudo systemctl status patchbay-serve +journalctl -u patchbay-serve -f +``` diff --git a/patchbay-server/patchbay-serve.service b/patchbay-server/patchbay-serve.service new file mode 100644 index 0000000..9ab449b --- /dev/null +++ b/patchbay-server/patchbay-serve.service @@ -0,0 +1,28 @@ +[Unit] +Description=patchbay-serve - patchbay run results server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=patchbay +Group=patchbay +ExecStart=/usr/local/bin/patchbay-serve \ + --accept-push \ + --data-dir /var/lib/patchbay-serve \ + --acme-domain patchbay.example.com \ + --acme-email you@example.com \ + --retention 10GB +Environment=PATCHBAY_API_KEY=changeme +Restart=on-failure +RestartSec=5 + +# Hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/var/lib/patchbay-serve + +[Install] +WantedBy=multi-user.target From 1cfe67a4ef58edf5ff924207593d4404b26a1e60 Mon Sep 17 00:00:00 2001 From: Frando Date: Thu, 19 Mar 2026 23:54:24 +0100 Subject: [PATCH 03/17] fix: correct view URL in CI snippet and runs index page The UI doesn't support ?run= query params for deep linking. Fix CI snippet to use /runs#path fragment (scrolls to run entry). Fix runs index "View" link to point to / where the sidebar lists all runs. Add :target CSS highlight so fragment-linked runs are visually marked. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guide/testing.md | 2 +- patchbay-server/src/lib.rs | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/guide/testing.md b/docs/guide/testing.md index d371fff..1ee2d96 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -292,7 +292,7 @@ Add this to your workflow **after** the test step: fi RUN_PATH=$(echo "$BODY" | jq -r .path) - VIEW_URL="$PATCHBAY_URL/?run=$RUN_PATH" + VIEW_URL="$PATCHBAY_URL/runs#$RUN_PATH" echo "PATCHBAY_VIEW_URL=$VIEW_URL" >> "$GITHUB_ENV" echo "Results uploaded: $VIEW_URL" diff --git a/patchbay-server/src/lib.rs b/patchbay-server/src/lib.rs index 0e38dbf..041518d 100644 --- a/patchbay-server/src/lib.rs +++ b/patchbay-server/src/lib.rs @@ -876,6 +876,7 @@ async fn runs_index_html(State(state): State) -> Html { margin-bottom: 0.5rem; display: flex; align-items: center; gap: 1rem; background: #161b22; } .run:hover { border-color: #388bfd; } + .run:target { border-color: #388bfd; background: #1c2333; } .project { font-weight: 600; color: #58a6ff; min-width: 120px; } .meta { flex: 1; font-size: 0.875rem; color: #8b949e; } .meta a { color: #58a6ff; text-decoration: none; } @@ -897,7 +898,10 @@ async fn runs_index_html(State(state): State) -> Html { html.push_str(r#"
No runs yet. Push results using the API.
"#); } else { for entry in &entries { - html.push_str(r#"
"#); + html.push_str(&format!( + r#"
"#, + html_escape(&entry.path) + )); html.push_str(&format!( r#"{}"#, html_escape(&entry.project) @@ -935,11 +939,8 @@ async fn runs_index_html(State(state): State) -> Html { html.push_str(&format!(r#"{date}"#)); } - // Link into the devtools UI — the run path is the base for discover_runs - html.push_str(&format!( - r#" View →"#, - html_escape(&entry.path) - )); + // Link into the devtools UI — runs appear in the sidebar automatically + html.push_str(r#" View →"#); html.push_str("
\n"); } From f92295bb77074fb8ba1d37e760345f6d7e7a98d6 Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 20 Mar 2026 00:02:03 +0100 Subject: [PATCH 04/17] feat: add deep linking with react-router HashRouter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add react-router-dom with HashRouter for URL-based navigation: - /#/ — runs index page listing all runs with links to each - /#/run/{name} — deep link to a specific sim run - /#/inv/{name} — deep link to an invocation (combined view) The dropdown in the topbar navigates via the router. The patchbay title links back to the runs index. Selection is fully derived from the URL — no more local state for run selection. Push endpoint now returns `first_run` (first discovered sim name) so CI can build a direct deep link into the UI. CI snippet updated to use `/#/run/{first_run}` for the PR comment link. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guide/testing.md | 8 +++- patchbay-server/src/lib.rs | 14 ++++++- ui/package-lock.json | 60 +++++++++++++++++++++++++++- ui/package.json | 3 +- ui/src/App.tsx | 71 +++++++++++++++------------------ ui/src/RunsIndex.tsx | 82 ++++++++++++++++++++++++++++++++++++++ ui/src/index.css | 53 ++++++++++++++++++++++++ ui/src/main.tsx | 11 ++++- 8 files changed, 257 insertions(+), 45 deletions(-) create mode 100644 ui/src/RunsIndex.tsx diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 1ee2d96..bd76047 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -291,8 +291,12 @@ Add this to your workflow **after** the test step: exit 1 fi - RUN_PATH=$(echo "$BODY" | jq -r .path) - VIEW_URL="$PATCHBAY_URL/runs#$RUN_PATH" + FIRST_RUN=$(echo "$BODY" | jq -r '.first_run // empty') + if [ -n "$FIRST_RUN" ]; then + VIEW_URL="$PATCHBAY_URL/#/run/$FIRST_RUN" + else + VIEW_URL="$PATCHBAY_URL/#/" + fi echo "PATCHBAY_VIEW_URL=$VIEW_URL" >> "$GITHUB_ENV" echo "Results uploaded: $VIEW_URL" diff --git a/patchbay-server/src/lib.rs b/patchbay-server/src/lib.rs index 041518d..c99f85c 100644 --- a/patchbay-server/src/lib.rs +++ b/patchbay-server/src/lib.rs @@ -939,7 +939,7 @@ async fn runs_index_html(State(state): State) -> Html { html.push_str(&format!(r#"{date}"#)); } - // Link into the devtools UI — runs appear in the sidebar automatically + // Link to the UI runs index — the run will be listed there html.push_str(r#" View →"#); html.push_str("
\n"); @@ -1020,12 +1020,24 @@ async fn push_run( // Notify subscribers about new run let _ = state.runs_tx.send(()); + // Discover runs inside the extracted directory to provide a deep link. + // The run_dir is {project}/{date}-{uuid} inside the base. discover_runs + // works relative to state.base, so the run names will include the project prefix. + let first_run = discover_runs(&state.base) + .ok() + .and_then(|runs| { + runs.into_iter() + .find(|r| r.name.starts_with(&format!("{project}/{run_name}"))) + .map(|r| r.name) + }); + let view_path = format!("{project}/{run_name}"); let result = serde_json::json!({ "ok": true, "project": project, "run": run_name, "path": view_path, + "first_run": first_run, }); (StatusCode::OK, serde_json::to_string(&result).unwrap()) diff --git a/ui/package-lock.json b/ui/package-lock.json index 7a9438d..db76d9b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12,7 +12,8 @@ "@xyflow/react": "^12.10.1", "dagre": "^0.8.5", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^7.13.1" }, "devDependencies": { "@playwright/test": "^1.51.1", @@ -1417,6 +1418,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1911,6 +1925,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rollup": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", @@ -1976,6 +2028,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/ui/package.json b/ui/package.json index 572e1b9..4d3eb95 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,7 +14,8 @@ "@xyflow/react": "^12.10.1", "dagre": "^0.8.5", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^7.13.1" }, "devDependencies": { "@playwright/test": "^1.51.1", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 142a7ca..8d5b787 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' import type { Firewall, LabEvent, @@ -31,9 +32,6 @@ import NodeDetail from './components/NodeDetail' type Tab = 'topology' | 'logs' | 'timeline' | 'perf' // ── Selection model ──────────────────────────────────────────────── -// The user can select either an individual sim run (by name) or an -// invocation group (to see combined results). We encode this as a -// tagged union so the rest of the component can branch cleanly. type Selection = | { kind: 'run'; name: string } @@ -44,15 +42,9 @@ function selectionKey(s: Selection | null): string { return s.kind === 'invocation' ? `inv:${s.name}` : s.name } -function parseSelectionKey(key: string, runs: RunInfo[]): Selection | null { - if (!key) return null - if (key.startsWith('inv:')) { - return { kind: 'invocation', name: key.slice(4) } - } - if (runs.some((r) => r.name === key)) { - return { kind: 'run', name: key } - } - return null +function selectionPath(s: Selection | null): string { + if (!s) return '/' + return s.kind === 'invocation' ? `/inv/${s.name}` : `/run/${s.name}` } // ── Invocation grouping ──────────────────────────────────────────── @@ -176,12 +168,25 @@ function applyEvent(state: LabState, event: LabEvent): LabState { // ── Unified App ──────────────────────────────────────────────────── -export default function App() { - // Run selection - const [runs, setRuns] = useState([]) - const [selection, setSelection] = useState(null) +export default function App({ mode }: { mode: 'run' | 'inv' }) { + const location = useLocation() + const navigate = useNavigate() + + // Derive selection from the URL path. + // Route is /run/* or /inv/* so everything after the prefix is the name. + const nameFromUrl = location.pathname.slice(mode === 'run' ? 5 : 5) // "/run/" or "/inv/" = 5 chars + const selection: Selection | null = nameFromUrl + ? { kind: mode === 'inv' ? 'invocation' : 'run', name: nameFromUrl } + : null + + const selectedRun = selection?.kind === 'run' ? selection.name : null + const selectedInvocation = selection?.kind === 'invocation' ? selection.name : null + const [tab, setTab] = useState('topology') + // Run list (for the dropdown) + const [runs, setRuns] = useState([]) + // Lab state (from SSE) const [labState, setLabState] = useState(null) const [labEvents, setLabEvents] = useState([]) @@ -202,24 +207,11 @@ export default function App() { // Cross-tab log jump const [logJump, setLogJump] = useState<{ node: string; path: string; timeLabel: string; nonce: number } | null>(null) - // Derived selection helpers - const selectedRun = selection?.kind === 'run' ? selection.name : null - const selectedInvocation = selection?.kind === 'invocation' ? selection.name : null - // ── Fetch and subscribe to runs ── const refreshRuns = useCallback(async () => { const r = await fetchRuns() setRuns(r) - setSelection((prev) => { - if (r.length === 0) return null - if (prev) { - // Keep current selection if still valid - if (prev.kind === 'run' && r.some((ri) => ri.name === prev.name)) return prev - if (prev.kind === 'invocation' && r.some((ri) => ri.invocation === prev.name)) return prev - } - return { kind: 'run', name: r[0].name } - }) }, []) useEffect(() => { @@ -291,9 +283,6 @@ export default function App() { }, [selectedRun]) // Close SSE connections on page unload/refresh. - // Firefox limits HTTP/1.1 to 6 connections per domain — stale SSE - // connections from a previous page load can exhaust the pool and block - // all subsequent fetch requests. useEffect(() => { const cleanup = () => { runsEsRef.current?.close() @@ -345,15 +334,19 @@ export default function App() { return (
-

patchbay

+

patchbay