diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index af8faf107..e10cd2a60 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -91,6 +91,13 @@ export function useRunGraph(id: string | undefined, direction?: "LR" | "TB") { ); } +export function useRunGraphSource(id: string | undefined, enabled: boolean) { + return useSWR( + id && enabled ? queryKeys.runs.graphSource(id) : null, + apiNullableTextFetcher, + ); +} + export function useRunLogs(id: string | undefined, refreshInterval?: number) { return useSWR( id ? queryKeys.runs.logs(id) : null, diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index 300b3c022..7dcbc90ee 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -32,6 +32,7 @@ export const queryKeys = { stages: (id: string) => `/api/v1/runs/${pathSegment(id)}/stages`, graph: (id: string, direction?: "LR" | "TB") => withQuery(`/api/v1/runs/${pathSegment(id)}/graph`, { direction }), + graphSource: (id: string) => `/api/v1/runs/${pathSegment(id)}/graph/source`, settings: (id: string) => `/api/v1/runs/${pathSegment(id)}/settings`, logs: (id: string) => `/api/v1/runs/${pathSegment(id)}/logs`, billing: (id: string) => `/api/v1/runs/${pathSegment(id)}/billing`, diff --git a/apps/fabro-web/app/routes/run-graph.tsx b/apps/fabro-web/app/routes/run-graph.tsx index ef0c13f11..1590a2ac9 100644 --- a/apps/fabro-web/app/routes/run-graph.tsx +++ b/apps/fabro-web/app/routes/run-graph.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams } from "react-router"; import { graphTheme } from "../lib/graph-theme"; -import { useRunGraph, useRunStages } from "../lib/queries"; +import { useRunGraph, useRunGraphSource, useRunStages } from "../lib/queries"; +import { CopyButton } from "../components/ui"; +import { LoadingState } from "../components/state"; import { StageSidebar } from "../components/stage-sidebar"; import { GRAPH_DEFAULT_ZOOM_INDEX, @@ -69,11 +71,15 @@ function stripGraphTitle(svg: SVGSVGElement) { title.remove(); } +type View = "graph" | "source"; + export default function RunGraph() { const { id } = useParams(); const [direction, setDirection] = useState("LR"); + const [view, setView] = useState("graph"); const stagesQuery = useRunStages(id); const graphQuery = useRunGraph(id, direction); + const sourceQuery = useRunGraphSource(id, view === "source"); const stages = useMemo( () => mapRunStagesToSidebarStages(stagesQuery.data), [stagesQuery.data], @@ -184,35 +190,94 @@ export default function RunGraph() {
-
-
- +
+ + + {view === "graph" ? ( +
+ -
-

Loading diagram...

+
+

Loading diagram...

+
-
+ ) : ( + + )}
); } + +function ViewToggle({ view, setView }: { view: View; setView: (v: View) => void }) { + const btn = + "rounded px-3 py-1.5 text-xs font-medium transition-colors"; + return ( +
+ + +
+ ); +} + +function SourcePanel({ source, loading }: { source: string | null | undefined; loading: boolean }) { + if (loading) { + return ( +
+ +
+ ); + } + if (!source) { + return ( +
+

No graph source available for this run.

+
+ ); + } + return ( +
+
+ workflow.fabro + +
+
+        {source}
+      
+
+ ); +} diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 01e1cf8c8..3625b4606 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -1049,6 +1049,28 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/graph/source: + get: + operationId: retrieveRunGraphSource + tags: [Runs] + summary: Retrieve Graphviz DOT source + description: Returns the raw Graphviz DOT source for the workflow graph (the contents of the workflow's `.fabro` file). + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: Graphviz DOT source + content: + text/vnd.graphviz: + schema: + type: string + "404": + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/checkpoint: get: operationId: retrieveRunCheckpoint diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index a57a04096..c40840fe8 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -414,15 +414,27 @@ pub(crate) async fn unpause_stub( (StatusCode::OK, Json(serde_json::json!({"id": id, "status": "running", "created_at": "2026-03-06T14:30:00Z"}))).into_response() } +const DEMO_GRAPH_DOT: &str = "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}"; + pub(crate) async fn get_run_graph( _auth: AuthenticatedService, State(_state): State>, Path(_id): Path, ) -> Response { - // 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}"; + crate::server::render_graph_bytes(DEMO_GRAPH_DOT).await +} - crate::server::render_graph_bytes(dot_source).await +pub(crate) async fn get_run_graph_source( + _auth: AuthenticatedService, + State(_state): State>, + Path(_id): Path, +) -> Response { + ( + StatusCode::OK, + [("content-type", "text/vnd.graphviz")], + DEMO_GRAPH_DOT, + ) + .into_response() } pub(crate) async fn list_secrets( diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index fd7da991d..a95ab6c98 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -1138,6 +1138,7 @@ fn demo_routes() -> Router> { .route("/runs/{id}/pause", post(demo::pause_stub)) .route("/runs/{id}/unpause", post(demo::unpause_stub)) .route("/runs/{id}/graph", get(demo::get_run_graph)) + .route("/runs/{id}/graph/source", get(demo::get_run_graph_source)) .route("/runs/{id}/stages", get(demo::get_run_stages)) .route("/runs/{id}/artifacts", get(demo::list_run_artifacts_stub)) .route("/runs/{id}/files", get(demo::list_run_files_stub)) @@ -1238,6 +1239,7 @@ fn real_routes() -> Router> { .route("/runs/{id}/timeline", get(run_timeline)) .route("/runs/{id}/unarchive", post(unarchive_run)) .route("/runs/{id}/graph", get(get_graph)) + .route("/runs/{id}/graph/source", get(get_graph_source)) .route("/runs/{id}/stages", get(list_run_stages)) .route("/runs/{id}/artifacts", get(list_run_artifacts)) .route("/runs/{id}/files", get(list_run_files)) @@ -7931,6 +7933,33 @@ struct GraphParams { direction: Option, } +async fn load_run_dot_source(state: &AppState, id: &RunId) -> Result { + let live_dot_source = { + let runs = state.runs.lock().expect("runs lock poisoned"); + runs.get(id).map(|managed_run| managed_run.dot_source.clone()) + }; + + let dot_source = if let Some(dot) = live_dot_source.filter(|d| !d.is_empty()) { + Some(dot) + } else { + match state.store.open_run_reader(id).await { + Ok(run_store) => match run_store.state().await { + Ok(run_state) => run_state.graph_source, + Err(err) => { + return Err( + ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response(), + ); + } + }, + Err(_) => return Err(ApiError::not_found("Run not found.").into_response()), + } + }; + + dot_source.ok_or_else(|| { + ApiError::new(StatusCode::NOT_FOUND, "Graph not found.").into_response() + }) +} + async fn get_graph( _auth: AuthenticatedService, State(state): State>, @@ -7942,28 +7971,9 @@ async fn get_graph( Err(response) => return response, }; - let live_dot_source = { - let runs = state.runs.lock().expect("runs lock poisoned"); - runs.get(&id) - .map(|managed_run| managed_run.dot_source.clone()) - }; - - let dot_source = if let Some(dot) = live_dot_source.filter(|d| !d.is_empty()) { - Some(dot) - } else { - match state.store.open_run_reader(&id).await { - Ok(run_store) => match run_store.state().await { - Ok(run_state) => run_state.graph_source, - Err(err) => { - return ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response(); - } - }, - Err(_) => return ApiError::not_found("Run not found.").into_response(), - } - }; - - let Some(dot) = dot_source else { - return ApiError::new(StatusCode::NOT_FOUND, "Graph not found.").into_response(); + let dot = match load_run_dot_source(&state, &id).await { + Ok(dot) => dot, + Err(response) => return response, }; let dot = match params.direction.as_deref() { @@ -7977,6 +7987,22 @@ async fn get_graph( render_graph_bytes(&dot).await } +async fn get_graph_source( + _auth: AuthenticatedService, + State(state): State>, + Path(id): Path, +) -> Response { + let id = match parse_run_id_path(&id) { + Ok(id) => id, + Err(response) => return response, + }; + + match load_run_dot_source(&state, &id).await { + Ok(dot) => (StatusCode::OK, [("content-type", "text/vnd.graphviz")], dot).into_response(), + Err(response) => response, + } +} + #[cfg(test)] #[expect( clippy::disallowed_methods, @@ -11784,6 +11810,7 @@ slug = "fabro" (Method::POST, format!("/runs/{run_id}/archive")), (Method::POST, format!("/runs/{run_id}/unarchive")), (Method::GET, format!("/runs/{run_id}/graph")), + (Method::GET, format!("/runs/{run_id}/graph/source")), (Method::GET, format!("/runs/{run_id}/stages")), (Method::GET, format!("/runs/{run_id}/artifacts")), (Method::GET, format!("/runs/{run_id}/files")), @@ -12120,6 +12147,60 @@ slug = "fabro" ); } + #[tokio::test] + async fn get_graph_source_returns_dot() { + let state = create_app_state(); + let app = build_router(Arc::clone(&state), AuthMode::Disabled); + + let req = Request::builder() + .method("POST") + .uri(api("/runs")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "version": 1, + "cwd": "/tmp", + "target": { + "identifier": "workflow.fabro", + "path": "workflow.fabro", + }, + "workflows": { + "workflow.fabro": { + "source": MINIMAL_DOT, + "files": {}, + }, + }, + })) + .unwrap(), + )) + .unwrap(); + + let response = app.clone().oneshot(req).await.unwrap(); + let body = body_json(response.into_body()).await; + let run_id = body["id"].as_str().unwrap().parse::().unwrap(); + + let req = Request::builder() + .method("GET") + .uri(api(&format!("/runs/{run_id}/graph/source"))) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + let response = checked_response!(response, StatusCode::OK).await; + + let content_type = response + .headers() + .get("content-type") + .expect("content-type header should be present") + .to_str() + .unwrap(); + assert_eq!(content_type, "text/vnd.graphviz"); + + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let dot = String::from_utf8(bytes.to_vec()).unwrap(); + assert_eq!(dot, MINIMAL_DOT); + } + #[tokio::test] async fn render_graph_from_manifest_returns_svg() { let app = test_app_with(); diff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts index 07767ebf9..cffa4e9d1 100644 --- a/lib/packages/fabro-api-client/src/api/runs-api.ts +++ b/lib/packages/fabro-api-client/src/api/runs-api.ts @@ -786,6 +786,46 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * Returns the raw Graphviz DOT source for the workflow graph (the contents of the workflow\'s `.fabro` file). + * @summary Retrieve Graphviz DOT source + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + retrieveRunGraphSource: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('retrieveRunGraphSource', 'id', id) + const localVarPath = `/api/v1/runs/{id}/graph/source` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'text/vnd.graphviz,application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed. * @summary Rewind Run @@ -1230,6 +1270,19 @@ export const RunsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['RunsApi.retrieveRunGraph']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Returns the raw Graphviz DOT source for the workflow graph (the contents of the workflow\'s `.fabro` file). + * @summary Retrieve Graphviz DOT source + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async retrieveRunGraphSource(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveRunGraphSource(id, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['RunsApi.retrieveRunGraphSource']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed. * @summary Rewind Run @@ -1483,6 +1536,16 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? retrieveRunGraph(id: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.retrieveRunGraph(id, options).then((request) => request(axios, basePath)); }, + /** + * Returns the raw Graphviz DOT source for the workflow graph (the contents of the workflow\'s `.fabro` file). + * @summary Retrieve Graphviz DOT source + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + retrieveRunGraphSource(id: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.retrieveRunGraphSource(id, options).then((request) => request(axios, basePath)); + }, /** * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed. * @summary Rewind Run @@ -1736,6 +1799,17 @@ export class RunsApi extends BaseAPI { return RunsApiFp(this.configuration).retrieveRunGraph(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * Returns the raw Graphviz DOT source for the workflow graph (the contents of the workflow\'s `.fabro` file). + * @summary Retrieve Graphviz DOT source + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public retrieveRunGraphSource(id: string, options?: RawAxiosRequestConfig) { + return RunsApiFp(this.configuration).retrieveRunGraphSource(id, options).then((request) => request(this.axios, this.basePath)); + } + /** * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed. * @summary Rewind Run