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) { return (
data.onNavigate(data.project, data.package)} >
{data.package}
{data.version && {data.version}} {formatBytes(data.size)}
); } const nodeTypes = { dependency: DependencyNode }; // Dagre layout function function getLayoutedElements( nodes: Node[], 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(null); const [resolution, setResolution] = useState(null); const [nodes, setNodes, onNodesChange] = useNodesState([]); 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(); resolutionData.resolved.forEach(artifact => { artifactMap.set(artifact.artifact_id, artifact); }); // Fetch dependencies for each artifact const depsMap = new Map(); 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[] = []; const flowEdges: Edge[] = []; const visited = new Set(); const nodeIdMap = new Map(); // 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 (
e.stopPropagation()}>

Dependency Graph

{projectName}/{packageName} @ {tagName} {resolution && ( {resolution.artifact_count} cached {resolution.missing && resolution.missing.length > 0 && ( • {resolution.missing.length} not cached )} • {formatBytes(resolution.total_size)} total )}
{loading ? (
Resolving dependencies...
) : error ? (

{error}

) : nodes.length > 0 ? ( ) : (
No dependencies to display
)}
{resolution && resolution.missing && resolution.missing.length > 0 && (

Not Cached ({resolution.missing.length})

These dependencies are referenced but not yet cached on the server.

    {resolution.missing.map((dep, i) => (
  • {dep.project}/{dep.package} {dep.constraint && @{dep.constraint}} {dep.required_by && ← {dep.required_by}}
  • ))}
)}
); } export default DependencyGraph;