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.

    +
    + ) : ( + + )} +
    +
    + ); +}