From 86068f93dceaa41ae60a2006e3b1fa28adedf7ec Mon Sep 17 00:00:00 2001 From: M Bussonnier Date: Wed, 26 Nov 2025 11:14:00 +0100 Subject: [PATCH 1/6] Use dagree for interactive migration graph In addition to the normal graphviz svg dagree is already a dependency via mermaid/docusaurus This filter and a remove packages that are "done", and/or have no parrent children befor showing the graph, and split it into connected components. The interactive graph has the following features: - span/zoom with mouse/trackpad - hover a node to dimm all nodes except direct children/parents. - click on a node to show only current graph and all parents (and their parents...)/children (and their children...), but not siblings. A filter search bar to find a node easily and select it without having to click on it. A dropdown for the various dagree layout options I think are un-necessary but let us explore the various dagree options. The node color should reflect the ci status of the PR, but are not properly clickable to open given PR, this is something I'm planning to do if there is interest. Note that there are some hacks as some packages are marked as "awaiting parent", while they do not have any parent pending (like pyside2 for 314t, llvmlite for 314t also IIRC), maybe this should be fixed in the table as well with a different label. I'm also rendering the graph even if there is an extremly large number of node, I think I there should be a confirmation if there is say > 1k nodes and a confirmation, but one of my powerful machine handles things really well so not sure. --- package-lock.json | 19 +- package.json | 4 +- src/pages/status/migration/graphUtils.js | 457 +++++++++++++++++++ src/pages/status/migration/index.jsx | 344 +++++++++++++- src/pages/status/migration/styles.module.css | 61 +++ 5 files changed, 878 insertions(+), 7 deletions(-) create mode 100644 src/pages/status/migration/graphUtils.js diff --git a/package-lock.json b/package-lock.json index 233c6a1bd51..cd443491389 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" @@ -12001,6 +12001,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 19c7997a595..a7f170c396d 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/graphUtils.js b/src/pages/status/migration/graphUtils.js new file mode 100644 index 00000000000..bd83879407f --- /dev/null +++ b/src/pages/status/migration/graphUtils.js @@ -0,0 +1,457 @@ +/** + * Utility functions for graph operations in the migration status page + */ + +import * as dagreD3 from "dagre-d3-es"; +import * as d3 from "d3"; + +// Constants for graph styling +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: #333;", + style: "stroke: #333; stroke-width: 2px;", +}; + +// Helper function to extract node ID from SVG element +export const getNodeIdFromSvgElement = (element) => { + const fullText = d3.select(element).select("text").text().split("\n")[0]; + return fullText.split("(")[0].trim(); +}; + +// Remove any done node for a smaller graph +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 getStatusColor = (prStatus) => { + switch (prStatus) { + case "clean": + return "#28a745"; // Green + case "unstable": + return "#ffc107"; // Yellow + case "unknown": + return "#adb5bd"; // Lighter gray + default: + return "#e9ecef"; // Light gray for awaiting + } +}; + +export const getStatusTextColor = (prStatus) => { + return prStatus === "clean" ? "#ffffff" : "#000000"; +}; + +export const filterNodesBySearchTerm = (nodeNames, searchTerm) => { + if (!searchTerm) return []; + return nodeNames.filter((name) => + name.toLowerCase().includes(searchTerm.toLowerCase()), + ); +}; + +// some node are awaiting parent, but have no current parent in the graph +// eg.pyside2 on 3.14t I'm guessing this is awaiting external changes (PyPI?) +export const getAwaitingParentsWithNoParent = (nodeMap, details) => { + const noParents = new Set(); + const allChildren = new Set(); + + // Collect all nodes that are children of any node + Object.values(nodeMap).forEach((nodeInfo) => { + const children = nodeInfo.data?.immediate_children || []; + children.forEach((childId) => { + allChildren.add(childId); + }); + }); + + // Find packages in awaiting-parents that are not children of any node + 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) => { + if (!feedstockStatus || Object.keys(feedstockStatus).length === 0) { + return { nodeMap: {}, edgeMap: {}, allNodeIds: [] }; + } + + const nodeMap = {}; + const edgeMap = {}; + + // Initialize all nodes + Object.keys(feedstockStatus).forEach((nodeId) => { + nodeMap[nodeId] = { + data: feedstockStatus[nodeId], + 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", "#333") + .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", "#FF6B35") + .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 status = nodeInfo.data.pr_status || "unknown"; + const componentId = nodeToComponent[nodeId]; + + g.setNode(nodeId, { + label: nodeId, + rx: 5, + ry: 5, + padding: 10, + style: `fill: ${getStatusColor(status)}; stroke: #333; stroke-width: 1px;`, + labelStyle: `fill: ${getStatusTextColor(status)}; font-size: 12px; font-weight: bold;`, + }); + + 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: #ccc; 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; +}; diff --git a/src/pages/status/migration/index.jsx b/src/pages/status/migration/index.jsx index 1e4ed88a18a..f5a26b8696b 100644 --- a/src/pages/status/migration/index.jsx +++ b/src/pages/status/migration/index.jsx @@ -9,6 +9,20 @@ import styles from "./styles.module.css"; import { Tooltip } from "react-tooltip"; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; +import * as dagreD3 from "dagre-d3-es"; +import * as d3 from "d3"; +import { + getPrunedFeedstockStatus, + buildGraphDataStructure, + buildInitialGraph, + getStatusColor, + getStatusTextColor, + filterNodesBySearchTerm, + getAwaitingParentsWithNoParent, + applyHighlight, + createZoomedGraphData, + getNodeIdFromSvgElement +} from "./graphUtils"; // { Done, In PR, Awaiting PR, Awaiting parents, Not solvable, Bot error } // The third value is a boolean representing the default display state on load @@ -83,6 +97,14 @@ export default function MigrationDetails() { }, []); if (state.redirect) return ; const { details, name, view } = state; + + // Build graph data structure from pruned feedstock status + const graphDataStructure = React.useMemo(() => { + if (!details) return { nodeMap: {}, edgeMap: {}, allNodeIds: [] }; + const prunedFeedstock = getPrunedFeedstockStatus(details._feedstock_status, details); + return buildGraphDataStructure(prunedFeedstock); + }, [details]); + return ( Graph + {name && ", name)} target="_blank">
  • || null} {view === "graph" ? {name} : - (details && ) + view === "dependencies" ? + (details && ) : + (details &&
    ) } @@ -359,3 +391,313 @@ async function checkPausedOrClosed(name) { } } } + +function DependencyGraph({ graphDataStructure, details }) { + const [graph, setGraph] = useState(null); + const svgRef = React.useRef(); + const [selectedNodeId, setSelectedNodeId] = React.useState(null); + const [searchTerm, setSearchTerm] = React.useState(""); + const [showDropdown, setShowDropdown] = React.useState(false); + const [graphDirection, setGraphDirection] = React.useState("TB"); + const [graphRanker, setGraphRanker] = React.useState("network-simplex"); + const [graphAlign, setGraphAlign] = React.useState(""); + + // Create zoomed graph data based on selected node + const zoomedGraphData = React.useMemo(() => { + return createZoomedGraphData(selectedNodeId, graphDataStructure); + }, [selectedNodeId, graphDataStructure]); + + const { nodeMap, edgeMap, allNodeIds } = zoomedGraphData; + + useEffect(() => { + const g = buildInitialGraph(zoomedGraphData, graphDirection, graphRanker, graphAlign || undefined); + setGraph(g); + }, [zoomedGraphData, graphDirection, graphRanker, graphAlign]); + + // Get searchable nodes (only nodes with children or parents) + 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]); + + // Filter nodes based on search term + const filteredNodes = React.useMemo(() => { + return filterNodesBySearchTerm(searchableNodeIds, searchTerm); + }, [searchTerm, searchableNodeIds]); + + // Identify nodes in "awaiting-parents" that have no parents in the graph + 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); + + // Highlight selected node + if (selectedNodeId === nodeId) { + d3.select(this).selectAll("rect") + .style("stroke-width", "3px") + .style("fill", "#ADD8E6"); + } + + // Mark nodes in awaiting-parents with no parents with a light red background + if (awaitingParentsNoParent.has(nodeId)) { + d3.select(this).selectAll("rect") + .style("fill", "#ffe6e6") + .style("stroke-dasharray", "5,5") + .style("stroke-width", "2px"); + } + }); + + // Set edge IDs from graph data + 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) { + setSelectedNodeId(null); + return; + } + + setSelectedNodeId(nodeId); + }); + + // Click on background (void) to reset view + 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); + + // Center the graph initially + 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) + ); + }, [graph, selectedNodeId, awaitingParentsNoParent, zoomedGraphData]); + + const handleSelectNode = (nodeName) => { + setSelectedNodeId(nodeName); + setSearchTerm(""); + setShowDropdown(false); + }; + + return ( +
    +
    +
    +

    Dependency Graph

    +
    +
    + { + setSearchTerm(e.target.value); + setShowDropdown(true); + }} + onFocus={() => setShowDropdown(true)} + onBlur={() => setTimeout(() => setShowDropdown(false), 200)} + style={{ + padding: "8px 12px", + fontSize: "14px", + borderRadius: "4px", + border: "1px solid var(--ifm-color-emphasis-300)", + marginTop: "8px", + width: "100%", + boxSizing: "border-box" + }} + /> + {showDropdown && filteredNodes.length > 0 && ( +
      + {filteredNodes.slice(0, 10).map((nodeName) => ( +
    • handleSelectNode(nodeName)} + style={{ + padding: "8px 12px", + cursor: "pointer", + hover: { backgroundColor: "var(--ifm-color-emphasis-100)" } + }} + onMouseEnter={(e) => e.target.style.backgroundColor = "var(--ifm-color-emphasis-100)"} + onMouseLeave={(e) => e.target.style.backgroundColor = "transparent"} + > + {nodeName} +
    • + ))} +
    + )} +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +
    + + 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/styles.module.css b/src/pages/status/migration/styles.module.css index f5a9f334be5..99fb54f1c67 100644 --- a/src/pages/status/migration/styles.module.css +++ b/src/pages/status/migration/styles.module.css @@ -180,3 +180,64 @@ position: relative; left: 14px; } + +.dependencyGraphContainer { + padding: 20px 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-radius: 8px; + background-color: var(--ifm-color-emphasis-0); + overflow: hidden; +} + +.graphContainer svg { + width: 100%; + height: 100%; +} + +.graphContainer :global(svg g.edgePath path) { + stroke: #333 !important; + stroke-width: 2px !important; + fill: none !important; +} + +.graphContainer :global(svg g.edgeLabel) { + font-size: 12px; + fill: #333; +} + +.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; +} From d25b51559732a666b75d73566776089788023362 Mon Sep 17 00:00:00 2001 From: M Bussonnier Date: Wed, 26 Nov 2025 11:44:06 +0100 Subject: [PATCH 2/6] Reapply "fFIx zoom/panning of SVG." This reverts commit 6e65d733e71fef7ce31a55273315b5d32e6bac94. --- src/pages/status/migration/index.jsx | 117 ++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/src/pages/status/migration/index.jsx b/src/pages/status/migration/index.jsx index f5a26b8696b..5bcfd67aa22 100644 --- a/src/pages/status/migration/index.jsx +++ b/src/pages/status/migration/index.jsx @@ -277,8 +277,112 @@ 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); + + // Get bounding box of the content (relative to SVG coordinate system) + const bbox = svgElement.getBBox(); + + // Calculate initial transform values + const initialScale = Math.min( + svgWidth / bbox.width, + svgHeight / bbox.height, + 1 + ) * 0.9; + + // Calculate center position in SVG coordinate system + const centerX = svgWidth / 2; + const centerY = svgHeight / 2; + + // The bbox center in SVG coordinates + const bboxCenterX = bbox.x + bbox.width / 2; + const bboxCenterY = bbox.y + bbox.height / 2; + + // Translate so that the bbox center (scaled) maps to the SVG center + 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); + }); + + // Apply zoom to the SVG element itself for proper drag behavior + svg.call(zoom); + + // Center and scale initially (only if not already transformed) + 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(); + + // Cleanup + 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 (

    @@ -291,12 +395,23 @@ function Graph(props) {

    Graph is unavailable.

    : -
    +
    } From b4c2b774f40bca32d0f0587c9b6b8b3566ebba23 Mon Sep 17 00:00:00 2001 From: M Bussonnier Date: Wed, 26 Nov 2025 12:08:53 +0100 Subject: [PATCH 3/6] try to fix build --- src/pages/status/migration/index.jsx | 2 +- .../migration/graphUtils.js => utils/migrationGraphUtils.js} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{pages/status/migration/graphUtils.js => utils/migrationGraphUtils.js} (100%) diff --git a/src/pages/status/migration/index.jsx b/src/pages/status/migration/index.jsx index 5bcfd67aa22..71d7c7ff58f 100644 --- a/src/pages/status/migration/index.jsx +++ b/src/pages/status/migration/index.jsx @@ -22,7 +22,7 @@ import { applyHighlight, createZoomedGraphData, getNodeIdFromSvgElement -} from "./graphUtils"; +} from "@site/src/utils/migrationGraphUtils"; // { Done, In PR, Awaiting PR, Awaiting parents, Not solvable, Bot error } // The third value is a boolean representing the default display state on load diff --git a/src/pages/status/migration/graphUtils.js b/src/utils/migrationGraphUtils.js similarity index 100% rename from src/pages/status/migration/graphUtils.js rename to src/utils/migrationGraphUtils.js From a1286586ebc229b6fe853c7d7a48224a1bd152fc Mon Sep 17 00:00:00 2001 From: M Bussonnier Date: Sun, 30 Nov 2025 21:07:58 +0100 Subject: [PATCH 4/6] Enhance migration dependency graph with interactive features Add several improvements to the interactive dependency graph: - Display PR numbers as clickable links on selected nodes - Add collapsible settings panel with gear icon for graph controls - Implement CSS toggle switch for showing/hiding completed packages - Add large graph warning (threshold: 1000 nodes) to prevent browser slowdown - Improve node coloring based on migration category (bot-error, not-solvable, etc.) - Use border highlighting instead of fill color for selected nodes - Increase node padding for better readability The settings panel consolidates graph direction, ranker algorithm, alignment options, and the completed packages toggle into a single floating panel accessed via gear icon. Move inline styles to CSS module for better maintainability and remove unnecessary comments from the codebase. --- src/pages/status/migration/index.jsx | 313 +++++++++++-------- src/pages/status/migration/styles.module.css | 186 ++++++++++- src/utils/migrationGraphUtils.js | 63 +++- 3 files changed, 407 insertions(+), 155 deletions(-) diff --git a/src/pages/status/migration/index.jsx b/src/pages/status/migration/index.jsx index 71d7c7ff58f..cf512b5914c 100644 --- a/src/pages/status/migration/index.jsx +++ b/src/pages/status/migration/index.jsx @@ -24,9 +24,9 @@ import { getNodeIdFromSvgElement } from "@site/src/utils/migrationGraphUtils"; -// { Done, In PR, Awaiting PR, Awaiting parents, Not solvable, Bot error } -// The third value is a boolean representing the default display state on load -// 'true' means hidden, 'false' means visible +// Threshold for showing large graph warning +const LARGE_GRAPH_THRESHOLD = 1000; + const ORDERED = [ ["done", "Done", true], ["in-pr", "In PR", false], @@ -62,6 +62,7 @@ export default function MigrationDetails() { redirect: false, view: "table", }); + const [showDoneNodes, setShowDoneNodes] = useState(false); const toggle = (view) => { if (window && window.localStorage) { try { @@ -98,12 +99,11 @@ export default function MigrationDetails() { if (state.redirect) return ; const { details, name, view } = state; - // Build graph data structure from pruned feedstock status const graphDataStructure = React.useMemo(() => { if (!details) return { nodeMap: {}, edgeMap: {}, allNodeIds: [] }; - const prunedFeedstock = getPrunedFeedstockStatus(details._feedstock_status, details); - return buildGraphDataStructure(prunedFeedstock); - }, [details]); + const feedstock = showDoneNodes ? details._feedstock_status : getPrunedFeedstockStatus(details._feedstock_status, details); + return buildGraphDataStructure(feedstock, details); + }, [details, showDoneNodes]); return ( {name} : view === "dependencies" ? - (details && ) : + (details && ) : (details &&
    ) } @@ -507,29 +507,38 @@ async function checkPausedOrClosed(name) { } } -function DependencyGraph({ graphDataStructure, details }) { +function DependencyGraph({ graphDataStructure, details, showDoneNodes, setShowDoneNodes }) { const [graph, setGraph] = useState(null); const svgRef = React.useRef(); const [selectedNodeId, setSelectedNodeId] = React.useState(null); 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); - // Create zoomed graph data based on selected node 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]); + }, [zoomedGraphData, graphDirection, graphRanker, graphAlign, shouldShowWarning]); - // Get searchable nodes (only nodes with children or parents) const searchableNodeIds = React.useMemo(() => { return graphDataStructure.allNodeIds.filter(nodeId => { const node = graphDataStructure.nodeMap[nodeId]; @@ -540,12 +549,10 @@ function DependencyGraph({ graphDataStructure, details }) { }); }, [graphDataStructure]); - // Filter nodes based on search term const filteredNodes = React.useMemo(() => { return filterNodesBySearchTerm(searchableNodeIds, searchTerm); }, [searchTerm, searchableNodeIds]); - // Identify nodes in "awaiting-parents" that have no parents in the graph const awaitingParentsNoParent = React.useMemo(() => { return getAwaitingParentsWithNoParent(nodeMap, details); }, [nodeMap, details]); @@ -564,15 +571,55 @@ function DependencyGraph({ graphDataStructure, details }) { svgGroup.selectAll("g.node").each(function () { const nodeId = getNodeIdFromSvgElement(this); d3.select(this).attr("data-node-id", nodeId); + const graphNode = graph.node(nodeId); - // Highlight selected node if (selectedNodeId === nodeId) { d3.select(this).selectAll("rect") - .style("stroke-width", "3px") - .style("fill", "#ADD8E6"); + .style("stroke", "#0066cc") + .style("stroke-width", "4px"); + + 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", "#0066cc") + .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); + } } - // Mark nodes in awaiting-parents with no parents with a light red background if (awaitingParentsNoParent.has(nodeId)) { d3.select(this).selectAll("rect") .style("fill", "#ffe6e6") @@ -581,7 +628,6 @@ function DependencyGraph({ graphDataStructure, details }) { } }); - // Set edge IDs from graph data svgGroup.selectAll("g.edgePath").each(function () { const edgeElement = d3.select(this); const edges = graph.edges(); @@ -619,7 +665,6 @@ function DependencyGraph({ graphDataStructure, details }) { setSelectedNodeId(nodeId); }); - // Click on background (void) to reset view svg.on("click", function (event) { if (event.target === this) { setSelectedNodeId(null); @@ -633,7 +678,6 @@ function DependencyGraph({ graphDataStructure, details }) { svg.call(zoom); - // Center the graph initially const graphWidth = graph.graph().width; const graphHeight = graph.graph().height; const svgWidth = svgRef.current.clientWidth; @@ -667,12 +711,72 @@ function DependencyGraph({ graphDataStructure, details }) { return (
    -
    -

    Dependency Graph

    -
    -
    +
    + {showSettings && ( +
    +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + )} +
    +
    { @@ -681,46 +785,14 @@ function DependencyGraph({ graphDataStructure, details }) { }} onFocus={() => setShowDropdown(true)} onBlur={() => setTimeout(() => setShowDropdown(false), 200)} - style={{ - padding: "8px 12px", - fontSize: "14px", - borderRadius: "4px", - border: "1px solid var(--ifm-color-emphasis-300)", - marginTop: "8px", - width: "100%", - boxSizing: "border-box" - }} /> {showDropdown && filteredNodes.length > 0 && ( -
      +
        {filteredNodes.slice(0, 10).map((nodeName) => (
      • handleSelectNode(nodeName)} - style={{ - padding: "8px 12px", - cursor: "pointer", - hover: { backgroundColor: "var(--ifm-color-emphasis-100)" } - }} - onMouseEnter={(e) => e.target.style.backgroundColor = "var(--ifm-color-emphasis-100)"} - onMouseLeave={(e) => e.target.style.backgroundColor = "transparent"} > {nodeName}
      • @@ -728,87 +800,56 @@ function DependencyGraph({ graphDataStructure, details }) {
      )}
    -
    - -
    -
    - -
    -
    - + Continue Anyway + + {showDoneNodes && ( + + )} + {!showDoneNodes && ( + + )}
    -
    -
    - -
    + ) : ( +
    + +
    + )} 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/styles.module.css b/src/pages/status/migration/styles.module.css index 99fb54f1c67..757051f4866 100644 --- a/src/pages/status/migration/styles.module.css +++ b/src/pages/status/migration/styles.module.css @@ -181,10 +181,6 @@ left: 14px; } -.dependencyGraphContainer { - padding: 20px 0; -} - .graphHeader { display: flex; justify-content: space-between; @@ -241,3 +237,185 @@ .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; +} diff --git a/src/utils/migrationGraphUtils.js b/src/utils/migrationGraphUtils.js index bd83879407f..33421627a68 100644 --- a/src/utils/migrationGraphUtils.js +++ b/src/utils/migrationGraphUtils.js @@ -5,7 +5,6 @@ import * as dagreD3 from "dagre-d3-es"; import * as d3 from "d3"; -// Constants for graph styling const DEFAULT_GRAPH_SETTINGS = { nodesep: 50, ranksep: 100, @@ -29,13 +28,11 @@ const EDGE_STYLE = { style: "stroke: #333; stroke-width: 2px;", }; -// Helper function to extract node ID from SVG element export const getNodeIdFromSvgElement = (element) => { const fullText = d3.select(element).select("text").text().split("\n")[0]; return fullText.split("(")[0].trim(); }; -// Remove any done node for a smaller graph export const getPrunedFeedstockStatus = (feedstockStatus, details) => { if (!feedstockStatus || !details?.done) return feedstockStatus; @@ -56,7 +53,19 @@ export const getStatusColor = (prStatus) => { case "clean": return "#28a745"; // Green case "unstable": - return "#ffc107"; // Yellow + return "#dc3545"; // Red for failing + case "bot-error": + return "#ffc107"; // Orange for bot errors + case "not-solvable": + return "#ffc107"; // Orange for not solvable + case "done": + return "#28a745"; // Green for done + case "in-pr": + return "#17a2b8"; // Blue for in-pr (will be overridden by PR status) + case "awaiting-pr": + return "#adb5bd"; // Gray for awaiting-pr + case "awaiting-parents": + return "#6c757d"; // Darker gray for awaiting-parents case "unknown": return "#adb5bd"; // Lighter gray default: @@ -65,7 +74,10 @@ export const getStatusColor = (prStatus) => { }; export const getStatusTextColor = (prStatus) => { - return prStatus === "clean" ? "#ffffff" : "#000000"; + if (["clean", "done", "unstable", "awaiting-parents", "in-pr"].includes(prStatus)) { + return "#ffffff"; + } + return "#000000"; }; export const filterNodesBySearchTerm = (nodeNames, searchTerm) => { @@ -75,13 +87,10 @@ export const filterNodesBySearchTerm = (nodeNames, searchTerm) => { ); }; -// some node are awaiting parent, but have no current parent in the graph -// eg.pyside2 on 3.14t I'm guessing this is awaiting external changes (PyPI?) export const getAwaitingParentsWithNoParent = (nodeMap, details) => { const noParents = new Set(); const allChildren = new Set(); - // Collect all nodes that are children of any node Object.values(nodeMap).forEach((nodeInfo) => { const children = nodeInfo.data?.immediate_children || []; children.forEach((childId) => { @@ -89,7 +98,6 @@ export const getAwaitingParentsWithNoParent = (nodeMap, details) => { }); }); - // Find packages in awaiting-parents that are not children of any node const awaitingParents = details?.["awaiting-parents"] || []; awaitingParents.forEach((name) => { if (!allChildren.has(name)) { @@ -202,7 +210,7 @@ export const findConnectedComponents = ( return components; }; -export const buildGraphDataStructure = (feedstockStatus) => { +export const buildGraphDataStructure = (feedstockStatus, details = null) => { if (!feedstockStatus || Object.keys(feedstockStatus).length === 0) { return { nodeMap: {}, edgeMap: {}, allNodeIds: [] }; } @@ -210,10 +218,25 @@ export const buildGraphDataStructure = (feedstockStatus) => { 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], + data: { + ...feedstockStatus[nodeId], + category: nodeCategoryMap[nodeId] || "unknown", // Add category to data + }, incoming: [], outgoing: [], }; @@ -379,16 +402,26 @@ const addNodeToGraph = (g, nodeId, nodeMap, nodeToComponent, addedNodes) => { const nodeInfo = nodeMap[nodeId]; if (!nodeInfo) return; - const status = nodeInfo.data.pr_status || "unknown"; + 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 + statusColor: getStatusColor(colorStatus), // Store status color rx: 5, ry: 5, - padding: 10, - style: `fill: ${getStatusColor(status)}; stroke: #333; stroke-width: 1px;`, - labelStyle: `fill: ${getStatusTextColor(status)}; font-size: 12px; font-weight: bold;`, + padding: 15, + style: `fill: ${getStatusColor(colorStatus)}; stroke: #333; stroke-width: 1px;`, + labelStyle: `fill: ${getStatusTextColor(colorStatus)}; font-size: 12px; font-weight: bold;`, }); if (componentId) { From e92e1cc89e93b941333361d0d5f4e8552c178c0f Mon Sep 17 00:00:00 2001 From: M Bussonnier Date: Sun, 30 Nov 2025 21:33:46 +0100 Subject: [PATCH 5/6] fix pre-commit --- src/pages/status/migration/styles.module.css | 2 +- src/utils/migrationGraphUtils.js | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/pages/status/migration/styles.module.css b/src/pages/status/migration/styles.module.css index 757051f4866..32a73b9067c 100644 --- a/src/pages/status/migration/styles.module.css +++ b/src/pages/status/migration/styles.module.css @@ -300,7 +300,7 @@ 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); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); z-index: 1000; min-width: 400px; } diff --git a/src/utils/migrationGraphUtils.js b/src/utils/migrationGraphUtils.js index 33421627a68..39ed62ad41a 100644 --- a/src/utils/migrationGraphUtils.js +++ b/src/utils/migrationGraphUtils.js @@ -74,7 +74,11 @@ export const getStatusColor = (prStatus) => { }; export const getStatusTextColor = (prStatus) => { - if (["clean", "done", "unstable", "awaiting-parents", "in-pr"].includes(prStatus)) { + if ( + ["clean", "done", "unstable", "awaiting-parents", "in-pr"].includes( + prStatus, + ) + ) { return "#ffffff"; } return "#000000"; @@ -221,9 +225,16 @@ export const buildGraphDataStructure = (feedstockStatus, details = null) => { // 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 => { + [ + "done", + "in-pr", + "awaiting-pr", + "awaiting-parents", + "not-solvable", + "bot-error", + ].forEach((category) => { if (details[category]) { - details[category].forEach(nodeName => { + details[category].forEach((nodeName) => { nodeCategoryMap[nodeName] = category; }); } From c7ecb7580587fdc109069b559dcbb144bcfc206c Mon Sep 17 00:00:00 2001 From: M Bussonnier Date: Sun, 30 Nov 2025 21:39:52 +0100 Subject: [PATCH 6/6] Apply suggestions from code review --- src/pages/status/migration/index.jsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/pages/status/migration/index.jsx b/src/pages/status/migration/index.jsx index cf512b5914c..5861db0f35a 100644 --- a/src/pages/status/migration/index.jsx +++ b/src/pages/status/migration/index.jsx @@ -27,6 +27,9 @@ import { // Threshold for showing large graph warning const LARGE_GRAPH_THRESHOLD = 1000; +// { Done, In PR, Awaiting PR, Awaiting parents, Not solvable, Bot error } +// The third value is a boolean representing the default display state on load +// 'true' means hidden, 'false' means visible const ORDERED = [ ["done", "Done", true], ["in-pr", "In PR", false], @@ -316,25 +319,20 @@ function Graph(props) { const svgWidth = viewBox ? viewBox.width : (svgElement.getBoundingClientRect().width || containerRef.current.clientWidth); const svgHeight = viewBox ? viewBox.height : (svgElement.getBoundingClientRect().height || containerRef.current.clientHeight || 600); - // Get bounding box of the content (relative to SVG coordinate system) const bbox = svgElement.getBBox(); - // Calculate initial transform values const initialScale = Math.min( svgWidth / bbox.width, svgHeight / bbox.height, 1 ) * 0.9; - // Calculate center position in SVG coordinate system const centerX = svgWidth / 2; const centerY = svgHeight / 2; - // The bbox center in SVG coordinates const bboxCenterX = bbox.x + bbox.width / 2; const bboxCenterY = bbox.y + bbox.height / 2; - // Translate so that the bbox center (scaled) maps to the SVG center const initialTranslate = [ centerX - bboxCenterX * initialScale, centerY - bboxCenterY * initialScale, @@ -352,10 +350,8 @@ function Graph(props) { svgGroup.attr("transform", event.transform); }); - // Apply zoom to the SVG element itself for proper drag behavior svg.call(zoom); - // Center and scale initially (only if not already transformed) if (!svgGroup.attr("transform")) { svg.call(zoom.transform, initialTransform); } @@ -371,7 +367,6 @@ function Graph(props) { setupZoom(); - // Cleanup return () => { if (timer) clearTimeout(timer); const svgElement = container.select('svg').node();