diff --git a/lib/crates/fabro-api/src/demo/mod.rs b/lib/crates/fabro-api/src/demo/mod.rs index d056854ff..d8b1166e3 100644 --- a/lib/crates/fabro-api/src/demo/mod.rs +++ b/lib/crates/fabro-api/src/demo/mod.rs @@ -226,37 +226,7 @@ pub async fn get_run_graph( // Use graphviz to render the demo DOT source let dot_source = "digraph demo {\n graph [goal=\"Demo\"]\n rankdir=LR\n start [shape=Mdiamond, label=\"Start\"]\n detect [label=\"Detect\\nDrift\"]\n exit [shape=Msquare, label=\"Exit\"]\n propose [label=\"Propose\\nChanges\"]\n review [label=\"Review\\nChanges\"]\n apply [label=\"Apply\\nChanges\"]\n start -> detect\n detect -> exit [label=\"No drift\"]\n detect -> propose [label=\"Drift found\"]\n propose -> review\n review -> propose [label=\"Revise\"]\n review -> apply [label=\"Accept\"]\n apply -> exit\n}"; - let mut child = match tokio::process::Command::new("dot") - .arg("-Tsvg") - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - { - Ok(child) => child, - Err(_) => { - return ApiError::new( - StatusCode::BAD_GATEWAY, - "Graphviz dot command not available.", - ) - .into_response(); - } - }; - - if let Some(mut stdin) = child.stdin.take() { - use tokio::io::AsyncWriteExt; - let _ = stdin.write_all(dot_source.as_bytes()).await; - } - - match child.wait_with_output().await { - Ok(output) if output.status.success() => ( - StatusCode::OK, - [("content-type", "image/svg+xml")], - output.stdout, - ) - .into_response(), - _ => ApiError::new(StatusCode::BAD_GATEWAY, "Dot rendering failed.").into_response(), - } + crate::server::render_dot_svg(dot_source).await } pub async fn get_run_retro( diff --git a/lib/crates/fabro-api/src/server.rs b/lib/crates/fabro-api/src/server.rs index 400498fc4..1d3917c6e 100644 --- a/lib/crates/fabro-api/src/server.rs +++ b/lib/crates/fabro-api/src/server.rs @@ -1434,6 +1434,20 @@ async fn get_retro( } } +/// Render DOT source to a styled SVG via `render_dot` on a blocking thread. +pub(crate) async fn render_dot_svg(dot_source: &str) -> Response { + use fabro_workflows::cli::graph::{render_dot, GraphFormat}; + + let source = dot_source.to_owned(); + match tokio::task::spawn_blocking(move || render_dot(&source, GraphFormat::Svg)).await { + Ok(Ok(bytes)) => { + (StatusCode::OK, [("content-type", "image/svg+xml")], bytes).into_response() + } + Ok(Err(e)) => ApiError::new(StatusCode::BAD_GATEWAY, e.to_string()).into_response(), + Err(e) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + async fn get_graph( _auth: AuthenticatedService, State(state): State>, @@ -1447,43 +1461,7 @@ async fn get_graph( } }; - let mut child = match tokio::process::Command::new("dot") - .arg("-Tsvg") - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - { - Ok(child) => child, - Err(_) => { - return ApiError::new( - StatusCode::BAD_GATEWAY, - "Graphviz dot command not available.", - ) - .into_response(); - } - }; - - if let Some(mut stdin) = child.stdin.take() { - use tokio::io::AsyncWriteExt; - let _ = stdin.write_all(dot_source.as_bytes()).await; - // stdin is dropped here, closing the pipe - } - - match child.wait_with_output().await { - Ok(output) if output.status.success() => ( - StatusCode::OK, - [("content-type", "image/svg+xml")], - output.stdout, - ) - .into_response(), - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - ApiError::new(StatusCode::BAD_GATEWAY, format!("dot failed: {stderr}")).into_response() - } - Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("dot process error: {e}")) - .into_response(), - } + render_dot_svg(&dot_source).await } #[cfg(test)] diff --git a/lib/crates/fabro-workflows/src/cli/graph.rs b/lib/crates/fabro-workflows/src/cli/graph.rs index 03bcaa242..c6e43153f 100644 --- a/lib/crates/fabro-workflows/src/cli/graph.rs +++ b/lib/crates/fabro-workflows/src/cli/graph.rs @@ -101,6 +101,25 @@ pub fn graph_command(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> { Ok(()) } +/// Dark mode CSS injected into SVG output (leading newline included for insertion). +const DARK_MODE_STYLE: &str = r##" +"##; + +/// DOT graph-level defaults injected after the first `{`. +const DOT_STYLE_DEFAULTS: &str = r##" + bgcolor="transparent" + node [color="#357f9e", fontname="Helvetica", fontsize=12, fontcolor="#1a1a1a"] + edge [color="#666666", fontname="Helvetica", fontsize=10, fontcolor="#666666"] +"##; + static RANKDIR_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"rankdir\s*=\s*\w+").unwrap()); @@ -115,8 +134,52 @@ fn apply_direction<'a>(source: &'a str, direction: Option) -> Co } } -/// Spawn the `dot` command to render DOT source into the given format. -fn render_dot(source: &str, format: GraphFormat) -> anyhow::Result> { +/// Inject DOT graph-level style defaults (transparent background, teal nodes, +/// gray edges, Helvetica font) right after the first `{` in the DOT source. +/// Per-node/edge attributes override these defaults. +fn inject_dot_style_defaults(source: &str) -> String { + let Some(pos) = source.find('{') else { + return source.to_string(); + }; + let (before, after) = source.split_at(pos + 1); + format!("{before}{DOT_STYLE_DEFAULTS}{after}") +} + +/// Post-process raw SVG output from Graphviz: +/// 1. Remove the white background `` element +/// 2. Insert a dark-mode `