diff --git a/package-lock.json b/package-lock.json index 8837a309..b9120dae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,9 @@ "@ui5/webcomponents-fiori": "^2.7.2", "@ui5/webcomponents-icons": "^2.7.2", "@ui5/webcomponents-react": "^2.7.2", + "@xyflow/react": "^12.8.2", "clsx": "^2.1.1", + "dagre": "^0.8.5", "dotenv": "^17.0.0", "fastify": "^5.3.3", "fastify-plugin": "^5.0.1", @@ -53,6 +55,7 @@ "@eslint/js": "^9.22.0", "@graphql-codegen/cli": "^5.0.5", "@graphql-codegen/client-preset": "^4.8.0", + "@types/dagre": "^0.7.53", "@types/js-yaml": "^4.0.9", "@types/node": "^22.13.5", "@types/react": "^19.0.10", @@ -4939,6 +4942,62 @@ "@types/node": "*" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-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-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "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-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/dagre": { + "version": "0.7.53", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz", + "integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -6093,6 +6152,38 @@ "node": ">=8" } }, + "node_modules/@xyflow/react": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.2.tgz", + "integrity": "sha512-VifLpxOy74ck283NQOtBn1e8igmB7xo7ADDKxyBHkKd8IKpyr16TgaYOhzqVwNMdB4NT+m++zfkic530L+gEXw==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.66", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.66", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.66.tgz", + "integrity": "sha512-TTxESDwPsATnuDMUeYYtKe4wt9v8bRO29dgYBhR8HyhSCzipnAdIL/1CDfFd+WqS1srVreo24u6zZeVIDk4r3Q==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/@zxing/library": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz", @@ -7398,6 +7489,12 @@ "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "license": "MIT" }, + "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", @@ -7931,6 +8028,121 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "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/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -10428,6 +10640,15 @@ "dev": true, "license": "MIT" }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/graphql": { "version": "16.11.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", @@ -12139,7 +12360,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -16654,6 +16874,34 @@ "peerDependencies": { "zod": "^3.24.1" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "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 + } + } } } } diff --git a/package.json b/package.json index 5e180a6f..924d72bc 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "tsc && npm run build:server && vite build", "build:server": "tsc -p tsconfig.server.json", "lint": "eslint ./src --report-unused-disable-directives --max-warnings 0", + "lint:fix": "eslint ./src --fix", "preview": "vite preview", "test:vi": "vitest", "test:cy": "cypress run --component --browser chrome", @@ -39,7 +40,9 @@ "@ui5/webcomponents-fiori": "^2.7.2", "@ui5/webcomponents-icons": "^2.7.2", "@ui5/webcomponents-react": "^2.7.2", + "@xyflow/react": "^12.8.2", "clsx": "^2.1.1", + "dagre": "^0.8.5", "dotenv": "^17.0.0", "fastify": "^5.3.3", "fastify-plugin": "^5.0.1", @@ -65,6 +68,7 @@ "@eslint/js": "^9.22.0", "@graphql-codegen/cli": "^5.0.5", "@graphql-codegen/client-preset": "^4.8.0", + "@types/dagre": "^0.7.53", "@types/js-yaml": "^4.0.9", "@types/node": "^22.13.5", "@types/react": "^19.0.10", diff --git a/public/locales/en.json b/public/locales/en.json index aa053a98..66946c18 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -175,7 +175,8 @@ "componentsTitle": "Components", "crossplaneTitle": "Crossplane", "gitOpsTitle": "GitOps", - "landscapersTitle": "Landscapers" + "landscapersTitle": "Landscapers", + "graphTitle": "Graph" }, "ToastContext": { "errorMessage": "useToast must be used within a ToastProvider" @@ -281,6 +282,17 @@ "tableHeaderInstalled": "Installed", "tableHeaderHealthy": "Healthy" }, + "Graphs": { + "colorsProvider": "Provider", + "colorsProviderConfig": "Provider Config", + "colorizedTitle": "Colorized Graph by: ", + "loadingError": "Error loading graph data", + "loadingGraph": "Loading graph data...", + "noResources": "No resources to display" + }, + "GraphsLegend": { + "title": "Legend" + }, "validationErrors": { "required": "This field is required!", "properFormatting": "Use A-Z, a-z, 0-9, hyphen (-), and period (.), but note that whitespace (spaces, tabs, etc.) is not allowed for proper compatibility.", diff --git a/src/components/Graphs/CustomNode.module.css b/src/components/Graphs/CustomNode.module.css new file mode 100644 index 00000000..b314605d --- /dev/null +++ b/src/components/Graphs/CustomNode.module.css @@ -0,0 +1,51 @@ +.nodeContainer { + display: flex; + align-items: center; + height: 100%; + padding: 0 10px; + box-sizing: border-box; + overflow: hidden; + justify-content: space-between; + position: relative; + font-family: var(--sapFontFamily); + pointer-events: auto; +} + +.nodeContent { + display: flex; + align-items: center; + overflow: hidden; +} + +.nodeTextContainer { + overflow: hidden; +} + +.nodeLabel { + font-weight: 600; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; +} + +.nodeType { + font-size: 12px; + color: #888; + margin-top: 2px; +} + +.yamlButtonWrapper { + margin-left: 0px; + display: flex; + align-items: center; +} + +.statusIcon { + margin-right: 6px; +} + +.handleHidden { + visibility: hidden; +} \ No newline at end of file diff --git a/src/components/Graphs/CustomNode.tsx b/src/components/Graphs/CustomNode.tsx new file mode 100644 index 00000000..0470ecab --- /dev/null +++ b/src/components/Graphs/CustomNode.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Button, Icon } from '@ui5/webcomponents-react'; +import StatusIcon from './StatusIcon'; +import styles from './CustomNode.module.css'; +import { Handle, Position } from '@xyflow/react'; + +export interface CustomNodeProps { + label: string; + type?: string; + status: string; + onYamlClick: () => void; +} + +const CustomNode: React.FC = ({ label, type, status, onYamlClick }) => ( +
+ + +
+
+ +
+
+
+ {label} +
+ {type &&
{type}
} +
+
+
+ +
+
+); + +export default CustomNode; diff --git a/src/components/Graphs/Graph.module.css b/src/components/Graphs/Graph.module.css new file mode 100644 index 00000000..57ab3501 --- /dev/null +++ b/src/components/Graphs/Graph.module.css @@ -0,0 +1,46 @@ +.graphContainer { + display: flex; + height: 600px; + border: 1px solid #ddd; + border-radius: 8px; + overflow: hidden; + background-color: #fafafa; + font-family: var(--sapFontFamily); +} + +.graphColumn { + flex: 1; + display: flex; + flex-direction: column; +} + +.graphHeader { + padding: 0.5rem; + display: flex; + gap: 1rem; + align-items: center; +} + +.graphToolbar { + padding: 0.5rem; + display: flex; + gap: 1rem; + align-items: center; +} + +.message { + display: flex; + align-items: center; + justify-content: center; + height: 400px; + font-size: 18px; + color: #888; +} + +.errorMessage { + color: #c00; +} + +.colorizedTitle { + font-weight: 500; +} \ No newline at end of file diff --git a/src/components/Graphs/Graph.tsx b/src/components/Graphs/Graph.tsx new file mode 100644 index 00000000..44029035 --- /dev/null +++ b/src/components/Graphs/Graph.tsx @@ -0,0 +1,125 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import { ReactFlow, Background, Controls, MarkerType, Node } from '@xyflow/react'; +import type { NodeProps } from '@xyflow/react'; +import { RadioButton, FlexBox, FlexBoxAlignItems } from '@ui5/webcomponents-react'; +import styles from './Graph.module.css'; +import '@xyflow/react/dist/style.css'; +import { ManagedResourceItem, NodeData, ColorBy } from './types'; +import CustomNode from './CustomNode'; +import { Legend, LegendItem } from './Legend'; +import { YamlViewDialog } from '../Yaml/YamlViewDialog'; +import YamlViewer from '../Yaml/YamlViewer'; +import { stringify } from 'yaml'; +import { removeManagedFieldsProperty } from '../../utils/removeManagedFieldsProperty'; +import { useTranslation } from 'react-i18next'; +import { useGraph } from './useGraph'; + +const nodeTypes = { + custom: (props: NodeProps>) => ( + props.data.onYamlClick(props.data.item)} + /> + ), +}; + +const Graph: React.FC = () => { + const { t } = useTranslation(); + const [colorBy, setColorBy] = useState('provider'); + const [yamlDialogOpen, setYamlDialogOpen] = useState(false); + const [yamlResource, setYamlResource] = useState(null); + + const handleYamlClick = useCallback((item: ManagedResourceItem) => { + setYamlResource(item); + setYamlDialogOpen(true); + }, []); + + const { nodes, edges, colorMap, loading, error } = useGraph(colorBy, handleYamlClick); + + const yamlString = useMemo( + () => (yamlResource ? stringify(removeManagedFieldsProperty(yamlResource)) : ''), + [yamlResource], + ); + + const yamlFilename = useMemo(() => { + if (!yamlResource) return ''; + const { kind, metadata } = yamlResource; + return `${kind ?? ''}${metadata?.name ? '_' : ''}${metadata?.name ?? ''}`; + }, [yamlResource]); + + const legendItems: LegendItem[] = useMemo( + () => + Object.entries(colorMap).map(([name, color]) => ({ + name: name === 'default' ? 'default' : name, + color, + })), + [colorMap], + ); + + if (error) { + return
{t('Graphs.loadingError')}
; + } + + if (loading) { + return
{t('Graphs.loadingGraph')}
; + } + + if (!nodes.length) { + return
{t('Graphs.noResources')}
; + } + + return ( +
+
+
+ + {t('Graphs.colorizedTitle')} + setColorBy('provider')} + /> + setColorBy('source')} + /> + +
+ + + + +
+ } + /> + +
+ ); +}; + +export default Graph; diff --git a/src/components/Graphs/Legend.module.css b/src/components/Graphs/Legend.module.css new file mode 100644 index 00000000..54759829 --- /dev/null +++ b/src/components/Graphs/Legend.module.css @@ -0,0 +1,31 @@ +.legendContainer { + padding: 1rem; + min-width: 240px; + max-width: 300px; + max-height: 280px; + border: 1px solid #ccc; + border-radius: 8px; + background-color: #fff; + margin: 1rem; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); + overflow: auto; + align-self: flex-start; +} + +.legendTitle { + margin-bottom: 10px; +} + +.legendRow { + display: flex; + align-items: center; + margin-bottom: 8px; +} + +.legendColorBox { + width: 16px; + height: 16px; + margin-right: 8px; + border-radius: 3px; + border: 1px solid #999; +} \ No newline at end of file diff --git a/src/components/Graphs/Legend.tsx b/src/components/Graphs/Legend.tsx new file mode 100644 index 00000000..0cf8a960 --- /dev/null +++ b/src/components/Graphs/Legend.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import styles from './Legend.module.css'; +import { useTranslation } from 'react-i18next'; + +export interface LegendItem { + name: string; + color: string; +} + +interface LegendProps { + legendItems: LegendItem[]; +} + +export const Legend: React.FC = ({ legendItems }) => { + const { t } = useTranslation(); + + return ( +
+

{t('GraphsLegend.title')}

+ {legendItems.map(({ name, color }) => ( +
+
+ {name} +
+ ))} +
+ ); +}; diff --git a/src/components/Graphs/StatusIcon.module.css b/src/components/Graphs/StatusIcon.module.css new file mode 100644 index 00000000..b14caf3a --- /dev/null +++ b/src/components/Graphs/StatusIcon.module.css @@ -0,0 +1,4 @@ +.statusIcon { + font-size: 1rem; + margin-right: 6px; +} \ No newline at end of file diff --git a/src/components/Graphs/StatusIcon.tsx b/src/components/Graphs/StatusIcon.tsx new file mode 100644 index 00000000..34a87e6d --- /dev/null +++ b/src/components/Graphs/StatusIcon.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Icon } from '@ui5/webcomponents-react'; +import styles from './StatusIcon.module.css'; + +export interface StatusIconProps { + isOk: boolean; +} + +const StatusIcon: React.FC = ({ isOk }) => ( + +); + +export default StatusIcon; diff --git a/src/components/Graphs/graphUtils.spec.ts b/src/components/Graphs/graphUtils.spec.ts new file mode 100644 index 00000000..b807f307 --- /dev/null +++ b/src/components/Graphs/graphUtils.spec.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { getStatusFromConditions, resolveProviderType, generateColorMap } from './graphUtils'; + +describe('getStatusFromConditions', () => { + it('returns OK if Ready is True', () => { + expect(getStatusFromConditions([{ type: 'Ready', status: 'True', lastTransitionTime: '2024-01-01' }])).toBe('OK'); + }); + + it('returns OK if Healthy is True', () => { + expect(getStatusFromConditions([{ type: 'Healthy', status: 'True', lastTransitionTime: '2024-01-01' }])).toBe('OK'); + }); + + it('returns ERROR if Ready is False', () => { + expect(getStatusFromConditions([{ type: 'Ready', status: 'False', lastTransitionTime: '2024-01-01' }])).toBe( + 'ERROR', + ); + }); + + it('returns ERROR if Healthy is False', () => { + expect(getStatusFromConditions([{ type: 'Healthy', status: 'False', lastTransitionTime: '2024-01-01' }])).toBe( + 'ERROR', + ); + }); + + it('returns ERROR if no relevant condition exists', () => { + expect(getStatusFromConditions([{ type: 'Other', status: 'True', lastTransitionTime: '2024-01-01' }])).toBe( + 'ERROR', + ); + }); + + it('returns ERROR for undefined or empty input', () => { + expect(getStatusFromConditions(undefined)).toBe('ERROR'); + expect(getStatusFromConditions([])).toBe('ERROR'); + }); +}); + +describe('resolveProviderType', () => { + it('returns correct providerType if found', () => { + const configs = [ + { + items: [ + { metadata: { name: 'foo' }, apiVersion: 'btp/v1' }, + { metadata: { name: 'bar' }, apiVersion: 'cloudfoundry/v1' }, + ], + }, + { + items: [{ metadata: { name: 'baz' }, apiVersion: 'gardener/v1' }], + }, + ]; + expect(resolveProviderType('foo', configs)).toBe('provider-btp'); + expect(resolveProviderType('bar', configs)).toBe('provider-cf'); + expect(resolveProviderType('baz', configs)).toBe('provider-gardener'); + }); + + it('returns apiVersion or configName if no match for known providers', () => { + const configs = [ + { + items: [{ metadata: { name: 'other' }, apiVersion: 'custom/v1' }], + }, + ]; + expect(resolveProviderType('other', configs)).toBe('custom/v1'); + }); + + it('returns configName if not found', () => { + const configs = [{ items: [{ metadata: { name: 'foo' }, apiVersion: 'btp/v1' }] }]; + expect(resolveProviderType('notfound', configs)).toBe('notfound'); + }); +}); + +describe('generateColorMap', () => { + it('returns a color map for providerConfigName', () => { + const nodes = [ + { providerConfigName: 'a', providerType: 'x' }, + { providerConfigName: 'b', providerType: 'y' }, + ]; + const colorMap = generateColorMap(nodes as any, 'provider'); + expect(colorMap['a']).toBeDefined(); + expect(colorMap['b']).toBeDefined(); + }); + + it('returns a color map for providerType', () => { + const nodes = [ + { providerConfigName: 'a', providerType: 'x' }, + { providerConfigName: 'b', providerType: 'y' }, + ]; + const colorMap = generateColorMap(nodes as any, 'source'); + expect(colorMap['x']).toBeDefined(); + expect(colorMap['y']).toBeDefined(); + }); + + it('returns an empty object for empty input', () => { + expect(generateColorMap([], 'provider')).toEqual({}); + }); +}); diff --git a/src/components/Graphs/graphUtils.ts b/src/components/Graphs/graphUtils.ts new file mode 100644 index 00000000..3eeda676 --- /dev/null +++ b/src/components/Graphs/graphUtils.ts @@ -0,0 +1,76 @@ +import { Condition, ManagedResourceItem, NodeData, ProviderConfig } from './types'; + +export type StatusType = 'ERROR' | 'OK'; + +export const getStatusFromConditions = (conditions?: Condition[]): StatusType => { + if (!conditions || !Array.isArray(conditions)) return 'ERROR'; + const relevant = conditions.find((c) => c.type === 'Ready' || c.type === 'Healthy'); + return relevant?.status === 'True' ? 'OK' : 'ERROR'; +}; + +export const resolveProviderType = (configName: string, providerConfigsList: ProviderConfig[]): string => { + for (const configList of providerConfigsList || []) { + const match = configList.items?.find((item) => item.metadata?.name === configName); + + if (match) { + const apiVersion = match.apiVersion?.toLowerCase() || ''; + if (apiVersion.includes('btp')) return 'provider-btp'; + if (apiVersion.includes('cloudfoundry')) return 'provider-cf'; + if (apiVersion.includes('gardener')) return 'provider-gardener'; + if (apiVersion.includes('kubernetes')) return 'provider-kubernetes'; + return apiVersion || configName; + } + } + + return configName; +}; + +export const generateColorMap = (items: NodeData[], colorBy: string): Record => { + const colors = [ + '#1abc9c', + '#9b59b6', + '#2ecc71', + '#2980b9', + '#3498db', + '#e67e22', + '#e74c3c', + '#16a085', + '#f39c12', + '#d35400', + '#8e44ad', + '#c0392b', + ]; + + const keys = + colorBy === 'source' + ? Array.from(new Set(items.map((i) => i.providerType).filter(Boolean))) + : Array.from(new Set(items.map((i) => i.providerConfigName).filter(Boolean))); + + const map = new Map(); + keys.forEach((key, i) => { + map.set(key, colors[i % colors.length]); + }); + + return Object.fromEntries(map); +}; + +export function extractRefs(item: ManagedResourceItem) { + return { + subaccountRef: item?.spec?.forProvider?.subaccountRef?.name, + serviceManagerRef: item?.spec?.forProvider?.serviceManagerRef?.name, + spaceRef: item?.spec?.forProvider?.spaceRef?.name, + orgRef: item?.spec?.forProvider?.orgRef?.name, + cloudManagementRef: item?.spec?.cloudManagementRef?.name, + directoryRef: item?.spec?.forProvider?.directoryRef?.name, + entitlementRef: item?.spec?.forProvider?.entitlementRef?.name, + globalAccountRef: item?.spec?.forProvider?.globalAccountRef?.name, + orgRoleRef: item?.spec?.forProvider?.orgRoleRef?.name, + spaceMembersRef: item?.spec?.forProvider?.spaceMembersRef?.name, + cloudFoundryEnvironmentRef: item?.spec?.forProvider?.cloudFoundryEnvironmentRef?.name, + kymaEnvironmentRef: item?.spec?.forProvider?.kymaEnvironmentRef?.name, + roleCollectionRef: item?.spec?.forProvider?.roleCollectionRef?.name, + roleCollectionAssignmentRef: item?.spec?.forProvider?.roleCollectionAssignmentRef?.name, + subaccountTrustConfigurationRef: item?.spec?.forProvider?.subaccountTrustConfigurationRef?.name, + globalaccountTrustConfigurationRef: item?.spec?.forProvider?.globalaccountTrustConfigurationRef?.name, + }; +} diff --git a/src/components/Graphs/types.ts b/src/components/Graphs/types.ts new file mode 100644 index 00000000..632bb3a8 --- /dev/null +++ b/src/components/Graphs/types.ts @@ -0,0 +1,67 @@ +export type ColorBy = 'provider' | 'source'; + +export interface Condition { + type: 'Ready' | 'Synced' | unknown; + status: 'True' | 'False'; + lastTransitionTime: string; +} + +export interface ManagedResourceItem { + kind: string; + metadata: { + name: string; + creationTimestamp: string; + }; + apiVersion?: string; + spec?: { + providerConfigRef?: { name: string }; + forProvider?: { + subaccountRef?: { name?: string }; + serviceManagerRef?: { name?: string }; + spaceRef?: { name?: string }; + orgRef?: { name?: string }; + directoryRef?: { name?: string }; + entitlementRef?: { name?: string }; + globalAccountRef?: { name?: string }; + orgRoleRef?: { name?: string }; + spaceMembersRef?: { name?: string }; + cloudFoundryEnvironmentRef?: { name?: string }; + kymaEnvironmentRef?: { name?: string }; + roleCollectionRef?: { name?: string }; + roleCollectionAssignmentRef?: { name?: string }; + subaccountTrustConfigurationRef?: { name?: string }; + globalaccountTrustConfigurationRef?: { name?: string }; + }; + cloudManagementRef?: { name: string }; + }; + status?: { + conditions?: Condition[]; + }; +} + +export interface ManagedResourceGroup { + items: ManagedResourceItem[]; +} + +export interface ProviderConfigItem { + metadata?: { name: string }; + apiVersion?: string; +} + +export interface ProviderConfig { + items?: ProviderConfigItem[]; +} + +export interface NodeData { + [key: string]: unknown; + id: string; + label: string; + type?: string; + providerConfigName: string; + providerType: string; + status: string; + parentId?: string; + extraRefs: string[]; + item: ManagedResourceItem; + onYamlClick: (item: ManagedResourceItem) => void; +} diff --git a/src/components/Graphs/useGraph.ts b/src/components/Graphs/useGraph.ts new file mode 100644 index 00000000..68593f22 --- /dev/null +++ b/src/components/Graphs/useGraph.ts @@ -0,0 +1,182 @@ +import { useMemo, useEffect, useState } from 'react'; +import { useApiResource, useProvidersConfigResource } from '../../lib/api/useApiResource'; +import { ManagedResourcesRequest } from '../../lib/api/types/crossplane/listManagedResources'; +import { resourcesInterval } from '../../lib/shared/constants'; +import { Node, Edge, Position, MarkerType } from '@xyflow/react'; +import dagre from 'dagre'; +import { NodeData, ManagedResourceGroup, ManagedResourceItem, ColorBy } from './types'; +import { extractRefs, generateColorMap, getStatusFromConditions, resolveProviderType } from './graphUtils'; + +const nodeWidth = 250; +const nodeHeight = 60; + +function buildGraph( + treeData: NodeData[], + colorBy: ColorBy, + colorMap: Record, +): { nodes: Node[]; edges: Edge[] } { + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); + dagreGraph.setGraph({ rankdir: 'TB' }); + + const nodeMap = new Map>(); + treeData.forEach((n) => { + const colorKey = colorBy === 'source' ? n.providerType : n.providerConfigName; + const node: Node = { + id: n.id, + type: 'custom', + data: { ...n }, + style: { + border: `2px solid ${colorMap[colorKey] || '#ccc'}`, + borderRadius: 8, + backgroundColor: '#fff', + width: nodeWidth, + height: nodeHeight, + }, + width: nodeWidth, + height: nodeHeight, + position: { x: 0, y: 0 }, + sourcePosition: Position.Bottom, + targetPosition: Position.Top, + }; + nodeMap.set(n.id, node); + dagreGraph.setNode(n.id, { width: nodeWidth, height: nodeHeight }); + }); + + const edgeList: Edge[] = []; + treeData.forEach((n) => { + if (n.parentId) { + dagreGraph.setEdge(n.parentId, n.id); + edgeList.push({ + id: `e-${n.parentId}-${n.id}`, + source: n.parentId, + target: n.id, + markerEnd: { type: MarkerType.ArrowClosed }, + }); + } + n.extraRefs?.forEach((refId) => { + if (nodeMap.has(refId)) { + dagreGraph.setEdge(refId, n.id); + edgeList.push({ + id: `e-${refId}-${n.id}`, + source: refId, + target: n.id, + markerEnd: { type: MarkerType.ArrowClosed }, + }); + } + }); + }); + + dagre.layout(dagreGraph); + nodeMap.forEach((node) => { + const pos = dagreGraph.node(node.id); + node.position = { x: pos.x - nodeWidth / 2, y: pos.y - nodeHeight / 2 }; + }); + + return { nodes: Array.from(nodeMap.values()), edges: edgeList }; +} + +export function useGraph(colorBy: ColorBy, onYamlClick: (item: ManagedResourceItem) => void) { + const { + data: managedResources, + isLoading: managedResourcesLoading, + error: managedResourcesError, + } = useApiResource(ManagedResourcesRequest, { + refreshInterval: resourcesInterval, + }); + const { + data: providerConfigsList, + isLoading: providerConfigsLoading, + error: providerConfigsError, + } = useProvidersConfigResource({ + refreshInterval: resourcesInterval, + }); + + const loading = managedResourcesLoading || providerConfigsLoading; + const error = managedResourcesError || providerConfigsError; + + const treeData = useMemo(() => { + if (!managedResources || !providerConfigsList) return []; + const allNodesMap = new Map(); + managedResources.forEach((group: ManagedResourceGroup) => { + group.items?.forEach((item: ManagedResourceItem) => { + const id = item?.metadata?.name; + const kind = item?.kind; + const providerConfigName = item?.spec?.providerConfigRef?.name ?? 'unknown'; + const providerType = resolveProviderType(providerConfigName, providerConfigsList); + const status = getStatusFromConditions(item?.status?.conditions); + + const { + subaccountRef, + serviceManagerRef, + spaceRef, + orgRef, + cloudManagementRef, + directoryRef, + entitlementRef, + globalAccountRef, + orgRoleRef, + spaceMembersRef, + cloudFoundryEnvironmentRef, + kymaEnvironmentRef, + roleCollectionRef, + roleCollectionAssignmentRef, + subaccountTrustConfigurationRef, + globalaccountTrustConfigurationRef, + } = extractRefs(item); + + const parentId = serviceManagerRef || subaccountRef; + const extraRefs = [ + spaceRef, + orgRef, + cloudManagementRef, + directoryRef, + entitlementRef, + globalAccountRef, + orgRoleRef, + spaceMembersRef, + cloudFoundryEnvironmentRef, + kymaEnvironmentRef, + roleCollectionRef, + roleCollectionAssignmentRef, + subaccountTrustConfigurationRef, + globalaccountTrustConfigurationRef, + ].filter(Boolean) as string[]; + + if (id) { + allNodesMap.set(id, { + id, + label: id, + type: kind, + providerConfigName, + providerType, + status, + parentId, + extraRefs, + item, + onYamlClick, + }); + } + }); + }); + return Array.from(allNodesMap.values()); + }, [managedResources, providerConfigsList, onYamlClick]); + + const colorMap = useMemo(() => generateColorMap(treeData, colorBy), [treeData, colorBy]); + + const [nodes, setNodes] = useState[]>([]); + const [edges, setEdges] = useState([]); + + useEffect(() => { + if (!treeData.length) { + setNodes([]); + setEdges([]); + return; + } + const { nodes, edges } = buildGraph(treeData, colorBy, colorMap); + setNodes(nodes); + setEdges(edges); + }, [treeData, colorBy, colorMap]); + + return { nodes, edges, colorMap, loading, error }; +} diff --git a/src/spaces/mcp/pages/McpPage.tsx b/src/spaces/mcp/pages/McpPage.tsx index 5637478f..9ef8c16b 100644 --- a/src/spaces/mcp/pages/McpPage.tsx +++ b/src/spaces/mcp/pages/McpPage.tsx @@ -24,6 +24,7 @@ import { Landscapers } from '../../../components/ControlPlane/Landscapers.tsx'; import { AuthProviderMcp } from '../auth/AuthContextMcp.tsx'; import { isNotFoundError } from '../../../lib/api/error.ts'; import { NotFoundBanner } from '../../../components/Ui/NotFoundBanner/NotFoundBanner.tsx'; +import Graph from '../../../components/Graphs/Graph.tsx'; export default function McpPage() { const { projectName, workspaceName, controlPlaneName } = useParams(); @@ -90,6 +91,14 @@ export default function McpPage() { /> } > + + +