mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
refactor(web): replace Graph tab with a Graph Source sidebar page
The Graph tab duplicated the Overview's diagram. Drop it and the /runs/:id/graph route, and move the DOT source view to a dedicated /runs/:id/source page reachable from the sidebar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6e979ef85a
commit
ebf9bc7ed0
5 changed files with 63 additions and 314 deletions
|
|
@ -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
|
|||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={`/runs/${runId}/graph`}
|
||||
to={`/runs/${runId}/source`}
|
||||
className={`${linkBase} ${
|
||||
activeLink === "graph"
|
||||
activeLink === "source"
|
||||
? "bg-overlay text-fg"
|
||||
: "text-fg-3 hover:bg-overlay hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<MapIcon className="size-4 shrink-0 text-fg-muted" />
|
||||
Workflow Graph
|
||||
Graph Source
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Direction>("LR");
|
||||
const [view, setView] = useState<View>("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<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 <p className="text-sm text-coral">{error}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<StageSidebar stages={stages} runId={id!} activeLink="graph" />
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<ViewToggle view={view} setView={setView} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="graph-svg relative rounded-md border border-line bg-panel-alt"
|
||||
hidden={view !== "graph"}
|
||||
>
|
||||
<GraphToolbar
|
||||
direction={direction}
|
||||
setDirection={setDirection}
|
||||
fitToWindow={fitToWindow}
|
||||
zoomIndex={zoomIndex}
|
||||
setZoomIndex={setZoomIndex}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="overflow-hidden p-6"
|
||||
style={{ cursor: dragState.current ? "grabbing" : "grab" }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<div
|
||||
ref={innerRef}
|
||||
className="flex items-center justify-center"
|
||||
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom / 100})`, transformOrigin: "center center" }}
|
||||
>
|
||||
<p className="text-sm text-fg-muted">Loading diagram...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "source" && (
|
||||
<SourcePanel
|
||||
source={sourceQuery.data}
|
||||
loading={sourceQuery.data === undefined && !sourceQuery.error}
|
||||
dotReady={dotReady}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div role="group" aria-label="Graph view" className="inline-flex rounded-md border border-line bg-panel/80 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("graph")}
|
||||
aria-pressed={view === "graph"}
|
||||
className={`${btn} ${view === "graph" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
>
|
||||
Graph
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("source")}
|
||||
aria-pressed={view === "source"}
|
||||
className={`${btn} ${view === "source" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
>
|
||||
Source
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourcePanel({
|
||||
source,
|
||||
loading,
|
||||
dotReady,
|
||||
}: {
|
||||
source: string | null | undefined;
|
||||
loading: boolean;
|
||||
dotReady: boolean;
|
||||
}) {
|
||||
if (loading || !dotReady) {
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-panel-alt p-4">
|
||||
<LoadingState label="Loading graph source…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!source) {
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-panel-alt p-4">
|
||||
<p className="text-sm text-fg-muted">No graph source available for this run.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CollapsibleFile
|
||||
file={{ name: "workflow.fabro", contents: source, lang: "dot" as BundledLanguage }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
apps/fabro-web/app/routes/run-source.tsx
Normal file
57
apps/fabro-web/app/routes/run-source.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex gap-6">
|
||||
<StageSidebar stages={stages} runId={id!} activeLink="source" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{loading || !dotReady ? (
|
||||
<div className="rounded-md border border-line bg-panel-alt p-4">
|
||||
<LoadingState label="Loading graph source…" />
|
||||
</div>
|
||||
) : !source ? (
|
||||
<div className="rounded-md border border-line bg-panel-alt p-4">
|
||||
<p className="text-sm text-fg-muted">No graph source available for this run.</p>
|
||||
</div>
|
||||
) : (
|
||||
<CollapsibleFile
|
||||
file={{ name: "workflow.fabro", contents: source, lang: "dot" as BundledLanguage }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue