diff --git a/package-lock.json b/package-lock.json
index f01c9bb17d..366cb99e1b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,8 @@
"chart.js": "^4.5.1",
"chartjs-adapter-moment": "^1.0.1",
"clsx": "^2.1.1",
+ "d3": "^7.9.0",
+ "dagre-d3-es": "^7.0.13",
"install": "^0.13.0",
"moment": "^2.29.4",
"octokit": "^5.0.5",
@@ -8056,7 +8058,6 @@
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
- "license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
@@ -8504,10 +8505,9 @@
}
},
"node_modules/dagre-d3-es": {
- "version": "7.0.11",
- "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.11.tgz",
- "integrity": "sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==",
- "license": "MIT",
+ "version": "7.0.13",
+ "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz",
+ "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==",
"dependencies": {
"d3": "^7.9.0",
"lodash-es": "^4.17.21"
@@ -12017,6 +12017,15 @@
"uuid": "^11.1.0"
}
},
+ "node_modules/mermaid/node_modules/dagre-d3-es": {
+ "version": "7.0.11",
+ "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.11.tgz",
+ "integrity": "sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==",
+ "dependencies": {
+ "d3": "^7.9.0",
+ "lodash-es": "^4.17.21"
+ }
+ },
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
diff --git a/package.json b/package.json
index 13c31ccfa0..86a4b65536 100644
--- a/package.json
+++ b/package.json
@@ -16,14 +16,16 @@
"dependencies": {
"@docusaurus/core": "^3.9.2",
"@docusaurus/plugin-client-redirects": "^3.9.2",
- "@docusaurus/preset-classic": "^3.9.2",
"@docusaurus/plugin-content-blog": "^3.5.2",
+ "@docusaurus/preset-classic": "^3.9.2",
"@docusaurus/theme-mermaid": "^3.9.2",
"@mdx-js/react": "^3.1.1",
"@stoplight/json-ref-resolver": "^3.1.6",
"chart.js": "^4.5.1",
"chartjs-adapter-moment": "^1.0.1",
"clsx": "^2.1.1",
+ "d3": "^7.9.0",
+ "dagre-d3-es": "^7.0.13",
"install": "^0.13.0",
"moment": "^2.29.4",
"octokit": "^5.0.5",
diff --git a/src/pages/status/migration/DependencyGraph.jsx b/src/pages/status/migration/DependencyGraph.jsx
new file mode 100644
index 0000000000..f40c63aea7
--- /dev/null
+++ b/src/pages/status/migration/DependencyGraph.jsx
@@ -0,0 +1,442 @@
+import React, { useEffect, useState } from "react";
+import * as dagreD3 from "dagre-d3-es";
+import * as d3 from "d3";
+import { useHistory, useLocation } from "@docusaurus/router";
+import graphStyles from "./graphStyles.module.css";
+import {
+ getPrunedFeedstockStatus,
+ buildGraphDataStructure,
+ buildInitialGraph,
+ filterNodesBySearchTerm,
+ getAwaitingParentsWithNoParent,
+ applyHighlight,
+ createZoomedGraphData,
+ getNodeIdFromSvgElement
+} from "@site/src/utils/migrationGraphUtils";
+
+const LARGE_GRAPH_THRESHOLD = 1000;
+
+export default function DependencyGraph({ details, initialSelectedNode = null }) {
+ const history = useHistory();
+ const location = useLocation();
+ const [showDoneNodes, setShowDoneNodes] = useState(false);
+
+ const graphDataStructure = React.useMemo(() => {
+ if (!details) return { nodeMap: {}, edgeMap: {}, allNodeIds: [] };
+ const feedstock = showDoneNodes ? details._feedstock_status : getPrunedFeedstockStatus(details._feedstock_status, details);
+ return buildGraphDataStructure(feedstock, details);
+ }, [details, showDoneNodes]);
+ const [graph, setGraph] = useState(null);
+ const svgRef = React.useRef();
+ const [selectedNodeId, setSelectedNodeId] = React.useState(null);
+ const [isInitialized, setIsInitialized] = React.useState(false);
+ const [searchTerm, setSearchTerm] = React.useState("");
+ const [showDropdown, setShowDropdown] = React.useState(false);
+ const [showSettings, setShowSettings] = React.useState(false);
+ const [graphDirection, setGraphDirection] = React.useState("TB");
+ const [graphRanker, setGraphRanker] = React.useState("network-simplex");
+ const [graphAlign, setGraphAlign] = React.useState("");
+ const [userConfirmedLargeGraph, setUserConfirmedLargeGraph] = React.useState(false);
+
+ useEffect(() => {
+ if (initialSelectedNode && graphDataStructure.nodeMap[initialSelectedNode]) {
+ setSelectedNodeId(initialSelectedNode);
+ }
+ setIsInitialized(true);
+ }, [initialSelectedNode, graphDataStructure]);
+
+ useEffect(() => {
+ if (!isInitialized) return;
+
+ const searchParams = new URLSearchParams(location.search);
+
+ if (selectedNodeId) {
+ searchParams.set("dependency", selectedNodeId);
+ } else {
+ searchParams.delete("dependency");
+ }
+
+ const newSearch = searchParams.toString();
+ const newUrl = `${location.pathname}${newSearch ? `?${newSearch}` : ""}`;
+
+ if (newUrl !== `${location.pathname}${location.search}`) {
+ history.replace(newUrl);
+ }
+ }, [selectedNodeId, history, location.pathname, isInitialized]);
+
+ const zoomedGraphData = React.useMemo(() => {
+ return createZoomedGraphData(selectedNodeId, graphDataStructure);
+ }, [selectedNodeId, graphDataStructure]);
+
+ const { nodeMap, edgeMap, allNodeIds } = zoomedGraphData;
+
+ const nodeCount = allNodeIds.length;
+ const isLargeGraph = nodeCount > LARGE_GRAPH_THRESHOLD;
+ const shouldShowWarning = isLargeGraph && !userConfirmedLargeGraph;
+
+ useEffect(() => {
+ setUserConfirmedLargeGraph(false);
+ }, [graphDataStructure, showDoneNodes]);
+
+ useEffect(() => {
+ if (shouldShowWarning) return;
+ const g = buildInitialGraph(zoomedGraphData, graphDirection, graphRanker, graphAlign || undefined);
+ setGraph(g);
+ }, [zoomedGraphData, graphDirection, graphRanker, graphAlign, shouldShowWarning]);
+
+ const searchableNodeIds = React.useMemo(() => {
+ return graphDataStructure.allNodeIds.filter(nodeId => {
+ const node = graphDataStructure.nodeMap[nodeId];
+ if (!node) return false;
+ const hasChildren = node.outgoing && node.outgoing.length > 0;
+ const hasParents = node.incoming && node.incoming.length > 0;
+ return hasChildren || hasParents;
+ });
+ }, [graphDataStructure]);
+
+ const filteredNodes = React.useMemo(() => {
+ return filterNodesBySearchTerm(searchableNodeIds, searchTerm);
+ }, [searchTerm, searchableNodeIds]);
+
+ const awaitingParentsNoParent = React.useMemo(() => {
+ return getAwaitingParentsWithNoParent(nodeMap, details);
+ }, [nodeMap, details]);
+
+ useEffect(() => {
+ if (!graph || !svgRef.current) return;
+
+ const svg = d3.select(svgRef.current);
+ svg.selectAll("*").remove();
+
+ const svgGroup = svg.append("g");
+
+ const render = new dagreD3.render();
+ render(svgGroup, graph);
+
+ svgGroup.selectAll("g.node").each(function () {
+ const nodeId = getNodeIdFromSvgElement(this);
+ d3.select(this).attr("data-node-id", nodeId);
+ const graphNode = graph.node(nodeId);
+
+ if (selectedNodeId === nodeId) {
+ d3.select(this).classed("nodeSelected", true);
+
+ if (graphNode?.prUrl) {
+ const prMatch = graphNode.prUrl.match(/\/pull\/(\d+)/);
+ if (prMatch) {
+ const prNumber = prMatch[1];
+ const textElement = d3.select(this).select("text");
+
+ if (!textElement.attr("data-original-text")) {
+ textElement.attr("data-original-text", textElement.text());
+ }
+
+ textElement.text("");
+
+ textElement.append("tspan")
+ .attr("x", 0)
+ .attr("dy", 0)
+ .text(nodeId);
+
+ textElement.append("tspan")
+ .attr("class", "pr-number")
+ .attr("x", 0)
+ .attr("dy", "1.2em")
+ .attr("fill", "#ffffff")
+ .attr("font-size", "11px")
+ .style("text-decoration", "underline")
+ .style("cursor", "pointer")
+ .text(`#${prNumber}`)
+ .on("click", function(event) {
+ event.stopPropagation();
+ window.open(graphNode.prUrl, '_blank');
+ });
+ }
+ }
+ } else {
+ const textElement = d3.select(this).select("text");
+ const originalText = textElement.attr("data-original-text");
+ if (originalText && textElement.selectAll("tspan").size() > 0) {
+ textElement.selectAll("tspan").remove();
+ textElement.text(originalText);
+ textElement.attr("data-original-text", null);
+ }
+ }
+
+ if (awaitingParentsNoParent.has(nodeId)) {
+ d3.select(this)
+ .classed("nodeAwaitingParentsNoParent", true)
+ .selectAll("rect")
+ .style("stroke-dasharray", "5,5")
+ .style("stroke-width", "2px");
+ }
+ });
+
+ svgGroup.selectAll("g.edgePath").each(function () {
+ const edgeElement = d3.select(this);
+ const edges = graph.edges();
+ const edgeIndex = Array.from(svgGroup.selectAll("g.edgePath").nodes()).indexOf(this);
+
+ if (edgeIndex >= 0 && edgeIndex < edges.length) {
+ const edge = edges[edgeIndex];
+ const edgeData = graph.edge(edge);
+
+ if (edgeData && edgeData.edgeId) {
+ edgeElement.attr("data-edge-id", edgeData.edgeId);
+ }
+ }
+ });
+
+ svgGroup.selectAll("g.node").style("cursor", "pointer");
+
+ svgGroup.selectAll("g.node").on("mouseenter", function () {
+ const nodeId = d3.select(this).attr("data-node-id");
+ applyHighlight(svgGroup, nodeId, zoomedGraphData);
+ });
+
+ svgGroup.selectAll("g.node").on("mouseleave", function () {
+ applyHighlight(svgGroup, null, zoomedGraphData);
+ });
+
+ svgGroup.selectAll("g.node").on("click", function () {
+ const nodeId = d3.select(this).attr("data-node-id");
+
+ if (selectedNodeId === nodeId) {
+ return;
+ }
+
+ setSelectedNodeId(nodeId);
+ });
+
+ svg.on("click", function (event) {
+ if (event.target === this) {
+ setSelectedNodeId(null);
+ applyHighlight(svgGroup, null, zoomedGraphData);
+ }
+ });
+
+ const zoom = d3.zoom().on("zoom", (event) => {
+ svgGroup.attr("transform", event.transform);
+ });
+
+ svg.call(zoom);
+
+ const graphWidth = graph.graph().width;
+ const graphHeight = graph.graph().height;
+ const svgWidth = svgRef.current.clientWidth;
+ const svgHeight = svgRef.current.clientHeight;
+
+ const initialScale = Math.min(
+ svgWidth / graphWidth,
+ svgHeight / graphHeight,
+ 1
+ ) * 0.85;
+
+ const initialTranslate = [
+ (svgWidth - graphWidth * initialScale) / 2,
+ (svgHeight - graphHeight * initialScale) / 2,
+ ];
+
+ svg.call(
+ zoom.transform,
+ d3.zoomIdentity
+ .translate(initialTranslate[0], initialTranslate[1])
+ .scale(initialScale)
+ );
+
+ svg.on("dblclick.zoom", null);
+ svg.on("dblclick", function (event) {
+ if (event.target === this) {
+ svg.transition().duration(750).call(
+ zoom.transform,
+ d3.zoomIdentity
+ .translate(initialTranslate[0], initialTranslate[1])
+ .scale(initialScale)
+ );
+ }
+ });
+ }, [graph, selectedNodeId, awaitingParentsNoParent, zoomedGraphData]);
+
+ const handleSelectNode = (nodeName) => {
+ setSelectedNodeId(nodeName);
+ setSearchTerm("");
+ setShowDropdown(false);
+ };
+
+ return (
+
+
+
+ {showSettings && (
+
+
+
+ Include completed packages
+ setShowDoneNodes(e.target.checked)}
+ />
+
+
+
+
+
+ Direction
+ setGraphDirection(e.target.value)}
+ >
+ Top to Bottom
+ Bottom to Top
+ Left to Right
+ Right to Left
+
+
+
+ Ranker
+ setGraphRanker(e.target.value)}
+ >
+ Network Simplex
+ Tight Tree
+ Longest Path
+
+
+
+ Alignment
+ setGraphAlign(e.target.value)}
+ >
+ Center (default)
+ Upper Left
+ Upper Right
+ Down Left
+ Down Right
+
+
+
+
+ )}
+
+
+
{
+ setSearchTerm(e.target.value);
+ setShowDropdown(true);
+ }}
+ onFocus={() => setShowDropdown(true)}
+ onBlur={() => setTimeout(() => setShowDropdown(false), 200)}
+ />
+ {showDropdown && filteredNodes.length > 0 && (
+
+ {filteredNodes.slice(0, 10).map((nodeName) => (
+ handleSelectNode(nodeName)}
+ >
+ {nodeName}
+
+ ))}
+
+ )}
+
+
setShowSettings(!showSettings)}
+ className={`button button--secondary ${graphStyles.settingsButton}`}
+ title="Graph Settings"
+ >
+
+
+
+
+
+ {shouldShowWarning ? (
+
+
+
⚠️
+
Large Graph Warning
+
+ This graph contains {nodeCount.toLocaleString()} nodes .
+ Rendering more than {LARGE_GRAPH_THRESHOLD.toLocaleString()} nodes may slow down your browser and affect performance.
+
+
+ setUserConfirmedLargeGraph(true)}
+ className="button button--primary"
+ >
+ Continue Anyway
+
+ {showDoneNodes && (
+ setShowDoneNodes(false)}
+ className="button button--secondary"
+ >
+ Hide Completed Nodes
+
+ )}
+ {!showDoneNodes && (
+
+ Completed Nodes Already Hidden
+
+ )}
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ CI Passing
+
+
+
+ CI Failing
+
+
+
+ Bot/Solver Error or Status Unknown
+
+
+
+ Awaiting PR
+
+
+
+ Awaiting Parents
+
+
+
+ Awaiting Parent in Another Migration
+
+
+
+
+ Arrows point from package to its immediate children (dependents).
+ Use mouse wheel to zoom, drag to pan, click on a node to zoom to its subgraph, or click on the background to reset the view.
+
+
+ );
+}
diff --git a/src/pages/status/migration/graphStyles.module.css b/src/pages/status/migration/graphStyles.module.css
new file mode 100644
index 0000000000..0b85604681
--- /dev/null
+++ b/src/pages/status/migration/graphStyles.module.css
@@ -0,0 +1,400 @@
+/* Migration Dependency Graph Styles */
+
+.dependencyGraphContainer {
+ padding: 20px 0 0 0;
+}
+
+.graphHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.graphHeader h3 {
+ margin: 0;
+}
+
+.instructions {
+ font-size: 14px;
+ color: var(--ifm-color-emphasis-700);
+ font-style: italic;
+ margin-top: 12px;
+ display: block;
+}
+
+.graphContainer {
+ width: 100%;
+ height: 600px;
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-bottom: none;
+ border-radius: 8px 8px 0 0;
+ background-color: var(--ifm-color-emphasis-0);
+ overflow: hidden;
+}
+
+.graphContainer svg {
+ width: 100%;
+ height: 100%;
+}
+
+.graphContainer :global(svg g.edgePath path) {
+ stroke: var(--ifm-color-emphasis-800) !important;
+ stroke-width: 2px !important;
+ fill: none !important;
+}
+
+.graphContainer :global(svg g.edgeLabel) {
+ font-size: 12px;
+ fill: var(--ifm-color-emphasis-800);
+}
+
+.graphInfo {
+ margin-top: 15px;
+ padding: 10px;
+ background-color: var(--ifm-color-info-light);
+ border-left: 4px solid var(--ifm-color-info);
+ border-radius: 4px;
+ font-size: 14px;
+}
+
+.graphInfo p {
+ margin: 0;
+}
+
+/* Toggle switch styles */
+.toggleLabel {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: bold;
+ position: relative;
+ user-select: none;
+}
+
+.toggleInput {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.toggleSlider {
+ position: relative;
+ display: inline-block;
+ width: 44px;
+ height: 24px;
+ background-color: var(--ifm-color-emphasis-400);
+ border-radius: 12px;
+ transition: background-color 0.2s;
+}
+
+.toggleSlider::before {
+ content: "";
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ left: 2px;
+ top: 2px;
+ background-color: white;
+ border-radius: 50%;
+ transition: transform 0.2s;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+.toggleInput:checked + .toggleSlider {
+ background-color: var(--ifm-color-primary);
+}
+
+.toggleInput:checked + .toggleSlider::before {
+ transform: translateX(20px);
+}
+
+.toggleInput:focus + .toggleSlider {
+ box-shadow: 0 0 0 2px var(--ifm-color-primary-lighter);
+}
+
+.settingsPanel {
+ position: absolute;
+ top: 40px;
+ right: 0;
+ padding: 16px;
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: 4px;
+ background-color: var(--ifm-color-emphasis-0);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ z-index: 1000;
+ min-width: 400px;
+}
+
+.settingsGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.settingLabel {
+ display: block;
+ font-size: 12px;
+ margin-bottom: 4px;
+ font-weight: bold;
+}
+
+.settingSelect {
+ padding: 8px 12px;
+ font-size: 14px;
+ border-radius: 4px;
+ border: 1px solid var(--ifm-color-emphasis-300);
+ width: 100%;
+ box-sizing: border-box;
+ background-color: var(--ifm-color-emphasis-0);
+ cursor: pointer;
+}
+
+.searchContainer {
+ position: relative;
+ max-width: 400px;
+ flex: 0 1 400px;
+}
+
+.searchInput {
+ padding: 8px 12px;
+ font-size: 14px;
+ border-radius: 4px;
+ border: 1px solid var(--ifm-color-emphasis-300);
+ margin-top: 8px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.searchDropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-top: none;
+ border-radius: 0 0 4px 4px;
+ background-color: var(--ifm-color-emphasis-0);
+ list-style: none;
+ margin: 0;
+ padding: 8px 0;
+ max-height: 200px;
+ overflow-y: auto;
+ z-index: 1000;
+}
+
+.searchDropdownItem {
+ padding: 8px 12px;
+ cursor: pointer;
+}
+
+.searchDropdownItem:hover {
+ background-color: var(--ifm-color-emphasis-100);
+}
+
+.settingsButton {
+ margin-top: 8px;
+ flex-shrink: 0;
+}
+
+.warningContainer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.warningContent {
+ text-align: center;
+ padding: 40px;
+}
+
+.warningIcon {
+ font-size: 48px;
+ margin-bottom: 20px;
+}
+
+.warningText {
+ margin-bottom: 20px;
+ max-width: 500px;
+}
+
+.warningButtons {
+ display: flex;
+ gap: 12px;
+ justify-content: center;
+}
+
+.headerContainer {
+ position: relative;
+ width: 100%;
+}
+
+.headerControls {
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+ width: 100%;
+ justify-content: space-between;
+}
+
+.toggleContainer {
+ margin-bottom: 12px;
+}
+
+/* SVG Node Status Colors - using style guide colors */
+.graphContainer :global(g.node.nodeClean rect) {
+ fill: var(--ifm-color-success) !important;
+ stroke: var(--ifm-color-emphasis-800) !important;
+ stroke-width: 1px !important;
+}
+
+.graphContainer :global(g.node.nodeUnstable rect) {
+ fill: var(--ifm-color-danger) !important;
+ stroke: var(--ifm-color-emphasis-800) !important;
+ stroke-width: 1px !important;
+}
+
+.graphContainer :global(g.node.nodeBotError rect) {
+ fill: var(--ifm-color-warning) !important;
+ stroke: var(--ifm-color-emphasis-800) !important;
+ stroke-width: 1px !important;
+}
+
+.graphContainer :global(g.node.nodeNotSolvable rect) {
+ fill: var(--ifm-color-warning) !important;
+ stroke: var(--ifm-color-emphasis-800) !important;
+ stroke-width: 1px !important;
+}
+
+.graphContainer :global(g.node.nodeDone rect) {
+ fill: var(--ifm-color-success) !important;
+ stroke: var(--ifm-color-emphasis-800) !important;
+ stroke-width: 1px !important;
+}
+
+.graphContainer :global(g.node.nodeInPr rect) {
+ fill: var(--ifm-color-warning) !important;
+ stroke: var(--ifm-color-emphasis-800) !important;
+ stroke-width: 1px !important;
+}
+
+.graphContainer :global(g.node.nodeAwaitingPr rect) {
+ fill: var(--ifm-color-emphasis-600) !important;
+ stroke: var(--ifm-color-emphasis-800) !important;
+ stroke-width: 1px !important;
+}
+
+.graphContainer :global(g.node.nodeAwaitingParents rect) {
+ fill: var(--ifm-color-emphasis-500) !important;
+ stroke: var(--ifm-color-emphasis-800) !important;
+ stroke-width: 1px !important;
+}
+
+.graphContainer :global(g.node.nodeAwaitingParentsNoParent rect) {
+ stroke: var(--ifm-color-emphasis-800) !important;
+ stroke-width: 2px !important;
+}
+
+.graphContainer :global(g.node.nodeSelected rect) {
+ stroke-width: 4px !important;
+}
+
+.graphContainer :global(g.node.nodeUnknown rect) {
+ fill: var(--ifm-color-emphasis-400) !important;
+ stroke: var(--ifm-color-emphasis-800) !important;
+ stroke-width: 1px !important;
+}
+
+.graphContainer :global(g.node.nodeDefault rect) {
+ fill: var(--ifm-color-emphasis-200) !important;
+ stroke: var(--ifm-color-emphasis-800) !important;
+ stroke-width: 1px !important;
+}
+
+/* Text colors for contrast */
+.graphContainer :global(g.node.nodeClean text),
+.graphContainer :global(g.node.nodeDone text),
+.graphContainer :global(g.node.nodeUnstable text) {
+ fill: #ffffff !important;
+ font-size: 12px !important;
+ font-weight: bold !important;
+}
+
+.graphContainer :global(g.node.nodeBotError text),
+.graphContainer :global(g.node.nodeNotSolvable text),
+.graphContainer :global(g.node.nodeInPr text),
+.graphContainer :global(g.node.nodeAwaitingPr text),
+.graphContainer :global(g.node.nodeAwaitingParents text),
+.graphContainer :global(g.node.nodeUnknown text),
+.graphContainer :global(g.node.nodeDefault text) {
+ fill: #000000 !important;
+ font-size: 12px !important;
+ font-weight: bold !important;
+}
+
+/* Legend styles */
+.legend {
+ padding: 10px 12px;
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-top: none;
+ border-radius: 0 0 8px 8px;
+ background-color: var(--ifm-color-emphasis-0);
+}
+
+.legendItems {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.legendItem {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+}
+
+.legendCircle {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ border: 1px solid var(--ifm-color-emphasis-800);
+ flex-shrink: 0;
+}
+
+.legendSuccess {
+ background-color: var(--ifm-color-success);
+}
+
+.legendDanger {
+ background-color: var(--ifm-color-danger);
+}
+
+.legendWarning {
+ background-color: var(--ifm-color-warning);
+}
+
+.legendInfo {
+ background-color: var(--ifm-color-info);
+}
+
+.legendAwaitingPr {
+ background-color: var(--ifm-color-emphasis-600);
+}
+
+.legendAwaitingParents {
+ background-color: var(--ifm-color-emphasis-500);
+}
+
+.legendDashed {
+ background-color: var(--ifm-color-emphasis-500);
+ border: 1px dashed var(--ifm-color-emphasis-800);
+ box-sizing: border-box;
+}
+
+.legendLabel {
+ color: var(--ifm-color-emphasis-800);
+}
diff --git a/src/pages/status/migration/index.jsx b/src/pages/status/migration/index.jsx
index 92b3bbf868..b0ebffacb1 100644
--- a/src/pages/status/migration/index.jsx
+++ b/src/pages/status/migration/index.jsx
@@ -12,6 +12,8 @@ import TabItem from '@theme/TabItem';
import moment from 'moment';
import { compare } from '@site/src/components/StatusDashboard/current_migrations';
import { useSorting, SortableHeader } from '@site/src/components/SortableTable';
+import * as d3 from "d3";
+import DependencyGraph from "./DependencyGraph";
// GitHub GraphQL MergeStateStatus documentation
// Reference: https://docs.github.com/en/graphql/reference/enums#mergestatestatus
@@ -96,11 +98,15 @@ function formatExactDateTime(timestamp) {
export default function MigrationDetails() {
const location = useLocation();
const { siteConfig } = useDocusaurusContext();
+ const urlParams = new URLSearchParams(location.search);
+ const dependencyParam = urlParams.get("dependency");
+
const [state, setState] = useState({
- name: new URLSearchParams(location.search).get("name"),
+ name: urlParams.get("name"),
details: null,
redirect: false,
view: "table",
+ selectedDependency: dependencyParam,
});
const toggle = (view) => {
if (window && window.localStorage) {
@@ -122,6 +128,9 @@ export default function MigrationDetails() {
console.warn(`error reading from local storage`, error);
}
}
+ if (dependencyParam) {
+ view = "dependencies";
+ }
void (async () => {
try {
const url = urls.migrations.details.replace("", state.name);
@@ -136,7 +145,8 @@ export default function MigrationDetails() {
})();
}, []);
if (state.redirect) return ;
- const { details, name, view } = state;
+ const { details, name, view, selectedDependency } = state;
+
return (
Graph
+ toggle("dependencies")}
+ >
+ Dependencies
+
{name &&
", name)} target="_blank">
|| null}
{view === "graph" ?
{name} :
- (details && )
+ view === "dependencies" ?
+ (details && ) :
+ (details && )
}
@@ -309,8 +329,104 @@ function Filters({ counts, filters, onFilter }) {
function Graph(props) {
const [error, setState] = useState("");
+ const containerRef = React.useRef(null);
const url = urls.migrations.graph.replace("", props.children);
const onError = (error) => setState(error);
+
+ useEffect(() => {
+ if (!containerRef.current || error) return;
+
+ const container = d3.select(containerRef.current);
+ let timer = null;
+
+ const setupZoom = () => {
+ const svgElement = container.select('svg').node();
+ if (!svgElement) {
+ // Wait a bit for SVG to load
+ timer = setTimeout(setupZoom, 100);
+ return;
+ }
+
+ const svg = d3.select(svgElement);
+
+ // Check if group already exists
+ let svgGroup = svg.select('g.zoom-group');
+ if (svgGroup.empty()) {
+ svgGroup = svg.append('g').attr('class', 'zoom-group');
+
+ // Move all existing children into the group (except the group itself)
+ svg.selectAll('*').each(function() {
+ const node = this;
+ if (node !== svgGroup.node() && node.parentNode === svgElement) {
+ svgGroup.node().appendChild(node);
+ }
+ });
+ }
+
+ // Get SVG dimensions (use viewBox if available, otherwise use bounding rect)
+ const viewBox = svgElement.viewBox?.baseVal;
+ const svgWidth = viewBox ? viewBox.width : (svgElement.getBoundingClientRect().width || containerRef.current.clientWidth);
+ const svgHeight = viewBox ? viewBox.height : (svgElement.getBoundingClientRect().height || containerRef.current.clientHeight || 600);
+
+ const bbox = svgElement.getBBox();
+
+ const initialScale = Math.min(
+ svgWidth / bbox.width,
+ svgHeight / bbox.height,
+ 1
+ ) * 0.9;
+
+ const centerX = svgWidth / 2;
+ const centerY = svgHeight / 2;
+
+ const bboxCenterX = bbox.x + bbox.width / 2;
+ const bboxCenterY = bbox.y + bbox.height / 2;
+
+ const initialTranslate = [
+ centerX - bboxCenterX * initialScale,
+ centerY - bboxCenterY * initialScale,
+ ];
+
+ // Store initial transform for reset
+ const initialTransform = d3.zoomIdentity
+ .translate(initialTranslate[0], initialTranslate[1])
+ .scale(initialScale);
+
+ // Set up zoom behavior - apply to SVG element for proper drag sensitivity
+ const zoom = d3.zoom()
+ .scaleExtent([0.1, 4])
+ .on("zoom", (event) => {
+ svgGroup.attr("transform", event.transform);
+ });
+
+ svg.call(zoom);
+
+ if (!svgGroup.attr("transform")) {
+ svg.call(zoom.transform, initialTransform);
+ }
+
+ // Double-click to reset zoom/pan to initial state
+ svg.on("dblclick.zoom", null); // Remove default double-click zoom
+ svg.on("dblclick", function() {
+ svg.transition()
+ .duration(750)
+ .call(zoom.transform, initialTransform);
+ });
+ };
+
+ setupZoom();
+
+ return () => {
+ if (timer) clearTimeout(timer);
+ const svgElement = container.select('svg').node();
+ if (svgElement) {
+ const svg = d3.select(svgElement);
+ svg.on(".zoom", null);
+ svg.on("dblclick", null);
+ }
+ };
+ }, [error, url]);
+
return (
@@ -323,12 +439,23 @@ function Graph(props) {
Graph is unavailable.
:
-
+
}
diff --git a/src/utils/migrationGraphUtils.js b/src/utils/migrationGraphUtils.js
new file mode 100644
index 0000000000..6439aff25b
--- /dev/null
+++ b/src/utils/migrationGraphUtils.js
@@ -0,0 +1,488 @@
+/**
+ * Utility functions for graph operations in the migration status page
+ */
+
+import * as dagreD3 from "dagre-d3-es";
+import * as d3 from "d3";
+
+const DEFAULT_GRAPH_SETTINGS = {
+ nodesep: 50,
+ ranksep: 100,
+ rankdir: "TB",
+};
+
+export const getGraphSettings = (
+ rankdir = "TB",
+ ranker = "network-simplex",
+ align = undefined,
+) => ({
+ nodesep: 50,
+ ranksep: 100,
+ rankdir: rankdir,
+ ranker: ranker,
+ align: align,
+});
+
+const EDGE_STYLE = {
+ arrowheadStyle: "fill: var(--ifm-color-emphasis-800);",
+ style: "stroke: var(--ifm-color-emphasis-800); stroke-width: 2px;",
+};
+
+export const getNodeIdFromSvgElement = (element) => {
+ const fullText = d3.select(element).select("text").text().split("\n")[0];
+ return fullText.split("(")[0].trim();
+};
+
+export const getPrunedFeedstockStatus = (feedstockStatus, details) => {
+ if (!feedstockStatus || !details?.done) return feedstockStatus;
+
+ const mergedPackages = new Set(details.done);
+ const pruned = {};
+
+ Object.entries(feedstockStatus).forEach(([name, data]) => {
+ if (!mergedPackages.has(name)) {
+ pruned[name] = data;
+ }
+ });
+
+ return pruned;
+};
+
+export const getStatusClass = (prStatus) => {
+ switch (prStatus) {
+ case "clean":
+ return "nodeClean";
+ case "unstable":
+ return "nodeUnstable";
+ case "bot-error":
+ return "nodeBotError";
+ case "not-solvable":
+ return "nodeNotSolvable";
+ case "done":
+ return "nodeDone";
+ case "in-pr":
+ return "nodeInPr";
+ case "awaiting-pr":
+ return "nodeAwaitingPr";
+ case "awaiting-parents":
+ return "nodeAwaitingParents";
+ case "unknown":
+ return "nodeUnknown";
+ default:
+ return "nodeDefault";
+ }
+};
+
+export const filterNodesBySearchTerm = (nodeNames, searchTerm) => {
+ if (!searchTerm) return [];
+ return nodeNames.filter((name) =>
+ name.toLowerCase().includes(searchTerm.toLowerCase()),
+ );
+};
+
+export const getAwaitingParentsWithNoParent = (nodeMap, details) => {
+ const noParents = new Set();
+ const allChildren = new Set();
+
+ Object.values(nodeMap).forEach((nodeInfo) => {
+ const children = nodeInfo.data?.immediate_children || [];
+ children.forEach((childId) => {
+ allChildren.add(childId);
+ });
+ });
+
+ const awaitingParents = details?.["awaiting-parents"] || [];
+ awaitingParents.forEach((name) => {
+ if (!allChildren.has(name)) {
+ noParents.add(name);
+ }
+ });
+
+ return noParents;
+};
+
+export const findAllAncestors = (nodeId, graphDataStructure) => {
+ const { nodeMap, edgeMap } = graphDataStructure;
+ const ancestors = new Set();
+ const queue = [nodeId];
+ const visited = new Set([nodeId]);
+
+ while (queue.length > 0) {
+ const current = queue.shift();
+ const incomingEdges = nodeMap[current]?.incoming || [];
+
+ incomingEdges.forEach((eid) => {
+ const parentId = edgeMap[eid].source;
+ if (!visited.has(parentId)) {
+ visited.add(parentId);
+ ancestors.add(parentId);
+ queue.push(parentId);
+ }
+ });
+ }
+
+ return ancestors;
+};
+
+export const findAllDescendants = (nodeId, graphDataStructure) => {
+ const { nodeMap, edgeMap } = graphDataStructure;
+ const descendants = new Set();
+ const queue = [nodeId];
+ const visited = new Set([nodeId]);
+
+ while (queue.length > 0) {
+ const current = queue.shift();
+ const outgoingEdges = nodeMap[current]?.outgoing || [];
+
+ outgoingEdges.forEach((eid) => {
+ const childId = edgeMap[eid].target;
+ if (!visited.has(childId)) {
+ visited.add(childId);
+ descendants.add(childId);
+ queue.push(childId);
+ }
+ });
+ }
+
+ return descendants;
+};
+
+export const findConnectedComponents = (
+ graphDataStructure,
+ nodesWithChildren,
+) => {
+ const { nodeMap } = graphDataStructure;
+ const visited = new Set();
+ const components = [];
+
+ // Build parent map for efficient parent lookup
+ const parentMap = {}; // childId -> [parentId1, parentId2, ...]
+ Object.entries(nodeMap).forEach(([nodeId, nodeInfo]) => {
+ const children = nodeInfo.data?.immediate_children || [];
+ children.forEach((childId) => {
+ if (!parentMap[childId]) {
+ parentMap[childId] = [];
+ }
+ parentMap[childId].push(nodeId);
+ });
+ });
+
+ const dfs = (nodeId, component, visited) => {
+ if (visited.has(nodeId)) return;
+ visited.add(nodeId);
+ component.add(nodeId);
+
+ const nodeInfo = nodeMap[nodeId];
+ if (nodeInfo) {
+ // Follow children
+ const children = nodeInfo.data?.immediate_children || [];
+ children.forEach((childId) => {
+ if (nodeMap[childId]) {
+ dfs(childId, component, visited);
+ }
+ });
+
+ // Follow parents using the parent map
+ const parents = parentMap[nodeId] || [];
+ parents.forEach((parentId) => {
+ dfs(parentId, component, visited);
+ });
+ }
+ };
+
+ nodesWithChildren.forEach((name) => {
+ if (!visited.has(name)) {
+ const component = new Set();
+ dfs(name, component, visited);
+ if (component.size > 0) {
+ components.push(component);
+ }
+ }
+ });
+
+ return components;
+};
+
+export const buildGraphDataStructure = (feedstockStatus, details = null) => {
+ if (!feedstockStatus || Object.keys(feedstockStatus).length === 0) {
+ return { nodeMap: {}, edgeMap: {}, allNodeIds: [] };
+ }
+
+ const nodeMap = {};
+ const edgeMap = {};
+
+ // Build category map from details for quick lookup
+ const nodeCategoryMap = {};
+ if (details) {
+ [
+ "done",
+ "in-pr",
+ "awaiting-pr",
+ "awaiting-parents",
+ "not-solvable",
+ "bot-error",
+ ].forEach((category) => {
+ if (details[category]) {
+ details[category].forEach((nodeName) => {
+ nodeCategoryMap[nodeName] = category;
+ });
+ }
+ });
+ }
+
+ // Initialize all nodes
+ Object.keys(feedstockStatus).forEach((nodeId) => {
+ nodeMap[nodeId] = {
+ data: {
+ ...feedstockStatus[nodeId],
+ category: nodeCategoryMap[nodeId] || "unknown", // Add category to data
+ },
+ incoming: [],
+ outgoing: [],
+ };
+ });
+
+ // Build edges from immediate_children
+ Object.entries(feedstockStatus).forEach(([nodeId, data]) => {
+ if (data.immediate_children && Array.isArray(data.immediate_children)) {
+ data.immediate_children.forEach((childId) => {
+ if (feedstockStatus[childId]) {
+ const edgeId = `${nodeId}->${childId}`;
+ edgeMap[edgeId] = {
+ source: nodeId,
+ target: childId,
+ };
+ nodeMap[nodeId].outgoing.push(edgeId);
+ nodeMap[childId].incoming.push(edgeId);
+ }
+ });
+ }
+ });
+
+ return {
+ nodeMap,
+ edgeMap,
+ allNodeIds: Object.keys(nodeMap),
+ };
+};
+
+export const buildInitialGraph = (
+ graphDataStructure,
+ rankdir = "TB",
+ ranker = "network-simplex",
+ align = undefined,
+) => {
+ const { nodeMap, edgeMap, allNodeIds } = graphDataStructure;
+
+ // Identify nodes that have direct children using nodeMap
+ const nodesWithChildren = new Set();
+ allNodeIds.forEach((nodeId) => {
+ if (nodeMap[nodeId].outgoing && nodeMap[nodeId].outgoing.length > 0) {
+ nodesWithChildren.add(nodeId);
+ }
+ });
+
+ // Find connected components using the data structure
+ const components = findConnectedComponents(
+ graphDataStructure,
+ nodesWithChildren,
+ );
+
+ // Build and return the graph using the data structure
+ return buildGraph(
+ nodeMap,
+ edgeMap,
+ components,
+ nodesWithChildren,
+ rankdir,
+ ranker,
+ align,
+ );
+};
+
+export const applyHighlight = (svgGroup, nodeId, graphDataStructure) => {
+ const { nodeMap, edgeMap } = graphDataStructure;
+
+ if (!nodeId) {
+ // Clear all highlights
+ svgGroup.selectAll("g.node").style("opacity", 1);
+ svgGroup.selectAll("g.edgePath").style("opacity", 1);
+ svgGroup
+ .selectAll("g.edgePath path")
+ .style("stroke", "var(--ifm-color-emphasis-800)")
+ .style("stroke-width", "2px");
+ return;
+ }
+
+ // Get related nodes and edges from our data structure
+ const outgoingEdgeIds = nodeMap[nodeId]?.outgoing || [];
+ const incomingEdgeIds = nodeMap[nodeId]?.incoming || [];
+ const allRelatedEdgeIds = new Set([...outgoingEdgeIds, ...incomingEdgeIds]);
+
+ const childNodeIds = outgoingEdgeIds.map((eid) => edgeMap[eid].target);
+ const parentNodeIds = incomingEdgeIds.map((eid) => edgeMap[eid].source);
+ const highlightNodeIds = new Set([nodeId, ...childNodeIds, ...parentNodeIds]);
+
+ // Dim all nodes
+ svgGroup.selectAll("g.node").style("opacity", function () {
+ const nid = d3.select(this).attr("data-node-id");
+ return highlightNodeIds.has(nid) ? 1 : 0.2;
+ });
+
+ // Dim all edges
+ svgGroup.selectAll("g.edgePath").style("opacity", 0.05);
+
+ // Highlight related edges (both incoming and outgoing)
+ svgGroup.selectAll("g.edgePath").each(function () {
+ const eid = d3.select(this).attr("data-edge-id");
+ if (allRelatedEdgeIds.has(eid)) {
+ // Move to front
+ this.parentNode.appendChild(this);
+
+ d3.select(this)
+ .style("opacity", 1)
+ .selectAll("path")
+ .style("stroke", "var(--ifm-color-warning)")
+ .style("stroke-width", "4px");
+ }
+ });
+};
+
+export const createZoomedGraphData = (nodeIdToZoom, graphDataStructure) => {
+ if (!nodeIdToZoom) {
+ // No zoom - return the full graph data structure
+ return graphDataStructure;
+ }
+
+ const { nodeMap: nodeMapData, edgeMap: edgeMapData } = graphDataStructure;
+
+ // Find all ancestors and descendants using utility functions
+ const ancestors = findAllAncestors(nodeIdToZoom, graphDataStructure);
+ const descendants = findAllDescendants(nodeIdToZoom, graphDataStructure);
+ const visibleNodes = new Set([nodeIdToZoom, ...ancestors, ...descendants]);
+
+ // Create filtered edgeMap with only edges between visible nodes
+ const filteredEdgeMap = {};
+ const filteredEdgeIds = new Set();
+ Object.entries(edgeMapData).forEach(([edgeId, edge]) => {
+ if (visibleNodes.has(edge.source) && visibleNodes.has(edge.target)) {
+ filteredEdgeMap[edgeId] = edge;
+ filteredEdgeIds.add(edgeId);
+ }
+ });
+
+ // Create filtered nodeMap with only visible nodes and filtered edge references
+ const filteredNodeMap = {};
+ visibleNodes.forEach((nodeId) => {
+ if (nodeMapData[nodeId]) {
+ const originalNode = nodeMapData[nodeId];
+ filteredNodeMap[nodeId] = {
+ ...originalNode,
+ incoming: originalNode.incoming.filter((edgeId) =>
+ filteredEdgeIds.has(edgeId),
+ ),
+ outgoing: originalNode.outgoing.filter((edgeId) =>
+ filteredEdgeIds.has(edgeId),
+ ),
+ };
+ }
+ });
+
+ return {
+ nodeMap: filteredNodeMap,
+ edgeMap: filteredEdgeMap,
+ allNodeIds: Array.from(visibleNodes),
+ };
+};
+
+// Helper function to add a node to the graph
+const addNodeToGraph = (g, nodeId, nodeMap, nodeToComponent, addedNodes) => {
+ if (addedNodes.has(nodeId)) return;
+
+ const nodeInfo = nodeMap[nodeId];
+ if (!nodeInfo) return;
+
+ const category = nodeInfo.data.category || "unknown";
+ const prStatus = nodeInfo.data.pr_status || "unknown";
+ const componentId = nodeToComponent[nodeId];
+
+ // Determine color based on category first, then PR status
+ let colorStatus = category;
+ if (category === "in-pr" && prStatus !== "unknown") {
+ // For in-PR nodes, use the PR status for color
+ colorStatus = prStatus;
+ }
+
+ g.setNode(nodeId, {
+ label: nodeId,
+ prUrl: nodeInfo.data.pr_url, // Store PR URL for later use
+ rx: 5,
+ ry: 5,
+ padding: 15,
+ class: getStatusClass(colorStatus),
+ });
+
+ if (componentId) {
+ g.setParent(nodeId, componentId);
+ }
+
+ addedNodes.add(nodeId);
+};
+
+export const buildGraph = (
+ nodeMap,
+ edgeMap,
+ components,
+ nodesWithChildren,
+ rankdir = "TB",
+ ranker = "network-simplex",
+ align = undefined,
+) => {
+ const g = new dagreD3.graphlib.Graph({ compound: true, directed: true })
+ .setGraph(getGraphSettings(rankdir, ranker, align))
+ .setDefaultEdgeLabel(() => ({}));
+
+ // Add compound nodes (subgraphs) for each component
+ components.forEach((component, componentIndex) => {
+ const componentId = `component-${componentIndex}`;
+ g.setNode(componentId, {
+ label: "",
+ clusterLabelPos: "top",
+ style:
+ "fill: none; stroke: var(--ifm-color-emphasis-300); stroke-width: 1px; stroke-dasharray: 5,5;",
+ });
+ });
+
+ // Add nodes to their components
+ const nodeToComponent = {};
+ components.forEach((component, componentIndex) => {
+ component.forEach((nodeId) => {
+ nodeToComponent[nodeId] = `component-${componentIndex}`;
+ });
+ });
+
+ // Add all nodes and edges in a single pass
+ const addedNodes = new Set();
+
+ // Process all nodes with children
+ nodesWithChildren.forEach((name) => {
+ // Add the parent node
+ addNodeToGraph(g, name, nodeMap, nodeToComponent, addedNodes);
+
+ const nodeInfo = nodeMap[name];
+ if (!nodeInfo) return;
+
+ // Process all outgoing edges and add child nodes
+ nodeInfo.outgoing.forEach((edgeId) => {
+ const childId = edgeMap[edgeId].target;
+
+ // Add the child node (leaf node)
+ addNodeToGraph(g, childId, nodeMap, nodeToComponent, addedNodes);
+
+ // Add edge with edge ID
+ g.setEdge(name, childId, { ...EDGE_STYLE, edgeId });
+ });
+ });
+
+ return g;
+};