diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1af0fbd9..e75a9f75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,9 +36,8 @@ jobs: sudo mv /tmp/cross /usr/local/bin/cross - name: Build shell: bash - env: - BUILD_FRONTEND: 1 run: | + make build-frontend cross build --release --target=${{ matrix.target }} --bin=bendsql - name: Publish Binary uses: ./.github/actions/publish_binary @@ -71,9 +70,8 @@ jobs: targets: ${{ matrix.target }} - name: Build shell: bash - env: - BUILD_FRONTEND: 1 run: | + make build-frontend cargo build --release --target=${{ matrix.target }} --bin=bendsql - name: Publish Binary uses: ./.github/actions/publish_binary diff --git a/cli/build.rs b/cli/build.rs index 6b017cf6..5ed841de 100644 --- a/cli/build.rs +++ b/cli/build.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{env, error::Error, process::Command}; +use std::{env, error::Error}; use vergen::EmitBuilder; fn main() -> Result<(), Box> { @@ -29,52 +29,5 @@ fn main() -> Result<(), Box> { println!("cargo:rustc-env=BENDSQL_BUILD_INFO={}", info); }); - if env::var("BUILD_FRONTEND").is_ok() { - println!("cargo:warning=Start to build frontend dir via env BUILD_FRONTEND."); - let cwd = env::current_dir().expect("Failed to get current directory"); - println!("cargo:warning=Current Dir {:?}.", cwd.display()); - - env::set_current_dir("../frontend").expect("Failed to change directory to ../frontend"); - // Clean old frontend directory - let _ = Command::new("rm") - .arg("-rf") - .arg("../cli/frontend") - .status() - .expect("Failed to remove old frontend directory"); - - // Mkdir new dir - let _ = Command::new("mkdir") - .arg("-p") - .arg("../cli/frontend") - .status() - .expect("Failed to create frontend directory"); - - let _ = Command::new("yarn") - .arg("config") - .arg("set") - .arg("network-timeout") - .arg("600000") - .status() - .expect("Failed to set Yarn network timeout"); - - let _ = Command::new("yarn") - .arg("install") - .status() - .expect("Yarn install failed"); - - let _ = Command::new("yarn") - .arg("build") - .status() - .expect("Yarn build failed"); - - // 移动构建结果 - let _ = Command::new("mv") - .arg("build") - .arg("../cli/frontend/") - .status() - .expect("Failed to move build directory"); - - env::set_current_dir(cwd).unwrap(); - } Ok(()) } diff --git a/cli/src/config.rs b/cli/src/config.rs index 44f80975..fbf1403f 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -27,6 +27,8 @@ pub struct Config { pub connection: ConnectionConfig, #[serde(default)] pub settings: SettingsConfig, + #[serde(default)] + pub server: ServerConfig, } #[derive(Clone, Debug, Deserialize, Default)] @@ -99,6 +101,10 @@ pub struct Settings { pub multi_line: bool, /// whether replace '\n' with '\\n', default true. pub replace_newline: bool, + + pub bind_address: String, + pub bind_port: u16, + pub auto_open_browser: bool, } #[derive(ValueEnum, Clone, Debug, PartialEq, Deserialize)] @@ -135,7 +141,9 @@ impl TryFrom<&str> for TimeOption { } impl Settings { - pub fn merge_config(&mut self, cfg: SettingsConfig) { + pub fn merge_config(&mut self, c: &Config) { + let cfg = c.settings.clone(); + self.display_pretty_sql = cfg.display_pretty_sql.unwrap_or(self.display_pretty_sql); self.prompt = cfg.prompt.unwrap_or_else(|| self.prompt.clone()); self.progress_color = cfg @@ -152,6 +160,9 @@ impl Settings { self.max_width = cfg.max_width.unwrap_or(self.max_width); self.max_col_width = cfg.max_col_width.unwrap_or(self.max_col_width); self.max_display_rows = cfg.max_display_rows.unwrap_or(self.max_display_rows); + self.auto_open_browser = c.server.auto_open_browser; + self.bind_address.clone_from(&c.server.bind_address); + self.bind_port = c.server.bind_port; } pub fn inject_ctrl_cmd(&mut self, cmd_name: &str, cmd_value: &str) -> Result<()> { @@ -203,6 +214,14 @@ pub struct ConnectionConfig { pub args: BTreeMap, } +#[derive(Clone, Debug, Deserialize)] +#[serde(default)] +pub struct ServerConfig { + pub bind_address: String, + pub bind_port: u16, + pub auto_open_browser: bool, +} + impl Config { pub fn load() -> Self { let paths = [ @@ -251,6 +270,9 @@ impl Default for Settings { time: None, multi_line: true, replace_newline: true, + auto_open_browser: false, + bind_address: "127.0.0.1".to_string(), + bind_port: 8080, } } } @@ -267,3 +289,13 @@ impl Default for ConnectionConfig { } } } + +impl Default for ServerConfig { + fn default() -> Self { + Self { + bind_address: "127.0.0.1".to_string(), + bind_port: 8080, + auto_open_browser: true, + } + } +} diff --git a/cli/src/display.rs b/cli/src/display.rs index 95ba60f2..ed2641ba 100644 --- a/cli/src/display.rs +++ b/cli/src/display.rs @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::collections::HashSet; use std::fmt::Write; +use std::{collections::HashSet, env}; use anyhow::{anyhow, Result}; use comfy_table::{Cell, CellAlignment, Table}; @@ -28,7 +28,7 @@ use crate::{ ast::{format_query, highlight_query}, config::{ExpandMode, OutputFormat, OutputQuoteStyle, Settings}, session::QueryKind, - web::start_server_and_open_browser, + web::set_data, }; #[async_trait::async_trait] @@ -98,6 +98,32 @@ impl<'a> FormatDisplay<'a> { } } + async fn display_graphical(&mut self, rows: &[Row]) -> Result<()> { + let mut result = String::new(); + for row in rows { + result.push_str(&row.values()[0].to_string()); + } + + let perf_id = set_data(result); + + let url = format!( + "http://{}:{}?perf_id={}", + self.settings.bind_address, self.settings.bind_port, perf_id + ); + + // Open the browser in a separate task if not in ssh mode + let in_sshmode = env::var("SSH_CLIENT").is_ok() || env::var("SSH_TTY").is_ok(); + if !in_sshmode && self.settings.auto_open_browser { + if let Err(e) = webbrowser::open(&url) { + eprintln!("Failed to open browser: {}", e); + } + } + + println!("View graphical online: \x1B[4m{}\x1B[0m", url); + println!(); + Ok(()) + } + async fn display_table(&mut self) -> Result<()> { if self.settings.display_pretty_sql { let format_sql = format_query(self.query); @@ -142,19 +168,7 @@ impl<'a> FormatDisplay<'a> { } if self.kind == QueryKind::Graphical { - println!("Graphical query result: -- "); - let mut explain_results = Vec::new(); - for result in &rows { - explain_results.push(result.values()[0].to_string()); - } - - tokio::spawn(async move { - if let Err(e) = start_server_and_open_browser(explain_results.join("")).await { - eprintln!("Failed to start server: {}", e); - } - }); - println!(); - return Ok(()); + return self.display_graphical(&rows).await; } let schema = self.data.schema(); diff --git a/cli/src/main.rs b/cli/src/main.rs index 78b6b30d..5904fbba 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -345,7 +345,7 @@ pub async fn main() -> Result<()> { settings.output_format = OutputFormat::TSV; } - settings.merge_config(config.settings); + settings.merge_config(&config); if args.no_auto_complete { settings.no_auto_complete = true; diff --git a/cli/src/session.rs b/cli/src/session.rs index 532483ff..ab0c8507 100644 --- a/cli/src/session.rs +++ b/cli/src/session.rs @@ -33,6 +33,7 @@ use rustyline::history::DefaultHistory; use rustyline::{CompletionType, Editor}; use tokio::fs::{remove_file, File}; use tokio::io::AsyncWriteExt; +use tokio::task::JoinHandle; use tokio::time::Instant; use tokio_stream::StreamExt; @@ -40,6 +41,8 @@ use crate::config::Settings; use crate::config::TimeOption; use crate::display::{format_write_progress, ChunkDisplay, FormatDisplay}; use crate::helper::CliHelper; +use crate::web::find_available_port; +use crate::web::start_server; use crate::VERSION; static PROMPT_SQL: &str = "select name, 'f' as type from system.functions union all select name, 'd' as type from system.databases union all select name, 't' as type from system.tables union all select name, 'c' as type from system.columns limit 10000"; @@ -71,11 +74,12 @@ pub struct Session { settings: Settings, query: String, + server_handle: Option>>, keywords: Option>, } impl Session { - pub async fn try_new(dsn: String, settings: Settings, is_repl: bool) -> Result { + pub async fn try_new(dsn: String, mut settings: Settings, is_repl: bool) -> Result { let client = Client::new(dsn).with_name(format!("bendsql/{}", VERSION_SHORT.as_str())); let conn = client.get_conn().await?; let info = conn.info().await; @@ -158,9 +162,23 @@ impl Session { } } keywords = Some(Arc::new(db)); - println!(); } + let server_handle = if is_repl { + let port = find_available_port(settings.bind_port).await; + let addr = settings.bind_address.clone(); + + let server_handle = tokio::spawn(async move { start_server(&addr, port).await }); + println!("Started web server at {}:{}", settings.bind_address, port); + settings.bind_port = port; + Some(server_handle) + } else { + None + }; + + if is_repl { + println!(); + } Ok(Self { client, conn, @@ -168,6 +186,7 @@ impl Session { settings, query: String::new(), keywords, + server_handle, }) } @@ -692,3 +711,11 @@ fn replace_newline_in_box_display(query: &str) -> bool { _ => true, } } + +impl Drop for Session { + fn drop(&mut self) { + if let Some(handle) = self.server_handle.take() { + handle.abort(); + } + } +} diff --git a/cli/src/web.rs b/cli/src/web.rs index 8caafb55..942ce1f7 100644 --- a/cli/src/web.rs +++ b/cli/src/web.rs @@ -12,13 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::env; +use std::collections::HashMap; +use std::sync::atomic::AtomicUsize; +use std::sync::{Arc, Mutex}; +use actix_web::dev::Server; use actix_web::middleware::Logger; +use actix_web::web::Query; use actix_web::{get, web, App, HttpResponse, HttpServer, Responder}; -use anyhow::Result; use mime_guess::from_path; +use once_cell::sync::Lazy; use rust_embed::RustEmbed; +use serde::Deserialize; use tokio::net::TcpListener; #[derive(RustEmbed)] @@ -43,63 +48,58 @@ async fn embed_file(path: web::Path) -> HttpResponse { } } -struct AppState { - result: String, -} - -#[get("/api/message")] -async fn get_message(data: web::Data) -> impl Responder { - let response = serde_json::json!({ - "result": data.result, - }); - HttpResponse::Ok().json(response) -} - -pub async fn start_server_and_open_browser<'a>(explain_result: String) -> Result<()> { - let port = find_available_port(8080).await; - let server = tokio::spawn(async move { - start_server(port, explain_result.to_string()).await; - }); +static PERF_ID: AtomicUsize = AtomicUsize::new(0); - let url = format!("http://0.0.0.0:{}", port); - println!("Started a new server at: {url}"); +static APP_DATA: Lazy>>> = + Lazy::new(|| Arc::new(Mutex::new(HashMap::new()))); - // Open the browser in a separate task if not in ssh mode - let in_sshmode = env::var("SSH_CLIENT").is_ok() || env::var("SSH_TTY").is_ok(); - if !in_sshmode { - tokio::spawn(async move { - if let Err(e) = webbrowser::open(&format!("http://127.0.0.1:{}", port)) { - println!("Failed to open browser, {} ", e); - } - }); - } - - // Continue with the rest of the code - server.await.expect("Server task failed"); +#[derive(Deserialize, Debug)] +struct MessageQuery { + perf_id: Option, +} - Ok(()) +pub fn set_data(result: String) -> usize { + let perf_id = PERF_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let l = APP_DATA.as_ref(); + l.lock().unwrap().insert(perf_id, result); + perf_id } -pub async fn start_server<'a>(port: u16, result: String) { - let app_state = web::Data::new(AppState { - result: result.clone(), - }); +#[get("/api/message")] +async fn get_message(query: Query) -> impl Responder { + query + .perf_id + .as_deref() + .unwrap_or("") + .parse::() + .ok() + .and_then(|id| { + APP_DATA.as_ref().lock().unwrap().get(&id).map(|result| { + HttpResponse::Ok().json(serde_json::json!({ + "result": result, + })) + }) + }) + .unwrap_or_else(|| { + HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("Perf ID {:?} not found", query.perf_id), + })) + }) +} +pub fn start_server(addr: &str, port: u16) -> Server { HttpServer::new(move || { App::new() .wrap(Logger::default()) - .app_data(app_state.clone()) .service(get_message) .route("/{filename:.*}", web::get().to(embed_file)) }) - .bind(("127.0.0.1", port)) + .bind((addr, port)) .expect("Cannot bind to port") .run() - .await - .expect("Server run failed"); } -async fn find_available_port(start: u16) -> u16 { +pub async fn find_available_port(start: u16) -> u16 { let mut port = start; loop { if TcpListener::bind(("127.0.0.1", port)).await.is_ok() { diff --git a/frontend/src/hooks/useProfileData.ts b/frontend/src/hooks/useProfileData.ts index 6b5bacac..b97c042a 100644 --- a/frontend/src/hooks/useProfileData.ts +++ b/frontend/src/hooks/useProfileData.ts @@ -21,14 +21,15 @@ export function useProfileData(): { const [rangeData, setRangeData] = useState([]); const [statisticsData, setStatisticsData] = useState([]); const [labels, setLabels] = useState([]); - const [overviewInfo, setOverviewInfo] = useState(undefined); + const [overviewInfo, setOverviewInfo] = useState(undefined); const [isLoading, setIsLoading] = useState(true); const overviewInfoCurrent = useRef(undefined); - useEffect(() => { const fetchMessage = async () => { try { - const response: Response = await fetch("/api/message"); + const urlParams = new URLSearchParams(window.location.search); + const perf_id = urlParams.get('perf_id') || '0'; + const response: Response = await fetch(`/api/message?perf_id=${perf_id}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -100,7 +101,7 @@ export function useProfileData(): { _value: item.statistics[descObj?.index], }) ); -} + } function calculateOverviewInfo(profiles: Profile[], statistics_desc: StatisticsDesc) { const cpuTime = profiles.reduce((sum: number, item: Profile) => sum + item.cpuTime, 0);