diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5bc33542781..06456a6621f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@apidevtools/swagger-parser": "^10.0.3", + "@dagrejs/dagre": "^1.1.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@headlamp-k8s/eslint-config": "^0.6.0", @@ -44,12 +45,14 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/xterm": "^5.5.0", + "@xyflow/react": "^12.2.0", "base64-arraybuffer": "^1.0.2", "buffer": "^6.0.3", "console-browserify": "^1.2.0", "cronstrue": "^2.50.0", "cross-env": "^7.0.3", "crypto-browserify": "^3.12.0", + "elkjs": "^0.9.3", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.24.2", @@ -93,7 +96,8 @@ "util": "^0.12.4", "vite": "^5.4.9", "vite-plugin-node-polyfills": "^0.22.0", - "vite-plugin-svgr": "^4.2.0" + "vite-plugin-svgr": "^4.2.0", + "web-worker": "^1.3.0" }, "devDependencies": { "@axe-core/react": "^4.3.2", @@ -569,6 +573,24 @@ "tough-cookie": "^4.1.4" } }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz", + "integrity": "sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz", @@ -1904,6 +1926,7 @@ "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -1916,6 +1939,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -1928,6 +1952,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -1940,6 +1965,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -1952,6 +1978,7 @@ "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -1964,6 +1991,7 @@ "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -1976,6 +2004,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -1988,6 +2017,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -2000,6 +2030,7 @@ "cpu": [ "ppc64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -2012,6 +2043,7 @@ "cpu": [ "riscv64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -2024,6 +2056,7 @@ "cpu": [ "s390x" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -2060,6 +2093,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -2072,6 +2106,7 @@ "cpu": [ "ia32" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -2084,6 +2119,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -3312,6 +3348,15 @@ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" }, + "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-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", @@ -3338,6 +3383,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", @@ -3356,6 +3407,25 @@ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" }, + "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/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -4135,6 +4205,36 @@ "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==" }, + "node_modules/@xyflow/react": { + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.3.1.tgz", + "integrity": "sha512-PurYFxwzJa0U6RRX9k4VbNRU+vQd6mRKFR8Uk1dF81diCKZDj495y6AupqsjMHtkO66tGHV0LdenLpIHvnOEFw==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.43", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.43.tgz", + "integrity": "sha512-1zHgad1cWr1mKm2xbFaarK0Jg8WRgaQ8ubSBIo/pRdq3fEgCuqgNkL9NSAP6Rvm8zi3+Lu4JPUMN+EEx5QgX9A==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -5183,6 +5283,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -5684,6 +5790,28 @@ "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-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -5734,6 +5862,15 @@ "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", @@ -5775,6 +5912,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/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6181,6 +6353,12 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz", "integrity": "sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==" }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, "node_modules/elliptic": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.0.tgz", @@ -7649,6 +7827,7 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -15326,6 +15505,12 @@ "node": "*" } }, + "node_modules/web-worker": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz", + "integrity": "sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -15746,6 +15931,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zustand": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.5.tgz", + "integrity": "sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1bb86fea4f2..b8129c26897 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "type": "module", "dependencies": { "@apidevtools/swagger-parser": "^10.0.3", + "@dagrejs/dagre": "^1.1.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@headlamp-k8s/eslint-config": "^0.6.0", @@ -45,12 +46,14 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/xterm": "^5.5.0", + "@xyflow/react": "^12.2.0", "base64-arraybuffer": "^1.0.2", "buffer": "^6.0.3", "console-browserify": "^1.2.0", "cronstrue": "^2.50.0", "cross-env": "^7.0.3", "crypto-browserify": "^3.12.0", + "elkjs": "^0.9.3", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.24.2", @@ -94,7 +97,8 @@ "util": "^0.12.4", "vite": "^5.4.9", "vite-plugin-node-polyfills": "^0.22.0", - "vite-plugin-svgr": "^4.2.0" + "vite-plugin-svgr": "^4.2.0", + "web-worker": "^1.3.0" }, "overrides": { "domain-browser": "npm:dry-uninstall", diff --git a/frontend/src/components/App/Layout.tsx b/frontend/src/components/App/Layout.tsx index 7d21938d197..912d32238fd 100644 --- a/frontend/src/components/App/Layout.tsx +++ b/frontend/src/components/App/Layout.tsx @@ -98,6 +98,7 @@ export default function Layout({}: LayoutProps) { const arePluginsLoaded = useTypedSelector(state => state.plugins.loaded); const dispatch = useDispatch(); const clusters = useTypedSelector(state => state.config.clusters); + const isFullWidth = useTypedSelector(state => state.ui.isFullWidth); const { t } = useTranslation(); const allClusters = useClustersConf(); const clusterInURL = getCluster(); @@ -179,6 +180,10 @@ export default function Layout({}: LayoutProps) { }); }; + const containerProps = isFullWidth + ? ({ maxWidth: false, disableGutters: true } as const) + : ({ maxWidth: 'xl' } as const); + return ( <>
- + {arePluginsLoaded && ( { + dispatch(setIsFullWidth(route.isFullWidth)); + }, [route.isFullWidth]); + return (
+
  • + +
  • +
  • + +
  • +
  • + +
  • void; + disabled?: boolean; +}) { + const sx = { + width: '32px', + height: '32px', + padding: 0, + minWidth: '32px', + borderRadius: '50%', + '> svg': { + width: '14px', + height: '14px', + }, + fontSize: 'x-small', + }; + + return ( + + ); +} + +export function GraphControls({ children }: { children?: React.ReactNode }) { + const { t } = useTranslation(); + const minZoomReached = useStore(it => it.transform[2] <= it.minZoom); + const maxZoomReached = useStore(it => it.transform[2] >= it.maxZoom); + const { zoomIn, zoomOut, fitView } = useReactFlow(); + + return ( + + .MuiButtonGroup-grouped': { + minWidth: '32px', + }, + }} + orientation="vertical" + aria-label="Vertical button group" + variant="contained" + > + zoomIn()}> + + + zoomOut()} + > + + + + fitView()}> + + + {children} + + ); +} diff --git a/frontend/src/components/resourceMap/GraphRenderer.tsx b/frontend/src/components/resourceMap/GraphRenderer.tsx new file mode 100644 index 00000000000..7226e705a55 --- /dev/null +++ b/frontend/src/components/resourceMap/GraphRenderer.tsx @@ -0,0 +1,119 @@ +import { Box, Typography, useTheme } from '@mui/material'; +import { + Background, + BackgroundVariant, + ConnectionMode, + Controls, + Edge, + EdgeMouseHandler, + Node, + NodeMouseHandler, + OnMoveStart, + ReactFlow, +} from '@xyflow/react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader } from '../common'; +import { KubeRelationEdge } from './edges/KubeRelationEdge'; +import { GraphControls } from './GraphControls'; +import { GroupNodeComponent } from './nodes/GroupNode'; +import { KubeGroupNodeComponent } from './nodes/KubeGroupNode'; +import { KubeObjectNodeComponent } from './nodes/KubeObjectNode'; + +export const nodeTypes = { + kubeObject: KubeObjectNodeComponent, + kubeGroup: KubeGroupNodeComponent, + group: GroupNodeComponent, +}; + +const edgeTypes = { + kubeRelation: KubeRelationEdge, +}; + +export interface GraphRendererProps { + /** List of nodes to render */ + nodes: Node[]; + /** List of edges to render */ + edges: Edge[]; + /** Callback when a node is clicked */ + onNodeClick?: NodeMouseHandler; + /** Callback when an edge is clicked */ + onEdgeClick?: EdgeMouseHandler; + /** Callback when the graph is started to be moved */ + onMoveStart?: OnMoveStart; + /** Callback when the background is clicked */ + onBackgroundClick?: () => void; + /** Additional components to render */ + children?: React.ReactNode; + /** Additional actions for the controls panael */ + controlActions?: React.ReactNode; + isLoading?: boolean; +} + +const emptyArray: any[] = []; + +export function GraphRenderer({ + nodes, + edges, + onNodeClick, + onEdgeClick, + onMoveStart, + onBackgroundClick, + children, + controlActions, + isLoading, +}: GraphRendererProps) { + const { t } = useTranslation(); + const theme = useTheme(); + + return ( + { + if ((e.target as HTMLElement)?.className?.includes?.('react-flow__pane')) { + onBackgroundClick?.(); + } + }} + minZoom={0.1} + maxZoom={2.0} + connectionMode={ConnectionMode.Loose} + > + + + {controlActions} + + {isLoading && ( + + + + )} + {!isLoading && nodes.length === 0 && ( + + {t('No data to be shown. Try to change filters or select a different namespace.')} + + )} + {children} + + ); +} diff --git a/frontend/src/components/resourceMap/GraphView.css b/frontend/src/components/resourceMap/GraphView.css new file mode 100644 index 00000000000..98a02814095 --- /dev/null +++ b/frontend/src/components/resourceMap/GraphView.css @@ -0,0 +1,47 @@ +:root { + --graph-animation-duration: 0.15s; +} + +.react-flow__node, +.react-flow__edge, +.react-flow__edges path { + transition: all; + transition-duration: var(--graph-animation-duration); +} + +.react-flow__edges { + z-index: 1; +} + +.react-flow__node-group, +.react-flow__node-group:focus, +.react-flow__node-group:active { + border: unset; + color: unset; +} + +.react-flow__pane:not(.dragging) .react-flow__viewport { + transition: transform var(--graph-animation-duration); +} + +@media (prefers-reduced-motion) { + .react-flow__node, + .react-flow__edge, + .react-flow__edges path { + transition-duration: 0; + } + .react-flow__pane:not(.dragging) .react-flow__viewport { + transition-duration: 0; + } +} + +.react-flow__controls-button { + display: flex; + justify-content: center; + align-items: center; + height: 36px; + width: 36px; + padding: 2px; + cursor: pointer; + user-select: none; +} diff --git a/frontend/src/components/resourceMap/GraphView.stories.tsx b/frontend/src/components/resourceMap/GraphView.stories.tsx new file mode 100644 index 00000000000..2e80227409e --- /dev/null +++ b/frontend/src/components/resourceMap/GraphView.stories.tsx @@ -0,0 +1,37 @@ +import Pod from '../../lib/k8s/pod'; +import { TestContext } from '../../test'; +import { podList } from '../pod/storyHelper'; +import { GraphNode, GraphSource } from './graph/graphModel'; +import { GraphView } from './GraphView'; + +export default { + title: 'GraphView', + component: GraphView, + argTypes: {}, + parameters: {}, +}; + +const mockNodes: GraphNode[] = [ + { + id: 'mock-id', + type: 'kubeObject', + data: { + resource: new Pod(podList[0]), + }, + }, +]; + +const mockSource: GraphSource = { + id: 'mock-source', + label: 'Pods', + useData() { + return { nodes: mockNodes, edges: [] }; + }, +}; + +export const BasicExample = () => ( + + ; + +); +BasicExample.args = {}; diff --git a/frontend/src/components/resourceMap/GraphView.tsx b/frontend/src/components/resourceMap/GraphView.tsx new file mode 100644 index 00000000000..2ec348fa05b --- /dev/null +++ b/frontend/src/components/resourceMap/GraphView.tsx @@ -0,0 +1,434 @@ +import '@xyflow/react/dist/base.css'; +import './GraphView.css'; +import { Icon } from '@iconify/react'; +import { Box, Chip, Theme, ThemeProvider } from '@mui/material'; +import { + Edge, + getNodesBounds, + Node, + Panel, + ReactFlowProvider, + useReactFlow, + useStore, +} from '@xyflow/react'; +import { + createContext, + ReactNode, + StrictMode, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { useTypedSelector } from '../../redux/reducers/reducers'; +import { NamespacesAutocomplete } from '../common'; +import { GraphNodeDetails } from './details/GraphNodeDetails'; +import { filterGraph, GraphFilter } from './graph/graphFiltering'; +import { + collapseGraph, + findGroupContaining, + getGraphSize, + getParentNode, + GroupBy, + groupGraph, +} from './graph/graphGrouping'; +import { applyGraphLayout } from './graph/graphLayout'; +import { GraphNode, GraphSource, GroupNode, isGroup, KubeObjectNode } from './graph/graphModel'; +import { GraphControlButton } from './GraphControls'; +import { GraphRenderer } from './GraphRenderer'; +import { NodeHighlight, useNodeHighlight } from './NodeHighlight'; +import { ResourceSearch } from './search/ResourceSearch'; +import { SelectionBreadcrumbs } from './SelectionBreadcrumbs'; +import { allSources, GraphSourceManager, useSources } from './sources/GraphSources'; +import { GraphSourcesView } from './sources/GraphSourcesView'; +import { useQueryParamsState } from './useQueryParamsState'; + +interface GraphViewContent { + setNodeSelection: (nodeId: string) => void; + nodeSelection?: string; + highlights: NodeHighlight; +} +export const GraphViewContext = createContext({} as any); +export const useGraphView = () => useContext(GraphViewContext); + +interface GraphViewContentProps { + /** Height of the Map */ + height?: string; + /** ID of a node to select by default */ + defaultNodeSelection?: string; + /** + * List of Graph Source to display + * + * See {@link GraphSource} for more information + */ + defaultSources?: GraphSource[]; + + /** Default filters to apply */ + defaultFilters?: GraphFilter[]; +} + +const defaultFiltersValue: GraphFilter[] = []; + +function GraphViewContent({ + height, + defaultNodeSelection, + defaultSources = allSources, + defaultFilters = defaultFiltersValue, +}: GraphViewContentProps) { + const { t } = useTranslation(); + + // List of selected namespaces + const namespaces = useTypedSelector(state => state.filter).namespaces; + + // Filters + const [hasErrorsFilter, setHasErrorsFilter] = useState(false); + + // Grouping state + const [groupBy, setGroupBy] = useQueryParamsState('group', 'namespace'); + + // Keep track if user moved the viewport + const viewportMovedRef = useRef(false); + + // ID of the selected Node, undefined means nothing is selected + const [selectedNodeId, _setSelectedNodeId] = useQueryParamsState( + 'node', + defaultNodeSelection + ); + const setSelectedNodeId = useCallback( + (id: string | undefined) => { + if (id === 'root') { + _setSelectedNodeId(undefined); + return; + } + _setSelectedNodeId(id); + }, + [_setSelectedNodeId] + ); + + // Expand all groups state + const [expandAll, setExpandAll] = useState(false); + + // Load source data + const { nodes, edges, selectedSources, sourceData, isLoading, toggleSelection } = useSources(); + + // Graph with applied layout, has sizes and positions for all elements + const [layoutedGraph, setLayoutedGraph] = useState<{ nodes: Node[]; edges: Edge[] }>({ + nodes: [], + edges: [], + }); + + const flow = useReactFlow(); + + // Apply filters + const filteredGraph = useMemo(() => { + const filters = [...defaultFilters]; + if (hasErrorsFilter) { + filters.push({ type: 'hasErrors' }); + } + if (namespaces) { + filters.push({ type: 'namespace', namespaces }); + } + return filterGraph(nodes, edges, filters); + }, [nodes, edges, hasErrorsFilter, namespaces, defaultFilters]); + + // Group the graph + const { visibleGraph, fullGraph } = useMemo(() => { + const graph = groupGraph(filteredGraph.nodes as KubeObjectNode[], filteredGraph.edges, { + groupBy, + }); + + const visibleGraph = collapseGraph(graph, { selectedNodeId, expandAll }) as GroupNode; + + return { visibleGraph, fullGraph: graph }; + }, [filteredGraph, groupBy, selectedNodeId, expandAll]); + + // Apply layout to visible graph + const aspectRatio = useStore(it => it.width / it.height); + const reactFlowWidth = useStore(it => it.width); + const reactFlowHeight = useStore(it => it.height); + + /** + * Zooms the viewport to 100% zoom level + * It will center the nodes if they fit into view + * Or if they don't fit it: + * - align to top if they don't fit vertically + * - align to left if they don't fit horizontally + */ + const zoomTo100 = useCallback( + (nodes: Node[]) => { + const bounds = getNodesBounds(nodes); + + const defaultViewportPaddingPx = 50; + + const topLeftOrigin = { x: defaultViewportPaddingPx, y: defaultViewportPaddingPx }; + const centerOrigin = { + x: reactFlowWidth / 2 - bounds.width / 2, + y: reactFlowHeight / 2 - bounds.height / 2, + }; + + const xFits = bounds.width + defaultViewportPaddingPx * 2 <= reactFlowWidth; + const yFits = bounds.height + defaultViewportPaddingPx * 2 <= reactFlowHeight; + + const defaultZoomViewport = { + x: xFits ? centerOrigin.x : topLeftOrigin.x, + y: yFits ? centerOrigin.y : topLeftOrigin.y, + zoom: 1, + }; + + flow.setViewport(defaultZoomViewport); + }, + [flow, reactFlowWidth, reactFlowHeight] + ); + + useEffect(() => { + applyGraphLayout(visibleGraph, aspectRatio).then(layout => { + setLayoutedGraph(layout); + + // Only fit bounds when user hasn't moved viewport manually + if (!viewportMovedRef.current) { + zoomTo100(layout.nodes); + } + }); + }, [visibleGraph, aspectRatio, zoomTo100]); + + // Reset after view change + useLayoutEffect(() => { + viewportMovedRef.current = false; + }, [selectedNodeId, groupBy, expandAll]); + + const selectedNode = useMemo( + () => nodes.find((it: GraphNode) => it.id === selectedNodeId), + [selectedNodeId, nodes] + ); + + const selectedGroup = useMemo(() => { + if (selectedNodeId) { + return findGroupContaining(visibleGraph, selectedNodeId); + } + }, [selectedNodeId, visibleGraph, findGroupContaining]); + const highlights = useNodeHighlight(selectedNodeId); + + const graphSize = getGraphSize(visibleGraph); + useEffect(() => { + if (expandAll && graphSize > 50) { + setExpandAll(false); + } + }, [graphSize]); + + const contextValue = useMemo( + () => ({ nodeSelection: selectedNodeId, highlights, setNodeSelection: setSelectedNodeId }), + [selectedNodeId, setSelectedNodeId, highlights] + ); + return ( + + + + + + + it.type === 'kubeObject' ? [it.data.resource] : [] + )} + onSearch={resource => { + setSelectedNodeId(resource.metadata.uid); + }} + /> + + + + + + {namespaces.size !== 1 && ( + setGroupBy(groupBy === 'namespace' ? undefined : 'namespace')} + /> + )} + + setGroupBy(groupBy === 'instance' ? undefined : 'instance')} + /> + + setGroupBy(groupBy === 'node' ? undefined : 'node')} + /> + + setHasErrorsFilter(!hasErrorsFilter)} + /> + + {graphSize < 50 && ( + setExpandAll(it => !it)} + /> + )} + + +
    + { + if (e === null) return; + viewportMovedRef.current = true; + }} + onBackgroundClick={() => { + // When node is selected (side panel is open) and user clicks on the background + // We should select parent node, closing the side panel + if (selectedNode && !isGroup(selectedNode)) { + setSelectedNodeId(getParentNode(fullGraph, selectedNode.id)?.id); + } + }} + controlActions={ + zoomTo100(layoutedGraph.nodes)} + > + 100% + + } + > + + {selectedGroup && ( + setSelectedNodeId(id)} + /> + )} + + +
    +
    +
    + {selectedNode && ( + { + setSelectedNodeId(selectedGroup?.id ?? defaultNodeSelection); + }} + /> + )} +
    +
    + ); +} + +function ChipToggleButton({ + label, + isActive, + onClick, +}: { + label: string; + isActive?: boolean; + icon?: ReactNode; + onClick: () => void; +}): ReactNode { + return ( + : undefined} + onClick={onClick} + sx={{ + lineHeight: '1', + }} + /> + ); +} + +function CustomThemeProvider({ children }: { children: ReactNode }) { + return ( + ({ + ...outer, + palette: + outer.palette.mode === 'light' + ? { + ...outer.palette, + primary: { + main: '#555', + contrastText: '#fff', + light: '#666', + dark: '#444', + }, + } + : { + ...outer.palette, + primary: { + main: '#fafafa', + contrastText: '#444', + light: '#fff', + dark: '#f0f0f0', + }, + }, + components: {}, + })} + > + {children} + + ); +} + +/** + * Renders Map of Kubernetes resources + * + * @param params - Map parameters + * @returns + */ +export function GraphView(props: GraphViewContentProps) { + return ( + + + + + + + + ); +} diff --git a/frontend/src/components/resourceMap/KubeObjectGlance/DeploymentGlance.tsx b/frontend/src/components/resourceMap/KubeObjectGlance/DeploymentGlance.tsx new file mode 100644 index 00000000000..ff322843146 --- /dev/null +++ b/frontend/src/components/resourceMap/KubeObjectGlance/DeploymentGlance.tsx @@ -0,0 +1,24 @@ +import { Box } from '@mui/system'; +import { useTranslation } from 'react-i18next'; +import { KubeCondition } from '../../../lib/k8s/cluster'; +import Deployment from '../../../lib/k8s/deployment'; +import { StatusLabel } from '../../common'; + +export function DeploymentGlance({ deployment }: { deployment: Deployment }) { + const { t } = useTranslation(); + const { replicas, availableReplicas } = deployment.status; + const pods = `${availableReplicas || 0}/${replicas || 0}`; + + return ( + + + {t('glossary|Pods')}: {pods} + + {deployment.status.conditions.map((it: KubeCondition) => ( + + {it.type} + + ))} + + ); +} diff --git a/frontend/src/components/resourceMap/KubeObjectGlance/EndpointsGlance.tsx b/frontend/src/components/resourceMap/KubeObjectGlance/EndpointsGlance.tsx new file mode 100644 index 00000000000..b1d7292ff36 --- /dev/null +++ b/frontend/src/components/resourceMap/KubeObjectGlance/EndpointsGlance.tsx @@ -0,0 +1,25 @@ +import { Box } from '@mui/system'; +import { useTranslation } from 'react-i18next'; +import Endpoints from '../../../lib/k8s/endpoints'; +import { StatusLabel } from '../../common'; + +export function EndpointsGlance({ endpoints }: { endpoints: Endpoints }) { + const { t } = useTranslation(); + const addresses = endpoints.subsets?.flatMap(it => it.addresses?.map(it => it.ip)) ?? []; + const ports = endpoints.subsets?.flatMap(it => it.ports) ?? []; + + return ( + + + {t('Addresses')}: {addresses.join(', ')} + + {ports.map(it => + it ? ( + + {it.protocol}:{it.port} + + ) : null + )} + + ); +} diff --git a/frontend/src/components/resourceMap/KubeObjectGlance/KubeObjectGlance.tsx b/frontend/src/components/resourceMap/KubeObjectGlance/KubeObjectGlance.tsx new file mode 100644 index 00000000000..52f85be906d --- /dev/null +++ b/frontend/src/components/resourceMap/KubeObjectGlance/KubeObjectGlance.tsx @@ -0,0 +1,87 @@ +import { Icon } from '@iconify/react'; +import { Box } from '@mui/system'; +import { memo, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KubeObject } from '../../../lib/k8s/cluster'; +import Deployment from '../../../lib/k8s/deployment'; +import Endpoints from '../../../lib/k8s/endpoints'; +import Event from '../../../lib/k8s/event'; +import Pod from '../../../lib/k8s/pod'; +import ReplicaSet from '../../../lib/k8s/replicaSet'; +import Service from '../../../lib/k8s/service'; +import { DateLabel } from '../../common/Label'; +import { DeploymentGlance } from './DeploymentGlance'; +import { EndpointsGlance } from './EndpointsGlance'; +import { PodGlance } from './PodGlance'; +import { ReplicaSetGlance } from './ReplicaSetGlance'; +import { ServiceGlance } from './ServiceGlance'; + +/** + * Little Popup preview of a Kube object + */ +export const KubeObjectGlance = memo(({ resource }: { resource: KubeObject }) => { + const { t } = useTranslation(); + const [events, setEvents] = useState([]); + useEffect(() => { + Event.objectEvents(resource).then(it => setEvents(it)); + }, []); + + const kind = resource.kind; + + const sections = []; + + if (kind === 'Pod') { + sections.push(); + } + + if (kind === 'Deployment') { + sections.push(); + } + + if (kind === 'Service') { + sections.push(); + } + + if (kind === 'Endpoints') { + sections.push(); + } + + if (kind === 'ReplicaSet' || kind === 'StatefulSet') { + sections.push(); + } + + if (events.length > 0) { + sections.push( + + + + {t('glossary|Events')} + + {events.slice(0, 5).map(it => ( + + + {it.message} + + + + ))} + + ); + } + + return sections; +}); diff --git a/frontend/src/components/resourceMap/KubeObjectGlance/PodGlance.tsx b/frontend/src/components/resourceMap/KubeObjectGlance/PodGlance.tsx new file mode 100644 index 00000000000..bbd1f2bb02a --- /dev/null +++ b/frontend/src/components/resourceMap/KubeObjectGlance/PodGlance.tsx @@ -0,0 +1,20 @@ +import { Box } from '@mui/system'; +import { useTranslation } from 'react-i18next'; +import Pod from '../../../lib/k8s/pod'; +import { StatusLabel } from '../../common'; +import { makePodStatusLabel } from '../../pod/List'; + +export function PodGlance({ pod }: { pod: Pod }) { + const { t } = useTranslation(); + return ( + + {makePodStatusLabel(pod)} + {pod.spec.containers.map(it => ( + + {t('glossary|Container')}: {it.name} + + ))} + {pod.status?.podIP && IP: {pod.status?.podIP}} + + ); +} diff --git a/frontend/src/components/resourceMap/KubeObjectGlance/ReplicaSetGlance.tsx b/frontend/src/components/resourceMap/KubeObjectGlance/ReplicaSetGlance.tsx new file mode 100644 index 00000000000..7b860a6e639 --- /dev/null +++ b/frontend/src/components/resourceMap/KubeObjectGlance/ReplicaSetGlance.tsx @@ -0,0 +1,18 @@ +import { Box } from '@mui/system'; +import { useTranslation } from 'react-i18next'; +import ReplicaSet from '../../../lib/k8s/replicaSet'; +import { StatusLabel } from '../../common'; + +export function ReplicaSetGlance({ set }: { set: ReplicaSet }) { + const { t } = useTranslation(); + const ready = set.status?.readyReplicas || 0; + const desired = set.spec?.replicas || 0; + + return ( + + + {t('glossary|Replicas')}: {ready}/{desired} + + + ); +} diff --git a/frontend/src/components/resourceMap/KubeObjectGlance/ServiceGlance.tsx b/frontend/src/components/resourceMap/KubeObjectGlance/ServiceGlance.tsx new file mode 100644 index 00000000000..54526bbe7e8 --- /dev/null +++ b/frontend/src/components/resourceMap/KubeObjectGlance/ServiceGlance.tsx @@ -0,0 +1,30 @@ +import { Box } from '@mui/system'; +import { useTranslation } from 'react-i18next'; +import Service from '../../../lib/k8s/service'; +import { StatusLabel } from '../../common'; + +export function ServiceGlance({ service }: { service: Service }) { + const { t } = useTranslation(); + const externalIP = service.getExternalAddresses(); + + return ( + + + {t('Type')}: {service.spec.type} + + + {t('glossary|Cluster IP')}: {service.spec.clusterIP} + + {externalIP && ( + + {t('glossary|External IP')}: {externalIP} + + )} + {service.spec?.ports?.map(it => ( + + {it.protocol}:{it.port} + + ))} + + ); +} diff --git a/frontend/src/components/resourceMap/NodeHighlight.tsx b/frontend/src/components/resourceMap/NodeHighlight.tsx new file mode 100644 index 00000000000..811ade79b1c --- /dev/null +++ b/frontend/src/components/resourceMap/NodeHighlight.tsx @@ -0,0 +1,52 @@ +import { useCallback, useState } from 'react'; +import { GraphEdge } from './graph/graphModel'; + +export type NodeHighlight = ReturnType; + +export interface Highlight { + /** Label to display */ + label?: string; + /** Set of node IDs to highlight */ + nodeIds?: Set; + /** Set of edge IDs to highlight */ + edgeIds?: Set; +} + +/** + * Manage the state for node highlighting + */ +export const useNodeHighlight = (nodeSelection?: string, selectedEdge?: GraphEdge) => { + const [highlight, setHighlight] = useState(undefined); + + const isNodeHighlighted = useCallback( + (nodeId: string) => { + if (selectedEdge) { + return nodeId === selectedEdge.source || nodeId === selectedEdge.target; + } + if (!highlight) return true; + + return highlight.nodeIds?.has(nodeId); + }, + [highlight, selectedEdge, nodeSelection] + ); + + const isEdgeHighlighted = useCallback( + (edgeId: string) => { + if (selectedEdge) { + return edgeId === selectedEdge.id; + } + if (!highlight) return true; + + return highlight.edgeIds?.has(edgeId); + }, + [highlight, selectedEdge, nodeSelection] + ); + + return { + highlight: highlight, + someHighlighted: highlight !== undefined || selectedEdge !== undefined, + setHighlight, + isNodeHighlighted, + isEdgeHighlighted, + }; +}; diff --git a/frontend/src/components/resourceMap/SelectionBreadcrumbs.tsx b/frontend/src/components/resourceMap/SelectionBreadcrumbs.tsx new file mode 100644 index 00000000000..fe4f15812db --- /dev/null +++ b/frontend/src/components/resourceMap/SelectionBreadcrumbs.tsx @@ -0,0 +1,83 @@ +import { Box, Breadcrumbs, Link } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { GraphNode, isGroup } from './graph/graphModel'; + +/** + * Find a path in graph from root to the selected node + */ +function findSelectionPath(graph: GraphNode, selectedNodeId?: string) { + function dfs(node: GraphNode, path: GraphNode[]): GraphNode[] | null { + path.push(node); + + if (node.id === selectedNodeId) { + return path; + } + + if (isGroup(node)) { + for (const child of node.data.nodes) { + const result = dfs(child, path); + if (result) { + return result; + } + } + } + + path.pop(); + return null; + } + + const result = dfs(graph, []); + return result || []; +} + +export interface SelectionBreadcrumbsProps { + /** The graph in which to search the selected node */ + graph: GraphNode; + /** The ID of the selected node */ + selectedNodeId?: string; + /** Callback when a node is clicked */ + onNodeClick: (id: string) => void; +} + +export function SelectionBreadcrumbs({ + graph, + selectedNodeId, + onNodeClick, +}: SelectionBreadcrumbsProps) { + const { t } = useTranslation(); + const path = findSelectionPath(graph, selectedNodeId); + + return ( + + {path.map((it, i) => { + const getLabel = (node: GraphNode) => { + if (node.type === 'kubeObject') { + return node.data.resource.metadata.name; + } + if (node.id === 'root') { + return t('translation|Home'); + } + return node.data.label; + }; + return i === path.length - 1 ? ( + {getLabel(it)} + ) : ( + onNodeClick(it.id)} + sx={{ + textTransform: 'unset', + maxWidth: '200px', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + > + {getLabel(it)} + + ); + })} + + ); +} diff --git a/frontend/src/components/resourceMap/__snapshots__/GraphView.BasicExample.stories.storyshot b/frontend/src/components/resourceMap/__snapshots__/GraphView.BasicExample.stories.storyshot new file mode 100644 index 00000000000..666d05d490e --- /dev/null +++ b/frontend/src/components/resourceMap/__snapshots__/GraphView.BasicExample.stories.storyshot @@ -0,0 +1,587 @@ + +
    +
    +
    +
    +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    +
    + +
    + +
    + +
    + +
    +
    +
    +
    +
    + +
    + + Pods + +
    +
    + +
    +
    + + Group By: Namespace + + +
    +
    + + Group By: Instance + + +
    +
    + + Group By: Node + + +
    +
    + + Status: Error or Warning + + +
    +
    + + Expand All + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + Namespace: default +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + +
    +
    +
    + Pod +
    +
    + imagepullbackoff +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + + + +
    +
    +
    + + +
    + + +
    +
    +
    + + + +
    +
    +
    +
    +
    + ; +
    + \ No newline at end of file diff --git a/frontend/src/components/resourceMap/details/GraphNodeDetails.tsx b/frontend/src/components/resourceMap/details/GraphNodeDetails.tsx new file mode 100644 index 00000000000..f67aa7e25a9 --- /dev/null +++ b/frontend/src/components/resourceMap/details/GraphNodeDetails.tsx @@ -0,0 +1,75 @@ +import { Box, Card } from '@mui/material'; +import { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActionButton } from '../../common'; +import { GraphNode, KubeObjectNode } from '../graph/graphModel'; +import { KubeObjectDetails } from './KubeNodeDetails'; + +interface GraphNodeDetailsSection { + id: string; + nodeType?: string; + render: (node: GraphNode) => ReactNode; +} + +const kubeNodeDetailsSection: GraphNodeDetailsSection = { + id: 'kubeObjectDetails', + nodeType: 'kubeObject', + render: node => ( + + ), +}; + +const defaultSections = [kubeNodeDetailsSection]; + +export interface GraphNodeDetailsProps { + /** Sections to render */ + sections?: GraphNodeDetailsSection[]; + /** Node to display */ + node: GraphNode; + /** Callback when the panel is closed */ + close: () => void; +} + +/** + * Side panel display information about a selected Node + */ +export function GraphNodeDetails({ + sections = defaultSections, + node, + close, +}: GraphNodeDetailsProps) { + const { t } = useTranslation(); + + return ( + ({ + margin: '0', + padding: '1rem', + width: '900px', + overflowY: 'auto', + flexShrink: 0, + [theme.breakpoints.down('xl')]: { + width: '720px', + }, + [theme.breakpoints.down('lg')]: { + zIndex: 1, + position: 'absolute', + width: '100%', + minWidth: '100%', + }, + })} + > + + { + close(); + }} + icon="mdi:close" + description={t('Close')} + /> + + + {sections.filter(it => it.nodeType === node.type).map(it => it.render(node))} + + ); +} diff --git a/frontend/src/components/resourceMap/details/KubeNodeDetails.tsx b/frontend/src/components/resourceMap/details/KubeNodeDetails.tsx new file mode 100644 index 00000000000..545b2f79eaf --- /dev/null +++ b/frontend/src/components/resourceMap/details/KubeNodeDetails.tsx @@ -0,0 +1,105 @@ +import { Box } from '@mui/system'; +import { memo, ReactElement, useEffect } from 'react'; +import { KubeObject } from '../../../lib/k8s/cluster'; +import Deployment from '../../../lib/k8s/deployment'; +import Job from '../../../lib/k8s/job'; +import ReplicaSet from '../../../lib/k8s/replicaSet'; +import ConfigDetails from '../../configmap/Details'; +import CronJobDetails from '../../cronjob/Details'; +import DaemonSetDetails from '../../daemonset/Details'; +import EndpointDetails from '../../endpoints/Details'; +import HpaDetails from '../../horizontalPodAutoscaler/Details'; +import IngressClassDetails from '../../ingress/ClassDetails'; +import IngressDetails from '../../ingress/Details'; +import { LeaseDetails } from '../../lease/Details'; +import { LimitRangeDetails } from '../../limitRange/Details'; +import NamespaceDetails from '../../namespace/Details'; +import { NetworkPolicyDetails } from '../../networkpolicy/Details'; +import NodeDetails from '../../node/Details'; +import PodDetails from '../../pod/Details'; +import PDBDetails from '../../podDisruptionBudget/Details'; +import PriorityClassDetails from '../../priorityClass/Details'; +import ResourceQuotaDetails from '../../resourceQuota/Details'; +import RoleBindingDetails from '../../role/BindingDetails'; +import RoleDetails from '../../role/Details'; +import { RuntimeClassDetails } from '../../runtimeClass/Details'; +import SecretDetails from '../../secret/Details'; +import ServiceDetails from '../../service/Details'; +import ServiceAccountDetails from '../../serviceaccount/Details'; +import StatefulSetDetails from '../../statefulset/Details'; +import VolumeClaimDetails from '../../storage/ClaimDetails'; +import StorageClassDetails from '../../storage/ClassDetails'; +import VolumeDetails from '../../storage/VolumeDetails'; +import VpaDetails from '../../verticalPodAutoscaler/Details'; +import MutatingWebhookConfigList from '../../webhookconfiguration/MutatingWebhookConfigDetails'; +import ValidatingWebhookConfigurationDetails from '../../webhookconfiguration/ValidatingWebhookConfigDetails'; +import WorkloadDetails from '../../workload/Details'; + +const kindComponentMap: Record< + string, + (props: { name?: string; namespace?: string }) => ReactElement +> = { + Pod: PodDetails, + Deployment: props => , + ReplicaSet: props => , + Job: props => , + Service: ServiceDetails, + CronJob: CronJobDetails, + DaemonSet: DaemonSetDetails, + ConfigMap: ConfigDetails, + Endpoints: EndpointDetails, + HorizontalPodAutoscaler: HpaDetails, + Ingress: IngressDetails, + Lease: LeaseDetails, + LimitRange: LimitRangeDetails, + Namespace: NamespaceDetails, + NetworkPolicy: NetworkPolicyDetails, + Node: NodeDetails, + PodDisruptionBudget: PDBDetails, + PriorityClass: PriorityClassDetails, + ResourceQuota: ResourceQuotaDetails, + ClusterRole: RoleDetails, + Role: RoleDetails, + RoleBinding: RoleBindingDetails, + RuntimeClass: RuntimeClassDetails, + Secret: SecretDetails, + ServiceAccount: ServiceAccountDetails, + StatefulSet: StatefulSetDetails, + PersistentVolumeClaim: VolumeClaimDetails, + StorageClass: StorageClassDetails, + PersistentVolume: VolumeDetails, + VerticalPodAutoscaler: VpaDetails, + MutatingWebhookConfiguration: MutatingWebhookConfigList, + ValidatingWebhookConfiguration: ValidatingWebhookConfigurationDetails, + IngressClass: IngressClassDetails, +}; + +function DetailsNotFound() { + return null; +} + +/** + * Shows details page for a given Kube resource + */ +export const KubeObjectDetails = memo(({ resource }: { resource: KubeObject }) => { + const kind = resource.kind; + const { name, namespace } = resource.metadata; + + const Component = kindComponentMap[kind] ?? DetailsNotFound; + + useEffect(() => { + if (!kindComponentMap[kind]) { + console.error( + 'No details component for kind ${kind} was found. See KubeNodeDetails.tsx for more info' + ); + } + }, [kind, kindComponentMap]); + + return ( + + + + + + ); +}); diff --git a/frontend/src/components/resourceMap/edges/KubeRelationEdge.tsx b/frontend/src/components/resourceMap/edges/KubeRelationEdge.tsx new file mode 100644 index 00000000000..decfe1d08a7 --- /dev/null +++ b/frontend/src/components/resourceMap/edges/KubeRelationEdge.tsx @@ -0,0 +1,44 @@ +import { alpha, useTheme } from '@mui/material'; +import { BaseEdge, EdgeProps } from '@xyflow/react'; +import { memo } from 'react'; +import { GraphEdge } from '../graph/graphModel'; +import { useGraphView } from '../GraphView'; + +/** + * An edge between Kube Objects + */ +export const KubeRelationEdge = memo((props: EdgeProps & { data: GraphEdge['data'] }) => { + const theme = useTheme(); + const graph = useGraphView(); + + const isHighlighted = graph.highlights.isEdgeHighlighted(props.id); + + const data = props.data; + + const parentOffset = data.parentOffset; + + const dx = parentOffset.x; + const dy = parentOffset.y; + + const sections = data.sections; + + const { startPoint, endPoint, bendPoints } = sections[0]; + + // Generate the path data string + const svgPath = `M ${startPoint.x + dx},${startPoint.y + dy} C ${bendPoints[0].x + dx},${ + bendPoints[0].y + dy + } ${bendPoints[1].x + dx},${bendPoints[1].y + dy} ${endPoint.x + dx},${endPoint.y + dy}`; + + return ( + + ); +}); diff --git a/frontend/src/components/resourceMap/graph/graphLayout.tsx b/frontend/src/components/resourceMap/graph/graphLayout.tsx new file mode 100644 index 00000000000..540371f1aff --- /dev/null +++ b/frontend/src/components/resourceMap/graph/graphLayout.tsx @@ -0,0 +1,235 @@ +import { Edge, EdgeMarker, Node } from '@xyflow/react'; +import { ElkExtendedEdge, ElkNode } from 'elkjs'; +import ELK from 'elkjs'; +import { forEachNode, GraphNode, KubeObjectNode } from './graphModel'; + +type ElkNodeWithData = Omit & { + type: string; + data: any; + edges?: ElkEdgeWithData[]; +}; + +type ElkEdgeWithData = ElkExtendedEdge & { + type: string; + data: any; +}; + +const elk = new ELK({ + defaultLayoutOptions: {}, +}); + +const layoutOptions = { + nodeSize: { + width: 220, + height: 70, + }, +}; + +const partitionLayers = [ + ['Deployment'], + ['ReplicaSet', 'ServiceAccount', 'CronJob'], + ['Job'], + ['Pod', 'RoleBinding'], + ['Service', 'NetworkPolicy', 'Role'], +]; + +/** + * To increase readability of the graph we can sort nodes left-to-right + * Where more 'owner' nodes like Deployment or ReplicaSet are on the left + */ +function getPartitionLayer(node: KubeObjectNode) { + if (!('resource' in node.data)) return; + const { kind } = node.data.resource; + const partitionLayer = partitionLayers.findIndex(layer => layer.includes(kind)); + return partitionLayer > -1 ? partitionLayer : undefined; +} + +/** + * Prepare the node for the layout by converting it to the ELK node + * + * @param node - node + * @param aspectRatio - aspect ratio of the container + */ +function convertToElkNode(node: GraphNode, aspectRatio: number): ElkNodeWithData { + const isCollapsed = 'collapsed' in node.data && node.data.collapsed; + + const convertedEdges = + 'edges' in node.data + ? (node.data.edges + .map(edge => { + // Make sure source and target exists + let hasSource = false; + let hasTarget = false; + forEachNode(node, n => { + if (n.id === edge.source) { + hasSource = true; + } + if (n.id === edge.target) { + hasTarget = true; + } + }); + + if (!hasSource || !hasTarget) { + return; + } + + return { + type: edge.type, + id: edge.id, + sources: [edge.source], + targets: [edge.target], + label: edge.label, + labels: [{ text: edge.label, width: 70, height: 20 }], + animated: edge.animated, + hidden: false, + data: edge.data, + }; + }) + .filter(Boolean) as ElkEdgeWithData[]) + : []; + + const elkNode: ElkNodeWithData = { + id: node.id, + type: node.type, + data: node.data, + }; + + if (node.type === 'kubeObject') { + elkNode.layoutOptions = { + 'partitioning.partition': String(getPartitionLayer(node)), + }; + elkNode.width = layoutOptions.nodeSize.width; + elkNode.height = layoutOptions.nodeSize.height; + return elkNode; + } + + if (node.type === 'kubeGroup') { + elkNode.layoutOptions = { + 'partitioning.activate': 'true', + 'elk.direction': 'RIGHT', + 'elk.edgeRouting': 'SPLINES', + 'elk.nodeSize.minimum': '(220.0,70.0)', + 'elk.nodeSize.constraints': '[MINIMUM_SIZE]', + 'elk.algorithm': 'layered', + 'elk.spacing.nodeNode': isCollapsed ? '1' : '60', + 'elk.layered.spacing.nodeNodeBetweenLayers': '60', + 'org.eclipse.elk.stress.desiredEdgeLength': isCollapsed ? '20' : '250', + 'org.eclipse.elk.stress.epsilon': '0.1', + 'elk.padding': '[left=16, top=16, right=16, bottom=16]', + }; + elkNode.edges = convertedEdges; + elkNode.children = + 'collapsed' in node.data && node.data.collapsed + ? [] + : node.data.nodes.map(it => convertToElkNode(it, aspectRatio)); + return elkNode; + } + + if (node.type === 'group') { + elkNode.layoutOptions = { + 'elk.aspectRatio': String(aspectRatio), + 'elk.algorithm': 'rectpacking', + 'elk.rectpacking.widthApproximation.optimizationGoal': 'ASPECT_RATIO_DRIVEN', + 'elk.rectpacking.packing.compaction.rowHeightReevaluation': 'true', + 'elk.edgeRouting': 'SPLINES', + 'elk.spacing.nodeNode': '20', + 'elk.padding': '[left=24, top=24, right=24, bottom=24]', + }; + elkNode.edges = convertedEdges; + elkNode.children = node.data.nodes.map(it => convertToElkNode(it, aspectRatio)); + + return elkNode; + } + return elkNode; +} + +/** + * Convert ELK graph back to react-flow graph + */ +function convertToReactFlowGraph(elkGraph: ElkNodeWithData) { + const edges: Edge[] = []; + const nodes: Node[] = []; + + const pushEdges = (node: ElkNodeWithData, parent?: ElkNodeWithData) => { + node.edges?.forEach(edge => { + edges.push({ + id: edge.id, + source: edge.sources[0], + target: edge.targets[0], + type: edge.type ?? 'customEdge', + selectable: false, + focusable: false, + hidden: false, + markerEnd: { + type: 'arrowclosed', + } as EdgeMarker, + data: { + data: edge.data, + sections: edge.sections, + // @ts-ignore + label: edge?.label, + labels: edge.labels, + parentOffset: { + x: (node?.x ?? 0) + (parent?.x ?? 0), + y: (node?.y ?? 0) + (parent?.y ?? 0), + }, + }, + }); + }); + }; + + const pushNode = (node: ElkNodeWithData, parent?: ElkNodeWithData) => { + nodes.push({ + id: node.id, + type: node.type, + style: { + width: node.width, + height: node.height, + }, + hidden: false, + selectable: true, + draggable: false, + width: node.width, + height: node.height, + position: { x: node.x!, y: node.y! }, + data: node.data, + parentId: parent?.id ?? undefined, + }); + }; + + const convertElkNode = (node: ElkNodeWithData, parent?: ElkNodeWithData) => { + pushNode(node, parent); + pushEdges(node, parent); + + node.children?.forEach(it => { + convertElkNode(it as ElkNodeWithData, node); + }); + }; + + pushEdges(elkGraph); + elkGraph.children!.forEach(node => { + convertElkNode(node as ElkNodeWithData); + }); + + return { nodes, edges }; +} + +/** + * Takes a graph and returns a graph with layout applied + * Layout will set size and poisiton for all the elements + * + * @param graph - root node of the graph + * @param aspectRatio - aspect ratio of the container + * @returns + */ +export const applyGraphLayout = (graph: GraphNode, aspectRatio: number) => { + const elkGraph = convertToElkNode(graph, aspectRatio); + + return elk + .layout(elkGraph, { + layoutOptions: { + 'elk.aspectRatio': String(aspectRatio), + }, + }) + .then(elkGraph => convertToReactFlowGraph(elkGraph as ElkNodeWithData)); +}; diff --git a/frontend/src/components/resourceMap/kubeIcon/KubeIcon.tsx b/frontend/src/components/resourceMap/kubeIcon/KubeIcon.tsx new file mode 100644 index 00000000000..af402f330bf --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/KubeIcon.tsx @@ -0,0 +1,160 @@ +import { Box } from '@mui/material'; +import CRoleIcon from './img/c-role.svg?react'; +import CmIcon from './img/cm.svg?react'; +import CrbIcon from './img/crb.svg?react'; +import CrdIcon from './img/crd.svg?react'; +import CronjobIcon from './img/cronjob.svg?react'; +import DeployIcon from './img/deploy.svg?react'; +import DsIcon from './img/ds.svg?react'; +import EpIcon from './img/ep.svg?react'; +import GroupIcon from './img/group.svg?react'; +import HpaIcon from './img/hpa.svg?react'; +import IngIcon from './img/ing.svg?react'; +import JobIcon from './img/job.svg?react'; +import LimitsIcon from './img/limits.svg?react'; +import NetpolIcon from './img/netpol.svg?react'; +import NsIcon from './img/ns.svg?react'; +import PodIcon from './img/pod.svg?react'; +import PspIcon from './img/psp.svg?react'; +import PvIcon from './img/pv.svg?react'; +import PvcIcon from './img/pvc.svg?react'; +import QuotaIcon from './img/quota.svg?react'; +import RbIcon from './img/rb.svg?react'; +import RoleIcon from './img/role.svg?react'; +import RsIcon from './img/rs.svg?react'; +import SaIcon from './img/sa.svg?react'; +import ScIcon from './img/sc.svg?react'; +import SecretIcon from './img/secret.svg?react'; +import StsIcon from './img/sts.svg?react'; +import SvcIcon from './img/svc.svg?react'; +import UserIcon from './img/user.svg?react'; +import VolIcon from './img/vol.svg?react'; + +const kindToIcon = { + ClusterRole: CRoleIcon, + ClusterRoleBinding: CrbIcon, + CronJob: CronjobIcon, + DaemonSet: DsIcon, + Group: GroupIcon, + Ingress: IngIcon, + LimitRange: LimitsIcon, + Namespace: NsIcon, + PodSecurityPolicy: PspIcon, + PersistentVolumeClaim: PvcIcon, + RoleBinding: RbIcon, + ReplicaSet: RsIcon, + StorageClass: ScIcon, + StatefulSet: StsIcon, + User: UserIcon, + ConfigMap: CmIcon, + CustomResourceDefinition: CrdIcon, + Deployment: DeployIcon, + Endpoint: EpIcon, + Endpoints: EpIcon, + HorizontalPodAutoscaler: HpaIcon, + Job: JobIcon, + NetworkPolicy: NetpolIcon, + Pod: PodIcon, + PersistentVolume: PvIcon, + ResourceQuota: QuotaIcon, + Role: RoleIcon, + ServiceAccount: SaIcon, + Secret: SecretIcon, + Service: SvcIcon, + Volume: VolIcon, +} as const; + +const kindGroups = { + workloads: new Set([ + 'Pod', + 'Deployment', + 'ReplicaSet', + 'StatefulSet', + 'DaemonSet', + 'ReplicaSet', + 'Job', + 'CronJob', + ]), + storage: new Set(['PersistentVolumeClaim']), + network: new Set([ + 'Service', + 'Endpoints', + 'Endpoint', + 'Ingress', + 'IngressClass', + 'NetworkPolicy', + ]), + security: new Set(['ServiceAccount', 'Role', 'RoleBinding']), + configuration: new Set([ + 'ConfigMap', + 'Secret', + 'MutatingWebhookConfiguration', + 'ValidatingWebhookConfiguration', + ]), +} as const; + +const getKindGroup = (kind: string) => + Object.entries(kindGroups).find(([, set]) => set.has(kind))?.[0] as keyof typeof kindGroups; + +const lightness = '67.85%'; +const chroma = '0.12'; + +const kindGroupColors = { + workloads: `oklch(${lightness} ${chroma} 182.18)`, + storage: `oklch(${lightness} ${chroma} 46.47)`, + network: `oklch(${lightness} ${chroma} 225.16)`, + security: `oklch(${lightness} ${chroma} 275.16)`, + configuration: `oklch(${lightness} ${chroma} 320.03)`, + other: `oklch(${lightness} 0 215.25)`, +} as const; + +const getKindColor = (kind: string) => kindGroupColors[getKindGroup(kind) ?? 'other']; +export const getKindGroupColor = (group: keyof typeof kindGroupColors) => + kindGroupColors[group] ?? kindGroupColors.other; + +/** + * Icon for the Kube resource + * Color is based on the resource category (workload,storage, etc) + * + * Icons are taken from + * https://github.com/kubernetes/community/tree/master/icons + * + * @param params.kind - Resource kind + * @param params.width - width in css units + * @param params.height - width in css units + * @returns + */ +export function KubeIcon({ + kind, + width, + height, +}: { + kind: keyof typeof kindToIcon; + width?: string; + height?: string; +}) { + const IconComponent = kindToIcon[kind] ?? kindToIcon['Pod']; + + const color = getKindColor(kind); + + return ( + + + + ); +} diff --git a/frontend/src/components/resourceMap/kubeIcon/img/c-role.svg b/frontend/src/components/resourceMap/kubeIcon/img/c-role.svg new file mode 100644 index 00000000000..cc89f80e9ef --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/c-role.svg @@ -0,0 +1,102 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/cm.svg b/frontend/src/components/resourceMap/kubeIcon/img/cm.svg new file mode 100644 index 00000000000..e5ba243e417 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/cm.svg @@ -0,0 +1,141 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/crb.svg b/frontend/src/components/resourceMap/kubeIcon/img/crb.svg new file mode 100644 index 00000000000..41a10393bba --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/crb.svg @@ -0,0 +1,83 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/crd.svg b/frontend/src/components/resourceMap/kubeIcon/img/crd.svg new file mode 100644 index 00000000000..8d380864b3b --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/crd.svg @@ -0,0 +1,83 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/cronjob.svg b/frontend/src/components/resourceMap/kubeIcon/img/cronjob.svg new file mode 100644 index 00000000000..46681775674 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/cronjob.svg @@ -0,0 +1,93 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/deploy.svg b/frontend/src/components/resourceMap/kubeIcon/img/deploy.svg new file mode 100644 index 00000000000..ce93ec51570 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/deploy.svg @@ -0,0 +1,92 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/ds.svg b/frontend/src/components/resourceMap/kubeIcon/img/ds.svg new file mode 100644 index 00000000000..d59a3e67fa4 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/ds.svg @@ -0,0 +1,107 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/ep.svg b/frontend/src/components/resourceMap/kubeIcon/img/ep.svg new file mode 100644 index 00000000000..e8ed2da8d1b --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/ep.svg @@ -0,0 +1,189 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/group.svg b/frontend/src/components/resourceMap/kubeIcon/img/group.svg new file mode 100644 index 00000000000..d9e59df38b3 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/group.svg @@ -0,0 +1,83 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/hpa.svg b/frontend/src/components/resourceMap/kubeIcon/img/hpa.svg new file mode 100644 index 00000000000..22c77165bdf --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/hpa.svg @@ -0,0 +1,103 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/ing.svg b/frontend/src/components/resourceMap/kubeIcon/img/ing.svg new file mode 100644 index 00000000000..634fe8351d2 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/ing.svg @@ -0,0 +1,84 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/job.svg b/frontend/src/components/resourceMap/kubeIcon/img/job.svg new file mode 100644 index 00000000000..e103afa3f66 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/job.svg @@ -0,0 +1,131 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/limits.svg b/frontend/src/components/resourceMap/kubeIcon/img/limits.svg new file mode 100644 index 00000000000..132257b9100 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/limits.svg @@ -0,0 +1,96 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/netpol.svg b/frontend/src/components/resourceMap/kubeIcon/img/netpol.svg new file mode 100644 index 00000000000..aff3ae7b710 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/netpol.svg @@ -0,0 +1,84 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/ns.svg b/frontend/src/components/resourceMap/kubeIcon/img/ns.svg new file mode 100644 index 00000000000..3cda86bbf2f --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/ns.svg @@ -0,0 +1,85 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/pod.svg b/frontend/src/components/resourceMap/kubeIcon/img/pod.svg new file mode 100644 index 00000000000..cfc49faab5f --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/pod.svg @@ -0,0 +1,103 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/psp.svg b/frontend/src/components/resourceMap/kubeIcon/img/psp.svg new file mode 100644 index 00000000000..fd082e64862 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/psp.svg @@ -0,0 +1,108 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/pv.svg b/frontend/src/components/resourceMap/kubeIcon/img/pv.svg new file mode 100644 index 00000000000..d84d91a9949 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/pv.svg @@ -0,0 +1,97 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/pvc.svg b/frontend/src/components/resourceMap/kubeIcon/img/pvc.svg new file mode 100644 index 00000000000..f401fb2a97d --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/pvc.svg @@ -0,0 +1,83 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/quota.svg b/frontend/src/components/resourceMap/kubeIcon/img/quota.svg new file mode 100644 index 00000000000..c4c127db875 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/quota.svg @@ -0,0 +1,94 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/rb.svg b/frontend/src/components/resourceMap/kubeIcon/img/rb.svg new file mode 100644 index 00000000000..9fbd1c4b4f7 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/rb.svg @@ -0,0 +1,94 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/role.svg b/frontend/src/components/resourceMap/kubeIcon/img/role.svg new file mode 100644 index 00000000000..0e322ec3a49 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/role.svg @@ -0,0 +1,109 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/rs.svg b/frontend/src/components/resourceMap/kubeIcon/img/rs.svg new file mode 100644 index 00000000000..757ea82436c --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/rs.svg @@ -0,0 +1,102 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/sa.svg b/frontend/src/components/resourceMap/kubeIcon/img/sa.svg new file mode 100644 index 00000000000..b3a175cf678 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/sa.svg @@ -0,0 +1,84 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/sc.svg b/frontend/src/components/resourceMap/kubeIcon/img/sc.svg new file mode 100644 index 00000000000..57e5876dc28 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/sc.svg @@ -0,0 +1,130 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/secret.svg b/frontend/src/components/resourceMap/kubeIcon/img/secret.svg new file mode 100644 index 00000000000..e343942634d --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/secret.svg @@ -0,0 +1,128 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/sts.svg b/frontend/src/components/resourceMap/kubeIcon/img/sts.svg new file mode 100644 index 00000000000..8aa4bd8afc2 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/sts.svg @@ -0,0 +1,120 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/svc.svg b/frontend/src/components/resourceMap/kubeIcon/img/svc.svg new file mode 100644 index 00000000000..9b1baeb1e59 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/svc.svg @@ -0,0 +1,117 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/user.svg b/frontend/src/components/resourceMap/kubeIcon/img/user.svg new file mode 100644 index 00000000000..23edcce41f4 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/user.svg @@ -0,0 +1,83 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/kubeIcon/img/vol.svg b/frontend/src/components/resourceMap/kubeIcon/img/vol.svg new file mode 100644 index 00000000000..41f1412c651 --- /dev/null +++ b/frontend/src/components/resourceMap/kubeIcon/img/vol.svg @@ -0,0 +1,97 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/resourceMap/nodes/GroupNode.tsx b/frontend/src/components/resourceMap/nodes/GroupNode.tsx new file mode 100644 index 00000000000..a8c68846b02 --- /dev/null +++ b/frontend/src/components/resourceMap/nodes/GroupNode.tsx @@ -0,0 +1,59 @@ +import { alpha, styled } from '@mui/material'; +import { NodeProps } from '@xyflow/react'; +import { memo } from 'react'; +import { GroupNode } from '../graph/graphModel'; +import { useGraphView } from '../GraphView'; + +const Container = styled('div')<{ isSelected: boolean }>(({ theme, isSelected }) => ({ + width: '100%', + height: '100%', + transition: 'border-color 0.1s', + background: alpha(theme.palette.background.paper, 0.6), + border: '1px solid', + borderColor: theme.palette.divider, + borderRadius: theme.spacing(1.5), + ':hover': { + borderColor: isSelected ? undefined : alpha(theme.palette.action.active, 0.4), + }, +})); + +const Label = styled('div')(({ theme }) => ({ + position: 'absolute', + fontSize: '16px', + top: '-16px', + background: theme.palette.background.paper, + left: '22px', + padding: '4px', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + maxWidth: 'calc(100% - 52px)', + color: alpha(theme.palette.text.primary, 0.6), + borderRadius: 2, +})); + +export const GroupNodeComponent = memo(({ id, data }: NodeProps & { data: GroupNode['data'] }) => { + const graph = useGraphView(); + const isSelected = id === graph.nodeSelection; + + const handleSelect = () => { + graph.setNodeSelection(id); + graph.highlights.setHighlight(undefined); + }; + + return ( + { + if (e.key === 'Enter' || e.key === 'Space') { + handleSelect(); + } + }} + > + + + ); +}); diff --git a/frontend/src/components/resourceMap/nodes/KubeGroupNode.tsx b/frontend/src/components/resourceMap/nodes/KubeGroupNode.tsx new file mode 100644 index 00000000000..7bcb5c9098c --- /dev/null +++ b/frontend/src/components/resourceMap/nodes/KubeGroupNode.tsx @@ -0,0 +1,177 @@ +import { Icon } from '@iconify/react'; +import { alpha, Box, styled, useTheme } from '@mui/material'; +import { NodeProps } from '@xyflow/react'; +import { memo, useState } from 'react'; +import { getMainNode } from '../graph/graphGrouping'; +import { KubeGroupNode } from '../graph/graphModel'; +import { useGraphView } from '../GraphView'; +import { KubeIcon } from '../kubeIcon/KubeIcon'; +import { getStatus } from './KubeObjectStatus'; + +const Container = styled('div')<{ isFaded: boolean; isCollapsed: boolean }>( + ({ theme, isFaded, isCollapsed }) => ({ + display: 'flex', + opacity: isFaded ? 0.6 : undefined, + filter: isFaded ? 'grayscale(1.0)' : undefined, + alignItems: 'center', + justifyContent: 'center', + width: '100% !important', + height: '100%', + background: theme.palette.background.paper, + border: '1px solid', + borderColor: theme.palette.divider, + borderRadius: '10px', + transition: 'all 0.05s', + transitionTimingFunction: 'linear', + + boxShadow: isCollapsed ? undefined : '6px 6px 12px rgba(0,0,0,0.03)', + + ':hover': { + boxShadow: isCollapsed ? '2px 2px 6px rgba(0,0,0,0.05)' : undefined, + marginTop: isCollapsed ? '-4px' : undefined, + borderColor: isCollapsed ? alpha(theme.palette.action.active, 0.25) : undefined, + }, + }) +); + +const CircleBadge = styled('div')<{ isHovered: boolean }>(({ theme, isHovered }) => ({ + position: 'absolute', + right: 0, + width: '32px', + height: '32px', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + margin: theme.spacing(-1), + justifyContent: 'center', + background: theme.palette.background.paper, + boxShadow: '1px 1px 5px rgba(0,0,0,0.08)', + transition: 'top 0.05s', + transitionTimingFunction: 'linear', + border: '1px solid #e1e1e1', + borderColor: theme.palette.divider, + color: theme.typography.caption.color, + top: isHovered ? '-4px' : 0, +})); + +const FakeContainer = styled('div')<{ isHovered: boolean }>(({ theme, isHovered }) => ({ + position: 'absolute', + width: '100%', + height: '100%', + + top: 0, + left: 0, + background: theme.palette.background.paper, + border: '1px solid', + borderRadius: '10px', + transition: 'all 0.025s', + transitionTimingFunction: 'linear', + boxShadow: isHovered ? '2px 2px 6px rgba(0,0,0,0.05)' : undefined, + borderColor: isHovered ? alpha(theme.palette.action.active, 0.25) : theme.palette.divider, +})); + +export const KubeGroupNodeComponent = memo( + ({ data, id }: NodeProps & { data: KubeGroupNode['data'] }) => { + const theme = useTheme(); + const graph = useGraphView(); + + const someHighlighted = data.nodes.length + ? data?.nodes?.some(it => graph.highlights.isNodeHighlighted(it.id)) + : true; + + const errors = + data?.nodes?.filter(it => getStatus(it?.data?.resource) === 'error')?.length ?? 0; + const warnings = + data?.nodes?.filter(it => getStatus(it?.data?.resource) === 'warning')?.length ?? 0; + + const status = errors > 0 ? 'error' : warnings > 0 ? 'warning' : 'success'; + + const firstResource = getMainNode(data?.nodes ?? [])?.data?.resource; + + const isCollapsed = !!data.collapsed; + + const [isHovered, setIsHovered] = useState(false); + const isHighlighted = someHighlighted || graph.highlights.isNodeHighlighted(id); + + const icon = ; + + const handleSelect = () => { + graph.setNodeSelection(id); + graph.highlights.setHighlight(undefined); + }; + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={handleSelect} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === 'Space') { + handleSelect(); + } + }} + > + {isCollapsed && {data?.nodes?.length ?? 0}} + {isCollapsed && status !== 'success' && ( + + + + )} + {firstResource && ( + + {(data?.nodes?.length ?? 0) > 1 && ( + + )} + {(data?.nodes?.length ?? 0) > 2 && ( + + )} + + {icon} + + + + {firstResource.kind} + + + {firstResource.metadata.name} + + + + )} + + ); + } +); diff --git a/frontend/src/components/resourceMap/nodes/KubeObjectChip.tsx b/frontend/src/components/resourceMap/nodes/KubeObjectChip.tsx new file mode 100644 index 00000000000..8f85a45d7e6 --- /dev/null +++ b/frontend/src/components/resourceMap/nodes/KubeObjectChip.tsx @@ -0,0 +1,41 @@ +import { Box, styled } from '@mui/material'; +import { KubeObject } from '../../../lib/k8s/cluster'; +import { KubeIcon } from '../kubeIcon/KubeIcon'; + +const Container = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + + background: theme.palette.background.paper, + borderRadius: theme.spacing(1), + border: '1px solid #e3e3e3', + + borderColor: theme.palette.divider, + + padding: theme.spacing(1), +})); + +export function KubeObjectChip({ resource }: { resource: KubeObject }) { + return ( + + + + + + {resource.kind} + + + {resource.metadata.name} + + + + + ); +} diff --git a/frontend/src/components/resourceMap/nodes/KubeObjectNode.tsx b/frontend/src/components/resourceMap/nodes/KubeObjectNode.tsx new file mode 100644 index 00000000000..11503c80242 --- /dev/null +++ b/frontend/src/components/resourceMap/nodes/KubeObjectNode.tsx @@ -0,0 +1,189 @@ +import { Icon } from '@iconify/react'; +import { alpha, Box, styled, useTheme } from '@mui/material'; +import { Handle, NodeProps, Position, useEdges, useNodes } from '@xyflow/react'; +import { memo, startTransition, useEffect, useRef, useState } from 'react'; +import { KubeObjectNode } from '../graph/graphModel'; +import { useGraphView } from '../GraphView'; +import { KubeIcon } from '../kubeIcon/KubeIcon'; +import { KubeObjectGlance } from '../KubeObjectGlance/KubeObjectGlance'; +import { getStatus } from './KubeObjectStatus'; + +const Container = styled('div')<{ + isHovered: boolean; + isExpanded: boolean; + isFaded: boolean; + isSelected: boolean; +}>(({ theme, isHovered, isFaded, isSelected, isExpanded }) => ({ + display: 'flex', + flexDirection: 'column', + zIndex: isHovered ? 1 : undefined, + opacity: isFaded && !isHovered ? 0.5 : undefined, + filter: isFaded && !isHovered ? 'grayscale(0.0)' : undefined, + + width: isExpanded ? 'auto' : '100%!important', + minWidth: '100%', + + position: isHovered ? 'absolute' : undefined, + background: theme.palette.background.paper, + borderRadius: '10px', + border: '1px solid #e3e3e3', + + borderColor: isSelected ? theme.palette.action.active : theme.palette.divider, + + boxShadow: isHovered ? '4px 4px 6px rgba(0,0,0,0.06)' : undefined, + transform: isHovered ? 'translateY(-2px)' : undefined, + padding: isExpanded ? '16px' : '10px', + marginLeft: isExpanded ? '-6px' : 0, + marginTop: isExpanded ? '-6px' : 0, + + transition: 'all 0.05s', + + ':hover': { + borderColor: isSelected ? undefined : alpha(theme.palette.action.active, 0.2), + }, +})); + +const CircleBadge = styled('div')(({ theme }) => ({ + position: 'absolute', + width: '32px', + height: '32px', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + margin: theme.spacing(-1), + justifyContent: 'center', + background: theme.palette.background.paper, + boxShadow: '1px 1px 5px rgba(0,0,0,0.08)', + transition: 'top 0.05s', + transitionTimingFunction: 'linear', + border: '1px solid #e1e1e1', + borderColor: theme.palette.divider, + color: theme.typography.caption.color, + top: 0, + right: '12px', +})); + +const EXPAND_DELAY = 450; + +export const KubeObjectNodeComponent = memo( + ({ data, id }: NodeProps & { data: KubeObjectNode['data'] }) => { + const [isHovered, setHovered] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const theme = useTheme(); + + const graph = useGraphView(); + const nodes = useNodes(); + const edges = useEdges(); + + const resource = data.resource; + + const isSelected = id === graph.nodeSelection; + const isHighlighted = graph.highlights.isNodeHighlighted(id); + + const status = getStatus(data.resource) ?? 'success'; + + const nodeRef = useRef(null); + + useEffect(() => { + if (nodeRef.current && nodeRef.current.parentElement) { + let index = '0'; + if (isSelected) index = '1003'; + if (isHovered) index = '1004'; + nodeRef.current.parentElement.style.zIndex = index; + } + }, [isSelected, isHovered]); + + useEffect(() => { + if (!isHovered) { + setIsExpanded(false); + return; + } + + const id = setTimeout(() => setIsExpanded(true), EXPAND_DELAY); + return () => clearInterval(id); + }, [isHovered]); + + const icon = ; + + function handleMouseEnter() { + const relatedEdges = edges.filter(it => it.source === id || it.target === id); + const relatedNodes = nodes.filter(node => + relatedEdges.find(edge => edge.source === node.id || edge.target === node.id) + ); + + if (relatedNodes.length > 1) { + startTransition(() => { + graph.highlights.setHighlight({ + label: undefined, + nodeIds: new Set(relatedNodes?.map(it => it.id) ?? []), + edgeIds: new Set(relatedEdges?.map(it => it.id) ?? []), + }); + }); + } + } + + const openDetails = () => { + graph.setNodeSelection(id); + setHovered(false); + graph.highlights.setHighlight(undefined); + }; + + return ( + startTransition(() => graph.highlights.setHighlight(undefined))} + onPointerEnter={() => setHovered(true)} + onPointerLeave={() => { + setHovered(false); + }} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === 'Space') { + openDetails(); + } + }} + > + + + + {status !== 'success' && ( + + + + )} + + + {icon} + + + {resource.kind} + + + {resource.metadata.name} + + + + {isExpanded && } + + ); + } +); diff --git a/frontend/src/components/resourceMap/search/ResourceSearch.tsx b/frontend/src/components/resourceMap/search/ResourceSearch.tsx new file mode 100644 index 00000000000..f12a5d304a4 --- /dev/null +++ b/frontend/src/components/resourceMap/search/ResourceSearch.tsx @@ -0,0 +1,106 @@ +import { Autocomplete, Box, TextField } from '@mui/material'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KubeObject } from '../../../lib/k8s/cluster'; +import { KubeIcon } from '../kubeIcon/KubeIcon'; + +const MAX_RESULTS = 8; + +/** + * Search input that looks for resources by their name + * + * @param params.resources - list of Kube resources + * @param params.onSearch - on search callback + * @returns + */ +export function ResourceSearch({ + resources, + onSearch, +}: { + resources: KubeObject[]; + onSearch: (resource: KubeObject) => void; +}) { + const { t } = useTranslation(); + const [query, setQuery] = useState(''); + + const results = useMemo(() => { + if (!resources || !query.trim()) return []; + const results = []; + + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + if (resource.metadata.name.includes(query)) { + results.push(resource); + } + if (results.length >= MAX_RESULTS) { + break; + } + } + + return results; + }, [query, resources]); + + return ( + + ( + + )} + freeSolo + clearOnBlur + filterOptions={x => x} + getOptionLabel={option => (typeof option === 'string' ? option : option.metadata.name)} + onInputChange={(e, value) => { + setQuery(value); + }} + onChange={(e, value) => { + if (value && typeof value !== 'string') { + onSearch(value); + } + }} + options={results} + renderOption={(props, it) => { + return ( +
  • + + + + + + + + {it.kind} + + + {it.metadata.name} + + + +
  • + ); + }} + >
    +
    + ); +} diff --git a/frontend/src/components/resourceMap/sources/GraphSources.tsx b/frontend/src/components/resourceMap/sources/GraphSources.tsx new file mode 100644 index 00000000000..ee872c2e5d6 --- /dev/null +++ b/frontend/src/components/resourceMap/sources/GraphSources.tsx @@ -0,0 +1,270 @@ +import { throttle } from 'lodash'; +import { + createContext, + memo, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { KubeObject } from '../../../lib/k8s/cluster'; +import { GraphEdge, GraphNode, GraphSource } from '../graph/graphModel'; +import { configurationSource } from './definitions/configurationSource'; +import { networkSource } from './definitions/networkSource'; +import { securitySource } from './definitions/securitySource'; +import { storageSource } from './definitions/storageSource'; +import { workloadsSource } from './definitions/workloadSource'; + +export const allSources: GraphSource[] = [ + workloadsSource, + storageSource, + networkSource, + securitySource, + configurationSource, +]; + +/** + * Map of nodes and edges where the key is source id + */ +export type SourceData = Map; + +type MaybeNodesAndEdges = { + nodes?: GraphNode[]; + edges?: GraphEdge[]; +} | null; + +interface GraphSourcesContext { + nodes: GraphNode[]; + edges: GraphEdge[]; + toggleSelection: (source: GraphSource) => void; + setSelectedSources: (sources: Set) => void; + selectedSources: Set; + sourceData?: SourceData; + isLoading?: boolean; +} + +const Context = createContext(undefined as any); + +export const useSources = () => useContext(Context); + +/** + * Returns a flat list of all the sources + */ +function getFlatSources(sources: GraphSource[], result: GraphSource[] = []): GraphSource[] { + for (const source of sources) { + if ('sources' in source) { + getFlatSources(source.sources, result); + } else { + result.push(source); + } + } + return result; +} + +/** + * Create Edges from object's ownerReferences + */ +export const kubeOwnersEdges = (obj: KubeObject): GraphEdge[] => { + return ( + obj.metadata.ownerReferences?.map(owner => ({ + id: `${obj.metadata.uid}-${owner.uid}`, + type: 'kubeRelation', + source: obj.metadata.uid, + target: owner.uid, + })) ?? [] + ); +}; + +/** + * Create an object from any Kube object + */ +export const makeKubeObjectNode = (obj: KubeObject): GraphNode => ({ + id: obj.metadata.uid, + type: 'kubeObject', + data: { + resource: obj, + }, +}); + +/** + * Make an edge connecting two Kube objects + */ +export const makeKubeToKubeEdge = (from: KubeObject, to: KubeObject): GraphEdge => ({ + id: `${from.metadata.uid}-${to.metadata.uid}`, + type: 'kubeRelation', + source: from.metadata.uid, + target: to.metadata.uid, +}); + +/** + * Since we can't use hooks in a loop, we need to create a component for each source + * that will load the data and pass it to the parent component. + */ +const SourceLoader = memo( + ({ + useHook, + onData, + id, + }: { + useHook: () => MaybeNodesAndEdges; + onData: (id: string, data: MaybeNodesAndEdges) => void; + id: string; + }) => { + const data = useHook(); + + useEffect(() => { + onData(id, data); + }, [id, data]); + + return null; + } +); + +export default function useThrottledMemo(factory: () => T, deps: any[], throttleMs: number): T { + const [state, setState] = useState(factory()); + + const debouncedSetState = useCallback(throttle(setState, throttleMs), []); + + useEffect(() => { + debouncedSetState(factory()); + }, deps); + + return state; +} + +export interface GraphSourceManagerProps { + /** List of sources to load */ + sources: GraphSource[]; + /** Children to render */ + children: ReactNode; +} + +/** + * Loads data from all the sources + */ +export function GraphSourceManager({ sources, children }: GraphSourceManagerProps) { + const [sourceData, setSourceData] = useState(new Map()); + const [selectedSources, setSelectedSources] = useState(() => { + const _selectedSources = new Set(); + + const step = (source: GraphSource) => { + if (source.isEnabledByDefault ?? true) { + _selectedSources.add(source.id); + if ('sources' in source) { + source.sources.forEach(step); + } + } + }; + sources.map(step); + return _selectedSources; + }); + + const toggleSelection = useCallback( + (source: GraphSource) => { + setSelectedSources(selection => { + const isSelected = (source: GraphSource): boolean => + 'sources' in source ? source.sources.every(s => isSelected(s)) : selection.has(source.id); + + const deselectAll = (source: GraphSource) => { + if ('sources' in source) { + source.sources.forEach(deselectAll); + } else { + selection.delete(source.id); + } + }; + + const selectAll = (source: GraphSource) => { + if ('sources' in source) { + source.sources.forEach(s => selectAll(s)); + } else { + selection.add(source.id); + } + }; + + if (!('sources' in source)) { + // not a group, just toggle the selection + if (selection.has(source.id)) { + selection.delete(source.id); + } else { + selection.add(source.id); + } + } else { + // if all children are selected, deselect them + if (source.sources.every(isSelected)) { + source.sources.forEach(deselectAll); + selection.delete(source.id); + } else { + source.sources.forEach(selectAll); + } + } + return new Set(selection); + }); + }, + [setSelectedSources] + ); + + const onData = useCallback( + (id: string, data: MaybeNodesAndEdges) => { + setSourceData(map => new Map(map).set(id, data)); + }, + [setSourceData] + ); + + const components = useMemo(() => { + const allSources = getFlatSources(sources); + + return allSources + .filter(it => selectedSources.has(it.id)) + .filter(it => 'useData' in it) + .map(source => { + return { + props: { + useHook: source.useData, + onData: onData, + key: source.id, + id: source.id, + }, + }; + }); + }, [sources, selectedSources]); + + const contextValue = useThrottledMemo( + () => { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + + selectedSources.forEach(id => { + const data = sourceData.get(id); + nodes.push(...(data?.nodes ?? [])); + edges.push(...(data?.edges ?? [])); + }); + + const isLoading = + sourceData.size === 0 || + selectedSources?.values()?.some?.(source => sourceData.get(source) === null); + + return { + nodes, + edges, + toggleSelection, + setSelectedSources, + selectedSources, + sourceData, + isLoading, + }; + }, + [sources, selectedSources, sourceData, setSelectedSources], + 500 + ); + + return ( + <> + {components.map(it => ( + + ))} + {children} + + ); +} diff --git a/frontend/src/components/resourceMap/sources/GraphSourcesView.tsx b/frontend/src/components/resourceMap/sources/GraphSourcesView.tsx new file mode 100644 index 00000000000..e8327370aad --- /dev/null +++ b/frontend/src/components/resourceMap/sources/GraphSourcesView.tsx @@ -0,0 +1,228 @@ +import { Icon } from '@iconify/react'; +import { + alpha, + Badge, + Box, + Checkbox, + Chip, + CircularProgress, + Popover, + Stack, + styled, + Typography, +} from '@mui/material'; +import { memo, useState } from 'react'; +import { GraphSource } from '../graph/graphModel'; +import { SourceData } from './GraphSources'; + +const Node = styled('div')(() => ({ + display: 'flex', + flexDirection: 'column', +})); + +const NodeHeader = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + borderRadius: theme.spacing(1), + paddingLeft: theme.spacing(0.5), + paddingRight: theme.spacing(0.5), + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5), + + ':hover': { + background: theme.palette.action.hover, + }, + ':active': { + background: alpha(theme.palette.action.active, theme.palette.action.activatedOpacity), + }, +})); + +/** + * Component that displays a Source and allows to check or uncheck it + * and its' descendants + * + * @returns + */ +function GraphSourceView({ + source, + sourceData, + selection, + activeItemId, + setActiveItemId, + toggleSelection, +}: { + /** Source definition */ + source: GraphSource; + /** Loaded data for the sources */ + sourceData: SourceData; + /** Set of selected source ids */ + selection: Set; + /** Active (exapnded) source */ + activeItemId: string | undefined; + toggleSelection: (source: GraphSource) => void; + setActiveItemId: (id: string | undefined) => void; +}) { + const hasChildren = 'sources' in source; + const isSelected = (source: GraphSource): boolean => + 'sources' in source ? source.sources.every(s => isSelected(s)) : selection.has(source.id); + const isChecked = isSelected(source); + const intermediate = 'sources' in source && source.sources.some(s => isSelected(s)) && !isChecked; + + const data = sourceData.get(source.id); + + const check = ( + <> + + + + {source.icon} + + + + {source.label} + {!('sources' in source) && isChecked && !data && } + ({ marginLeft: 'auto' })} + checked={isChecked} + indeterminate={intermediate} + onClick={e => { + e.stopPropagation(); + toggleSelection(source); + }} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + e.preventDefault(); + toggleSelection(source); + } + }} + /> + + ); + + if (!('sources' in source)) { + return ( + { + toggleSelection(source); + }} + > + {check} + + ); + } + + const isActive = source.id === activeItemId; + + return ( + + setActiveItemId(isActive ? undefined : source.id)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setActiveItemId(isActive ? undefined : source.id); + } + }} + > + + + {check} + + + + {source.id === activeItemId && + source.sources?.map(source => ( + + ))} + + + ); +} + +export interface GraphSourcesViewProps { + /** List of sources to render */ + sources: GraphSource[]; + /** Data for each source */ + sourceData: SourceData; + /** Selected sources */ + selectedSources: Set; + /** Callback when a source is toggled */ + toggleSource: (source: GraphSource) => void; +} + +export const GraphSourcesView = memo( + ({ sources, sourceData, selectedSources, toggleSource }: GraphSourcesViewProps) => { + const [anchorEl, setAnchorEl] = useState(null); + const [activeItemId, setActiveItemId] = useState(undefined); + + const selected = sources.filter(source => { + const isSelected = selectedSources.has(source.id); + return 'sources' in source + ? source.sources.some(it => selectedSources.has(it.id)) + : isSelected; + }); + const selectedText = + selected.length > 2 + ? `${selected[0].label}, ${selected[1].label}, +${selected.length - 2}` + : selected.map(it => it.label).join(', '); + + return ( + <> + + {selectedText}{' '} + + } + color="primary" + variant={'filled'} + onClick={e => setAnchorEl(e.currentTarget)} + sx={{ + lineHeight: '1', + }} + /> + setAnchorEl(null)} + anchorEl={anchorEl} + open={Boolean(anchorEl)} + > + + {sources.map((source, index) => ( + setActiveItemId(id)} + /> + ))} + + + + ); + } +); diff --git a/frontend/src/components/resourceMap/sources/definitions/configurationSource.tsx b/frontend/src/components/resourceMap/sources/definitions/configurationSource.tsx new file mode 100644 index 00000000000..f58ccebcca1 --- /dev/null +++ b/frontend/src/components/resourceMap/sources/definitions/configurationSource.tsx @@ -0,0 +1,208 @@ +import { Icon } from '@iconify/react'; +import { useMemo } from 'react'; +import ConfigMap from '../../../../lib/k8s/configMap'; +import Job from '../../../../lib/k8s/job'; +import MutatingWebhookConfiguration from '../../../../lib/k8s/mutatingWebhookConfiguration'; +import Pod from '../../../../lib/k8s/pod'; +import Secret from '../../../../lib/k8s/secret'; +import Service from '../../../../lib/k8s/service'; +import ValidatingWebhookConfiguration from '../../../../lib/k8s/validatingWebhookConfiguration'; +import { GraphEdge, GraphSource } from '../../graph/graphModel'; +import { getKindGroupColor, KubeIcon } from '../../kubeIcon/KubeIcon'; +import { makeKubeObjectNode, makeKubeToKubeEdge } from '../GraphSources'; + +const secretsSource: GraphSource = { + id: 'secrets', + label: 'Secrets', + icon: , + isEnabledByDefault: false, + useData() { + const [secrets] = Secret.useList(); + + const [pods] = Pod.useList(); + + return useMemo(() => { + if (!secrets || !pods) return null; + + const edges: GraphEdge[] = []; + + // find used secrets + pods.forEach(pod => { + // container env + pod.spec.containers.forEach(container => { + container.env?.forEach(env => { + if (env.valueFrom?.secretKeyRef) { + const secret = secrets.find( + secret => secret.metadata.name === env.valueFrom?.secretKeyRef?.name + ); + if (secret) { + if ( + edges.find(it => it.id === `${secret.metadata.uid}-${pod.metadata.uid}`) === + undefined + ) { + edges.push(makeKubeToKubeEdge(secret, pod)); + } + } + } + }); + }); + + // volumes projected sources + pod.spec.volumes?.forEach(volume => { + if (volume.projected) { + volume.projected.sources.forEach((source: any) => { + if (source.secret) { + const secret = secrets.find(secret => secret.metadata.name === source.secret!.name); + if (secret) { + edges.push(makeKubeToKubeEdge(secret, pod)); + } + } + }); + } + }); + }); + + return { + nodes: secrets.map(makeKubeObjectNode) ?? [], + edges, + }; + }, [pods, secrets]); + }, +}; + +const configMapsSource: GraphSource = { + id: 'configMaps', + label: 'Config Maps', + isEnabledByDefault: false, + icon: , + useData() { + const [configMaps] = ConfigMap.useList(); + const [pods] = Pod.useList(); + const [jobs] = Job.useList(); + + return useMemo(() => { + if (!configMaps || !pods || !jobs) return null; + + const edges: GraphEdge[] = []; + + // find used configmaps + pods.forEach(pod => { + pod.spec.volumes?.forEach(volume => { + if (volume.configMap) { + const cm = configMaps.find(cm => cm.metadata.name === volume.configMap!.name); + if (cm) { + edges.push(makeKubeToKubeEdge(cm, pod)); + } + } + }); + }); + + // in jobs + jobs.forEach(job => { + job.spec.template.spec.volumes?.forEach(volume => { + if (volume.configMap) { + const cm = configMaps.find(cm => cm.metadata.name === volume.configMap!.name); + if (cm) { + edges.push(makeKubeToKubeEdge(cm, job)); + } + } + }); + }); + + return { + nodes: configMaps.map(makeKubeObjectNode) ?? [], + edges, + }; + }, [configMaps, pods, jobs]); + }, +}; + +const validatingWebhookConfigurationSource: GraphSource = { + id: 'validatingWebhookConfigurations', + label: 'Validating Webhook Configurations', + icon: , + isEnabledByDefault: false, + useData() { + const [vwc] = ValidatingWebhookConfiguration.useList(); + const [services] = Service.useList(); + + return useMemo(() => { + if (!vwc || !services) return null; + + const nodes = vwc.map(makeKubeObjectNode) ?? []; + + const edges: GraphEdge[] = []; + + vwc.forEach(vwc => { + vwc.webhooks.forEach(webhook => { + const service = services.find( + service => service.metadata.name === webhook.clientConfig.service?.name + ); + if (service) { + edges.push(makeKubeToKubeEdge(service, vwc)); + } + }); + }); + + return { nodes, edges }; + }, [vwc, services]); + }, +}; + +const mutatingWebhookConfigurationSource: GraphSource = { + id: 'mutatingWebhookConfigurations', + label: 'Mutating Webhook Configurations', + icon: , + isEnabledByDefault: false, + useData() { + const [mwc] = MutatingWebhookConfiguration.useList(); + const [services] = Service.useList(); + + return useMemo(() => { + if (!mwc || !services) return null; + + const edges: GraphEdge[] = []; + + mwc.forEach(mwc => { + mwc.webhooks.forEach(webhook => { + const service = services.find( + service => service.metadata.name === webhook.clientConfig.service?.name + ); + if (service) { + edges.push(makeKubeToKubeEdge(service, mwc)); + } + }); + }); + + return { nodes: mwc.map(makeKubeObjectNode) ?? [], edges }; + }, [mwc, services]); + }, +}; + +export const configurationSource: GraphSource = { + id: 'configuration', + label: 'Configuration', + icon: ( + + ), + sources: [ + configMapsSource, + secretsSource, + // TODO: Implement the rest of resources + // hpa + // vpa + // pdb + // rq + // lr + // priorityClass + // runtimeClass + // leases + mutatingWebhookConfigurationSource, + validatingWebhookConfigurationSource, + ], +}; diff --git a/frontend/src/components/resourceMap/sources/definitions/networkSource.tsx b/frontend/src/components/resourceMap/sources/definitions/networkSource.tsx new file mode 100644 index 00000000000..eb00ff123e4 --- /dev/null +++ b/frontend/src/components/resourceMap/sources/definitions/networkSource.tsx @@ -0,0 +1,169 @@ +import { Icon } from '@iconify/react'; +import { useMemo } from 'react'; +import Endpoints from '../../../../lib/k8s/endpoints'; +import Ingress, { IngressRule } from '../../../../lib/k8s/ingress'; +import IngressClass from '../../../../lib/k8s/ingressClass'; +import NetworkPolicy from '../../../../lib/k8s/networkpolicy'; +import Pod from '../../../../lib/k8s/pod'; +import Secret from '../../../../lib/k8s/secret'; +import Service from '../../../../lib/k8s/service'; +import { GraphEdge, GraphSource } from '../../graph/graphModel'; +import { getKindGroupColor, KubeIcon } from '../../kubeIcon/KubeIcon'; +import { makeKubeObjectNode, makeKubeToKubeEdge } from '../GraphSources'; +import { matchesSelector } from './workloadSource'; + +const serviceSource: GraphSource = { + id: 'services', + label: 'Services', + icon: , + useData() { + const [services] = Service.useList(); + const [pods] = Pod.useList(); + + return useMemo(() => { + if (!services || !pods) return null; + + const edges: GraphEdge[] = []; + + services.forEach(service => { + const matchingPods = pods.filter(matchesSelector(service.spec.selector)); + + matchingPods?.forEach(pod => { + edges.push(makeKubeToKubeEdge(service, pod)); + }); + }); + + return { + edges, + nodes: services.map(makeKubeObjectNode) ?? [], + }; + }, [services, pods]); + }, +}; + +const endpointsSource: GraphSource = { + id: 'endpoints', + label: 'Endpoints', + icon: , + useData() { + const [endpoints] = Endpoints.useList(); + const [services] = Service.useList(); + + return useMemo(() => { + const nodes = endpoints?.map(makeKubeObjectNode) ?? []; + const edges: GraphEdge[] = []; + + services?.forEach(service => { + endpoints?.forEach(endpoint => { + if (endpoint.getName() === service.getName()) { + edges.push(makeKubeToKubeEdge(service, endpoint)); + } + }); + }); + + return { nodes, edges }; + }, [endpoints, services]); + }, +}; + +const ingressListSource: GraphSource = { + id: 'ingressList', + label: 'Ingress', + icon: , + useData() { + const [ingresses] = Ingress.useList(); + const [services] = Service.useList(); + const [secrets] = Secret.useList(); + + return useMemo(() => { + if (!ingresses || !services || !secrets) return null; + + const edges: GraphEdge[] = []; + + ingresses.forEach(ingress => { + ingress.spec.rules.forEach((rule: IngressRule) => { + rule.http.paths.forEach(path => { + const service = services.find( + service => service.metadata.name === path?.backend?.service?.name + ); + if (service) { + edges.push(makeKubeToKubeEdge(service, ingress)); + } + }); + }); + + ingress.spec.tls?.forEach(tls => { + if (tls.secretName) { + const secret = secrets.find(secret => secret.metadata.name === tls.secretName); + if (secret) { + edges.push(makeKubeToKubeEdge(secret, ingress)); + } + } + }); + }); + + return { + edges, + nodes: ingresses.map(makeKubeObjectNode) ?? [], + }; + }, [ingresses, services, secrets]); + }, +}; + +const networkPoliciesSource: GraphSource = { + id: 'networkPolicies', + label: 'Network Policies', + icon: , + useData() { + const [networkPolicies] = NetworkPolicy.useList(); + const [pods] = Pod.useList(); + + return useMemo(() => { + if (!networkPolicies || !pods) return null; + + const edges: GraphEdge[] = []; + + networkPolicies.forEach(np => { + const matchingPods = pods.filter(matchesSelector(np.jsonData.spec.podSelector.matchLabels)); + + matchingPods?.forEach(pod => { + edges.push(makeKubeToKubeEdge(np, pod)); + }); + }); + + return { + nodes: networkPolicies.map(makeKubeObjectNode) ?? [], + edges, + }; + }, [networkPolicies, pods]); + }, +}; + +const ingressClassesSource: GraphSource = { + id: 'ingressClasses', + label: 'Ingress Classes', + icon: , + useData() { + const [ingressClasses] = IngressClass.useList(); + + return useMemo(() => { + return { + nodes: ingressClasses?.map(makeKubeObjectNode) ?? [], + edges: [], + }; + }, [ingressClasses]); + }, +}; + +export const networkSource = { + id: 'network', + label: 'Network', + icon: , + sources: [ + serviceSource, + endpointsSource, + ingressListSource, + ingressClassesSource, + networkPoliciesSource, + ], +}; diff --git a/frontend/src/components/resourceMap/sources/definitions/securitySource.tsx b/frontend/src/components/resourceMap/sources/definitions/securitySource.tsx new file mode 100644 index 00000000000..e555e329e56 --- /dev/null +++ b/frontend/src/components/resourceMap/sources/definitions/securitySource.tsx @@ -0,0 +1,118 @@ +import { Icon } from '@iconify/react'; +import { useMemo } from 'react'; +import DaemonSet from '../../../../lib/k8s/daemonSet'; +import Deployment from '../../../../lib/k8s/deployment'; +import Role from '../../../../lib/k8s/role'; +import RoleBinding from '../../../../lib/k8s/roleBinding'; +import ServiceAccount from '../../../../lib/k8s/serviceAccount'; +import { GraphEdge, GraphSource } from '../../graph/graphModel'; +import { getKindGroupColor, KubeIcon } from '../../kubeIcon/KubeIcon'; +import { makeKubeObjectNode, makeKubeToKubeEdge } from '../GraphSources'; + +const rolesSource: GraphSource = { + id: 'roles', + label: 'Roles', + icon: , + useData() { + const [roles] = Role.useList(); + + return useMemo( + () => + roles + ? { + nodes: roles.map(makeKubeObjectNode) ?? [], + } + : null, + [roles] + ); + }, +}; + +const roleBindingsSource: GraphSource = { + id: 'roleBindings', + label: 'Role Bindings', + icon: , + useData() { + const [roleBindings] = RoleBinding.useList(); + const [roles] = Role.useList(); + const [serviceAccounts] = ServiceAccount.useList(); + + return useMemo(() => { + if (!roleBindings || !roles || !serviceAccounts) return null; + + const edges: GraphEdge[] = []; + + roleBindings.forEach(roleBinding => { + const role = roles.find(role => role.metadata.name === roleBinding.roleRef.name); + if (role) { + edges.push(makeKubeToKubeEdge(role, roleBinding)); + } + + // subject + roleBinding.subjects.forEach(subject => { + if (subject.kind === 'ServiceAccount') { + const sa = serviceAccounts.find(sa => sa.metadata.name === subject.name); + if (sa) { + edges.push(makeKubeToKubeEdge(sa, roleBinding)); + } + } + }); + }); + + return { + nodes: roleBindings.map(makeKubeObjectNode) ?? [], + edges, + }; + }, [roleBindings, roles, serviceAccounts]); + }, +}; + +const serviceAccountsSource: GraphSource = { + id: 'serviceAccounts', + label: 'Service Accounts', + icon: , + useData() { + const [serviceAccounts] = ServiceAccount.useList(); + const [deployments] = Deployment.useList(); + const [daemonSets] = DaemonSet.useList(); + + return useMemo(() => { + if (!serviceAccounts || !deployments || !daemonSets) return null; + + const edges: GraphEdge[] = []; + + serviceAccounts.forEach(sa => { + const matchingDeployments = deployments?.filter( + d => + (d.spec?.template?.spec?.serviceAccountName ?? 'default') === sa.metadata.name && + d.metadata.namespace === sa.metadata.namespace + ); + + matchingDeployments.forEach(d => { + edges.push(makeKubeToKubeEdge(sa, d)); + }); + + daemonSets + ?.filter( + d => + (d.spec?.template?.spec?.serviceAccountName ?? 'default') === sa.metadata.name && + d.metadata.namespace === sa.metadata.namespace + ) + .forEach(d => edges.push(makeKubeToKubeEdge(sa, d))); + }); + + return { + edges, + nodes: serviceAccounts.map(makeKubeObjectNode) ?? [], + }; + }, [serviceAccounts, deployments, daemonSets]); + }, +}; + +export const securitySource: GraphSource = { + id: 'security', + label: 'Security', + isEnabledByDefault: false, + icon: , + sources: [serviceAccountsSource, rolesSource, roleBindingsSource], +}; diff --git a/frontend/src/components/resourceMap/sources/definitions/storageSource.tsx b/frontend/src/components/resourceMap/sources/definitions/storageSource.tsx new file mode 100644 index 00000000000..af60ef0b413 --- /dev/null +++ b/frontend/src/components/resourceMap/sources/definitions/storageSource.tsx @@ -0,0 +1,51 @@ +import { Icon } from '@iconify/react'; +import { useMemo } from 'react'; +import PersistentVolumeClaim from '../../../../lib/k8s/persistentVolumeClaim'; +import Pod from '../../../../lib/k8s/pod'; +import { GraphEdge, GraphSource } from '../../graph/graphModel'; +import { getKindGroupColor, KubeIcon } from '../../kubeIcon/KubeIcon'; +import { makeKubeObjectNode, makeKubeToKubeEdge } from '../GraphSources'; + +const pvcSource: GraphSource = { + id: 'pvcs', + label: 'PVCs', + icon: , + useData() { + const [pvcs] = PersistentVolumeClaim.useList(); + const [pods] = Pod.useList(); + + return useMemo(() => { + if (!pvcs || !pods) return null; + + const edges: GraphEdge[] = []; + + // find used pvc + pods.forEach(pod => { + pod.spec.volumes?.forEach(volume => { + if (volume.persistentVolumeClaim) { + const pvc = pvcs.find( + pvc => pvc.metadata.name === volume.persistentVolumeClaim!.claimName + ); + if (pvc) { + edges.push(makeKubeToKubeEdge(pvc, pod)); + } + } + }); + }); + + return { + nodes: pvcs.map(makeKubeObjectNode) ?? [], + edges, + }; + }, [pvcs, pods]); + }, +}; + +export const storageSource: GraphSource = { + id: 'storage', + label: 'Storage', + icon: ( + + ), + sources: [pvcSource], +}; diff --git a/frontend/src/components/resourceMap/sources/definitions/workloadSource.tsx b/frontend/src/components/resourceMap/sources/definitions/workloadSource.tsx new file mode 100644 index 00000000000..0593cbf4929 --- /dev/null +++ b/frontend/src/components/resourceMap/sources/definitions/workloadSource.tsx @@ -0,0 +1,234 @@ +import { Icon } from '@iconify/react'; +import { useMemo } from 'react'; +import CronJob from '../../../../lib/k8s/cronJob'; +import DaemonSet from '../../../../lib/k8s/daemonSet'; +import Deployment from '../../../../lib/k8s/deployment'; +import Job from '../../../../lib/k8s/job'; +import { KubeObject } from '../../../../lib/k8s/KubeObject'; +import Node from '../../../../lib/k8s/node'; +import Pod from '../../../../lib/k8s/pod'; +import ReplicaSet from '../../../../lib/k8s/replicaSet'; +import Secret from '../../../../lib/k8s/secret'; +import StatefulSet from '../../../../lib/k8s/statefulSet'; +import { GraphEdge, GraphSource } from '../../graph/graphModel'; +import { getKindGroupColor, KubeIcon } from '../../kubeIcon/KubeIcon'; +import { kubeOwnersEdges, makeKubeObjectNode, makeKubeToKubeEdge } from '../GraphSources'; + +export const matchesSelector = (matchLabels: Record) => (item: KubeObject) => { + return ( + matchLabels && + item.metadata.labels && + Object.entries(matchLabels).every(([key, value]) => item.metadata?.labels?.[key] === value) + ); +}; + +const podsSource: GraphSource = { + id: 'pods', + label: 'Pods', + icon: , + useData: () => { + const [pods] = Pod.useList(); + const [nodes] = Node.useList(); + + return useMemo(() => { + if (!pods || !nodes) return null; + + const edges: GraphEdge[] = []; + + pods.forEach(pod => { + pod.metadata.ownerReferences?.forEach(owner => { + edges.push({ + id: `${owner.uid}-${pod.metadata.uid}`, + type: 'kubeRelation', + source: owner.uid, + target: pod.metadata.uid, + }); + }); + + const node = nodes.find(node => node.metadata.name === pod.spec.nodeName); + + if (node) { + edges.push({ + id: `${node.metadata.uid}-${pod.metadata.uid}`, + type: 'kubeRelation', + source: node.metadata.uid, + target: pod.metadata.uid, + }); + } + }); + + return { + edges, + nodes: + pods.map(pod => ({ + type: 'kubeObject', + id: pod.metadata.uid, + data: { + resource: pod, + }, + })) ?? [], + }; + }, [pods, nodes]); + }, +}; + +const deploymentsSource: GraphSource = { + id: 'deployments', + label: 'Deployments', + icon: , + useData() { + const [deployments] = Deployment.useList(); + + return useMemo(() => { + if (!deployments) return null; + return { + nodes: deployments?.map(makeKubeObjectNode) ?? [], + }; + }, [deployments]); + }, +}; + +const cronJobSource: GraphSource = { + id: 'cronJobs', + label: 'CronJobs', + icon: , + useData() { + const [cronJobs] = CronJob.useList(); + + return useMemo(() => { + if (!cronJobs) return null; + return { + edges: [], + nodes: cronJobs?.map(it => makeKubeObjectNode(it)) ?? [], + }; + }, [cronJobs]); + }, +}; + +const jobsSource: GraphSource = { + id: 'jobs', + label: 'Jobs', + icon: , + useData() { + const [jobs] = Job.useList(); + const [secrets] = Secret.useList(); + + return useMemo(() => { + if (!jobs || !secrets) return null; + + const edges: GraphEdge[] = []; + + jobs?.forEach(job => { + edges.push(...kubeOwnersEdges(job)); + + job.spec.template.spec.containers.forEach(container => { + container.env?.forEach(env => { + if (env.valueFrom?.secretKeyRef) { + const secret = secrets?.find( + secret => secret.metadata.name === env.valueFrom?.secretKeyRef?.name + ); + if ( + secret && + edges.find(it => it.id === `${secret.metadata.uid}-${job.metadata.uid}`) === + undefined + ) { + edges.push(makeKubeToKubeEdge(secret, job)); + } + } + }); + }); + }); + + return { + edges, + nodes: + jobs?.map(job => ({ + type: 'kubeObject', + id: job.metadata.uid, + data: { + resource: job, + }, + })) ?? [], + }; + }, [jobs, secrets]); + }, +}; + +const replicaSetsSource: GraphSource = { + id: 'replicaSets', + label: 'Replica Sets', + icon: , + useData() { + const [replicaSets] = ReplicaSet.useList(); + + return useMemo(() => { + if (!replicaSets) return null; + + const edges: GraphEdge[] = []; + + replicaSets?.forEach(replicaSet => { + edges.push(...kubeOwnersEdges(replicaSet)); + }); + + return { + edges, + nodes: replicaSets?.map(makeKubeObjectNode) ?? [], + }; + }, [replicaSets]); + }, +}; + +const statefulSetSource: GraphSource = { + id: 'statefulSets', + label: 'Stateful Sets', + icon: , + useData() { + const [statefulSets] = StatefulSet.useList(); + + return useMemo(() => { + if (!statefulSets) return null; + return { + nodes: statefulSets?.map(makeKubeObjectNode) ?? [], + }; + }, [statefulSets]); + }, +}; + +const daemonSetSource: GraphSource = { + id: 'daemonSets', + label: 'Daemon Sets', + icon: , + useData() { + const [daemonSets] = DaemonSet.useList(); + + return useMemo(() => { + if (!daemonSets) return null; + + return { + nodes: daemonSets?.map(makeKubeObjectNode) ?? [], + }; + }, [daemonSets]); + }, +}; + +export const workloadsSource: GraphSource = { + id: 'workloads', + label: 'Workloads', + icon: ( + + ), + sources: [ + podsSource, + deploymentsSource, + statefulSetSource, + daemonSetSource, + replicaSetsSource, + jobsSource, + cronJobSource, + ], +}; diff --git a/frontend/src/components/resourceMap/useQueryParamsState.tsx b/frontend/src/components/resourceMap/useQueryParamsState.tsx index c9f41fa6f71..23feb4945ff 100644 --- a/frontend/src/components/resourceMap/useQueryParamsState.tsx +++ b/frontend/src/components/resourceMap/useQueryParamsState.tsx @@ -27,7 +27,7 @@ export function useQueryParamsState( const searchParams = new URLSearchParams(search); const paramValue = searchParams.get(param); - return paramValue !== null ? (decodeURIComponent(paramValue) as T) : initialState; + return paramValue !== null ? (decodeURIComponent(paramValue) as T) : undefined; }); // Update the value from URL to state @@ -62,9 +62,14 @@ export function useQueryParamsState( history.push(newUrl); }, [param, value]); + // Initi state with initial state value + useEffect(() => { + setValue(initialState); + }, []); + const handleSetValue = useCallback( (newValue: T | undefined) => { - if (typeof newValue !== 'string') { + if (newValue !== undefined && typeof newValue !== 'string') { throw new Error("useQueryParamsState: Can't set a value to something that isn't a string"); } setValue(newValue); diff --git a/frontend/src/i18n/locales/de/glossary.json b/frontend/src/i18n/locales/de/glossary.json index 3f66869c485..867d56c8296 100644 --- a/frontend/src/i18n/locales/de/glossary.json +++ b/frontend/src/i18n/locales/de/glossary.json @@ -141,6 +141,7 @@ "PriorityClass": "Prioritäts-Klasse", "Replica Sets": "Replika-Sets", "Generation": "Generation", + "Cluster IP": "Cluster-IP", "Resource Quotas": "Ressourcen-Quotas", "Reference Kind": "Referenzart", "Reference Name": "Referenzname", @@ -156,7 +157,6 @@ "Verbs": "Verben", "RuntimeClass": "Laufzeit-Klasse", "Secrets": "Secrets", - "Cluster IP": "Cluster-IP", "Services": "Dienste", "Workloads": "Workloads", "Stateful Sets": "Stateful Sets", @@ -176,6 +176,7 @@ "Leases": "Leasingverträge", "Mutating Webhook Configurations": "Mutierende Webhook-Konfigurationen", "Validating Webhook Configurations": "Validierende Webhook-Konfigurationen", + "Map (beta)": "", "Git Version": "Git-Version", "Git Tree State": "Git-Tree-Status", "Go Version": "Go Version", diff --git a/frontend/src/i18n/locales/de/translation.json b/frontend/src/i18n/locales/de/translation.json index 37e6f52158d..040764054c1 100644 --- a/frontend/src/i18n/locales/de/translation.json +++ b/frontend/src/i18n/locales/de/translation.json @@ -375,6 +375,18 @@ "Preemption Policy": "Preemtions-Policy", "Current//context:replicas": "Aktuell", "Desired//context:replicas": "Gewünscht", + "Zoom in": "", + "Zoom out": "", + "Fit to screen": "", + "No data to be shown. Try to change filters or select a different namespace.": "", + "Group By: {{ name }}": "", + "Namespace": "", + "Instance": "", + "Node": "", + "Status: Error or Warning": "", + "Expand All": "", + "Zoom to 100%": "", + "Search": "", "Used": "Genutzt", "Hard": "Hart", "Request": "Anfrage", diff --git a/frontend/src/i18n/locales/en/glossary.json b/frontend/src/i18n/locales/en/glossary.json index ee03bbe851f..fe69299f90c 100644 --- a/frontend/src/i18n/locales/en/glossary.json +++ b/frontend/src/i18n/locales/en/glossary.json @@ -141,6 +141,7 @@ "PriorityClass": "PriorityClass", "Replica Sets": "Replica Sets", "Generation": "Generation", + "Cluster IP": "Cluster IP", "Resource Quotas": "Resource Quotas", "Reference Kind": "Reference Kind", "Reference Name": "Reference Name", @@ -156,7 +157,6 @@ "Verbs": "Verbs", "RuntimeClass": "RuntimeClass", "Secrets": "Secrets", - "Cluster IP": "Cluster IP", "Services": "Services", "Workloads": "Workloads", "Stateful Sets": "Stateful Sets", @@ -176,6 +176,7 @@ "Leases": "Leases", "Mutating Webhook Configurations": "Mutating Webhook Configurations", "Validating Webhook Configurations": "Validating Webhook Configurations", + "Map (beta)": "Map (beta)", "Git Version": "Git Version", "Git Tree State": "Git Tree State", "Go Version": "Go Version", diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index 8049d96c39e..06eff75b14f 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -375,6 +375,18 @@ "Preemption Policy": "Preemption Policy", "Current//context:replicas": "Current", "Desired//context:replicas": "Desired", + "Zoom in": "Zoom in", + "Zoom out": "Zoom out", + "Fit to screen": "Fit to screen", + "No data to be shown. Try to change filters or select a different namespace.": "No data to be shown. Try to change filters or select a different namespace.", + "Group By: {{ name }}": "Group By: {{ name }}", + "Namespace": "Namespace", + "Instance": "Instance", + "Node": "Node", + "Status: Error or Warning": "Status: Error or Warning", + "Expand All": "Expand All", + "Zoom to 100%": "Zoom to 100%", + "Search": "Search", "Used": "Used", "Hard": "Hard", "Request": "Request", diff --git a/frontend/src/i18n/locales/es/glossary.json b/frontend/src/i18n/locales/es/glossary.json index dae91a0965f..c577910684a 100644 --- a/frontend/src/i18n/locales/es/glossary.json +++ b/frontend/src/i18n/locales/es/glossary.json @@ -141,6 +141,7 @@ "PriorityClass": "PriorityClass", "Replica Sets": "Replica Sets", "Generation": "Generación", + "Cluster IP": "IP del Cluster", "Resource Quotas": "Cuotas de Recurso", "Reference Kind": "«Kind» de la referencia", "Reference Name": "Nombre de la referencia", @@ -156,7 +157,6 @@ "Verbs": "Verbos", "RuntimeClass": "RuntimeClass", "Secrets": "Secrets", - "Cluster IP": "IP del Cluster", "Services": "Services", "Workloads": "Cargas de Trabajo", "Stateful Sets": "Stateful Sets", @@ -176,6 +176,7 @@ "Leases": "Leases", "Mutating Webhook Configurations": "Mutating Webhook Configurations", "Validating Webhook Configurations": "Validating Webhook Configurations", + "Map (beta)": "", "Git Version": "Versión de Git", "Git Tree State": "Estado del Árbol de Git", "Go Version": "Versión de Go", diff --git a/frontend/src/i18n/locales/es/translation.json b/frontend/src/i18n/locales/es/translation.json index 9b656492765..35348771510 100644 --- a/frontend/src/i18n/locales/es/translation.json +++ b/frontend/src/i18n/locales/es/translation.json @@ -376,6 +376,18 @@ "Preemption Policy": "Política de \"Preemption\"", "Current//context:replicas": "Actuales", "Desired//context:replicas": "Deseadas", + "Zoom in": "", + "Zoom out": "", + "Fit to screen": "", + "No data to be shown. Try to change filters or select a different namespace.": "", + "Group By: {{ name }}": "", + "Namespace": "", + "Instance": "", + "Node": "", + "Status: Error or Warning": "", + "Expand All": "", + "Zoom to 100%": "", + "Search": "", "Used": "Usado", "Hard": "Duro", "Request": "Solicitud", diff --git a/frontend/src/i18n/locales/fr/glossary.json b/frontend/src/i18n/locales/fr/glossary.json index a38b31f130b..5016b4448ae 100644 --- a/frontend/src/i18n/locales/fr/glossary.json +++ b/frontend/src/i18n/locales/fr/glossary.json @@ -141,6 +141,7 @@ "PriorityClass": "PriorityClass", "Replica Sets": "Replica Sets", "Generation": "Génération", + "Cluster IP": "IP cluster", "Resource Quotas": "Resource Quotas", "Reference Kind": "Type de référence", "Reference Name": "Nom de référence", @@ -156,7 +157,6 @@ "Verbs": "Verbes", "RuntimeClass": "RuntimeClass", "Secrets": "Secrets", - "Cluster IP": "IP cluster", "Services": "Services", "Workloads": "Charges de travail", "Stateful Sets": "Stateful Sets", @@ -176,6 +176,7 @@ "Leases": "Baux", "Mutating Webhook Configurations": "Mutating Webhook Configurations", "Validating Webhook Configurations": "Validating Webhook Configurations", + "Map (beta)": "", "Git Version": "Version Git", "Git Tree State": "État de l'arbre Git", "Go Version": "Version de Go", diff --git a/frontend/src/i18n/locales/fr/translation.json b/frontend/src/i18n/locales/fr/translation.json index 519a0221516..68db9c1c3ce 100644 --- a/frontend/src/i18n/locales/fr/translation.json +++ b/frontend/src/i18n/locales/fr/translation.json @@ -376,6 +376,18 @@ "Preemption Policy": "Politique de préemption", "Current//context:replicas": "Actuels", "Desired//context:replicas": "Souhaités", + "Zoom in": "", + "Zoom out": "", + "Fit to screen": "", + "No data to be shown. Try to change filters or select a different namespace.": "", + "Group By: {{ name }}": "", + "Namespace": "", + "Instance": "", + "Node": "", + "Status: Error or Warning": "", + "Expand All": "", + "Zoom to 100%": "", + "Search": "", "Used": "Utilisé", "Hard": "Dur", "Request": "Demande", diff --git a/frontend/src/i18n/locales/pt/glossary.json b/frontend/src/i18n/locales/pt/glossary.json index 40dc6db9a16..fdf7e072ca5 100644 --- a/frontend/src/i18n/locales/pt/glossary.json +++ b/frontend/src/i18n/locales/pt/glossary.json @@ -141,6 +141,7 @@ "PriorityClass": "PriorityClass", "Replica Sets": "Replica Sets", "Generation": "Geração", + "Cluster IP": "IP do Cluster", "Resource Quotas": "Resource Quotas", "Reference Kind": "Reference Kind", "Reference Name": "Reference Name", @@ -156,7 +157,6 @@ "Verbs": "Verbos", "RuntimeClass": "RuntimeClass", "Secrets": "Secrets", - "Cluster IP": "IP do Cluster", "Services": "Services", "Workloads": "Workloads", "Stateful Sets": "Stateful Sets", @@ -176,6 +176,7 @@ "Leases": "Leases", "Mutating Webhook Configurations": "Mutating Webhook Configurations", "Validating Webhook Configurations": "Validating Webhook Configurations", + "Map (beta)": "", "Git Version": "Versão do Git", "Git Tree State": "Estado de Árvore do Git", "Go Version": "Versão do Go", diff --git a/frontend/src/i18n/locales/pt/translation.json b/frontend/src/i18n/locales/pt/translation.json index a55df62fd25..f7aa198d493 100644 --- a/frontend/src/i18n/locales/pt/translation.json +++ b/frontend/src/i18n/locales/pt/translation.json @@ -376,6 +376,18 @@ "Preemption Policy": "Política de \"Preemption\"", "Current//context:replicas": "Actuais", "Desired//context:replicas": "Desejadas", + "Zoom in": "", + "Zoom out": "", + "Fit to screen": "", + "No data to be shown. Try to change filters or select a different namespace.": "", + "Group By: {{ name }}": "", + "Namespace": "", + "Instance": "", + "Node": "", + "Status: Error or Warning": "", + "Expand All": "", + "Zoom to 100%": "", + "Search": "", "Used": "Usado", "Hard": "Rígido", "Request": "Pedido", diff --git a/frontend/src/lib/router.tsx b/frontend/src/lib/router.tsx index bc2b98ba3d6..dd533784b25 100644 --- a/frontend/src/lib/router.tsx +++ b/frontend/src/lib/router.tsx @@ -119,8 +119,14 @@ export interface Route { hideAppBar?: boolean; /** Whether the route should be disabled (not registered). */ disabled?: boolean; + /** Render route for full width */ + isFullWidth?: boolean; } +const LazyGraphView = React.lazy(() => + import('../components/resourceMap/GraphView').then(it => ({ default: it.GraphView })) +); + const defaultRoutes: { [routeName: string]: Route; } = { @@ -787,6 +793,14 @@ const defaultRoutes: { disabled: !helpers.isElectron(), component: () => , }, + map: { + path: '/map', + exact: true, + name: 'Map (beta)', + sidebar: 'map', + isFullWidth: true, + component: () => , + }, }; // The NotFound route needs to be considered always in the last place when used diff --git a/frontend/src/redux/actions/actions.tsx b/frontend/src/redux/actions/actions.tsx index 46a352accde..ece0ef52f68 100644 --- a/frontend/src/redux/actions/actions.tsx +++ b/frontend/src/redux/actions/actions.tsx @@ -7,6 +7,7 @@ export const UI_HIDE_APP_BAR = 'UI_HIDE_APP_BAR'; export const UI_FUNCTIONS_OVERRIDE = 'UI_FUNCTIONS_OVERRIDE'; export const UI_VERSION_DIALOG_OPEN = 'UI_VERSION_DIALOG_OPEN'; export const UI_INITIALIZE_PLUGIN_VIEWS = 'UI_INITIALIZE_PLUGIN_VIEWS'; +export const UI_SET_IS_FULLWIDTH = 'UI_SET_IS_FULLWIDTH'; export interface BrandingProps { logo: AppLogoType; @@ -36,3 +37,7 @@ export type FunctionsToOverride = { export function setFunctionsToOverride(override: FunctionsToOverride) { return { type: UI_FUNCTIONS_OVERRIDE, override }; } + +export function setIsFullWidth(isFullWidth?: boolean) { + return { type: UI_SET_IS_FULLWIDTH, isFullWidth }; +} diff --git a/frontend/src/redux/reducers/ui.tsx b/frontend/src/redux/reducers/ui.tsx index bee11f5ef5f..3f3091e274f 100644 --- a/frontend/src/redux/reducers/ui.tsx +++ b/frontend/src/redux/reducers/ui.tsx @@ -7,6 +7,7 @@ import { UI_HIDE_APP_BAR, UI_INITIALIZE_PLUGIN_VIEWS, UI_SET_CLUSTER_CHOOSER_BUTTON, + UI_SET_IS_FULLWIDTH, UI_VERSION_DIALOG_OPEN, } from '../actions/actions'; @@ -14,12 +15,14 @@ export interface UIState { isVersionDialogOpen: boolean; clusterChooserButtonComponent?: ClusterChooserType; hideAppBar?: boolean; + isFullWidth?: boolean; functionsToOverride: FunctionsToOverride; } export const INITIAL_STATE: UIState = { isVersionDialogOpen: false, hideAppBar: false, + isFullWidth: false, functionsToOverride: {}, }; @@ -53,6 +56,10 @@ function reducer(state = _.cloneDeep(INITIAL_STATE), action: Action) { } break; } + case UI_SET_IS_FULLWIDTH: { + newFilters.isFullWidth = action.isFullWidth; + break; + } default: return state; } diff --git a/plugins/headlamp-plugin/package-lock.json b/plugins/headlamp-plugin/package-lock.json index 97686fa8147..29ed261fa93 100644 --- a/plugins/headlamp-plugin/package-lock.json +++ b/plugins/headlamp-plugin/package-lock.json @@ -19,6 +19,7 @@ "@babel/preset-env": "^7.11.0", "@babel/preset-react": "^7.10.4", "@babel/preset-typescript": "^7.10.4", + "@dagrejs/dagre": "^1.1.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@headlamp-k8s/eslint-config": "^0.6.0", @@ -71,6 +72,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/xterm": "^5.5.0", + "@xyflow/react": "^12.2.0", "babel-loader": "^8.2.5", "base64-arraybuffer": "^1.0.2", "buffer": "^6.0.3", @@ -79,6 +81,7 @@ "cross-env": "^7.0.3", "crypto-browserify": "^3.12.0", "css-loader": "^6.7.1", + "elkjs": "^0.9.3", "env-paths": "^2.2.1", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", @@ -148,6 +151,7 @@ "util": "^0.12.4", "validate-npm-package-name": "^3.0.0", "vm-browserify": "^1.1.2", + "web-worker": "^1.3.0", "webpack": "^5.66.0", "webpack-cli": "^4.9.0", "ws": "^8.16.0", @@ -2599,6 +2603,24 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz", + "integrity": "sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "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", @@ -7617,6 +7639,15 @@ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" }, + "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-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", @@ -7643,6 +7674,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", @@ -7661,6 +7698,25 @@ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" }, + "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/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -8748,6 +8804,36 @@ "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" }, + "node_modules/@xyflow/react": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.3.2.tgz", + "integrity": "sha512-+bK3L61BDIvUX++jMiEqIjy5hIIyVmfeiUavpeOZIYKwg6NW0pR5EnHJM2JFfkVqZisFauzS9EgmI+tvTqx9Qw==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.43", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.43.tgz", + "integrity": "sha512-1zHgad1cWr1mKm2xbFaarK0Jg8WRgaQ8ubSBIo/pRdq3fEgCuqgNkL9NSAP6Rvm8zi3+Lu4JPUMN+EEx5QgX9A==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", @@ -10554,6 +10640,12 @@ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==" }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -11582,6 +11674,28 @@ "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-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -11632,6 +11746,15 @@ "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", @@ -11673,6 +11796,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/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -12281,6 +12439,12 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.32.tgz", "integrity": "sha512-M+7ph0VGBQqqpTT2YrabjNKSQ2fEl9PVx6AK3N558gDH9NO8O6XN9SXXFWRo9u9PbEg/bWq+tjXQr+eXmxubCw==" }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, "node_modules/elliptic": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.0.tgz", @@ -30872,6 +31036,12 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/web-worker": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz", + "integrity": "sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -31970,6 +32140,34 @@ "node": ">= 6" } }, + "node_modules/zustand": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.5.tgz", + "integrity": "sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/plugins/headlamp-plugin/package.json b/plugins/headlamp-plugin/package.json index 2dfd7492b33..78e428a21f6 100644 --- a/plugins/headlamp-plugin/package.json +++ b/plugins/headlamp-plugin/package.json @@ -23,6 +23,7 @@ "@babel/preset-env": "^7.11.0", "@babel/preset-react": "^7.10.4", "@babel/preset-typescript": "^7.10.4", + "@dagrejs/dagre": "^1.1.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@headlamp-k8s/eslint-config": "^0.6.0", @@ -75,6 +76,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/xterm": "^5.5.0", + "@xyflow/react": "^12.2.0", "babel-loader": "^8.2.5", "base64-arraybuffer": "^1.0.2", "buffer": "^6.0.3", @@ -83,6 +85,7 @@ "cross-env": "^7.0.3", "crypto-browserify": "^3.12.0", "css-loader": "^6.7.1", + "elkjs": "^0.9.3", "env-paths": "^2.2.1", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", @@ -152,6 +155,7 @@ "util": "^0.12.4", "validate-npm-package-name": "^3.0.0", "vm-browserify": "^1.1.2", + "web-worker": "^1.3.0", "webpack": "^5.66.0", "webpack-cli": "^4.9.0", "ws": "^8.16.0",