From ad9fd3246b00c13b74a613ad2bf266e3c242b9d2 Mon Sep 17 00:00:00 2001 From: liqilong Date: Mon, 25 May 2026 14:36:14 +0800 Subject: [PATCH 1/3] fea: support PROBING_BASE_PATH for reverse proxy deployment When probing is deployed behind a reverse proxy with a sub-path prefix (e.g. /proxy/task-123), set PROBING_BASE_PATH to make all frontend links, API calls, and resource paths work correctly through the proxy. Server-side: - Read PROBING_BASE_PATH env var and inject window.__PROBING_BASE_PATH__ and a fetch interceptor into index.html - Rewrite src/href attributes in HTML to include base path prefix - Skip PROBING_BASE_PATH in sync_env_settings to avoid SQL parse error Frontend (WASM): - Add base_path module to read window.__PROBING_BASE_PATH__ via wasm-bindgen - Configure Dioxus WebHistory with prefix for client-side routing - Use with_base() for API URLs, sidebar logo, and file links --- probing/server/src/asset.rs | 68 ++++++++++++++++++++++++++-- probing/server/src/server/mod.rs | 1 + web/Cargo.toml | 1 + web/index.html | 7 ++- web/src/api/mod.rs | 2 +- web/src/components/callstack_view.rs | 2 +- web/src/components/sidebar/mod.rs | 2 +- web/src/main.rs | 11 ++++- web/src/utils/base_path.rs | 22 +++++++++ web/src/utils/mod.rs | 1 + 10 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 web/src/utils/base_path.rs diff --git a/probing/server/src/asset.rs b/probing/server/src/asset.rs index 1f3f6a9..a6231a2 100644 --- a/probing/server/src/asset.rs +++ b/probing/server/src/asset.rs @@ -5,6 +5,14 @@ use axum::response::IntoResponse; use bytes::Bytes; use include_dir::include_dir; use include_dir::Dir; +use once_cell::sync::Lazy; + +static BASE_PATH: Lazy = Lazy::new(|| { + env::var("PROBING_BASE_PATH") + .unwrap_or_default() + .trim_end_matches('/') + .to_string() +}); static ASSET: Dir = include_dir!("web/dist"); @@ -49,7 +57,59 @@ fn get_content_type(path: &str) -> &'static str { /// Handler for index page pub async fn index() -> impl IntoResponse { - ([(header::CONTENT_TYPE, "text/html")], get("/index.html")) + let mut html = String::from_utf8_lossy(&get("/index.html")).to_string(); + let base_path = BASE_PATH.clone(); + if !base_path.is_empty() { + // Inject JS global for the frontend runtime + let inject = format!( + r#""#, + base_path + ); + // Intercept fetch to rewrite WASM URLs with base path prefix + let fetch_intercept = [ + "", + ].concat(); + let replacement = format!("{}{}", inject, fetch_intercept); + html = html.replacen("", &replacement, 1); + + // Rewrite absolute paths in HTML to include base path prefix + html = rewrite_html_paths(&html, &base_path); + } + ([(header::CONTENT_TYPE, "text/html")], html) +} + +/// Rewrite absolute paths (src="/...", href="/...") in HTML to include base_path prefix. +/// Skips external URLs (//, http://, https://). +fn rewrite_html_paths(html: &str, base_path: &str) -> String { + let mut result = html.to_string(); + for attr in &["src", "href"] { + let mut offset = 0; + let pattern = format!("{}=\"/", attr); + while let Some(pos) = result[offset..].find(&pattern) { + let global_pos = offset + pos; + let value_start = global_pos + pattern.len(); // position right after the leading / + // Find the closing quote to get the full attribute value + let value_end = result[value_start..].find('"').map(|i| value_start + i).unwrap_or(result.len()); + let value = &result[value_start..value_end]; + // Skip protocol-relative URLs: //cdn.example.com/... + if value.starts_with('/') { + offset = global_pos + 1; + continue; + } + // Skip external URLs: http:// or https:// + if value.starts_with("http://") || value.starts_with("https://") { + offset = global_pos + 1; + continue; + } + // Insert base_path right after the leading / + // e.g. href="/./assets/foo.js" -> href="/proxy/task-123/./assets/foo.js" + result.insert_str(value_start, &format!("{}/", base_path.trim_start_matches('/'))); + offset = value_start + base_path.len() + 1; + } + } + result } /// Handler for serving static files @@ -59,6 +119,8 @@ pub async fn static_files(uri: Uri) -> Result { return Err(StatusCode::NOT_FOUND); } - log::debug!("serving file: {path}"); - Ok(([(header::CONTENT_TYPE, get_content_type(path))], get(path))) + let content_type = get_content_type(path); + let data = get(path); + + Ok(([(header::CONTENT_TYPE, content_type)], data)) } diff --git a/probing/server/src/server/mod.rs b/probing/server/src/server/mod.rs index 823f434..be74ae2 100644 --- a/probing/server/src/server/mod.rs +++ b/probing/server/src/server/mod.rs @@ -201,6 +201,7 @@ pub fn sync_env_settings() { "PROBING_ASSETS_ROOT", "PROBING_SERVER_ADDRPATTERN", "PROBING_AUTH_TOKEN", // Skip syncing the auth token for security reasons + "PROBING_BASE_PATH", // Used by server at startup, not a runtime setting ] .contains(&k.as_str()) }) diff --git a/web/Cargo.toml b/web/Cargo.toml index a17c9b4..069ddd5 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -26,6 +26,7 @@ thiserror = "1.0" # Logging log = "0.4" web-sys = { version = "0.3", features = ["Window", "Storage", "HtmlElement"] } +wasm-bindgen = "0.2" # Icons icondata = { version = "0.5.0", default-features = false, features = [ diff --git a/web/index.html b/web/index.html index a48e6ff..bb29d40 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,12 @@ Probing Web Interface - + + diff --git a/web/src/api/mod.rs b/web/src/api/mod.rs index dd00dff..de507c2 100644 --- a/web/src/api/mod.rs +++ b/web/src/api/mod.rs @@ -19,7 +19,7 @@ impl ApiClient { /// Build API URL fn build_url(path: &str) -> Result { - Ok(format!("{}{}", Self::get_origin()?, path)) + Ok(format!("{}{}", Self::get_origin()?, crate::utils::base_path::with_base(path))) } /// Send GET request diff --git a/web/src/components/callstack_view.rs b/web/src/components/callstack_view.rs index de05774..325dbe2 100644 --- a/web/src/components/callstack_view.rs +++ b/web/src/components/callstack_view.rs @@ -23,7 +23,7 @@ pub fn CallStackView(callstack: CallFrame) -> Element { } } CallFrame::PyFrame { file, func, lineno, locals } => { - let url = format!("/apis/files?path={}", file); + let url = crate::utils::base_path::with_base(&format!("/apis/files?path={}", file)); let key = format!("{func} @ {file}: {lineno}"); rsx! { CollapsibleCardWithIcon { diff --git a/web/src/components/sidebar/mod.rs b/web/src/components/sidebar/mod.rs index 65faf5b..c4a87fd 100644 --- a/web/src/components/sidebar/mod.rs +++ b/web/src/components/sidebar/mod.rs @@ -66,7 +66,7 @@ pub fn Sidebar() -> Element { Link { to: Route::DashboardPage {}, class: "flex items-center gap-2", - img { src: "/assets/logo.svg", alt: "Probing", class: "w-7 h-7 flex-shrink-0" } + img { src: "{crate::utils::base_path::with_base(\"/assets/logo.svg\")}", alt: "Probing", class: "w-7 h-7 flex-shrink-0" } span { class: "{brand}", "Probing" } } } diff --git a/web/src/main.rs b/web/src/main.rs index 7ad29f3..1157dc6 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -9,7 +9,16 @@ mod state; mod utils; use app::App; +use utils::base_path::base_path; fn main() { - launch(App); + let base = base_path(); + if base.is_empty() { + launch(App); + } else { + let prefix = Some(base); + let config = dioxus_web::Config::new() + .history(std::rc::Rc::new(dioxus_web::WebHistory::new(prefix, true))); + dioxus_web::launch::launch_cfg(App, config); + } } diff --git a/web/src/utils/base_path.rs b/web/src/utils/base_path.rs new file mode 100644 index 0000000..f1c7d05 --- /dev/null +++ b/web/src/utils/base_path.rs @@ -0,0 +1,22 @@ +/// Read the base path injected by the server into index.html. +/// +/// When probing is deployed behind a reverse proxy with a sub-path prefix +/// (e.g. `/proxy/task-123`), the server sets `window.__PROBING_BASE_PATH__` +/// so the frontend can build correct URLs. +pub fn base_path() -> String { + #[wasm_bindgen::prelude::wasm_bindgen(inline_js = r#" + export function get_base_path() { + return window.__PROBING_BASE_PATH__ || ""; + } + "#)] + extern "C" { + fn get_base_path() -> String; + } + get_base_path() +} + +/// Prepend the base path to an absolute path (e.g. `/apis/nodes` → `/proxy/xxx/apis/nodes`). +pub fn with_base(path: &str) -> String { + let base = base_path(); + format!("{}{}", base.trim_end_matches('/'), path) +} diff --git a/web/src/utils/mod.rs b/web/src/utils/mod.rs index 117ee7f..855f879 100644 --- a/web/src/utils/mod.rs +++ b/web/src/utils/mod.rs @@ -1,2 +1,3 @@ +pub mod base_path; pub mod error; pub mod tracing_viewer; From 52d0b89bdf3c5beec65394ec03ca0fb4b1193a5a Mon Sep 17 00:00:00 2001 From: liqilong Date: Mon, 25 May 2026 15:20:02 +0800 Subject: [PATCH 2/3] style: fix rustfmt formatting --- probing/server/src/asset.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/probing/server/src/asset.rs b/probing/server/src/asset.rs index a6231a2..49098d9 100644 --- a/probing/server/src/asset.rs +++ b/probing/server/src/asset.rs @@ -90,8 +90,11 @@ fn rewrite_html_paths(html: &str, base_path: &str) -> String { while let Some(pos) = result[offset..].find(&pattern) { let global_pos = offset + pos; let value_start = global_pos + pattern.len(); // position right after the leading / - // Find the closing quote to get the full attribute value - let value_end = result[value_start..].find('"').map(|i| value_start + i).unwrap_or(result.len()); + // Find the closing quote to get the full attribute value + let value_end = result[value_start..] + .find('"') + .map(|i| value_start + i) + .unwrap_or(result.len()); let value = &result[value_start..value_end]; // Skip protocol-relative URLs: //cdn.example.com/... if value.starts_with('/') { @@ -105,7 +108,10 @@ fn rewrite_html_paths(html: &str, base_path: &str) -> String { } // Insert base_path right after the leading / // e.g. href="/./assets/foo.js" -> href="/proxy/task-123/./assets/foo.js" - result.insert_str(value_start, &format!("{}/", base_path.trim_start_matches('/'))); + result.insert_str( + value_start, + &format!("{}/", base_path.trim_start_matches('/')), + ); offset = value_start + base_path.len() + 1; } } From 80d2898ac2bfd29a0fdbacac71248d5a9f4a1347 Mon Sep 17 00:00:00 2001 From: liqilong Date: Wed, 27 May 2026 11:57:43 +0800 Subject: [PATCH 3/3] fix: handle empty/error API responses and add publish toggle to CI - Frontend: handle Nil/Error/TimeSeries responses in execute_query instead of throwing generic "DataFrame is Expected" error - CI: add publish input to workflow_dispatch, deploy job only runs on release or when manually toggled --- .github/workflows/pypi.yml | 6 ++++++ web/src/api/analytics.rs | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index db2f5da..07fd244 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -12,6 +12,11 @@ on: release: types: [published] workflow_dispatch: + inputs: + publish: + description: 'Publish to PyPI' + type: boolean + default: false permissions: contents: read @@ -110,6 +115,7 @@ jobs: name: Publish to PyPI runs-on: ubuntu-latest needs: [linux, macos, sdist] + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.publish == true) steps: - uses: actions/download-artifact@v4 with: diff --git a/web/src/api/analytics.rs b/web/src/api/analytics.rs index ff02394..a1f33a6 100644 --- a/web/src/api/analytics.rs +++ b/web/src/api/analytics.rs @@ -20,7 +20,9 @@ impl ApiClient { match msg.payload { QueryDataFormat::DataFrame(dataframe) => Ok(dataframe), - _ => Err(AppError::Api("Bad Response: DataFrame is Expected.".to_string())) + QueryDataFormat::Nil => Ok(DataFrame { names: vec![], cols: vec![], size: 0 }), + QueryDataFormat::Error(err) => Err(AppError::Api(err.message)), + QueryDataFormat::TimeSeries(_) => Err(AppError::Api("TimeSeries format not supported".to_string())), } }