diff --git a/Cargo.lock b/Cargo.lock index 0c4452f..689c5be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,7 @@ dependencies = [ "regex", "semver", "serde", + "serde_json", "serde_yaml", "size", "strfmt", diff --git a/Cargo.toml b/Cargo.toml index 73332b5..680dfe3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ log = { version = "0.4", default-features = false } futures-util = { version = "*", default-features = false } semver = { version = "*", default-features = false } serde = { version = "*", features = ["derive"] } +serde_json = { version = "*", default-features = false, features = ["std"] } serde_yaml = { version = "*", default-features = false } quick-xml = { version = "*", features = ["serialize"] } urlencoding = { version = "*", default-features = false } diff --git a/README.md b/README.md index f2ececa..5290078 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ introspection, like `top` for Linux. - [Flamegraphs](Documentation/FAQ.md#what-is-flamegraph) (CPU/Real/Memory/Live) in TUI (thanks to [flamelens](https://github.com/ys-l/flamelens)) - Share flamegraphs (using [pastila.nl](https://pastila.nl/) and [speedscope](https://www.speedscope.app/)) - Share logs via [pastila.nl](https://pastila.nl/) -- Share query pipelines (using [GraphvizOnline](https://dreampuf.github.io/GraphvizOnline/?engine=dot#digraph%0A%7B%0A%20%20rankdir%3D%22LR%22%3B%0A%20%20%7B%20node%20%5Bshape%20%3D%20rect%5D%0A%20%20%20%20n0%5Blabel%3D%22CountingTransform_7%22%5D%3B%0A%20%20%20%20n1%5Blabel%3D%22AddDeduplicationInfoTransform_6%22%5D%3B%0A%20%20%20%20n2%5Blabel%3D%22PlanSquashingTransform_5%22%5D%3B%0A%20%20%20%20n3%5Blabel%3D%22ApplySquashingTransform_4%22%5D%3B%0A%20%20%20%20n4%5Blabel%3D%22ConvertingTransform_0%22%5D%3B%0A%20%20%20%20n5%5Blabel%3D%22RemovingReplicatedColumnsTransform_1%22%5D%3B%0A%20%20%20%20n6%5Blabel%3D%22NestedElementsValidationTransform_2%22%5D%3B%0A%20%20%20%20n7%5Blabel%3D%22SharedMergeTreeSink_3%22%5D%3B%0A%20%20%20%20n8%5Blabel%3D%22EmptySink_8%22%5D%3B%0A%20%20%7D%0A%20%20n0%20-%3E%20n1%3B%0A%20%20n1%20-%3E%20n2%3B%0A%20%20n2%20-%3E%20n3%3B%0A%20%20n3%20-%3E%20n4%3B%0A%20%20n4%20-%3E%20n5%3B%0A%20%20n5%20-%3E%20n6%3B%0A%20%20n6%20-%3E%20n7%3B%0A%20%20n7%20-%3E%20n8%3B%0A%7D)) +- Share query pipelines (using [viz.js](https://github.com/mdaines/viz-js) and [pastila.nl](https://pastila.nl/)) - Cluster support (`--cluster`) - aggregate data from all hosts in the cluster - Historical support (`--history`) - includes rotated `system.*_log_*` tables - `clickhouse-client` compatibility (including `--connection`) for options and configuration files diff --git a/src/interpreter/flamegraph.rs b/src/interpreter/flamegraph.rs index ea97c28..b08372e 100644 --- a/src/interpreter/flamegraph.rs +++ b/src/interpreter/flamegraph.rs @@ -93,8 +93,7 @@ pub async fn open_in_speedscope( return Err(Error::msg("Flamegraph is empty")); } - let pastila_url = - pastila::upload_to_pastila(&data, pastila_clickhouse_host, pastila_url).await?; + let pastila_url = pastila::upload(&data, pastila_clickhouse_host, pastila_url).await?; let url = format!( "https://www.speedscope.app/#profileURL={}", diff --git a/src/interpreter/worker.rs b/src/interpreter/worker.rs index c169471..1fcb3b1 100644 --- a/src/interpreter/worker.rs +++ b/src/interpreter/worker.rs @@ -6,7 +6,7 @@ use crate::{ flamegraph, }, pastila, - utils::{highlight_sql, open_graph_in_browser}, + utils::{highlight_sql, share_graph}, view::{self, Navigation}, }; use anyhow::{Result, anyhow}; @@ -31,7 +31,7 @@ pub enum Event { LastQueryLog(String, RelativeDateTime, RelativeDateTime, u64), // (view_name, args) TextLog(&'static str, TextLogArguments), - // [bool (true - show in TUI, false - open in browser), type, start, end] + // [bool (true - show in TUI, false - share via pastila), type, start, end] ServerFlameGraph(bool, TraceType, DateTime, DateTime), // (type, bool (true - show in TUI, false - open in browser), start time, end time, [query_ids]) QueryFlameGraph( @@ -55,7 +55,7 @@ pub enum Event { // (database, query) ExplainPipeline(String, String), // (database, query) - ExplainPipelineOpenGraphInBrowser(String, String), + ExplainPipelineShareGraph(String, String), // (database, query) ExplainPlanIndexes(String, String), // (database, table) @@ -74,7 +74,7 @@ pub enum Event { TableParts(String, String), // (database, table) AsynchronousInserts(String, String), - // (content to share) + // (content to share via pastila) ShareLogs(String), } @@ -94,9 +94,7 @@ impl Event { Event::ExplainSyntax(..) => "ExplainSyntax".to_string(), Event::ExplainPlan(..) => "ExplainPlan".to_string(), Event::ExplainPipeline(..) => "ExplainPipeline".to_string(), - Event::ExplainPipelineOpenGraphInBrowser(..) => { - "ExplainPipelineOpenGraphInBrowser".to_string() - } + Event::ExplainPipelineShareGraph(..) => "ExplainPipelineShareGraph".to_string(), Event::ExplainPlanIndexes(..) => "ExplainPlanIndexes".to_string(), Event::ShowCreateTable(..) => "ShowCreateTable".to_string(), Event::SQLQuery(view_name, _query) => format!("SQLQuery({})", view_name), @@ -541,21 +539,24 @@ async fn process_event(context: ContextArc, event: Event, need_clear: &mut bool) })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } - Event::ExplainPipelineOpenGraphInBrowser(database, query) => { + Event::ExplainPipelineShareGraph(database, query) => { let pipeline = clickhouse .explain_pipeline_graph(database.as_str(), query.as_str()) .await? .join("\n"); - cb_sink - .send(Box::new(move |siv: &mut cursive::Cursive| { - open_graph_in_browser(pipeline) - .or_else(|err| { - siv.add_layer(views::Dialog::info(err.to_string())); - return anyhow::Ok(()); - }) - .unwrap(); - })) - .map_err(|_| anyhow!("Cannot send message to UI"))?; + + // Upload graph to pastila and open in browser + match share_graph(pipeline, &pastila_clickhouse_host, &pastila_url).await { + Ok(_) => {} + Err(err) => { + let error_msg = err.to_string(); + cb_sink + .send(Box::new(move |siv: &mut cursive::Cursive| { + siv.add_layer(views::Dialog::info(error_msg)); + })) + .map_err(|_| anyhow!("Cannot send message to UI"))?; + } + } } Event::ShowCreateTable(database, table) => { let create_statement = clickhouse @@ -718,8 +719,7 @@ async fn process_event(context: ContextArc, event: Event, need_clear: &mut bool) } Event::ShareLogs(content) => { let url = - pastila::upload_logs_encrypted(&content, &pastila_clickhouse_host, &pastila_url) - .await?; + pastila::upload_encrypted(&content, &pastila_clickhouse_host, &pastila_url).await?; let url_clone = url.clone(); cb_sink diff --git a/src/pastila.rs b/src/pastila.rs index b133668..5e85492 100644 --- a/src/pastila.rs +++ b/src/pastila.rs @@ -172,7 +172,7 @@ async fn get_pastila_client(pastila_clickhouse_host: &str) -> Result(siv: &mut Cursive, actions: Vec, on_select: F) where @@ -228,16 +228,58 @@ pub fn open_url_command(url: &str) -> Command { cmd } -pub fn open_graph_in_browser(graph: String) -> Result<()> { +pub async fn share_graph( + graph: String, + pastila_clickhouse_host: &str, + pastila_url: &str, +) -> Result<()> { if graph.is_empty() { return Err(Error::msg("Graph is empty")); } - let url = format!( - "https://dreampuf.github.io/GraphvizOnline/#{}", - encode(&graph) + + // Create a self-contained HTML file that renders the Graphviz graph + // Using viz.js from CDN for client-side rendering + let html = format!( + r#" + + + + Graphviz Graph + + + +
Loading graph...
+ + + +"#, + serde_json::to_string(&graph)? ); + + // Upload HTML to pastila with end-to-end encryption + let mut url = pastila::upload_encrypted(&html, pastila_clickhouse_host, pastila_url).await?; + + if let Some(anchor_pos) = url.find('#') { + url.insert_str(anchor_pos, ".html"); + } + + // Open the URL in the browser open_url_command(&url).status()?; - return Ok(()); + + Ok(()) } pub fn find_common_hostname_prefix_and_suffix<'a, I>(hostnames: I) -> (String, String) diff --git a/src/view/queries_view.rs b/src/view/queries_view.rs index 2a5c56f..de0babb 100644 --- a/src/view/queries_view.rs +++ b/src/view/queries_view.rs @@ -669,7 +669,7 @@ impl QueriesView { let mut context_locked = self.context.lock().unwrap(); context_locked.worker.send( true, - WorkerEvent::ExplainPipelineOpenGraphInBrowser(database, query), + WorkerEvent::ExplainPipelineShareGraph(database, query), ); Ok(Some(EventResult::consumed())) } @@ -1104,7 +1104,7 @@ impl QueriesView { add_action!(context, &mut event_view, "Share Query events flamegraph", action_show_flamegraph(false, Some(TraceType::ProfileEvents))); add_action!(context, &mut event_view, "Share Query live flamegraph", action_show_flamegraph(false, None)); add_action!(context, &mut event_view, "EXPLAIN INDEXES", 'I', action_explain_indexes); - add_action!(context, &mut event_view, "EXPLAIN PIPELINE graph=1 (open in browser)", 'G', action_explain_pipeline_graph); + add_action!(context, &mut event_view, "EXPLAIN PIPELINE graph=1 (share)", 'G', action_explain_pipeline_graph); add_action!(context, &mut event_view, "KILL query", 'K', action_kill_query); add_action!(context, &mut event_view, "Increase number of queries to render to 20", '(', action_increase_limit); add_action!(context, &mut event_view, "Decrease number of queries to render to 20", ')', action_decrease_limit);