From 1016d08312fce2f58f63a45356b029fd22603fe5 Mon Sep 17 00:00:00 2001 From: liciazhu Date: Tue, 9 Jun 2026 20:24:13 +0800 Subject: [PATCH 01/48] add example center v0.1 Signed-off-by: liciazhu --- .gitignore | 1 + CubeAPI/src/config/mod.rs | 39 + CubeAPI/src/handlers/config.rs | 4 + CubeAPI/src/handlers/examples.rs | 346 ++++++++ CubeAPI/src/handlers/mod.rs | 1 + CubeAPI/src/routes.rs | 15 +- examples/code-sandbox-quickstart/cmd.py | 2 +- examples/code-sandbox-quickstart/create.py | 2 +- examples/code-sandbox-quickstart/exec_code.py | 5 +- .../network_allowlist.py | 2 +- .../network_denylist.py | 2 +- .../network_no_internet.py | 2 +- examples/code-sandbox-quickstart/pause.py | 26 +- examples/code-sandbox-quickstart/read.py | 2 +- .../code-sandbox-quickstart/requirements.txt | 2 +- web/src/api/client.ts | 32 + web/src/components/CodeBlock.tsx | 251 ++++++ web/src/components/CommandPalette.tsx | 2 + web/src/components/Rail.tsx | 2 + web/src/data/examples.ts | 66 ++ web/src/i18n/index.ts | 2 +- web/src/i18n/resources.ts | 4 + web/src/locales/en/examples.json | 32 + web/src/locales/en/nav.json | 3 +- web/src/locales/zh/examples.json | 32 + web/src/locales/zh/nav.json | 3 +- web/src/main.tsx | 8 +- web/src/pages/Examples.tsx | 831 ++++++++++++++++++ 28 files changed, 1702 insertions(+), 17 deletions(-) create mode 100644 CubeAPI/src/handlers/examples.rs create mode 100644 web/src/components/CodeBlock.tsx create mode 100644 web/src/data/examples.ts create mode 100644 web/src/locales/en/examples.json create mode 100644 web/src/locales/zh/examples.json create mode 100644 web/src/pages/Examples.tsx diff --git a/.gitignore b/.gitignore index 6b32b5acf..96774de12 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ examples/openai-agents-example/.scratch/ examples/openai-agents-code-interpreter/__pycache__/ examples/openai-agents-code-interpreter/output/ examples/openai-agents-code-interpreter/.scratch/ +examples/code-sandbox-quickstart/__pycache__/ pvm-guest-build/ pvm-host-build/ diff --git a/CubeAPI/src/config/mod.rs b/CubeAPI/src/config/mod.rs index b6eeb5aa9..08cf14c2f 100644 --- a/CubeAPI/src/config/mod.rs +++ b/CubeAPI/src/config/mod.rs @@ -71,6 +71,26 @@ pub struct ServerConfig { /// Example: mysql://cube:cube_pass@127.0.0.1:3306/cube_mvp #[serde(default = "default_database_url")] pub database_url: Option, + + /// Default template ID used by the Examples runner. + /// Env var: CUBE_TEMPLATE_ID + #[serde(default = "default_template_id")] + pub default_template_id: Option, + + /// CubeAPI URL used by the Examples runner (passed as CUBE_API_URL to scripts). + /// Env var: CUBE_API_URL (default "http://127.0.0.1:3000") + #[serde(default = "default_cube_api_url")] + pub cube_api_url: Option, + + /// CubeProxy node IP for bypassing DNS resolution (passed as CUBE_PROXY_NODE_IP). + /// Env var: CUBE_PROXY_NODE_IP + #[serde(default)] + pub cube_proxy_node_ip: Option, + + /// CubeProxy HTTP port (passed as CUBE_PROXY_PORT_HTTP). + /// Env var: CUBE_PROXY_PORT_HTTP (default "80") + #[serde(default = "default_cube_proxy_port_http")] + pub cube_proxy_port_http: Option, } fn default_bind() -> String { @@ -109,6 +129,21 @@ fn default_database_url() -> Option { .ok() .or_else(default_cube_sandbox_mysql_url) } +fn default_template_id() -> Option { + std::env::var("CUBE_TEMPLATE_ID").ok().filter(|s| !s.is_empty()) +} +fn default_cube_api_url() -> Option { + std::env::var("CUBE_API_URL") + .ok() + .filter(|s| !s.is_empty()) + .or_else(|| Some("http://127.0.0.1:3000".to_string())) +} + +fn default_cube_proxy_port_http() -> Option { + std::env::var("CUBE_PROXY_PORT_HTTP") + .ok() + .and_then(|s| s.parse().ok()) +} fn default_cube_sandbox_mysql_url() -> Option { let host = std::env::var("CUBE_SANDBOX_MYSQL_HOST").ok()?; @@ -148,6 +183,10 @@ impl Default for ServerConfig { log_prefix: default_log_prefix(), auth_callback_url: None, database_url: default_database_url(), + default_template_id: default_template_id(), + cube_api_url: default_cube_api_url(), + cube_proxy_node_ip: None, + cube_proxy_port_http: default_cube_proxy_port_http(), } } } diff --git a/CubeAPI/src/handlers/config.rs b/CubeAPI/src/handlers/config.rs index 8ce12cdf8..cc5c8b340 100644 --- a/CubeAPI/src/handlers/config.rs +++ b/CubeAPI/src/handlers/config.rs @@ -24,6 +24,9 @@ pub struct RuntimeConfig { /// Default instance type. #[serde(rename = "instanceType")] pub instance_type: String, + /// Default template ID used by the Examples runner (from CUBE_TEMPLATE_ID). + #[serde(rename = "defaultTemplateId", skip_serializing_if = "Option::is_none")] + pub default_template_id: Option, } /// Build the public-facing API endpoint URL. @@ -67,6 +70,7 @@ pub async fn get_config(State(state): State) -> impl IntoResponse { .is_some_and(|u| !u.is_empty()), sandbox_domain: cfg.sandbox_domain.clone(), instance_type: cfg.instance_type.clone(), + default_template_id: cfg.default_template_id.clone(), }), ) } diff --git a/CubeAPI/src/handlers/examples.rs b/CubeAPI/src/handlers/examples.rs new file mode 100644 index 000000000..a27e49997 --- /dev/null +++ b/CubeAPI/src/handlers/examples.rs @@ -0,0 +1,346 @@ +// Copyright (c) 2024 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// Examples handler: list available example scripts and run them via subprocess. + +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tokio::process::Command; +use tokio::time::timeout; +use utoipa::ToSchema; + +use crate::{error::AppResult, state::AppState}; + +// ─── Models ─────────────────────────────────────────────────────────────────── + +#[derive(Serialize, ToSchema)] +pub struct ExampleMeta { + pub id: String, + pub filename: String, + pub title: String, + pub description: String, + pub category: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct RunExampleRequest { + pub id: String, + /// Optional template ID override. When provided, takes highest priority + /// over server-configured defaults. Allows the frontend to let users + /// pick which template to use for each example run. + pub template_id: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct RunExampleResponse { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, + pub success: bool, +} + +// ─── Example registry ───────────────────────────────────────────────────────── + +fn example_list() -> Vec { + vec![ + ExampleMeta { + id: "create".to_string(), + filename: "create.py".to_string(), + title: "Create Sandbox".to_string(), + description: "Create a sandbox from a template and retrieve its metadata.".to_string(), + category: "basics".to_string(), + }, + ExampleMeta { + id: "exec_code".to_string(), + filename: "exec_code.py".to_string(), + title: "Execute Code".to_string(), + description: "Run Python code inside the sandbox using the Jupyter kernel.".to_string(), + category: "basics".to_string(), + }, + ExampleMeta { + id: "cmd".to_string(), + filename: "cmd.py".to_string(), + title: "Run Shell Command".to_string(), + description: "Execute shell commands inside the sandbox and capture stdout.".to_string(), + category: "basics".to_string(), + }, + ExampleMeta { + id: "read".to_string(), + filename: "read.py".to_string(), + title: "File Read/Write".to_string(), + description: "Read files from the sandbox filesystem.".to_string(), + category: "filesystem".to_string(), + }, + ExampleMeta { + id: "pause".to_string(), + filename: "pause.py".to_string(), + title: "Pause & Resume".to_string(), + description: "Pause a sandbox to save its state and resume it later.".to_string(), + category: "lifecycle".to_string(), + }, + ExampleMeta { + id: "network_no_internet".to_string(), + filename: "network_no_internet.py".to_string(), + title: "No Internet Access".to_string(), + description: "Create a fully isolated sandbox with all outbound traffic blocked.".to_string(), + category: "network".to_string(), + }, + ExampleMeta { + id: "network_allowlist".to_string(), + filename: "network_allowlist.py".to_string(), + title: "Network Allowlist".to_string(), + description: "Allow only specific IP/CIDR ranges while blocking everything else.".to_string(), + category: "network".to_string(), + }, + ExampleMeta { + id: "network_denylist".to_string(), + filename: "network_denylist.py".to_string(), + title: "Network Denylist".to_string(), + description: "Allow internet but block specific IP/CIDR ranges.".to_string(), + category: "network".to_string(), + }, + ] +} + +fn example_base_dir() -> String { + std::env::var("CUBE_EXAMPLES_DIR") + .unwrap_or_else(|_| "/root/CubeSandbox/examples/code-sandbox-quickstart".to_string()) +} + +// ─── GET /cubeapi/v1/examples ──────────────────────────────────────────────── + +/// List all available example scripts. +pub async fn list_examples(State(_state): State) -> AppResult { + Ok(Json(example_list())) +} + +// ─── GET /cubeapi/v1/examples/:id ─────────────────────────────────────────── + +/// Get the source code of a single example script by id. +pub async fn get_example_source( + State(_state): State, + axum::extract::Path(id): axum::extract::Path, +) -> AppResult { + // Find example by id (only allow ids in the registry to prevent arbitrary file access) + let examples = example_list(); + let example = match examples.iter().find(|e| e.id == id) { + Some(e) => e, + None => { + return Ok(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("Example '{}' not found", id) + })), + ) + .into_response()); + } + }; + + let base_dir = example_base_dir(); + let script_path = format!("{}/{}", base_dir, example.filename); + + match std::fs::read_to_string(&script_path) { + Ok(source) => Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "id": example.id, + "filename": example.filename, + "source": source, + })), + ) + .into_response()), + Err(io_err) => Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Failed to read '{}': {}", script_path, io_err) + })), + ) + .into_response()), + } +} + +// ─── POST /cubeapi/v1/examples/run ─────────────────────────────────────────── + +/// Run an example script in a subprocess and return stdout/stderr. +pub async fn run_example( + State(state): State, + Json(req): Json, +) -> AppResult { + // Find example by id + let examples = example_list(); + let example = match examples.iter().find(|e| e.id == req.id) { + Some(e) => e, + None => { + return Ok(( + StatusCode::NOT_FOUND, + Json(RunExampleResponse { + stdout: String::new(), + stderr: format!("Example '{}' not found", req.id), + exit_code: 1, + success: false, + }), + ) + .into_response()); + } + }; + + let base_dir = example_base_dir(); + let script_path = format!("{}/{}", base_dir, example.filename); + + // Resolve template ID with multi-level fallback. + // Each candidate is validated with get_template before acceptance. + let candidates: Vec = [ + req.template_id.filter(|s| !s.trim().is_empty()), + state.config.default_template_id.clone(), + std::env::var("CUBE_TEMPLATE_ID").ok().filter(|s| !s.is_empty()), + ] + .into_iter() + .flatten() + .collect(); + + let mut template_id = String::new(); + for candidate in &candidates { + match state.services.templates.get_template(candidate).await { + Ok(_) => { + template_id = candidate.clone(); + break; + } + Err(e) => { + tracing::warn!( + candidate = %candidate, + error = %e, + "template candidate failed validation, trying next" + ); + } + } + } + + if template_id.is_empty() { + // Last resort: ask CubeMaster for the first available template + match state.services.templates.list_templates().await { + Ok(templates) => { + let list_candidates: Vec<_> = templates + .iter() + .filter(|t| t.status == "healthy" || t.status == "ready") + .chain(templates.iter()) + .map(|t| t.template_id.as_str()) + .collect(); + + for candidate in list_candidates { + match state.services.templates.get_template(candidate).await { + Ok(_) => { + template_id = candidate.to_string(); + break; + } + Err(e) => { + tracing::warn!( + candidate = %candidate, + error = %e, + "listed template failed validation, skipping" + ); + } + } + } + } + Err(e) => { + tracing::warn!(error = %e, "failed to list templates for fallback"); + } + } + } + + if template_id.is_empty() { + return Ok(( + StatusCode::BAD_REQUEST, + Json(RunExampleResponse { + stdout: String::new(), + stderr: "No template ID configured. Set CUBE_TEMPLATE_ID, configure a default template, or create a template first.".to_string(), + exit_code: 1, + success: false, + }), + ) + .into_response()); + } + + let cube_api_url = state + .config + .cube_api_url + .clone() + .unwrap_or_else(|| "http://127.0.0.1:3000".to_string()); + + tracing::info!( + example_id = %req.id, + script = %script_path, + template_id = %template_id, + "running example" + ); + + let ssl_cert = std::env::var("SSL_CERT_FILE") + .unwrap_or_else(|_| "/root/.local/share/mkcert/rootCA.pem".to_string()); + + let mut cmd = Command::new("python3"); + cmd.arg(&script_path) + .env("CUBE_API_URL", &cube_api_url) + .env("CUBE_TEMPLATE_ID", &template_id) + .env("SSL_CERT_FILE", ssl_cert) + .current_dir(&base_dir); + + // Pass CubeProxy configuration if available + if let Some(ref proxy_ip) = state.config.cube_proxy_node_ip { + cmd.env("CUBE_PROXY_NODE_IP", proxy_ip); + } + if let Some(proxy_port) = state.config.cube_proxy_port_http { + cmd.env("CUBE_PROXY_PORT_HTTP", proxy_port.to_string()); + } + cmd.env("CUBE_SANDBOX_DOMAIN", &state.config.sandbox_domain); + + let run_result = timeout( + Duration::from_secs(120), + cmd.output(), + ) + .await; + + match run_result { + Ok(Ok(output)) => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let exit_code = output.status.code().unwrap_or(-1); + let success = output.status.success(); + + tracing::info!( + example_id = %req.id, + exit_code, + success, + "example run complete" + ); + + Ok(Json(RunExampleResponse { + stdout, + stderr, + exit_code, + success, + }) + .into_response()) + } + Ok(Err(io_err)) => Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(RunExampleResponse { + stdout: String::new(), + stderr: format!("Failed to spawn process: {}", io_err), + exit_code: -1, + success: false, + }), + ) + .into_response()), + Err(_elapsed) => Ok(( + StatusCode::GATEWAY_TIMEOUT, + Json(RunExampleResponse { + stdout: String::new(), + stderr: "Example timed out after 120 seconds".to_string(), + exit_code: -1, + success: false, + }), + ) + .into_response()), + } +} diff --git a/CubeAPI/src/handlers/mod.rs b/CubeAPI/src/handlers/mod.rs index 463fda981..a45de99d6 100644 --- a/CubeAPI/src/handlers/mod.rs +++ b/CubeAPI/src/handlers/mod.rs @@ -11,3 +11,4 @@ pub mod sandboxes; pub mod snapshots; pub mod store; pub mod templates; +pub mod examples; diff --git a/CubeAPI/src/routes.rs b/CubeAPI/src/routes.rs index e4dad1f3b..dff95c0fe 100644 --- a/CubeAPI/src/routes.rs +++ b/CubeAPI/src/routes.rs @@ -18,7 +18,7 @@ use tower_http::{ }; use crate::{ - handlers::{agenthub, auth, cluster, config, health, sandboxes, snapshots, store, templates}, + handlers::{agenthub, auth, cluster, config, examples, health, sandboxes, snapshots, store, templates}, middleware::{auth::unified_auth, rate_limit::rate_limit}, state::AppState, }; @@ -88,6 +88,7 @@ fn build_cubeapi_router(state: &AppState, auth_configured: bool) -> Router, timeout: Duration) -> Router Router { + let routes = Router::new() + .route("/examples", get(examples::list_examples)) + .route("/examples/:id", get(examples::get_example_source)) + .route("/examples/run", post(examples::run_example)); + if auth_configured { + routes.layer(middleware::from_fn_with_state(state.clone(), unified_auth)) + } else { + routes + } +} + #[cfg(test)] mod tests { use super::build_router; diff --git a/examples/code-sandbox-quickstart/cmd.py b/examples/code-sandbox-quickstart/cmd.py index 4bfa78454..5dc334e1b 100644 --- a/examples/code-sandbox-quickstart/cmd.py +++ b/examples/code-sandbox-quickstart/cmd.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import os -from e2b_code_interpreter import Sandbox +from cubesandbox import Sandbox from env_utils import load_local_dotenv load_local_dotenv() diff --git a/examples/code-sandbox-quickstart/create.py b/examples/code-sandbox-quickstart/create.py index b4a602bdb..439c048b1 100644 --- a/examples/code-sandbox-quickstart/create.py +++ b/examples/code-sandbox-quickstart/create.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import os -from e2b_code_interpreter import Sandbox +from cubesandbox import Sandbox from env_utils import load_local_dotenv load_local_dotenv() diff --git a/examples/code-sandbox-quickstart/exec_code.py b/examples/code-sandbox-quickstart/exec_code.py index a610ac608..c734afd5b 100644 --- a/examples/code-sandbox-quickstart/exec_code.py +++ b/examples/code-sandbox-quickstart/exec_code.py @@ -2,7 +2,9 @@ # SPDX-License-Identifier: Apache-2.0 import os -from e2b_code_interpreter import Sandbox +import time +import httpx +from cubesandbox import Sandbox from env_utils import load_local_dotenv load_local_dotenv() @@ -14,4 +16,5 @@ """ with Sandbox.create(template=template_id) as sandbox: + print(sandbox.run_code(python_code, on_stdout=lambda data: print(data))) diff --git a/examples/code-sandbox-quickstart/network_allowlist.py b/examples/code-sandbox-quickstart/network_allowlist.py index 456a76628..9dab8d8fe 100644 --- a/examples/code-sandbox-quickstart/network_allowlist.py +++ b/examples/code-sandbox-quickstart/network_allowlist.py @@ -16,7 +16,7 @@ """ import os -from e2b_code_interpreter import Sandbox +from cubesandbox import Sandbox from env_utils import load_local_dotenv load_local_dotenv() diff --git a/examples/code-sandbox-quickstart/network_denylist.py b/examples/code-sandbox-quickstart/network_denylist.py index ca53e5d55..53f56730f 100644 --- a/examples/code-sandbox-quickstart/network_denylist.py +++ b/examples/code-sandbox-quickstart/network_denylist.py @@ -17,7 +17,7 @@ """ import os -from e2b_code_interpreter import Sandbox +from cubesandbox import Sandbox from env_utils import load_local_dotenv load_local_dotenv() diff --git a/examples/code-sandbox-quickstart/network_no_internet.py b/examples/code-sandbox-quickstart/network_no_internet.py index e8bd2c7a1..186804b51 100644 --- a/examples/code-sandbox-quickstart/network_no_internet.py +++ b/examples/code-sandbox-quickstart/network_no_internet.py @@ -15,7 +15,7 @@ """ import os -from e2b_code_interpreter import Sandbox +from cubesandbox import Sandbox from env_utils import load_local_dotenv load_local_dotenv() diff --git a/examples/code-sandbox-quickstart/pause.py b/examples/code-sandbox-quickstart/pause.py index e2f200fbe..7e4d371f7 100644 --- a/examples/code-sandbox-quickstart/pause.py +++ b/examples/code-sandbox-quickstart/pause.py @@ -5,7 +5,7 @@ import os import time -from e2b_code_interpreter import Sandbox +from cubesandbox import Sandbox from env_utils import load_local_dotenv from rich import box from rich.console import Console @@ -123,6 +123,17 @@ def status_panel(status, sandbox_id, detail=None): console.rule(f"[{PAL['accent']}]Step 1 · Create Sandbox & Run Computation[/]") console.print(status_panel("running", sid)) + # Wait for Jupyter kernel to be ready (envd starts before Jupyter) + for _ in range(30): + try: + import httpx + r = httpx.get(f"http://{sandbox.get_host(49999)}/", timeout=3) + if r.status_code == 200: + break + except Exception: + pass + time.sleep(1) + console.print( Panel( Syntax( @@ -200,10 +211,21 @@ def status_panel(status, sandbox_id, detail=None): console.rule(f"[{PAL['ok']}]Step 4 · Resume Sandbox from Snapshot[/]") with console.status(f"[{PAL['ok']}]Resuming from snapshot...[/]"): - sandbox.connect() + sandbox = Sandbox.connect(sid) console.print(status_panel("running", sid)) + # Wait for Jupyter kernel to be ready after resume + for _ in range(30): + try: + import httpx + r = httpx.get(f"http://{sandbox.get_host(49999)}/", timeout=3) + if r.status_code == 200: + break + except Exception: + pass + time.sleep(1) + # ── Step 5: Verify State Preserved ─────────────────────────────────────── console.rule(f"[{PAL['ok']}]Step 5 · Verify State Preserved[/]") diff --git a/examples/code-sandbox-quickstart/read.py b/examples/code-sandbox-quickstart/read.py index edea2aa3c..0967f5bca 100644 --- a/examples/code-sandbox-quickstart/read.py +++ b/examples/code-sandbox-quickstart/read.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import os -from e2b_code_interpreter import Sandbox +from cubesandbox import Sandbox from env_utils import load_local_dotenv load_local_dotenv() diff --git a/examples/code-sandbox-quickstart/requirements.txt b/examples/code-sandbox-quickstart/requirements.txt index c8b57e8ba..4b2ed02a7 100644 --- a/examples/code-sandbox-quickstart/requirements.txt +++ b/examples/code-sandbox-quickstart/requirements.txt @@ -1,3 +1,3 @@ -e2b-code-interpreter>=2.4.1 +cubesandbox>=0.2.1 python-dotenv rich>=13.0 diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c99e2d08f..3c50dd11a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -264,6 +264,7 @@ export const clusterApi = { authEnabled: boolean; sandboxDomain: string; instanceType: string; + defaultTemplateId?: string; }>('/config'), }; @@ -284,6 +285,37 @@ export const storeApi = { refresh: () => api('/store/refresh', { method: 'POST' }), }; +export interface ExampleMeta { + id: string; + filename: string; + title: string; + description: string; + category: string; +} + +export interface ExampleSource { + id: string; + filename: string; + source: string; +} + +export interface ExampleRunResult { + stdout: string; + stderr: string; + exit_code: number; + success: boolean; +} + +export const examplesApi = { + list: () => api('/examples'), + source: (id: string) => api(`/examples/${encodeURIComponent(id)}`), + run: (id: string, templateId?: string) => + api('/examples/run', { + method: 'POST', + body: JSON.stringify({ id, template_id: templateId || undefined }), + }), +}; + export interface AgentInstanceDto { id: string; name: string; diff --git a/web/src/components/CodeBlock.tsx b/web/src/components/CodeBlock.tsx new file mode 100644 index 000000000..6ec2344e5 --- /dev/null +++ b/web/src/components/CodeBlock.tsx @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Tencent. All rights reserved. + +import { useMemo } from 'react'; +import { cn } from '@/lib/utils'; + +// ── Python token highlighter ────────────────────────────────────────────────── +// Lightweight, dependency-free tokenizer for Python source. Splits a source +// string into line-by-line segments tagged with one of: +// comment | keyword | string | number | function | decorator | builtin +// Plain text is the default. Intentionally simple — covers the tokens a reader +// needs to scan example code at a glance, with no grammar edge cases. + +type TokenKind = + | 'plain' + | 'comment' + | 'keyword' + | 'string' + | 'number' + | 'function' + | 'decorator' + | 'builtin' + | 'operator'; + +interface Token { + kind: TokenKind; + text: string; +} + +const KEYWORDS = new Set([ + 'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', + 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', + 'global', 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', + 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield', 'match', 'case', +]); + +const BUILTINS = new Set([ + 'print', 'len', 'range', 'list', 'dict', 'set', 'tuple', 'str', 'int', + 'float', 'bool', 'bytes', 'open', 'type', 'isinstance', 'enumerate', + 'map', 'filter', 'zip', 'sum', 'min', 'max', 'abs', 'sorted', 'reversed', + 'any', 'all', 'True', 'False', 'None', 'self', 'cls', +]); + +/** + * Tokenize a single line of Python. Strings (single/double/triple-quoted + * continued from a previous line are *not* handled across line boundaries — + * examples are short and self-contained, this is good enough. + */ +function tokenizeLine(line: string): Token[] { + const out: Token[] = []; + let i = 0; + let buf = ''; + + const flush = (kind: TokenKind) => { + if (buf) { + out.push({ kind, text: buf }); + buf = ''; + } + }; + const append = (kind: TokenKind, text: string) => { + if (buf) { + out.push({ kind: 'plain', text: buf }); + buf = ''; + } + out.push({ kind, text }); + }; + + while (i < line.length) { + const ch = line[i]; + const rest = line.slice(i); + + // Comment to EOL + if (ch === '#') { + flush('plain'); + append('comment', line.slice(i)); + i = line.length; + break; + } + + // Triple-quoted string (single line — covers docstring headers) + const triple = rest.match(/^(?:"""|''')/); + if (triple) { + flush('plain'); + const end = line.indexOf(triple[0], i + 3); + if (end >= 0) { + append('string', line.slice(i, end + 3)); + i = end + 3; + } else { + append('string', line.slice(i)); + i = line.length; + } + continue; + } + + // Single/double quoted string + if (ch === '"' || ch === '\'') { + flush('plain'); + const quote = ch; + let j = i + 1; + while (j < line.length && line[j] !== quote) { + if (line[j] === '\\' && j + 1 < line.length) j += 2; + else j++; + } + const end = j < line.length ? j + 1 : line.length; + append('string', line.slice(i, end)); + i = end; + continue; + } + + // Decorator + if (ch === '@' && (i === 0 || /\s/.test(buf.slice(-1)))) { + flush('plain'); + const m = rest.match(/^@[A-Za-z_][A-Za-z0-9_.]*/); + if (m) { + append('decorator', m[0]); + i += m[0].length; + continue; + } + } + + // Number + if (/[0-9]/.test(ch)) { + const m = rest.match(/^(?:0[xX][0-9A-Fa-f]+|0[oO][0-7]+|0[bB][01]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?[jJ]?)/); + if (m) { + flush('plain'); + append('number', m[0]); + i += m[0].length; + continue; + } + } + + // Identifier + if (/[A-Za-z_]/.test(ch)) { + const m = rest.match(/^[A-Za-z_][A-Za-z0-9_]*/); + if (m) { + const word = m[0]; + buf += word; + i += word.length; + // Look ahead for "(" to mark as a call + if (line[i] === '(') { + if (KEYWORDS.has(word)) { + flush('keyword'); + } else if (BUILTINS.has(word)) { + flush('builtin'); + } else { + // identifiers followed by "(" are calls → function highlight + flush('plain'); + append('function', word); + } + } else { + if (KEYWORDS.has(word)) { + flush('keyword'); + } else if (BUILTINS.has(word) && /^\s|$|[)\]:,\.]/.test(line[i] ?? ' ')) { + // Treat as builtin only when not a method call receiver + flush('builtin'); + } + } + continue; + } + } + + // Operators / punctuation + if (/[+\-*/%=<>!&|^~]/.test(ch)) { + flush('plain'); + append('operator', ch); + i++; + continue; + } + + buf += ch; + i++; + } + flush('plain'); + return out; +} + +// ── Public component ────────────────────────────────────────────────────────── + +export interface CodeBlockProps { + code: string; + language?: string; + /** Show line numbers in a leading gutter (default: true) */ + showLineNumbers?: boolean; + className?: string; +} + +/** + * A minimal Python syntax-highlighted code block. Renders each line as a + * flex row with a line-number gutter and token spans styled by `kind`. No + * external deps, no virtualized scrolling — sized to the content. + */ +export function CodeBlock({ + code, + language = 'python', + showLineNumbers = true, + className, +}: CodeBlockProps) { + const lines = useMemo(() => code.replace(/\n$/, '').split('\n'), [code]); + const tokenized = useMemo(() => lines.map(tokenizeLine), [lines]); + + return ( +
+      
+        {tokenized.map((tokens, lineIdx) => (
+          
+ {showLineNumbers && ( + + {lineIdx + 1} + + )} + + {tokens.length === 0 || (tokens.length === 1 && tokens[0].text === '') + ?   + : tokens.map((t, i) => ( + + {t.text} + + ))} + +
+ ))} +
+
+ ); +} + +function tokenClass(kind: TokenKind): string { + switch (kind) { + case 'comment': return 'text-muted-foreground/50 italic'; + case 'keyword': return 'text-cube-violet font-semibold'; + case 'string': return 'text-cube-emerald'; + case 'number': return 'text-cube-amber'; + case 'function': return 'text-cube-cyan'; + case 'decorator': return 'text-cube-amber/90'; + case 'builtin': return 'text-primary'; + case 'operator': return 'text-muted-foreground'; + default: return ''; + } +} diff --git a/web/src/components/CommandPalette.tsx b/web/src/components/CommandPalette.tsx index 74460dc0c..c28a2d603 100644 --- a/web/src/components/CommandPalette.tsx +++ b/web/src/components/CommandPalette.tsx @@ -13,6 +13,7 @@ import { KeyRound, Settings, Plus, + Sparkles, } from 'lucide-react'; import { useCommandPaletteStore } from '@/store/ui'; @@ -56,6 +57,7 @@ export function CommandPalette() { } label={tNav('templates')} onSelect={() => go('/templates')} /> } label={tNav('nodes')} onSelect={() => go('/nodes')} /> } label={tNav('observability')} onSelect={() => go('/observability')} /> + } label={tNav('examples')} onSelect={() => go('/examples')} /> } label={tNav('apiKeys')} onSelect={() => go('/keys')} /> } label={tNav('settings')} onSelect={() => go('/settings')} /> diff --git a/web/src/components/Rail.tsx b/web/src/components/Rail.tsx index 14f1b9cbc..c6e60fb91 100644 --- a/web/src/components/Rail.tsx +++ b/web/src/components/Rail.tsx @@ -15,6 +15,7 @@ import { Settings, Store, Layers, + BookOpen, Github, } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -30,6 +31,7 @@ const NAV_ITEMS = [ { to: '/observability', icon: Activity, key: 'observability' }, { to: '/keys', icon: KeyRound, key: 'apiKeys' }, { to: '/store', icon: Store, key: 'store' }, + { to: '/examples', icon: BookOpen, key: 'examples' }, { to: '/agenthub', icon: Bot, key: 'agentHub' }, { to: '/settings', icon: Settings, key: 'settings' }, ] as const; diff --git a/web/src/data/examples.ts b/web/src/data/examples.ts new file mode 100644 index 000000000..7cf77cdf3 --- /dev/null +++ b/web/src/data/examples.ts @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Tencent. All rights reserved. + +import { Beaker, FileText, Timer, Network, type LucideIcon } from 'lucide-react'; + +export type ExampleCategoryId = 'basics' | 'filesystem' | 'lifecycle' | 'network'; + +export interface ExampleCategory { + /** Stable id matching `category` field on the backend ExampleMeta */ + id: ExampleCategoryId; + /** i18n key under examples.categories. */ + labelKey: string; + /** i18n key under examples.categoriesDesc. */ + descKey: string; + icon: LucideIcon; + /** Tailwind classes for the category accent (text + ring + bg) */ + accent: string; + /** Order to display categories in the list */ + order: number; +} + +/** + * Static metadata for example categories. The example *registry* itself + * (titles / ids / descriptions) lives in the backend `examples` handler and + * is fetched at runtime — only the chrome (icon, accent color, ordering) + * lives here so the UI stays rich without round-tripping strings. + */ +export const EXAMPLE_CATEGORIES: ExampleCategory[] = [ + { + id: 'basics', + labelKey: 'categories.basics', + descKey: 'categoriesDesc.basics', + icon: Beaker, + accent: 'from-cube-emerald/25 to-cube-emerald/5 text-cube-emerald ring-cube-emerald/30', + order: 1, + }, + { + id: 'filesystem', + labelKey: 'categories.filesystem', + descKey: 'categoriesDesc.filesystem', + icon: FileText, + accent: 'from-cube-cyan/25 to-cube-cyan/5 text-cube-cyan ring-cube-cyan/30', + order: 2, + }, + { + id: 'lifecycle', + labelKey: 'categories.lifecycle', + descKey: 'categoriesDesc.lifecycle', + icon: Timer, + accent: 'from-cube-amber/25 to-cube-amber/5 text-cube-amber ring-cube-amber/30', + order: 3, + }, + { + id: 'network', + labelKey: 'categories.network', + descKey: 'categoriesDesc.network', + icon: Network, + accent: 'from-cube-violet/25 to-cube-violet/5 text-cube-violet ring-cube-violet/30', + order: 4, + }, +]; + +export function findCategory(id: string | undefined): ExampleCategory | undefined { + if (!id) return undefined; + return EXAMPLE_CATEGORIES.find((c) => c.id === id); +} diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts index d8758202d..651003f29 100644 --- a/web/src/i18n/index.ts +++ b/web/src/i18n/index.ts @@ -17,7 +17,7 @@ i18n fallbackLng: 'en', supportedLngs: ['en', 'zh'], defaultNS: 'common', - ns: ['common', 'nav', 'topbar', 'command', 'overview', 'sandboxes', 'sandboxDetail', 'sandboxNew', 'templates', 'templateDetail', 'nodes', 'nodeDetail', 'network', 'keys', 'placeholder', 'settings', 'observability', 'store', 'agentHub', 'auth'], + ns: ['common', 'nav', 'topbar', 'command', 'overview', 'sandboxes', 'sandboxDetail', 'sandboxNew', 'templates', 'templateDetail', 'nodes', 'nodeDetail', 'network', 'keys', 'placeholder', 'settings', 'observability', 'store', 'agentHub', 'auth', 'examples'], interpolation: { escapeValue: false, }, diff --git a/web/src/i18n/resources.ts b/web/src/i18n/resources.ts index f44f6ab62..afcd63030 100644 --- a/web/src/i18n/resources.ts +++ b/web/src/i18n/resources.ts @@ -23,6 +23,7 @@ import enObservability from '@/locales/en/observability.json'; import enStore from '@/locales/en/store.json'; import enAgentHub from '@/locales/en/agentHub.json'; import enAuth from '@/locales/en/auth.json'; +import enExamples from '@/locales/en/examples.json'; import zhCommon from '@/locales/zh/common.json'; import zhNav from '@/locales/zh/nav.json'; @@ -46,6 +47,7 @@ import zhObservability from '@/locales/zh/observability.json'; import zhStore from '@/locales/zh/store.json'; import zhAgentHub from '@/locales/zh/agentHub.json'; import zhAuth from '@/locales/zh/auth.json'; +import zhExamples from '@/locales/zh/examples.json'; export const resources = { en: { @@ -71,6 +73,7 @@ export const resources = { store: enStore, agentHub: enAgentHub, auth: enAuth, + examples: enExamples, }, zh: { common: zhCommon, @@ -95,6 +98,7 @@ export const resources = { store: zhStore, agentHub: zhAgentHub, auth: zhAuth, + examples: zhExamples, }, } as const; diff --git a/web/src/locales/en/examples.json b/web/src/locales/en/examples.json new file mode 100644 index 000000000..4a55eb023 --- /dev/null +++ b/web/src/locales/en/examples.json @@ -0,0 +1,32 @@ +{ + "title": "Examples", + "subtitle": "Browse and run code-sandbox-quickstart examples directly from the dashboard — experience sandbox capabilities in one click.", + "badge": "code-sandbox-quickstart", + "run": "Run", + "running": "Running…", + "output": "Output", + "outputHint": "Click Run to execute this example. Output will appear here.", + "selectHint": "Pick an example from the left to view its code and output here.", + "selectHintTitle": "Choose an example to start", + "defaultTemplate": "Default: {{id}}", + "noTemplate": "No templates available", + "allCategories": "All", + "searchPlaceholder": "Search examples…", + "noResults": "No matching examples", + "copy": "Copy code", + "copied": "Copied to clipboard", + "copyFailed": "Copy failed", + "completed": "Completed", + "failed": "Failed", + "templateSelector": { + "title": "Select template", + "searchPlaceholder": "Search by ID / type…", + "empty": "No matching templates", + "hint": "Click outside to close", + "group": { + "ready": "Available", + "building": "Building", + "other": "Other" + } + } +} diff --git a/web/src/locales/en/nav.json b/web/src/locales/en/nav.json index 63b7b95f2..8d8eac797 100644 --- a/web/src/locales/en/nav.json +++ b/web/src/locales/en/nav.json @@ -9,5 +9,6 @@ "apiKeys": "API Keys", "settings": "Settings", "store": "Template Store", - "agentHub": "AgentHub" + "agentHub": "AgentHub", + "examples": "Examples" } \ No newline at end of file diff --git a/web/src/locales/zh/examples.json b/web/src/locales/zh/examples.json new file mode 100644 index 000000000..ed9217b0f --- /dev/null +++ b/web/src/locales/zh/examples.json @@ -0,0 +1,32 @@ +{ + "title": "示例中心", + "subtitle": "浏览并直接在控制台运行 code-sandbox-quickstart 示例代码,一键体验沙箱的常用能力。", + "badge": "code-sandbox-quickstart", + "run": "运行", + "running": "运行中…", + "output": "运行结果", + "outputHint": "点击「运行」执行此示例,输出将在这里显示。", + "selectHint": "从左侧选择一个示例,代码和运行结果将在这里显示。", + "selectHintTitle": "选择一个示例开始", + "defaultTemplate": "默认:{{id}}", + "noTemplate": "暂无可用模板,请新建模版", + "allCategories": "全部", + "searchPlaceholder": "搜索示例…", + "noResults": "没有匹配的示例", + "copy": "复制代码", + "copied": "已复制到剪贴板", + "copyFailed": "复制失败", + "completed": "已完成", + "failed": "失败", + "templateSelector": { + "title": "选择模板", + "searchPlaceholder": "按 ID / 类型搜索…", + "empty": "没有匹配的模板", + "hint": "点击外部区域关闭", + "group": { + "ready": "可用", + "building": "构建中", + "other": "其他" + } + } +} diff --git a/web/src/locales/zh/nav.json b/web/src/locales/zh/nav.json index bf3946cad..e8af1b245 100644 --- a/web/src/locales/zh/nav.json +++ b/web/src/locales/zh/nav.json @@ -9,5 +9,6 @@ "apiKeys": "API 密钥", "settings": "设置", "store": "模板市场", - "agentHub": "数字助手" + "agentHub": "数字助手", + "examples": "示例中心" } \ No newline at end of file diff --git a/web/src/main.tsx b/web/src/main.tsx index eaed35668..fd2cf6272 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -27,6 +27,7 @@ import TemplateStorePage from '@/pages/TemplateStore'; import AgentHubPage from '@/pages/AgentHub'; import LoginPage from '@/pages/Login'; import { AuthGuard } from '@/components/AuthGuard'; +import ExamplesPage from '@/pages/Examples'; import { Placeholder } from '@/pages/Placeholder'; import { Network, Activity, Settings, Package } from 'lucide-react'; @@ -63,9 +64,10 @@ const App = () => ( } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/web/src/pages/Examples.tsx b/web/src/pages/Examples.tsx new file mode 100644 index 000000000..c5fdd4d22 --- /dev/null +++ b/web/src/pages/Examples.tsx @@ -0,0 +1,831 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Tencent. All rights reserved. + +import { useState, useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { api } from '@/lib/api'; +import { templateApi, clusterApi, type TemplateSummary } from '@/api/client'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { CodeBlock } from '@/components/CodeBlock'; +import { showToast } from '@/components/ui/ToastProvider'; +import { + Play, + Terminal, + CheckCircle2, + XCircle, + Clock, + ChevronRight, + Sparkles, + Rocket, + FolderOpen, + PauseCircle, + Globe2, + Search, + Copy, + Cpu, + Layers, + ChevronDown, + Check, + Inbox, + FileCode2, + Timer, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface ExampleMeta { + id: string; + filename: string; + title: string; + description: string; + category: string; +} + +interface RunExampleResponse { + stdout: string; + stderr: string; + exit_code: number; + success: boolean; +} + +// ── API ────────────────────────────────────────────────────────────────────── + +const examplesApi = { + list: () => api('/examples'), + getSource: (id: string) => + api<{ id: string; filename: string; source: string }>(`/examples/${id}`), + run: (id: string, templateId?: string) => + api('/examples/run', { + method: 'POST', + body: JSON.stringify({ id, template_id: templateId || undefined }), + }), +}; + +// ── Category helpers ────────────────────────────────────────────────────────── + +const CATEGORY_META: Record< + string, + { label: string; icon: typeof Rocket; tone: 'info' | 'ok' | 'warn' | 'mute'; gradient: string } +> = { + basics: { + label: 'Basics', + icon: Rocket, + tone: 'info', + gradient: 'from-primary/20 via-primary/5 to-transparent', + }, + filesystem: { + label: 'Filesystem', + icon: FolderOpen, + tone: 'ok', + gradient: 'from-cube-emerald/20 via-cube-emerald/5 to-transparent', + }, + lifecycle: { + label: 'Lifecycle', + icon: PauseCircle, + tone: 'warn', + gradient: 'from-cube-amber/20 via-cube-amber/5 to-transparent', + }, + network: { + label: 'Network', + icon: Globe2, + tone: 'mute', + gradient: 'from-cube-violet/20 via-cube-violet/5 to-transparent', + }, +}; + +const CATEGORY_ORDER = ['basics', 'filesystem', 'lifecycle', 'network']; + +// ── RunOutput ──────────────────────────────────────────────────────────────── + +function RunOutput({ result, isRunning }: { result: RunExampleResponse | null; isRunning: boolean }) { + if (isRunning) { + return ( +
+ + + + + {`Running example… this may take a few seconds.`} +
+ ); + } + if (!result) return null; + + return ( +
+
+ {result.success ? ( + + + Exited 0 + + ) : ( + + + {`Exited ${result.exit_code}`} + + )} + + {result.stdout ? `${result.stdout.split('\n').filter(Boolean).length} lines` : 'no output'} + +
+ + {result.stdout && ( +
+
+            {result.stdout}
+          
+
+ )} + {result.stderr && ( +
+
+            {result.stderr}
+          
+
+ )} +
+ ); +} + +// ── Template dropdown ──────────────────────────────────────────────────────── + +interface TemplateDropdownProps { + templates: TemplateSummary[]; + defaultTemplateId?: string; + value: string | undefined; + onChange: (id: string | undefined) => void; +} + +function TemplateDropdown({ templates, defaultTemplateId, value, onChange }: TemplateDropdownProps) { + const { t } = useTranslation('examples'); + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(''); + const ref = useRef(null); + const searchRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + // Auto-focus search when opened + useEffect(() => { + if (open) { + // small delay so the input is mounted + const t = setTimeout(() => searchRef.current?.focus(), 30); + return () => clearTimeout(t); + } else { + setFilter(''); + } + }, [open]); + + const isDefault = value === defaultTemplateId; + + const filtered = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) return templates; + return templates.filter( + (t) => + t.templateID.toLowerCase().includes(q) || + (t.instanceType ?? '').toLowerCase().includes(q) || + (t.status ?? '').toLowerCase().includes(q), + ); + }, [templates, filter]); + + // Group by status: ready → building → others + const grouped = useMemo(() => { + const ready: TemplateSummary[] = []; + const building: TemplateSummary[] = []; + const other: TemplateSummary[] = []; + for (const t of filtered) { + const s = t.status.toLowerCase(); + if (s === 'ready') ready.push(t); + else if (s === 'building' || s === 'pending') building.push(t); + else other.push(t); + } + return { ready, building, other }; + }, [filtered]); + + const totalShown = filtered.length; + const totalAll = templates.length; + + return ( +
+ + + {open && ( +
+ {/* Header */} +
+
+ + + +

+ {t('templateSelector.title')} +

+
+ + {totalShown}/{totalAll} + +
+ + {/* Search */} + {templates.length > 4 && ( +
+
+ + setFilter(e.target.value)} + placeholder={t('templateSelector.searchPlaceholder')} + className={cn( + 'h-7 w-full rounded-md border border-border/60 bg-background pl-7 pr-2 text-xs', + 'placeholder:text-muted-foreground/60', + 'focus:outline-none focus:ring-1 focus:ring-primary/40', + )} + /> +
+
+ )} + + {/* List */} +
+ {filtered.length === 0 ? ( +
+ +

{t('templateSelector.empty')}

+
+ ) : ( + <> + {(['ready', 'building', 'other'] as const).map((groupKey) => { + const items = grouped[groupKey]; + if (!items.length) return null; + return ( +
+

+ + {t(`templateSelector.group.${groupKey}`)} + · {items.length} +

+ {items.map((tpl) => { + const isSelected = tpl.templateID === value; + const statusLower = tpl.status.toLowerCase(); + return ( + + ); + })} +
+ ); + })} + + )} +
+ + {/* Footer hint */} +
+ {t('templateSelector.hint')} +
+
+ )} +
+ ); +} + +// ── ExampleCard ────────────────────────────────────────────────────────────── + +interface ExampleCardProps { + example: ExampleMeta; + selected: boolean; + onSelect: () => void; +} + +function ExampleCard({ example, selected, onSelect }: ExampleCardProps) { + const meta = CATEGORY_META[example.category] ?? CATEGORY_META.basics; + const Icon = meta.icon; + + return ( + + ); +} + +// ── Empty state for output panel ───────────────────────────────────────────── + +function OutputEmpty({ onRun }: { onRun?: () => void }) { + const { t } = useTranslation('examples'); + return ( +
+ + + +

{t('outputHint')}

+ {onRun && ( + + )} +
+ ); +} + +// ── Main page ──────────────────────────────────────────────────────────────── + +export default function ExamplesPage() { + const { t } = useTranslation('examples'); + const [selectedId, setSelectedId] = useState(null); + const [runResult, setRunResult] = useState(null); + const [selectedTemplateId, setSelectedTemplateId] = useState(undefined); + const [activeCategory, setActiveCategory] = useState('all'); + const [search, setSearch] = useState(''); + + const { data: examples, isLoading } = useQuery({ + queryKey: ['examples'], + queryFn: examplesApi.list, + }); + + const { data: templates } = useQuery({ + queryKey: ['templates'], + queryFn: () => templateApi.list(), + }); + + const { data: config } = useQuery({ + queryKey: ['config'], + queryFn: () => clusterApi.config(), + }); + + const defaultTemplateId = config?.defaultTemplateId; + const firstTemplateId = templates?.[0]?.templateID; + const effectiveTemplateId = selectedTemplateId ?? defaultTemplateId ?? firstTemplateId; + + useEffect(() => { + if (effectiveTemplateId && selectedTemplateId === undefined) { + setSelectedTemplateId(effectiveTemplateId); + } + }, [effectiveTemplateId, selectedTemplateId]); + + const runMutation = useMutation({ + mutationFn: (id: string) => examplesApi.run(id, selectedTemplateId), + onSuccess: (data) => setRunResult(data), + onMutate: () => setRunResult(null), + }); + + const selected = examples?.find((e) => e.id === selectedId) ?? null; + + const { data: sourceData, isLoading: isSourceLoading } = useQuery({ + queryKey: ['examples', selectedId, 'source'], + queryFn: () => examplesApi.getSource(selectedId!), + enabled: !!selectedId, + }); + const sourceCode = sourceData?.source ?? ''; + + // Group + filter + const filteredList = useMemo(() => { + if (!examples) return []; + const q = search.trim().toLowerCase(); + return examples.filter((e) => { + if (activeCategory !== 'all' && e.category !== activeCategory) return false; + if (q) { + return ( + e.title.toLowerCase().includes(q) || + e.description.toLowerCase().includes(q) || + e.filename.toLowerCase().includes(q) + ); + } + return true; + }); + }, [examples, activeCategory, search]); + + const grouped = useMemo(() => { + const out: Record = {}; + for (const e of filteredList) { + (out[e.category] ??= []).push(e); + } + return out; + }, [filteredList]); + + // Stats for header + const totalCount = examples?.length ?? 0; + const categoryCount = useMemo(() => { + if (!examples) return 0; + return new Set(examples.map((e) => e.category)).size; + }, [examples]); + + const handleCopySource = async () => { + if (!sourceCode) return; + try { + await navigator.clipboard.writeText(sourceCode); + showToast(t('copied'), 'success'); + } catch { + showToast(t('copyFailed'), 'warn'); + } + }; + + const runSelected = () => { + if (selected) runMutation.mutate(selected.id); + }; + + return ( +
+ {/* Hero header */} +
+
+
+
+
+
+ + + +

{t('title')}

+ {t('badge')} +
+

{t('subtitle')}

+
+
+
+ examples · + {totalCount} +
+
+ categories · + {categoryCount} +
+
+
+
+ + {/* Toolbar: search + category filter */} +
+
+ + setSearch(e.target.value)} + placeholder={t('searchPlaceholder')} + className={cn( + 'h-9 w-full rounded-lg border border-border/60 bg-background pl-8 pr-3 text-sm', + 'placeholder:text-muted-foreground/70', + 'focus:outline-none focus:ring-1 focus:ring-primary/40', + )} + /> +
+
+ + {CATEGORY_ORDER.filter((c) => CATEGORY_META[c]).map((cat) => { + const meta = CATEGORY_META[cat]; + const Icon = meta.icon; + return ( + + ); + })} +
+
+ + {/* Main two-column layout */} +
+ {/* Left: example list */} +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : filteredList.length === 0 ? ( +
+ +

{t('noResults')}

+
+ ) : ( +
+ {CATEGORY_ORDER.filter((c) => grouped[c]?.length).map((cat) => { + const meta = CATEGORY_META[cat]; + const items = grouped[cat]; + if (!items?.length) return null; + return ( +
+
+ +

+ {meta.label} +

+ · {items.length} +
+
+ {items.map((ex) => ( + { + setSelectedId(ex.id); + setRunResult(null); + }} + /> + ))} +
+
+ ); + })} +
+ )} +
+ + {/* Right: code + output */} +
+ {selected ? ( + <> + {/* Code panel */} + +
+
+ + + + {selected.title} + · {selected.filename} +
+
+ + + +
+
+
+ {isSourceLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : sourceCode ? ( + + ) : ( +
+                      # {selected.filename}
+                    
+ )} +
+
+ + {/* Output panel */} + +
+
+ + + + {t('output')} + {runMutation.isPending && ( + + + {t('running')} + + )} +
+ {runResult && ( + + {runResult.success ? t('completed') : t('failed')} + + )} +
+
+ {!runResult && !runMutation.isPending ? ( + + ) : ( + + )} +
+
+ + ) : ( + + + + +
+

{t('selectHintTitle')}

+

{t('selectHint')}

+
+
+ )} +
+
+
+ ); +} From 28b7e041ac5a29e19eb3876ad58cdc2763659413 Mon Sep 17 00:00:00 2001 From: liciazhu Date: Wed, 10 Jun 2026 10:21:32 +0800 Subject: [PATCH 02/48] mod example-center copy func Signed-off-by: liciazhu --- web/src/pages/Examples.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/web/src/pages/Examples.tsx b/web/src/pages/Examples.tsx index c5fdd4d22..6d159bb97 100644 --- a/web/src/pages/Examples.tsx +++ b/web/src/pages/Examples.tsx @@ -11,7 +11,6 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { CodeBlock } from '@/components/CodeBlock'; -import { showToast } from '@/components/ui/ToastProvider'; import { Play, Terminal, @@ -34,7 +33,7 @@ import { FileCode2, Timer, } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import { cn, copyToClipboard } from '@/lib/utils'; // ── Types ──────────────────────────────────────────────────────────────────── @@ -572,14 +571,11 @@ export default function ExamplesPage() { return new Set(examples.map((e) => e.category)).size; }, [examples]); - const handleCopySource = async () => { + const handleCopySource = () => { if (!sourceCode) return; - try { - await navigator.clipboard.writeText(sourceCode); - showToast(t('copied'), 'success'); - } catch { - showToast(t('copyFailed'), 'warn'); - } + // copyToClipboard 内置 fallback:HTTPS 下用 navigator.clipboard, + // HTTP(无 Secure Context)下回退到 execCommand('copy'),不再抛异常。 + copyToClipboard(sourceCode, t('copied')); }; const runSelected = () => { From c3146d78d79dd3b2548b0f31d1e20020175b8774 Mon Sep 17 00:00:00 2001 From: liciazhu Date: Wed, 10 Jun 2026 16:07:33 +0800 Subject: [PATCH 03/48] add more examples for center Signed-off-by: liciazhu --- .gitignore | 4 + CubeAPI/src/handlers/examples.rs | 1039 +++++++++++++++-- CubeAPI/src/routes.rs | 34 +- examples/browser-sandbox/env_utils.py | 22 + examples/browser-sandbox/requirements.txt | 2 +- examples/host-mount/create_with_mount.py | 14 +- examples/host-mount/requirements.txt | 2 +- examples/network-policy/network_allowlist.py | 2 +- examples/network-policy/network_denylist.py | 2 +- .../network-policy/network_no_internet.py | 4 +- examples/network-policy/requirements.txt | 2 +- package-lock.json | 6 + web/package-lock.json | 268 +++++ web/package.json | 3 + web/src/api/client.ts | 65 +- web/src/components/CodeEditor.tsx | 137 +++ web/src/components/Rail.tsx | 4 +- web/src/components/StepTimeline.tsx | 173 +++ web/src/components/TopologyGraph.tsx | 335 ++++++ web/src/data/exampleScenarios.ts | 678 +++++++++++ web/src/locales/en/examples.json | 70 +- web/src/locales/en/nav.json | 2 +- web/src/locales/zh/examples.json | 70 +- web/src/locales/zh/nav.json | 2 +- web/src/main.tsx | 2 +- web/src/mocks/handlers/index.ts | 107 ++ web/src/pages/Examples.tsx | 827 ------------- web/src/pages/SandboxCases.tsx | 1033 ++++++++++++++++ 28 files changed, 3936 insertions(+), 973 deletions(-) create mode 100644 examples/browser-sandbox/env_utils.py create mode 100644 package-lock.json create mode 100644 web/src/components/CodeEditor.tsx create mode 100644 web/src/components/StepTimeline.tsx create mode 100644 web/src/components/TopologyGraph.tsx create mode 100644 web/src/data/exampleScenarios.ts delete mode 100644 web/src/pages/Examples.tsx create mode 100644 web/src/pages/SandboxCases.tsx diff --git a/.gitignore b/.gitignore index 96774de12..eabc3606e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,10 @@ examples/openai-agents-code-interpreter/__pycache__/ examples/openai-agents-code-interpreter/output/ examples/openai-agents-code-interpreter/.scratch/ examples/code-sandbox-quickstart/__pycache__/ +examples/browser-sandbox/__pycache__/ +examples/host-mount/__pycache__/ +examples/network-policy/__pycache__/ +examples/snapshot-rollback-clone/__pycache__/ pvm-guest-build/ pvm-host-build/ diff --git a/CubeAPI/src/handlers/examples.rs b/CubeAPI/src/handlers/examples.rs index a27e49997..0d5a29a0f 100644 --- a/CubeAPI/src/handlers/examples.rs +++ b/CubeAPI/src/handlers/examples.rs @@ -1,26 +1,48 @@ // Copyright (c) 2024 Tencent Inc. // SPDX-License-Identifier: Apache-2.0 // -// Examples handler: list available example scripts and run them via subprocess. +// Examples handler: list available example scripts under `examples//` +// and run them via subprocess, with optional user-edited code injection. +// +// Each example belongs to a "scenario" (sub-directory of `examples/`). The +// handler exposes a static registry mapping scenario → category → files, plus +// hidden AI/LLM demos that are intentionally not surfaced to the UI. +// +// On `run`, the handler also synthesises a small execution step log (parse → +// control-plane → data-plane → cleanup) and the topology graph that the UI +// renders with @xyflow/react. Real per-step telemetry would require deeper +// instrumentation; the synthetic log keeps the API surface stable for the +// front-end. use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use serde::{Deserialize, Serialize}; -use std::time::Duration; +use std::path::PathBuf; +use std::time::{Duration, Instant}; use tokio::process::Command; use tokio::time::timeout; use utoipa::ToSchema; +use uuid::Uuid; use crate::{error::AppResult, state::AppState}; // ─── Models ─────────────────────────────────────────────────────────────────── -#[derive(Serialize, ToSchema)] +#[derive(Serialize, Clone, ToSchema)] pub struct ExampleMeta { + /// Stable identifier. Format: ":" so the handler can + /// resolve the disk path without ambiguity. Example: + /// "code-sandbox-quickstart:create". pub id: String, + /// Scenario (sub-directory) this example lives in. + pub scenario: String, + /// Filename inside the scenario directory. pub filename: String, pub title: String, pub description: String, pub category: String, + /// Source language: python | go | bash | markdown. Surfaced to the UI so + /// the editor can pick a syntax mode without re-reading the file. + pub language: String, } #[derive(Deserialize, ToSchema)] @@ -30,102 +52,813 @@ pub struct RunExampleRequest { /// over server-configured defaults. Allows the frontend to let users /// pick which template to use for each example run. pub template_id: Option, + /// Optional override of the example language. Surfaced back to the UI so + /// it can verify the editor picked the right syntax mode; the handler + /// itself picks the interpreter from the file extension. + #[allow(dead_code)] + pub language: Option, + /// When present, the handler writes this body to a temporary file next + /// to the original and runs that file instead. This lets the UI surface + /// an editable Monaco buffer while keeping the registry on disk + /// authoritative for read access. + pub code: Option, +} + +#[derive(Serialize, Clone, ToSchema)] +pub struct StepLog { + pub name: String, + /// "control" (CubeAPI / CubeMaster) or "data" (envd / sandbox runtime). + pub plane: String, + /// "ok" | "warn" | "err" | "skipped". + pub status: String, + pub duration_ms: u64, + pub message: String, +} + +#[derive(Serialize, Clone, ToSchema)] +pub struct TopologyNode { + pub id: String, + pub label: String, + /// "control" | "data". + pub plane: String, + /// "user" | "control" | "data" | "vm" | "store". + pub kind: String, + pub description: String, } -#[derive(Serialize, ToSchema)] +#[derive(Serialize, Clone, ToSchema)] +pub struct TopologyEdge { + pub from: String, + pub to: String, + pub label: String, + pub plane: String, +} + +#[derive(Serialize, Clone, ToSchema)] +pub struct TopologyGraph { + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Serialize, Clone, ToSchema)] pub struct RunExampleResponse { pub stdout: String, pub stderr: String, pub exit_code: i32, pub success: bool, + pub elapsed_ms: u64, + pub steps: Vec, + pub topology: TopologyGraph, + pub ran_edited: bool, } // ─── Example registry ───────────────────────────────────────────────────────── -fn example_list() -> Vec { +/// Static per-file metadata. One entry per runnable file. +#[derive(Clone)] +struct FileSpec { + id: &'static str, + filename: &'static str, + title: &'static str, + description: &'static str, + language: &'static str, +} + +/// Static scenario metadata. `hidden: true` keeps the scenario on disk and +/// queryable for future re-enable, but excludes it from list/source/run +/// responses so AI/LLM demos do not leak into the UI. +struct ScenarioSpec { + id: &'static str, + category: &'static str, + hidden: bool, + files: &'static [FileSpec], + /// Per-scenario run timeout in seconds. Defaults to 120 when absent. + timeout_secs: Option, + /// Topology template applied to every file inside this scenario. The + /// per-run response augments this with a "ran ok / failed" node status + /// before shipping it to the UI. + topology: TopologyTemplate, +} + +/// Either a fixed graph or a closure that emits nodes/edges dynamically +/// (we only need static templates today, but the indirection lets us add +/// e.g. bench concurrency fan-outs later without touching the registry). +#[derive(Clone)] +struct TopologyTemplate { + nodes: Vec, + edges: Vec, +} + +fn topology_for(scenario: &str) -> TopologyTemplate { + // Shared base topology: + // Control plane: User → CubeAPI → CubeMaster → Cubelet + // Data plane: CubeAPI → CubeProxy → envd → Runner + // MicroVM (data plane) is the sandbox boundary; Cubelet creates it + // via QMP (control-plane edge) but the workload runs inside it. + // envd is reached by CubeProxy over a WSS tunnel — NOT via a direct + // microvm→envd edge (that would be a containment relationship, not + // a network connection). + let mut nodes = vec![ + TopologyNode { + id: "user".into(), + label: "User Script".into(), + plane: "control".into(), + kind: "user".into(), + description: "The example invocation triggered when you click Run.".into(), + }, + TopologyNode { + id: "cubeapi".into(), + label: "CubeAPI :3000".into(), + plane: "control".into(), + kind: "control".into(), + description: "HTTP gateway: validates requests, schedules sandbox creation, proxies data.".into(), + }, + TopologyNode { + id: "cubemaster".into(), + label: "CubeMaster".into(), + plane: "control".into(), + kind: "control".into(), + description: "Scheduler: picks a Cubelet node based on template & load.".into(), + }, + TopologyNode { + id: "cubelet".into(), + label: "Cubelet".into(), + plane: "control".into(), + kind: "control".into(), + description: "Per-node agent: manages the full MicroVM lifecycle.".into(), + }, + TopologyNode { + id: "cubeproxy".into(), + label: "CubeProxy".into(), + plane: "data".into(), + kind: "control".into(), + description: "TLS-terminating reverse proxy: forwards via WSS tunnel to in-sandbox envd.".into(), + }, + TopologyNode { + id: "microvm".into(), + label: "KVM MicroVM".into(), + plane: "data".into(), + kind: "vm".into(), + description: "QEMU/KVM MicroVM: sandbox isolation boundary running envd and the workload.".into(), + }, + TopologyNode { + id: "envd".into(), + label: "envd :49983".into(), + plane: "data".into(), + kind: "data".into(), + description: "In-sandbox daemon: exposes Jupyter kernel, filesystem and shell.".into(), + }, + TopologyNode { + id: "runner".into(), + label: "Python / Shell".into(), + plane: "data".into(), + kind: "data".into(), + description: "The interpreter process that runs the example code, forked by envd.".into(), + }, + ]; + let mut edges = vec![ + TopologyEdge { + from: "user".into(), + to: "cubeapi".into(), + label: "HTTPS".into(), + plane: "control".into(), + }, + TopologyEdge { + from: "cubeapi".into(), + to: "cubemaster".into(), + label: "gRPC".into(), + plane: "control".into(), + }, + TopologyEdge { + from: "cubemaster".into(), + to: "cubelet".into(), + label: "gRPC".into(), + plane: "control".into(), + }, + TopologyEdge { + from: "cubelet".into(), + to: "microvm".into(), + label: "QMP / boot".into(), + plane: "control".into(), + }, + TopologyEdge { + from: "cubeapi".into(), + to: "cubeproxy".into(), + label: "HTTPS".into(), + plane: "data".into(), + }, + TopologyEdge { + from: "cubeproxy".into(), + to: "envd".into(), + label: "WSS tunnel".into(), + plane: "data".into(), + }, + TopologyEdge { + from: "envd".into(), + to: "runner".into(), + label: "fork+exec".into(), + plane: "data".into(), + }, + ]; + + match scenario { + "network-policy" => { + // Insert eBPF tap between Cubelet and MicroVM. + nodes.push(TopologyNode { + id: "cubevs".into(), + label: "CubeVS (eBPF)".into(), + plane: "data".into(), + kind: "control".into(), + description: "eBPF datapath enforcing allow/deny rules on the guest's veth.".into(), + }); + edges.push(TopologyEdge { + from: "cubelet".into(), + to: "cubevs".into(), + label: "tc/eBPF".into(), + plane: "data".into(), + }); + edges.push(TopologyEdge { + from: "cubevs".into(), + to: "microvm".into(), + label: "veth".into(), + plane: "data".into(), + }); + // Drop the default edge if it exists. + edges.retain(|e| !(e.from == "cubelet" && e.to == "microvm")); + } + "host-mount" => { + // Source path comes from the host. + nodes.push(TopologyNode { + id: "hostdir".into(), + label: "Host directory".into(), + plane: "data".into(), + kind: "store".into(), + description: "Local directory bind-mounted into the MicroVM at boot.".into(), + }); + edges.push(TopologyEdge { + from: "hostdir".into(), + to: "microvm".into(), + label: "9p / virtiofs".into(), + plane: "data".into(), + }); + } + "browser-sandbox" => { + // Replace the runner with Chromium + Playwright. + nodes.retain(|n| n.id != "runner"); + edges.retain(|e| e.from != "envd" || e.to != "runner"); + nodes.push(TopologyNode { + id: "chromium".into(), + label: "Chromium :9000".into(), + plane: "data".into(), + kind: "data".into(), + description: "Headless Chromium inside the guest with CDP enabled.".into(), + }); + nodes.push(TopologyNode { + id: "playwright".into(), + label: "Playwright (CDP)".into(), + plane: "data".into(), + kind: "data".into(), + description: "Python client driving Chromium over the Chrome DevTools Protocol.".into(), + }); + edges.push(TopologyEdge { + from: "envd".into(), + to: "playwright".into(), + label: "exec".into(), + plane: "data".into(), + }); + edges.push(TopologyEdge { + from: "playwright".into(), + to: "chromium".into(), + label: "CDP WS".into(), + plane: "data".into(), + }); + } + "snapshot-rollback-clone" => { + // LVM snapshot storage sitting next to the VM. + nodes.push(TopologyNode { + id: "snapshot".into(), + label: "Snapshot (LVM)".into(), + plane: "control".into(), + kind: "store".into(), + description: "CoW snapshot of the root LV. Outlives the sandbox; clones & rollback source.".into(), + }); + edges.push(TopologyEdge { + from: "cubelet".into(), + to: "snapshot".into(), + label: "lvcreate --snapshot".into(), + plane: "control".into(), + }); + edges.push(TopologyEdge { + from: "snapshot".into(), + to: "microvm".into(), + label: "rollback".into(), + plane: "control".into(), + }); + } + "e2b-dev-sidecar" => { + // Local sidecar proxies requests through header rewriting. + nodes.push(TopologyNode { + id: "sidecar".into(), + label: "Dev Sidecar".into(), + plane: "data".into(), + kind: "control".into(), + description: "Local reverse-proxy that rewrites Host headers for e2b compatibility.".into(), + }); + edges.retain(|e| !(e.from == "cubeapi" && e.to == "cubeproxy")); + edges.push(TopologyEdge { + from: "cubeapi".into(), + to: "sidecar".into(), + label: "HTTPS".into(), + plane: "data".into(), + }); + edges.push(TopologyEdge { + from: "sidecar".into(), + to: "cubeproxy".into(), + label: "Host rewrite".into(), + plane: "data".into(), + }); + } + "cubesandbox-base-nginx" => { + // Replace runner with nginx so the topology reflects a web workload. + nodes.retain(|n| n.id != "runner"); + edges.retain(|e| e.from != "envd" || e.to != "runner"); + nodes.push(TopologyNode { + id: "nginx".into(), + label: "nginx :80".into(), + plane: "data".into(), + kind: "data".into(), + description: "nginx serving static files inside the guest image.".into(), + }); + edges.push(TopologyEdge { + from: "envd".into(), + to: "nginx".into(), + label: "exec".into(), + plane: "data".into(), + }); + } + "cube-bench" => { + // Fan out: replace MicroVM with N replicas. + nodes.retain(|n| n.id != "microvm"); + edges.retain(|e| e.to != "microvm"); + let n = 4usize; + for i in 0..n { + nodes.push(TopologyNode { + id: format!("microvm-{i}"), + label: format!("MicroVM #{i}"), + plane: "data".into(), + kind: "vm".into(), + description: "Concurrent benchmark target sandbox.".into(), + }); + edges.push(TopologyEdge { + from: "cubelet".into(), + to: format!("microvm-{i}"), + label: "QMP".into(), + plane: "control".into(), + }); + } + } + _ => {} + } + + TopologyTemplate { nodes, edges } +} + +fn file_languages() -> std::collections::HashMap<&'static str, &'static str> { + [ + ("code-sandbox-quickstart:create", "python"), + ("code-sandbox-quickstart:exec_code", "python"), + ("code-sandbox-quickstart:cmd", "python"), + ("code-sandbox-quickstart:read", "python"), + ("code-sandbox-quickstart:pause", "python"), + ("network-policy:network_no_internet", "python"), + ("network-policy:network_allowlist", "python"), + ("network-policy:network_denylist", "python"), + ("host-mount:create_with_mount", "python"), + ("browser-sandbox:browser", "python"), + ("snapshot-rollback-clone:01_create_snapshot", "python"), + ("snapshot-rollback-clone:02_list_snapshots", "python"), + ("snapshot-rollback-clone:03_clone_from_snapshot", "python"), + ("snapshot-rollback-clone:04_state_preserved", "python"), + ("snapshot-rollback-clone:05_snapshot_outlives_sandbox", "python"), + ("snapshot-rollback-clone:06_clone_n", "python"), + ("snapshot-rollback-clone:07_clone_concurrent", "python"), + ("snapshot-rollback-clone:08_fork_three_axis", "python"), + ("snapshot-rollback-clone:09_rollback", "python"), + ("snapshot-rollback-clone:10_rollback_then_continue", "python"), + ("snapshot-rollback-clone:11_delete_snapshot", "python"), + ("snapshot-rollback-clone:clone_demo", "python"), + ("snapshot-rollback-clone:rollback_demo", "python"), + ("e2b-dev-sidecar:demo", "python"), + ("cubesandbox-base-nginx:test_files", "python"), + ("cube-bench:main", "go"), + ] + .into_iter() + .collect() +} + +fn scenario_registry() -> &'static [ScenarioSpec] { + // The scenarios are referenced from `file_languages()` via ":". + // Keeping the registry a single static slice lets the front-end render + // groups in a deterministic order without depending on filesystem layout. + // + // `Box::leak` materialises the Vec exactly once at process start; subsequent + // calls return the same `&'static` slice, so the front-end gets a stable + // ordering without the borrow checker complaining about temporaries. + Box::leak(Box::new(vec![ + ScenarioSpec { + id: "code-sandbox-quickstart", + category: "basics", + hidden: false, + files: &[ + FileSpec { + id: "create", + filename: "create.py", + title: "Create Sandbox", + description: "Create a sandbox from a template and read its metadata.", + language: "python", + }, + FileSpec { + id: "exec_code", + filename: "exec_code.py", + title: "Execute Code", + description: "Run Python code inside the sandbox through the Jupyter kernel.", + language: "python", + }, + FileSpec { + id: "cmd", + filename: "cmd.py", + title: "Run Shell Command", + description: "Execute a shell command inside the sandbox and capture stdout.", + language: "python", + }, + FileSpec { + id: "read", + filename: "read.py", + title: "Read / Write File", + description: "Read and write files inside the sandbox filesystem.", + language: "python", + }, + FileSpec { + id: "pause", + filename: "pause.py", + title: "Pause & Resume", + description: "Pause a sandbox to freeze its memory and resume it later.", + language: "python", + }, + ], + timeout_secs: None, + topology: topology_for("code-sandbox-quickstart"), + }, + ScenarioSpec { + id: "network-policy", + category: "network", + hidden: false, + files: &[ + FileSpec { + id: "network_no_internet", + filename: "network_no_internet.py", + title: "No Internet", + description: "Sandbox without outbound network access.", + language: "python", + }, + FileSpec { + id: "network_allowlist", + filename: "network_allowlist.py", + title: "Network Allowlist", + description: "Restrict egress to an explicit list of IPs.", + language: "python", + }, + FileSpec { + id: "network_denylist", + filename: "network_denylist.py", + title: "Network Denylist", + description: "Default-allow with explicit deny entries.", + language: "python", + }, + ], + timeout_secs: None, + topology: topology_for("network-policy"), + }, + ScenarioSpec { + id: "host-mount", + category: "filesystem", + hidden: false, + files: &[FileSpec { + id: "create_with_mount", + filename: "create_with_mount.py", + title: "Create With Mount", + description: "Create a sandbox with a host directory mounted at /mnt.", + language: "python", + }], + timeout_secs: None, + topology: topology_for("host-mount"), + }, + ScenarioSpec { + id: "browser-sandbox", + category: "browser", + hidden: false, + files: &[FileSpec { + id: "browser", + filename: "browser.py", + title: "Playwright + Chromium", + description: "Boot a sandbox with Chromium and run a Playwright script.", + language: "python", + }], + timeout_secs: Some(600), + topology: topology_for("browser-sandbox"), + }, + ScenarioSpec { + id: "snapshot-rollback-clone", + category: "lifecycle", + hidden: false, + files: &[ + FileSpec { id: "01_create_snapshot", filename: "01_create_snapshot.py", title: "01 Create Snapshot", description: "Capture a snapshot from a running sandbox.", language: "python" }, + FileSpec { id: "02_list_snapshots", filename: "02_list_snapshots.py", title: "02 List Snapshots", description: "List snapshots attached to the cluster.", language: "python" }, + FileSpec { id: "03_clone_from_snapshot", filename: "03_clone_from_snapshot.py", title: "03 Clone From Snapshot", description: "Create a new sandbox from a snapshot.", language: "python" }, + FileSpec { id: "04_state_preserved", filename: "04_state_preserved.py", title: "04 State Preserved", description: "Verify state survives the clone.", language: "python" }, + FileSpec { id: "05_snapshot_outlives_sandbox", filename: "05_snapshot_outlives_sandbox.py", title: "05 Snapshot Outlives", description: "Snapshot outlives its source sandbox.", language: "python" }, + FileSpec { id: "06_clone_n", filename: "06_clone_n.py", title: "06 Clone N Times", description: "Spin up N clones in sequence.", language: "python" }, + FileSpec { id: "07_clone_concurrent", filename: "07_clone_concurrent.py", title: "07 Clone Concurrently", description: "Spin up N clones in parallel.", language: "python" }, + FileSpec { id: "08_fork_three_axis", filename: "08_fork_three_axis.py", title: "08 Fork Three-axis", description: "Three orthogonal dimensions of clone/rollback.", language: "python" }, + FileSpec { id: "09_rollback", filename: "09_rollback.py", title: "09 Rollback", description: "Roll the sandbox back to a previous snapshot.", language: "python" }, + FileSpec { id: "10_rollback_then_continue", filename: "10_rollback_then_continue.py", title: "10 Rollback Then Continue", description: "Rollback, then resume normal execution.", language: "python" }, + FileSpec { id: "11_delete_snapshot", filename: "11_delete_snapshot.py", title: "11 Delete Snapshot", description: "Clean up a snapshot from the cluster.", language: "python" }, + FileSpec { id: "clone_demo", filename: "clone_demo.py", title: "Clone Demo", description: "End-to-end clone walkthrough.", language: "python" }, + FileSpec { id: "rollback_demo", filename: "rollback_demo.py", title: "Rollback Demo", description: "End-to-end rollback walkthrough.", language: "python" }, + ], + timeout_secs: None, + topology: topology_for("snapshot-rollback-clone"), + }, + ScenarioSpec { + id: "e2b-dev-sidecar", + category: "advanced", + hidden: false, + files: &[FileSpec { + id: "demo", + filename: "demo.py", + title: "Sidecar Demo", + description: "Start a sidecar proxy in front of CubeAPI.", + language: "python", + }], + timeout_secs: None, + topology: topology_for("e2b-dev-sidecar"), + }, + ScenarioSpec { + id: "cubesandbox-base-nginx", + category: "image", + hidden: false, + files: &[FileSpec { + id: "test_files", + filename: "test_files.py", + title: "Test Files", + description: "Reach the nginx-served files via the proxy.", + language: "python", + }], + timeout_secs: None, + topology: topology_for("cubesandbox-base-nginx"), + }, + ScenarioSpec { + id: "cube-bench", + category: "perf", + hidden: false, + files: &[FileSpec { + id: "main", + filename: "main.go", + title: "Run Benchmark", + description: "Spawn N sandboxes in parallel and report throughput.", + language: "go", + }], + timeout_secs: None, + topology: topology_for("cube-bench"), + }, + // ── Hidden: AI / LLM scenarios. Intentionally NOT exposed via the + // HTTP surface. They live here so that toggling `hidden: false` + // later (when LLM credentials are configured) is a one-line + // change without any schema work. + ScenarioSpec { + id: "openclaw-integration", + category: "agent", + hidden: true, + files: &[], + timeout_secs: None, + topology: topology_for("code-sandbox-quickstart"), + }, + ScenarioSpec { + id: "openai-agents-example", + category: "agent", + hidden: true, + files: &[], + timeout_secs: None, + topology: topology_for("code-sandbox-quickstart"), + }, + ScenarioSpec { + id: "openai-agents-code-interpreter", + category: "agent", + hidden: true, + files: &[], + timeout_secs: None, + topology: topology_for("code-sandbox-quickstart"), + }, + ScenarioSpec { + id: "mini-rl-training", + category: "agent", + hidden: true, + files: &[], + timeout_secs: None, + topology: topology_for("code-sandbox-quickstart"), + }, + ])) +} + +fn examples_root() -> PathBuf { + // CUBE_EXAMPLES_DIR overrides the root for tests / packaged installs. + // Default points at the in-repo `examples/` directory. + if let Ok(v) = std::env::var("CUBE_EXAMPLES_DIR") { + return PathBuf::from(v); + } + // `Cargo.toml` lives at /CubeAPI/Cargo.toml, so `../../examples` is + // the in-repo default when running from source. + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("examples") +} + +fn list_visible() -> Vec { + let langs = file_languages(); + let mut out = Vec::new(); + for sc in scenario_registry() { + if sc.hidden { + continue; + } + for f in sc.files { + let full_id = format!("{}:{}", sc.id, f.id); + let language = langs + .get(full_id.as_str()) + .copied() + .unwrap_or(f.language) + .to_string(); + out.push(ExampleMeta { + id: full_id, + scenario: sc.id.to_string(), + filename: f.filename.to_string(), + title: f.title.to_string(), + description: f.description.to_string(), + category: sc.category.to_string(), + language, + }); + } + } + out +} + +fn resolve_visible(id: &str) -> Option<(ExampleMeta, &'static ScenarioSpec, &'static FileSpec)> { + let langs = file_languages(); + let (scenario_id, file_id) = id.split_once(':')?; + for sc in scenario_registry() { + if sc.hidden || sc.id != scenario_id { + continue; + } + for f in sc.files { + if f.id == file_id { + let full_id = format!("{}:{}", sc.id, f.id); + let language = langs + .get(full_id.as_str()) + .copied() + .unwrap_or(f.language) + .to_string(); + let meta = ExampleMeta { + id: full_id, + scenario: sc.id.to_string(), + filename: f.filename.to_string(), + title: f.title.to_string(), + description: f.description.to_string(), + category: sc.category.to_string(), + language, + }; + return Some((meta, sc, f)); + } + } + } + None +} + +// fn template_for is intentionally not exposed: the per-scenario topology +// is baked into `ScenarioSpec::topology` and resolved directly in the +// request handlers via `sc.topology.clone()`. + +// ─── Step log synthesis ─────────────────────────────────────────────────────── + +fn synthetic_steps( + elapsed_ms: u64, + success: bool, + stdout: &str, +) -> Vec { + // Without per-RPC tracing we approximate four coarse phases: + // 1. control plane routing (CubeAPI validation + CubeMaster schedule) + // 2. data plane bring-up (envd handshake + runner fork) + // 3. workload execution (proportional to elapsed time) + // 4. cleanup + // These four buckets are stable across scenarios; the per-run output + // panel renders them in order, so the user always sees something + // meaningful even when we cannot trace individual RPCs. + let phase_total = elapsed_ms.max(1); + let p1 = (phase_total / 6).max(20); + let p2 = (phase_total / 5).max(30); + let p4 = (phase_total / 8).max(15); + let p3 = phase_total.saturating_sub(p1 + p2 + p4).max(1); + + let workload_msg = if stdout.is_empty() { + "(no stdout)".to_string() + } else { + let first_line = stdout.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); + // Use char-based truncation to avoid slicing inside a multi-byte + // Unicode character (e.g. box-drawing chars from `rich` output). + if first_line.chars().count() > 80 { + format!("{}…", first_line.chars().take(80).collect::()) + } else { + first_line.to_string() + } + }; + + let exec_status = if success { "ok" } else { "err" }; + let cleanup_status = if success { "ok" } else { "warn" }; + vec![ - ExampleMeta { - id: "create".to_string(), - filename: "create.py".to_string(), - title: "Create Sandbox".to_string(), - description: "Create a sandbox from a template and retrieve its metadata.".to_string(), - category: "basics".to_string(), - }, - ExampleMeta { - id: "exec_code".to_string(), - filename: "exec_code.py".to_string(), - title: "Execute Code".to_string(), - description: "Run Python code inside the sandbox using the Jupyter kernel.".to_string(), - category: "basics".to_string(), - }, - ExampleMeta { - id: "cmd".to_string(), - filename: "cmd.py".to_string(), - title: "Run Shell Command".to_string(), - description: "Execute shell commands inside the sandbox and capture stdout.".to_string(), - category: "basics".to_string(), - }, - ExampleMeta { - id: "read".to_string(), - filename: "read.py".to_string(), - title: "File Read/Write".to_string(), - description: "Read files from the sandbox filesystem.".to_string(), - category: "filesystem".to_string(), - }, - ExampleMeta { - id: "pause".to_string(), - filename: "pause.py".to_string(), - title: "Pause & Resume".to_string(), - description: "Pause a sandbox to save its state and resume it later.".to_string(), - category: "lifecycle".to_string(), - }, - ExampleMeta { - id: "network_no_internet".to_string(), - filename: "network_no_internet.py".to_string(), - title: "No Internet Access".to_string(), - description: "Create a fully isolated sandbox with all outbound traffic blocked.".to_string(), - category: "network".to_string(), - }, - ExampleMeta { - id: "network_allowlist".to_string(), - filename: "network_allowlist.py".to_string(), - title: "Network Allowlist".to_string(), - description: "Allow only specific IP/CIDR ranges while blocking everything else.".to_string(), - category: "network".to_string(), - }, - ExampleMeta { - id: "network_denylist".to_string(), - filename: "network_denylist.py".to_string(), - title: "Network Denylist".to_string(), - description: "Allow internet but block specific IP/CIDR ranges.".to_string(), - category: "network".to_string(), + StepLog { + name: "Validate request".to_string(), + plane: "control".into(), + status: "ok".into(), + duration_ms: p1 / 4, + message: "CubeAPI parsed the example id and resolved the template.".into(), + }, + StepLog { + name: "Schedule & boot VM".to_string(), + plane: "control".into(), + duration_ms: p1 * 3 / 4, + status: if success { "ok".into() } else { "warn".into() }, + message: "CubeMaster picked a Cubelet; KVM MicroVM booted from the template.".into(), + }, + StepLog { + name: "Handshake envd".to_string(), + plane: "data".into(), + status: "ok".into(), + duration_ms: p2, + message: "WSS tunnel established between CubeProxy and envd :49983.".into(), + }, + StepLog { + name: "Execute workload".to_string(), + plane: "data".into(), + status: exec_status.into(), + duration_ms: p3, + message: workload_msg, + }, + StepLog { + name: "Cleanup & return".to_string(), + plane: "control".into(), + status: cleanup_status.into(), + duration_ms: p4, + message: "Captured stdout/stderr; sandbox returned to idle.".into(), }, ] } -fn example_base_dir() -> String { - std::env::var("CUBE_EXAMPLES_DIR") - .unwrap_or_else(|_| "/root/CubeSandbox/examples/code-sandbox-quickstart".to_string()) +fn topology_with_status(t: TopologyTemplate, success: bool) -> TopologyGraph { + let mut t = t; + // Mark the user / runner nodes with the run status so the UI can color + // them red when the run failed. The rest stay neutral. + let runner_status = if success { "ok" } else { "err" }; + for n in t.nodes.iter_mut() { + if n.id == "user" || n.id == "runner" || n.id == "playwright" { + // Don't overwrite kind; we just stash status in `description` + // since TopologyNode already carries plain metadata. To keep the + // schema stable, prepend a one-line status indicator. + n.description = format!("[{}] {}", runner_status, n.description); + } + } + TopologyGraph { + nodes: t.nodes, + edges: t.edges, + } } // ─── GET /cubeapi/v1/examples ──────────────────────────────────────────────── -/// List all available example scripts. +/// List all visible example scripts. Hidden scenarios (AI / LLM demos) are +/// intentionally filtered out at the source. pub async fn list_examples(State(_state): State) -> AppResult { - Ok(Json(example_list())) + Ok(Json(list_visible())) } // ─── GET /cubeapi/v1/examples/:id ─────────────────────────────────────────── -/// Get the source code of a single example script by id. +/// Get the source code of a single example script by scenario + file id. pub async fn get_example_source( State(_state): State, - axum::extract::Path(id): axum::extract::Path, + axum::extract::Path((scenario, file)): axum::extract::Path<(String, String)>, ) -> AppResult { - // Find example by id (only allow ids in the registry to prevent arbitrary file access) - let examples = example_list(); - let example = match examples.iter().find(|e| e.id == id) { - Some(e) => e, + let id = format!("{}:{}", scenario, file); + let (meta, _sc, _f) = match resolve_visible(&id) { + Some(v) => v, None => { return Ok(( StatusCode::NOT_FOUND, @@ -137,15 +870,17 @@ pub async fn get_example_source( } }; - let base_dir = example_base_dir(); - let script_path = format!("{}/{}", base_dir, example.filename); + let base_dir = examples_root().join(&meta.scenario); + let script_path = base_dir.join(&meta.filename); match std::fs::read_to_string(&script_path) { Ok(source) => Ok(( StatusCode::OK, Json(serde_json::json!({ - "id": example.id, - "filename": example.filename, + "id": meta.id, + "filename": meta.filename, + "scenario": meta.scenario, + "language": meta.language, "source": source, })), ) @@ -153,7 +888,7 @@ pub async fn get_example_source( Err(io_err) => Ok(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Failed to read '{}': {}", script_path, io_err) + "error": format!("Failed to read '{}': {}", script_path.display(), io_err) })), ) .into_response()), @@ -162,34 +897,36 @@ pub async fn get_example_source( // ─── POST /cubeapi/v1/examples/run ─────────────────────────────────────────── -/// Run an example script in a subprocess and return stdout/stderr. +/// Run an example script in a subprocess and return stdout / stderr plus a +/// synthetic step log and the topology graph for the scenario. pub async fn run_example( State(state): State, Json(req): Json, ) -> AppResult { - // Find example by id - let examples = example_list(); - let example = match examples.iter().find(|e| e.id == req.id) { - Some(e) => e, + let (meta, sc, _f) = match resolve_visible(&req.id) { + Some(v) => v, None => { return Ok(( StatusCode::NOT_FOUND, - Json(RunExampleResponse { + Json(serde_json::json!(RunExampleResponse { stdout: String::new(), stderr: format!("Example '{}' not found", req.id), exit_code: 1, success: false, - }), + elapsed_ms: 0, + steps: Vec::new(), + topology: topology_with_status(topology_for("code-sandbox-quickstart"), false), + ran_edited: false, + })), ) .into_response()); } }; - let base_dir = example_base_dir(); - let script_path = format!("{}/{}", base_dir, example.filename); + let base_dir = examples_root().join(&meta.scenario); + let script_path = base_dir.join(&meta.filename); - // Resolve template ID with multi-level fallback. - // Each candidate is validated with get_template before acceptance. + // ── Template ID resolution (same fallback chain as before) ────── let candidates: Vec = [ req.template_id.filter(|s| !s.trim().is_empty()), state.config.default_template_id.clone(), @@ -217,7 +954,6 @@ pub async fn run_example( } if template_id.is_empty() { - // Last resort: ask CubeMaster for the first available template match state.services.templates.list_templates().await { Ok(templates) => { let list_candidates: Vec<_> = templates @@ -257,6 +993,10 @@ pub async fn run_example( stderr: "No template ID configured. Set CUBE_TEMPLATE_ID, configure a default template, or create a template first.".to_string(), exit_code: 1, success: false, + elapsed_ms: 0, + steps: Vec::new(), + topology: topology_with_status(sc.topology.clone(), false), + ran_edited: req.code.is_some(), }), ) .into_response()); @@ -270,22 +1010,99 @@ pub async fn run_example( tracing::info!( example_id = %req.id, - script = %script_path, + scenario = %meta.scenario, + script = %script_path.display(), template_id = %template_id, + edited = req.code.is_some(), "running example" ); let ssl_cert = std::env::var("SSL_CERT_FILE") .unwrap_or_else(|_| "/root/.local/share/mkcert/rootCA.pem".to_string()); - let mut cmd = Command::new("python3"); - cmd.arg(&script_path) - .env("CUBE_API_URL", &cube_api_url) + // ── Interpreter dispatch based on file extension ──────────────── + // Keeping this language-driven (not request-driven) means a malicious + // `language` field cannot change the interpreter used for a file with + // a known extension. + let ext = script_path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_lowercase(); + let program: &str = match ext.as_str() { + "py" => "python3", + "go" => "go", + "sh" | "bash" => "bash", + "js" | "mjs" => "node", + _ => { + return Ok(( + StatusCode::BAD_REQUEST, + Json(RunExampleResponse { + stdout: String::new(), + stderr: format!( + "Unsupported file extension '.{}' for example '{}'", + ext, req.id + ), + exit_code: 1, + success: false, + elapsed_ms: 0, + steps: Vec::new(), + topology: topology_with_status(sc.topology.clone(), false), + ran_edited: req.code.is_some(), + }), + ) + .into_response()); + } + }; + + // ── When the user edited the code, materialise a temp file next to + // the original so relative imports / shared modules keep working. + let mut tmp_path: Option = None; + let run_path: PathBuf = if let Some(user_code) = req.code.as_ref() { + let tmp_name = format!(".tmp_run_{}.{}", Uuid::new_v4(), ext); + let tmp = base_dir.join(&tmp_name); + if let Err(io_err) = std::fs::write(&tmp, user_code) { + return Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(RunExampleResponse { + stdout: String::new(), + stderr: format!( + "Failed to write edited code to {}: {}", + tmp.display(), + io_err + ), + exit_code: 1, + success: false, + elapsed_ms: 0, + steps: Vec::new(), + topology: topology_with_status(sc.topology.clone(), false), + ran_edited: true, + }), + ) + .into_response()); + } + tmp_path = Some(tmp.clone()); + tmp + } else { + script_path.clone() + }; + + // Build argv from the resolved run_path. `go run file.go` needs an + // explicit "run" subcommand; everything else takes the path directly. + let argv: Vec = match program { + "go" => vec!["run".to_string(), run_path.to_string_lossy().to_string()], + _ => vec![run_path.to_string_lossy().to_string()], + }; + + let mut cmd = Command::new(program); + for a in &argv { + cmd.arg(a); + } + cmd.env("CUBE_API_URL", &cube_api_url) .env("CUBE_TEMPLATE_ID", &template_id) .env("SSL_CERT_FILE", ssl_cert) .current_dir(&base_dir); - // Pass CubeProxy configuration if available if let Some(ref proxy_ip) = state.config.cube_proxy_node_ip { cmd.env("CUBE_PROXY_NODE_IP", proxy_ip); } @@ -294,11 +1111,17 @@ pub async fn run_example( } cmd.env("CUBE_SANDBOX_DOMAIN", &state.config.sandbox_domain); - let run_result = timeout( - Duration::from_secs(120), - cmd.output(), - ) - .await; + let start = Instant::now(); + let max_secs = sc.timeout_secs.unwrap_or(120); + let run_result = timeout(Duration::from_secs(max_secs), cmd.output()).await; + let elapsed_ms = start.elapsed().as_millis() as u64; + + // Always remove the temp file, even on error paths. + if let Some(p) = tmp_path.take() { + let _ = std::fs::remove_file(&p); + } + + let topology = topology_with_status(sc.topology.clone(), false); // refined below match run_result { Ok(Ok(output)) => { @@ -311,14 +1134,22 @@ pub async fn run_example( example_id = %req.id, exit_code, success, + elapsed_ms, "example run complete" ); + let steps = synthetic_steps(elapsed_ms, success, &stdout); + let topology = topology_with_status(sc.topology.clone(), success); + Ok(Json(RunExampleResponse { stdout, stderr, exit_code, success, + elapsed_ms, + steps, + topology, + ran_edited: req.code.is_some(), }) .into_response()) } @@ -329,6 +1160,10 @@ pub async fn run_example( stderr: format!("Failed to spawn process: {}", io_err), exit_code: -1, success: false, + elapsed_ms, + steps: synthetic_steps(elapsed_ms, false, ""), + topology, + ran_edited: req.code.is_some(), }), ) .into_response()), @@ -336,11 +1171,15 @@ pub async fn run_example( StatusCode::GATEWAY_TIMEOUT, Json(RunExampleResponse { stdout: String::new(), - stderr: "Example timed out after 120 seconds".to_string(), + stderr: format!("Example timed out after {} seconds", max_secs), exit_code: -1, success: false, + elapsed_ms, + steps: synthetic_steps(elapsed_ms, false, ""), + topology, + ran_edited: req.code.is_some(), }), ) .into_response()), } -} +} \ No newline at end of file diff --git a/CubeAPI/src/routes.rs b/CubeAPI/src/routes.rs index dff95c0fe..4cd189111 100644 --- a/CubeAPI/src/routes.rs +++ b/CubeAPI/src/routes.rs @@ -37,6 +37,14 @@ const DEFAULT_ROUTE_TIMEOUT: Duration = Duration::from_secs(30); /// for a terminal state and does not expose a polling interface. const SNAPSHOT_LONG_ROUTE_TIMEOUT: Duration = Duration::from_secs(240); +/// Timeout budget for `POST /examples/run`. This handler spawns an external +/// process (the example script) and waits for it to finish; browser-sandbox +/// scenarios can legitimately run for several minutes (sandbox creation + +/// Chromium startup + CDP connection + user-facing wait time). The per- +/// scenario `timeout_secs` field controls the *inner* process timeout; this +/// outer HTTP timeout must be at least as large as the longest inner timeout. +const EXAMPLES_RUN_ROUTE_TIMEOUT: Duration = Duration::from_secs(600); + pub fn build_router(state: AppState) -> Router { let auth_configured = state .config @@ -58,10 +66,18 @@ pub fn build_router(state: AppState) -> Router { ), SNAPSHOT_LONG_ROUTE_TIMEOUT, ); + let examples_run_router = apply_http_layers( + Router::new().nest( + "/cubeapi/v1", + build_examples_run_routes(&state, auth_configured), + ), + EXAMPLES_RUN_ROUTE_TIMEOUT, + ); Router::new() .merge(standard_router) .merge(snapshot_long_router) + .merge(examples_run_router) .with_state(state) } @@ -364,10 +380,24 @@ fn apply_http_layers(router: Router, timeout: Duration) -> Router Router { + // NOTE: `POST /examples/run` is intentionally NOT routed here. + // It lives in `build_examples_run_routes` on the long-timeout router + // because it spawns an external process that can run for several minutes + // (e.g. browser-sandbox scenarios). let routes = Router::new() .route("/examples", get(examples::list_examples)) - .route("/examples/:id", get(examples::get_example_source)) - .route("/examples/run", post(examples::run_example)); + .route("/examples/:scenario/:file", get(examples::get_example_source)); + if auth_configured { + routes.layer(middleware::from_fn_with_state(state.clone(), unified_auth)) + } else { + routes + } +} + +/// Long-budget route for `POST /examples/run`. Mounted on its own router so +/// the 30 s default HTTP timeout does not kill long-running example scripts. +fn build_examples_run_routes(state: &AppState, auth_configured: bool) -> Router { + let routes = Router::new().route("/examples/run", post(examples::run_example)); if auth_configured { routes.layer(middleware::from_fn_with_state(state.clone(), unified_auth)) } else { diff --git a/examples/browser-sandbox/env_utils.py b/examples/browser-sandbox/env_utils.py new file mode 100644 index 000000000..f2890e28c --- /dev/null +++ b/examples/browser-sandbox/env_utils.py @@ -0,0 +1,22 @@ +from pathlib import Path + +from dotenv import load_dotenv + + +def load_local_dotenv() -> None: + """Best-effort load of a nearby .env file without overriding real env vars.""" + candidate_paths = [ + Path(__file__).with_name(".env"), + Path.cwd() / ".env", + ] + + seen_paths = set() + for path in candidate_paths: + resolved_path = path.resolve() + if resolved_path in seen_paths: + continue + seen_paths.add(resolved_path) + + if path.is_file(): + load_dotenv(dotenv_path=path, override=False) + return diff --git a/examples/browser-sandbox/requirements.txt b/examples/browser-sandbox/requirements.txt index 8de1eb40f..f0e60861e 100644 --- a/examples/browser-sandbox/requirements.txt +++ b/examples/browser-sandbox/requirements.txt @@ -1,3 +1,3 @@ -e2b>=2.15.2 +cubesandbox>=0.2.1 playwright>=1.40.0 python-dotenv diff --git a/examples/host-mount/create_with_mount.py b/examples/host-mount/create_with_mount.py index 652da967c..f70755c14 100644 --- a/examples/host-mount/create_with_mount.py +++ b/examples/host-mount/create_with_mount.py @@ -20,15 +20,25 @@ import json import os +import subprocess -from e2b_code_interpreter import Sandbox - +from cubesandbox import Sandbox from env_utils import load_local_dotenv load_local_dotenv() template_id = os.environ["CUBE_TEMPLATE_ID"] +# Ensure the host directories exist before creating the sandbox. +# CubeMaster requires that bind-mount source paths already exist. +for d in ["/tmp/rw", "/tmp/ro"]: + os.makedirs(d, exist_ok=True) + # Write a small marker so `ls` shows something meaningful + marker = os.path.join(d, "hello.txt") + if not os.path.exists(marker): + with open(marker, "w") as f: + f.write(f"marker from host {d}\n") + with Sandbox.create( template=template_id, metadata={ diff --git a/examples/host-mount/requirements.txt b/examples/host-mount/requirements.txt index e2f853e67..6608acd4c 100644 --- a/examples/host-mount/requirements.txt +++ b/examples/host-mount/requirements.txt @@ -1,2 +1,2 @@ -e2b-code-interpreter>=2.4.1 +cubesandbox>=0.2.1 python-dotenv diff --git a/examples/network-policy/network_allowlist.py b/examples/network-policy/network_allowlist.py index 0e782dc5f..cdd4fcd2e 100644 --- a/examples/network-policy/network_allowlist.py +++ b/examples/network-policy/network_allowlist.py @@ -17,7 +17,7 @@ import os -from e2b_code_interpreter import Sandbox +from cubesandbox import Sandbox from env_utils import load_local_dotenv diff --git a/examples/network-policy/network_denylist.py b/examples/network-policy/network_denylist.py index 26ee1ed01..1b5815902 100644 --- a/examples/network-policy/network_denylist.py +++ b/examples/network-policy/network_denylist.py @@ -18,7 +18,7 @@ import os -from e2b_code_interpreter import Sandbox +from cubesandbox import Sandbox from env_utils import load_local_dotenv diff --git a/examples/network-policy/network_no_internet.py b/examples/network-policy/network_no_internet.py index 12b5aa44d..186804b51 100644 --- a/examples/network-policy/network_no_internet.py +++ b/examples/network-policy/network_no_internet.py @@ -15,9 +15,7 @@ """ import os - -from e2b_code_interpreter import Sandbox - +from cubesandbox import Sandbox from env_utils import load_local_dotenv load_local_dotenv() diff --git a/examples/network-policy/requirements.txt b/examples/network-policy/requirements.txt index e2f853e67..6608acd4c 100644 --- a/examples/network-policy/requirements.txt +++ b/examples/network-policy/requirements.txt @@ -1,2 +1,2 @@ -e2b-code-interpreter>=2.4.1 +cubesandbox>=0.2.1 python-dotenv diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..d6d53abc2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "CubeSandbox", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/web/package-lock.json b/web/package-lock.json index f272b4301..f4cbfa8f5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "@dicebear/core": "^9.4.2", "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@monaco-editor/react": "^4.6.0", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", @@ -22,12 +23,14 @@ "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^5.59.0", + "@xyflow/react": "^12.3.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", "i18next": "^26.0.6", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.456.0", + "monaco-editor": "^0.52.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^17.0.4", @@ -1413,6 +1416,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.5", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.5.tgz", @@ -2890,6 +2916,55 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2979,6 +3054,76 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.11.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz", + "integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.77", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.77", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz", + "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3279,6 +3424,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -3405,6 +3556,111 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4015,6 +4271,12 @@ "node": ">=10" } }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4792,6 +5054,12 @@ "node": ">=0.10.0" } }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/web/package.json b/web/package.json index 8c626b376..92fe0cce7 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "@dicebear/core": "^9.4.2", "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@monaco-editor/react": "^4.6.0", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", @@ -27,12 +28,14 @@ "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^5.59.0", + "@xyflow/react": "^12.3.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", "i18next": "^26.0.6", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.456.0", + "monaco-editor": "^0.52.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^17.0.4", diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3c50dd11a..c9e85ca8b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -287,32 +287,91 @@ export const storeApi = { export interface ExampleMeta { id: string; + scenario: string; filename: string; title: string; description: string; category: string; + language: string; } export interface ExampleSource { id: string; filename: string; + scenario: string; + language: string; source: string; } +export interface StepLogDto { + name: string; + plane: 'control' | 'data' | string; + status: 'ok' | 'warn' | 'err' | 'skipped' | string; + duration_ms: number; + message: string; +} + +export interface TopologyNodeDto { + id: string; + label: string; + plane: 'control' | 'data' | string; + kind: 'user' | 'control' | 'data' | 'vm' | 'store' | string; + description: string; +} + +export interface TopologyEdgeDto { + from: string; + to: string; + label: string; + plane: 'control' | 'data' | string; +} + +export interface TopologyGraphDto { + nodes: TopologyNodeDto[]; + edges: TopologyEdgeDto[]; +} + export interface ExampleRunResult { stdout: string; stderr: string; exit_code: number; success: boolean; + elapsed_ms: number; + steps: StepLogDto[]; + topology: TopologyGraphDto; + ran_edited: boolean; +} + +export interface RunExampleBody { + id: string; + template_id?: string; + language?: string; + code?: string; } export const examplesApi = { list: () => api('/examples'), - source: (id: string) => api(`/examples/${encodeURIComponent(id)}`), - run: (id: string, templateId?: string) => + /** + * Fetch source code for a single example. + * The id format is "scenario:file" (e.g. "code-sandbox-quickstart:create"). + * We split it into two path segments to avoid URL-encoding issues with colons. + */ + source: (id: string) => { + const [scenario, file, ...rest] = id.split(':'); + if (!scenario || !file || rest.length > 0) { + throw new Error(`Invalid example id: "${id}". Expected "scenario:file".`); + } + return api(`/examples/${encodeURIComponent(scenario)}/${encodeURIComponent(file)}`); + }, + run: (body: RunExampleBody) => api('/examples/run', { method: 'POST', - body: JSON.stringify({ id, template_id: templateId || undefined }), + body: JSON.stringify({ + id: body.id, + template_id: body.template_id, + language: body.language, + code: body.code, + }), }), }; diff --git a/web/src/components/CodeEditor.tsx b/web/src/components/CodeEditor.tsx new file mode 100644 index 000000000..0aabb918d --- /dev/null +++ b/web/src/components/CodeEditor.tsx @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Thin wrapper around @monaco-editor/react. We don't need the full Monaco +// bundle for read-only display; this component picks the smallest loader +// config that still gives us: +// - JetBrains Mono via the project's fontsource registration +// - Language-aware tokenization +// - Line numbers + minimap off (read-only preview mode is supported via prop) + +import Editor, { type OnMount, type OnChange } from '@monaco-editor/react'; +import { useCallback, useRef } from 'react'; +import { Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export type EditorLanguage = 'python' | 'go' | 'javascript' | 'typescript' | 'shell' | 'markdown'; + +export interface CodeEditorProps { + value: string; + language: EditorLanguage; + onChange?: (value: string) => void; + readOnly?: boolean; + height?: number | string; + className?: string; + /** Minimum height for the editor viewport. Default: 360. */ + minHeight?: number; + /** Show a minimap inside the editor. Default: false. */ + minimap?: boolean; + /** Optional aria-label for the editor region. */ + ariaLabel?: string; +} + +export function CodeEditor({ + value, + language, + onChange, + readOnly = false, + height = 420, + className, + minHeight = 360, + minimap = false, + ariaLabel, +}: CodeEditorProps) { + const editorRef = useRef(null); + + const handleMount: OnMount = useCallback((editor, monaco) => { + editorRef.current = editor; + // Define a CubeSandbox-flavoured dark theme once on mount; Monaco reuses + // the same theme across all editor instances so this is cheap. + monaco.editor.defineTheme('cubesandbox-dark', { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'comment', foreground: '6b7280', fontStyle: 'italic' }, + { token: 'keyword', foreground: 'a78bfa' }, + { token: 'string', foreground: '34d399' }, + { token: 'number', foreground: 'fbbf24' }, + { token: 'type', foreground: '22d3ee' }, + ], + colors: { + 'editor.background': '#0b0d12', + 'editor.foreground': '#e5e7eb', + 'editorLineNumber.foreground': '#3f3f46', + 'editorLineNumber.activeForeground': '#a1a1aa', + 'editor.lineHighlightBackground': '#11141a', + 'editorCursor.foreground': '#22d3ee', + 'editor.selectionBackground': '#22d3ee33', + 'editor.inactiveSelectionBackground': '#22d3ee22', + 'editorIndentGuide.background1': '#1f2937', + 'editorIndentGuide.activeBackground1': '#374151', + 'editorGutter.background': '#0b0d12', + }, + }); + monaco.editor.setTheme('cubesandbox-dark'); + }, []); + + const handleChange: OnChange = useCallback( + (next) => { + if (readOnly) return; + onChange?.(next ?? ''); + }, + [onChange, readOnly], + ); + + return ( +
+ + + Loading editor… +
+ } + options={{ + readOnly, + minimap: { enabled: minimap }, + fontFamily: + '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace', + fontLigatures: true, + fontSize: 12.5, + lineHeight: 1.65, + scrollBeyondLastLine: false, + smoothScrolling: true, + cursorBlinking: 'smooth', + cursorSmoothCaretAnimation: 'on', + renderLineHighlight: 'all', + renderWhitespace: 'selection', + tabSize: 4, + automaticLayout: true, + wordWrap: 'on', + fixedOverflowWidgets: true, + padding: { top: 12, bottom: 12 }, + guides: { indentation: true, highlightActiveIndentation: true }, + scrollbar: { + vertical: 'auto', + horizontal: 'auto', + verticalScrollbarSize: 8, + horizontalScrollbarSize: 8, + }, + }} + /> + + ); +} \ No newline at end of file diff --git a/web/src/components/Rail.tsx b/web/src/components/Rail.tsx index c6e60fb91..737fe73ff 100644 --- a/web/src/components/Rail.tsx +++ b/web/src/components/Rail.tsx @@ -15,7 +15,7 @@ import { Settings, Store, Layers, - BookOpen, + FlaskConical, Github, } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -31,7 +31,7 @@ const NAV_ITEMS = [ { to: '/observability', icon: Activity, key: 'observability' }, { to: '/keys', icon: KeyRound, key: 'apiKeys' }, { to: '/store', icon: Store, key: 'store' }, - { to: '/examples', icon: BookOpen, key: 'examples' }, + { to: '/examples', icon: FlaskConical, key: 'examples' }, { to: '/agenthub', icon: Bot, key: 'agentHub' }, { to: '/settings', icon: Settings, key: 'settings' }, ] as const; diff --git a/web/src/components/StepTimeline.tsx b/web/src/components/StepTimeline.tsx new file mode 100644 index 000000000..d31ce2cd4 --- /dev/null +++ b/web/src/components/StepTimeline.tsx @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Horizontal step timeline used by the SandboxCases page. The component is +// intentionally dependency-free — it draws the timeline with plain divs so +// that we don't pull in an extra date-fns / d3 / framer-motion bundle for a +// widget that renders at most a dozen rows. + +import { CheckCircle2, CircleDashed, OctagonX, TriangleAlert } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; + +export type StepStatus = 'ok' | 'warn' | 'err' | 'skipped'; + +export interface StepLog { + name: string; + plane: 'control' | 'data' | string; + status: StepStatus | string; + duration_ms: number; + message: string; +} + +export interface StepTimelineProps { + steps: StepLog[]; + /** Total wall-clock duration of the run; used to scale bar widths. */ + totalMs?: number; + /** Optional override for the heading. Defaults to a translation key. */ + title?: string; + className?: string; +} + +function statusIcon(status: string) { + if (status === 'ok') return CheckCircle2; + if (status === 'warn') return TriangleAlert; + if (status === 'err') return OctagonX; + return CircleDashed; +} + +function statusTone(status: string): { icon: string; bar: string; chip: string } { + if (status === 'ok') + return { + icon: 'text-cube-emerald', + bar: 'bg-gradient-to-r from-cube-emerald/80 to-cube-emerald/40', + chip: 'chip-ok', + }; + if (status === 'warn') + return { + icon: 'text-cube-amber', + bar: 'bg-gradient-to-r from-cube-amber/80 to-cube-amber/40', + chip: 'chip-warn', + }; + if (status === 'err') + return { + icon: 'text-destructive', + bar: 'bg-gradient-to-r from-destructive/80 to-destructive/40', + chip: 'chip-err', + }; + return { + icon: 'text-muted-foreground', + bar: 'bg-gradient-to-r from-muted-foreground/40 to-muted-foreground/10', + chip: 'chip-mute', + }; +} + +export function StepTimeline({ steps, totalMs, title, className }: StepTimelineProps) { + const { t } = useTranslation('examples'); + const scale = (totalMs && totalMs > 0 ? totalMs : steps.reduce((s, x) => s + x.duration_ms, 0)) || 1; + + if (steps.length === 0) { + return ( +
+ {title &&

{title}

} + {t('timeline.empty')} +
+ ); + } + + const controlSteps = steps.filter((s) => s.plane === 'control'); + const dataSteps = steps.filter((s) => s.plane === 'data'); + + return ( +
+ {title && ( +
+ + {title} + + · {steps.length} +
+ )} +
+ {controlSteps.length > 0 && ( + + )} + {dataSteps.length > 0 && ( + + )} +
+
+ ); +} + +interface PlaneGroupProps { + label: string; + tone: 'cyan' | 'violet'; + steps: StepLog[]; + scale: number; +} + +function PlaneGroup({ label, tone, steps, scale }: PlaneGroupProps) { + return ( +
+
+ + + {label} + + · {steps.length} +
+
+ {steps.map((s, idx) => { + const Icon = statusIcon(s.status); + const tone = statusTone(s.status); + const widthPct = Math.max(2, Math.min(100, (s.duration_ms / scale) * 100)); + return ( +
+ +
+

{s.name}

+
+
+
+
+

+ {s.message} +

+
+ {s.status} + + {s.duration_ms}ms + +
+
+ ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/web/src/components/TopologyGraph.tsx b/web/src/components/TopologyGraph.tsx new file mode 100644 index 000000000..6e222f86b --- /dev/null +++ b/web/src/components/TopologyGraph.tsx @@ -0,0 +1,335 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Topology graph for the SandboxCases page. Renders the management + data +// flow using @xyflow/react, with a custom node component that picks its +// color from the `plane` field. +// +// The layout is deterministic: nodes are placed on a fixed grid that walks +// the topology left-to-right, with control-plane nodes on top and +// data-plane nodes on the bottom half. This keeps the bundle small (no +// dagre / elk dependency) and the result easy to scan. + +import { + Background, + Controls, + Handle, + MiniMap, + Position, + ReactFlow, + ReactFlowProvider, + type Edge, + type Node, + type NodeProps, + type NodeTypes, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Maximize2, Minimize2, Network } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { Plane, ScenarioEdge, ScenarioNode } from '@/data/exampleScenarios'; + +export interface TopologyGraphProps { + nodes: ScenarioNode[]; + edges: ScenarioEdge[]; + className?: string; + /** Optional fixed height in pixels. Default: 360. */ + height?: number; + /** Show a compact legend under the canvas. Default: true. */ + showLegend?: boolean; +} + +const COL_WIDTH = 220; +const ROW_HEIGHT_CONTROL = 88; +const ROW_HEIGHT_DATA = 88; +const COL_GAP = 70; + +// Layer ordering used by the layout. Each entry is a list of node ids that +// share the same x-coordinate. Unknown nodes are appended at the end in +// the order they appear in `nodes`. +function topoLayeredOrder(nodes: ScenarioNode[]): string[][] { + // Layout layers (left-to-right). Nodes in the same layer share the same + // x-coordinate. Control-plane nodes are rendered on the top row, data-plane + // nodes on the bottom row within each layer. + const knownLayers: Record = { + // Layer 0: Entry point + user: 0, + // Layer 1: Gateway (dual-plane) + cubeapi: 1, + cubeproxy: 1, + sidecar: 2, + // Layer 2: Orchestration + cubemaster: 2, + // Layer 3: Node agent + external stores + cubelet: 3, + hostdir: 3, + snapshot: 3, + cubevs: 4, + // Layer 4: Sandbox boundary (MicroVM) + microvm: 5, + 'microvm-0': 5, + 'microvm-1': 5, + 'microvm-2': 5, + 'microvm-3': 5, + // Layer 5: In-sandbox daemon + envd: 6, + // Layer 6: Workload + runner: 7, + playwright: 7, + nginx: 7, + // Layer 7: Sub-workload + chromium: 8, + }; + const seen = new Set(); + const layers: string[][] = []; + for (const n of nodes) { + if (seen.has(n.id)) continue; + seen.add(n.id); + const layerIdx = knownLayers[n.id] ?? layers.length; + while (layers.length <= layerIdx) layers.push([]); + layers[layerIdx].push(n.id); + } + return layers; +} + +function layoutNodes(nodes: LocalizedNode[]): Node[] { + const layers = topoLayeredOrder(nodes); + const out: Node[] = []; + for (let col = 0; col < layers.length; col++) { + const layer = layers[col]; + const controlInLayer = layer.filter((id) => nodes.find((n) => n.id === id)?.plane === 'control'); + const dataInLayer = layer.filter((id) => nodes.find((n) => n.id === id)?.plane === 'data'); + let controlIdx = 0; + let dataIdx = 0; + for (const id of layer) { + const node = nodes.find((n) => n.id === id); + if (!node) continue; + const x = col * (COL_WIDTH + COL_GAP); + if (node.plane === 'control') { + const total = controlInLayer.length; + const offsetY = ((controlIdx - (total - 1) / 2) * ROW_HEIGHT_CONTROL); + controlIdx++; + out.push({ + id: node.id, + type: 'topologyNode', + data: { node }, + position: { x, y: 80 + offsetY }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + draggable: true, + }); + } else { + const total = dataInLayer.length; + const offsetY = ((dataIdx - (total - 1) / 2) * ROW_HEIGHT_DATA); + dataIdx++; + out.push({ + id: node.id, + type: 'topologyNode', + data: { node }, + position: { x, y: 320 + offsetY }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + draggable: true, + }); + } + } + } + return out; +} + +function buildEdges(edges: LocalizedEdge[]): Edge[] { + return edges.map((e, idx) => ({ + id: `e-${e.from}-${e.to}-${idx}`, + source: e.from, + target: e.to, + label: e.label, + type: 'smoothstep', + animated: e.plane === 'data', + style: { + stroke: e.plane === 'control' ? '#22d3ee' : '#a78bfa', + strokeOpacity: 0.55, + strokeWidth: 1.5, + }, + labelStyle: { fill: '#a1a1aa', fontSize: 10, fontWeight: 500 }, + labelBgStyle: { fill: '#0b0d12', fillOpacity: 0.85 }, + labelBgPadding: [4, 2], + labelBgBorderRadius: 4, + })); +} + +interface LocalizedNode extends ScenarioNode { + label: string; + description: string; +} + +interface LocalizedEdge extends ScenarioEdge { + label: string; +} + +interface TopologyNodeData extends Record { + node: LocalizedNode; +} + +function planeColor(plane: Plane): { ring: string; bg: string; text: string; pill: string } { + if (plane === 'control') { + return { + ring: 'ring-cube-cyan/50', + bg: 'bg-cube-cyan/10', + text: 'text-cube-cyan', + pill: 'bg-cube-cyan/15 text-cube-cyan ring-cube-cyan/30', + }; + } + return { + ring: 'ring-cube-violet/50', + bg: 'bg-cube-violet/10', + text: 'text-cube-violet', + pill: 'bg-cube-violet/15 text-cube-violet ring-cube-violet/30', + }; +} + +function TopologyNodeView({ data, selected }: NodeProps>) { + const { node } = data; + const tone = planeColor(node.plane); + return ( +
+ +
+ + {node.plane === 'control' ? 'ctrl' : 'data'} + + + {node.kind} + +
+

{node.label}

+

{node.description}

+ +
+ ); +} + +const NODE_TYPES: NodeTypes = { topologyNode: TopologyNodeView }; + +function TopologyGraphInner({ nodes, edges, className, height = 360, showLegend = true }: TopologyGraphProps) { + const { t, i18n } = useTranslation('examples'); + const [expanded, setExpanded] = useState(false); + const isZh = (i18n.language ?? 'en').toLowerCase().startsWith('zh'); + + // Translate labels / descriptions on the fly using the catalogue. + const localizedNodes = useMemo( + () => + nodes.map((n) => ({ + ...n, + label: isZh ? n.labelZh : n.labelEn, + description: isZh ? n.descriptionZh : n.descriptionEn, + })), + [nodes, isZh], + ); + const localizedEdges = useMemo( + () => edges.map((e) => ({ ...e, label: isZh ? e.labelZh : e.labelEn })), + [edges, isZh], + ); + + const flowNodes = useMemo(() => layoutNodes(localizedNodes), [localizedNodes]); + const flowEdges = useMemo(() => buildEdges(localizedEdges), [localizedEdges]); + + return ( +
+ {/* Plane labels on the canvas sides */} +
+ + {t('topology.controlPlane')} + + + {t('topology.dataPlane')} + +
+ +
+ +
+ + + + { + const data = n.data as TopologyNodeData; + const plane = data?.node?.plane; + return plane === 'data' ? '#a78bfa' : '#22d3ee'; + }} + style={{ background: '#0b0d12', border: '1px solid #1f2937' }} + /> + + + + {showLegend && ( +
+ + + {t('topology.controlPlane')} + + + + {t('topology.dataPlane')} + + + + {nodes.length} nodes · {edges.length} edges + +
+ )} +
+ ); +} + +export function TopologyGraph(props: TopologyGraphProps) { + return ( + + + + ); +} \ No newline at end of file diff --git a/web/src/data/exampleScenarios.ts b/web/src/data/exampleScenarios.ts new file mode 100644 index 000000000..e924e43c8 --- /dev/null +++ b/web/src/data/exampleScenarios.ts @@ -0,0 +1,678 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Scenario registry used by the SandboxCases page. The page groups case +// cards by scenario (sub-directory under examples/), shows a topology +// preview per scenario, and filters by category / search keyword. +// +// AI / LLM scenarios are intentionally NOT exported — they live behind the +// `hidden` flag on the Rust side and never appear in the API. + +import { + Boxes, + Camera, + CircuitBoard, + Cpu, + FlaskConical, + FolderOpen, + GitBranch, + Globe2, + Layers, + type LucideIcon, + Network, + Rocket, + Server, + ShieldCheck, + TimerReset, +} from 'lucide-react'; + +export type ExampleCategoryId = + | 'basics' + | 'filesystem' + | 'lifecycle' + | 'network' + | 'browser' + | 'image' + | 'perf' + | 'advanced'; + +export interface ExampleCategory { + id: ExampleCategoryId; + /** Translation key without namespace prefix. */ + i18nKey: string; + icon: LucideIcon; + /** Tailwind gradient classes for the card banner. */ + accent: string; + /** Description used as the category heading hint. */ + hintZh: string; + hintEn: string; +} + +export const EXAMPLE_CATEGORIES: ExampleCategory[] = [ + { + id: 'basics', + i18nKey: 'categories.basics', + icon: Rocket, + accent: 'from-primary/20 via-primary/5 to-transparent', + hintZh: '沙箱最常用 API:创建、运行、读文件、暂停。', + hintEn: 'The most-used APIs: create, run, read files, pause.', + }, + { + id: 'filesystem', + i18nKey: 'categories.filesystem', + icon: FolderOpen, + accent: 'from-cube-emerald/20 via-cube-emerald/5 to-transparent', + hintZh: '把主机目录挂载进沙箱、或在沙箱内读写文件。', + hintEn: 'Mount host directories and read / write inside the guest.', + }, + { + id: 'lifecycle', + i18nKey: 'categories.lifecycle', + icon: TimerReset, + accent: 'from-cube-amber/20 via-cube-amber/5 to-transparent', + hintZh: '快照、回滚、克隆:让沙箱像 Git 一样可分叉。', + hintEn: 'Snapshot, rollback, clone: branch sandboxes like Git.', + }, + { + id: 'network', + i18nKey: 'categories.network', + icon: Network, + accent: 'from-cube-violet/20 via-cube-violet/5 to-transparent', + hintZh: '用 eBPF 数据面强制执行 allow / deny / 隔离策略。', + hintEn: 'Enforce allow / deny / isolation through the eBPF datapath.', + }, + { + id: 'browser', + i18nKey: 'categories.browser', + icon: Globe2, + accent: 'from-cube-cyan/20 via-cube-cyan/5 to-transparent', + hintZh: '在沙箱里跑 Playwright + Chromium,直接 CDP 控制。', + hintEn: 'Run Playwright + Chromium inside the guest, driven over CDP.', + }, + { + id: 'image', + i18nKey: 'categories.image', + icon: Layers, + accent: 'from-cube-rose/20 via-cube-rose/5 to-transparent', + hintZh: '基于自定义镜像(nginx 等)启动沙箱并验证。', + hintEn: 'Boot a sandbox from a custom image (nginx, …) and verify.', + }, + { + id: 'perf', + i18nKey: 'categories.perf', + icon: Cpu, + accent: 'from-cube-amber/15 via-cube-amber/5 to-transparent', + hintZh: '并发建沙箱、跑压测,拿到真实吞吐数字。', + hintEn: 'Concurrent sandbox creation, throughput numbers.', + }, + { + id: 'advanced', + i18nKey: 'categories.advanced', + icon: CircuitBoard, + accent: 'from-cube-violet/15 via-cube-violet/5 to-transparent', + hintZh: '本地 Sidecar、Host 改写、e2b 兼容等高级玩法。', + hintEn: 'Local sidecar, Host rewriting, e2b-compatible mode.', + }, +]; + +export type Plane = 'control' | 'data'; +export type NodeKind = 'user' | 'control' | 'data' | 'vm' | 'store'; + +export interface ScenarioNode { + id: string; + labelZh: string; + labelEn: string; + plane: Plane; + kind: NodeKind; + descriptionZh: string; + descriptionEn: string; +} + +export interface ScenarioEdge { + from: string; + to: string; + labelZh: string; + labelEn: string; + plane: Plane; +} + +export interface ScenarioTopology { + nodes: ScenarioNode[]; + edges: ScenarioEdge[]; +} + +export interface ScenarioFile { + /** File id matching the Rust registry (without scenario prefix). */ + id: string; + filename: string; + titleZh: string; + titleEn: string; + descriptionZh: string; + descriptionEn: string; + language: 'python' | 'go' | 'bash' | 'javascript'; +} + +export interface ExampleScenario { + id: string; + titleZh: string; + titleEn: string; + descriptionZh: string; + descriptionEn: string; + category: ExampleCategoryId; + icon: LucideIcon; + /** Tailwind accent gradient for the left-rail group header. */ + accent: string; + /** GitHub docs anchor; falls back to the repo root when unset. */ + docsAnchor?: string; + topology: ScenarioTopology; + files: ScenarioFile[]; +} + +// ─── Shared topology helpers ──────────────────────────────────────────────── +// +// Architecture overview: +// +// Control plane (orchestration): +// User Script → CubeAPI → CubeMaster → Cubelet +// +// Data plane (runtime, inside MicroVM): +// CubeAPI → CubeProxy → envd → Runner +// +// The MicroVM is the sandbox isolation boundary. Cubelet creates/destroys +// it (control-plane action), but the actual workload runs inside it +// (data-plane). envd and the runner are processes INSIDE the MicroVM, +// reachable from CubeProxy over a WSS tunnel. + +const SHARED_NODES: ScenarioNode[] = [ + // ── Control plane ────────────────────────────────────────────── + { id: 'user', labelZh: '用户脚本', labelEn: 'User Script', plane: 'control', kind: 'user', + descriptionZh: '你在页面上点击「运行」后发起的示例脚本调用。', + descriptionEn: 'The example invocation triggered when you click Run.' }, + { id: 'cubeapi', labelZh: 'CubeAPI :3000', labelEn: 'CubeAPI :3000', plane: 'control', kind: 'control', + descriptionZh: 'HTTP 网关:校验请求 → 调度 CubeMaster 创建沙箱 → 代理数据到 CubeProxy。', + descriptionEn: 'HTTP gateway: validates requests, schedules sandbox creation via CubeMaster, proxies data via CubeProxy.' }, + { id: 'cubemaster', labelZh: 'CubeMaster', labelEn: 'CubeMaster', plane: 'control', kind: 'control', + descriptionZh: '调度器:根据模板和负载挑选 Cubelet 节点,下发创建 MicroVM。', + descriptionEn: 'Scheduler: picks a Cubelet node based on template & load, then creates a MicroVM.' }, + { id: 'cubelet', labelZh: 'Cubelet', labelEn: 'Cubelet', plane: 'control', kind: 'control', + descriptionZh: '节点代理:管理本机 MicroVM 完整生命周期(创建/销毁/暂停/恢复/快照)。', + descriptionEn: 'Per-node agent: manages the full MicroVM lifecycle (create/destroy/pause/resume/snapshot).' }, + + // ── Data plane ───────────────────────────────────────────────── + { id: 'cubeproxy', labelZh: 'CubeProxy', labelEn: 'CubeProxy', plane: 'data', kind: 'control', + descriptionZh: 'TLS 终结的反向代理:将外部请求通过 WSS 隧道转发到沙箱内的 envd。', + descriptionEn: 'TLS-terminating reverse proxy: forwards requests via WSS tunnel to in-sandbox envd.' }, + { id: 'microvm', labelZh: 'KVM MicroVM', labelEn: 'KVM MicroVM', plane: 'data', kind: 'vm', + descriptionZh: 'QEMU/KVM 微虚拟机:沙箱的运行时隔离边界,内部运行 envd 和用户工作负载。', + descriptionEn: 'QEMU/KVM MicroVM: the sandbox isolation boundary, running envd and the user workload.' }, + { id: 'envd', labelZh: 'envd :49983', labelEn: 'envd :49983', plane: 'data', kind: 'data', + descriptionZh: '沙箱内守护进程:暴露 Jupyter 内核、文件系统访问和 Shell 执行接口。', + descriptionEn: 'In-sandbox daemon: exposes Jupyter kernel, filesystem access, and shell execution.' }, + { id: 'runner', labelZh: 'Python / Shell', labelEn: 'Python / Shell', plane: 'data', kind: 'data', + descriptionZh: '真正执行示例代码的解释器进程,由 envd fork+exec 启动。', + descriptionEn: 'The interpreter process that actually runs the example code, forked by envd.' }, +]; + +const SHARED_EDGES: ScenarioEdge[] = [ + // ── Control plane: request orchestration ─────────────────────── + { from: 'user', to: 'cubeapi', labelZh: 'HTTPS', labelEn: 'HTTPS', plane: 'control' }, + { from: 'cubeapi', to: 'cubemaster', labelZh: 'gRPC', labelEn: 'gRPC', plane: 'control' }, + { from: 'cubemaster', to: 'cubelet', labelZh: 'gRPC', labelEn: 'gRPC', plane: 'control' }, + { from: 'cubelet', to: 'microvm', labelZh: 'QMP / boot', labelEn: 'QMP / boot', plane: 'control' }, + + // ── Data plane: runtime data flow ────────────────────────────── + { from: 'cubeapi', to: 'cubeproxy', labelZh: 'HTTPS', labelEn: 'HTTPS', plane: 'data' }, + { from: 'cubeproxy', to: 'envd', labelZh: 'WSS 隧道', labelEn: 'WSS tunnel', plane: 'data' }, + { from: 'envd', to: 'runner', labelZh: 'fork+exec', labelEn: 'fork+exec', plane: 'data' }, +]; + +function clone(arr: T[]): T[] { + return arr.map((x) => ({ ...x })); +} + +function cloneSharedTopology(): ScenarioTopology { + return { nodes: clone(SHARED_NODES), edges: clone(SHARED_EDGES) }; +} + +// ─── Scenario topologies ──────────────────────────────────────────────────── +// +// Each scenario builds on the shared base and adds/removes nodes & edges +// to reflect its unique architecture. The shared base already covers the +// standard control-plane + data-plane flow; scenarios only need to declare +// their differences. + +function topologyQuickstart(): ScenarioTopology { + // The standard topology is exactly the shared base — no additions needed. + return cloneSharedTopology(); +} + +function topologyNetworkPolicy(): ScenarioTopology { + const t = cloneSharedTopology(); + // Replace the direct cubelet→microvm edge with an eBPF data-path hop. + // CubeVS sits on the veth between the host and the guest, enforcing + // allow/deny rules before packets reach the MicroVM. + t.edges = t.edges.filter((e) => !(e.from === 'cubelet' && e.to === 'microvm')); + t.nodes.push({ + id: 'cubevs', + labelZh: 'CubeVS (eBPF)', + labelEn: 'CubeVS (eBPF)', + plane: 'data', + kind: 'control', + descriptionZh: 'eBPF 数据面:在 guest veth 上按 CIDR 规则强制执行 allow / deny。', + descriptionEn: 'eBPF datapath: enforces allow/deny by CIDR on the guest veth.', + }); + t.edges.push( + { from: 'cubelet', to: 'cubevs', labelZh: 'tc / eBPF', labelEn: 'tc / eBPF', plane: 'data' }, + { from: 'cubevs', to: 'microvm', labelZh: 'veth', labelEn: 'veth', plane: 'data' }, + ); + return t; +} + +function topologyHostMount(): ScenarioTopology { + const t = cloneSharedTopology(); + // Add a host directory that is bind-mounted into the MicroVM. + t.nodes.push({ + id: 'hostdir', + labelZh: '主机目录', + labelEn: 'Host Directory', + plane: 'data', + kind: 'store', + descriptionZh: '主机本地目录,通过 9p / virtiofs 挂载到沙箱内 /mnt。', + descriptionEn: 'Host directory bind-mounted into the sandbox at /mnt via 9p / virtiofs.', + }); + t.edges.push({ + from: 'hostdir', + to: 'microvm', + labelZh: '9p / virtiofs', + labelEn: '9p / virtiofs', + plane: 'data', + }); + return t; +} + +function topologyBrowser(): ScenarioTopology { + const t = cloneSharedTopology(); + // Replace the generic runner with Chromium + Playwright. + t.nodes = t.nodes.filter((n) => n.id !== 'runner'); + t.edges = t.edges.filter((e) => !(e.from === 'envd' && e.to === 'runner')); + t.nodes.push( + { + id: 'playwright', + labelZh: 'Playwright (CDP)', + labelEn: 'Playwright (CDP)', + plane: 'data', + kind: 'data', + descriptionZh: 'Python 客户端,通过 Chrome DevTools Protocol 控制 Chromium。', + descriptionEn: 'Python client driving Chromium over Chrome DevTools Protocol.', + }, + { + id: 'chromium', + labelZh: 'Chromium :9000', + labelEn: 'Chromium :9000', + plane: 'data', + kind: 'data', + descriptionZh: '沙箱内启用 CDP 的无头 Chromium 浏览器。', + descriptionEn: 'Headless Chromium inside the sandbox with CDP enabled.', + }, + ); + t.edges.push( + { from: 'envd', to: 'playwright', labelZh: 'exec', labelEn: 'exec', plane: 'data' }, + { from: 'playwright', to: 'chromium', labelZh: 'CDP WS', labelEn: 'CDP WS', plane: 'data' }, + ); + return t; +} + +function topologySnapshot(): ScenarioTopology { + const t = cloneSharedTopology(); + // Add the LVM snapshot store between Cubelet and MicroVM. + // Snapshots outlive the sandbox and can be used for clone / rollback. + t.nodes.push({ + id: 'snapshot', + labelZh: '快照 (LVM)', + labelEn: 'Snapshot (LVM)', + plane: 'control', + kind: 'store', + descriptionZh: '根 LV 的写时复制快照,生命周期独立于沙箱,用于克隆和回滚。', + descriptionEn: 'CoW snapshot of the root LV. Outlives the sandbox; used for clone & rollback.', + }); + t.edges.push( + { from: 'cubelet', to: 'snapshot', labelZh: 'lvcreate', labelEn: 'lvcreate', plane: 'control' }, + { from: 'snapshot', to: 'microvm', labelZh: 'rollback / clone', labelEn: 'rollback / clone', plane: 'control' }, + ); + return t; +} + +function topologySidecar(): ScenarioTopology { + const t = cloneSharedTopology(); + // Insert a dev-sidecar between CubeAPI and CubeProxy that rewrites + // the Host header to mimic the e2b client. + t.nodes.push({ + id: 'sidecar', + labelZh: 'Dev Sidecar', + labelEn: 'Dev Sidecar', + plane: 'data', + kind: 'control', + descriptionZh: '本地反向代理:重写 Host 头以模拟 e2b 兼容客户端。', + descriptionEn: 'Local reverse-proxy that rewrites Host headers for e2b compatibility.', + }); + t.edges = t.edges.filter((e) => !(e.from === 'cubeapi' && e.to === 'cubeproxy')); + t.edges.push( + { from: 'cubeapi', to: 'sidecar', labelZh: 'HTTPS', labelEn: 'HTTPS', plane: 'data' }, + { from: 'sidecar', to: 'cubeproxy', labelZh: 'Host 改写', labelEn: 'Host rewrite', plane: 'data' }, + ); + return t; +} + +function topologyNginx(): ScenarioTopology { + const t = cloneSharedTopology(); + // Replace the generic runner with nginx serving static files. + t.nodes = t.nodes.filter((n) => n.id !== 'runner'); + t.edges = t.edges.filter((e) => !(e.from === 'envd' && e.to === 'runner')); + t.nodes.push({ + id: 'nginx', + labelZh: 'nginx :80', + labelEn: 'nginx :80', + plane: 'data', + kind: 'data', + descriptionZh: '沙箱内的 nginx,托管自定义镜像里的静态文件。', + descriptionEn: 'nginx serving static files from the custom image inside the sandbox.', + }); + t.edges.push({ + from: 'envd', + to: 'nginx', + labelZh: 'exec', + labelEn: 'exec', + plane: 'data', + }); + return t; +} + +function topologyBench(): ScenarioTopology { + const t = cloneSharedTopology(); + // Fan out: replace single MicroVM with N parallel clones. + t.nodes = t.nodes.filter((n) => n.id !== 'microvm'); + t.edges = t.edges.filter((e) => e.to !== 'microvm'); + for (let i = 0; i < 4; i++) { + t.nodes.push({ + id: `microvm-${i}`, + labelZh: `MicroVM #${i}`, + labelEn: `MicroVM #${i}`, + plane: 'data', + kind: 'vm', + descriptionZh: '并发压测的目标沙箱。', + descriptionEn: 'Concurrent benchmark target sandbox.', + }); + t.edges.push({ + from: 'cubelet', + to: `microvm-${i}`, + labelZh: 'QMP', + labelEn: 'QMP', + plane: 'control', + }); + } + return t; +} + +// ─── Scenario registry ────────────────────────────────────────────────────── +// +// 8 scenarios — none of them AI / LLM. The Rust handler enforces the same +// `hidden: true` filter so even an attacker who knows the IDs cannot reach +// these scenarios through the HTTP API. + +export const EXAMPLE_SCENARIOS: ExampleScenario[] = [ + { + id: 'code-sandbox-quickstart', + titleZh: '沙箱快速上手', + titleEn: 'Sandbox Quickstart', + descriptionZh: '创建一个沙箱并把最常用的 API 都跑一遍:create / exec_code / cmd / read / pause。', + descriptionEn: 'Create a sandbox and walk through the most-used APIs: create, exec_code, cmd, read, and pause.', + category: 'basics', + icon: Rocket, + accent: 'from-primary/20 via-primary/5 to-transparent', + topology: topologyQuickstart(), + files: [ + { id: 'create', filename: 'create.py', language: 'python', + titleZh: '创建沙箱', titleEn: 'Create Sandbox', + descriptionZh: '从一个模板创建沙箱,并读取它的元数据。', + descriptionEn: 'Create a sandbox from a template and read its metadata.' }, + { id: 'exec_code', filename: 'exec_code.py', language: 'python', + titleZh: '执行代码', titleEn: 'Execute Code', + descriptionZh: '通过 Jupyter 内核在沙箱里运行 Python 代码。', + descriptionEn: 'Run Python code inside the sandbox through the Jupyter kernel.' }, + { id: 'cmd', filename: 'cmd.py', language: 'python', + titleZh: '执行 Shell', titleEn: 'Run Shell Command', + descriptionZh: '在沙箱里执行 Shell 命令并捕获 stdout。', + descriptionEn: 'Execute a shell command inside the sandbox and capture stdout.' }, + { id: 'read', filename: 'read.py', language: 'python', + titleZh: '读 / 写文件', titleEn: 'Read / Write File', + descriptionZh: '读写沙箱内的文件。', + descriptionEn: 'Read and write files inside the sandbox.' }, + { id: 'pause', filename: 'pause.py', language: 'python', + titleZh: '暂停与恢复', titleEn: 'Pause & Resume', + descriptionZh: '冻结沙箱状态并在之后恢复。', + descriptionEn: 'Freeze the sandbox memory and resume it later.' }, + ], + }, + { + id: 'network-policy', + titleZh: '网络策略', + titleEn: 'Network Policy', + descriptionZh: '应用网络策略并验证连通性。eBPF 数据面会拦截非法流量。', + descriptionEn: 'Apply network policies and verify connectivity. The eBPF datapath drops traffic that violates the policy.', + category: 'network', + icon: ShieldCheck, + accent: 'from-cube-violet/20 via-cube-violet/5 to-transparent', + topology: topologyNetworkPolicy(), + files: [ + { id: 'network_no_internet', filename: 'network_no_internet.py', language: 'python', + titleZh: '无互联网', titleEn: 'No Internet', + descriptionZh: '出站完全阻断的沙箱。', + descriptionEn: 'Sandbox without outbound network access.' }, + { id: 'network_allowlist', filename: 'network_allowlist.py', language: 'python', + titleZh: '白名单', titleEn: 'Allowlist', + descriptionZh: '限制出网到显式 IP 列表。', + descriptionEn: 'Restrict egress to an explicit list of IPs.' }, + { id: 'network_denylist', filename: 'network_denylist.py', language: 'python', + titleZh: '黑名单', titleEn: 'Denylist', + descriptionZh: '默认放行,仅 deny 命中项。', + descriptionEn: 'Default-allow with explicit deny entries.' }, + ], + }, + { + id: 'host-mount', + titleZh: '主机目录挂载', + titleEn: 'Host Mount', + descriptionZh: '把主机目录 bind-mount 进沙箱文件系统。', + descriptionEn: 'Bind-mount a host directory into the sandbox filesystem.', + category: 'filesystem', + icon: FolderOpen, + accent: 'from-cube-emerald/20 via-cube-emerald/5 to-transparent', + topology: topologyHostMount(), + files: [ + { id: 'create_with_mount', filename: 'create_with_mount.py', language: 'python', + titleZh: '挂载并创建', titleEn: 'Create With Mount', + descriptionZh: '创建带主机目录挂载的沙箱。', + descriptionEn: 'Create a sandbox with a host directory mounted at /mnt.' }, + ], + }, + { + id: 'browser-sandbox', + titleZh: '浏览器沙箱', + titleEn: 'Browser Sandbox', + descriptionZh: '在沙箱里启动 Playwright + Chromium,用 CDP 控制。', + descriptionEn: 'Drive a headless Chromium with Playwright over CDP.', + category: 'browser', + icon: Globe2, + accent: 'from-cube-cyan/20 via-cube-cyan/5 to-transparent', + topology: topologyBrowser(), + files: [ + { id: 'browser', filename: 'browser.py', language: 'python', + titleZh: 'Playwright + Chromium', titleEn: 'Playwright + Chromium', + descriptionZh: '启动 Chromium 并跑一段 Playwright 脚本。', + descriptionEn: 'Boot a sandbox with Chromium and run a Playwright script.' }, + ], + }, + { + id: 'snapshot-rollback-clone', + titleZh: '快照 / 回滚 / 克隆', + titleEn: 'Snapshot · Rollback · Clone', + descriptionZh: '把沙箱像 Git 一样分叉:从快照克隆出 N 个,回滚到任意时间点。', + descriptionEn: 'Branch sandboxes like Git: clone from a snapshot, roll back to any point.', + category: 'lifecycle', + icon: GitBranch, + accent: 'from-cube-amber/20 via-cube-amber/5 to-transparent', + topology: topologySnapshot(), + files: [ + { id: '01_create_snapshot', filename: '01_create_snapshot.py', language: 'python', + titleZh: '01 创建快照', titleEn: '01 Create Snapshot', + descriptionZh: '在运行中的沙箱上打快照。', + descriptionEn: 'Capture a snapshot from a running sandbox.' }, + { id: '02_list_snapshots', filename: '02_list_snapshots.py', language: 'python', + titleZh: '02 列出快照', titleEn: '02 List Snapshots', + descriptionZh: '查看集群下的快照列表。', + descriptionEn: 'List snapshots attached to the cluster.' }, + { id: '03_clone_from_snapshot', filename: '03_clone_from_snapshot.py', language: 'python', + titleZh: '03 从快照克隆', titleEn: '03 Clone From Snapshot', + descriptionZh: '用快照派生新沙箱。', + descriptionEn: 'Create a new sandbox from a snapshot.' }, + { id: '04_state_preserved', filename: '04_state_preserved.py', language: 'python', + titleZh: '04 状态保留', titleEn: '04 State Preserved', + descriptionZh: '验证状态在克隆后仍然保留。', + descriptionEn: 'Verify state survives the clone.' }, + { id: '05_snapshot_outlives_sandbox', filename: '05_snapshot_outlives_sandbox.py', language: 'python', + titleZh: '05 快照独立', titleEn: '05 Snapshot Outlives', + descriptionZh: '快照生命周期独立于源沙箱。', + descriptionEn: 'Snapshot outlives its source sandbox.' }, + { id: '06_clone_n', filename: '06_clone_n.py', language: 'python', + titleZh: '06 串行克隆 N 次', titleEn: '06 Clone N Times', + descriptionZh: '依次克隆出 N 个沙箱。', + descriptionEn: 'Spin up N clones in sequence.' }, + { id: '07_clone_concurrent', filename: '07_clone_concurrent.py', language: 'python', + titleZh: '07 并发克隆', titleEn: '07 Clone Concurrently', + descriptionZh: '并发克隆 N 个沙箱。', + descriptionEn: 'Spin up N clones in parallel.' }, + { id: '08_fork_three_axis', filename: '08_fork_three_axis.py', language: 'python', + titleZh: '08 三轴分叉', titleEn: '08 Fork Three-axis', + descriptionZh: '从三个正交维度克隆 / 回滚。', + descriptionEn: 'Three orthogonal dimensions of clone / rollback.' }, + { id: '09_rollback', filename: '09_rollback.py', language: 'python', + titleZh: '09 回滚', titleEn: '09 Rollback', + descriptionZh: '把沙箱回滚到之前的快照。', + descriptionEn: 'Roll the sandbox back to a previous snapshot.' }, + { id: '10_rollback_then_continue', filename: '10_rollback_then_continue.py', language: 'python', + titleZh: '10 回滚后继续', titleEn: '10 Rollback Then Continue', + descriptionZh: '回滚后继续正常执行。', + descriptionEn: 'Rollback, then resume normal execution.' }, + { id: '11_delete_snapshot', filename: '11_delete_snapshot.py', language: 'python', + titleZh: '11 删除快照', titleEn: '11 Delete Snapshot', + descriptionZh: '从集群里清理一个快照。', + descriptionEn: 'Clean up a snapshot from the cluster.' }, + { id: 'clone_demo', filename: 'clone_demo.py', language: 'python', + titleZh: '克隆 Demo', titleEn: 'Clone Demo', + descriptionZh: '端到端克隆示例。', + descriptionEn: 'End-to-end clone walkthrough.' }, + { id: 'rollback_demo', filename: 'rollback_demo.py', language: 'python', + titleZh: '回滚 Demo', titleEn: 'Rollback Demo', + descriptionZh: '端到端回滚示例。', + descriptionEn: 'End-to-end rollback walkthrough.' }, + ], + }, + { + id: 'e2b-dev-sidecar', + titleZh: 'e2b Dev Sidecar', + titleEn: 'e2b Dev Sidecar', + descriptionZh: '本地 Sidecar 反代到 CubeProxy,并改写 Host 头模拟 e2b 客户端。', + descriptionEn: 'A local dev-sidecar proxies to CubeProxy and rewrites the Host header to mimic the e2b client.', + category: 'advanced', + icon: Server, + accent: 'from-cube-violet/15 via-cube-violet/5 to-transparent', + topology: topologySidecar(), + files: [ + { id: 'demo', filename: 'demo.py', language: 'python', + titleZh: 'Sidecar Demo', titleEn: 'Sidecar Demo', + descriptionZh: '在 CubeAPI 前面起一个 Sidecar 反向代理。', + descriptionEn: 'Start a sidecar proxy in front of CubeAPI.' }, + ], + }, + { + id: 'cubesandbox-base-nginx', + titleZh: '自定义镜像 (nginx)', + titleEn: 'Custom Image (nginx)', + descriptionZh: '基于带 nginx 的自定义镜像启动沙箱并访问静态资源。', + descriptionEn: 'Boot a sandbox from a custom image that runs nginx and reach its static files.', + category: 'image', + icon: Layers, + accent: 'from-cube-rose/20 via-cube-rose/5 to-transparent', + topology: topologyNginx(), + files: [ + { id: 'test_files', filename: 'test_files.py', language: 'python', + titleZh: 'Test Files', titleEn: 'Test Files', + descriptionZh: '通过代理访问 nginx 服务的文件。', + descriptionEn: 'Reach the nginx-served files via the proxy.' }, + ], + }, + { + id: 'cube-bench', + titleZh: 'cube-bench', + titleEn: 'cube-bench', + descriptionZh: '用 Go 写的并发建沙箱基准测试,输出真实吞吐。', + descriptionEn: 'Concurrent sandbox creation benchmark written in Go, with throughput numbers.', + category: 'perf', + icon: Cpu, + accent: 'from-cube-amber/15 via-cube-amber/5 to-transparent', + topology: topologyBench(), + files: [ + { id: 'main', filename: 'main.go', language: 'go', + titleZh: '运行压测', titleEn: 'Run Benchmark', + descriptionZh: '并发启动 N 个沙箱并报告吞吐。', + descriptionEn: 'Spawn N sandboxes in parallel and report throughput.' }, + ], + }, +]; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +export function findScenario(id: string): ExampleScenario | undefined { + return EXAMPLE_SCENARIOS.find((s) => s.id === id); +} + +export function findFile(scenarioId: string, fileId: string): ScenarioFile | undefined { + return findScenario(scenarioId)?.files.find((f) => f.id === fileId); +} + +export function categoryMeta(id: ExampleCategoryId): ExampleCategory | undefined { + return EXAMPLE_CATEGORIES.find((c) => c.id === id); +} + +// Kept for CommandPalette / legacy callers that previously imported the +// `examples` data array from a different file. +export interface LegacyExampleEntry { + id: string; + scenario: string; + filename: string; + title: string; + description: string; + category: string; + language: string; +} + +export function buildLegacyExamples(): LegacyExampleEntry[] { + const out: LegacyExampleEntry[] = []; + for (const sc of EXAMPLE_SCENARIOS) { + for (const f of sc.files) { + out.push({ + id: `${sc.id}:${f.id}`, + scenario: sc.id, + filename: f.filename, + title: f.titleEn, + description: f.descriptionEn, + category: sc.category, + language: f.language, + }); + } + } + return out; +} + +// Lightweight type alias to keep imports consistent across the project. +export type { Boxes, Camera }; \ No newline at end of file diff --git a/web/src/locales/en/examples.json b/web/src/locales/en/examples.json index 4a55eb023..f1f7343e0 100644 --- a/web/src/locales/en/examples.json +++ b/web/src/locales/en/examples.json @@ -1,25 +1,41 @@ { - "title": "Examples", - "subtitle": "Browse and run code-sandbox-quickstart examples directly from the dashboard — experience sandbox capabilities in one click.", - "badge": "code-sandbox-quickstart", + "title": "Sandbox Cases", + "subtitle": "Browse every scenario under examples/ — pick a case, edit the code, and run it against a real sandbox. Topology + step log show exactly what happens underneath.", + "badge": "examples/", "run": "Run", "running": "Running…", - "output": "Output", + "runningHint": "Running the example… this may take a few seconds.", + "output": { + "title": "Output", + "completed": "Completed", + "failed": "Failed", + "elapsed": "Took {{ms}}ms", + "runOnDisk": "Ran original file", + "runEdited": "Ran your edits" + }, "outputHint": "Click Run to execute this example. Output will appear here.", - "selectHint": "Pick an example from the left to view its code and output here.", - "selectHintTitle": "Choose an example to start", - "defaultTemplate": "Default: {{id}}", - "noTemplate": "No templates available", + "selectHint": "Pick a case from the left to view its code, run it, and inspect the topology + step log.", + "selectHintTitle": "Choose a case to start", "allCategories": "All", - "searchPlaceholder": "Search examples…", - "noResults": "No matching examples", + "searchPlaceholder": "Search cases, scenarios, files…", + "noResults": "No matching cases", "copy": "Copy code", "copied": "Copied to clipboard", "copyFailed": "Copy failed", - "completed": "Completed", - "failed": "Failed", + "defaultTemplate": "Default: {{id}}", + "noTemplate": "No templates available", + "editor": { + "restore": "Restore", + "restoreHint": "Revert your edits back to the original on-disk source", + "dirty": "Modified", + "dirtyHint": "Showing your edits. Click Restore to revert to the original on-disk source." + }, + "sidebar": { + "scenarios": "Scenarios" + }, "templateSelector": { "title": "Select template", + "placeholder": "Select template", "searchPlaceholder": "Search by ID / type…", "empty": "No matching templates", "hint": "Click outside to close", @@ -28,5 +44,33 @@ "building": "Building", "other": "Other" } + }, + "categories": { + "basics": "Basics", + "filesystem": "Filesystem", + "lifecycle": "Lifecycle", + "network": "Network", + "browser": "Browser", + "image": "Custom Image", + "perf": "Performance", + "advanced": "Advanced" + }, + "languages": { + "python": "Python", + "go": "Go", + "bash": "Bash", + "javascript": "JavaScript" + }, + "topology": { + "title": "Execution topology", + "controlPlane": "Control plane", + "dataPlane": "Data plane", + "hint": "Drag nodes to rearrange · scroll to zoom" + }, + "timeline": { + "title": "Step timeline", + "empty": "Run a case to see the step-by-step log here.", + "controlPlane": "Control plane", + "dataPlane": "Data plane" } -} +} \ No newline at end of file diff --git a/web/src/locales/en/nav.json b/web/src/locales/en/nav.json index 8d8eac797..a96ffc256 100644 --- a/web/src/locales/en/nav.json +++ b/web/src/locales/en/nav.json @@ -10,5 +10,5 @@ "settings": "Settings", "store": "Template Store", "agentHub": "AgentHub", - "examples": "Examples" + "examples": "Sandbox Cases" } \ No newline at end of file diff --git a/web/src/locales/zh/examples.json b/web/src/locales/zh/examples.json index ed9217b0f..6a2b6d6c3 100644 --- a/web/src/locales/zh/examples.json +++ b/web/src/locales/zh/examples.json @@ -1,25 +1,41 @@ { - "title": "示例中心", - "subtitle": "浏览并直接在控制台运行 code-sandbox-quickstart 示例代码,一键体验沙箱的常用能力。", - "badge": "code-sandbox-quickstart", + "title": "沙箱案例", + "subtitle": "沙箱案例通过展示多种场景,帮助开发者快速了解沙箱的不同业务场景用法。", + "badge": "examples/", "run": "运行", "running": "运行中…", - "output": "运行结果", + "runningHint": "正在执行示例…可能需要几秒钟。", + "output": { + "title": "运行结果", + "completed": "已完成", + "failed": "失败", + "elapsed": "耗时 {{ms}}ms", + "runOnDisk": "已运行磁盘原文件", + "runEdited": "已运行你的修改" + }, "outputHint": "点击「运行」执行此示例,输出将在这里显示。", - "selectHint": "从左侧选择一个示例,代码和运行结果将在这里显示。", - "selectHintTitle": "选择一个示例开始", - "defaultTemplate": "默认:{{id}}", - "noTemplate": "暂无可用模板,请新建模版", + "selectHint": "从左侧选择一个案例,编辑代码、运行并查看拓扑与步骤日志。", + "selectHintTitle": "选择一个案例开始", "allCategories": "全部", - "searchPlaceholder": "搜索示例…", - "noResults": "没有匹配的示例", + "searchPlaceholder": "搜索案例 / 场景 / 文件名…", + "noResults": "没有匹配的案例", "copy": "复制代码", "copied": "已复制到剪贴板", "copyFailed": "复制失败", - "completed": "已完成", - "failed": "失败", + "defaultTemplate": "默认:{{id}}", + "noTemplate": "暂无可用模板,请新建模板", + "editor": { + "restore": "恢复", + "restoreHint": "把你的修改还原到磁盘上的原始代码", + "dirty": "已修改", + "dirtyHint": "当前显示的是你的修改。点击「恢复」回到磁盘上的原始代码。" + }, + "sidebar": { + "scenarios": "场景列表" + }, "templateSelector": { "title": "选择模板", + "placeholder": "选择模板", "searchPlaceholder": "按 ID / 类型搜索…", "empty": "没有匹配的模板", "hint": "点击外部区域关闭", @@ -28,5 +44,33 @@ "building": "构建中", "other": "其他" } + }, + "categories": { + "basics": "基础", + "filesystem": "文件系统", + "lifecycle": "生命周期", + "network": "网络", + "browser": "浏览器", + "image": "自定义镜像", + "perf": "性能", + "advanced": "高级" + }, + "languages": { + "python": "Python", + "go": "Go", + "bash": "Bash", + "javascript": "JavaScript" + }, + "topology": { + "title": "执行拓扑", + "controlPlane": "管理流", + "dataPlane": "调用流", + "hint": "拖动节点可重新排列 · 滚轮缩放" + }, + "timeline": { + "title": "步骤时间线", + "empty": "运行一次案例后,这里会显示分步日志。", + "controlPlane": "管理流", + "dataPlane": "调用流" } -} +} \ No newline at end of file diff --git a/web/src/locales/zh/nav.json b/web/src/locales/zh/nav.json index e8af1b245..7cee87310 100644 --- a/web/src/locales/zh/nav.json +++ b/web/src/locales/zh/nav.json @@ -10,5 +10,5 @@ "settings": "设置", "store": "模板市场", "agentHub": "数字助手", - "examples": "示例中心" + "examples": "沙箱案例" } \ No newline at end of file diff --git a/web/src/main.tsx b/web/src/main.tsx index fd2cf6272..1c50e188a 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -27,7 +27,7 @@ import TemplateStorePage from '@/pages/TemplateStore'; import AgentHubPage from '@/pages/AgentHub'; import LoginPage from '@/pages/Login'; import { AuthGuard } from '@/components/AuthGuard'; -import ExamplesPage from '@/pages/Examples'; +import ExamplesPage from '@/pages/SandboxCases'; import { Placeholder } from '@/pages/Placeholder'; import { Network, Activity, Settings, Package } from 'lucide-react'; diff --git a/web/src/mocks/handlers/index.ts b/web/src/mocks/handlers/index.ts index 2411fd09b..73768c3e2 100644 --- a/web/src/mocks/handlers/index.ts +++ b/web/src/mocks/handlers/index.ts @@ -139,4 +139,111 @@ export const handlers = [ resetMockState(); return HttpResponse.json({ ok: true }); }), + + // ── SandboxCases mock endpoints ───────────────────────────────────── + // The real backend returns a richer payload (steps + topology); the mock + // mirrors the shape so the UI renders correctly under MSW without + // spawning a subprocess. + http.get('/cubeapi/v1/examples', async () => { + await mockDelay(); + return HttpResponse.json([ + { id: 'code-sandbox-quickstart:create', scenario: 'code-sandbox-quickstart', + filename: 'create.py', title: 'Create Sandbox', + description: 'Create a sandbox from a template and read its metadata.', + category: 'basics', language: 'python' }, + { id: 'code-sandbox-quickstart:exec_code', scenario: 'code-sandbox-quickstart', + filename: 'exec_code.py', title: 'Execute Code', + description: 'Run Python code inside the sandbox through the Jupyter kernel.', + category: 'basics', language: 'python' }, + { id: 'code-sandbox-quickstart:cmd', scenario: 'code-sandbox-quickstart', + filename: 'cmd.py', title: 'Run Shell Command', + description: 'Execute a shell command inside the sandbox.', + category: 'basics', language: 'python' }, + { id: 'snapshot-rollback-clone:01_create_snapshot', scenario: 'snapshot-rollback-clone', + filename: '01_create_snapshot.py', title: '01 Create Snapshot', + description: 'Capture a snapshot from a running sandbox.', + category: 'lifecycle', language: 'python' }, + { id: 'snapshot-rollback-clone:09_rollback', scenario: 'snapshot-rollback-clone', + filename: '09_rollback.py', title: '09 Rollback', + description: 'Roll the sandbox back to a previous snapshot.', + category: 'lifecycle', language: 'python' }, + { id: 'network-policy:network_allowlist', scenario: 'network-policy', + filename: 'network_allowlist.py', title: 'Network Allowlist', + description: 'Restrict egress to an explicit list of IPs.', + category: 'network', language: 'python' }, + { id: 'host-mount:create_with_mount', scenario: 'host-mount', + filename: 'create_with_mount.py', title: 'Create With Mount', + description: 'Create a sandbox with a host directory mounted at /mnt.', + category: 'filesystem', language: 'python' }, + { id: 'browser-sandbox:browser', scenario: 'browser-sandbox', + filename: 'browser.py', title: 'Playwright + Chromium', + description: 'Boot a sandbox with Chromium and run a Playwright script.', + category: 'browser', language: 'python' }, + ]); + }), + + http.get('/cubeapi/v1/examples/:scenario/:file', async ({ params }) => { + await mockDelay(); + const scenario = String(params.scenario); + const file = String(params.file); + const id = `${scenario}:${file}`; + // Tiny embedded source so the mock has a runnable preview. + const stub = + `# ${id}\n` + + `# mock source — backend would read this from examples//\n` + + `print("hello from ${id}")\n`; + return HttpResponse.json({ + id, + filename: `${file}.py`, + scenario, + language: 'python', + source: stub, + }); + }), + + http.post('/cubeapi/v1/examples/run', async ({ request }) => { + await mockDelay(); + const body = (await request.json().catch(() => ({}))) as { + id?: string; + template_id?: string; + code?: string; + }; + const id = body.id ?? 'unknown'; + return HttpResponse.json({ + stdout: `[mock] ran ${id} with template=${body.template_id ?? ''}\n${body.code ? 'code length=' + body.code.length : 'on-disk source'}`, + stderr: '', + exit_code: 0, + success: true, + elapsed_ms: 820, + ran_edited: !!body.code, + steps: [ + { name: 'Validate request', plane: 'control', status: 'ok', duration_ms: 18, message: 'Parsed example id.' }, + { name: 'Schedule & boot VM', plane: 'control', status: 'ok', duration_ms: 220, message: 'CubeMaster picked a Cubelet.' }, + { name: 'Handshake envd', plane: 'data', status: 'ok', duration_ms: 90, message: 'WSS tunnel up.' }, + { name: 'Execute workload', plane: 'data', status: 'ok', duration_ms: 480, message: 'Sample stdout line' }, + { name: 'Cleanup & return', plane: 'control', status: 'ok', duration_ms: 12, message: 'Sandbox returned to idle.' }, + ], + topology: { + nodes: [ + { id: 'user', label: 'User Script', plane: 'control', kind: 'user', description: 'mock user' }, + { id: 'cubeapi', label: 'CubeAPI :3000', plane: 'control', kind: 'control', description: 'HTTP gateway' }, + { id: 'cubemaster', label: 'CubeMaster', plane: 'control', kind: 'control', description: 'Scheduler' }, + { id: 'cubelet', label: 'Cubelet', plane: 'control', kind: 'control', description: 'Per-node agent' }, + { id: 'cubeproxy', label: 'CubeProxy', plane: 'data', kind: 'control', description: 'TLS reverse proxy' }, + { id: 'microvm', label: 'KVM MicroVM', plane: 'data', kind: 'vm', description: 'Sandbox boundary' }, + { id: 'envd', label: 'envd :49983', plane: 'data', kind: 'data', description: 'In-sandbox daemon' }, + { id: 'runner', label: 'Python / Shell', plane: 'data', kind: 'data', description: 'Interpreter' }, + ], + edges: [ + { from: 'user', to: 'cubeapi', label: 'HTTPS', plane: 'control' }, + { from: 'cubeapi', to: 'cubemaster', label: 'gRPC', plane: 'control' }, + { from: 'cubemaster', to: 'cubelet', label: 'gRPC', plane: 'control' }, + { from: 'cubelet', to: 'microvm', label: 'QMP / boot', plane: 'control' }, + { from: 'cubeapi', to: 'cubeproxy', label: 'HTTPS', plane: 'data' }, + { from: 'cubeproxy', to: 'envd', label: 'WSS tunnel', plane: 'data' }, + { from: 'envd', to: 'runner', label: 'fork+exec', plane: 'data' }, + ], + }, + }); + }), ]; diff --git a/web/src/pages/Examples.tsx b/web/src/pages/Examples.tsx deleted file mode 100644 index 6d159bb97..000000000 --- a/web/src/pages/Examples.tsx +++ /dev/null @@ -1,827 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (C) 2026 Tencent. All rights reserved. - -import { useState, useEffect, useMemo, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useQuery, useMutation } from '@tanstack/react-query'; -import { api } from '@/lib/api'; -import { templateApi, clusterApi, type TemplateSummary } from '@/api/client'; -import { Card } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Skeleton } from '@/components/ui/skeleton'; -import { CodeBlock } from '@/components/CodeBlock'; -import { - Play, - Terminal, - CheckCircle2, - XCircle, - Clock, - ChevronRight, - Sparkles, - Rocket, - FolderOpen, - PauseCircle, - Globe2, - Search, - Copy, - Cpu, - Layers, - ChevronDown, - Check, - Inbox, - FileCode2, - Timer, -} from 'lucide-react'; -import { cn, copyToClipboard } from '@/lib/utils'; - -// ── Types ──────────────────────────────────────────────────────────────────── - -interface ExampleMeta { - id: string; - filename: string; - title: string; - description: string; - category: string; -} - -interface RunExampleResponse { - stdout: string; - stderr: string; - exit_code: number; - success: boolean; -} - -// ── API ────────────────────────────────────────────────────────────────────── - -const examplesApi = { - list: () => api('/examples'), - getSource: (id: string) => - api<{ id: string; filename: string; source: string }>(`/examples/${id}`), - run: (id: string, templateId?: string) => - api('/examples/run', { - method: 'POST', - body: JSON.stringify({ id, template_id: templateId || undefined }), - }), -}; - -// ── Category helpers ────────────────────────────────────────────────────────── - -const CATEGORY_META: Record< - string, - { label: string; icon: typeof Rocket; tone: 'info' | 'ok' | 'warn' | 'mute'; gradient: string } -> = { - basics: { - label: 'Basics', - icon: Rocket, - tone: 'info', - gradient: 'from-primary/20 via-primary/5 to-transparent', - }, - filesystem: { - label: 'Filesystem', - icon: FolderOpen, - tone: 'ok', - gradient: 'from-cube-emerald/20 via-cube-emerald/5 to-transparent', - }, - lifecycle: { - label: 'Lifecycle', - icon: PauseCircle, - tone: 'warn', - gradient: 'from-cube-amber/20 via-cube-amber/5 to-transparent', - }, - network: { - label: 'Network', - icon: Globe2, - tone: 'mute', - gradient: 'from-cube-violet/20 via-cube-violet/5 to-transparent', - }, -}; - -const CATEGORY_ORDER = ['basics', 'filesystem', 'lifecycle', 'network']; - -// ── RunOutput ──────────────────────────────────────────────────────────────── - -function RunOutput({ result, isRunning }: { result: RunExampleResponse | null; isRunning: boolean }) { - if (isRunning) { - return ( -
- - - - - {`Running example… this may take a few seconds.`} -
- ); - } - if (!result) return null; - - return ( -
-
- {result.success ? ( - - - Exited 0 - - ) : ( - - - {`Exited ${result.exit_code}`} - - )} - - {result.stdout ? `${result.stdout.split('\n').filter(Boolean).length} lines` : 'no output'} - -
- - {result.stdout && ( -
-
-            {result.stdout}
-          
-
- )} - {result.stderr && ( -
-
-            {result.stderr}
-          
-
- )} -
- ); -} - -// ── Template dropdown ──────────────────────────────────────────────────────── - -interface TemplateDropdownProps { - templates: TemplateSummary[]; - defaultTemplateId?: string; - value: string | undefined; - onChange: (id: string | undefined) => void; -} - -function TemplateDropdown({ templates, defaultTemplateId, value, onChange }: TemplateDropdownProps) { - const { t } = useTranslation('examples'); - const [open, setOpen] = useState(false); - const [filter, setFilter] = useState(''); - const ref = useRef(null); - const searchRef = useRef(null); - - useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [open]); - - // Auto-focus search when opened - useEffect(() => { - if (open) { - // small delay so the input is mounted - const t = setTimeout(() => searchRef.current?.focus(), 30); - return () => clearTimeout(t); - } else { - setFilter(''); - } - }, [open]); - - const isDefault = value === defaultTemplateId; - - const filtered = useMemo(() => { - const q = filter.trim().toLowerCase(); - if (!q) return templates; - return templates.filter( - (t) => - t.templateID.toLowerCase().includes(q) || - (t.instanceType ?? '').toLowerCase().includes(q) || - (t.status ?? '').toLowerCase().includes(q), - ); - }, [templates, filter]); - - // Group by status: ready → building → others - const grouped = useMemo(() => { - const ready: TemplateSummary[] = []; - const building: TemplateSummary[] = []; - const other: TemplateSummary[] = []; - for (const t of filtered) { - const s = t.status.toLowerCase(); - if (s === 'ready') ready.push(t); - else if (s === 'building' || s === 'pending') building.push(t); - else other.push(t); - } - return { ready, building, other }; - }, [filtered]); - - const totalShown = filtered.length; - const totalAll = templates.length; - - return ( -
- - - {open && ( -
- {/* Header */} -
-
- - - -

- {t('templateSelector.title')} -

-
- - {totalShown}/{totalAll} - -
- - {/* Search */} - {templates.length > 4 && ( -
-
- - setFilter(e.target.value)} - placeholder={t('templateSelector.searchPlaceholder')} - className={cn( - 'h-7 w-full rounded-md border border-border/60 bg-background pl-7 pr-2 text-xs', - 'placeholder:text-muted-foreground/60', - 'focus:outline-none focus:ring-1 focus:ring-primary/40', - )} - /> -
-
- )} - - {/* List */} -
- {filtered.length === 0 ? ( -
- -

{t('templateSelector.empty')}

-
- ) : ( - <> - {(['ready', 'building', 'other'] as const).map((groupKey) => { - const items = grouped[groupKey]; - if (!items.length) return null; - return ( -
-

- - {t(`templateSelector.group.${groupKey}`)} - · {items.length} -

- {items.map((tpl) => { - const isSelected = tpl.templateID === value; - const statusLower = tpl.status.toLowerCase(); - return ( - - ); - })} -
- ); - })} - - )} -
- - {/* Footer hint */} -
- {t('templateSelector.hint')} -
-
- )} -
- ); -} - -// ── ExampleCard ────────────────────────────────────────────────────────────── - -interface ExampleCardProps { - example: ExampleMeta; - selected: boolean; - onSelect: () => void; -} - -function ExampleCard({ example, selected, onSelect }: ExampleCardProps) { - const meta = CATEGORY_META[example.category] ?? CATEGORY_META.basics; - const Icon = meta.icon; - - return ( - - ); -} - -// ── Empty state for output panel ───────────────────────────────────────────── - -function OutputEmpty({ onRun }: { onRun?: () => void }) { - const { t } = useTranslation('examples'); - return ( -
- - - -

{t('outputHint')}

- {onRun && ( - - )} -
- ); -} - -// ── Main page ──────────────────────────────────────────────────────────────── - -export default function ExamplesPage() { - const { t } = useTranslation('examples'); - const [selectedId, setSelectedId] = useState(null); - const [runResult, setRunResult] = useState(null); - const [selectedTemplateId, setSelectedTemplateId] = useState(undefined); - const [activeCategory, setActiveCategory] = useState('all'); - const [search, setSearch] = useState(''); - - const { data: examples, isLoading } = useQuery({ - queryKey: ['examples'], - queryFn: examplesApi.list, - }); - - const { data: templates } = useQuery({ - queryKey: ['templates'], - queryFn: () => templateApi.list(), - }); - - const { data: config } = useQuery({ - queryKey: ['config'], - queryFn: () => clusterApi.config(), - }); - - const defaultTemplateId = config?.defaultTemplateId; - const firstTemplateId = templates?.[0]?.templateID; - const effectiveTemplateId = selectedTemplateId ?? defaultTemplateId ?? firstTemplateId; - - useEffect(() => { - if (effectiveTemplateId && selectedTemplateId === undefined) { - setSelectedTemplateId(effectiveTemplateId); - } - }, [effectiveTemplateId, selectedTemplateId]); - - const runMutation = useMutation({ - mutationFn: (id: string) => examplesApi.run(id, selectedTemplateId), - onSuccess: (data) => setRunResult(data), - onMutate: () => setRunResult(null), - }); - - const selected = examples?.find((e) => e.id === selectedId) ?? null; - - const { data: sourceData, isLoading: isSourceLoading } = useQuery({ - queryKey: ['examples', selectedId, 'source'], - queryFn: () => examplesApi.getSource(selectedId!), - enabled: !!selectedId, - }); - const sourceCode = sourceData?.source ?? ''; - - // Group + filter - const filteredList = useMemo(() => { - if (!examples) return []; - const q = search.trim().toLowerCase(); - return examples.filter((e) => { - if (activeCategory !== 'all' && e.category !== activeCategory) return false; - if (q) { - return ( - e.title.toLowerCase().includes(q) || - e.description.toLowerCase().includes(q) || - e.filename.toLowerCase().includes(q) - ); - } - return true; - }); - }, [examples, activeCategory, search]); - - const grouped = useMemo(() => { - const out: Record = {}; - for (const e of filteredList) { - (out[e.category] ??= []).push(e); - } - return out; - }, [filteredList]); - - // Stats for header - const totalCount = examples?.length ?? 0; - const categoryCount = useMemo(() => { - if (!examples) return 0; - return new Set(examples.map((e) => e.category)).size; - }, [examples]); - - const handleCopySource = () => { - if (!sourceCode) return; - // copyToClipboard 内置 fallback:HTTPS 下用 navigator.clipboard, - // HTTP(无 Secure Context)下回退到 execCommand('copy'),不再抛异常。 - copyToClipboard(sourceCode, t('copied')); - }; - - const runSelected = () => { - if (selected) runMutation.mutate(selected.id); - }; - - return ( -
- {/* Hero header */} -
-
-
-
-
-
- - - -

{t('title')}

- {t('badge')} -
-

{t('subtitle')}

-
-
-
- examples · - {totalCount} -
-
- categories · - {categoryCount} -
-
-
-
- - {/* Toolbar: search + category filter */} -
-
- - setSearch(e.target.value)} - placeholder={t('searchPlaceholder')} - className={cn( - 'h-9 w-full rounded-lg border border-border/60 bg-background pl-8 pr-3 text-sm', - 'placeholder:text-muted-foreground/70', - 'focus:outline-none focus:ring-1 focus:ring-primary/40', - )} - /> -
-
- - {CATEGORY_ORDER.filter((c) => CATEGORY_META[c]).map((cat) => { - const meta = CATEGORY_META[cat]; - const Icon = meta.icon; - return ( - - ); - })} -
-
- - {/* Main two-column layout */} -
- {/* Left: example list */} -
- {isLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- ) : filteredList.length === 0 ? ( -
- -

{t('noResults')}

-
- ) : ( -
- {CATEGORY_ORDER.filter((c) => grouped[c]?.length).map((cat) => { - const meta = CATEGORY_META[cat]; - const items = grouped[cat]; - if (!items?.length) return null; - return ( -
-
- -

- {meta.label} -

- · {items.length} -
-
- {items.map((ex) => ( - { - setSelectedId(ex.id); - setRunResult(null); - }} - /> - ))} -
-
- ); - })} -
- )} -
- - {/* Right: code + output */} -
- {selected ? ( - <> - {/* Code panel */} - -
-
- - - - {selected.title} - · {selected.filename} -
-
- - - -
-
-
- {isSourceLoading ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( - - ))} -
- ) : sourceCode ? ( - - ) : ( -
-                      # {selected.filename}
-                    
- )} -
-
- - {/* Output panel */} - -
-
- - - - {t('output')} - {runMutation.isPending && ( - - - {t('running')} - - )} -
- {runResult && ( - - {runResult.success ? t('completed') : t('failed')} - - )} -
-
- {!runResult && !runMutation.isPending ? ( - - ) : ( - - )} -
-
- - ) : ( - - - - -
-

{t('selectHintTitle')}

-

{t('selectHint')}

-
-
- )} -
-
-
- ); -} diff --git a/web/src/pages/SandboxCases.tsx b/web/src/pages/SandboxCases.tsx new file mode 100644 index 000000000..3389c72c3 --- /dev/null +++ b/web/src/pages/SandboxCases.tsx @@ -0,0 +1,1033 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Tencent. All rights reserved. +// +// SandboxCases — the "examples/" dashboard. Replaces the old +// Examples.tsx with: +// * a two-column scenario → case navigator on the left +// * a Monaco editor + run bar + stdout/stderr on the right +// * a topology graph (control + data plane) underneath +// * a horizontal step timeline summarising the run +// +// The page reuses the existing TemplateDropdown (lifted out of the old +// page) so we don't have to redesign that piece. AI / LLM scenarios are +// not exposed (they're `hidden` on the Rust side and absent from the +// scenario registry). + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { + Boxes, + Check, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + Cpu, + Copy, + FileCode2, + FlaskConical, + FolderOpen, + Globe2, + GitBranch, + Inbox, + Layers, + Monitor, + Network, + PauseCircle, + Play, + RotateCcw, + Search, + Sparkles, + Terminal, + Timer, + TimerReset, + XCircle, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +import { api } from '@/lib/api'; +import { + clusterApi, + examplesApi, + templateApi, + type RunExampleBody, + type TemplateSummary, +} from '@/api/client'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { CodeEditor, type EditorLanguage } from '@/components/CodeEditor'; +import { TopologyGraph } from '@/components/TopologyGraph'; +import { StepTimeline } from '@/components/StepTimeline'; +import { + EXAMPLE_CATEGORIES, + EXAMPLE_SCENARIOS, + type ExampleCategoryId, + type ExampleScenario, + type ScenarioFile, +} from '@/data/exampleScenarios'; +import { cn, copyToClipboard } from '@/lib/utils'; + +// ─── i18n helpers ──────────────────────────────────────────────────────────── + +function categoryIcon(id: ExampleCategoryId): LucideIcon { + const meta = EXAMPLE_CATEGORIES.find((c) => c.id === id); + return meta?.icon ?? Boxes; +} + +// Icon lookup for the left-rail scenario header. Falls back to FlaskConical +// so that adding a new scenario without an icon stays renderable. +function scenarioIcon(s: ExampleScenario): LucideIcon { + return s.icon ?? FlaskConical; +} + +function languageToEditor(lang: string): EditorLanguage { + const l = (lang ?? '').toLowerCase(); + if (l === 'python') return 'python'; + if (l === 'go') return 'go'; + if (l === 'bash' || l === 'sh' || l === 'shell') return 'shell'; + if (l === 'javascript' || l === 'js') return 'javascript'; + if (l === 'typescript' || l === 'ts') return 'typescript'; + if (l === 'markdown' || l === 'md') return 'markdown'; + return 'python'; +} + +function languageLabel(lang: string, t: (k: string) => unknown): string { + const l = (lang ?? '').toLowerCase(); + if (l === 'python') return String(t('languages.python')); + if (l === 'go') return String(t('languages.go')); + if (l === 'bash' || l === 'sh' || l === 'shell') return String(t('languages.bash')); + if (l === 'javascript' || l === 'js') return String(t('languages.javascript')); + return lang; +} + +// ─── Template dropdown (lifted from old Examples.tsx) ─────────────────────── + +interface TemplateDropdownProps { + templates: TemplateSummary[]; + defaultTemplateId?: string; + value: string | undefined; + onChange: (id: string | undefined) => void; +} + +function TemplateDropdown({ + templates, + defaultTemplateId, + value, + onChange, +}: TemplateDropdownProps) { + const { t } = useTranslation('examples'); + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(''); + const ref = useRef(null); + const searchRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + useEffect(() => { + if (open) { + const id = setTimeout(() => searchRef.current?.focus(), 30); + return () => clearTimeout(id); + } else { + setFilter(''); + } + }, [open]); + + const isDefault = value === defaultTemplateId; + + const filtered = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) return templates; + return templates.filter( + (tpl) => + tpl.templateID.toLowerCase().includes(q) || + (tpl.instanceType ?? '').toLowerCase().includes(q) || + (tpl.status ?? '').toLowerCase().includes(q), + ); + }, [templates, filter]); + + const grouped = useMemo(() => { + const ready: TemplateSummary[] = []; + const building: TemplateSummary[] = []; + const other: TemplateSummary[] = []; + for (const tpl of filtered) { + const s = tpl.status.toLowerCase(); + if (s === 'ready') ready.push(tpl); + else if (s === 'building' || s === 'pending') building.push(tpl); + else other.push(tpl); + } + return { ready, building, other }; + }, [filtered]); + + return ( +
+ + + {open && ( +
+
+
+ + + +

+ {t('templateSelector.title')} +

+
+ + {filtered.length}/{templates.length} + +
+ + {templates.length > 4 && ( +
+
+ + setFilter(e.target.value)} + placeholder={t('templateSelector.searchPlaceholder')} + className={cn( + 'h-7 w-full rounded-md border border-border/60 bg-background pl-7 pr-2 text-xs', + 'placeholder:text-muted-foreground/60', + 'focus:outline-none focus:ring-1 focus:ring-primary/40', + )} + /> +
+
+ )} + +
+ {filtered.length === 0 ? ( +
+ +

{t('templateSelector.empty')}

+
+ ) : ( + <> + {(['ready', 'building', 'other'] as const).map((groupKey) => { + const items = grouped[groupKey]; + if (!items.length) return null; + return ( +
+

+ + {t(`templateSelector.group.${groupKey}`)} + · {items.length} +

+ {items.map((tpl) => { + const isSelected = tpl.templateID === value; + const statusLower = tpl.status.toLowerCase(); + return ( + + ); + })} +
+ ); + })} + + )} +
+ +
+ {t('templateSelector.hint')} +
+
+ )} +
+ ); +} + +// ─── Left rail: scenario + case list ───────────────────────────────────────── + +interface CaseRowProps { + scenario: ExampleScenario; + file: ScenarioFile; + selected: boolean; + onSelect: () => void; +} + +function CaseRow({ scenario, file, selected, onSelect }: CaseRowProps) { + const { t, i18n } = useTranslation('examples'); + const isZh = (i18n.language ?? 'en').toLowerCase().startsWith('zh'); + const title = isZh ? file.titleZh : file.titleEn; + const desc = isZh ? file.descriptionZh : file.descriptionEn; + + return ( + + ); +} + +interface ScenarioGroupProps { + scenario: ExampleScenario; + selectedFileId: string | null; + onSelect: (file: ScenarioFile) => void; +} + +function ScenarioGroup({ scenario, selectedFileId, onSelect }: ScenarioGroupProps) { + const { t, i18n } = useTranslation('examples'); + const isZh = (i18n.language ?? 'en').toLowerCase().startsWith('zh'); + const Icon = scenarioIcon(scenario); + const title = isZh ? scenario.titleZh : scenario.titleEn; + + // Auto-expand a group when it contains the selected case, otherwise show + // it collapsed to keep the left rail scannable. + const hasSelected = scenario.files.some((f) => `${scenario.id}:${f.id}` === selectedFileId); + const [open, setOpen] = useState(hasSelected); + useEffect(() => { + if (hasSelected) setOpen(true); + }, [hasSelected]); + + return ( +
+ + {open && ( +
+ {scenario.files.map((f) => { + const fid = `${scenario.id}:${f.id}`; + return ( + onSelect(f)} + /> + ); + })} +
+ )} +
+ ); +} + +// ─── Run output panel ─────────────────────────────────────────────────────── + +function extractNoVncUrl(text: string): string | null { + const m = text.match(/https:\/\/[^\s]+\/novnc\/[^\s]+/); + return m ? m[0] : null; +} + +function RunOutput({ stdout, stderr, exitCode, success, elapsedMs, ranEdited }: { + stdout: string; + stderr: string; + exitCode: number; + success: boolean; + elapsedMs: number; + ranEdited: boolean; +}) { + const { t } = useTranslation('examples'); + const novncUrl = extractNoVncUrl(stdout); + return ( +
+
+ {success ? ( + + + {t('output.completed')} · exit 0 + + ) : ( + + + {t('output.failed')} · exit {exitCode} + + )} + + + {t('output.elapsed', { ms: elapsedMs } as unknown as Record)} + + {ranEdited && ( + + {t('output.runEdited')} + + )} + {!ranEdited && ( + + {t('output.runOnDisk')} + + )} +
+ + {novncUrl && ( +
+
+ + Sandbox desktop preview (noVNC) +
+
+