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 && ( +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ )} +
+
+ { + 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} +
  • + ))} +
+ )} +
+ +
+
+
+ {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. +

+
+ + {showDoneNodes && ( + + )} + {!showDoneNodes && ( + + )} +
+
+
+ ) : ( +
+ +
+ )} +
+
+
+ + 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 + {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; +};