From 1434f717dd26abf843a6fa426288e2476ca89b51 Mon Sep 17 00:00:00 2001 From: Phillip Bailey Date: Thu, 24 Sep 2026 16:15:36 -0400 Subject: [PATCH] Add network topology view --- .../en/plugin__networking-console-plugin.json | 2 + .../es/plugin__networking-console-plugin.json | 2 + .../fr/plugin__networking-console-plugin.json | 2 + .../ja/plugin__networking-console-plugin.json | 2 + .../ko/plugin__networking-console-plugin.json | 2 + .../zh/plugin__networking-console-plugin.json | 2 + package-lock.json | 702 +++++++++++++++++- package.json | 1 + plugin-manifest.ts | 3 + src/utils/resources/pods/constants.ts | 2 + src/utils/resources/udns/constants.ts | 3 + src/utils/resources/udns/helper.ts | 63 +- src/utils/resources/vm/constants.ts | 31 + src/utils/resources/vm/utils.ts | 54 +- .../routes/list/components/RouteStatus.tsx | 4 +- .../routes/list/hooks/useRouteFilters.ts | 6 +- src/views/routes/list/utils/utils.ts | 6 +- .../details/tabs/pods/components/PodRow.tsx | 11 +- .../tabs/pods/components/PodStatus.tsx | 4 +- .../services/details/tabs/pods/constants.ts | 12 + .../details/tabs/pods/hooks/usePodColumns.tsx | 4 +- src/views/services/details/tabs/pods/utils.ts | 4 +- src/views/topology/TopologyPage.scss | 21 + src/views/topology/TopologyPage.tsx | 49 ++ .../components/CustomGroup/CustomGroup.scss | 10 + .../components/CustomGroup/CustomGroup.tsx | 25 + .../components/CustomNode/CustomNode.scss | 19 + .../components/CustomNode/CustomNode.tsx | 59 ++ src/views/topology/components/Topology.tsx | 69 ++ .../components/TopologyControlBar.tsx | 43 ++ .../topology/context/TopologyContext.tsx | 32 + .../hooks/useNetworkTopologyResources.ts | 122 +++ src/views/topology/manifest.ts | 37 + src/views/topology/utils/LevelsLayout.ts | 209 ++++++ src/views/topology/utils/constants.ts | 48 ++ .../dataTransformation/dataTransformation.ts | 191 +++++ .../utils/dataTransformation/resourceUtils.ts | 226 ++++++ src/views/topology/utils/factories.ts | 39 + src/views/topology/utils/filteringUtils.ts | 99 +++ src/views/topology/utils/nodePositionUtils.ts | 44 ++ .../useUDNVirtualMachines.ts | 4 +- .../useUDNVirtualMachines/utils/utils.ts | 8 - 42 files changed, 2234 insertions(+), 42 deletions(-) create mode 100644 src/utils/resources/pods/constants.ts create mode 100644 src/views/topology/TopologyPage.scss create mode 100644 src/views/topology/TopologyPage.tsx create mode 100644 src/views/topology/components/CustomGroup/CustomGroup.scss create mode 100644 src/views/topology/components/CustomGroup/CustomGroup.tsx create mode 100644 src/views/topology/components/CustomNode/CustomNode.scss create mode 100644 src/views/topology/components/CustomNode/CustomNode.tsx create mode 100644 src/views/topology/components/Topology.tsx create mode 100644 src/views/topology/components/TopologyControlBar.tsx create mode 100644 src/views/topology/context/TopologyContext.tsx create mode 100644 src/views/topology/hooks/useNetworkTopologyResources.ts create mode 100644 src/views/topology/manifest.ts create mode 100644 src/views/topology/utils/LevelsLayout.ts create mode 100644 src/views/topology/utils/constants.ts create mode 100644 src/views/topology/utils/dataTransformation/dataTransformation.ts create mode 100644 src/views/topology/utils/dataTransformation/resourceUtils.ts create mode 100644 src/views/topology/utils/factories.ts create mode 100644 src/views/topology/utils/filteringUtils.ts create mode 100644 src/views/topology/utils/nodePositionUtils.ts diff --git a/locales/en/plugin__networking-console-plugin.json b/locales/en/plugin__networking-console-plugin.json index 18e2c041..b08aed9f 100644 --- a/locales/en/plugin__networking-console-plugin.json +++ b/locales/en/plugin__networking-console-plugin.json @@ -94,6 +94,7 @@ "ClusterUserDefinedNetworks": "ClusterUserDefinedNetworks", "Completed": "Completed", "Conditions": "Conditions", + "Configuration Topology": "Configuration Topology", "Configure in Virtual machine networks page": "Configure in Virtual machine networks page", "Configure via:": "Configure via:", "Confirm": "Confirm", @@ -491,6 +492,7 @@ "View logs": "View logs", "View naming information": "View naming information", "Virtual Machines": "Virtual Machines", + "Visualize, scale, and manage your cluster topology. Right-click the canvas for additional actions.": "Visualize, scale, and manage your cluster topology. Right-click the canvas for additional actions.", "VLAN": "VLAN", "VLAN tag number": "VLAN tag number", "Weight": "Weight", diff --git a/locales/es/plugin__networking-console-plugin.json b/locales/es/plugin__networking-console-plugin.json index 57aec8b3..54e179dd 100644 --- a/locales/es/plugin__networking-console-plugin.json +++ b/locales/es/plugin__networking-console-plugin.json @@ -97,6 +97,7 @@ "ClusterUserDefinedNetworks": "ClusterUserDefinedNetworks", "Completed": "Terminado", "Conditions": "Condiciones", + "Configuration Topology": "Configuration Topology", "Configure in Virtual machine networks page": "Configurar en la página de redes de máquinas virtuales", "Configure via:": "Configurar a través de:", "Confirm": "Confirmar", @@ -494,6 +495,7 @@ "View logs": "Ver registros", "View naming information": "Ver información de nombres", "Virtual Machines": "Máquinas virtuales", + "Visualize, scale, and manage your cluster topology. Right-click the canvas for additional actions.": "Visualize, scale, and manage your cluster topology. Right-click the canvas for additional actions.", "VLAN": "VLAN", "VLAN tag number": "Número de etiqueta VLAN", "Weight": "Peso", diff --git a/locales/fr/plugin__networking-console-plugin.json b/locales/fr/plugin__networking-console-plugin.json index a36b6af4..0071c4ac 100644 --- a/locales/fr/plugin__networking-console-plugin.json +++ b/locales/fr/plugin__networking-console-plugin.json @@ -97,6 +97,7 @@ "ClusterUserDefinedNetworks": "ClusterUserDefinedNetworks", "Completed": "Terminé", "Conditions": "Conditions", + "Configuration Topology": "Configuration Topology", "Configure in Virtual machine networks page": "Configurer dans la page Réseaux de machines virtuelles", "Configure via:": "Configurer via\u00a0:", "Confirm": "Confirmer", @@ -494,6 +495,7 @@ "View logs": "Afficher les journaux", "View naming information": "Afficher les informations sur l’attribution de nom", "Virtual Machines": "Machines virtuelles", + "Visualize, scale, and manage your cluster topology. Right-click the canvas for additional actions.": "Visualize, scale, and manage your cluster topology. Right-click the canvas for additional actions.", "VLAN": "VLAN", "VLAN tag number": "Numéro de balise VLAN", "Weight": "Poids", diff --git a/locales/ja/plugin__networking-console-plugin.json b/locales/ja/plugin__networking-console-plugin.json index 58fc72d3..cbc09576 100644 --- a/locales/ja/plugin__networking-console-plugin.json +++ b/locales/ja/plugin__networking-console-plugin.json @@ -94,6 +94,7 @@ "ClusterUserDefinedNetworks": "ClusterUserDefinedNetworks", "Completed": "完了済み", "Conditions": "状態", + "Configuration Topology": "Configuration Topology", "Configure in Virtual machine networks page": "仮想マシンネットワークページで設定する", "Configure via:": "設定方法:", "Confirm": "確定", @@ -491,6 +492,7 @@ "View logs": "ログの表示", "View naming information": "命名情報の表示", "Virtual Machines": "仮想マシン", + "Visualize, scale, and manage your cluster topology. Right-click the canvas for additional actions.": "Visualize, scale, and manage your cluster topology. Right-click the canvas for additional actions.", "VLAN": "VLAN", "VLAN tag number": "VLAN タグ番号", "Weight": "重み", diff --git a/locales/ko/plugin__networking-console-plugin.json b/locales/ko/plugin__networking-console-plugin.json index 514d4657..6e965b2f 100644 --- a/locales/ko/plugin__networking-console-plugin.json +++ b/locales/ko/plugin__networking-console-plugin.json @@ -94,6 +94,7 @@ "ClusterUserDefinedNetworks": "ClusterUserDefinedNetworks", "Completed": "완료됨", "Conditions": "조건", + "Configuration Topology": "Configuration Topology", "Configure in Virtual machine networks page": "가상 머신 네트워크 페이지에서 구성", "Configure via:": "다음을 사용하여 설정:", "Confirm": "확인", @@ -491,6 +492,7 @@ "View logs": "로그보기", "View naming information": "이름 지정 정보 보기", "Virtual Machines": "가상 머신", + "Visualize, scale, and manage your cluster topology. Right-click the canvas for additional actions.": "Visualize, scale, and manage your cluster topology. Right-click the canvas for additional actions.", "VLAN": "VLAN", "VLAN tag number": "VLAN 태그 번호", "Weight": "가중치", diff --git a/locales/zh/plugin__networking-console-plugin.json b/locales/zh/plugin__networking-console-plugin.json index 426e6f86..d457761c 100644 --- a/locales/zh/plugin__networking-console-plugin.json +++ b/locales/zh/plugin__networking-console-plugin.json @@ -94,6 +94,7 @@ "ClusterUserDefinedNetworks": "ClusterUserDefinedNetworks", "Completed": "完成", "Conditions": "条件", + "Configuration Topology": "Configuration Topology", "Configure in Virtual machine networks page": "在虚拟机网络页中配置", "Configure via:": "通过以下配置:", "Confirm": "确认", @@ -491,6 +492,7 @@ "View logs": "查看日志", "View naming information": "查看命名信息", "Virtual Machines": "虚拟机", + "Visualize, scale, and manage your cluster topology. Right-click the canvas for additional actions.": "Visualize, scale, and manage your cluster topology. Right-click the canvas for additional actions.", "VLAN": "VLAN", "VLAN tag number": "VLAN 标签号", "Weight": "权重", diff --git a/package-lock.json b/package-lock.json index 73d01c43..c5e92aef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@patternfly/react-styles": "6.6.1", "@patternfly/react-table": "6.6.2", "@patternfly/react-tokens": "6.6.1", + "@patternfly/react-topology": "6.6.0", "classnames": "^2.5.1", "js-yaml": "^4.3.2", "linkify-it": "6.1.0", @@ -1827,6 +1828,24 @@ "ms": "^2.1.1" } }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.2.tgz", + "integrity": "sha512-F09dphqvHsbe/6C2t2unbmpr5q41BNPEfJCdn8Z7aEBpVSy/zFQ/b4SWsweQjWNsYMDvE2ffNUN8X0CeFsEGNw==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.2" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.2.tgz", + "integrity": "sha512-CbyGpCDKsiTg/wuk79S7Muoj8mghDGAESWGxcSyhHX5jD35vYMBZochYVFzlHxynpE9unpu6O+4ZuhrLxASsOg==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -3923,6 +3942,24 @@ "react-dom": "^17 || ^18 || ^19" } }, + "node_modules/@patternfly/react-component-groups/node_modules/@patternfly/react-table": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-6.6.2.tgz", + "integrity": "sha512-HIxECFB1eiVcAi/DP+FYQMzA7JU4zTg/7idVKuEvWJfILx66ZBwpitwOt27IPxQ3AjCgixLMyh6lTDFAfmd3Kw==", + "license": "MIT", + "dependencies": { + "@patternfly/react-core": "^6.6.2", + "@patternfly/react-icons": "~6.6.1", + "@patternfly/react-styles": "~6.6.1", + "@patternfly/react-tokens": "~6.6.1", + "lodash": "^4.18.1", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, "node_modules/@patternfly/react-core": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.6.2.tgz", @@ -4021,6 +4058,31 @@ "integrity": "sha512-t7dDEJMnO3QkdIKyjbyKJznVYki9ONZG27Cn/9RXwaM0lDGCrgQEJXy2XoVUCdYhIdXZu+TGSEqXeNFYUyZECg==", "license": "MIT" }, + "node_modules/@patternfly/react-topology": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-topology/-/react-topology-6.6.0.tgz", + "integrity": "sha512-Ake5kYZMKwhe5DqCo3w4J0aX970wWNdWotwLNq65Ad0hBxnKw3e3FEhjyaw1OJJN+H9L/AV9VazU/u56qYeBZA==", + "license": "MIT", + "dependencies": { + "@dagrejs/dagre": "1.1.2", + "@patternfly/react-core": "^6.5.1", + "@patternfly/react-icons": "^6.5.1", + "@patternfly/react-styles": "^6.5.1", + "@types/d3": "^7.4.0", + "@types/d3-force": "^1.2.1", + "d3": "^7.8.0", + "mobx": "^6.9.0", + "mobx-react": "^7.6.0", + "point-in-svg-path": "^1.0.1", + "popper.js": "^1.16.1", + "tslib": "^2.0.0", + "webcola": "3.4.0" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, "node_modules/@peculiar/asn1-cms": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.11.0.tgz", @@ -4308,24 +4370,159 @@ "@types/node": "*" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-1.2.7.tgz", + "integrity": "sha512-zySqZfnxn67RVEGWzpD9dQA0AbNIp4Rj0qGvAuUdUNfGLrwuGCbEGAGze5hEdNaHJKQT2gTqr6j+qAzncm11ew==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -4341,6 +4538,24 @@ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -4350,6 +4565,18 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", @@ -4365,12 +4592,37 @@ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -4404,6 +4656,12 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -7288,6 +7546,47 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/d3": { + "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", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -7300,6 +7599,43 @@ "node": ">=12" } }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -7309,6 +7645,86 @@ "node": ">=12" } }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -7318,6 +7734,32 @@ "node": ">=12" } }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -7327,6 +7769,27 @@ "node": ">=12" } }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -7348,6 +7811,33 @@ "node": ">=12" } }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -7364,6 +7854,28 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -7409,6 +7921,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -7607,6 +8154,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delaunay-find": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/delaunay-find/-/delaunay-find-0.0.6.tgz", @@ -11195,7 +11751,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -13385,6 +13940,63 @@ "node": "20 || 22 || 24 || 26" } }, + "node_modules/mobx": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.16.1.tgz", + "integrity": "sha512-syNcDdX3KT+Jq3je6eGjBhuc24Z68td2VG0zNFqRswaE433D9SNH5VRy/xrGbJsUixfppLLccXhAW9JSf6n+SQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + } + }, + "node_modules/mobx-react": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/mobx-react/-/mobx-react-7.6.0.tgz", + "integrity": "sha512-+HQUNuh7AoQ9ZnU6c4rvbiVVl+wEkb9WqYsVDzGLng+Dqj1XntHu79PvEWKtSMoMj67vFp/ZPXcElosuJO8ckA==", + "license": "MIT", + "dependencies": { + "mobx-react-lite": "^3.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + }, + "peerDependencies": { + "mobx": "^6.1.0", + "react": "^16.8.0 || ^17 || ^18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/mobx-react-lite": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz", + "integrity": "sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + }, + "peerDependencies": { + "mobx": "^6.1.0", + "react": "^16.8.0 || ^17 || ^18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/mocha": { "version": "12.0.3", "resolved": "https://registry.npmjs.org/mocha/-/mocha-12.0.3.tgz", @@ -14971,6 +15583,26 @@ "node": ">=4" } }, + "node_modules/point-in-svg-path": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/point-in-svg-path/-/point-in-svg-path-1.0.2.tgz", + "integrity": "sha512-+Smsf7B9e7eRFHIwpN+4rE8inF2APbFWeywPfUgbeh02xdJSkbTz6Pqdt7A36wVCR+CnLbaNkRnBjgOpF5RMVQ==", + "license": "ISC", + "engines": { + "node": ">=8.x.x" + } + }, + "node_modules/popper.js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", + "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", + "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -16076,6 +16708,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -16099,6 +16737,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -16193,7 +16837,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sass": { @@ -18887,6 +19530,61 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/webcola": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/webcola/-/webcola-3.4.0.tgz", + "integrity": "sha512-4BiLXjXw3SJHo3Xd+rF+7fyClT6n7I+AR6TkBqyQ4kTsePSAMDLRCXY1f3B/kXJeP9tYn4G1TblxTO+jAt0gaw==", + "license": "MIT", + "dependencies": { + "d3-dispatch": "^1.0.3", + "d3-drag": "^1.0.4", + "d3-shape": "^1.3.5", + "d3-timer": "^1.0.5" + } + }, + "node_modules/webcola/node_modules/d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", + "license": "BSD-3-Clause" + }, + "node_modules/webcola/node_modules/d3-drag": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz", + "integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dispatch": "1", + "d3-selection": "1" + } + }, + "node_modules/webcola/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/webcola/node_modules/d3-selection": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz", + "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==", + "license": "BSD-3-Clause" + }, + "node_modules/webcola/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/webcola/node_modules/d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", + "license": "BSD-3-Clause" + }, "node_modules/webpack": { "version": "5.112.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.112.0.tgz", diff --git a/package.json b/package.json index ef79856a..bf411a0c 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@patternfly/react-styles": "6.6.1", "@patternfly/react-table": "6.6.2", "@patternfly/react-tokens": "6.6.1", + "@patternfly/react-topology": "6.6.0", "classnames": "^2.5.1", "js-yaml": "^4.3.2", "linkify-it": "6.1.0", diff --git a/plugin-manifest.ts b/plugin-manifest.ts index 4a469656..ddf6905a 100644 --- a/plugin-manifest.ts +++ b/plugin-manifest.ts @@ -12,6 +12,7 @@ import { } from './src/views/networkpolicies/manifest'; import { RoutesExposedModules, RoutesExtensions } from './src/views/routes/manifest'; import { ServicesExposedModules, ServicesExtensions } from './src/views/services/manifest'; +import { TopologyExposedModules, TopologyExtensions } from './src/views/topology/manifest'; import { UserDefinedNetworksExposedModules, UserDefinedNetworksExtensions, @@ -31,6 +32,7 @@ export const pluginMetadata: ConsolePluginBuildMetadata = { ...FlagsExposedModules, ...RoutesExposedModules, ...UserDefinedNetworksExposedModules, + ...TopologyExposedModules, yamlTemplates: './templates/index.ts', }, name: 'networking-console-plugin', @@ -45,4 +47,5 @@ export const extensions: EncodedExtension[] = [ ...NADsExtensions, ...FlagsExtensions, ...UserDefinedNetworksExtensions, + ...TopologyExtensions, ]; diff --git a/src/utils/resources/pods/constants.ts b/src/utils/resources/pods/constants.ts new file mode 100644 index 00000000..fe49ac3f --- /dev/null +++ b/src/utils/resources/pods/constants.ts @@ -0,0 +1,2 @@ +export const VIRT_LAUNCHER_POD_LABEL_KEY = 'kubevirt.io'; +export const VIRT_LAUNCHER_POD_LABEL_VALUE = 'virt-launcher'; diff --git a/src/utils/resources/udns/constants.ts b/src/utils/resources/udns/constants.ts index 174e6fcf..4ffc058b 100644 --- a/src/utils/resources/udns/constants.ts +++ b/src/utils/resources/udns/constants.ts @@ -7,3 +7,6 @@ export const LAYER2_TOPOLOGY = 'Layer2'; export const LAYER3_TOPOLOGY = 'Layer3'; export const CLUSTER_UDN_NAD_NAME_PREFIX = 'cluster.udn.'; + +export const NETWORK_CREATED = 'NetworkCreated'; +export const NETWORK_ALLOCATION_SUCCEEDED = 'NetworkAllocationSucceeded'; diff --git a/src/utils/resources/udns/helper.ts b/src/utils/resources/udns/helper.ts index e917edf4..81a27a1d 100644 --- a/src/utils/resources/udns/helper.ts +++ b/src/utils/resources/udns/helper.ts @@ -1,7 +1,18 @@ +import { + K8sResourceCommon, + MatchExpression, + Operator, +} from '@openshift-console/dynamic-plugin-sdk'; import { ClusterUserDefinedNetworkModel } from '@utils/models'; import { getName } from '@utils/resources/shared'; +import { getUDNConditions } from '@utils/resources/udns/selectors'; +import { verifyMatchExpressions } from '@utils/utils'; -import { CLUSTER_UDN_NAD_NAME_PREFIX } from './constants'; +import { + CLUSTER_UDN_NAD_NAME_PREFIX, + NETWORK_ALLOCATION_SUCCEEDED, + NETWORK_CREATED, +} from './constants'; import { ClusterUserDefinedNetworkKind, UserDefinedNetworkKind, @@ -10,13 +21,18 @@ import { export type UDNResource = ClusterUserDefinedNetworkKind | UserDefinedNetworkKind; -export const isPrimaryUDN = (udn: ClusterUserDefinedNetworkKind | UserDefinedNetworkKind) => - udn?.spec?.layer2?.role === UserDefinedNetworkRole.Primary || - udn?.spec?.network?.layer2?.role === UserDefinedNetworkRole.Primary; - export const isClusterUDN = (udn: UDNResource): udn is ClusterUserDefinedNetworkKind => udn.kind === ClusterUserDefinedNetworkModel.kind; +export const isPrimaryUDN = (udn: ClusterUserDefinedNetworkKind | UserDefinedNetworkKind) => { + const spec = isClusterUDN(udn) ? udn?.spec?.network : udn?.spec; + return ( + spec?.layer2?.role === UserDefinedNetworkRole.Primary || + spec?.layer3?.role === UserDefinedNetworkRole.Primary || + spec?.localnet?.role === UserDefinedNetworkRole.Primary + ); +}; + export const getUDNNADNameCandidates = (udn: UDNResource): string[] => { const udnName = getName(udn); @@ -30,3 +46,40 @@ export const getUDNNADNameCandidates = (udn: UDNResource): string[] => { return [udnName]; }; + +export const udnIsReady = (udn: UDNResource) => { + const conditions = getUDNConditions(udn); + const networkCreationSuccessful = + conditions?.find((condition) => condition.type === NETWORK_CREATED)?.status === 'True'; + const networkAllocationSuccessful = + conditions?.find((condition) => condition.type === NETWORK_ALLOCATION_SUCCEEDED)?.status === + 'True'; + + return networkCreationSuccessful && networkAllocationSuccessful; +}; + +export const getCUDNProjects = ( + cudn: ClusterUserDefinedNetworkKind, + projects: K8sResourceCommon[], +): K8sResourceCommon[] => { + const namespaceSelector = cudn?.spec?.namespaceSelector || {}; + + const matchLabelsToExpressions = Object.entries(namespaceSelector.matchLabels || {}).map( + ([key, value]): MatchExpression => ({ + key, + operator: Operator.Equals, + values: [value], + }), + ); + + const matchExpressions = namespaceSelector.matchExpressions || []; + + const combinedExpressions = [...matchLabelsToExpressions, ...matchExpressions]; + + return projects?.filter((project) => verifyMatchExpressions(project, combinedExpressions)) || []; +}; + +export const getCUDNNamespaceNames = ( + cudn: ClusterUserDefinedNetworkKind, + projects: K8sResourceCommon[], +) => getCUDNProjects(cudn, projects).map((project) => getName(project)) || []; diff --git a/src/utils/resources/vm/constants.ts b/src/utils/resources/vm/constants.ts index 0f435a7d..4be46442 100644 --- a/src/utils/resources/vm/constants.ts +++ b/src/utils/resources/vm/constants.ts @@ -1,3 +1,34 @@ export const NETWORK_PATH = '/spec/template/spec/networks'; export const INTERFACE_PATH = '/spec/template/spec/domain/devices/interfaces'; export const INTERFACE_STATE_ABSENT = 'absent'; + +// https://github.com/kubevirt/api/blob/9689e71fe2bed9e7da5f165760bbbf6981cc1087/core/v1/types.go#L1277 +export const PrintableVMStatus = { + Deleting: 'Deleting', + Migrating: 'Migrating', + Paused: 'Paused', + Provisioning: 'Provisioning', + Running: 'Running', + Starting: 'Starting', + Stopped: 'Stopped', + Stopping: 'Stopping', + Terminating: 'Terminating', + Unknown: 'Unknown', + WaitingForReceiver: 'WaitingForReceiver', + WaitingForVolumeBinding: 'WaitingForVolumeBinding', +} as const; + +export type PrintableVMStatus = (typeof PrintableVMStatus)[keyof typeof PrintableVMStatus]; + +export const PrintableVMErrorStatus = { + CrashLoopBackOff: 'CrashLoopBackOff', + DataVolumeError: 'DataVolumeError', + ErrImagePull: 'ErrImagePull', + ErrorDataVolumeNotFound: 'ErrorDataVolumeNotFound', + ErrorPvcNotFound: 'ErrorPvcNotFound', + ErrorUnschedulable: 'ErrorUnschedulable', + ImagePullBackOff: 'ImagePullBackOff', +} as const; + +export type PrintableVMErrorStatus = + (typeof PrintableVMErrorStatus)[keyof typeof PrintableVMErrorStatus]; diff --git a/src/utils/resources/vm/utils.ts b/src/utils/resources/vm/utils.ts index ef6bb402..124381dc 100644 --- a/src/utils/resources/vm/utils.ts +++ b/src/utils/resources/vm/utils.ts @@ -4,7 +4,7 @@ import { k8sPatch } from '@openshift-console/dynamic-plugin-sdk'; import { UserDefinedNetworkModel } from '@utils/models'; import { NADNetworkPair, NetworkAttachmentDefinitionKind } from '@utils/resources/nads/types'; import { getName, getNamespace } from '@utils/resources/shared'; -import { getUDNNADNameCandidates, UDNResource } from '@utils/resources/udns/helper'; +import { getUDNNADNameCandidates, isPrimaryUDN, UDNResource } from '@utils/resources/udns/helper'; import { INTERFACE_PATH, INTERFACE_STATE_ABSENT, NETWORK_PATH } from './constants'; import { getInterfaces, getNetworks, getVMStatus } from './selectors'; @@ -131,13 +131,8 @@ export const multusNetworkMatchesNAD = ( export const getUDNNetworkNamespace = ( udn: UDNResource, vm: V1VirtualMachine, -): string | undefined => { - if (udn.kind === UserDefinedNetworkModel.kind) { - return getNamespace(udn); - } - - return getNamespace(vm); -}; +): string | undefined => + udn.kind === UserDefinedNetworkModel.kind ? getNamespace(udn) : getNamespace(vm); const multusNetworkNameMatchesCandidates = ( multusNetworkName: string, @@ -180,6 +175,49 @@ export const multusNetworkMatchesUDN = ( ); }; +export const vmUsesUDN = ( + vm: V1VirtualMachine, + udn: UDNResource, + cudnNamespaces?: string[], +): boolean => { + if (!udn) return false; + + const networks = getNetworks(vm); + const interfaces = getInterfaces(vm); + + // Check for secondary UDN connection via multus networks + const hasSecondaryConnection = networks.some((network) => { + if (!multusNetworkMatchesUDN(network, udn, vm)) return false; + + const iface = interfaces.find((item) => item.name === network.name); + return Boolean(iface && iface.state !== INTERFACE_STATE_ABSENT); + }); + + if (hasSecondaryConnection) return true; + + // Check for primary UDN connection via pod network + // Primary UDNs replace the default pod network for VMs in the same namespace + if (isPrimaryUDN(udn)) { + const vmNamespace = getNamespace(vm); + const udnNamespace = getNamespace(udn); + + // For namespace-scoped primary UDNs, VM must be in the same namespace + if (udnNamespace && vmNamespace === udnNamespace) { + // Check if VM has a pod network (which would use the primary UDN) + const hasPodNetwork = networks.some((network) => Boolean(network.pod)); + if (hasPodNetwork) return true; + } + + // For cluster-scoped primary CUDNs, check if VM's namespace is in the active namespaces + if (!udnNamespace && cudnNamespaces) { + const hasPodNetwork = networks.some((network) => Boolean(network.pod)); + if (hasPodNetwork && cudnNamespaces.includes(vmNamespace)) return true; + } + } + + return false; +}; + export const buildNADReferenceForUDN = ( udn: UDNResource, vm: V1VirtualMachine, diff --git a/src/views/routes/list/components/RouteStatus.tsx b/src/views/routes/list/components/RouteStatus.tsx index f125849a..13f53384 100644 --- a/src/views/routes/list/components/RouteStatus.tsx +++ b/src/views/routes/list/components/RouteStatus.tsx @@ -3,14 +3,14 @@ import React, { FC } from 'react'; import Status from '@openshift-console/dynamic-plugin-sdk/lib/app/components/status/Status'; import { RouteKind } from '@utils/types'; -import { routeStatus } from '../utils/utils'; +import { getRouteStatus } from '../utils/utils'; type RouteStatusProps = { route: RouteKind; }; const RouteStatus: FC = ({ route }) => { - const status: string = routeStatus(route); + const status: string = getRouteStatus(route); return ; }; diff --git a/src/views/routes/list/hooks/useRouteFilters.ts b/src/views/routes/list/hooks/useRouteFilters.ts index 5b85155e..3dec61ec 100644 --- a/src/views/routes/list/hooks/useRouteFilters.ts +++ b/src/views/routes/list/hooks/useRouteFilters.ts @@ -3,7 +3,7 @@ import { RoutesStatuses } from '@utils/constants/routes'; import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; import { RouteKind } from '@utils/types'; -import { routeStatus } from '../utils/utils'; +import { getRouteStatus } from '../utils/utils'; const useRouteFilters = (): RowFilter[] => { const { t } = useNetworkingTranslation(); @@ -13,13 +13,13 @@ const useRouteFilters = (): RowFilter[] => { filter: ({ selected }, obj) => { if (selected?.length === 0) return true; - const status = routeStatus(obj); + const status = getRouteStatus(obj); return selected.includes(status); }, filterGroupName: t('Status'), items: RoutesStatuses.map((status) => ({ id: status, title: status })), - reducer: (obj) => routeStatus(obj), + reducer: (obj) => getRouteStatus(obj), type: 'route-status', }, ]; diff --git a/src/views/routes/list/utils/utils.ts b/src/views/routes/list/utils/utils.ts index 5a9b7974..1cd98064 100644 --- a/src/views/routes/list/utils/utils.ts +++ b/src/views/routes/list/utils/utils.ts @@ -4,7 +4,7 @@ import { RoutesStatusesType } from '@utils/constants/routes'; import { t } from '@utils/hooks/useNetworkingTranslation'; import { RouteIngress, RouteKind } from '@utils/types'; -export const routeStatus = (route: RouteKind): RoutesStatusesType => { +export const getRouteStatus = (route: RouteKind): RoutesStatusesType => { let atLeastOneAdmitted = false; if (!route.status || !route.status.ingress) { @@ -92,8 +92,8 @@ export const sortRoutesByLocation = (direction: string) => (a: RouteKind, b: Rou export const sortRoutesByStatus = (direction: string) => (a: RouteKind, b: RouteKind) => { const { first, second } = direction === 'asc' ? { first: a, second: b } : { first: b, second: a }; - const firstValue = routeStatus(first); - const secondValue = routeStatus(second); + const firstValue = getRouteStatus(first); + const secondValue = getRouteStatus(second); return firstValue?.localeCompare(secondValue); }; diff --git a/src/views/services/details/tabs/pods/components/PodRow.tsx b/src/views/services/details/tabs/pods/components/PodRow.tsx index 9f56d831..93981494 100644 --- a/src/views/services/details/tabs/pods/components/PodRow.tsx +++ b/src/views/services/details/tabs/pods/components/PodRow.tsx @@ -1,6 +1,5 @@ import React, { FC } from 'react'; import classNames from 'classnames'; -import * as _ from 'lodash'; import { modelToGroupVersionKind, PodModel } from '@kubevirt-ui-ext/kubevirt-api/console'; import type { IoK8sApiCoreV1Pod } from '@kubevirt-ui-ext/kubevirt-api/kubernetes'; @@ -18,7 +17,13 @@ import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation' import { podColumnInfo } from '../hooks/usePodColumns'; import { formatBytesAsMiB, formatCores } from '../units'; -import { getPodCPUUsage, getPodMemoryUsage, podPhase, podReadiness, podRestarts } from '../utils'; +import { + getPodCPUUsage, + getPodMemoryUsage, + getPodPhase, + podReadiness, + podRestarts, +} from '../utils'; import { PodStatus } from './PodStatus'; import { PodTraffic } from './PodTraffic'; @@ -37,7 +42,7 @@ const PodRow: FC = ({ const { creationTimestamp, labels, name, namespace } = pod.metadata; const { readyCount, totalContainers } = podReadiness(pod); - const phase = podPhase(pod); + const phase = getPodPhase(pod); const restarts = podRestarts(pod); const context = { [`core~${PodModel.apiVersion}~${PodModel.kind}`]: pod }; diff --git a/src/views/services/details/tabs/pods/components/PodStatus.tsx b/src/views/services/details/tabs/pods/components/PodStatus.tsx index 6279aafc..66b13e7d 100644 --- a/src/views/services/details/tabs/pods/components/PodStatus.tsx +++ b/src/views/services/details/tabs/pods/components/PodStatus.tsx @@ -11,7 +11,7 @@ import { Button, ButtonVariant, Content, Divider, Popover } from '@patternfly/re import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; import { resourcePathFromModel } from '@utils/resources/shared'; -import { isContainerCrashLoopBackOff, isWindowsPod, podPhase } from '../utils'; +import { getPodPhase, isContainerCrashLoopBackOff, isWindowsPod } from '../utils'; type PodStatusPopoverProps = { bodyContent: string; @@ -40,7 +40,7 @@ const PodStatusPopover: FC = ({ }; export const PodStatus: FC = ({ pod }) => { - const status = podPhase(pod); + const status = getPodPhase(pod); const unschedulableCondition = pod.status?.conditions?.find( (condition) => condition.reason === 'Unschedulable' && condition.status === 'False', ); diff --git a/src/views/services/details/tabs/pods/constants.ts b/src/views/services/details/tabs/pods/constants.ts index aba9fbc5..85bd3c7c 100644 --- a/src/views/services/details/tabs/pods/constants.ts +++ b/src/views/services/details/tabs/pods/constants.ts @@ -3,3 +3,15 @@ export const PROMETHEUS_TENANCY_BASE_PATH = window.SERVER_FLAGS.prometheusTenanc export const MILLISECONDS_TO_SECONDS_MULTIPLIER = 1000; export const MIGRATION__PROMETHEUS_DELAY = 15 * MILLISECONDS_TO_SECONDS_MULTIPLIER; + +export const PodPhase = { + Evicted: 'Evicted', + Failed: 'Failed', + Pending: 'Pending', + Running: 'Running', + Succeeded: 'Succeeded', + Terminating: 'Terminating', + Unknown: 'Unknown', +} as const; + +export type PodPhase = (typeof PodPhase)[keyof typeof PodPhase]; diff --git a/src/views/services/details/tabs/pods/hooks/usePodColumns.tsx b/src/views/services/details/tabs/pods/hooks/usePodColumns.tsx index a2724d73..ae698706 100644 --- a/src/views/services/details/tabs/pods/hooks/usePodColumns.tsx +++ b/src/views/services/details/tabs/pods/hooks/usePodColumns.tsx @@ -15,7 +15,7 @@ import { PROMETHEUS_BASE_PATH, PROMETHEUS_TENANCY_BASE_PATH } from '../constants import { getPodCPUUsage, getPodMemoryUsage, - podPhase, + getPodPhase, podReadiness, podRestarts, sortResourceByValue, @@ -105,7 +105,7 @@ const usePodColumns = ( id: podColumnInfo.status.id, props: { className: podColumnInfo.status.classes }, sort: (data, direction) => - data.sort(sortResourceByValue(direction, podPhase)), + data.sort(sortResourceByValue(direction, getPodPhase)), title: t('Status'), transforms: [sortable], }, diff --git a/src/views/services/details/tabs/pods/utils.ts b/src/views/services/details/tabs/pods/utils.ts index 9e2e9fc4..6de5b0f1 100644 --- a/src/views/services/details/tabs/pods/utils.ts +++ b/src/views/services/details/tabs/pods/utils.ts @@ -13,7 +13,7 @@ import { getName, getNamespace } from '@utils/resources/shared'; export type PodPhase = string; // (See https://github.com/kubernetes/kubernetes/blob/release-1.17/pkg/printers/internalversion/printers.go) -export const podPhase = (pod: IoK8sApiCoreV1Pod): PodPhase => { +export const getPodPhase = (pod: IoK8sApiCoreV1Pod): PodPhase => { if (!pod || !pod.status) { return ''; } @@ -89,7 +89,7 @@ export const podPhase = (pod: IoK8sApiCoreV1Pod): PodPhase => { }; export const podPhaseFilterReducer = (pod: IoK8sApiCoreV1Pod): PodPhase => { - const status = podPhase(pod); + const status = getPodPhase(pod); if (status === 'Terminating') { return status; } diff --git a/src/views/topology/TopologyPage.scss b/src/views/topology/TopologyPage.scss new file mode 100644 index 00000000..82e1f6b4 --- /dev/null +++ b/src/views/topology/TopologyPage.scss @@ -0,0 +1,21 @@ +.networking-topology { + &__layout { + display: flex; + flex-direction: column; + height: calc(100vh - var(--pf-v6-c-page__main-section--PaddingTop, 0px)); + overflow: hidden; + } + + &__sticky-header { + flex-shrink: 0; + } + + &__topology-content { + flex: 1; + height: 100%; + min-height: 600px; + overflow: hidden; + display: flex; + flex-direction: column; + } +} diff --git a/src/views/topology/TopologyPage.tsx b/src/views/topology/TopologyPage.tsx new file mode 100644 index 00000000..10f16df8 --- /dev/null +++ b/src/views/topology/TopologyPage.tsx @@ -0,0 +1,49 @@ +import React, { FC } from 'react'; + +import { DocumentTitle, useActiveNamespace } from '@openshift-console/dynamic-plugin-sdk'; +import { Content, ContentVariants, PageSection, Title } from '@patternfly/react-core'; +import { useNetworkingTranslation } from '@utils/hooks/useNetworkingTranslation'; +import Topology from '@views/topology/components/Topology'; +import { TopologyContextProvider } from '@views/topology/context/TopologyContext'; +import useNetworkTopologyResources from '@views/topology/hooks/useNetworkTopologyResources'; + +import './TopologyPage.scss'; + +const TopologyPage: FC = () => { + const { t } = useNetworkingTranslation(); + const [activeNamespace] = useActiveNamespace(); + const { loaded, resources } = useNetworkTopologyResources(); + + return ( + <> + {t('Topology')} +
+
+ + {t('Topology')} + + {t( + 'Visualize, scale, and manage your cluster topology. Right-click the canvas for additional actions.', + )} + + +
+ + + + + +
+ + ); +}; + +export default TopologyPage; diff --git a/src/views/topology/components/CustomGroup/CustomGroup.scss b/src/views/topology/components/CustomGroup/CustomGroup.scss new file mode 100644 index 00000000..0d123555 --- /dev/null +++ b/src/views/topology/components/CustomGroup/CustomGroup.scss @@ -0,0 +1,10 @@ +.custom-group { + .pf-topology__group__label > text { + fill: var(--pf-topology__node__label__text--Fill); + } + + .pf-topology__node__label__background { + fill: var(--pf-topology__node__label__background--Fill); + stroke: var(--pf-topology__node__background--Stroke); + } +} diff --git a/src/views/topology/components/CustomGroup/CustomGroup.tsx b/src/views/topology/components/CustomGroup/CustomGroup.tsx new file mode 100644 index 00000000..815aecc5 --- /dev/null +++ b/src/views/topology/components/CustomGroup/CustomGroup.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react'; + +import { + DefaultGroup, + Node, + WithDndDropProps, + WithDragNodeProps, + WithSelectionProps, +} from '@patternfly/react-topology'; + +import './CustomGroup.scss'; + +type CustomGroupProps = { + element: Node; +} & WithDndDropProps & + WithDragNodeProps & + WithSelectionProps; + +const CustomGroup: FC = ({ element, ...rest }) => { + const data = element.getData(); + + return ; +}; + +export default CustomGroup; diff --git a/src/views/topology/components/CustomNode/CustomNode.scss b/src/views/topology/components/CustomNode/CustomNode.scss new file mode 100644 index 00000000..3533e5d6 --- /dev/null +++ b/src/views/topology/components/CustomNode/CustomNode.scss @@ -0,0 +1,19 @@ +.custom-node { + position: relative; + + &__inner-border { + pointer-events: none; + } + + .pf-topology__node__background { + stroke-width: 4px; + } + + .pf-topology__node__label__background { + stroke-width: 2px; + } +} + +.custom-node.pf-topology__node.pf-m-selected .pf-topology__node__background { + stroke-width: 4px; +} diff --git a/src/views/topology/components/CustomNode/CustomNode.tsx b/src/views/topology/components/CustomNode/CustomNode.tsx new file mode 100644 index 00000000..91eaee04 --- /dev/null +++ b/src/views/topology/components/CustomNode/CustomNode.tsx @@ -0,0 +1,59 @@ +import React, { FC } from 'react'; + +import { + DefaultNode, + Node, + WithDndDropProps, + WithDragNodeProps, + WithSelectionProps, +} from '@patternfly/react-topology'; +import { ICON_SIZE } from '@views/topology/utils/constants'; + +import './CustomNode.scss'; + +type CustomNodeProps = { + element: Node; +} & WithDndDropProps & + WithDragNodeProps & + WithSelectionProps; + +const CustomNode: FC = ({ element, ...rest }) => { + const data = element.getData(); + const Icon = data.icon; + const { height, width } = element.getBounds(); + + const xCenter = (width - ICON_SIZE) / 2; + const yCenter = (height - ICON_SIZE) / 2; + + const isDerivedFromPolicy = data.isDerivedFromPolicy; + return ( + + + + + {isDerivedFromPolicy && ( + + )} + + ); +}; + +export default CustomNode; diff --git a/src/views/topology/components/Topology.tsx b/src/views/topology/components/Topology.tsx new file mode 100644 index 00000000..f8985a9d --- /dev/null +++ b/src/views/topology/components/Topology.tsx @@ -0,0 +1,69 @@ +import React, { FC, useEffect, useState } from 'react'; + +import { + TopologyView, + Visualization, + VisualizationProvider, + VisualizationSurface, +} from '@patternfly/react-topology'; +import { isEmpty } from '@utils/utils'; +import ControlBar from '@views/topology/components/TopologyControlBar'; +import { useTopologyContext } from '@views/topology/context/TopologyContext'; +import { GRAPH_POSITIONING_EVENT, NODE_POSITIONING_EVENT } from '@views/topology/utils/constants'; +import { generateDataModel } from '@views/topology/utils/dataTransformation/dataTransformation'; +import { componentFactory, layoutFactory } from '@views/topology/utils/factories'; +import { restoreNodePositions, saveNodePositions } from '@views/topology/utils/nodePositionUtils'; + +const Topology: FC = () => { + const [controller, setController] = useState(); + const { resources } = useTopologyContext(); + + useEffect(() => { + if (!resources || isEmpty(resources)) return; + const topologyModel = generateDataModel(resources); + + if (!controller) { + const newController = new Visualization(); + newController.registerLayoutFactory(layoutFactory); + newController.registerComponentFactory(componentFactory); + + newController.addEventListener(NODE_POSITIONING_EVENT, () => + saveNodePositions(newController), + ); + newController.addEventListener(GRAPH_POSITIONING_EVENT, () => + saveNodePositions(newController), + ); + + newController.setFitToScreenOnLayout(true); + newController.fromModel(topologyModel, false); + restoreNodePositions(newController); + + newController.setFitToScreenOnLayout(true); + setController(newController); + return; + } + + controller.fromModel(topologyModel); + }, [controller, resources]); + + // Re-run fit after mount to ensure correct scale when container size stabilizes (e.g. navigation from create flow) + useEffect(() => { + if (!controller) return; + + const id = requestAnimationFrame(() => { + controller.getGraph().fit(40); + }); + + return () => cancelAnimationFrame(id); + }, [controller]); + + return ( + + }> + + + + ); +}; + +export default Topology; diff --git a/src/views/topology/components/TopologyControlBar.tsx b/src/views/topology/components/TopologyControlBar.tsx new file mode 100644 index 00000000..980978f4 --- /dev/null +++ b/src/views/topology/components/TopologyControlBar.tsx @@ -0,0 +1,43 @@ +import React, { FC } from 'react'; + +import { + action, + Controller, + createTopologyControlButtons, + defaultControlButtonsOptions, + TopologyControlBar, +} from '@patternfly/react-topology'; + +type ControlBarProps = { + controller: Controller; +}; + +const ControlBar: FC = ({ controller }) => { + if (!controller) { + return null; + } + return ( + { + controller.getGraph().fit(40); + }), + resetViewCallback: action(() => { + controller.getGraph().reset(); + controller.getGraph().layout(); + }), + zoomInCallback: action(() => { + const scale = controller.getGraph().getScale(); + controller.getGraph().setScale(scale * 1.1); + }), + zoomOutCallback: action(() => { + const scale = controller.getGraph().getScale(); + controller.getGraph().setScale(scale * 0.9); + }), + })} + /> + ); +}; + +export default ControlBar; diff --git a/src/views/topology/context/TopologyContext.tsx b/src/views/topology/context/TopologyContext.tsx new file mode 100644 index 00000000..d8feb436 --- /dev/null +++ b/src/views/topology/context/TopologyContext.tsx @@ -0,0 +1,32 @@ +import React, { createContext, FC, PropsWithChildren, useContext } from 'react'; + +import { ALL_NAMESPACES_KEY } from '@utils/constants'; +import { NetworkTopologyResources } from '@views/topology/hooks/useNetworkTopologyResources'; + +type TopologyContextType = { + activeNamespace: string; + resources: NetworkTopologyResources | undefined; + resourcesLoaded: boolean; +}; + +const TopologyContext = createContext({ + activeNamespace: ALL_NAMESPACES_KEY, + resources: undefined, + resourcesLoaded: false, +}); + +export const TopologyContextProvider: FC> = ({ + activeNamespace, + children, + resources, + resourcesLoaded, +}) => ( + + {children} + +); + +export const useTopologyContext = (): TopologyContextType => { + const contextData = useContext(TopologyContext); + return contextData; +}; diff --git a/src/views/topology/hooks/useNetworkTopologyResources.ts b/src/views/topology/hooks/useNetworkTopologyResources.ts new file mode 100644 index 00000000..f5ae825b --- /dev/null +++ b/src/views/topology/hooks/useNetworkTopologyResources.ts @@ -0,0 +1,122 @@ +import { useMemo } from 'react'; + +import { + modelToGroupVersionKind, + NetworkAttachmentDefinitionModel, + PodModel, + VirtualMachineModel, +} from '@kubevirt-ui-ext/kubevirt-api/console'; +import { IoK8sApiCoreV1Pod } from '@kubevirt-ui-ext/kubevirt-api/kubernetes'; +import { V1VirtualMachine } from '@kubevirt-ui-ext/kubevirt-api/kubevirt'; +import { + K8sResourceCommon, + useActiveNamespace, + useK8sWatchResources, +} from '@openshift-console/dynamic-plugin-sdk'; +import { ALL_NAMESPACES_KEY } from '@utils/constants'; +import useProjects from '@utils/hooks/useProjects/useProjects'; +import { + ClusterUserDefinedNetworkModelGroupVersionKind, + IngressModel, + UserDefinedNetworkModelGroupVersionKind, +} from '@utils/models'; +import { NetworkAttachmentDefinitionKind } from '@utils/resources/nads/types'; +import { ClusterUserDefinedNetworkKind, UserDefinedNetworkKind } from '@utils/resources/udns/types'; +import { + isVirtLauncherPod, + podHasSecondaryNetworks, + vmHasSecondaryNetworks, +} from '@views/topology/utils/filteringUtils'; + +export type NetworkTopologyResources = { + clusterUserDefinedNetworks: ClusterUserDefinedNetworkKind[]; + networkAttachmentDefinitions: NetworkAttachmentDefinitionKind[]; + pods: IoK8sApiCoreV1Pod[]; + projects: K8sResourceCommon[]; + userDefinedNetworks: UserDefinedNetworkKind[]; + virtualMachines: V1VirtualMachine[]; +}; + +type UseNetworkTopologyResourcesResult = { + loaded: boolean; + loadError: unknown; + resources: NetworkTopologyResources; +}; + +const useNetworkTopologyResources = (): UseNetworkTopologyResourcesResult => { + const [activeNamespace] = useActiveNamespace(); + const [projects, projectsLoaded] = useProjects(); + + const namespaceProperties = { + ...(activeNamespace !== ALL_NAMESPACES_KEY && { namespace: activeNamespace }), + namespaced: activeNamespace !== ALL_NAMESPACES_KEY, + }; + + const watchedResources = useK8sWatchResources({ + clusterUserDefinedNetworks: { + groupVersionKind: ClusterUserDefinedNetworkModelGroupVersionKind, + isList: true, + ...namespaceProperties, + }, + ingresses: { + groupVersionKind: modelToGroupVersionKind(IngressModel), + isList: true, + ...namespaceProperties, + }, + networkAttachmentDefinitions: { + groupVersionKind: modelToGroupVersionKind(NetworkAttachmentDefinitionModel), + isList: true, + ...namespaceProperties, + }, + pods: { + groupVersionKind: modelToGroupVersionKind(PodModel), + isList: true, + ...namespaceProperties, + }, + userDefinedNetworks: { + groupVersionKind: UserDefinedNetworkModelGroupVersionKind, + isList: true, + ...namespaceProperties, + }, + virtualMachines: { + groupVersionKind: modelToGroupVersionKind(VirtualMachineModel), + isList: true, + ...namespaceProperties, + }, + }); + + const loaded = + Object.values(watchedResources).every((resource) => resource.loaded) && projectsLoaded; + const loadError = Object.values(watchedResources).find( + (resource) => resource.loadError?.loadError, + ); + + const filteredPods = useMemo(() => { + const pods = (watchedResources.pods.data || []) as IoK8sApiCoreV1Pod[]; + return pods.filter(podHasSecondaryNetworks).filter((pod) => !isVirtLauncherPod(pod)); + }, [watchedResources.pods.data]); + + const filteredVMs = useMemo(() => { + const vms = (watchedResources.virtualMachines.data || []) as V1VirtualMachine[]; + return vms.filter(vmHasSecondaryNetworks); + }, [watchedResources.virtualMachines.data]); + + const resources: NetworkTopologyResources = { + clusterUserDefinedNetworks: watchedResources.clusterUserDefinedNetworks + .data as ClusterUserDefinedNetworkKind[], + networkAttachmentDefinitions: watchedResources.networkAttachmentDefinitions + .data as NetworkAttachmentDefinitionKind[], + pods: filteredPods, + projects: projects || [], + userDefinedNetworks: watchedResources.userDefinedNetworks.data as UserDefinedNetworkKind[], + virtualMachines: filteredVMs, + }; + + return { + loaded, + loadError, + resources, + }; +}; + +export default useNetworkTopologyResources; diff --git a/src/views/topology/manifest.ts b/src/views/topology/manifest.ts new file mode 100644 index 00000000..36b17764 --- /dev/null +++ b/src/views/topology/manifest.ts @@ -0,0 +1,37 @@ +import { HrefNavItem, RoutePage } from '@openshift-console/dynamic-plugin-sdk'; +import { + ConsolePluginBuildMetadata, + EncodedExtension, +} from '@openshift-console/dynamic-plugin-sdk-webpack'; + +export const TopologyExtensions: EncodedExtension[] = [ + { + properties: { + component: { $codeRef: 'Topology' }, + exact: true, + path: [`/k8s/ns/:ns/networking-topology`, '/k8s/all-namespaces/networking-topology'], + }, + type: 'console.page/route', + } as EncodedExtension, + { + properties: { + dataAttributes: { + 'data-quickstart-id': 'qs-nav-configuration-topology', + 'data-test-id': 'configuration-topology-nav-item', + }, + href: 'networking-topology', + id: 'network-configuration-topology', + insertBefore: 'services', + // t('Configuration Topology') + name: '%plugin__networking-console-plugin~Configuration Topology%', + perspective: 'admin', + prefixNamespaced: true, + section: 'networking', + }, + type: 'console.navigation/href', + } as EncodedExtension, +]; + +export const TopologyExposedModules: ConsolePluginBuildMetadata['exposedModules'] = { + Topology: './views/topology/TopologyPage.tsx', +}; diff --git a/src/views/topology/utils/LevelsLayout.ts b/src/views/topology/utils/LevelsLayout.ts new file mode 100644 index 00000000..34ccc537 --- /dev/null +++ b/src/views/topology/utils/LevelsLayout.ts @@ -0,0 +1,209 @@ +import { + BaseLayout, + Edge, + Graph, + GRAPH_LAYOUT_END_EVENT, + GridLayoutOptions, + Layout, + LayoutLink, + LayoutNode, + Node, +} from '@patternfly/react-topology'; +import { GridGroup } from '@patternfly/react-topology/dist/esm/layouts/GridGroup'; +import { GridLink } from '@patternfly/react-topology/dist/esm/layouts/GridLink'; +import { GridNode } from '@patternfly/react-topology/dist/esm/layouts/GridNode'; + +export class LevelsLayout extends BaseLayout implements Layout { + constructor(graph: Graph, options?: Partial) { + super(graph, options); + } + + // Method to calculate maximum group dimensions + private calculateMaxGroupDimensions(groups: { [groupId: string]: LayoutNode[] }): { + maxGroupHeight: number; + maxGroupWidth: number; + } { + const groupDimensions = Object.keys(groups || {}).map((key) => + this.positionGroupNodes(groups[key], 0, 0), + ); + const maxGroupWidth = Math.max(...groupDimensions.map((dim) => dim.width)); + const maxGroupHeight = Math.max(...groupDimensions.map((dim) => dim.height)); + return { maxGroupHeight, maxGroupWidth }; + } + + // Method to count nodes per level + private countNodesPerLevel(nodes: LayoutNode[]): { [level: number]: number } { + const levelCounts: { [level: number]: number } = {}; + (nodes || []).forEach((node) => { + const level = node?.element?.getData()?.level; + if (!levelCounts[level]) { + levelCounts[level] = 0; + } + levelCounts[level]++; + }); + return levelCounts; + } + + // Method to determine maximum node dimensions + private getMaxNodeDimensions(groupNodes: LayoutNode[]): { padX: number; padY: number } { + let padX = 0; + let padY = 0; + + (groupNodes || []).forEach((node) => { + if (padX < node.width) { + padX = node.width; + } + if (padY < node.height) { + padY = node.height; + } + }); + + return { padX, padY }; + } + + // Method to group nodes by their parent group + private groupNodesByParent(): { [groupId: string]: LayoutNode[] } { + const groups: { [groupId: string]: LayoutNode[] } = {}; + (this.nodes || []).forEach((node) => { + const parentGroupId = node.element.getParent()?.getId(); + if (parentGroupId) { + if (!groups[parentGroupId]) { + groups[parentGroupId] = []; + } + groups[parentGroupId].push(node); + } + }); + return groups; + } + + // Method to layout nodes by level + private layoutNodesByLevel( + sortedNodes: LayoutNode[], + offsetX: number, + offsetY: number, + padX: number, + padY: number, + ): { maxX: number; maxY: number } { + const levelCounts = this.countNodesPerLevel(sortedNodes); + let currentLevel = sortedNodes[0]?.element?.getData()?.level; + let nodesInCurrentLevel = 0; + + let x = offsetX; + let y = offsetY; + let maxX = x; + let maxY = y; + + (sortedNodes || []).forEach((node) => { + if (node?.element?.getData()?.level !== currentLevel) { + // Move to the next level when the level changes + currentLevel = node?.element?.getData()?.level; + x = offsetX; + y += padY + 20; // Adding some extra space between levels + nodesInCurrentLevel = 0; + } + + node.x = x; + node.y = y; + node.update(); + + nodesInCurrentLevel++; + const numNodesInRow = levelCounts[currentLevel]; + + if (nodesInCurrentLevel < numNodesInRow) { + x += padX + 20; // Adding some extra space between nodes + } else { + x = offsetX; + y += padY + 20; // Move to the next row within the same level + nodesInCurrentLevel = 0; + } + + // Track maxX and maxY to determine the total space used by this group + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + }); + + return { maxX, maxY }; + } + + // Method to layout nodes within a group + private positionGroupNodes( + groupNodes: LayoutNode[], + offsetX: number, + offsetY: number, + ): { height: number; width: number } { + const sortedNodes = this.sortNodesByLevel(groupNodes); + const { padX, padY } = this.getMaxNodeDimensions(sortedNodes); + const { maxX, maxY } = this.layoutNodesByLevel(sortedNodes, offsetX, offsetY, padX, padY); + + // Return the dimensions of the group layout + return { height: maxY - offsetY + padY, width: maxX - offsetX + padX }; + } + + // Method to position groups in a grid layout + private positionGroupsInGrid( + groups: { [groupId: string]: LayoutNode[] }, + maxGroupWidth: number, + maxGroupHeight: number, + ): void { + const groupKeys = Object.keys(groups); + const totalGroups = groupKeys.length; + const numCols = Math.ceil(Math.sqrt(totalGroups)); + + let offsetX = 0; + let offsetY = 0; + + for (let row = 0; row < Math.ceil(totalGroups / numCols); row++) { + for (let col = 0; col < numCols; col++) { + const groupIndex = row * numCols + col; + if (groupIndex < totalGroups) { + const groupNodes = groups[groupKeys[groupIndex]]; + this.positionGroupNodes(groupNodes, offsetX, offsetY); + offsetX += maxGroupWidth + 50; // Adding some extra space between groups + } + } + offsetX = 0; + offsetY += maxGroupHeight + 50; // Adding some extra space between rows of groups + } + } + + // Method to sort nodes by their level + private sortNodesByLevel(nodes: LayoutNode[]): LayoutNode[] { + return (nodes || []).sort((a, b) => { + const aLevel = a?.element?.getData()?.level; + const bLevel = b?.element?.getData()?.level; + return aLevel - bLevel; + }); + } + + // Method to create a layout group + protected createLayoutGroup(node: Node, padding: number, index: number) { + return new GridGroup(node, padding, index); + } + + // Method to create a layout link + protected createLayoutLink( + edge: Edge, + source: LayoutNode, + target: LayoutNode, + isFalse: boolean, + ): LayoutLink { + return new GridLink(edge, source, target, isFalse); + } + + // Method to create a layout node + protected createLayoutNode(node: Node, nodeDistance: number, index: number) { + return new GridNode(node, nodeDistance, index); + } + + // Main layout method + protected startLayout(graph: Graph, initialRun: boolean, addingNodes: boolean): void { + if (initialRun || addingNodes) { + this.nodes.sort((a, b) => a.id.localeCompare(b.id)); + const groups = this.groupNodesByParent(); + const { maxGroupHeight, maxGroupWidth } = this.calculateMaxGroupDimensions(groups); + this.positionGroupsInGrid(groups, maxGroupWidth, maxGroupHeight); + } + + this.graph.getController().fireEvent(GRAPH_LAYOUT_END_EVENT, { graph: this.graph }); + } +} diff --git a/src/views/topology/utils/constants.ts b/src/views/topology/utils/constants.ts new file mode 100644 index 00000000..b8adec6f --- /dev/null +++ b/src/views/topology/utils/constants.ts @@ -0,0 +1,48 @@ +export enum TopologyViewTypes { + namespace = 'namespace', + network = 'network', + node = 'node', + owner = 'owner', + resource = 'resource', +} + +export enum ResourceType { + ClusterUserDefinedNetwork = 'ClusterUserDefinedNetwork', + Ingress = 'Ingress', + MultiNetworkPolicy = 'MultiNetworkPolicy', + NetworkAttachmentDefinition = 'NetworkAttachmentDefinition', + NetworkPolicy = 'NetworkPolicy', + Pod = 'Pod', + Route = 'Route', + Service = 'Service', + UserDefinedNetwork = 'UserDefinedNetwork', + VirtualMachine = 'VirtualMachine', +} + +export enum RelationshipType { + CUDNToNAD = 'cudn-to-nad', + CUDNToPod = 'cudn-to-pod', + CUDNToVM = 'cudn-to-vm', + IngressToService = 'ingress-to-service', + MultiNetworkPolicyToNAD = 'multi-network-policy-to-nad', + PodToNAD = 'pod-to-nad', + RouteToService = 'route-to-service', + UDNToNAD = 'udn-to-nad', + UDNToPod = 'udn-to-pod', + UDNToVM = 'udn-to-vm', + VirtualMachineToNAD = 'vm-to-nad', +} + +export const TOPOLOGY_POSITION_LOCAL_STORAGE_KEY = 'topology-node-positions'; + +export const NODE_POSITIONING_EVENT = 'node-positioned'; +export const GRAPH_POSITIONING_EVENT = 'graph-position-change'; + +export const NODE_DIAMETER = 70; +export const ICON_SIZE = 30; + +export const GROUP = 'group'; + +export const MULTI_NETWORK_POLICY_NADS_ANNOTATION_KEY = 'k8s.v1.cni.cncf.io/policy-for'; + +export const L2_BRIDGE_BINDING_NAME = 'l2bridge'; diff --git a/src/views/topology/utils/dataTransformation/dataTransformation.ts b/src/views/topology/utils/dataTransformation/dataTransformation.ts new file mode 100644 index 00000000..038e84dc --- /dev/null +++ b/src/views/topology/utils/dataTransformation/dataTransformation.ts @@ -0,0 +1,191 @@ +import { ComponentType } from 'react'; + +import { IoK8sApiCoreV1Pod } from '@kubevirt-ui-ext/kubevirt-api/kubernetes'; +import { V1VirtualMachine } from '@kubevirt-ui-ext/kubevirt-api/kubevirt'; +import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; +import { CubeIcon, NetworkIcon, ServerIcon } from '@patternfly/react-icons'; +import { + EdgeModel, + EdgeStyle, + EdgeTerminalType, + Model, + ModelKind, + NodeModel, + NodeShape, + NodeStatus, +} from '@patternfly/react-topology'; +import { getName, getNamespace } from '@utils/resources/shared'; +import { udnIsReady } from '@utils/resources/udns/helper'; +import { PrintableVMErrorStatus, PrintableVMStatus } from '@utils/resources/vm/constants'; +import { getVMStatus } from '@utils/resources/vm/selectors'; +import { PodPhase } from '@views/services/details/tabs/pods/constants'; +import { getPodPhase } from '@views/services/details/tabs/pods/utils'; +import { NetworkTopologyResources } from '@views/topology/hooks/useNetworkTopologyResources'; +import { + addClusterUserDefinedNetworkNodesAndEdges, + addNetworkAttachmentDefinitionNodes, + addPodNodesAndEdges, + addUserDefinedNetworkNodesAndEdges, + addVirtualMachineNodesAndEdges, + getResourceID, +} from '@views/topology/utils/dataTransformation/resourceUtils'; + +import { NODE_DIAMETER, RelationshipType, ResourceType } from '../constants'; + +const getIconForResourceType = (resourceType: ResourceType): ComponentType => { + switch (resourceType) { + case ResourceType.NetworkAttachmentDefinition: + case ResourceType.UserDefinedNetwork: + case ResourceType.ClusterUserDefinedNetwork: + return NetworkIcon; + case ResourceType.Pod: + return CubeIcon; + case ResourceType.VirtualMachine: + return ServerIcon; + default: + return NetworkIcon; + } +}; + +const getShapeForResourceType = (resourceType: ResourceType): NodeShape => { + switch (resourceType) { + case ResourceType.NetworkAttachmentDefinition: + return NodeShape.octagon; + case ResourceType.UserDefinedNetwork: + case ResourceType.ClusterUserDefinedNetwork: + return NodeShape.hexagon; + case ResourceType.Pod: + case ResourceType.VirtualMachine: + return NodeShape.ellipse; + default: + return NodeShape.ellipse; + } +}; + +const getResourceStatus = (resource: K8sResourceCommon, resourceType: ResourceType): NodeStatus => { + switch (resourceType) { + case ResourceType.Pod: { + const podPhase = getPodPhase(resource as IoK8sApiCoreV1Pod); + if (podPhase === PodPhase.Succeeded || podPhase === PodPhase.Running) + return NodeStatus.success; + if (podPhase === PodPhase.Failed) return NodeStatus.danger; + return NodeStatus.default; + } + case ResourceType.VirtualMachine: { + const vmStatus = getVMStatus(resource as V1VirtualMachine) as + PrintableVMErrorStatus | PrintableVMStatus; + + if (Object.keys(PrintableVMErrorStatus).includes(vmStatus)) return NodeStatus.danger; + if (vmStatus === PrintableVMStatus.Unknown) return NodeStatus.warning; + if (Object.keys(PrintableVMStatus).includes(vmStatus)) return NodeStatus.success; + return NodeStatus.default; + } + case ResourceType.UserDefinedNetwork: + case ResourceType.ClusterUserDefinedNetwork: + return udnIsReady(resource) ? NodeStatus.success : NodeStatus.danger; + case ResourceType.NetworkAttachmentDefinition: + default: + return NodeStatus.default; + } +}; + +export const createNode = ( + resource: K8sResourceCommon, + resourceType: ResourceType, + level: number, + options?: { badge?: string; isDerivedFromPolicy?: boolean }, +): NodeModel => { + const id = getResourceID(resource); + const status = getResourceStatus(resource, resourceType); + + return { + data: { + badge: options?.badge, + icon: getIconForResourceType(resourceType), + isDerivedFromPolicy: options?.isDerivedFromPolicy, + level, + namespace: getNamespace(resource), + resource, + resourceType, + status, + }, + height: NODE_DIAMETER, + id, + label: getName(resource) || 'Unknown', + shape: getShapeForResourceType(resourceType), + status, + type: 'node', + width: NODE_DIAMETER, + }; +}; + +export const createEdge = ( + sourceId: string, + targetId: string, + relationshipType: RelationshipType, +): EdgeModel => { + return { + data: { + endTerminalType: EdgeTerminalType.directional, + relationshipType, + sourceId, + targetId, + }, + edgeStyle: EdgeStyle.solid, + id: `${sourceId}-to-${targetId}`, + source: sourceId, + target: targetId, + type: 'edge', + }; +}; + +export const findExistingEdge = ( + edges: EdgeModel[], + sourceId: string, + targetId: string, +): EdgeModel | undefined => { + return edges.find( + (e) => + (e.data.sourceId === sourceId && e.data.targetId === targetId) || + (e.data.sourceId === targetId && e.data.targetId === sourceId), + ); +}; + +export const generateDataModel = (resources: NetworkTopologyResources): Model => { + const nodes: NodeModel[] = []; + const edges: EdgeModel[] = []; + const nodeMap = new Map(); + + addNetworkAttachmentDefinitionNodes(resources.networkAttachmentDefinitions, nodes, nodeMap); + addPodNodesAndEdges(resources.pods, nodes, edges, nodeMap); + addVirtualMachineNodesAndEdges(resources.virtualMachines, nodes, edges, nodeMap); + addUserDefinedNetworkNodesAndEdges( + resources.userDefinedNetworks, + nodes, + edges, + nodeMap, + resources.pods, + resources.virtualMachines, + resources.networkAttachmentDefinitions, + ); + addClusterUserDefinedNetworkNodesAndEdges( + resources.clusterUserDefinedNetworks, + nodes, + edges, + nodeMap, + resources.pods, + resources.virtualMachines, + resources.networkAttachmentDefinitions, + resources.projects, + ); + + return { + edges, + graph: { + id: 'networking-configuration-topology', + layout: 'Levels', + type: ModelKind.graph, + }, + nodes: nodes.filter((n) => n.type !== 'group' || (n.children && n.children.length)), + }; +}; diff --git a/src/views/topology/utils/dataTransformation/resourceUtils.ts b/src/views/topology/utils/dataTransformation/resourceUtils.ts new file mode 100644 index 00000000..601e5217 --- /dev/null +++ b/src/views/topology/utils/dataTransformation/resourceUtils.ts @@ -0,0 +1,226 @@ +import { IoK8sApiCoreV1Pod } from '@kubevirt-ui-ext/kubevirt-api/kubernetes'; +import { V1VirtualMachine } from '@kubevirt-ui-ext/kubevirt-api/kubevirt'; +import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; +import { EdgeModel, NodeModel } from '@patternfly/react-topology'; +import { NetworkAttachmentDefinitionKind } from '@utils/resources/nads/types'; +import { getName, getNamespace } from '@utils/resources/shared'; +import { CLUSTER_UDN_NAD_NAME_PREFIX } from '@utils/resources/udns/constants'; +import { getCUDNNamespaceNames } from '@utils/resources/udns/helper'; +import { ClusterUserDefinedNetworkKind, UserDefinedNetworkKind } from '@utils/resources/udns/types'; +import { vmUsesUDN } from '@utils/resources/vm/utils'; +import { RelationshipType, ResourceType } from '@views/topology/utils/constants'; +import { + createEdge, + createNode, + findExistingEdge, +} from '@views/topology/utils/dataTransformation/dataTransformation'; +import { + getPodNADReferences, + getVMNADReferences, + podUsesUDN, +} from '@views/topology/utils/filteringUtils'; + +export const getResourceID = (resource: K8sResourceCommon): string => { + const namespace = getNamespace(resource) || 'cluster'; + const name = getName(resource) || 'unknown'; + const kind = resource.kind || 'unknown'; + return `${kind}-${namespace}-${name}`; +}; + +export const addNetworkAttachmentDefinitionNodes = ( + nads: NetworkAttachmentDefinitionKind[], + nodes: NodeModel[], + nodeMap: Map, +) => + nads?.forEach((nad: NetworkAttachmentDefinitionKind) => { + const id = getResourceID(nad); + const node = createNode(nad, ResourceType.NetworkAttachmentDefinition, 2, { + badge: 'NAD', + }); + nodes.push(node); + nodeMap.set(id, node); + }); + +export const addPodNodesAndEdges = ( + pods: IoK8sApiCoreV1Pod[], + nodes: NodeModel[], + edges: EdgeModel[], + nodeMap: Map, +) => + pods?.forEach((pod: IoK8sApiCoreV1Pod) => { + const id = getResourceID(pod); + const node = createNode(pod, ResourceType.Pod, 5, { badge: 'POD' }); + nodes.push(node); + nodeMap.set(id, node); + + const nadReferences = getPodNADReferences(pod); + nadReferences.forEach((nadRef) => { + const [nadNamespace, nadName] = nadRef.split('/'); + const nadID = `NetworkAttachmentDefinition-${nadNamespace}-${nadName}`; + const nadNode = nodeMap.get(nadID); + + if (nadNode && id !== nadID) { + const existingEdge = findExistingEdge(edges, id, nadID); + if (!existingEdge) { + edges.push(createEdge(id, nadID, RelationshipType.PodToNAD)); + } + } + }); + }); + +export const addVirtualMachineNodesAndEdges = ( + vms: V1VirtualMachine[], + nodes: NodeModel[], + edges: EdgeModel[], + nodeMap: Map, +) => + vms?.forEach((vm: V1VirtualMachine) => { + const id = getResourceID(vm); + const node = createNode(vm, ResourceType.VirtualMachine, 5, { badge: 'VM' }); + nodes.push(node); + nodeMap.set(id, node); + + const nadReferences = getVMNADReferences(vm); + nadReferences.forEach((nadRef) => { + const [nadNamespace, nadName] = nadRef.split('/'); + const nadID = `NetworkAttachmentDefinition-${nadNamespace}-${nadName}`; + const nadNode = nodeMap.get(nadID); + + if (nadNode && id !== nadID) { + const existingEdge = findExistingEdge(edges, id, nadID); + if (!existingEdge) { + edges.push(createEdge(id, nadID, RelationshipType.VirtualMachineToNAD)); + } + } + }); + }); + +export const addUserDefinedNetworkNodesAndEdges = ( + udns: UserDefinedNetworkKind[], + nodes: NodeModel[], + edges: EdgeModel[], + nodeMap: Map, + pods: IoK8sApiCoreV1Pod[], + vms: V1VirtualMachine[], + nads: NetworkAttachmentDefinitionKind[], +) => + udns?.forEach((udn: UserDefinedNetworkKind) => { + const id = getResourceID(udn); + const node = createNode(udn, ResourceType.UserDefinedNetwork, 3, { badge: 'UDN' }); + nodes.push(node); + nodeMap.set(id, node); + + const udnName = getName(udn); + const udnNamespace = getNamespace(udn); + + nads?.forEach((nad) => { + const nadName = getName(nad); + const nadNamespace = getNamespace(nad); + + if (nadName === udnName && nadNamespace === udnNamespace) { + const nadID = getResourceID(nad); + const nadNode = nodeMap.get(nadID); + if (nadNode && id !== nadID) { + const existingEdge = findExistingEdge(edges, id, nadID); + if (!existingEdge) { + edges.push(createEdge(id, nadID, RelationshipType.UDNToNAD)); + } + } + } + }); + + pods?.forEach((pod) => { + if (podUsesUDN(pod, udn)) { + const podID = getResourceID(pod); + const podNode = nodeMap.get(podID); + if (podNode && id !== podID) { + const existingEdge = findExistingEdge(edges, id, podID); + if (!existingEdge) { + edges.push(createEdge(id, podID, RelationshipType.UDNToPod)); + } + } + } + }); + + vms?.forEach((vm) => { + if (vmUsesUDN(vm, udn)) { + const vmID = getResourceID(vm); + const vmNode = nodeMap.get(vmID); + if (vmNode && id !== vmID) { + const existingEdge = findExistingEdge(edges, id, vmID); + if (!existingEdge) { + edges.push(createEdge(id, vmID, RelationshipType.UDNToVM)); + } + } + } + }); + }); + +export const addClusterUserDefinedNetworkNodesAndEdges = ( + clusterUDNs: ClusterUserDefinedNetworkKind[], + nodes: NodeModel[], + edges: EdgeModel[], + nodeMap: Map, + pods: IoK8sApiCoreV1Pod[], + vms: V1VirtualMachine[], + nads: NetworkAttachmentDefinitionKind[], + projects?: K8sResourceCommon[], +) => + clusterUDNs?.forEach((cudn: ClusterUserDefinedNetworkKind) => { + const id = getResourceID(cudn); + const node = createNode(cudn, ResourceType.ClusterUserDefinedNetwork, 3, { + badge: 'CUDN', + }); + nodes.push(node); + nodeMap.set(id, node); + + const cudnName = getName(cudn); + const candidateNames = [ + cudnName, + `${CLUSTER_UDN_NAD_NAME_PREFIX}${cudnName}`, + `cluster-udn-${cudnName}`, + ]; + + const cudnNamespaces = getCUDNNamespaceNames(cudn, projects); + + nads?.forEach((nad) => { + const nadName = getName(nad); + + if (candidateNames.includes(nadName)) { + const nadID = getResourceID(nad); + const nadNode = nodeMap.get(nadID); + if (nadNode && id !== nadID) { + const existingEdge = findExistingEdge(edges, id, nadID); + if (!existingEdge) { + edges.push(createEdge(id, nadID, RelationshipType.CUDNToNAD)); + } + } + } + }); + + pods?.forEach((pod) => { + if (podUsesUDN(pod, cudn)) { + const podID = getResourceID(pod); + const podNode = nodeMap.get(podID); + if (podNode && id !== podID) { + const existingEdge = findExistingEdge(edges, id, podID); + if (!existingEdge) { + edges.push(createEdge(id, podID, RelationshipType.CUDNToPod)); + } + } + } + }); + + vms?.forEach((vm) => { + if (vmUsesUDN(vm, cudn, cudnNamespaces)) { + const vmID = getResourceID(vm); + const vmNode = nodeMap.get(vmID); + if (vmNode && id !== vmID) { + const existingEdge = findExistingEdge(edges, id, vmID); + if (!existingEdge) { + edges.push(createEdge(id, vmID, RelationshipType.CUDNToVM)); + } + } + } + }); + }); diff --git a/src/views/topology/utils/factories.ts b/src/views/topology/utils/factories.ts new file mode 100644 index 00000000..7eea7d9f --- /dev/null +++ b/src/views/topology/utils/factories.ts @@ -0,0 +1,39 @@ +import { + ComponentFactory, + DefaultEdge, + GraphComponent, + ModelKind, + nodeDragSourceSpec, + withDragNode, + withPanZoom, + withSelection, +} from '@patternfly/react-topology'; +import { GROUP } from '@views/topology/utils/constants'; +import { LevelsLayout } from '@views/topology/utils/LevelsLayout'; + +import CustomGroup from '../components/CustomGroup/CustomGroup'; +import CustomNode from '../components/CustomNode/CustomNode'; + +export const layoutFactory = (type, graph) => { + return new LevelsLayout(graph, { layoutOnDrag: true }); +}; + +export const componentFactory: ComponentFactory = (kind: ModelKind, type: string) => { + switch (type) { + case GROUP: + return withDragNode(nodeDragSourceSpec(GROUP))(withSelection()(CustomGroup)); + default: + switch (kind) { + case ModelKind.graph: + return withPanZoom()(GraphComponent); + case ModelKind.node: + return withDragNode(nodeDragSourceSpec(ModelKind.node, true, true))( + withSelection()(CustomNode), + ); + case ModelKind.edge: + return DefaultEdge; + default: + return undefined; + } + } +}; diff --git a/src/views/topology/utils/filteringUtils.ts b/src/views/topology/utils/filteringUtils.ts new file mode 100644 index 00000000..36c250c6 --- /dev/null +++ b/src/views/topology/utils/filteringUtils.ts @@ -0,0 +1,99 @@ +import { IoK8sApiCoreV1Pod } from '@kubevirt-ui-ext/kubevirt-api/kubernetes'; +import { V1VirtualMachine } from '@kubevirt-ui-ext/kubevirt-api/kubevirt'; +import { + VIRT_LAUNCHER_POD_LABEL_KEY, + VIRT_LAUNCHER_POD_LABEL_VALUE, +} from '@utils/resources/pods/constants'; +import { getAnnotations, getLabels, getNamespace } from '@utils/resources/shared'; +import { getUDNNADNameCandidates, isClusterUDN, UDNResource } from '@utils/resources/udns/helper'; +import { getInterfaces, getNetworks } from '@utils/resources/vm/selectors'; +import { L2_BRIDGE_BINDING_NAME } from '@views/topology/utils/constants'; + +export const POD_NETWORKS_ANNOTATION_KEY = 'k8s.v1.cni.cncf.io/networks'; + +/** + * Extract NAD names from Pod's network annotation + * Handles both simple names and namespace-qualified names + */ +export const getPodNetworkAttachments = (pod: IoK8sApiCoreV1Pod): string[] => { + const annotation = getAnnotations(pod)?.[POD_NETWORKS_ANNOTATION_KEY]; + if (!annotation) return []; + + return annotation + .split(',') + .map((nad) => nad.trim()) + .filter(Boolean); +}; + +export const podHasSecondaryNetworks = (pod: IoK8sApiCoreV1Pod): boolean => + getPodNetworkAttachments(pod).length > 0; + +export const isVirtLauncherPod = (pod: IoK8sApiCoreV1Pod): boolean => + getLabels(pod)?.[VIRT_LAUNCHER_POD_LABEL_KEY] === VIRT_LAUNCHER_POD_LABEL_VALUE; + +export const vmHasSecondaryNetworks = (vm: V1VirtualMachine): boolean => { + // TODO Check logic + // const networks = getNetworks(vm); + // return networks.some((network) => network.multus?.networkName); + const interfaces = getInterfaces(vm); + return ( + interfaces?.some((iface) => iface?.binding?.name === L2_BRIDGE_BINDING_NAME) || + getNetworks(vm)?.length > 1 + ); +}; + +/** + * Get all NAD names that a Pod connects to (normalized to namespace/name format) + */ +export const getPodNADReferences = (pod: IoK8sApiCoreV1Pod): string[] => { + const podNamespace = getNamespace(pod); + const attachments = getPodNetworkAttachments(pod); + + return attachments.map((attachment) => { + if (attachment.includes('/')) { + return attachment; + } + return `${podNamespace}/${attachment}`; + }); +}; + +/** + * Get all NAD names that a VM connects to (normalized to namespace/name format) + */ +export const getVMNADReferences = (vm: V1VirtualMachine): string[] => { + const vmNamespace = getNamespace(vm); + const networks = getNetworks(vm); + + return networks + .filter((network) => network.multus?.networkName) + .map((network) => { + const networkName = network.multus.networkName; + if (networkName.includes('/')) { + return networkName; + } + return `${vmNamespace}/${networkName}`; + }); +}; + +/** + * Check if a Pod uses a UDN or ClusterUDN + */ +export const podUsesUDN = (pod: IoK8sApiCoreV1Pod, udn: UDNResource): boolean => { + const udnNamespace = getNamespace(udn); + const nadCandidates = getUDNNADNameCandidates(udn); + const podNADRefs = getPodNADReferences(pod); + + return podNADRefs.some((nadRef) => { + const [refNamespace, refName] = nadRef.split('/'); + + if (!nadCandidates.includes(refName)) { + return false; + } + + if (isClusterUDN(udn)) { + return true; + } + + return refNamespace === udnNamespace; + }); +}; diff --git a/src/views/topology/utils/nodePositionUtils.ts b/src/views/topology/utils/nodePositionUtils.ts new file mode 100644 index 00000000..daa8626b --- /dev/null +++ b/src/views/topology/utils/nodePositionUtils.ts @@ -0,0 +1,44 @@ +import { Controller, Point } from '@patternfly/react-topology'; +import { TOPOLOGY_POSITION_LOCAL_STORAGE_KEY } from '@views/topology/utils/constants'; + +export const saveNodePositions = (visualization: Controller) => { + const graph = visualization.getGraph(); + const nodePositions = {}; + + // Traverse all nodes and their children + graph.getNodes().forEach((node) => { + nodePositions[node.getId()] = node.getPosition(); + if (node.isGroup()) { + node.getAllNodeChildren().forEach((childNode) => { + nodePositions[childNode.getId()] = childNode.getPosition(); + }); + } + }); + + localStorage.setItem(TOPOLOGY_POSITION_LOCAL_STORAGE_KEY, JSON.stringify(nodePositions)); +}; + +export const restoreNodePositions = (visualization: Controller) => { + const savedPositions = localStorage.getItem(TOPOLOGY_POSITION_LOCAL_STORAGE_KEY); + if (savedPositions) { + const nodePositions = JSON.parse(savedPositions); + const graph = visualization.getGraph(); + + // Traverse all nodes and their children + graph.getNodes().forEach((node) => { + if (nodePositions[node.getId()]) { + node.setPosition(new Point(nodePositions[node.getId()].x, nodePositions[node.getId()].y)); + } + + if (node.isGroup()) { + node.getAllNodeChildren().forEach((childNode) => { + if (nodePositions[childNode.getId()]) { + childNode.setPosition( + new Point(nodePositions[childNode.getId()].x, nodePositions[childNode.getId()].y), + ); + } + }); + } + }); + } +}; diff --git a/src/views/udns/details/hooks/useUDNVirtualMachines/useUDNVirtualMachines.ts b/src/views/udns/details/hooks/useUDNVirtualMachines/useUDNVirtualMachines.ts index 2d19be04..40a5df41 100644 --- a/src/views/udns/details/hooks/useUDNVirtualMachines/useUDNVirtualMachines.ts +++ b/src/views/udns/details/hooks/useUDNVirtualMachines/useUDNVirtualMachines.ts @@ -9,9 +9,9 @@ import { V1VirtualMachine } from '@kubevirt-ui-ext/kubevirt-api/kubevirt'; import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; import { NetworkAttachmentDefinitionKind } from '@utils/resources/nads/types'; import { UDNResource } from '@utils/resources/udns/helper'; -import { findNADForUDN } from '@utils/resources/vm/utils'; +import { findNADForUDN, vmUsesUDN } from '@utils/resources/vm/utils'; -import { getUDNVirtualMachines, UDNVirtualMachine, vmUsesUDN } from './utils/utils'; +import { getUDNVirtualMachines, UDNVirtualMachine } from './utils/utils'; export type { UDNVirtualMachine }; diff --git a/src/views/udns/details/hooks/useUDNVirtualMachines/utils/utils.ts b/src/views/udns/details/hooks/useUDNVirtualMachines/utils/utils.ts index 78a0c6cb..8c202dfe 100644 --- a/src/views/udns/details/hooks/useUDNVirtualMachines/utils/utils.ts +++ b/src/views/udns/details/hooks/useUDNVirtualMachines/utils/utils.ts @@ -21,14 +21,6 @@ const networkUsesUDN = (network: V1Network, udn: UDNResource, vm: V1VirtualMachi return Boolean(iface && iface.state !== INTERFACE_STATE_ABSENT); }; -export const vmUsesUDN = (vm: V1VirtualMachine, udn: UDNResource | undefined): boolean => { - if (!udn) { - return false; - } - - return getNetworks(vm).some((network) => networkUsesUDN(network, udn, vm)); -}; - export const getUDNVirtualMachines = ( vms: undefined | V1VirtualMachine[], udn: UDNResource | undefined,