diff --git a/apps/fabro-web/app/components/stage-sidebar.tsx b/apps/fabro-web/app/components/stage-sidebar.tsx
index 0799a9ada..d18f85a1d 100644
--- a/apps/fabro-web/app/components/stage-sidebar.tsx
+++ b/apps/fabro-web/app/components/stage-sidebar.tsx
@@ -39,7 +39,7 @@ interface StageSidebarProps {
stages: Stage[];
runId: string;
selectedStageId?: string;
- activeLink?: "settings" | "graph" | "logs";
+ activeLink?: "settings" | "source" | "logs";
}
export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) {
@@ -127,15 +127,15 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
- Workflow Graph
+ Graph Source
diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx
index 72d960f01..5d2c2e653 100644
--- a/apps/fabro-web/app/router.tsx
+++ b/apps/fabro-web/app/router.tsx
@@ -16,7 +16,7 @@ import * as RunDetail from "./routes/run-detail";
import * as RunOverview from "./routes/run-overview";
import * as RunStages from "./routes/run-stages";
import * as RunSettings from "./routes/run-settings";
-import * as RunGraph from "./routes/run-graph";
+import * as RunSource from "./routes/run-source";
import * as RunLogs from "./routes/run-logs";
import * as RunFiles from "./routes/run-files";
import * as RunBilling from "./routes/run-billing";
@@ -92,7 +92,7 @@ export const routes: RouteObject[] = [
route("stages", RunStages),
route("stages/:stageId", RunStages),
route("settings", RunSettings),
- route("graph", RunGraph),
+ route("source", RunSource),
route("logs", RunLogs),
route("files", RunFiles),
route("billing", RunBilling),
diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx
index 3f38db945..e1162e641 100644
--- a/apps/fabro-web/app/routes/run-detail.tsx
+++ b/apps/fabro-web/app/routes/run-detail.tsx
@@ -56,7 +56,6 @@ const allTabs = [
{ name: "Overview", path: "", count: null, demoOnly: false },
{ name: "Stages", path: "/stages", count: null, demoOnly: false },
{ name: "Files Changed", path: "/files", count: null, demoOnly: false },
- { name: "Graph", path: "/graph", count: null, demoOnly: false },
{ name: "Billing", path: "/billing", count: null, demoOnly: false },
];
diff --git a/apps/fabro-web/app/routes/run-graph.tsx b/apps/fabro-web/app/routes/run-graph.tsx
deleted file mode 100644
index c42f5113f..000000000
--- a/apps/fabro-web/app/routes/run-graph.tsx
+++ /dev/null
@@ -1,307 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useParams } from "react-router";
-import type { BundledLanguage } from "@pierre/diffs";
-import { graphTheme } from "../lib/graph-theme";
-import { useRunGraph, useRunGraphSource, useRunStages } from "../lib/queries";
-import { LoadingState } from "../components/state";
-import { StageSidebar } from "../components/stage-sidebar";
-import {
- GRAPH_DEFAULT_ZOOM_INDEX,
- GRAPH_ZOOM_STEPS,
- GraphToolbar,
-} from "../components/graph-toolbar";
-import { CollapsibleFile } from "../components/collapsible-file";
-import { registerDotLanguage } from "../data/register-dot-language";
-import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
-
-export const handle = { wide: true };
-
-type Direction = "LR" | "TB";
-
-function buildDot(direction: Direction) {
- return `digraph sync {
- graph [label="Sync"]
- rankdir=${direction}
- bgcolor="transparent"
- pad=0.5
-
- node [
- fontname="ui-monospace, monospace"
- fontsize=11
- fontcolor="${graphTheme.nodeText}"
- color="${graphTheme.edgeColor}"
- fillcolor="${graphTheme.nodeFill}"
- style=filled
- penwidth=1.2
- ]
- edge [
- fontname="ui-monospace, monospace"
- fontsize=9
- fontcolor="${graphTheme.fontcolor}"
- color="${graphTheme.edgeColor}"
- arrowsize=0.7
- penwidth=1.2
- ]
-
- start [shape=Mdiamond, label="Start", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]
- exit [shape=Msquare, label="Exit", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]
-
- detect [label="Detect\\nDrift"]
- propose [label="Propose\\nChanges"]
- review [shape=hexagon, label="Review\\nChanges", fillcolor="${graphTheme.gateFill}", color="${graphTheme.gateBorder}", fontcolor="${graphTheme.gateText}"]
- apply [label="Apply\\nChanges"]
-
- start -> detect
- detect -> exit [label="No drift", style=dashed]
- detect -> propose [label="Drift found"]
- propose -> review
- review -> apply [label="Accept"]
- review -> propose [label="Revise", style=dashed]
- apply -> exit
-}`;
-}
-
-function stripGraphTitle(svg: SVGSVGElement) {
- const title = svg.querySelector(".graph > title");
- if (!title) return;
- let sibling = title.nextElementSibling;
- while (sibling && sibling.tagName === "text") {
- const next = sibling.nextElementSibling;
- sibling.remove();
- sibling = next;
- }
- 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],
- );
- const graphSvg = graphQuery.data;
- const containerRef = useRef(null);
- const innerRef = useRef(null);
- const svgRef = useRef(null);
- const [error, setError] = useState(null);
- const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX);
- const [pan, setPan] = useState({ x: 0, y: 0 });
- const [dotReady, setDotReady] = useState(false);
-
- useEffect(() => {
- let cancelled = false;
- registerDotLanguage().then(() => {
- if (!cancelled) setDotReady(true);
- });
- return () => {
- cancelled = true;
- };
- }, []);
- const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null);
- const zoom = GRAPH_ZOOM_STEPS[zoomIndex];
-
- useEffect(() => {
- if (graphSvg === undefined && !graphQuery.error) return;
-
- let cancelled = false;
-
- async function render() {
- try {
- setError(null);
-
- if (graphQuery.error) {
- setError("Failed to load graph");
- return;
- }
-
- let svg: SVGSVGElement;
-
- if (graphSvg) {
- const parser = new DOMParser();
- const doc = parser.parseFromString(graphSvg, "image/svg+xml");
- const parsed = doc.documentElement;
- if (!(parsed instanceof SVGSVGElement)) {
- setError("Invalid SVG from server");
- return;
- }
- svg = parsed;
- } else {
- // Fall back to hardcoded demo graph rendered client-side.
- const { instance } = await import("@viz-js/viz");
- const viz = await instance();
- if (cancelled) return;
- svg = viz.renderSVGElement(buildDot(direction));
- }
-
- stripGraphTitle(svg);
-
- svgRef.current = svg;
- if (innerRef.current) {
- innerRef.current.replaceChildren(svg);
- }
- } catch (e) {
- setError(e instanceof Error ? e.message : "Failed to render diagram");
- }
- }
-
- setPan({ x: 0, y: 0 });
- render();
- return () => { cancelled = true; };
- }, [direction, graphQuery.error, graphSvg]);
-
- const onPointerDown = useCallback((e: React.PointerEvent) => {
- if ((e.target as HTMLElement).closest("button")) return;
- e.currentTarget.setPointerCapture(e.pointerId);
- dragState.current = { startX: e.clientX, startY: e.clientY, startPanX: pan.x, startPanY: pan.y };
- }, [pan]);
-
- const onPointerMove = useCallback((e: React.PointerEvent) => {
- const drag = dragState.current;
- if (!drag) return;
- setPan({
- x: drag.startPanX + e.clientX - drag.startX,
- y: drag.startPanY + e.clientY - drag.startY,
- });
- }, []);
-
- const onPointerUp = useCallback(() => {
- dragState.current = null;
- }, []);
-
- const fitToWindow = useCallback(() => {
- const svg = svgRef.current;
- const container = containerRef.current;
- if (!svg || !container) return;
-
- const svgW = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width;
- const svgH = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height;
- const padPx = 48;
- const containerW = container.clientWidth - padPx;
- const containerH = container.clientHeight - padPx;
-
- const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100;
- let best = 0;
- for (let i = GRAPH_ZOOM_STEPS.length - 1; i >= 0; i--) {
- if (GRAPH_ZOOM_STEPS[i] <= fitPct) { best = i; break; }
- }
- setZoomIndex(best);
- setPan({ x: 0, y: 0 });
- }, []);
-
- if (error) {
- return {error}
;
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
- {view === "source" && (
-
- )}
-
-
- );
-}
-
-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,
- dotReady,
-}: {
- source: string | null | undefined;
- loading: boolean;
- dotReady: boolean;
-}) {
- if (loading || !dotReady) {
- return (
-
-
-
- );
- }
- if (!source) {
- return (
-
-
No graph source available for this run.
-
- );
- }
- return (
-
- );
-}
diff --git a/apps/fabro-web/app/routes/run-source.tsx b/apps/fabro-web/app/routes/run-source.tsx
new file mode 100644
index 000000000..621e94de0
--- /dev/null
+++ b/apps/fabro-web/app/routes/run-source.tsx
@@ -0,0 +1,57 @@
+import { useEffect, useMemo, useState } from "react";
+import { useParams } from "react-router";
+import type { BundledLanguage } from "@pierre/diffs";
+import { useRunGraphSource, useRunStages } from "../lib/queries";
+import { LoadingState } from "../components/state";
+import { StageSidebar } from "../components/stage-sidebar";
+import { CollapsibleFile } from "../components/collapsible-file";
+import { registerDotLanguage } from "../data/register-dot-language";
+import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
+
+export const handle = { wide: true };
+
+export default function RunSource() {
+ const { id } = useParams();
+ const stagesQuery = useRunStages(id);
+ const sourceQuery = useRunGraphSource(id, true);
+ const stages = useMemo(
+ () => mapRunStagesToSidebarStages(stagesQuery.data),
+ [stagesQuery.data],
+ );
+ const [dotReady, setDotReady] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ registerDotLanguage().then(() => {
+ if (!cancelled) setDotReady(true);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const source = sourceQuery.data;
+ const loading = source === undefined && !sourceQuery.error;
+
+ return (
+
+
+
+
+ {loading || !dotReady ? (
+
+
+
+ ) : !source ? (
+
+
No graph source available for this run.
+
+ ) : (
+
+ )}
+
+
+ );
+}