- Install reactflow and dagre for professional graph visualization - Use dagre for automatic tree layout (top-to-bottom) - Custom styled nodes with package name, version, and size - Built-in zoom/pan controls and minimap - Click nodes to navigate to package page - Cleaner, more professional appearance
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import ReactFlow, {
|
|
Node,
|
|
Edge,
|
|
Controls,
|
|
Background,
|
|
useNodesState,
|
|
useEdgesState,
|
|
MarkerType,
|
|
NodeProps,
|
|
Handle,
|
|
Position,
|
|
} from 'reactflow';
|
|
import dagre from 'dagre';
|
|
import 'reactflow/dist/style.css';
|
|
import { ResolvedArtifact, DependencyResolutionResponse, Dependency } from '../types';
|
|
import { resolveDependencies, getArtifactDependencies } from '../api';
|
|
import './DependencyGraph.css';
|
|
|
|
interface DependencyGraphProps {
|
|
projectName: string;
|
|
packageName: string;
|
|
tagName: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
interface NodeData {
|
|
label: string;
|
|
project: string;
|
|
package: string;
|
|
version: string | null;
|
|
size: number;
|
|
isRoot: boolean;
|
|
onNavigate: (project: string, pkg: string) => void;
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
// Custom node component
|
|
function DependencyNode({ data }: NodeProps<NodeData>) {
|
|
return (
|
|
<div
|
|
className={`flow-node ${data.isRoot ? 'flow-node--root' : ''}`}
|
|
onClick={() => data.onNavigate(data.project, data.package)}
|
|
>
|
|
<Handle type="target" position={Position.Top} className="flow-handle" />
|
|
<div className="flow-node__name">{data.package}</div>
|
|
<div className="flow-node__details">
|
|
{data.version && <span className="flow-node__version">{data.version}</span>}
|
|
<span className="flow-node__size">{formatBytes(data.size)}</span>
|
|
</div>
|
|
<Handle type="source" position={Position.Bottom} className="flow-handle" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const nodeTypes = { dependency: DependencyNode };
|
|
|
|
// Dagre layout function
|
|
function getLayoutedElements(
|
|
nodes: Node<NodeData>[],
|
|
edges: Edge[],
|
|
direction: 'TB' | 'LR' = 'TB'
|
|
) {
|
|
const dagreGraph = new dagre.graphlib.Graph();
|
|
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
|
|
|
const nodeWidth = 180;
|
|
const nodeHeight = 60;
|
|
|
|
dagreGraph.setGraph({ rankdir: direction, nodesep: 50, ranksep: 80 });
|
|
|
|
nodes.forEach((node) => {
|
|
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
|
});
|
|
|
|
edges.forEach((edge) => {
|
|
dagreGraph.setEdge(edge.source, edge.target);
|
|
});
|
|
|
|
dagre.layout(dagreGraph);
|
|
|
|
const layoutedNodes = nodes.map((node) => {
|
|
const nodeWithPosition = dagreGraph.node(node.id);
|
|
return {
|
|
...node,
|
|
position: {
|
|
x: nodeWithPosition.x - nodeWidth / 2,
|
|
y: nodeWithPosition.y - nodeHeight / 2,
|
|
},
|
|
};
|
|
});
|
|
|
|
return { nodes: layoutedNodes, edges };
|
|
}
|
|
|
|
function DependencyGraph({ projectName, packageName, tagName, onClose }: DependencyGraphProps) {
|
|
const navigate = useNavigate();
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [resolution, setResolution] = useState<DependencyResolutionResponse | null>(null);
|
|
const [nodes, setNodes, onNodesChange] = useNodesState<NodeData>([]);
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
|
|
|
const handleNavigate = useCallback((project: string, pkg: string) => {
|
|
navigate(`/project/${project}/${pkg}`);
|
|
onClose();
|
|
}, [navigate, onClose]);
|
|
|
|
// Build graph structure from resolution data
|
|
const buildFlowGraph = useCallback(async (
|
|
resolutionData: DependencyResolutionResponse,
|
|
onNavigate: (project: string, pkg: string) => void
|
|
) => {
|
|
const artifactMap = new Map<string, ResolvedArtifact>();
|
|
resolutionData.resolved.forEach(artifact => {
|
|
artifactMap.set(artifact.artifact_id, artifact);
|
|
});
|
|
|
|
// Fetch dependencies for each artifact
|
|
const depsMap = new Map<string, Dependency[]>();
|
|
|
|
for (const artifact of resolutionData.resolved) {
|
|
try {
|
|
const deps = await getArtifactDependencies(artifact.artifact_id);
|
|
depsMap.set(artifact.artifact_id, deps.dependencies);
|
|
} catch {
|
|
depsMap.set(artifact.artifact_id, []);
|
|
}
|
|
}
|
|
|
|
// Find the root artifact
|
|
const rootArtifact = resolutionData.resolved.find(
|
|
a => a.project === resolutionData.requested.project &&
|
|
a.package === resolutionData.requested.package
|
|
);
|
|
|
|
if (!rootArtifact) {
|
|
return { nodes: [], edges: [] };
|
|
}
|
|
|
|
const flowNodes: Node<NodeData>[] = [];
|
|
const flowEdges: Edge[] = [];
|
|
const visited = new Set<string>();
|
|
const nodeIdMap = new Map<string, string>(); // artifact_id -> node id
|
|
|
|
// Build nodes and edges recursively
|
|
const processNode = (artifact: ResolvedArtifact, isRoot: boolean) => {
|
|
if (visited.has(artifact.artifact_id)) {
|
|
return nodeIdMap.get(artifact.artifact_id);
|
|
}
|
|
|
|
visited.add(artifact.artifact_id);
|
|
const nodeId = `node-${flowNodes.length}`;
|
|
nodeIdMap.set(artifact.artifact_id, nodeId);
|
|
|
|
flowNodes.push({
|
|
id: nodeId,
|
|
type: 'dependency',
|
|
position: { x: 0, y: 0 }, // Will be set by dagre
|
|
data: {
|
|
label: `${artifact.project}/${artifact.package}`,
|
|
project: artifact.project,
|
|
package: artifact.package,
|
|
version: artifact.version || artifact.tag,
|
|
size: artifact.size,
|
|
isRoot,
|
|
onNavigate,
|
|
},
|
|
});
|
|
|
|
const deps = depsMap.get(artifact.artifact_id) || [];
|
|
|
|
for (const dep of deps) {
|
|
const childArtifact = resolutionData.resolved.find(
|
|
a => a.project === dep.project && a.package === dep.package
|
|
);
|
|
|
|
if (childArtifact) {
|
|
const childNodeId = processNode(childArtifact, false);
|
|
if (childNodeId) {
|
|
flowEdges.push({
|
|
id: `edge-${nodeId}-${childNodeId}`,
|
|
source: nodeId,
|
|
target: childNodeId,
|
|
markerEnd: {
|
|
type: MarkerType.ArrowClosed,
|
|
width: 15,
|
|
height: 15,
|
|
color: 'var(--accent-primary)',
|
|
},
|
|
style: {
|
|
stroke: 'var(--border-primary)',
|
|
strokeWidth: 2,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return nodeId;
|
|
};
|
|
|
|
processNode(rootArtifact, true);
|
|
|
|
// Apply dagre layout
|
|
return getLayoutedElements(flowNodes, flowEdges);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
async function loadData() {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const result = await resolveDependencies(projectName, packageName, tagName);
|
|
|
|
// If only the root package (no dependencies) and no missing deps, close the modal
|
|
const hasDeps = result.artifact_count > 1 || (result.missing && result.missing.length > 0);
|
|
if (!hasDeps) {
|
|
onClose();
|
|
return;
|
|
}
|
|
|
|
setResolution(result);
|
|
|
|
const { nodes: layoutedNodes, edges: layoutedEdges } = await buildFlowGraph(result, handleNavigate);
|
|
setNodes(layoutedNodes);
|
|
setEdges(layoutedEdges);
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
try {
|
|
const errorData = JSON.parse(err.message);
|
|
if (errorData.error === 'circular_dependency') {
|
|
setError(`Circular dependency detected: ${errorData.cycle?.join(' → ')}`);
|
|
} else if (errorData.error === 'dependency_conflict') {
|
|
setError(`Dependency conflict: ${errorData.message}`);
|
|
} else {
|
|
setError(err.message);
|
|
}
|
|
} catch {
|
|
setError(err.message);
|
|
}
|
|
} else {
|
|
setError('Failed to load dependency graph');
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
loadData();
|
|
}, [projectName, packageName, tagName, buildFlowGraph, handleNavigate, onClose, setNodes, setEdges]);
|
|
|
|
const defaultViewport = useMemo(() => ({ x: 50, y: 50, zoom: 0.8 }), []);
|
|
|
|
return (
|
|
<div className="dependency-graph-modal" onClick={onClose}>
|
|
<div className="dependency-graph-content" onClick={e => e.stopPropagation()}>
|
|
<div className="dependency-graph-header">
|
|
<h2>Dependency Graph</h2>
|
|
<div className="dependency-graph-info">
|
|
<span>{projectName}/{packageName} @ {tagName}</span>
|
|
{resolution && (
|
|
<span className="graph-stats">
|
|
{resolution.artifact_count} cached
|
|
{resolution.missing && resolution.missing.length > 0 && (
|
|
<span className="missing-count"> • {resolution.missing.length} not cached</span>
|
|
)}
|
|
• {formatBytes(resolution.total_size)} total
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button className="close-btn" onClick={onClose} title="Close">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="dependency-graph-container">
|
|
{loading ? (
|
|
<div className="graph-loading">
|
|
<div className="spinner"></div>
|
|
<span>Resolving dependencies...</span>
|
|
</div>
|
|
) : error ? (
|
|
<div className="graph-error">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
<line x1="12" y1="8" x2="12" y2="12"></line>
|
|
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
|
</svg>
|
|
<p>{error}</p>
|
|
</div>
|
|
) : nodes.length > 0 ? (
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
nodeTypes={nodeTypes}
|
|
defaultViewport={defaultViewport}
|
|
fitView
|
|
fitViewOptions={{ padding: 0.2 }}
|
|
minZoom={0.1}
|
|
maxZoom={2}
|
|
attributionPosition="bottom-left"
|
|
>
|
|
<Controls />
|
|
<Background color="var(--border-primary)" gap={20} />
|
|
</ReactFlow>
|
|
) : (
|
|
<div className="graph-empty">No dependencies to display</div>
|
|
)}
|
|
</div>
|
|
|
|
{resolution && resolution.missing && resolution.missing.length > 0 && (
|
|
<div className="missing-dependencies">
|
|
<h3>Not Cached ({resolution.missing.length})</h3>
|
|
<p className="missing-hint">These dependencies are referenced but not yet cached on the server.</p>
|
|
<ul className="missing-list">
|
|
{resolution.missing.map((dep, i) => (
|
|
<li key={i} className="missing-item">
|
|
<span className="missing-name">{dep.project}/{dep.package}</span>
|
|
{dep.constraint && <span className="missing-constraint">@{dep.constraint}</span>}
|
|
{dep.required_by && <span className="missing-required-by">← {dep.required_by}</span>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default DependencyGraph;
|