diff --git a/package-lock.json b/package-lock.json index 3a928042..060c46d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@ui5/webcomponents-fiori": "^2.7.2", "@ui5/webcomponents-icons": "^2.7.2", "@ui5/webcomponents-react": "^2.7.2", + "@ui5/webcomponents-react-charts": "^2.13.1", "@xyflow/react": "^12.8.2", "clsx": "^2.1.1", "dagre": "^0.8.5", @@ -4972,6 +4973,12 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -4987,6 +4994,12 @@ "@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", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -4996,12 +5009,48 @@ "@types/d3-color": "*" } }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@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.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/d3-transition": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", @@ -5658,6 +5707,22 @@ } } }, + "node_modules/@ui5/webcomponents-react-charts": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@ui5/webcomponents-react-charts/-/webcomponents-react-charts-2.13.1.tgz", + "integrity": "sha512-WrnD0v+t2HsSeZJepOFmcHVQuD6RqqVOAw330x2tt+bnXQk7aWX/ph07vPD6NLsFZur8MzqhTI2y7U4y6S2+dw==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "2.1.1", + "react-content-loader": "7.1.1", + "recharts": "2.15.4" + }, + "peerDependencies": { + "@ui5/webcomponents-react": "~2.13.0", + "@ui5/webcomponents-react-base": "~2.13.0", + "react": "^18 || ^19" + } + }, "node_modules/@ui5/webcomponents-react/node_modules/react-table": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.8.0.tgz", @@ -7904,7 +7969,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/cypress": { @@ -8058,6 +8122,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -8098,6 +8174,15 @@ "node": ">=12" } }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -8110,6 +8195,31 @@ "node": ">=12" } }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-selection": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", @@ -8119,6 +8229,42 @@ "node": ">=12" } }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-timer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", @@ -8293,6 +8439,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -8443,6 +8595,16 @@ "node": ">=0.10.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -9479,6 +9641,12 @@ "dev": true, "license": "MIT" }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -9762,6 +9930,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -11294,6 +11471,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -13992,6 +14178,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-content-loader": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/react-content-loader/-/react-content-loader-7.1.1.tgz", + "integrity": "sha512-yNkqtd+15wXRLfDKZb5nTqDV2fPTG2kpUgeGRb+WBz43bU0j4DSGXETF0bnFr44fAoTPpm0Dya0WGdhpHSvtYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/react-dom": { "version": "19.1.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", @@ -14112,6 +14310,21 @@ "react-dom": ">=18" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-syntax-highlighter": { "version": "15.6.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.1.tgz", @@ -14145,6 +14358,22 @@ "react-dom": ">=0.16.8" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -14181,6 +14410,44 @@ "node": ">= 12.13.0" } }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -15535,6 +15802,12 @@ "node": ">=16" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -16294,6 +16567,28 @@ "extsprintf": "^1.2.0" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", diff --git a/package.json b/package.json index f091ef96..98792e7c 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@ui5/webcomponents-fiori": "^2.7.2", "@ui5/webcomponents-icons": "^2.7.2", "@ui5/webcomponents-react": "^2.7.2", + "@ui5/webcomponents-react-charts": "^2.13.1", "@xyflow/react": "^12.8.2", "clsx": "^2.1.1", "dagre": "^0.8.5", diff --git a/public/locales/en.json b/public/locales/en.json index 1518ed5a..ccea36bc 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -172,6 +172,7 @@ "defaultNamespaceInfo": "Leave empty to use default namespace", "serviceAccoutsGuide": "You can also use our Service Account Guide for more information." }, + "ProjectsPage": { "header": "Your instances of ManagedControlPlane", "projectHeader": "Project:" @@ -185,6 +186,7 @@ "McpPage": { "accessError": "Managed Control Plane does not have access information yet", "componentsTitle": "Components", + "overviewTitle": "Overview", "crossplaneTitle": "Crossplane", "gitOpsTitle": "GitOps", "landscapersTitle": "Landscapers", @@ -334,7 +336,12 @@ "synced": "Synced", "healthy": "Healthy", "installed": "Installed", - "none": "None" + "none": "None", + "creating": "Creating", + "unhealthy": "Unhealthy", + "progress": "Managed", + "remaining": "Remaining", + "active": "Active" }, "errors": { "installError": "Install error", @@ -366,5 +373,52 @@ "selectedComponents": "Selected Components", "pleaseSelectComponents": "Choose the components you want to add to your Managed Control Plane.", "cannotLoad": "Cannot load components list" + }, + "Hints": { + "CrossplaneHint": { + "title": "Crossplane", + "subtitle": "Managed Resources Readiness", + "activeStatus": "Active v", + "progressAvailable": "% Available", + "noResources": "No Resources", + "inactive": "Inactive", + "activate": "Activate", + "healthy": "Healthy", + "hoverContent": { + "totalResources": "Total Resources", + "healthy": "Healthy", + "creating": "Creating", + "failing": "Failing" + } + }, + "GitOpsHint": { + "title": "Flux", + "subtitle": "GitOps Progress", + "activeStatus": "Active v", + "progressAvailable": "% Available", + "noResources": "No Resources", + "inactive": "Inactive", + "activate": "Activate", + "managed": "Managed", + "hoverContent": { + "totalResources": "Total Resources", + "managed": "Managed", + "unmanaged": "Unmanaged" + } + }, + "VaultHint": { + "title": "Vault", + "subtitle": "Rotating Secrets Progress", + "activeStatus": "Active v", + "progressAvailable": "% Available", + "noResources": "No Resources", + "inactive": "Coming soon...", + "activate": "Activate" + }, + "common": { + "loading": "Loading...", + "errorLoadingResources": "Error loading resources", + "activate": "Activate" + } } } diff --git a/public/vault.png b/public/vault.png new file mode 100644 index 00000000..6a3bdae4 Binary files /dev/null and b/public/vault.png differ diff --git a/src/components/Graphs/useGraph.ts b/src/components/Graphs/useGraph.ts index 63542e40..92e72db6 100644 --- a/src/components/Graphs/useGraph.ts +++ b/src/components/Graphs/useGraph.ts @@ -110,7 +110,7 @@ export function useGraph(colorBy: ColorBy, onYamlClick: (item: ManagedResourceIt const status = statusCond?.status === 'True' ? 'OK' : 'ERROR'; let fluxName: string | undefined; - const labelsMap = (item.metadata as { labels?: Record }).labels; + const labelsMap = (item.metadata as unknown as { labels?: Record }).labels; if (labelsMap) { const key = Object.keys(labelsMap).find((k) => k.endsWith('/name')); if (key) fluxName = labelsMap[key]; diff --git a/src/components/HintsCardsRow/CardHoverContent/CardHoverContent.module.css b/src/components/HintsCardsRow/CardHoverContent/CardHoverContent.module.css new file mode 100644 index 00000000..59a63c81 --- /dev/null +++ b/src/components/HintsCardsRow/CardHoverContent/CardHoverContent.module.css @@ -0,0 +1,17 @@ +/* Card Hover Content Styles */ +.hoverContent { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + margin: 1rem 0; + overflow: visible; +} + +.chartContainer { + width: 100%; + height: 300px; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/components/HintsCardsRow/CardHoverContent/CardHoverContent.tsx b/src/components/HintsCardsRow/CardHoverContent/CardHoverContent.tsx new file mode 100644 index 00000000..ad0bd314 --- /dev/null +++ b/src/components/HintsCardsRow/CardHoverContent/CardHoverContent.tsx @@ -0,0 +1,129 @@ +import React, { useMemo } from 'react'; +import { RadarChart } from '@ui5/webcomponents-react-charts'; +import { LegendSection } from '../LegendSection/LegendSection'; +import { styles } from '../HintsCardsRow'; +import styles2 from './CardHoverContent.module.css'; +import cx from 'clsx'; + +export interface LegendItem { + label: string; + count: number; + color: string; +} + +export interface RadarDataPoint { + [key: string]: string | number; +} + +export interface RadarMeasure { + accessor: string; + color: string; + hideDataLabel?: boolean; + label: string; +} + +export interface RadarDimension { + accessor: string; +} + +export interface HoverContentProps { + enabled: boolean; + totalCount: number; + totalLabel: string; + legendItems: LegendItem[]; + radarDataset: RadarDataPoint[]; + radarDimensions: RadarDimension[]; + radarMeasures: RadarMeasure[]; + isLoading?: boolean; +} + +// Helper function to truncate labels to max 13 characters +const truncateLabel = (label: string, maxLength: number = 13): string => { + if (label.length <= maxLength) { + return label; + } + return label.substring(0, maxLength) + '...'; +}; + +export const HoverContent: React.FC = ({ + enabled, + totalCount, + totalLabel, + legendItems, + radarDataset, + radarDimensions, + radarMeasures, + isLoading = false, +}) => { + // Process the dataset to truncate labels + const processedDataset = useMemo(() => { + return radarDataset.map((dataPoint) => { + const processedDataPoint = { ...dataPoint }; + + // Truncate labels for each dimension accessor + radarDimensions.forEach((dimension) => { + const value = dataPoint[dimension.accessor]; + if (typeof value === 'string') { + processedDataPoint[dimension.accessor] = truncateLabel(value); + } + }); + + return processedDataPoint; + }); + }, [radarDataset, radarDimensions]); + + if (!enabled) { + return null; + } + + return ( +
+ +
+ {isLoading || radarDataset.length === 0 ? ( +
+ String(value || ''), + }, + ]} + measures={[ + { + accessor: 'users', + formatter: (value: string | number) => String(value || ''), + label: 'Users', + }, + { + accessor: 'sessions', + formatter: (value: string | number) => String(value || ''), + hideDataLabel: true, + label: 'Active Sessions', + }, + { + accessor: 'volume', + label: 'Vol.', + }, + ]} + style={{ width: '100%', height: '100%', minWidth: 280, minHeight: 280 }} + noLegend={true} + onClick={() => {}} + onDataPointClick={() => {}} + onLegendClick={() => {}} + /> +
+ ) : ( + + )} +
+
+ ); +}; diff --git a/src/components/HintsCardsRow/CardHoverContent/hoverCalculations.ts b/src/components/HintsCardsRow/CardHoverContent/hoverCalculations.ts new file mode 100644 index 00000000..e77d18fd --- /dev/null +++ b/src/components/HintsCardsRow/CardHoverContent/hoverCalculations.ts @@ -0,0 +1,85 @@ +import { ManagedResourceItem, Condition } from '../../../lib/shared/types'; + +export interface ResourceTypeStats { + type: string; + total: number; + healthy: number; + creating: number; + unhealthy: number; + healthyPercentage: number; + creatingPercentage: number; + unhealthyPercentage: number; +} + +export interface OverallStats { + total: number; + healthy: number; + creating: number; + unhealthy: number; +} + +export interface CrossplaneHoverData { + resourceTypeStats: ResourceTypeStats[]; + overallStats: OverallStats; +} + +/** + * Calculate comprehensive statistics for Crossplane hover content + */ +export const calculateCrossplaneHoverData = (allItems: ManagedResourceItem[]): CrossplaneHoverData => { + const typeStats: Record = {}; + let totalHealthy = 0; + let totalCreating = 0; + let totalUnhealthy = 0; + + allItems.forEach((item: ManagedResourceItem) => { + const type = item.kind || 'Unknown'; + + if (!typeStats[type]) { + typeStats[type] = { total: 0, healthy: 0, creating: 0, unhealthy: 0 }; + } + + typeStats[type].total++; + + const conditions = item.status?.conditions || []; + const ready = conditions.find((c: Condition) => c.type === 'Ready' && c.status === 'True'); + const synced = conditions.find((c: Condition) => c.type === 'Synced' && c.status === 'True'); + + if (ready && synced) { + typeStats[type].healthy++; + totalHealthy++; + } else if (synced && !ready) { + // Resource is synced but not ready - it's creating + typeStats[type].creating++; + totalCreating++; + } else { + // Resource has issues or is not synced + typeStats[type].unhealthy++; + totalUnhealthy++; + } + }); + + const resourceTypeStats: ResourceTypeStats[] = Object.keys(typeStats).map((type) => { + const stats = typeStats[type]; + return { + type, + total: stats.total, + healthy: stats.healthy, + creating: stats.creating, + unhealthy: stats.unhealthy, + healthyPercentage: Math.round((stats.healthy / stats.total) * 100), + creatingPercentage: Math.round((stats.creating / stats.total) * 100), + unhealthyPercentage: Math.round((stats.unhealthy / stats.total) * 100), + }; + }); + + return { + resourceTypeStats, + overallStats: { + total: allItems.length, + healthy: totalHealthy, + creating: totalCreating, + unhealthy: totalUnhealthy, + }, + }; +}; diff --git a/src/components/HintsCardsRow/GenericHintCard/GenericHintCard.module.css b/src/components/HintsCardsRow/GenericHintCard/GenericHintCard.module.css new file mode 100644 index 00000000..2625616c --- /dev/null +++ b/src/components/HintsCardsRow/GenericHintCard/GenericHintCard.module.css @@ -0,0 +1,47 @@ +.container { + position: relative; + width: 100%; +} + +.avatar { + width: 50px; + height: 50px; + border-radius: 50%; + background: transparent; + object-fit: cover; +} + +.contentContainer { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.5rem 0; +} + +.progressBarContainer { + display: flex; + gap: 8px; + width: 100%; + max-width: 500px; + padding: 0 0.5rem; +} + +.progressBar { + width: 100%; +} + +.activateButton { + position: absolute; + top: 16px; + right: 16px; + z-index: 2; + pointer-events: auto; +} + +.activateButtonClickable { + cursor: pointer; +} + +.activateButtonDefault { + cursor: default; +} diff --git a/src/components/HintsCardsRow/GenericHintCard/GenericHintCard.tsx b/src/components/HintsCardsRow/GenericHintCard/GenericHintCard.tsx new file mode 100644 index 00000000..dc6123e0 --- /dev/null +++ b/src/components/HintsCardsRow/GenericHintCard/GenericHintCard.tsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import { Card, CardHeader } from '@ui5/webcomponents-react'; +import { useTranslation } from 'react-i18next'; +import cx from 'clsx'; +import { MultiPercentageBar } from '../MultiPercentageBar/MultiPercentageBar'; +import { styles } from '../HintsCardsRow'; +import { HoverContent } from '../CardHoverContent/CardHoverContent'; +import styles2 from './GenericHintCard.module.css'; +import { GenericHintProps } from '../../../types/types'; + +export const GenericHintCard: React.FC = ({ + enabled = false, + version, + allItems = [], + isLoading, + error, + config, +}) => { + const { t } = useTranslation(); + const [hovered, setHovered] = useState(false); + + // Calculate segments and state using the provided calculator + const hintState = config.calculateSegments(allItems, isLoading || false, error, enabled, t); + + // Handle click navigation if scroll target is provided + const handleClick = + enabled && config.scrollTarget + ? () => { + const el = document.querySelector(config.scrollTarget!); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + : undefined; + + return ( +
+ + } + titleText={config.title} + subtitleText={config.subtitle} + interactive={enabled} + /> + } + className={cx({ + [styles['disabled']]: !enabled, + })} + onClick={handleClick} + onMouseEnter={enabled ? () => setHovered(true) : undefined} + onMouseLeave={enabled ? () => setHovered(false) : undefined} + > + {/* Disabled overlay */} + {!enabled &&
} + +
+
+ +
+
+ + {(() => { + const shouldShowHoverContent = enabled && hovered && config.calculateHoverData; + if (!shouldShowHoverContent) return null; + + const hoverData = config.calculateHoverData!(allItems, enabled, t); + const hasValidHoverData = !!hoverData; + + return hasValidHoverData ? : null; + })()} + +
+ ); +}; diff --git a/src/components/HintsCardsRow/GenericHintCard/genericHintConfigs.ts b/src/components/HintsCardsRow/GenericHintCard/genericHintConfigs.ts new file mode 100644 index 00000000..b44eebc9 --- /dev/null +++ b/src/components/HintsCardsRow/GenericHintCard/genericHintConfigs.ts @@ -0,0 +1,53 @@ +import { useTranslation } from 'react-i18next'; +import { GenericHintConfig } from '../../../types/types'; +import { + calculateCrossplaneSegments, + calculateGitOpsSegments, + calculateVaultSegments, + calculateCrossplaneHoverDataGeneric, + calculateGitOpsHoverDataGeneric, +} from '../../../utils/hintsCardsRowCalculations'; + +export const useCrossplaneHintConfig = (): GenericHintConfig => { + const { t } = useTranslation(); + + return { + title: t('Hints.CrossplaneHint.title'), + subtitle: t('Hints.CrossplaneHint.subtitle'), + iconSrc: '/crossplane-icon.png', + iconAlt: 'Crossplane', + scrollTarget: '.crossplane-table-element', + calculateSegments: (allItems, isLoading, error, enabled) => + calculateCrossplaneSegments(allItems, isLoading, error, enabled, t), + calculateHoverData: (allItems, enabled) => calculateCrossplaneHoverDataGeneric(allItems, enabled, t), + }; +}; + +export const useGitOpsHintConfig = (): GenericHintConfig => { + const { t } = useTranslation(); + + return { + title: t('Hints.GitOpsHint.title'), + subtitle: t('Hints.GitOpsHint.subtitle'), + iconSrc: '/flux.png', + iconAlt: 'Flux', + scrollTarget: '.cp-page-section-gitops', + calculateSegments: (allItems, isLoading, error, enabled) => + calculateGitOpsSegments(allItems, isLoading, error, enabled, t), + calculateHoverData: (allItems, enabled) => calculateGitOpsHoverDataGeneric(allItems, enabled, t), + }; +}; + +export const useVaultHintConfig = (): GenericHintConfig => { + const { t } = useTranslation(); + + return { + title: t('Hints.VaultHint.title'), + subtitle: t('Hints.VaultHint.subtitle'), + iconSrc: '/vault.png', + iconAlt: 'Vault', + iconStyle: { borderRadius: '0' }, // Vault icon should not be rounded + calculateSegments: (allItems, isLoading, error, enabled) => + calculateVaultSegments(allItems, isLoading, error, enabled, t), + }; +}; diff --git a/src/components/HintsCardsRow/HintsCardsRow.module.css b/src/components/HintsCardsRow/HintsCardsRow.module.css new file mode 100644 index 00000000..9fc831c9 --- /dev/null +++ b/src/components/HintsCardsRow/HintsCardsRow.module.css @@ -0,0 +1,66 @@ +.disabled { + position: relative; +} + +.disabledOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.6); + backdrop-filter: grayscale(0.9) blur(0.5px); + border-radius: inherit; + z-index: 1; + pointer-events: none; +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .disabledOverlay { + background: rgba(0, 0, 0, 0.4); + } + + .chartBackground { + background-color: #2a2a2a; + } + + .chartLabel { + color: #ffffff; + } +} + +/* Also check for UI5 theme variables for dark themes */ +[data-ui5-theme-root*="dark"] .disabledOverlay, +[data-ui5-theme*="dark"] .disabledOverlay { + background: rgba(0, 0, 0, 0.4); +} + +/* Hover Content Animation */ +.hoverContent { + overflow: hidden; + transition: all 0.3s ease-in-out; + animation: expandIn 0.3s ease-in-out; +} + +@keyframes expandIn { + from { + max-height: 0; + opacity: 0; + transform: scaleY(0); + transform-origin: top; + } + to { + max-height: 500px; + opacity: 1; + transform: scaleY(1); + transform-origin: top; + } +} + +.hoverContentLoading { + display: flex; + justify-content: center; + align-items: center; + height: 300px; +} \ No newline at end of file diff --git a/src/components/HintsCardsRow/HintsCardsRow.tsx b/src/components/HintsCardsRow/HintsCardsRow.tsx new file mode 100644 index 00000000..de68d23c --- /dev/null +++ b/src/components/HintsCardsRow/HintsCardsRow.tsx @@ -0,0 +1,93 @@ +import { FlexBox, FlexBoxDirection } from '@ui5/webcomponents-react'; +import { GenericHintCard } from './GenericHintCard/GenericHintCard'; +import { useCrossplaneHintConfig, useGitOpsHintConfig, useVaultHintConfig } from './GenericHintCard/genericHintConfigs'; + +import { ControlPlaneType } from '../../lib/api/types/crate/controlPlanes'; +import { ManagedResourcesRequest, ManagedResourcesResponse } from '../../lib/api/types/crossplane/listManagedResources'; +import { resourcesInterval } from '../../lib/shared/constants'; +import { useApiResource } from '../../lib/api/useApiResource'; +import { ManagedResourceItem } from '../../lib/shared/types'; +import React, { useMemo } from 'react'; + +interface HintsProps { + mcp: ControlPlaneType; +} + +// Export styles for use by hint components +export { default as styles } from './HintsCardsRow.module.css'; + +// Utility function to flatten managed resources +export const flattenManagedResources = (managedResources: ManagedResourcesResponse): ManagedResourceItem[] => { + if (!managedResources || !Array.isArray(managedResources)) return []; + + return managedResources + .filter((managedResource) => managedResource?.items) + .flatMap((managedResource) => managedResource.items || []); +}; + +const HintsCardsRow: React.FC = ({ mcp }) => { + const { + data: managedResources, + isLoading: managedResourcesLoading, + error: managedResourcesError, + } = useApiResource(ManagedResourcesRequest, { + refreshInterval: resourcesInterval, + }); + + // Flatten all managed resources once and pass to components + const allItems = useMemo( + () => flattenManagedResources(managedResources ?? ([] as unknown as ManagedResourcesResponse)), + [managedResources], + ); + + // Get hint configurations + const crossplaneConfig = useCrossplaneHintConfig(); + const gitOpsConfig = useGitOpsHintConfig(); + const vaultConfig = useVaultHintConfig(); + + return ( + + + + + + ); +}; + +export default HintsCardsRow; diff --git a/src/components/HintsCardsRow/LegendSection/LegendSection.module.css b/src/components/HintsCardsRow/LegendSection/LegendSection.module.css new file mode 100644 index 00000000..63e40530 --- /dev/null +++ b/src/components/HintsCardsRow/LegendSection/LegendSection.module.css @@ -0,0 +1,71 @@ +/* Legend Section Styles */ +.legendSection { + color: var(--sapTextColor, #374151); + margin-bottom: 1rem; + width: 80%; + max-width: 400px; + align-self: center; +} + +.legendTitle { + color: var(--sapTitleColor, var(--sapTextColor, #374151)); + font-size: 0.95rem; + font-weight: 600; + margin-bottom: 0.5rem; + text-align: left; +} + +.legendItem { + color: var(--sapContent_LabelColor, #6b7280); + font-size: 0.85rem; +} + +.legendItemsContainer { + display: flex; + gap: 0.75rem; + align-items: center; + justify-content: flex-start; +} + +.legendItemWrapper { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.legendDot { + width: 10px; + height: 10px; + border-radius: 50%; +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .legendSection { + color: #ffffff; + } + + .legendTitle { + color: #ffffff; + } + + .legendItem { + color: #cccccc; + } +} + +/* Also check for UI5 theme variables for dark themes */ +[data-ui5-theme-root*="dark"] .legendSection, +[data-ui5-theme*="dark"] .legendSection { + color: #ffffff; +} + +[data-ui5-theme-root*="dark"] .legendTitle, +[data-ui5-theme*="dark"] .legendTitle { + color: #ffffff; +} + +[data-ui5-theme-root*="dark"] .legendItem, +[data-ui5-theme*="dark"] .legendItem { + color: #cccccc; +} diff --git a/src/components/HintsCardsRow/LegendSection/LegendSection.tsx b/src/components/HintsCardsRow/LegendSection/LegendSection.tsx new file mode 100644 index 00000000..8c308234 --- /dev/null +++ b/src/components/HintsCardsRow/LegendSection/LegendSection.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import styles from './LegendSection.module.css'; + +interface LegendItem { + label: string; + count: number; + color: string; +} + +interface LegendSectionProps { + title: string; + items: LegendItem[]; + style?: React.CSSProperties; +} + +export const LegendSection: React.FC = ({ title, items, style }) => { + return ( +
+
{title}
+
+ {items.map((item, index) => ( +
+
+ + {item.count} {item.label} + +
+ ))} +
+
+ ); +}; diff --git a/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.module.css b/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.module.css new file mode 100644 index 00000000..153b8ca7 --- /dev/null +++ b/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.module.css @@ -0,0 +1,183 @@ +/* CSS Variables for customization */ +.container { + --animation-duration: 600ms; + --bar-width: 80%; + --bar-max-width: 400px; + --bar-height: 8px; + --gap: 2px; + --border-radius: 6px; + --label-font-size: 0.875rem; + --background-color: var(--sapBackgroundColor, #fafafa); + + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: 100%; + padding-bottom: 8px; + + /* Initial animation */ + animation: fadeInUp var(--animation-duration) ease-out; +} + +/* Respect user's motion preferences */ +@media (prefers-reduced-motion: reduce) { + .container { + animation: fadeIn var(--animation-duration) ease-out; + } + + .segment { + transition: none !important; + } + + .waveOverlay { + animation: none !important; + } + + .percentage { + animation: none !important; + } +} + +/* Label styling */ +.labelContainer { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: left; + width: var(--bar-width); +} + +.labelGroup { + display: flex; + align-items: center; + gap: 6px; +} + +.label, +.percentage { + font-size: var(--label-font-size); + font-weight: 400; + color: var(--sapTextColor, #374151); + transition: color 0.3s ease, font-weight 0.3s ease; +} + +.label.healthy, +.percentage.healthy { + color: green; + font-weight: 700; +} + +/* Bar container */ +.barContainer { + display: flex; + gap: var(--gap); + width: var(--bar-width); + max-width: var(--bar-max-width); + background-color: var(--background-color); + border-radius: var(--border-radius); + padding: 2px; + overflow: hidden; +} + +/* Individual segments */ +.segment { + flex: var(--segment-percentage); + min-width: 10px; + background-color: var(--segment-color); + border-radius: var(--border-radius); + height: var(--bar-height); + position: relative; + overflow: hidden; + + /* Smooth transitions for flex changes */ + transition: flex var(--animation-duration) cubic-bezier(0.4, 0, 0.2, 1); + + /* Initial state for animation */ + transform: scaleX(0); + transform-origin: left; + animation: growWidth var(--animation-duration) cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +/* Stagger the segment animations */ +.segment:nth-child(1) { animation-delay: 0ms; } +.segment:nth-child(2) { animation-delay: 100ms; } +.segment:nth-child(3) { animation-delay: 200ms; } +.segment:nth-child(4) { animation-delay: 300ms; } +.segment:nth-child(5) { animation-delay: 400ms; } + +/* Wave animation overlay */ +.waveOverlay { + position: absolute; + top: 0; + left: -80%; + width: 80%; + height: 100%; + background: linear-gradient(90deg, + transparent 0%, + rgba(255, 255, 255, 0.15) 25%, + rgba(255, 255, 255, 0.25) 50%, + rgba(255, 255, 255, 0.15) 75%, + transparent 100%); + border-radius: var(--border-radius); + pointer-events: none; + + /* Continuous wave animation */ + animation: wave 3s ease-in-out infinite; + animation-delay: var(--animation-duration); +} + +/* Keyframe animations */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes growWidth { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + +@keyframes wave { + 0% { + left: -80%; + } + 50% { + left: 100%; + } + 100% { + left: -80%; + } +} + +/* Theme Support */ +[data-ui5-theme-root*="dark"] .container, +[data-ui5-theme*="dark"] .container { + --background-color: #2a2a2a; +} + +[data-ui5-theme-root*="dark"] .label, +[data-ui5-theme*="dark"] .label, +[data-ui5-theme-root*="dark"] .percentage, +[data-ui5-theme*="dark"] .percentage { + color: #ffffff; +} diff --git a/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.spec.tsx b/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.spec.tsx new file mode 100644 index 00000000..31b40dfa --- /dev/null +++ b/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.spec.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; + +// Pure function tests for MultiPercentageBar utility logic +describe('MultiPercentageBar utilities', () => { + interface PercentageSegment { + percentage: number; + color: string; + label: string; + } + + // Helper function to filter non-zero segments (simulating component logic) + const filterNonZeroSegments = (segments: PercentageSegment[], showOnlyNonZero: boolean) => { + return showOnlyNonZero ? segments.filter((segment) => segment.percentage > 0) : segments; + }; + + // Helper function to validate segment percentages + const validateSegmentPercentages = (segments: PercentageSegment[]) => { + const total = segments.reduce((sum, segment) => sum + segment.percentage, 0); + return { + total, + isValid: total <= 100, + segments: segments.length, + }; + }; + + describe('filterNonZeroSegments', () => { + it('filters out zero percentage segments when showOnlyNonZero is true', () => { + const segments: PercentageSegment[] = [ + { percentage: 50, color: '#28a745', label: 'Healthy' }, + { percentage: 0, color: '#e9730c', label: 'Creating' }, + { percentage: 50, color: '#d22020ff', label: 'Unhealthy' }, + ]; + + const result = filterNonZeroSegments(segments, true); + + expect(result).toHaveLength(2); + expect(result[0].label).toBe('Healthy'); + expect(result[1].label).toBe('Unhealthy'); + }); + + it('keeps all segments when showOnlyNonZero is false', () => { + const segments: PercentageSegment[] = [ + { percentage: 50, color: '#28a745', label: 'Healthy' }, + { percentage: 0, color: '#e9730c', label: 'Creating' }, + { percentage: 50, color: '#d22020ff', label: 'Unhealthy' }, + ]; + + const result = filterNonZeroSegments(segments, false); + + expect(result).toHaveLength(3); + expect(result[1].percentage).toBe(0); + }); + + it('handles empty segments array', () => { + const result = filterNonZeroSegments([], true); + expect(result).toHaveLength(0); + }); + + it('handles all zero segments', () => { + const segments: PercentageSegment[] = [ + { percentage: 0, color: '#28a745', label: 'Healthy' }, + { percentage: 0, color: '#e9730c', label: 'Creating' }, + ]; + + const result = filterNonZeroSegments(segments, true); + expect(result).toHaveLength(0); + }); + }); + + describe('validateSegmentPercentages', () => { + it('validates segments that sum to 100%', () => { + const segments: PercentageSegment[] = [ + { percentage: 60, color: '#28a745', label: 'Healthy' }, + { percentage: 40, color: '#d22020ff', label: 'Unhealthy' }, + ]; + + const result = validateSegmentPercentages(segments); + + expect(result.total).toBe(100); + expect(result.isValid).toBe(true); + expect(result.segments).toBe(2); + }); + + it('detects segments that sum to more than 100%', () => { + const segments: PercentageSegment[] = [ + { percentage: 60, color: '#28a745', label: 'Healthy' }, + { percentage: 50, color: '#d22020ff', label: 'Unhealthy' }, + ]; + + const result = validateSegmentPercentages(segments); + + expect(result.total).toBe(110); + expect(result.isValid).toBe(false); + }); + + it('handles segments that sum to less than 100%', () => { + const segments: PercentageSegment[] = [ + { percentage: 30, color: '#28a745', label: 'Healthy' }, + { percentage: 20, color: '#d22020ff', label: 'Unhealthy' }, + ]; + + const result = validateSegmentPercentages(segments); + + expect(result.total).toBe(50); + expect(result.isValid).toBe(true); + }); + + it('handles empty segments', () => { + const result = validateSegmentPercentages([]); + + expect(result.total).toBe(0); + expect(result.isValid).toBe(true); + expect(result.segments).toBe(0); + }); + }); +}); diff --git a/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.tsx b/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.tsx new file mode 100644 index 00000000..e1824653 --- /dev/null +++ b/src/components/HintsCardsRow/MultiPercentageBar/MultiPercentageBar.tsx @@ -0,0 +1,111 @@ +import React, { useMemo } from 'react'; +import styles from './MultiPercentageBar.module.css'; + +interface PercentageSegment { + percentage: number; + color: string; + label: string; +} + +interface MultiPercentageBarProps { + segments: PercentageSegment[]; + label?: string; + showOnlyNonZero?: boolean; + barWidth?: string; + barMaxWidth?: string; + barHeight?: string; + showLabels?: boolean; + showPercentage?: boolean; // Control whether to show percentage number + isHealthy?: boolean; // Control whether to style the text as healthy (green) + labelFontSize?: string; + gap?: string; + borderRadius?: string; + backgroundColor?: string; + className?: string; + style?: React.CSSProperties; + animationDuration?: number; // Animation duration in ms (for CSS custom property) +} + +export const MultiPercentageBar: React.FC = ({ + segments, + label, + showOnlyNonZero = true, + barWidth = '80%', + barMaxWidth = '400px', + barHeight = '8px', + showLabels = true, + showPercentage = true, + isHealthy, + labelFontSize = '0.875rem', + gap = '2px', + borderRadius = '6px', + backgroundColor, + className, + style, + animationDuration = 400, // Match CSS default +}) => { + // Memoize filtered segments + const filteredSegments = useMemo(() => { + return showOnlyNonZero ? segments.filter((segment) => segment.percentage > 0) : segments; + }, [segments, showOnlyNonZero]); + + if (filteredSegments.length === 0) { + return null; + } + + const primaryPercentage = filteredSegments[0]?.percentage || 0; + const displayLabel = label || 'Healthy'; + const allHealthy = isHealthy !== undefined ? isHealthy : primaryPercentage === 100; + + return ( +
+ {/* Label */} + {showLabels && ( +
+
+ {showPercentage && ( + {primaryPercentage}% + )} + {displayLabel} +
+
+ )} + + {/* Progress bar */} +
+ {filteredSegments.map((segment, index) => ( +
+ {/* Wave animation overlay */} +
+
+ ))} +
+
+ ); +}; + +export type { PercentageSegment, MultiPercentageBarProps }; diff --git a/src/lib/shared/types.ts b/src/lib/shared/types.ts index e8fe763e..798a30ec 100644 --- a/src/lib/shared/types.ts +++ b/src/lib/shared/types.ts @@ -51,6 +51,7 @@ export type ManagedResourceItem = { metadata: { name: string; creationTimestamp: string; + labels: []; }; apiVersion?: string; spec?: { diff --git a/src/spaces/mcp/pages/McpPage.tsx b/src/spaces/mcp/pages/McpPage.tsx index a4c563e9..64870281 100644 --- a/src/spaces/mcp/pages/McpPage.tsx +++ b/src/spaces/mcp/pages/McpPage.tsx @@ -26,6 +26,7 @@ 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'; +import HintsCardsRow from '../../../components/HintsCardsRow/HintsCardsRow.tsx'; export default function McpPage() { const { projectName, workspaceName, controlPlaneName } = useParams(); @@ -92,6 +93,14 @@ export default function McpPage() { /> } > + + + string, + ): GenericHintState; +} + +export interface HoverDataCalculator { + ( + allItems: ManagedResourceItem[], + enabled: boolean, + t: (key: string) => string, + ): Omit | null; +} + +export interface GenericHintState { + segments: PercentageSegment[]; + label: string; + showPercentage: boolean; + isHealthy: boolean; + showOnlyNonZero?: boolean; +} + +export interface GenericHintConfig { + title: string; + subtitle: string; + iconSrc: string; + iconAlt: string; + iconStyle?: React.CSSProperties; + scrollTarget?: string; + calculateSegments: GenericHintSegmentCalculator; + calculateHoverData?: HoverDataCalculator; + renderHoverContent?: (allItems: ManagedResourceItem[], enabled: boolean) => ReactNode; +} + +export interface GenericHintProps { + enabled?: boolean; + version?: string; + onActivate?: () => void; + allItems?: ManagedResourceItem[]; + isLoading?: boolean; + error?: APIError; + config: GenericHintConfig; +} diff --git a/src/utils/hintsCardsRowCalculations.spec.ts b/src/utils/hintsCardsRowCalculations.spec.ts new file mode 100644 index 00000000..d5c1df9f --- /dev/null +++ b/src/utils/hintsCardsRowCalculations.spec.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + calculateCrossplaneSegments, + calculateGitOpsSegments, + calculateVaultSegments, + calculateCrossplaneHoverData, + HINT_COLORS, +} from './hintsCardsRowCalculations'; +import { ManagedResourceItem, Condition } from '../lib/shared/types'; +import { APIError } from '../lib/api/error'; + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('calculations', () => { + // Mock translation function + const mockT = (key: string) => key; + + const createManagedResourceItem = ( + kind: string = 'TestResource', + conditions: Condition[] = [], + ): ManagedResourceItem => ({ + kind, + metadata: { + name: 'test-resource', + creationTimestamp: '2023-01-01T00:00:00Z', + labels: [], + }, + status: { + conditions, + }, + }); + + const createCondition = (type: string, status: 'True' | 'False'): Condition => ({ + type, + status, + lastTransitionTime: '2023-01-01T00:00:00Z', + }); + + describe('calculateCrossplaneSegments', () => { + it('returns loading state when isLoading is true', () => { + const result = calculateCrossplaneSegments([], true, undefined, true, mockT); + + expect(result.segments).toHaveLength(1); + expect(result.segments[0].color).toBe(HINT_COLORS.inactive); + expect(result.showPercentage).toBe(false); + expect(result.isHealthy).toBe(false); + }); + + it('returns error state when error is provided', () => { + const error = new APIError('Test error', 500); + const result = calculateCrossplaneSegments([], false, error, true, mockT); + + expect(result.segments).toHaveLength(1); + expect(result.segments[0].color).toBe(HINT_COLORS.unhealthy); + expect(result.showPercentage).toBe(false); + expect(result.isHealthy).toBe(false); + }); + + it('returns inactive state when enabled is false', () => { + const result = calculateCrossplaneSegments([], false, undefined, false, mockT); + + expect(result.segments).toHaveLength(1); + expect(result.segments[0].color).toBe(HINT_COLORS.inactive); + expect(result.showPercentage).toBe(false); + expect(result.isHealthy).toBe(false); + }); + + it('returns no resources state when items array is empty', () => { + const result = calculateCrossplaneSegments([], false, undefined, true, mockT); + + expect(result.segments).toHaveLength(1); + expect(result.segments[0].color).toBe(HINT_COLORS.inactive); + expect(result.showPercentage).toBe(false); + expect(result.isHealthy).toBe(false); + }); + + it('correctly calculates segments for healthy resources', () => { + const healthyItems = [ + createManagedResourceItem('Pod', [createCondition('Ready', 'True'), createCondition('Synced', 'True')]), + createManagedResourceItem('Service', [createCondition('Ready', 'True'), createCondition('Synced', 'True')]), + ]; + + const result = calculateCrossplaneSegments(healthyItems, false, undefined, true, mockT); + + expect(result.segments).toHaveLength(3); + expect(result.segments[0].percentage).toBe(100); // healthy + expect(result.segments[1].percentage).toBe(0); // creating + expect(result.segments[2].percentage).toBe(0); // unhealthy + expect(result.isHealthy).toBe(true); + expect(result.showPercentage).toBe(true); + }); + + it('correctly calculates segments for creating resources', () => { + const creatingItems = [ + createManagedResourceItem('Pod', [createCondition('Ready', 'False'), createCondition('Synced', 'True')]), + ]; + + const result = calculateCrossplaneSegments(creatingItems, false, undefined, true, mockT); + + expect(result.segments).toHaveLength(3); + expect(result.segments[0].percentage).toBe(0); // healthy + expect(result.segments[1].percentage).toBe(100); // creating + expect(result.segments[2].percentage).toBe(0); // unhealthy + expect(result.isHealthy).toBe(false); + }); + + it('correctly calculates segments for unhealthy resources', () => { + const unhealthyItems = [ + createManagedResourceItem('Pod', [createCondition('Ready', 'False'), createCondition('Synced', 'False')]), + ]; + + const result = calculateCrossplaneSegments(unhealthyItems, false, undefined, true, mockT); + + expect(result.segments).toHaveLength(3); + expect(result.segments[0].percentage).toBe(0); // healthy + expect(result.segments[1].percentage).toBe(0); // creating + expect(result.segments[2].percentage).toBe(100); // unhealthy + expect(result.isHealthy).toBe(false); + }); + + it('correctly calculates mixed resource states', () => { + const mixedItems = [ + createManagedResourceItem('Pod1', [createCondition('Ready', 'True'), createCondition('Synced', 'True')]), + createManagedResourceItem('Pod2', [createCondition('Ready', 'False'), createCondition('Synced', 'True')]), + createManagedResourceItem('Pod3', [createCondition('Ready', 'False'), createCondition('Synced', 'False')]), + createManagedResourceItem('Pod4', []), // No conditions = unhealthy + ]; + + const result = calculateCrossplaneSegments(mixedItems, false, undefined, true, mockT); + + expect(result.segments).toHaveLength(3); + expect(result.segments[0].percentage).toBe(25); // 1/4 healthy + expect(result.segments[1].percentage).toBe(25); // 1/4 creating + expect(result.segments[2].percentage).toBe(50); // 2/4 unhealthy + expect(result.isHealthy).toBe(false); + }); + }); + + describe('calculateGitOpsSegments', () => { + it('returns loading state when isLoading is true', () => { + const result = calculateGitOpsSegments([], true, undefined, true, mockT); + + expect(result.segments).toHaveLength(1); + expect(result.segments[0].color).toBe(HINT_COLORS.inactive); + expect(result.showPercentage).toBe(false); + expect(result.isHealthy).toBe(false); + }); + + it('correctly calculates progress for flux-labeled resources', () => { + const itemWithFluxLabel = createManagedResourceItem('Pod'); + itemWithFluxLabel.metadata.labels = { + 'kustomize.toolkit.fluxcd.io/name': 'test-app', + } as any; + + const itemWithoutFluxLabel = createManagedResourceItem('Service'); + + const items = [itemWithFluxLabel, itemWithoutFluxLabel]; + const result = calculateGitOpsSegments(items, false, undefined, true, mockT); + + expect(result.segments).toHaveLength(2); + expect(result.segments[0].percentage).toBe(50); // 1/2 with flux label + expect(result.segments[1].percentage).toBe(50); // 1/2 remaining + expect(result.isHealthy).toBe(false); // < 70% + }); + + it('marks as healthy when progress >= 70%', () => { + const items = Array.from({ length: 10 }, (_, i) => { + const item = createManagedResourceItem(`Pod${i}`); + if (i < 8) { + // 8/10 = 80% + item.metadata.labels = { + 'kustomize.toolkit.fluxcd.io/name': 'test-app', + } as any; + } + return item; + }); + + const result = calculateGitOpsSegments(items, false, undefined, true, mockT); + + expect(result.segments[0].percentage).toBe(80); + expect(result.segments[0].color).toBe(HINT_COLORS.healthy); + expect(result.isHealthy).toBe(true); + }); + + it('uses progress color when progress < 70%', () => { + const items = Array.from({ length: 10 }, (_, i) => { + const item = createManagedResourceItem(`Pod${i}`); + if (i < 5) { + // 5/10 = 50% + item.metadata.labels = { + 'kustomize.toolkit.fluxcd.io/name': 'test-app', + } as any; + } + return item; + }); + + const result = calculateGitOpsSegments(items, false, undefined, true, mockT); + + expect(result.segments[0].percentage).toBe(50); + expect(result.segments[0].color).toBe(HINT_COLORS.progress); + expect(result.isHealthy).toBe(false); + }); + }); + + describe('calculateVaultSegments', () => { + it('returns active state when resources exist', () => { + const items = [createManagedResourceItem('Secret')]; + const result = calculateVaultSegments(items, false, undefined, true, mockT); + + expect(result.segments).toHaveLength(1); + expect(result.segments[0].percentage).toBe(100); + expect(result.segments[0].color).toBe(HINT_COLORS.healthy); + expect(result.isHealthy).toBe(true); + expect(result.showPercentage).toBe(true); + }); + + it('returns inactive state when no resources exist', () => { + const result = calculateVaultSegments([], false, undefined, true, mockT); + + expect(result.segments).toHaveLength(1); + expect(result.segments[0].percentage).toBe(100); + expect(result.segments[0].color).toBe(HINT_COLORS.inactive); + expect(result.isHealthy).toBe(false); + }); + }); + + describe('calculateCrossplaneHoverData', () => { + it('calculates statistics by resource type', () => { + const items = [ + createManagedResourceItem('Pod', [createCondition('Ready', 'True'), createCondition('Synced', 'True')]), + createManagedResourceItem('Pod', [createCondition('Ready', 'False'), createCondition('Synced', 'True')]), + createManagedResourceItem('Service', [createCondition('Ready', 'True'), createCondition('Synced', 'True')]), + createManagedResourceItem('Service', [createCondition('Ready', 'False'), createCondition('Synced', 'False')]), + ]; + + const result = calculateCrossplaneHoverData(items); + + expect(result.resourceTypeStats).toHaveLength(2); + + const podStats = result.resourceTypeStats.find((s) => s.type === 'Pod'); + expect(podStats).toBeDefined(); + expect(podStats!.total).toBe(2); + expect(podStats!.healthy).toBe(1); + expect(podStats!.creating).toBe(1); + expect(podStats!.unhealthy).toBe(0); + expect(podStats!.healthyPercentage).toBe(50); + + const serviceStats = result.resourceTypeStats.find((s) => s.type === 'Service'); + expect(serviceStats).toBeDefined(); + expect(serviceStats!.total).toBe(2); + expect(serviceStats!.healthy).toBe(1); + expect(serviceStats!.creating).toBe(0); + expect(serviceStats!.unhealthy).toBe(1); + expect(serviceStats!.unhealthyPercentage).toBe(50); + }); + + it('calculates overall statistics correctly', () => { + const items = [ + createManagedResourceItem('Pod', [createCondition('Ready', 'True'), createCondition('Synced', 'True')]), + createManagedResourceItem('Service', [createCondition('Ready', 'False'), createCondition('Synced', 'True')]), + createManagedResourceItem('ConfigMap', [createCondition('Ready', 'False'), createCondition('Synced', 'False')]), + ]; + + const result = calculateCrossplaneHoverData(items); + + expect(result.overallStats.total).toBe(3); + expect(result.overallStats.healthy).toBe(1); + expect(result.overallStats.creating).toBe(1); + expect(result.overallStats.unhealthy).toBe(1); + }); + + it('handles resources without kind', () => { + const itemWithoutKind = createManagedResourceItem('', [ + createCondition('Ready', 'True'), + createCondition('Synced', 'True'), + ]); + // @ts-ignore - testing edge case + itemWithoutKind.kind = undefined; + + const result = calculateCrossplaneHoverData([itemWithoutKind]); + + expect(result.resourceTypeStats).toHaveLength(1); + expect(result.resourceTypeStats[0].type).toBe('Unknown'); + expect(result.resourceTypeStats[0].healthy).toBe(1); + }); + + it('handles resources without conditions', () => { + const itemWithoutConditions = createManagedResourceItem('Pod'); + delete itemWithoutConditions.status; + + const result = calculateCrossplaneHoverData([itemWithoutConditions]); + + expect(result.resourceTypeStats).toHaveLength(1); + expect(result.resourceTypeStats[0].type).toBe('Pod'); + expect(result.resourceTypeStats[0].unhealthy).toBe(1); + expect(result.overallStats.unhealthy).toBe(1); + }); + + it('returns empty arrays for no items', () => { + const result = calculateCrossplaneHoverData([]); + + expect(result.resourceTypeStats).toHaveLength(0); + expect(result.overallStats.total).toBe(0); + expect(result.overallStats.healthy).toBe(0); + expect(result.overallStats.creating).toBe(0); + expect(result.overallStats.unhealthy).toBe(0); + }); + }); +}); diff --git a/src/utils/hintsCardsRowCalculations.ts b/src/utils/hintsCardsRowCalculations.ts new file mode 100644 index 00000000..2e9f9a89 --- /dev/null +++ b/src/utils/hintsCardsRowCalculations.ts @@ -0,0 +1,449 @@ +import { ManagedResourceItem, Condition } from '../lib/shared/types'; +import { APIError } from '../lib/api/error'; +import { GenericHintSegmentCalculator, GenericHintState, HoverDataCalculator } from '../types/types'; + +import { HoverContentProps } from '../components/HintsCardsRow/CardHoverContent/CardHoverContent'; + +/** + * Common colors used across all hints + */ +export const HINT_COLORS = { + healthy: '#28a745', + creating: '#0874f4', + unhealthy: '#d22020ff', + inactive: '#e9e9e9ff', + managed: '#28a745', + progress: '#fd7e14', +} as const; + +/** + * Crossplane-specific segment calculation + */ +export const calculateCrossplaneSegments: GenericHintSegmentCalculator = ( + allItems: ManagedResourceItem[], + isLoading: boolean, + error: APIError | undefined, + enabled: boolean, + t: (key: string) => string, +): GenericHintState => { + if (isLoading) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.common.loading') }], + label: t('Hints.common.loading'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + if (error) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.unhealthy, label: t('Hints.common.errorLoadingResources') }], + label: t('Hints.common.errorLoadingResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + if (!enabled) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.CrossplaneHint.inactive') }], + label: t('Hints.CrossplaneHint.inactive'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + const totalCount = allItems.length; + + if (totalCount === 0) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.CrossplaneHint.noResources') }], + label: t('Hints.CrossplaneHint.noResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + // Calculate health statistics + const healthyCount = allItems.filter((item: ManagedResourceItem) => { + const conditions = item.status?.conditions || []; + const ready = conditions.find((c: Condition) => c.type === 'Ready' && c.status === 'True'); + const synced = conditions.find((c: Condition) => c.type === 'Synced' && c.status === 'True'); + return !!ready && !!synced; + }).length; + + const creatingCount = allItems.filter((item: ManagedResourceItem) => { + const conditions = item.status?.conditions || []; + const ready = conditions.find((c: Condition) => c.type === 'Ready' && c.status === 'True'); + const synced = conditions.find((c: Condition) => c.type === 'Synced' && c.status === 'True'); + return !!synced && !ready; + }).length; + + const unhealthyCount = totalCount - healthyCount - creatingCount; + const healthyPercentage = Math.round((healthyCount / totalCount) * 100); + const creatingPercentage = Math.round((creatingCount / totalCount) * 100); + const unhealthyPercentage = Math.round((unhealthyCount / totalCount) * 100); + + return { + segments: [ + { percentage: healthyPercentage, color: HINT_COLORS.healthy, label: t('common.healthy') }, + { percentage: creatingPercentage, color: HINT_COLORS.creating, label: t('common.creating') }, + { percentage: unhealthyPercentage, color: HINT_COLORS.unhealthy, label: t('common.unhealthy') }, + ], + label: t('Hints.CrossplaneHint.healthy'), + showPercentage: true, + isHealthy: healthyPercentage === 100 && totalCount > 0, + showOnlyNonZero: true, + }; +}; + +/** + * GitOps-specific segment calculation + */ +export const calculateGitOpsSegments: GenericHintSegmentCalculator = ( + allItems: ManagedResourceItem[], + isLoading: boolean, + error: APIError | undefined, + enabled: boolean, + t: (key: string) => string, +): GenericHintState => { + if (isLoading) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.common.loading') }], + label: t('Hints.common.loading'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + if (error) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.unhealthy, label: t('Hints.common.errorLoadingResources') }], + label: t('Hints.common.errorLoadingResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + if (!enabled) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.GitOpsHint.inactive') }], + label: t('Hints.GitOpsHint.inactive'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + const totalCount = allItems.length; + + if (totalCount === 0) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.GitOpsHint.noResources') }], + label: t('Hints.GitOpsHint.noResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + // Count the number of items with the flux label + const fluxLabelCount = allItems.filter( + (item: ManagedResourceItem) => + item?.metadata?.labels && + Object.prototype.hasOwnProperty.call(item.metadata.labels, 'kustomize.toolkit.fluxcd.io/name'), + ).length; + + const progressValue = totalCount > 0 ? Math.round((fluxLabelCount / totalCount) * 100) : 0; + const restPercentage = 100 - progressValue; + const progressColor = progressValue >= 70 ? HINT_COLORS.healthy : HINT_COLORS.progress; + + return { + segments: [ + { percentage: progressValue, color: progressColor, label: t('common.progress') }, + { percentage: restPercentage, color: HINT_COLORS.inactive, label: t('common.remaining') }, + ], + label: t('Hints.GitOpsHint.managed'), + showPercentage: true, + isHealthy: progressValue >= 70, + showOnlyNonZero: true, + }; +}; + +/** + * Vault-specific segment calculation + */ +export const calculateVaultSegments: GenericHintSegmentCalculator = ( + allItems: ManagedResourceItem[], + isLoading: boolean, + error: APIError | undefined, + enabled: boolean, + t: (key: string) => string, +): GenericHintState => { + if (isLoading) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.common.loading') }], + label: t('Hints.common.loading'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + if (error) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.unhealthy, label: t('Hints.common.errorLoadingResources') }], + label: t('Hints.common.errorLoadingResources'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + if (!enabled) { + return { + segments: [{ percentage: 100, color: HINT_COLORS.inactive, label: t('Hints.VaultHint.inactive') }], + label: t('Hints.VaultHint.inactive'), + showPercentage: false, + isHealthy: false, + showOnlyNonZero: true, + }; + } + + const hasResources = allItems.length > 0; + const label = hasResources ? `100${t('Hints.VaultHint.progressAvailable')}` : t('Hints.VaultHint.noResources'); + const color = hasResources ? HINT_COLORS.healthy : HINT_COLORS.inactive; + + return { + segments: [{ percentage: 100, color, label: t('common.active') }], + label, + showPercentage: true, + isHealthy: hasResources, + showOnlyNonZero: true, + }; +}; + +/** + * Types for hover content calculations + */ +export interface ResourceTypeStats { + type: string; + total: number; + healthy: number; + creating: number; + unhealthy: number; + healthyPercentage: number; + creatingPercentage: number; + unhealthyPercentage: number; +} + +export interface OverallStats { + total: number; + healthy: number; + creating: number; + unhealthy: number; +} + +export interface CrossplaneHoverData { + resourceTypeStats: ResourceTypeStats[]; + overallStats: OverallStats; +} + +/** + * Calculate comprehensive statistics for Crossplane hover content + */ +export const calculateCrossplaneHoverData = (allItems: ManagedResourceItem[]): CrossplaneHoverData => { + const typeStats: Record = {}; + let totalHealthy = 0; + let totalCreating = 0; + let totalUnhealthy = 0; + + allItems.forEach((item: ManagedResourceItem) => { + const type = item.kind || 'Unknown'; + + if (!typeStats[type]) { + typeStats[type] = { total: 0, healthy: 0, creating: 0, unhealthy: 0 }; + } + + typeStats[type].total++; + + const conditions = item.status?.conditions || []; + const ready = conditions.find((c: Condition) => c.type === 'Ready' && c.status === 'True'); + const synced = conditions.find((c: Condition) => c.type === 'Synced' && c.status === 'True'); + + if (ready && synced) { + typeStats[type].healthy++; + totalHealthy++; + } else if (synced && !ready) { + // Resource is synced but not ready - it's creating + typeStats[type].creating++; + totalCreating++; + } else { + // Resource has issues or is not synced + typeStats[type].unhealthy++; + totalUnhealthy++; + } + }); + + const resourceTypeStats: ResourceTypeStats[] = Object.keys(typeStats).map((type) => { + const stats = typeStats[type]; + return { + type, + total: stats.total, + healthy: stats.healthy, + creating: stats.creating, + unhealthy: stats.unhealthy, + healthyPercentage: Math.round((stats.healthy / stats.total) * 100), + creatingPercentage: Math.round((stats.creating / stats.total) * 100), + unhealthyPercentage: Math.round((stats.unhealthy / stats.total) * 100), + }; + }); + + return { + resourceTypeStats, + overallStats: { + total: allItems.length, + healthy: totalHealthy, + creating: totalCreating, + unhealthy: totalUnhealthy, + }, + }; +}; + +/** + * Calculate hover data for Crossplane using the generic HoverContent structure + * Shows healthy resources (the positive segment) + */ +export const calculateCrossplaneHoverDataGeneric: HoverDataCalculator = ( + allItems: ManagedResourceItem[], + enabled: boolean, + t: (key: string) => string, +): Omit | null => { + if (!enabled || allItems.length === 0) { + return null; + } + + const { resourceTypeStats, overallStats } = calculateCrossplaneHoverData(allItems); + + // Get the segments from the bar chart calculation to ensure color consistency + const segmentData = calculateCrossplaneSegments(allItems, false, undefined, enabled, t); + + const legendItems = segmentData.segments.map((segment) => ({ + label: segment.label, + count: + segment.label === t('common.healthy') + ? overallStats.healthy + : segment.label === t('common.creating') + ? overallStats.creating + : overallStats.unhealthy, + color: segment.color, + })); + + // Focus on healthy percentage in radar chart (the positive aspect) + const radarDataset = resourceTypeStats.map((stats) => ({ + type: stats.type, + healthy: stats.healthyPercentage, + })); + + // Use the color of the healthy segment (first segment in the bar chart) + const healthyColor = segmentData.segments.find((s) => s.label === t('common.healthy'))?.color || HINT_COLORS.healthy; + + return { + totalCount: overallStats.total, + totalLabel: t('Hints.CrossplaneHint.hoverContent.totalResources'), + legendItems, + radarDataset, + radarDimensions: [{ accessor: 'type' }], + radarMeasures: [ + { + accessor: 'healthy', + color: healthyColor, + hideDataLabel: true, + label: t('Hints.CrossplaneHint.hoverContent.healthy') + ' (%)', + }, + ], + }; +}; + +/** + * Calculate hover data for GitOps showing resource type management coverage + * Shows managed resources (the positive segment) + */ +export const calculateGitOpsHoverDataGeneric: HoverDataCalculator = ( + allItems: ManagedResourceItem[], + enabled: boolean, + t: (key: string) => string, +): Omit | null => { + if (!enabled || allItems.length === 0) { + return null; + } + + // Group by resource type and calculate flux management coverage + const typeStats: Record = {}; + let totalManaged = 0; + + allItems.forEach((item: ManagedResourceItem) => { + const type = item.kind || 'Unknown'; + + if (!typeStats[type]) { + typeStats[type] = { total: 0, managed: 0 }; + } + + typeStats[type].total++; + + // Check if the resource is managed by Flux + if ( + item?.metadata?.labels && + Object.prototype.hasOwnProperty.call(item.metadata.labels, 'kustomize.toolkit.fluxcd.io/name') + ) { + typeStats[type].managed++; + totalManaged++; + } + }); + + const totalUnmanaged = allItems.length - totalManaged; + + // Get the segments from the bar chart calculation to ensure color consistency + const segmentData = calculateGitOpsSegments(allItems, false, undefined, enabled, t); + + const legendItems = segmentData.segments.map((segment) => ({ + label: segment.label, + count: segment.label === t('common.progress') ? totalManaged : totalUnmanaged, + color: segment.color, + })); + + // Focus on managed percentage in radar chart (the positive aspect) + const radarDataset = Object.keys(typeStats).map((type) => { + const stats = typeStats[type]; + const managedPercentage = Math.round((stats.managed / stats.total) * 100); + return { + type, + managed: managedPercentage, + }; + }); + + // Use the color of the progress/managed segment (first segment in the bar chart) + const managedColor = segmentData.segments.find((s) => s.label === t('common.progress'))?.color || HINT_COLORS.managed; + + return { + totalCount: allItems.length, + totalLabel: t('Hints.GitOpsHint.hoverContent.totalResources'), + legendItems, + radarDataset, + radarDimensions: [{ accessor: 'type' }], + radarMeasures: [ + { + accessor: 'managed', + color: managedColor, + hideDataLabel: true, + label: t('Hints.GitOpsHint.hoverContent.managed') + ' (%)', + }, + ], + }; +};