diff --git a/.gitignore b/.gitignore index 321130a7..77f37328 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ node_modules/ .claude/settings.local.json __pycache__ .planning/ +/vmm/src/console_v1.html diff --git a/vmm/build.rs b/vmm/build.rs new file mode 100644 index 00000000..64eb9025 --- /dev/null +++ b/vmm/build.rs @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::Command, + time::SystemTime, +}; + +fn main() { + if let Err(err) = build_console() { + panic!("failed to build vmm console: {err}"); + } +} + +fn build_console() -> Result<(), Box> { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); + let ui_dir = manifest_dir.join("ui"); + let out_dir = PathBuf::from(env::var("OUT_DIR")?); + let output = out_dir.join("console_v1.html"); + + emit_rerun_if_changed(&manifest_dir.join("build.rs"))?; + emit_rerun_tree(&ui_dir)?; + + ensure_command("node", &["--version"], "Node.js")?; + ensure_command( + npm_cmd(), + &["--version"], + "npm (normally bundled with Node.js)", + )?; + + if should_run_npm_ci(&ui_dir)? { + run( + npm_cmd(), + &["ci"], + &ui_dir, + &[], + "Install VMM UI dependencies with `npm ci`", + )?; + } + + let output_str = output + .to_str() + .ok_or("OUT_DIR path contains non-UTF-8 characters")?; + run( + "node", + &["build.mjs"], + &ui_dir, + &[("DSTACK_UI_OUT", output_str)], + "Build VMM UI", + )?; + + if !output.exists() { + return Err(format!( + "UI build succeeded but {} was not created", + output.display() + ) + .into()); + } + + Ok(()) +} + +fn emit_rerun_tree(path: &Path) -> Result<(), Box> { + if !path.exists() { + return Ok(()); + } + println!("cargo:rerun-if-changed={}", path.display()); + for entry in fs::read_dir(path)? { + let entry = entry?; + let child = entry.path(); + if entry.file_type()?.is_dir() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if matches!(name.as_ref(), "node_modules" | "build" | "dist") { + continue; + } + emit_rerun_tree(&child)?; + } else { + emit_rerun_if_changed(&child)?; + } + } + Ok(()) +} + +fn emit_rerun_if_changed(path: &Path) -> Result<(), Box> { + println!("cargo:rerun-if-changed={}", path.display()); + Ok(()) +} + +fn ensure_command( + program: &str, + args: &[&str], + display_name: &str, +) -> Result<(), Box> { + match Command::new(program).args(args).output() { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(format!( + "{display_name} is required to build vmm/ui. Please install it first. `{program} {}` failed. stdout: {} stderr: {}", + args.join(" "), + stdout.trim(), + stderr.trim(), + ) + .into()) + } + Err(err) => Err(format!( + "{display_name} is required to build vmm/ui. Please install Node.js and npm first: {err}" + ) + .into()), + } +} + +fn should_run_npm_ci(ui_dir: &Path) -> Result> { + let package_json = ui_dir.join("package.json"); + let package_lock = ui_dir.join("package-lock.json"); + let marker = ui_dir.join("node_modules/.package-lock.json"); + + if !marker.exists() { + return Ok(true); + } + + let marker_time = modified_time(&marker)?; + Ok(modified_time(&package_json)? > marker_time || modified_time(&package_lock)? > marker_time) +} + +fn modified_time(path: &Path) -> Result> { + Ok(fs::metadata(path)?.modified()?) +} + +fn run( + program: &str, + args: &[&str], + cwd: &Path, + envs: &[(&str, &str)], + what: &str, +) -> Result<(), Box> { + let mut command = Command::new(program); + command + .current_dir(cwd) + .args(args) + .envs(envs.iter().copied()); + let status = command.status()?; + if !status.success() { + return Err(format!("{what} failed with exit status {status}").into()); + } + Ok(()) +} + +fn npm_cmd() -> &'static str { + if cfg!(windows) { + "npm.cmd" + } else { + "npm" + } +} diff --git a/vmm/src/console_v1.html b/vmm/src/console_v1.html deleted file mode 100644 index 7951f5d6..00000000 --- a/vmm/src/console_v1.html +++ /dev/null @@ -1,17658 +0,0 @@ - - - - - - - - {{TITLE}} - - - - -
- - - - - diff --git a/vmm/src/main_routes.rs b/vmm/src/main_routes.rs index 500480db..c74e7e63 100644 --- a/vmm/src/main_routes.rs +++ b/vmm/src/main_routes.rs @@ -16,6 +16,8 @@ use std::time::Duration; use tokio::time::timeout; use tracing::{debug, info}; +const CONSOLE_V1: &str = include_str!(concat!(env!("OUT_DIR"), "/console_v1.html")); + macro_rules! file_or_include_str { ($path:literal) => { fs::metadata($path) @@ -42,7 +44,7 @@ fn render_console(html: String, app: &State) -> (ContentType, String) { #[get("/")] async fn index(app: &State) -> (ContentType, String) { - render_console(file_or_include_str!("console_v1.html"), app) + render_console(CONSOLE_V1.to_string(), app) } #[get("/v1")] diff --git a/vmm/ui/README.md b/vmm/ui/README.md index 55476d20..8ba28fb0 100644 --- a/vmm/ui/README.md +++ b/vmm/ui/README.md @@ -5,18 +5,17 @@ This directory contains the source for the Vue-based VM management console. ## Usage ```bash -# Install dev dependencies (installs protobufjs CLI) -npm install - -# Build the console once -npm run build +# cargo build will run the UI build automatically +cargo build -p dstack-vmm # Build continuously (writes console_v1 on changes) +npm install npm run watch ``` -The build step generates a single-file HTML artifact at `../src/console_v1.html` -which is served by `dstack-vmm` under `/` and `/v1`. The previous +`dstack-vmm` now builds the single-file HTML artifact from `build.rs` and writes it +to Cargo's `OUT_DIR`. This requires Node.js and npm to be installed; if they are +missing, the Rust build will fail with an installation hint. The previous `console_v0.html` remains untouched so the legacy UI stays available under `/v0`. The UI codebase is written in TypeScript. The build pipeline performs three steps: diff --git a/vmm/ui/build.mjs b/vmm/ui/build.mjs index b1193a45..bd6f6446 100644 --- a/vmm/ui/build.mjs +++ b/vmm/ui/build.mjs @@ -204,7 +204,10 @@ async function build({ watch = false } = {}) { const distFile = path.join(DIST_DIR, 'index.html'); await fs.writeFile(distFile, html); - const targetFile = path.resolve(ROOT, '../src/console_v1.html'); + const targetFile = process.env.DSTACK_UI_OUT + ? path.resolve(process.env.DSTACK_UI_OUT) + : path.resolve(ROOT, '../src/console_v1.html'); + await fs.mkdir(path.dirname(targetFile), { recursive: true }); await fs.writeFile(targetFile, html); if (watch) { @@ -239,6 +242,7 @@ async function build({ watch = false } = {}) { ]); await fs.writeFile(distFile, rehtml); const spdxHeader = '\n'; + await fs.mkdir(path.dirname(targetFile), { recursive: true }); await fs.writeFile(targetFile, spdxHeader + rehtml); console.log('Rebuilt console'); } catch (err) {