diff --git a/Cargo.lock b/Cargo.lock index 9e3630f1..d6d97334 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -854,14 +854,38 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -877,13 +901,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -909,6 +944,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1080,6 +1146,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "embed-resource" version = "3.0.8" @@ -1549,6 +1621,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1596,6 +1678,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "gio" version = "0.18.4" @@ -1907,7 +2000,9 @@ dependencies = [ "axum", "chrono", "dirs", + "gethostname", "hk-core", + "local-ip-address", "mime_guess", "parking_lot", "rand 0.9.4", @@ -2569,6 +2664,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "local-ip-address" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3" +dependencies = [ + "libc", + "neli", + "windows-sys 0.61.2", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -2774,6 +2880,35 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "neli" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" +dependencies = [ + "bitflags 2.11.0", + "byteorder", + "derive_builder", + "getset", + "libc", + "log", + "neli-proc-macros", + "parking_lot", +] + +[[package]] +name = "neli-proc-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" +dependencies = [ + "either", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -4403,7 +4538,7 @@ version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", diff --git a/README.md b/README.md index a167ee61..393725fa 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,8 @@ Already installed? Open **Settings → Check for Updates** to upgrade in-app. Then open `http://localhost:7070` in your local browser. Keep the SSH session running while you use HarnessKit. +> **Tip:** Managing several remote nodes? Start each with `hk serve --name +
Manual download — if you prefer not to use the install script, or your machine doesn't have curl diff --git a/README.zh-CN.md b/README.zh-CN.md index aaff7ec9..b20be8df 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -284,6 +284,8 @@ HarnessKit Web UI running at http://127.0.0.1:7070 然后在本地浏览器打开 `http://localhost:7070`。使用 HarnessKit 期间请保持该 SSH 会话开启。 +> **提示:** 管理多个远程节点时,用 `hk serve --name <标签>` 启动(如 `--name my-macbook`)。标签会显示在侧边栏和浏览器标签页标题里,多个 tab 一眼就能区分。默认取机器主机名。 +
手动下载 —— 如果你不想用安装脚本,或机器上没有 curl diff --git a/crates/hk-cli/src/main.rs b/crates/hk-cli/src/main.rs index 66293534..41276919 100644 --- a/crates/hk-cli/src/main.rs +++ b/crates/hk-cli/src/main.rs @@ -89,13 +89,18 @@ enum Commands { /// Access token (auto-generated for non-localhost binds if omitted) #[arg(long)] token: Option, + + /// Node label shown in the web UI (defaults to the machine hostname). + /// Useful when opening multiple tabs against different remote nodes. + #[arg(long)] + name: Option, }, } fn main() -> Result<()> { let cli = Cli::parse(); - if let Commands::Serve { port, host, token } = cli.command { + if let Commands::Serve { port, host, token, name } = cli.command { let effective_token = if host != "127.0.0.1" { Some(token.unwrap_or_else(|| { use rand::Rng; @@ -111,6 +116,7 @@ fn main() -> Result<()> { port, host, token: effective_token, + name, }))?; return Ok(()); } diff --git a/crates/hk-web/Cargo.toml b/crates/hk-web/Cargo.toml index 9404ef4d..031890c8 100644 --- a/crates/hk-web/Cargo.toml +++ b/crates/hk-web/Cargo.toml @@ -20,6 +20,8 @@ rand = "0.9" tempfile = "3" mime_guess = "2" uuid.workspace = true +gethostname = "1" +local-ip-address = "0.6" [dev-dependencies] tokio = { version = "1", features = ["full", "test-util"] } diff --git a/crates/hk-web/src/handlers/mod.rs b/crates/hk-web/src/handlers/mod.rs index 89447507..e7346c87 100644 --- a/crates/hk-web/src/handlers/mod.rs +++ b/crates/hk-web/src/handlers/mod.rs @@ -5,6 +5,7 @@ pub mod install; pub mod kits; pub mod marketplace; pub mod projects; +pub mod server; pub mod settings; use hk_core::store::Store; diff --git a/crates/hk-web/src/handlers/server.rs b/crates/hk-web/src/handlers/server.rs new file mode 100644 index 00000000..f1fbeb0f --- /dev/null +++ b/crates/hk-web/src/handlers/server.rs @@ -0,0 +1,18 @@ +use axum::extract::State; +use axum::Json; +use serde::Serialize; + +use crate::state::WebState; + +/// Identity of the node serving this UI. Lets the frontend distinguish multiple +/// browser tabs that each point at a different remote `hk serve` instance. +#[derive(Serialize)] +pub struct ServerInfo { + pub node_name: String, +} + +pub async fn server_info(State(state): State) -> Json { + Json(ServerInfo { + node_name: state.node_name.clone(), + }) +} diff --git a/crates/hk-web/src/lib.rs b/crates/hk-web/src/lib.rs index 285b735d..5ee11a8f 100644 --- a/crates/hk-web/src/lib.rs +++ b/crates/hk-web/src/lib.rs @@ -7,13 +7,43 @@ use hk_core::{adapter, store::Store}; use parking_lot::Mutex; use state::WebState; use std::collections::HashMap; -use std::net::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; pub struct ServeOptions { pub port: u16, pub host: String, pub token: Option, + /// Optional node label override; falls back to the machine hostname. + pub name: Option, +} + +/// Resolve the display name for this node: the explicit `--name` if given, +/// otherwise the machine hostname (best-effort; "unknown-host" if unavailable). +fn resolve_node_name(name: Option) -> String { + name.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| { + gethostname::gethostname() + .into_string() + .unwrap_or_else(|_| "unknown-host".to_string()) + }) +} + +/// Non-loopback, non-link-local IPv4 addresses of this machine, used to print +/// reachable URLs when bound to 0.0.0.0 (which itself isn't a usable address). +fn reachable_ipv4_addrs() -> Vec { + let Ok(ifaces) = local_ip_address::list_afinet_netifas() else { + return Vec::new(); + }; + let mut addrs: Vec = ifaces + .into_iter() + .filter_map(|(_, ip)| match ip { + IpAddr::V4(v4) if !v4.is_loopback() && !v4.is_link_local() => Some(v4), + _ => None, + }) + .collect(); + addrs.sort(); + addrs.dedup(); + addrs } pub async fn serve(options: ServeOptions) -> anyhow::Result<()> { @@ -23,19 +53,49 @@ pub async fn serve(options: ServeOptions) -> anyhow::Result<()> { std::fs::create_dir_all(&data_dir)?; let store = Store::open(&data_dir.join("metadata.db"))?; + let node_name = resolve_node_name(options.name.clone()); + let state = WebState { store: Arc::new(Mutex::new(store)), adapters: Arc::new(adapter::all_adapters()), pending_clones: Arc::new(Mutex::new(HashMap::new())), token: options.token.clone(), + node_name: node_name.clone(), }; let app = router::build_router(state); let addr: SocketAddr = format!("{}:{}", options.host, options.port).parse()?; - eprintln!("HarnessKit Web UI running at http://{addr}"); - if options.host == "127.0.0.1" { - eprintln!("Access via SSH tunnel: ssh -L {p}:localhost:{p} your-server", p = options.port); + // When auth is enabled (non-localhost binds), embed the token in the URL so + // the user can paste a single link and be logged in — the frontend reads it + // and strips it from the address bar. Mirrors Jupyter's `?token=` flow. + let token_query = options + .token + .as_deref() + .map(|t| format!("/?token={t}")) + .unwrap_or_default(); + + match options.host.as_str() { + "127.0.0.1" => { + eprintln!("HarnessKit Web UI [{node_name}] running at http://{addr}"); + eprintln!("Access via SSH tunnel: ssh -L {p}:localhost:{p} your-server", p = options.port); + } + // 0.0.0.0 binds every interface but is not itself a reachable address, + // so don't present it as a clickable URL — print the actual LAN IPs. + "0.0.0.0" => { + eprintln!("HarnessKit Web UI [{node_name}] listening on all interfaces (port {})", options.port); + let addrs = reachable_ipv4_addrs(); + if addrs.is_empty() { + eprintln!("Use this machine's LAN IP at port {}", options.port); + } else { + for ip in addrs { + eprintln!("Reachable on your network at http://{ip}:{}{token_query}", options.port); + } + } + } + _ => { + eprintln!("HarnessKit Web UI [{node_name}] running at http://{addr}{token_query}"); + } } if let Some(token) = &options.token { eprintln!("Auth token: {token}"); diff --git a/crates/hk-web/src/router.rs b/crates/hk-web/src/router.rs index 211ef818..d7773cf6 100644 --- a/crates/hk-web/src/router.rs +++ b/crates/hk-web/src/router.rs @@ -73,6 +73,8 @@ pub fn build_router(state: WebState) -> Router { let api = Router::new() // Health .route("/api/health", get(health)) + // Node identity (web mode multi-node distinction) + .route("/api/server_info", post(handlers::server::server_info)) // Extensions .route("/api/list_extensions", post(handlers::extensions::list_extensions)) .route("/api/toggle_extension", post(handlers::extensions::toggle_extension)) diff --git a/crates/hk-web/src/state.rs b/crates/hk-web/src/state.rs index bf89610a..e7651520 100644 --- a/crates/hk-web/src/state.rs +++ b/crates/hk-web/src/state.rs @@ -18,4 +18,8 @@ pub struct WebState { pub pending_clones: Arc>>, /// None means no auth required (localhost-only mode) pub token: Option, + /// Human-readable name for this node, shown in the web UI so multiple + /// tabs pointing at different remote hosts are distinguishable. Defaults + /// to the machine hostname; overridable via `hk serve --name`. + pub node_name: String, } diff --git a/crates/hk-web/tests/api_test.rs b/crates/hk-web/tests/api_test.rs index 5a1f9beb..601c9d21 100644 --- a/crates/hk-web/tests/api_test.rs +++ b/crates/hk-web/tests/api_test.rs @@ -17,6 +17,7 @@ fn test_state() -> (WebState, tempfile::TempDir) { adapters: Arc::new(adapter::all_adapters()), pending_clones: Arc::new(Mutex::new(HashMap::new())), token: None, + node_name: "test-node".to_string(), }; (state, tmp) } @@ -34,6 +35,29 @@ async fn health_returns_ok() { assert_eq!(response.status(), StatusCode::OK); } +#[tokio::test] +async fn server_info_returns_node_name() { + let (state, _tmp) = test_state(); + let app = hk_web::router::build_router(state); + + let response = app + .oneshot( + Request::post("/api/server_info") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["node_name"], "test-node"); +} + #[tokio::test] async fn list_extensions_returns_array() { let (state, _tmp) = test_state(); diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 64ce87ff..a4a3c898 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -11,6 +11,7 @@ import { import { useTranslation } from "react-i18next"; import { NavLink } from "react-router-dom"; import { isDesktop } from "@/lib/transport"; +import { useServerInfo } from "@/lib/use-server-info"; import { ScopeSwitcher } from "./scope-switcher"; import { UpdateCard } from "./update-card"; import { WebUpdateCard } from "./web-update-card"; @@ -71,6 +72,7 @@ function SidebarLink({ export function Sidebar() { const { t } = useTranslation("navigation"); + const serverInfo = useServerInfo(); return (