diff --git a/package-lock.json b/package-lock.json index c57fa02..d79f22b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,13 @@ "name": "cluster-iq-console", "version": "0.1.0", "dependencies": { - "@patternfly/react-core": "^5.4.11", - "@patternfly/react-icons": "^5.4.2", - "@patternfly/react-styles": "^5.4.1", - "@patternfly/react-table": "^5.4.12", + "@patternfly/react-core": "^6.4.0", + "@patternfly/react-icons": "^6.4.0", + "@patternfly/react-styles": "^6.4.0", + "@patternfly/react-table": "^6.4.0", "@types/react": "^19.0.1", "axios": "^1.8.2", + "cron-validate": "^1.5.3", "date-fns": "^4.1.0", "lodash.debounce": "4.0.8", "luxon": "^3.5.0", @@ -34,7 +35,7 @@ "eslint": "^9.31.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-unused-imports": "^4.1.4", "globals": "^15.15.0", @@ -42,7 +43,7 @@ "lint-staged": "^15.3.0", "prettier": "^3.6.2", "typescript-eslint": "^8.38.0", - "vite": "^6.3.5" + "vite": ">=6.4.1" } }, "node_modules/@ampproject/remapping": { @@ -1058,62 +1059,68 @@ } }, "node_modules/@patternfly/react-core": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-5.4.11.tgz", - "integrity": "sha512-+WgxVnnqiZiASB+zMBA8Z1YuaX32/ehJ8Y4UB+j2xIB/tjvBqKNVn7n4VPy56F+G9nJ92Kv8Tekccau6bL6jFg==", - "dependencies": { - "@patternfly/react-icons": "^5.4.2", - "@patternfly/react-styles": "^5.4.1", - "@patternfly/react-tokens": "^5.4.1", - "focus-trap": "7.6.2", - "react-dropzone": "^14.2.3", - "tslib": "^2.7.0" + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.4.0.tgz", + "integrity": "sha512-zMgJmcFohp2FqgAoZHg7EXZS7gnaFESquk0qIavemYI0FsqspVlzV2/PUru7w+86+jXfqebRhgubPRsv1eJwEg==", + "license": "MIT", + "dependencies": { + "@patternfly/react-icons": "^6.4.0", + "@patternfly/react-styles": "^6.4.0", + "@patternfly/react-tokens": "^6.4.0", + "focus-trap": "7.6.4", + "react-dropzone": "^14.3.5", + "tslib": "^2.8.1" }, "peerDependencies": { - "react": "^17 || ^18", - "react-dom": "^17 || ^18" + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" } }, "node_modules/@patternfly/react-icons": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-5.4.2.tgz", - "integrity": "sha512-CMQ5oHYzW6TPVTs2jpNJmP2vGCAKR/YeTPwHGO9dLkAUej1IcIxtCCWK2Fdo2UJsnBjuZihasyw2b6ehvbUm9Q==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.4.0.tgz", + "integrity": "sha512-SPjzatm73NUYv/BL6A/cjRA5sFQ15NkiyPAcT8gmklI7HY+ptd6/eg49uBDFmxTQcSwbb5ISW/R6wwCQBY2M+Q==", + "license": "MIT", "peerDependencies": { - "react": "^17 || ^18", - "react-dom": "^17 || ^18" + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" } }, "node_modules/@patternfly/react-styles": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-5.4.1.tgz", - "integrity": "sha512-XA8PXksD8uiA3RTwxdUwJXOCf+V6sVd+2HKapWAdRLvtSV+Sdk7NgCvalb4IAQncsddLopjPQD8gAHA298+N8w==" + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.4.0.tgz", + "integrity": "sha512-EXmHA67s5sy+Wy/0uxWoUQ52jr9lsH2wV3QcgtvVc5zxpyBX89gShpqv4jfVqaowznHGDoL6fVBBrSe9BYOliQ==", + "license": "MIT" }, "node_modules/@patternfly/react-table": { - "version": "5.4.12", - "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-5.4.12.tgz", - "integrity": "sha512-ZpW/49bWR9JkZCyBGBCbHNdJt0Vn34g5BaXlVF/P7Tl0WPdxFLWS5EQywiXEhu8qoOhz3uGcyGwACCJCvmqCSg==", - "dependencies": { - "@patternfly/react-core": "^5.4.11", - "@patternfly/react-icons": "^5.4.2", - "@patternfly/react-styles": "^5.4.1", - "@patternfly/react-tokens": "^5.4.1", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-6.4.0.tgz", + "integrity": "sha512-yv0sFOLGts8a2q9C1xUegjp50ayYyVRe0wKjMf+aMSNIK8sVYu8qu0yfBsCDybsUCldue7+qsYKRLFZosTllWQ==", + "license": "MIT", + "dependencies": { + "@patternfly/react-core": "^6.4.0", + "@patternfly/react-icons": "^6.4.0", + "@patternfly/react-styles": "^6.4.0", + "@patternfly/react-tokens": "^6.4.0", "lodash": "^4.17.21", - "tslib": "^2.7.0" + "tslib": "^2.8.1" }, "peerDependencies": { - "react": "^17 || ^18", - "react-dom": "^17 || ^18" + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" } }, "node_modules/@patternfly/react-tokens": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-5.4.1.tgz", - "integrity": "sha512-eygdHE7Krta1mijAv/E8RHiKIgysD0eeNTo8EXUYC8/M4e5K6sqpr2p6rQBF8QiRMN8FnbXvZT3K2OQ28pYt9Q==" + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.4.0.tgz", + "integrity": "sha512-iZthBoXSGQ/+PfGTdPFJVulaJZI3rwE+7A/whOXPGp3Jyq3k6X52pr1+5nlO6WHasbZ9FyeZGqXf4fazUZNjbw==", + "license": "MIT" }, "node_modules/@remix-run/router": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", - "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -1978,12 +1985,13 @@ } }, "node_modules/axios": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", - "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -2220,6 +2228,15 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cron-validate": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/cron-validate/-/cron-validate-1.5.3.tgz", + "integrity": "sha512-jcu8g/3wZL8OBr4MkEcbeIdLpM8pp5Y6UoOlRktcJG3WjgpifijR0s26Yac7ywR0gC2ABtevOsz5mlD3l3gzwA==", + "license": "MIT", + "dependencies": { + "yup": "1.7.1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2744,12 +2761,20 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" @@ -3120,9 +3145,10 @@ "dev": true }, "node_modules/focus-trap": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.2.tgz", - "integrity": "sha512-9FhUxK1hVju2+AiQIDJ5Dd//9R2n2RAfJ0qfhF4IHGHgcoEUTMpbTeG/zbEuwaiYXfuAH6XE0/aCyxDdRM+W5w==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.4.tgz", + "integrity": "sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==", + "license": "MIT", "dependencies": { "tabbable": "^6.2.0" } @@ -3453,6 +3479,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -3950,10 +3993,11 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -4708,6 +4752,12 @@ "react-is": "^16.13.1" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4796,11 +4846,12 @@ } }, "node_modules/react-router": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz", - "integrity": "sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.21.0" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -4810,12 +4861,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.0.tgz", - "integrity": "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.21.0", - "react-router": "6.28.0" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" @@ -5443,9 +5495,16 @@ } }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.14", @@ -5501,6 +5560,12 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -5530,6 +5595,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -5705,10 +5782,11 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5959,6 +6037,41 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yup": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/zod": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 5f2c481..f6d4d7a 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,13 @@ "clean": "rm -Rf dist" }, "dependencies": { - "@patternfly/react-core": "^5.4.11", - "@patternfly/react-icons": "^5.4.2", - "@patternfly/react-styles": "^5.4.1", - "@patternfly/react-table": "^5.4.12", + "@patternfly/react-core": "^6.4.0", + "@patternfly/react-icons": "^6.4.0", + "@patternfly/react-styles": "^6.4.0", + "@patternfly/react-table": "^6.4.0", "@types/react": "^19.0.1", "axios": "^1.8.2", + "cron-validate": "^1.5.3", "date-fns": "^4.1.0", "lodash.debounce": "4.0.8", "luxon": "^3.5.0", @@ -37,7 +38,7 @@ "eslint": "^9.31.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-unused-imports": "^4.1.4", "globals": "^15.15.0", @@ -45,7 +46,7 @@ "lint-staged": "^15.3.0", "prettier": "^3.6.2", "typescript-eslint": "^8.38.0", - "vite": "^6.3.5" + "vite": ">=6.4.1" }, "overrides": { "@babel/helpers": "7.26.10" diff --git a/src/api/Accounts.ts b/src/api/Accounts.ts new file mode 100644 index 0000000..4473b55 --- /dev/null +++ b/src/api/Accounts.ts @@ -0,0 +1,160 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { + AccountListResponseApi, + AccountRequestApi, + AccountResponseApi, + ClusterListResponseApi, + GenericErrorResponseApi, + InstanceListResponseApi, + PostResponseApi, +} from './data-contracts'; +import { ContentType, HttpClient, RequestParams } from './http-client'; + +export class Accounts { + http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + /** + * @description Paginated retrieval of accounts with optional filters. + * + * @tags Accounts + * @name AccountsList + * @summary List accounts + * @request GET:/accounts + */ + accountsList = ( + query?: { + /** + * Page number + * @default 1 + */ + page?: number; + /** + * Items per page + * @default 10 + */ + page_size?: number; + /** Cloud provider */ + provider?: string; + }, + params: RequestParams = {} + ) => + this.http.request({ + path: `/accounts`, + method: 'GET', + query: query, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Create one or multiple accounts from the request body. + * + * @tags Accounts + * @name AccountsCreate + * @summary Create accounts + * @request POST:/accounts + */ + accountsCreate = (accounts: AccountRequestApi[], params: RequestParams = {}) => + this.http.request({ + path: `/accounts`, + method: 'POST', + body: accounts, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Return a single account resource. + * + * @tags Accounts + * @name AccountsDetail + * @summary Get an account by ID + * @request GET:/accounts/{id} + */ + accountsDetail = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/accounts/${id}`, + method: 'GET', + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Delete an account resource by its ID. + * + * @tags Accounts + * @name AccountsDelete + * @summary Delete an account + * @request DELETE:/accounts/{id} + */ + accountsDelete = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/accounts/${id}`, + method: 'DELETE', + type: ContentType.Json, + ...params, + }); + /** + * @description Patch an existing account by ID. + * + * @tags Accounts + * @name AccountsPartialUpdate + * @summary Update an account + * @request PATCH:/accounts/{id} + */ + accountsPartialUpdate = (id: string, account: AccountRequestApi, params: RequestParams = {}) => + this.http.request({ + path: `/accounts/${id}`, + method: 'PATCH', + body: account, + type: ContentType.Json, + ...params, + }); + /** + * @description Return the clusters associated with the specified account. + * + * @tags Accounts + * @name ClustersList + * @summary List clusters by account ID + * @request GET:/accounts/{id}/clusters + */ + clustersList = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/accounts/${id}/clusters`, + method: 'GET', + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Return the list of instances for expense update for a specified account. + * + * @tags Accounts + * @name ExpenseUpdateInstancesList + * @summary List expense update instances + * @request GET:/accounts/{id}/expense_update_instances + */ + expenseUpdateInstancesList = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/accounts/${id}/expense_update_instances`, + method: 'GET', + type: ContentType.Json, + format: 'json', + ...params, + }); +} diff --git a/src/api/Actions.ts b/src/api/Actions.ts new file mode 100644 index 0000000..4391ad9 --- /dev/null +++ b/src/api/Actions.ts @@ -0,0 +1,103 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { ActionRequestApi, GenericErrorResponseApi, PostResponseApi } from './data-contracts'; +import { ContentType, HttpClient, RequestParams } from './http-client'; + +export class Actions { + http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + /** + * @description Create one or multiple actions. + * + * @tags Actions + * @name ActionsCreate + * @summary Create actions + * @request POST:/actions + */ + actionsCreate = (actions: ActionRequestApi[], params: RequestParams = {}) => + this.http.request({ + path: `/actions`, + method: 'POST', + body: actions, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Enables one or multiple actions. + * + * @tags Actions + * @name ActionsEnable + * @summary Enable actions + * @request PATCH:/actions + */ + actionsEnable = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/actions/${id}/enable`, + method: 'PATCH', + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Disables one or multiple actions. + * + * @tags Actions + * @name ActionsDisable + * @summary Disable actions + * @request PATCH:/actions + */ + actionsDisable = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/actions/${id}/disable`, + method: 'PATCH', + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Patch an existing actions by ID. + * + * @tags Actions + * @name ActionsPartialUpdate + * @summary Update an actions + * @request PATCH:/actions/ + */ + actionsPartialUpdate = (action: ActionRequestApi, params: RequestParams = {}) => + this.http.request({ + path: `/actions/`, + method: 'PATCH', + body: action, + type: ContentType.Json, + ...params, + }); + /** + * @description Delete an action by ID. + * + * @tags Actions + * @name ActionsDelete + * @summary Delete an action + * @request DELETE:/actions/{id} + */ + actionsDelete = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/actions/${id}`, + method: 'DELETE', + type: ContentType.Json, + ...params, + }); +} diff --git a/src/api/Clusters.ts b/src/api/Clusters.ts new file mode 100644 index 0000000..6cd7f4a --- /dev/null +++ b/src/api/Clusters.ts @@ -0,0 +1,232 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { + ClusterEventListResponseApi, + ClusterListResponseApi, + ClusterRequestApi, + ClusterResponseApi, + GenericErrorResponseApi, + GenericResponseApi, + InstanceResponseApi, + PostResponseApi, + TagResponseApi, +} from './data-contracts'; +import { ContentType, HttpClient, RequestParams } from './http-client'; + +export class Clusters { + http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + /** + * @description Paginated retrieval with optional filters. + * + * @tags Clusters + * @name ClustersList + * @summary List clusters + * @request GET:/clusters + */ + clustersList = ( + query?: { + /** Account name */ + account?: string; + /** + * Page number + * @default 1 + */ + page?: number; + /** + * Items per page + * @default 10 + */ + page_size?: number; + /** Cloud provider */ + provider?: string; + /** Provider region */ + region?: string; + /** Cluster status */ + status?: string; + }, + params: RequestParams = {} + ) => + this.http.request({ + path: `/clusters`, + method: 'GET', + query: query, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Create one or multiple clusters. + * + * @tags Clusters + * @name ClustersCreate + * @summary Create clusters + * @request POST:/clusters + */ + clustersCreate = (clusters: ClusterRequestApi[], params: RequestParams = {}) => + this.http.request({ + path: `/clusters`, + method: 'POST', + body: clusters, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Return a single cluster. + * + * @tags Clusters + * @name ClustersDetail + * @summary Get cluster by ID + * @request GET:/clusters/{id} + */ + clustersDetail = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/clusters/${id}`, + method: 'GET', + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Delete a cluster by ID. + * + * @tags Clusters + * @name ClustersDelete + * @summary Delete a cluster + * @request DELETE:/clusters/{id} + */ + clustersDelete = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/clusters/${id}`, + method: 'DELETE', + type: ContentType.Json, + ...params, + }); + /** + * @description Patch mutable fields of a cluster. + * + * @tags Clusters + * @name ClustersPartialUpdate + * @summary Update a cluster + * @request PATCH:/clusters/{id} + */ + clustersPartialUpdate = (id: string, cluster: ClusterResponseApi, params: RequestParams = {}) => + this.http.request({ + path: `/clusters/${id}`, + method: 'PATCH', + body: cluster, + type: ContentType.Json, + ...params, + }); + /** + * @description Paginated events for the specified cluster. + * + * @tags Clusters + * @name EventsList + * @summary List cluster events + * @request GET:/clusters/{id}/events + */ + eventsList = ( + id: string, + query?: { + /** + * Page number + * @default 1 + */ + page?: number; + /** + * Items per page + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {} + ) => + this.http.request({ + path: `/clusters/${id}/events`, + method: 'GET', + query: query, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Return instances belonging to the specified cluster. + * + * @tags Clusters + * @name InstancesList + * @summary Get cluster instances + * @request GET:/clusters/{id}/instances + */ + instancesList = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/clusters/${id}/instances`, + method: 'GET', + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Request powering off a cluster. + * + * @tags Clusters + * @name PowerOffCreate + * @summary Power off a cluster + * @request POST:/clusters/{id}/power_off + */ + powerOffCreate = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/clusters/${id}/power_off`, + method: 'POST', + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Request powering on a cluster. + * + * @tags Clusters + * @name PowerOnCreate + * @summary Power on a cluster + * @request POST:/clusters/{id}/power_on + */ + powerOnCreate = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/clusters/${id}/power_on`, + method: 'POST', + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Return all tags for a cluster. + * + * @tags Clusters + * @name TagsList + * @summary Get cluster tags + * @request GET:/clusters/{id}/tags + */ + tagsList = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/clusters/${id}/tags`, + method: 'GET', + type: ContentType.Json, + format: 'json', + ...params, + }); +} diff --git a/src/api/Events.ts b/src/api/Events.ts new file mode 100644 index 0000000..012258d --- /dev/null +++ b/src/api/Events.ts @@ -0,0 +1,103 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { + EventRequestApi, + GenericErrorResponseApi, + PostResponseApi, + SystemEventListResponseApi, +} from './data-contracts'; +import { ContentType, HttpClient, RequestParams } from './http-client'; + +export class Events { + http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + /** + * @description Paginated retrieval with optional filters. + * + * @tags Events + * @name EventsList + * @summary List system events + * @request GET:/events + */ + eventsList = ( + query?: { + /** Action name */ + action_name?: string; + /** + * Page number + * @default 1 + */ + page?: number; + /** + * Items per page + * @default 10 + */ + page_size?: number; + /** Resource type */ + resource_type?: string; + /** Result */ + result?: string; + /** Severity */ + severity?: string; + /** Triggered by */ + triggered_by?: string; + }, + params: RequestParams = {} + ) => + this.http.request({ + path: `/events`, + method: 'GET', + query: query, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Create a single event from the request body. + * + * @tags Events + * @name EventsCreate + * @summary Create event + * @request POST:/events + */ + eventsCreate = (event: EventRequestApi, params: RequestParams = {}) => + this.http.request({ + path: `/events`, + method: 'POST', + body: event, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Update an event result using the provided payload. + * + * @tags Events + * @name EventsPartialUpdate + * @summary Update event + * @request PATCH:/events + */ + eventsPartialUpdate = (event: EventRequestApi, params: RequestParams = {}) => + this.http.request({ + path: `/events`, + method: 'PATCH', + body: event, + type: ContentType.Json, + format: 'json', + ...params, + }); +} diff --git a/src/api/Expenses.ts b/src/api/Expenses.ts new file mode 100644 index 0000000..cd2b175 --- /dev/null +++ b/src/api/Expenses.ts @@ -0,0 +1,73 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { ExpenseListResponseApi, ExpenseRequestApi, GenericErrorResponseApi, PostResponseApi } from './data-contracts'; +import { ContentType, HttpClient, RequestParams } from './http-client'; + +export class Expenses { + http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + /** + * @description Paginated retrieval with optional filters. + * + * @tags Expenses + * @name ExpensesList + * @summary List expenses + * @request GET:/expenses + */ + expensesList = ( + query?: { + /** Instance ID filter */ + instance_id?: string; + /** + * Page number + * @default 1 + */ + page?: number; + /** + * Items per page + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {} + ) => + this.http.request({ + path: `/expenses`, + method: 'GET', + query: query, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Create one or multiple expense records. + * + * @tags Expenses + * @name ExpensesCreate + * @summary Create expenses + * @request POST:/expenses + */ + expensesCreate = (expenses: ExpenseRequestApi[], params: RequestParams = {}) => + this.http.request({ + path: `/expenses`, + method: 'POST', + body: expenses, + type: ContentType.Json, + format: 'json', + ...params, + }); +} diff --git a/src/api/Healthcheck.ts b/src/api/Healthcheck.ts new file mode 100644 index 0000000..c145fcf --- /dev/null +++ b/src/api/Healthcheck.ts @@ -0,0 +1,39 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { HealthCheckResponseApi } from './data-contracts'; +import { ContentType, HttpClient, RequestParams } from './http-client'; + +export class Healthcheck { + http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + /** + * @description Report API process health and DB connectivity. + * + * @tags Health + * @name HealthcheckList + * @summary Health checks + * @request GET:/healthcheck + */ + healthcheckList = (params: RequestParams = {}) => + this.http.request({ + path: `/healthcheck`, + method: 'GET', + type: ContentType.Json, + format: 'json', + ...params, + }); +} diff --git a/src/api/Instances.ts b/src/api/Instances.ts new file mode 100644 index 0000000..873f943 --- /dev/null +++ b/src/api/Instances.ts @@ -0,0 +1,97 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { + GenericErrorResponseApi, + InstanceListResponseApi, + InstanceRequestApi, + InstanceResponseApi, + PostResponseApi, +} from './data-contracts'; +import { ContentType, HttpClient, RequestParams } from './http-client'; + +export class Instances { + http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + /** + * @description Paginated retrieval with optional filters. + * + * @tags Instances + * @name InstancesList + * @summary List instances + * @request GET:/instances + */ + instancesList = ( + query?: { + /** Cluster ID filter */ + cluster_id?: string; + /** + * Page number + * @default 1 + */ + page?: number; + /** + * Items per page + * @default 10 + */ + page_size?: number; + /** Instance status filter */ + status?: string; + }, + params: RequestParams = {} + ) => + this.http.request({ + path: `/instances`, + method: 'GET', + query: query, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Create one or multiple instances. + * + * @tags Instances + * @name InstancesCreate + * @summary Create instances + * @request POST:/instances + */ + instancesCreate = (instances: InstanceRequestApi[], params: RequestParams = {}) => + this.http.request({ + path: `/instances`, + method: 'POST', + body: instances, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Return a single instance resource. + * + * @tags Instances + * @name InstancesDetail + * @summary Get instance by ID + * @request GET:/instances/{id} + */ + instancesDetail = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/instances/${id}`, + method: 'GET', + type: ContentType.Json, + format: 'json', + ...params, + }); +} diff --git a/src/api/Overview.ts b/src/api/Overview.ts new file mode 100644 index 0000000..2110b30 --- /dev/null +++ b/src/api/Overview.ts @@ -0,0 +1,39 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { GenericErrorResponseApi, OverviewSummaryApi } from './data-contracts'; +import { ContentType, HttpClient, RequestParams } from './http-client'; + +export class Overview { + http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + /** + * @description Return an aggregated summary of the inventory state. + * + * @tags Overview + * @name OverviewList + * @summary Get inventory overview + * @request GET:/overview + */ + overviewList = (params: RequestParams = {}) => + this.http.request({ + path: `/overview`, + method: 'GET', + type: ContentType.Json, + format: 'json', + ...params, + }); +} diff --git a/src/api/Schedule.ts b/src/api/Schedule.ts new file mode 100644 index 0000000..e6f1498 --- /dev/null +++ b/src/api/Schedule.ts @@ -0,0 +1,102 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { ActionListResponseApi, GenericErrorResponseApi, ScheduledActionApi } from './data-contracts'; +import { ContentType, HttpClient, RequestParams } from './http-client'; + +export class Schedule { + http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + /** + * @description Paginated retrieval with optional filters. + * + * @tags Actions + * @name ScheduleList + * @summary List scheduled actions + * @request GET:/actions + */ + scheduleList = ( + query?: { + /** Enabled state filter (true/false) */ + enabled?: string; + /** + * Page number + * @default 1 + */ + page?: number; + /** + * Items per page + * @default 10 + */ + page_size?: number; + /** Status filter */ + status?: string; + }, + params: RequestParams = {} + ) => + this.http.request({ + path: `/actions`, + method: 'GET', + query: query, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Return a scheduled action resource. + * + * @tags Actions + * @name ScheduleDetail + * @summary Get scheduled action by ID + * @request GET:/actions/{id} + */ + scheduleDetail = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/actions/${id}`, + method: 'GET', + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Disable a scheduled action. + * + * @tags Actions + * @name DisablePartialUpdate + * @summary Disable scheduled action + * @request PATCH:/actions/{id}/disable + */ + disablePartialUpdate = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/actions/${id}/disable`, + method: 'PATCH', + ...params, + }); + /** + * @description Enable a scheduled action. + * + * @tags Actions + * @name EnablePartialUpdate + * @summary Enable scheduled action + * @request PATCH:/actions/{id}/enable + */ + enablePartialUpdate = (id: string, params: RequestParams = {}) => + this.http.request({ + path: `/actions/${id}/enable`, + method: 'PATCH', + ...params, + }); +} diff --git a/src/api/data-contracts.ts b/src/api/data-contracts.ts new file mode 100644 index 0000000..a115923 --- /dev/null +++ b/src/api/data-contracts.ts @@ -0,0 +1,319 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +export interface AccountListResponseApi { + count?: number; + items?: AccountResponseApi[]; +} + +export interface AccountRequestApi { + accountId?: string; + accountName?: string; + createdAt?: string; + lastScanTimestamp?: string; + provider?: ProviderApi; +} + +export interface AccountResponseApi { + last15DaysCost?: number; + accountId?: string; + accountName?: string; + clusterCount?: number; + createdAt?: string; + currentMonthSoFarCost?: number; + lastMonthCost?: number; + lastScanTimestamp?: string; + provider?: ProviderApi; + totalCost?: number; +} + +export interface ActionListResponseApi { + count?: number; + items?: ActionResponseApi[]; +} + +export interface ActionRequestApi { + accountId?: string; + clusterId?: string; + cronExpression?: string; + enabled?: boolean; + id?: string; + instances?: string[]; + operation?: string; + region?: string; + status?: string; + time?: string; + type?: string; +} + +export interface ActionResponseApi { + accountId?: string; + clusterId?: string; + cronExpression?: string; + enabled?: boolean; + id?: string; + instances?: string[]; + operation?: string; + region?: string; + status?: string; + time?: string; + type?: string; +} + +export interface ActionTargetApi { + accountName?: string; + clusterId?: string; + instances?: string[]; + region?: string; +} + +export interface ClusterEventListResponseApi { + count?: number; + items?: ClusterEventResponseApi[]; +} + +export interface ClusterEventResponseApi { + action?: string; + description?: string; + id?: number; + resourceId?: string; + resourceType?: string; + result?: ResultStatus; + severity?: string; + timestamp?: string; + triggeredBy?: string; +} + +export interface ClusterListResponseApi { + count?: number; + items?: ClusterResponseApi[]; +} + +export interface ClusterRequestApi { + accountId?: string; + age?: number; + clusterId?: string; + clusterName?: string; + consoleLink?: string; + createdAt?: string; + infraId?: string; + lastScanTimestamp?: string; + owner?: string; + provider?: ProviderApi; + region?: string; + status?: ResourceStatusApi; +} + +export interface ClusterResponseApi { + last15DaysCost?: number; + accountId?: string; + accountName?: string; + age?: number; + clusterId?: string; + clusterName?: string; + consoleLink?: string; + createdAt?: string; + currentMonthSoFarCost?: number; + infraId?: string; + instanceCount?: number; + lastMonthCost?: number; + lastScanTimestamp?: string; + owner?: string; + provider?: ProviderApi; + region?: string; + status?: ResourceStatusApi; + totalCost?: number; +} + +export interface ClusterSummaryApi { + archived?: number; + running?: number; + stopped?: number; +} + +export interface EventRequestApi { + action?: string; + description?: string; + id?: number; + resourceId?: string; + resourceType?: string; + result?: string; + severity?: string; + timestamp?: string; + triggeredBy?: string; +} + +export interface ExpenseListResponseApi { + count?: number; + items?: ExpenseResponseApi[]; +} + +export interface ExpenseRequestApi { + amount?: number; + date?: string; + instanceId?: string; +} + +export interface ExpenseResponseApi { + amount?: number; + date?: string; + instanceId?: string; +} + +export interface GenericErrorResponseApi { + message?: string; +} + +export interface GenericResponseApi { + message?: string; +} + +export interface HealthCheckResponseApi { + apiHealth?: boolean; + dbHealth?: boolean; +} + +export interface InstanceListResponseApi { + count?: number; + items?: InstanceResponseApi[]; +} + +export interface InstanceRequestApi { + age?: number; + availabilityZone?: string; + clusterId?: string; + createdAt?: string; + instanceId?: string; + instanceName?: string; + instanceType?: string; + lastScanTimestamp?: string; + owner?: string; + provider?: ProviderApi; + status?: ResourceStatusApi; + tags?: TagRequestApi[]; +} + +export interface InstanceResponseApi { + last15DaysCost?: number; + age?: number; + availabilityZone?: string; + clusterId?: string; + clusterName?: string; + creationTimestamp?: string; + currentMonthSoFarCost?: number; + instanceId?: string; + instanceName?: string; + instanceType?: string; + lastMonthCost?: number; + lastScanTimestamp?: string; + owner?: string; + provider?: ProviderApi; + status?: ResourceStatusApi; + tags?: TagResponseApi[]; + totalCost?: number; +} + +export interface InstancesSummaryApi { + archived?: number; + running?: number; + stopped?: number; +} + +export interface OverviewSummaryApi { + clusters?: ClusterSummaryApi; + instances?: InstancesSummaryApi; + providers?: ProvidersSummaryApi; + scanner?: ScannerApi; +} + +export interface PostResponseApi { + count?: number; + status?: string; +} + +export enum ProviderApi { + /** AWSProvider - Amazon Web Services Cloud Provider */ + AWSProvider = 'AWS', + /** AzureProvider - Microsoft Azure Cloud Provider */ + AzureProvider = 'Azure', + /** GCPProvider - Google Cloud Platform Cloud Provider */ + GCPProvider = 'GCP', + /** UnknownProvider - Unknown Provider */ + UnknownProvider = 'UNKNOWN_PROVIDER', +} + +export interface ProviderDetailsApi { + accountCount?: number; + clusterCount?: number; +} + +export interface ProvidersSummaryApi { + aws?: ProviderDetailsApi; + azure?: ProviderDetailsApi; + gcp?: ProviderDetailsApi; +} + +export enum ResourceStatusApi { + /** Running Instance status */ + Running = 'Running', + /** Stopped Instance status */ + Stopped = 'Stopped', + /** Terminated Instance status */ + Terminated = 'Terminated', +} + +export interface ScannerApi { + lastScanTimestamp?: string; +} + +export interface ScheduledActionApi { + /** for cron_action */ + cronExpression?: string; + enabled?: boolean; + id?: string; + operation?: string; + status?: string; + target?: ActionTargetApi; + /** for scheduled_action */ + time?: string; + type?: string; +} + +export interface SystemEventListResponseApi { + count?: number; + items?: SystemEventResponseApi[]; +} + +export interface SystemEventResponseApi { + id?: number; + action?: string; + resourceId?: string; + resourceType?: string; + timestamp?: string; + result?: ResultStatus; + severity?: string; + triggeredBy?: string; + description?: string; + accountId?: string; + provider?: string; +} + +export interface TagRequestApi { + key?: string; + value?: string; +} + +export interface TagResponseApi { + instanceId?: string; + key?: string; + value?: string; +} diff --git a/src/api/http-client.ts b/src/api/http-client.ts new file mode 100644 index 0000000..3ae328d --- /dev/null +++ b/src/api/http-client.ts @@ -0,0 +1,149 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, HeadersDefaults, ResponseType } from 'axios'; +import axios from 'axios'; + +export type QueryParamsType = Record; + +export interface FullRequestParams extends Omit { + /** set parameter to `true` for call `securityWorker` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseType; + /** request body */ + body?: unknown; +} + +export type RequestParams = Omit; + +export interface ApiConfig extends Omit { + securityWorker?: ( + securityData: SecurityDataType | null + ) => Promise | AxiosRequestConfig | void; + secure?: boolean; + format?: ResponseType; +} + +export enum ContentType { + Json = 'application/json', + JsonApi = 'application/vnd.api+json', + FormData = 'multipart/form-data', + UrlEncoded = 'application/x-www-form-urlencoded', + Text = 'text/plain', +} + +export class HttpClient { + public instance: AxiosInstance; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig['securityWorker']; + private secure?: boolean; + private format?: ResponseType; + + constructor({ securityWorker, secure, format, ...axiosConfig }: ApiConfig = {}) { + this.instance = axios.create({ + ...axiosConfig, + baseURL: axiosConfig.baseURL || '//localhost:8080/api/v1', + }); + this.secure = secure; + this.format = format; + this.securityWorker = securityWorker; + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected mergeRequestParams(params1: AxiosRequestConfig, params2?: AxiosRequestConfig): AxiosRequestConfig { + const method = params1.method || (params2 && params2.method); + + return { + ...this.instance.defaults, + ...params1, + ...(params2 || {}), + headers: { + ...((method && this.instance.defaults.headers[method.toLowerCase() as keyof HeadersDefaults]) || {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected stringifyFormItem(formItem: unknown) { + if (typeof formItem === 'object' && formItem !== null) { + return JSON.stringify(formItem); + } else { + return `${formItem}`; + } + } + + protected createFormData(input: Record): FormData { + if (input instanceof FormData) { + return input; + } + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + const propertyContent: any[] = property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + const isFileType = formItem instanceof Blob || formItem instanceof File; + formData.append(key, isFileType ? formItem : this.stringifyFormItem(formItem)); + } + + return formData; + }, new FormData()); + } + + public request = async ({ + secure, + path, + type, + query, + format, + body, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === 'boolean' ? secure : this.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const responseFormat = format || this.format || undefined; + + if (type === ContentType.FormData && body && body !== null && typeof body === 'object') { + body = this.createFormData(body as Record); + } + + if (type === ContentType.Text && body && body !== null && typeof body !== 'string') { + body = JSON.stringify(body); + } + + return this.instance.request({ + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type ? { 'Content-Type': type } : {}), + }, + params: query, + responseType: responseFormat, + data: body, + url: path, + }); + }; +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..8d37d7a --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,75 @@ +import { HttpClient } from './http-client'; +import { Accounts } from './Accounts'; +import { Actions } from './Actions'; +import { Clusters } from './Clusters'; +import { Events } from './Events'; +import { Instances } from './Instances'; +import { Overview } from './Overview'; +import { Schedule } from './Schedule'; + +// Initialize HTTP client with base settings +const http = new HttpClient({ + baseURL: '/api', + timeout: 20000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +const PAGINATED_ENDPOINTS = ['/accounts', '/clusters', '/instances']; + +http.instance.interceptors.request.use(config => { + const isListEndpoint = PAGINATED_ENDPOINTS.some(endpoint => { + const url = config.url || ''; + return url.includes(endpoint) && !url.match(/\/[^/]+\/[^/]+$/); + }); + + if (isListEndpoint && config.method === 'get') { + if (!config.params?.page_size) { + config.params = { + page: config.params?.page || 1, + page_size: 100, + ...config.params, + }; + } + } + + return config; +}); + +// Initialize API classes +export const api = { + accounts: new Accounts(http), + actions: new Actions(http), + clusters: new Clusters(http), + events: new Events(http), + instances: new Instances(http), + overview: new Overview(http), + schedule: new Schedule(http), +}; + +export const startCluster = (clusterID: string, userEmail?: string, description?: string) => + http.instance.post(`/clusters/${clusterID}/power_on`, { + triggered_by: userEmail || 'unknown', + description: description, + }); + +export const stopCluster = (clusterID: string, userEmail?: string, description?: string) => + http.instance.post(`/clusters/${clusterID}/power_off`, { + triggered_by: userEmail || 'unknown', + description: description, + }); + +export type { + ClusterResponseApi, + InstanceResponseApi, + AccountResponseApi, + ClusterListResponseApi, + InstanceListResponseApi, + AccountListResponseApi, + SystemEventResponseApi, +} from './data-contracts'; + +export { ResourceStatusApi, ProviderApi } from './data-contracts'; + +export * from './data-contracts'; diff --git a/src/app/AccountDetails/AccountDetails.tsx b/src/app/AccountDetails/AccountDetails.tsx index 42adc2b..dc27d83 100644 --- a/src/app/AccountDetails/AccountDetails.tsx +++ b/src/app/AccountDetails/AccountDetails.tsx @@ -1,8 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { Page } from '@patternfly/react-core'; -import { getAccountByName } from '@app/services/api'; -import { AccountData } from '@app/types/types'; +import { api, AccountResponseApi } from '@api'; import AccountsHeader from './components/AccountHeader'; import AccountsTabs from './components/AccountTabs'; import { AccountDetailsContent } from './components/AccountDetailsContent'; @@ -10,19 +8,16 @@ import { debug } from '@app/utils/debugLogs'; import { AccountClusters } from './components/AccountClusters'; const AccountDetails: React.FunctionComponent = () => { - const { accountName } = useParams() as { accountName: string }; - const [accountData, setAccountData] = useState({ - count: 0, - accounts: [], - }); + const { accountId } = useParams() as { accountId: string }; + const [accountData, setAccountData] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { const fetchData = async () => { try { - debug('Fetching Account Clusters ', accountName); - const fetchedAccountClusters = await getAccountByName(accountName); - setAccountData(fetchedAccountClusters); - debug('Fetched Account Clusters data:', fetchedAccountClusters); + debug('Fetching Account Clusters ', accountId); + const { data: fetchedAccount } = await api.accounts.accountsDetail(accountId); + setAccountData(fetchedAccount); + debug('Fetched Account Clusters data:', fetchedAccount); } catch (error) { console.error('Error fetching data:', error); } finally { @@ -31,16 +26,16 @@ const AccountDetails: React.FunctionComponent = () => { }; fetchData(); - }, [accountName]); + }, [accountId]); return ( - - + + } clustersTabContent={} /> - + ); }; diff --git a/src/app/AccountDetails/components/AccountClusters.tsx b/src/app/AccountDetails/components/AccountClusters.tsx index 41eccba..00ab96e 100644 --- a/src/app/AccountDetails/components/AccountClusters.tsx +++ b/src/app/AccountDetails/components/AccountClusters.tsx @@ -6,7 +6,6 @@ import { ToolbarItem, Switch, EmptyState, - EmptyStateIcon, Title, EmptyStateBody, EmptyStateVariant, @@ -14,23 +13,22 @@ import { import { CubesIcon } from '@patternfly/react-icons'; import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; import { ClustersTable } from './ClustersTable'; -import { getAccountClusters } from '@app/services/api'; +import { api, ClusterResponseApi } from '@api'; import { debug } from '@app/utils/debugLogs'; -import { Cluster } from '@app/types/types'; export const AccountClusters: React.FunctionComponent = () => { - const [clusters, setClusters] = useState([]); + const [clusters, setClusters] = useState([]); const [showTerminated, setShowTerminated] = useState(false); const [loading, setLoading] = useState(true); - const { accountName } = useParams(); + const { accountId } = useParams(); useEffect(() => { const fetchData = async () => { try { debug('Fetching data...'); - const fetchedAccountClusters = await getAccountClusters(accountName); - debug('Fetched Account data:', fetchedAccountClusters); - setClusters(fetchedAccountClusters); + const { data } = await api.accounts.clustersList(accountId); + debug('Fetched Account data:', data); + setClusters(data.items || []); } catch (error) { console.error('Error fetching data:', error); } finally { @@ -39,7 +37,7 @@ export const AccountClusters: React.FunctionComponent = () => { }; fetchData(); - }, [accountName]); + }, [accountId]); // Filter terminated clusters const displayClusters = showTerminated ? clusters : clusters.filter(cluster => cluster.status !== 'Terminated'); @@ -60,7 +58,6 @@ export const AccountClusters: React.FunctionComponent = () => { @@ -69,11 +66,15 @@ export const AccountClusters: React.FunctionComponent = () => { {displayClusters.length === 0 ? ( - - - - No clusters found - + + No clusters found + + } + icon={CubesIcon} + variant={EmptyStateVariant.sm} + > {!showTerminated ? ( <> diff --git a/src/app/AccountDetails/components/AccountDescriptionList.tsx b/src/app/AccountDetails/components/AccountDescriptionList.tsx index a1d3724..47c13f2 100644 --- a/src/app/AccountDetails/components/AccountDescriptionList.tsx +++ b/src/app/AccountDetails/components/AccountDescriptionList.tsx @@ -14,9 +14,9 @@ export const AccountDescriptionList: React.FunctionComponent Account Name - {account.name} + {account.accountName} Account ID - {account.id} + {account.accountId} Clusters count {account.clusterCount} Cloud Provider @@ -38,6 +38,8 @@ export const AccountDescriptionList: React.FunctionComponent Last scanned at {parseScanTimestamp(account.lastScanTimestamp)} + Created at (in ClusterIQ) + {parseScanTimestamp(account.createdAt)} ); diff --git a/src/app/AccountDetails/components/AccountDetailsContent.tsx b/src/app/AccountDetails/components/AccountDetailsContent.tsx index f976d4c..191d23d 100644 --- a/src/app/AccountDetails/components/AccountDetailsContent.tsx +++ b/src/app/AccountDetails/components/AccountDetailsContent.tsx @@ -12,18 +12,18 @@ export const AccountDetailsContent: React.FunctionComponent {loading ? ( - ) : ( + ) : accountData ? ( - + <Title headingLevel="h2" size="lg" className="pf-v6-u-mt-sm" id="open-tabs-example-tabs-list-details-title"> Account details - + - )} + ) : null} ); }; diff --git a/src/app/AccountDetails/components/AccountHeader.tsx b/src/app/AccountDetails/components/AccountHeader.tsx index 81538f6..4b260ee 100644 --- a/src/app/AccountDetails/components/AccountHeader.tsx +++ b/src/app/AccountDetails/components/AccountHeader.tsx @@ -1,10 +1,10 @@ -import { Flex, FlexItem, Label, PageSection, PageSectionVariants, Title } from '@patternfly/react-core'; +import { Flex, FlexItem, Label, PageSection, Title } from '@patternfly/react-core'; import { AccountsHeaderProps } from './types'; import React from 'react'; export const AccountsHeader: React.FunctionComponent = ({ accountName, label }) => { return ( - + = ({ detai return ( <> - + Details} tabContentId={`tabContent${0}`} /> Clusters} tabContentId={`tabContent${1}`} /> - + diff --git a/src/app/AccountDetails/components/ClustersTable.tsx b/src/app/AccountDetails/components/ClustersTable.tsx index 748a830..d0a2d16 100644 --- a/src/app/AccountDetails/components/ClustersTable.tsx +++ b/src/app/AccountDetails/components/ClustersTable.tsx @@ -1,8 +1,10 @@ import { renderStatusLabel } from '@app/utils/renderUtils'; -import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; -import React from 'react'; +import { ThProps, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import { ClustersTableProps } from './types'; +import { sortItems } from '@app/utils/tableFilters'; +import { ClusterResponseApi } from '@api'; const columnNames = { id: 'ID', @@ -13,24 +15,55 @@ const columnNames = { }; export const ClustersTable: React.FunctionComponent = ({ clusters }) => { + const [activeSortIndex, setActiveSortIndex] = useState(1); + const [activeSortDirection, setActiveSortDirection] = useState<'asc' | 'desc'>('asc'); + + let sortedClusters = clusters; + if (activeSortIndex !== undefined && activeSortDirection) { + const sortFields: (keyof ClusterResponseApi)[] = [ + 'clusterId', + 'clusterName', + 'status', + 'provider', + 'instanceCount', + ]; + // status column (index 2) is not sortable + if (activeSortIndex !== 2) { + sortedClusters = sortItems(clusters, sortFields[activeSortIndex], activeSortDirection); + } + } + + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + defaultDirection: 'asc', + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }); + return ( - - + + - - + + - {clusters.map(cluster => ( - - + - + diff --git a/src/app/AccountDetails/components/types.ts b/src/app/AccountDetails/components/types.ts index 20f5ad4..276c291 100644 --- a/src/app/AccountDetails/components/types.ts +++ b/src/app/AccountDetails/components/types.ts @@ -1,4 +1,4 @@ -import { AccountData, Cluster } from '@app/types/types'; +import { AccountResponseApi, ClusterResponseApi } from '@api'; import React from 'react'; export interface AccountsHeaderProps { @@ -13,13 +13,13 @@ export interface AccountsTabsProps { export interface AccountDetailsContentProps { loading: boolean; - accountData: AccountData; + accountData: AccountResponseApi | null; } export interface AccountDescriptionListProps { - account: AccountData['accounts'][0]; + account: AccountResponseApi; } export interface ClustersTableProps { - clusters: Cluster[]; + clusters: ClusterResponseApi[]; } diff --git a/src/app/Accounts/Accounts.tsx b/src/app/Accounts/Accounts.tsx index 853093a..b0850b3 100644 --- a/src/app/Accounts/Accounts.tsx +++ b/src/app/Accounts/Accounts.tsx @@ -1,38 +1,34 @@ -import { PageSection, PageSectionVariants, Panel, TextContent, Text } from '@patternfly/react-core'; -import React, { useState } from 'react'; -import { useLocation } from 'react-router-dom'; +import { PageSection, Panel, Content } from '@patternfly/react-core'; +import React from 'react'; import AccountsToolbar from './components/AccountsToolbar'; import AccountsTable from './components/AccountsTable'; +import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs'; +import { ProviderApi } from '@api'; + +const filterParams = { + provider: parseAsArrayOf(parseAsStringEnum(Object.values(ProviderApi))).withDefault([]), + accountName: parseAsString.withDefault(''), +}; const Accounts: React.FunctionComponent = () => { - const [searchValue, setSearchValue] = useState(''); - const [providerSelections, setProviderSelections] = useState([]); - const location = useLocation(); - const queryParams = new URLSearchParams(location.search); - const cloudProviderFilter = queryParams.get('cloudProvider'); + const [{ provider, accountName }, setQuery] = useQueryStates(filterParams); return ( - - - Accounts - + + + Accounts + - + - setQuery({ accountName: value })} + providerSelections={provider} + setProviderSelections={value => setQuery({ provider: value || null })} /> + diff --git a/src/app/Accounts/components/AccountsTable.tsx b/src/app/Accounts/components/AccountsTable.tsx index 2501c49..2a0fe3d 100644 --- a/src/app/Accounts/components/AccountsTable.tsx +++ b/src/app/Accounts/components/AccountsTable.tsx @@ -1,49 +1,65 @@ -import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import { ThProps, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { AccountsTableItem } from '../types'; -import { getAccounts } from '@app/services/api'; +import { api, AccountResponseApi, ProviderApi } from '@api'; import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { TablePagination } from '@app/components/common/TablesPagination'; +import { searchItems, filterByProvider, sortItems, paginateItems } from '@app/utils/tableFilters'; +import { fetchAllPages } from '@app/utils/fetchAllPages'; export const AccountsTable: React.FunctionComponent<{ searchValue: string; - statusFilter: string | null; - cloudProviderFilter: string | null; - providerSelections; -}> = ({ searchValue, cloudProviderFilter, providerSelections }) => { - const [accountData, setAccountData] = useState([]); - const [filteredData, setFilteredData] = useState([]); + providerSelections: ProviderApi[] | null; +}> = ({ searchValue, providerSelections }) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(20); + const [allAccounts, setAllAccounts] = useState([]); const [loading, setLoading] = useState(true); + const [activeSortIndex, setActiveSortIndex] = useState(0); + const [activeSortDirection, setActiveSortDirection] = useState<'asc' | 'desc'>('asc'); + useEffect(() => { const fetchData = async () => { setLoading(true); try { - const fetchedAccounts = await getAccounts(); - setAccountData(fetchedAccounts.accounts); + const allItems = await fetchAllPages(async (page, pageSize) => { + const { data } = await api.accounts.accountsList({ page, page_size: pageSize }); + return { items: data.items || [], count: data.count || 0 }; + }); + setAllAccounts(allItems); } catch (error) { - console.error('Error fetching data:', error); + console.error('Error fetching accounts:', error); } finally { setLoading(false); } }; - fetchData(); }, []); - useEffect(() => { - let filtered = accountData.filter(account => account.name.toLowerCase().includes(searchValue.toLowerCase())); + let filtered = allAccounts; + filtered = searchItems(filtered, searchValue, ['accountName']); + filtered = filterByProvider(filtered, providerSelections); - if (providerSelections.length > 0) { - filtered = filtered.filter(cluster => providerSelections.includes(cluster.provider)); - } + if (activeSortIndex !== undefined && activeSortDirection) { + const sortFields: (keyof AccountResponseApi)[] = ['accountName', 'provider', 'clusterCount']; + filtered = sortItems(filtered, sortFields[activeSortIndex], activeSortDirection); + } - if (cloudProviderFilter) { - filtered = filtered.filter(cluster => cluster.provider === cloudProviderFilter); - } + const paginated = paginateItems(filtered, page, perPage); - setFilteredData(filtered); - }, [searchValue, accountData, providerSelections, cloudProviderFilter]); + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + defaultDirection: 'asc', + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }); const columnNames = { name: 'Name', @@ -52,23 +68,23 @@ export const AccountsTable: React.FunctionComponent<{ }; return ( - + <> {loading ? ( ) : ( -
{columnNames.id}{columnNames.name}{columnNames.id}{columnNames.name} {columnNames.status}{columnNames.cloudProvider}{columnNames.instanceCount}{columnNames.cloudProvider}{columnNames.instanceCount}
- {cluster.id} + {sortedClusters.map(cluster => ( +
+ {cluster.clusterId} {cluster.name}{cluster.clusterName} {renderStatusLabel(cluster.status)} {cluster.provider} {cluster.instanceCount}
+
- - - + + + - {filteredData.map(account => ( - + {paginated.map(account => ( + @@ -77,7 +93,14 @@ export const AccountsTable: React.FunctionComponent<{
{columnNames.name}{columnNames.cloudProvider}{columnNames.clusterCount}{columnNames.name}{columnNames.cloudProvider}{columnNames.clusterCount}
- {account.name} + {account.accountName} {account.provider} {account.clusterCount}
)} - + + ); }; diff --git a/src/app/Accounts/components/AccountsToolbar.tsx b/src/app/Accounts/components/AccountsToolbar.tsx index 73bfdd1..2f0c0ae 100644 --- a/src/app/Accounts/components/AccountsToolbar.tsx +++ b/src/app/Accounts/components/AccountsToolbar.tsx @@ -17,6 +17,9 @@ import { import { FilterIcon } from '@patternfly/react-icons'; import React from 'react'; import { AccountsToolbarProps } from '../types'; +import { ProviderApi } from '@api'; +import debounce from 'lodash.debounce'; +import { usePopperContainer } from '@app/hooks/usePopperContainer'; export const AccountsToolbar: React.FunctionComponent = ({ searchValue, @@ -24,77 +27,58 @@ export const AccountsToolbar: React.FunctionComponent = ({ providerSelections, setProviderSelections, }) => { - const onSearchChange = (value: string) => { - setSearchValue(value); - }; + const debouncedSearch = React.useMemo(() => debounce(setSearchValue, 300), [setSearchValue]); + + React.useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); - // Set up name search input const searchInput = ( onSearchChange(value)} - onClear={() => onSearchChange('')} + onChange={(_event, value) => debouncedSearch(value)} + onClear={() => debouncedSearch('')} /> ); - // Set up name input - const [isStatusMenuOpen, setIsStatusMenuOpen] = React.useState(false); - const statusToggleRef = React.useRef(null); - const statusMenuRef = React.useRef(null); - const handleStatusMenuKeys = (event: KeyboardEvent) => { - if (isStatusMenuOpen && statusMenuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsStatusMenuOpen(!isStatusMenuOpen); - statusToggleRef.current?.focus(); - } - } - }; - - const handleStatusClickOutside = (event: MouseEvent) => { - if (isStatusMenuOpen && !statusMenuRef.current?.contains(event.target as Node)) { - setIsStatusMenuOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleStatusMenuKeys); - window.addEventListener('click', handleStatusClickOutside); - return () => { - window.removeEventListener('keydown', handleStatusMenuKeys); - window.removeEventListener('click', handleStatusClickOutside); - }; - }, [isStatusMenuOpen, statusMenuRef]); - - // Set up provider input const [isProviderMenuOpen, setIsProviderMenuOpen] = React.useState(false); const providerToggleRef = React.useRef(null); const providerMenuRef = React.useRef(null); - const providerContainerRef = React.useRef(null); + const { containerRef: providerContainerRef, containerElement: providerContainerElement } = usePopperContainer(); - const handleProviderMenuKeys = (event: KeyboardEvent) => { - if (isProviderMenuOpen && providerMenuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsProviderMenuOpen(!isProviderMenuOpen); - providerToggleRef.current?.focus(); + const handleProviderMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleProviderClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleProviderMenuKeysRef.current = (event: KeyboardEvent) => { + if (isProviderMenuOpen && providerMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsProviderMenuOpen(!isProviderMenuOpen); + providerToggleRef.current?.focus(); + } } - } - }; + }; - const handleProviderClickOutside = (event: MouseEvent) => { - if (isProviderMenuOpen && !providerMenuRef.current?.contains(event.target as Node)) { - setIsProviderMenuOpen(false); - } - }; + handleProviderClickOutsideRef.current = (event: MouseEvent) => { + if (isProviderMenuOpen && !providerMenuRef.current?.contains(event.target as Node)) { + setIsProviderMenuOpen(false); + } + }; + }); React.useEffect(() => { - window.addEventListener('keydown', handleProviderMenuKeys); - window.addEventListener('click', handleProviderClickOutside); + const handleKeydown = (event: KeyboardEvent) => handleProviderMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleProviderClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); return () => { - window.removeEventListener('keydown', handleProviderMenuKeys); - window.removeEventListener('click', handleProviderClickOutside); + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); }; - }, [isProviderMenuOpen, providerMenuRef]); + }, [isProviderMenuOpen]); const onProviderMenuToggleClick = (ev: React.MouseEvent) => { ev.stopPropagation(); @@ -114,12 +98,13 @@ export const AccountsToolbar: React.FunctionComponent = ({ return; } - const itemStr = itemId.toString(); - + const provider = itemId as ProviderApi; setProviderSelections( - providerSelections.includes(itemStr) - ? providerSelections.filter(selection => selection !== itemStr) - : [itemStr, ...providerSelections] + providerSelections && providerSelections.includes(provider) + ? providerSelections.filter(selection => selection !== provider) + : provider + ? [provider, ...(providerSelections || [])] + : [] ); } @@ -128,9 +113,10 @@ export const AccountsToolbar: React.FunctionComponent = ({ ref={providerToggleRef} onClick={onProviderMenuToggleClick} isExpanded={isProviderMenuOpen} - {...(providerSelections.length > 0 && { - badge: {providerSelections.length}, - })} + {...(providerSelections && + providerSelections.length > 0 && { + badge: {providerSelections.length}, + })} style={ { width: '200px', @@ -150,13 +136,25 @@ export const AccountsToolbar: React.FunctionComponent = ({ > - + AWS - + Google Cloud - + Azure @@ -171,7 +169,7 @@ export const AccountsToolbar: React.FunctionComponent = ({ triggerRef={providerToggleRef} popper={providerMenu} popperRef={providerMenuRef} - appendTo={providerContainerRef.current || undefined} + appendTo={providerContainerElement || undefined} isVisible={isProviderMenuOpen} /> @@ -182,37 +180,44 @@ export const AccountsToolbar: React.FunctionComponent = ({ const [isAttributeMenuOpen, setIsAttributeMenuOpen] = React.useState(false); const attributeToggleRef = React.useRef(null); const attributeMenuRef = React.useRef(null); - const attributeContainerRef = React.useRef(null); + const { containerRef: attributeContainerRef, containerElement: attributeContainerElement } = usePopperContainer(); - const handleAttribueMenuKeys = (event: KeyboardEvent) => { - if (!isAttributeMenuOpen) { - return; - } - if ( - attributeMenuRef.current?.contains(event.target as Node) || - attributeToggleRef.current?.contains(event.target as Node) - ) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsAttributeMenuOpen(!isAttributeMenuOpen); - attributeToggleRef.current?.focus(); + const handleAttribueMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleAttributeClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleAttribueMenuKeysRef.current = (event: KeyboardEvent) => { + if (!isAttributeMenuOpen) { + return; } - } - }; + if ( + attributeMenuRef.current?.contains(event.target as Node) || + attributeToggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsAttributeMenuOpen(!isAttributeMenuOpen); + attributeToggleRef.current?.focus(); + } + } + }; - const handleAttributeClickOutside = (event: MouseEvent) => { - if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { - setIsAttributeMenuOpen(false); - } - }; + handleAttributeClickOutsideRef.current = (event: MouseEvent) => { + if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { + setIsAttributeMenuOpen(false); + } + }; + }); React.useEffect(() => { - window.addEventListener('keydown', handleAttribueMenuKeys); - window.addEventListener('click', handleAttributeClickOutside); + const handleKeydown = (event: KeyboardEvent) => handleAttribueMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleAttributeClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); return () => { - window.removeEventListener('keydown', handleAttribueMenuKeys); - window.removeEventListener('click', handleAttributeClickOutside); + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); }; - }, [isAttributeMenuOpen, attributeMenuRef]); + }, [isAttributeMenuOpen]); const onAttributeToggleClick = (ev: React.MouseEvent) => { ev.stopPropagation(); @@ -261,7 +266,7 @@ export const AccountsToolbar: React.FunctionComponent = ({ triggerRef={attributeToggleRef} popper={attributeMenu} popperRef={attributeMenuRef} - appendTo={attributeContainerRef.current || undefined} + appendTo={attributeContainerElement || undefined} isVisible={isAttributeMenuOpen} /> @@ -280,9 +285,9 @@ export const AccountsToolbar: React.FunctionComponent = ({ {attributeDropdown} setSearchValue('')} - deleteChipGroup={() => setSearchValue('')} + labels={searchValue !== '' ? [searchValue] : ([] as string[])} + deleteLabel={() => setSearchValue('')} + deleteLabelGroup={() => setSearchValue('')} categoryName="Name" showToolbarItem={activeAttributeMenu === 'Account'} > @@ -290,9 +295,9 @@ export const AccountsToolbar: React.FunctionComponent = ({ onProviderMenuSelect(undefined, chip as string)} - deleteChipGroup={() => setProviderSelections([])} + labels={providerSelections || []} + deleteLabel={(_category, chip) => onProviderMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setProviderSelections([])} categoryName="Provider" showToolbarItem={activeAttributeMenu === 'Provider'} > diff --git a/src/app/Accounts/types.ts b/src/app/Accounts/types.ts index e4eca44..0ae1edd 100644 --- a/src/app/Accounts/types.ts +++ b/src/app/Accounts/types.ts @@ -1,20 +1,8 @@ -export interface AccountsTableItem { - clusterCount: number; - name: string; - provider: string; -} - -export interface AccountsTableProps { - cloudProviderFilter: string | null; - providerSelections; - searchValue: string; - statusFilter: string | null; -} +import { ProviderApi } from '@api'; export interface AccountsToolbarProps { searchValue: string; setSearchValue: (value: string) => void; - providerSelections: string[]; - setProviderSelections: (value: string[]) => void; - onSearchChange: (value: string) => void; + providerSelections: ProviderApi[] | null; + setProviderSelections: (value: ProviderApi[] | null) => void; } diff --git a/src/app/Observe/AuditLogs/AuditLogs.tsx b/src/app/Actions/AuditLogs/AuditLogs.tsx similarity index 64% rename from src/app/Observe/AuditLogs/AuditLogs.tsx rename to src/app/Actions/AuditLogs/AuditLogs.tsx index 60739ab..4429b88 100644 --- a/src/app/Observe/AuditLogs/AuditLogs.tsx +++ b/src/app/Actions/AuditLogs/AuditLogs.tsx @@ -1,14 +1,15 @@ import React from 'react'; -import { PageSection, PageSectionVariants, Panel, Text, TextContent } from '@patternfly/react-core'; -import AuditLogsTableToolbar from '@app/Observe/AuditLogs/AuditLogsTableToolbar.tsx'; +import { PageSection, Panel, Content } from '@patternfly/react-core'; +import AuditLogsTableToolbar from './AuditLogsTableToolbar'; import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs'; -import { CloudProvider, ClusterActions, ResultStatus } from '@app/types/types.tsx'; -import { AuditLogsTable } from '@app/Observe/AuditLogs/AuditLogsTable.tsx'; +import { ActionOperations, ResultStatus } from '@app/types/types.tsx'; +import { ProviderApi } from '@api'; +import { AuditLogsTable } from './AuditLogsTable.tsx'; const filterParams = { accountName: parseAsString.withDefault(''), - action: parseAsArrayOf(parseAsStringEnum(Object.values(ClusterActions))).withDefault([]), - provider: parseAsArrayOf(parseAsStringEnum(Object.values(CloudProvider))).withDefault([]), + action: parseAsArrayOf(parseAsStringEnum(Object.values(ActionOperations))).withDefault([]), + provider: parseAsArrayOf(parseAsStringEnum(Object.values(ProviderApi))).withDefault([]), result: parseAsArrayOf(parseAsStringEnum(Object.values(ResultStatus))).withDefault([]), triggered_by: parseAsString.withDefault(''), }; @@ -18,12 +19,12 @@ const AuditLogs: React.FunctionComponent = () => { return ( - - - Audit logs - + + + Audit logs + - + ( - - } /> - + ); export const AuditLogsTable: React.FunctionComponent = ({ @@ -41,15 +39,15 @@ export const AuditLogsTable: React.FunctionComponent = ({ const [perPage, setPerPage] = React.useState(10); const [filteredCount, setFilteredCount] = useState(0); - const [data, setData] = useState([]); + const [data, setData] = useState([]); const [loading, setLoading] = useState(true); - const [filteredData, setFilteredData] = useState([]); + const [filteredData, setFilteredData] = useState([]); useEffect(() => { const fetchData = async () => { try { - const systemEvents = await getSystemEvents(); - setData(systemEvents); + const { data: systemEvents } = await api.events.eventsList(); + setData(systemEvents.items || []); } catch (error) { console.error('Error fetching system wide events:', error); } finally { @@ -65,15 +63,15 @@ export const AuditLogsTable: React.FunctionComponent = ({ let filtered = data; if (accountName) { - filtered = filtered.filter(event => event.account_id?.toLowerCase().includes(accountName.toLowerCase())); + filtered = filtered.filter(event => event.accountId?.toLowerCase().includes(accountName.toLowerCase())); } if (action?.length) { - filtered = filtered.filter(event => action.includes(event.action_name as ClusterActions)); + filtered = filtered.filter(event => action.includes(event.action as ActionOperations)); } if (provider?.length) { - filtered = filtered.filter(event => provider.includes(event.provider as CloudProvider)); + filtered = filtered.filter(event => event.provider && provider.some(p => p === event.provider)); } if (result?.length) { @@ -81,29 +79,33 @@ export const AuditLogsTable: React.FunctionComponent = ({ } if (triggered_by) { - filtered = filtered.filter(event => event.triggered_by?.toLowerCase().includes(triggered_by.toLowerCase())); + filtered = filtered.filter(event => event.triggeredBy?.toLowerCase().includes(triggered_by.toLowerCase())); } setFilteredCount(filtered.length); - setFilteredData(getPaginatedSlice(filtered, page, perPage)); + setFilteredData(paginateItems(filtered, page, perPage)); }, [data, accountName, action, provider, result, triggered_by, page, perPage]); - const getSortableRowValues = (event: AuditEvent): (string | number | null)[] => { - const { action_name, result, resource_id, account_id, provider, triggered_by, description, event_timestamp } = - event; + const getSortableRowValues = (event: SystemEventResponseApi): (string | number | null)[] => { + const { action, result, resourceId, accountId, provider, triggeredBy, description, timestamp } = event; return [ - action_name, - result, - resource_id, - account_id ?? null, + action ?? null, + result ?? null, + resourceId ?? null, + accountId ?? null, provider ?? null, - triggered_by, + triggeredBy ?? null, description ?? null, - event_timestamp, + timestamp ?? null, ]; }; - const { sortedData, getSortParams } = useTableSort(filteredData, getSortableRowValues, 7, 'desc'); + const { sortedData, getSortParams } = useTableSort( + filteredData, + getSortableRowValues, + 7, + 'desc' + ); if (loading) return ; if (filteredCount === 0) return ; @@ -113,40 +115,42 @@ export const AuditLogsTable: React.FunctionComponent = ({ - - + - + + {sortedData.map(event => ( - - - {/*TODO. Hardcoded, adjust later if needed*/} - - + + - + - + {/*TODO. Hardcoded, adjust later if needed*/} + + ))} diff --git a/src/app/Observe/AuditLogs/AuditLogsTableToolbar.tsx b/src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx similarity index 62% rename from src/app/Observe/AuditLogs/AuditLogsTableToolbar.tsx rename to src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx index 20ec7be..8c7c97c 100644 --- a/src/app/Observe/AuditLogs/AuditLogsTableToolbar.tsx +++ b/src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx @@ -18,7 +18,9 @@ import { FilterIcon } from '@patternfly/react-icons'; import React from 'react'; import { AuditLogsTableToolbarProps } from './types'; import debounce from 'lodash.debounce'; -import { CloudProvider, ClusterActions, ResultStatus } from '@app/types/types'; +import { ActionOperations, ResultStatus } from '@app/types/types'; +import { usePopperContainer } from '@app/hooks/usePopperContainer'; +import { ProviderApi } from '@api'; type AttributeMenuOption = 'Account' | 'Provider' | 'Action' | 'Result' | 'TriggeredBy'; @@ -69,31 +71,38 @@ export const AuditLogsTableToolbar: React.FunctionComponent(false); const providerToggleRef = React.useRef(null); const providerMenuRef = React.useRef(null); - const providerContainerRef = React.useRef(null); + const { containerRef: providerContainerRef, containerElement: providerContainerElement } = usePopperContainer(); - const handleProviderMenuKeys = (event: KeyboardEvent) => { - if (isProviderMenuOpen && providerMenuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsProviderMenuOpen(!isProviderMenuOpen); - providerToggleRef.current?.focus(); + const handleProviderMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleProviderClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleProviderMenuKeysRef.current = (event: KeyboardEvent) => { + if (isProviderMenuOpen && providerMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsProviderMenuOpen(!isProviderMenuOpen); + providerToggleRef.current?.focus(); + } } - } - }; + }; - const handleProviderClickOutside = (event: MouseEvent) => { - if (isProviderMenuOpen && !providerMenuRef.current?.contains(event.target as Node)) { - setIsProviderMenuOpen(false); - } - }; + handleProviderClickOutsideRef.current = (event: MouseEvent) => { + if (isProviderMenuOpen && !providerMenuRef.current?.contains(event.target as Node)) { + setIsProviderMenuOpen(false); + } + }; + }); React.useEffect(() => { - window.addEventListener('keydown', handleProviderMenuKeys); - window.addEventListener('click', handleProviderClickOutside); + const handleKeydown = (event: KeyboardEvent) => handleProviderMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleProviderClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); return () => { - window.removeEventListener('keydown', handleProviderMenuKeys); - window.removeEventListener('click', handleProviderClickOutside); + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); }; - }, [isProviderMenuOpen, providerMenuRef]); + }, [isProviderMenuOpen]); const onProviderMenuToggleClick = (ev: React.MouseEvent) => { ev.stopPropagation(); @@ -113,7 +122,7 @@ export const AuditLogsTableToolbar: React.FunctionComponent selection !== provider) @@ -151,16 +160,24 @@ export const AuditLogsTableToolbar: React.FunctionComponent - + AWS - + Google Cloud Azure @@ -176,7 +193,7 @@ export const AuditLogsTableToolbar: React.FunctionComponent @@ -186,31 +203,38 @@ export const AuditLogsTableToolbar: React.FunctionComponent(false); const actionToggleRef = React.useRef(null); const actionMenuRef = React.useRef(null); - const actionContainerRef = React.useRef(null); + const { containerRef: actionContainerRef, containerElement: actionContainerElement } = usePopperContainer(); - const handleActionMenuKeys = (event: KeyboardEvent) => { - if (isActionMenuOpen && actionMenuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsActionMenuOpen(!isActionMenuOpen); - actionToggleRef.current?.focus(); + const handleActionMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleActionClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleActionMenuKeysRef.current = (event: KeyboardEvent) => { + if (isActionMenuOpen && actionMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsActionMenuOpen(!isActionMenuOpen); + actionToggleRef.current?.focus(); + } } - } - }; + }; - const handleActionClickOutside = (event: MouseEvent) => { - if (isActionMenuOpen && !actionMenuRef.current?.contains(event.target as Node)) { - setIsActionMenuOpen(false); - } - }; + handleActionClickOutsideRef.current = (event: MouseEvent) => { + if (isActionMenuOpen && !actionMenuRef.current?.contains(event.target as Node)) { + setIsActionMenuOpen(false); + } + }; + }); React.useEffect(() => { - window.addEventListener('keydown', handleActionMenuKeys); - window.addEventListener('click', handleActionClickOutside); + const handleKeydown = (event: KeyboardEvent) => handleActionMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleActionClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); return () => { - window.removeEventListener('keydown', handleActionMenuKeys); - window.removeEventListener('click', handleActionClickOutside); + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); }; - }, [isActionMenuOpen, actionMenuRef]); + }, [isActionMenuOpen]); const onActionMenuToggleClick = (ev: React.MouseEvent) => { ev.stopPropagation(); @@ -230,7 +254,7 @@ export const AuditLogsTableToolbar: React.FunctionComponent item !== selectedAction) @@ -263,11 +287,19 @@ export const AuditLogsTableToolbar: React.FunctionComponent - - {ClusterActions.PowerOn} + + {ActionOperations.POWER_ON} - - {ClusterActions.PowerOff} + + {ActionOperations.POWER_OFF} @@ -281,7 +313,7 @@ export const AuditLogsTableToolbar: React.FunctionComponent @@ -291,31 +323,38 @@ export const AuditLogsTableToolbar: React.FunctionComponent(false); const resultToggleRef = React.useRef(null); const resultMenuRef = React.useRef(null); - const resultContainerRef = React.useRef(null); + const { containerRef: resultContainerRef, containerElement: resultContainerElement } = usePopperContainer(); - const handleResultMenuKeys = (event: KeyboardEvent) => { - if (isResultMenuOpen && resultMenuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsResultMenuOpen(!isResultMenuOpen); - resultToggleRef.current?.focus(); + const handleResultMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleResultClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleResultMenuKeysRef.current = (event: KeyboardEvent) => { + if (isResultMenuOpen && resultMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsResultMenuOpen(!isResultMenuOpen); + resultToggleRef.current?.focus(); + } } - } - }; + }; - const handleResultClickOutside = (event: MouseEvent) => { - if (isResultMenuOpen && !resultMenuRef.current?.contains(event.target as Node)) { - setIsResultMenuOpen(false); - } - }; + handleResultClickOutsideRef.current = (event: MouseEvent) => { + if (isResultMenuOpen && !resultMenuRef.current?.contains(event.target as Node)) { + setIsResultMenuOpen(false); + } + }; + }); React.useEffect(() => { - window.addEventListener('keydown', handleResultMenuKeys); - window.addEventListener('click', handleResultClickOutside); + const handleKeydown = (event: KeyboardEvent) => handleResultMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleResultClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); return () => { - window.removeEventListener('keydown', handleResultMenuKeys); - window.removeEventListener('click', handleResultClickOutside); + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); }; - }, [isResultMenuOpen, resultMenuRef]); + }, [isResultMenuOpen]); const onResultMenuToggleClick = (ev: React.MouseEvent) => { ev.stopPropagation(); @@ -389,7 +428,7 @@ export const AuditLogsTableToolbar: React.FunctionComponent @@ -400,37 +439,44 @@ export const AuditLogsTableToolbar: React.FunctionComponent(null); const attributeMenuRef = React.useRef(null); - const attributeContainerRef = React.useRef(null); + const { containerRef: attributeContainerRef, containerElement: attributeContainerElement } = usePopperContainer(); - const handleAttributeMenuKeys = (event: KeyboardEvent) => { - if (!isAttributeMenuOpen) { - return; - } - if ( - attributeMenuRef.current?.contains(event.target as Node) || - attributeToggleRef.current?.contains(event.target as Node) - ) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsAttributeMenuOpen(!isAttributeMenuOpen); - attributeToggleRef.current?.focus(); + const handleAttributeMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleAttributeClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleAttributeMenuKeysRef.current = (event: KeyboardEvent) => { + if (!isAttributeMenuOpen) { + return; } - } - }; + if ( + attributeMenuRef.current?.contains(event.target as Node) || + attributeToggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsAttributeMenuOpen(!isAttributeMenuOpen); + attributeToggleRef.current?.focus(); + } + } + }; - const handleAttributeClickOutside = (event: MouseEvent) => { - if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { - setIsAttributeMenuOpen(false); - } - }; + handleAttributeClickOutsideRef.current = (event: MouseEvent) => { + if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { + setIsAttributeMenuOpen(false); + } + }; + }); React.useEffect(() => { - window.addEventListener('keydown', handleAttributeMenuKeys); - window.addEventListener('click', handleAttributeClickOutside); + const handleKeydown = (event: KeyboardEvent) => handleAttributeMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleAttributeClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); return () => { - window.removeEventListener('keydown', handleAttributeMenuKeys); - window.removeEventListener('click', handleAttributeClickOutside); + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); }; - }, [isAttributeMenuOpen, attributeMenuRef]); + }, [isAttributeMenuOpen]); const onAttributeToggleClick = (ev: React.MouseEvent) => { ev.stopPropagation(); @@ -485,7 +531,7 @@ export const AuditLogsTableToolbar: React.FunctionComponent @@ -507,45 +553,45 @@ export const AuditLogsTableToolbar: React.FunctionComponent {attributeDropdown} setSearchValue('')} - deleteChipGroup={() => setSearchValue('')} + labels={searchValue !== '' ? [searchValue] : []} + deleteLabel={() => setSearchValue('')} + deleteLabelGroup={() => setSearchValue('')} categoryName="Account" showToolbarItem={activeAttributeMenu === 'Account'} > {searchInput} setTriggeredBy('')} - deleteChipGroup={() => setTriggeredBy('')} + labels={triggered_by !== '' ? [triggered_by] : []} + deleteLabel={() => setTriggeredBy('')} + deleteLabelGroup={() => setTriggeredBy('')} categoryName="TriggeredBy" showToolbarItem={activeAttributeMenu === 'TriggeredBy'} > {triggeredByInput} onResultMenuSelect(undefined, chip as string)} - deleteChipGroup={() => setResult([])} + labels={result || []} + deleteLabel={(_category, chip) => onResultMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setResult([])} categoryName="Result" showToolbarItem={activeAttributeMenu === 'Result'} > {resultSelect} onProviderMenuSelect(undefined, chip as string)} - deleteChipGroup={() => setProviderSelections([])} + labels={providerSelections || []} + deleteLabel={(_category, chip) => onProviderMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setProviderSelections([])} categoryName="Provider" showToolbarItem={activeAttributeMenu === 'Provider'} > {providerSelect} onActionMenuSelect(undefined, chip as string)} - deleteChipGroup={() => setAction([])} + labels={action || []} + deleteLabel={(_category, chip) => onActionMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setAction([])} categoryName="Action" showToolbarItem={activeAttributeMenu === 'Action'} > diff --git a/src/app/Observe/AuditLogs/types.ts b/src/app/Actions/AuditLogs/types.ts similarity index 51% rename from src/app/Observe/AuditLogs/types.ts rename to src/app/Actions/AuditLogs/types.ts index 03ec87c..1325d0e 100644 --- a/src/app/Observe/AuditLogs/types.ts +++ b/src/app/Actions/AuditLogs/types.ts @@ -1,22 +1,23 @@ -import { CloudProvider, ClusterActions, ResultStatus } from '@app/types/types'; +import { ActionOperations, ResultStatus } from '@app/types/types'; +import { ProviderApi } from '@api'; export interface AuditLogsTableToolbarProps { searchValue: string; setSearchValue: (value: string) => void; - action: ClusterActions[] | null; - setAction: (value: ClusterActions[] | null) => void; + action: ActionOperations[] | null; + setAction: (value: ActionOperations[] | null) => void; result: ResultStatus[] | null; setResult: (value: ResultStatus[] | null) => void; - providerSelections: CloudProvider[] | null; - setProviderSelections: (value: CloudProvider[] | null) => void; + providerSelections: ProviderApi[] | null; + setProviderSelections: (value: ProviderApi[] | null) => void; triggered_by: string; setTriggeredBy: (value: string) => void; } export interface AuditLogsTableProps { accountName?: string; - action?: ClusterActions[]; - provider?: CloudProvider[]; + action?: ActionOperations[]; + provider?: ProviderApi[]; result?: ResultStatus[]; triggered_by?: string; } diff --git a/src/app/Actions/Scheduler/Schedule.tsx b/src/app/Actions/Scheduler/Schedule.tsx new file mode 100644 index 0000000..cb88a80 --- /dev/null +++ b/src/app/Actions/Scheduler/Schedule.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { ModalPowerManagement } from './components/ModalPowerManagement'; +import { Flex, FlexItem, Button, PageSection, Panel, Content } from '@patternfly/react-core'; +import ScheduleActionsTable from './components/ActionsTable'; +import ScheduleActionsTableToolbar from './components/ActionsToolBar'; +import { ActionOperations, ActionTypes, ActionStatus } from '@app/types/types'; +import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs'; + +import { parseAsBooleanNullable } from '@app/utils/parseFuncs'; + +// Nullable boolean: "true" -> true, "false" -> false, missing/other -> null + +const filterParams = { + accountId: parseAsString.withDefault(''), + action: parseAsArrayOf(parseAsStringEnum(Object.values(ActionOperations))).withDefault([]), + type: parseAsStringEnum(Object.values(ActionTypes)), + status: parseAsStringEnum(Object.values(ActionStatus)), + enabled: parseAsBooleanNullable, +}; + +const Scheduler: React.FunctionComponent = () => { + const [{ accountId, action, type, status, enabled }, setQuery] = useQueryStates(filterParams); + const [isModalOpen, setIsModalOpen] = React.useState(false); + const [reloadFlag, setReloadFlag] = React.useState(0); + + const onClick = () => { + setIsModalOpen(true); + }; + + const resetModalState = () => { + setIsModalOpen(false); + }; + + return ( + + + + + + Scheduled Actions + + + + + + + + + + setQuery({ accountId: value })} + actionOperation={action} + setOperation={value => setQuery({ action: value || [] })} + actionType={type} + setType={value => setQuery({ type: value })} + actionStatus={status} + setStatus={value => setQuery({ status: value })} + actionEnabled={enabled} + setEnabled={value => setQuery({ enabled: value })} + /> + + + setReloadFlag(k => k + 1)} + /> + + + ); +}; + +export default Scheduler; diff --git a/src/app/Actions/Scheduler/components/AccountSelector.tsx b/src/app/Actions/Scheduler/components/AccountSelector.tsx new file mode 100644 index 0000000..6d6243e --- /dev/null +++ b/src/app/Actions/Scheduler/components/AccountSelector.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { + Button, + FormGroup, + Select, + SelectOption, + MenuToggle, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Tooltip, +} from '@patternfly/react-core'; +import { AccountResponseApi } from '@api'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +interface AccountTypeaheadSelectProps { + accounts: AccountResponseApi[]; + selectedAccount: AccountResponseApi | null; + onSelectAccount: (account: AccountResponseApi | null) => void; + onClearAccount: () => void; +} + +export const AccountTypeaheadSelect: React.FunctionComponent = ({ + accounts, + selectedAccount, + onSelectAccount, + onClearAccount, +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + + const safeAccounts = React.useMemo(() => (Array.isArray(accounts) ? accounts : []), [accounts]); + + const filteredAccounts = React.useMemo(() => { + const q = inputValue.trim().toLowerCase(); + if (!q) return safeAccounts; + + return safeAccounts.filter(a => { + const haystack = `${a.accountName ?? ''} ${a.accountId ?? ''}`.toLowerCase(); + return haystack.includes(q); + }); + }, [safeAccounts, inputValue]); + + const onSelect = (_event?: React.MouseEvent, value?: string | number) => { + const id = String(value ?? ''); + const acc = safeAccounts.find(a => a.accountId === id) ?? null; + + // Keep input in sync with selection for a predictable UX + setInputValue(acc ? `${acc.accountName} (${acc.accountId})` : ''); + onSelectAccount(acc); + setIsOpen(false); + }; + + return ( + + + + ); +}; diff --git a/src/app/Actions/Scheduler/components/ActionsKebabMenu.tsx b/src/app/Actions/Scheduler/components/ActionsKebabMenu.tsx new file mode 100644 index 0000000..a29f7d9 --- /dev/null +++ b/src/app/Actions/Scheduler/components/ActionsKebabMenu.tsx @@ -0,0 +1,27 @@ +import { api, ActionResponseApi } from '@api'; + +export const rowActions = (action: ActionResponseApi, reloadActions: () => Promise) => [ + { + title: 'Enable', + onClick: async () => { + await api.actions.actionsEnable(action.id); + await reloadActions(); + }, + }, + { + title: 'Disable', + onClick: async () => { + await api.actions.actionsDisable(action.id); + await reloadActions(); + }, + }, + { isSeparator: true }, + { + title: 'Delete', + isDanger: true, + onClick: async () => { + await api.actions.actionsDelete(action.id); + await reloadActions(); + }, + }, +]; diff --git a/src/app/Actions/Scheduler/components/ActionsTable.tsx b/src/app/Actions/Scheduler/components/ActionsTable.tsx new file mode 100644 index 0000000..30f31f6 --- /dev/null +++ b/src/app/Actions/Scheduler/components/ActionsTable.tsx @@ -0,0 +1,157 @@ +import { renderActionTypeLabel, renderOperationLabel, renderActionStatusLabel } from '@app/utils/renderUtils'; +import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import { Label } from '@patternfly/react-core'; +import React, { useState, useEffect } from 'react'; +import { ActionStatus, ActionOperations, ActionTypes } from '@app/types/types'; +import { Link } from 'react-router-dom'; +import { api, ActionResponseApi } from '@api'; +import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { TablePagination } from '@app/components/common/TablesPagination'; +import { paginateItems } from '@app/utils/tableFilters'; +import { fetchAllPages } from '@app/utils/fetchAllPages'; +import { ActionsColumn } from '@patternfly/react-table'; +import { rowActions } from './ActionsKebabMenu'; + +export const ScheduleActionsTable: React.FunctionComponent<{ + actionType: ActionTypes | null; + actionOperation: ActionOperations[] | null; + actionStatus: ActionStatus | null; + actionEnabled: boolean | null; + accountId: string | null; + reloadFlag: number; +}> = ({ actionType, actionOperation, actionStatus, actionEnabled, accountId, reloadFlag }) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + const [allActions, setAllActions] = useState([]); + const [filteredActions, setFilteredActions] = useState([]); + const [filteredCount, setFilteredCount] = useState(0); + const [loading, setLoading] = useState(true); + + // useEffect logic abstracted in this function to be reused in the kebab menu + const reloadActions = async () => { + setLoading(true); + try { + const allItems = await fetchAllPages(async (page, pageSize) => { + const { data } = await api.schedule.scheduleList({ + page, + page_size: pageSize, + }); + return { items: data.items || [], count: data.count || 0 }; + }); + setAllActions(allItems); + } catch (error) { + console.error('Error fetching ScheduleActions:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + reloadActions(); + }, [reloadFlag]); + + // Apply filters when data or filter props change + useEffect(() => { + let filtered = allActions; + + if (actionType) { + filtered = filtered.filter(item => item.type === actionType); + } + + if (accountId) { + filtered = filtered.filter(item => item.accountId?.includes(accountId)); + } + + if (actionOperation?.length) { + filtered = filtered.filter(item => { + return actionOperation.includes(item.operation as never); + }); + } + + if (actionStatus) { + filtered = filtered.filter(item => item.status === actionStatus); + } + + if (actionEnabled !== null) { + filtered = filtered.filter(item => item.enabled === actionEnabled); + } + + setFilteredCount(filtered.length); + setFilteredActions(paginateItems(filtered, page, perPage)); + }, [allActions, actionType, accountId, actionOperation, actionStatus, actionEnabled, page, perPage]); + + const columnNames = { + id: 'ID', + type: 'Action Type', + time: 'Time', + cronExpression: 'Cron Expression', + operation: 'Operation', + status: 'Status', + clusterId: 'Cluster ID', + accountId: 'Account ID', + region: 'Region', + enabled: 'Enabled', + }; + + return ( + <> + {loading ? ( + + ) : ( +
{columnNames.action}{columnNames.result} {columnNames.resource}{columnNames.action} {columnNames.account} {columnNames.provider}{columnNames.loggedBy}{columnNames.triggeredBy} {columnNames.description}{columnNames.result} {columnNames.date}
{event.action_name} - {getResultIcon(event.result as ResultStatus)} {event.result} - + - {event.resource_id} + {event.resourceId} {event.account_id}{renderOperationLabel(event.action)} + {event.accountId} + {event.provider}{event.triggered_by}{event.triggeredBy} {event.description}{event.event_timestamp} + {getResultIcon(event.result as ResultStatus)} {event.result} + {event.timestamp}
+ + + + + + + + + + + + + + + + {filteredActions.map(action => ( + + + + + + + + + + + + {/* Kebab actions per row */} + + + ))} + +
{columnNames.id}{columnNames.type}{columnNames.time}{columnNames.cronExpression}{columnNames.operation}{columnNames.status}{columnNames.clusterId}{columnNames.region}{columnNames.accountId}{columnNames.enabled}
{action.id}{renderActionTypeLabel(action.type)}{action.type !== ActionTypes.CRON_ACTION ? action.time : '-'} + {action.type === ActionTypes.CRON_ACTION ? action.cronExpression : '-'} + {renderOperationLabel(action.operation)}{renderActionStatusLabel(action.status)} + {action.clusterId} + {action.region} + {action.accountId} + + {action.enabled ? : } + + +
+ )} + + + ); +}; + +export default ScheduleActionsTable; diff --git a/src/app/Actions/Scheduler/components/ActionsToolBar.tsx b/src/app/Actions/Scheduler/components/ActionsToolBar.tsx new file mode 100644 index 0000000..6e52cb9 --- /dev/null +++ b/src/app/Actions/Scheduler/components/ActionsToolBar.tsx @@ -0,0 +1,731 @@ +import { + Badge, + Menu, + MenuContent, + MenuItem, + MenuList, + MenuToggle, + Popper, + SearchInput, + Toolbar, + ToolbarContent, + ToolbarFilter, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; +import React from 'react'; +import debounce from 'lodash.debounce'; +import { ActionTypes, ActionOperations, ActionStatus } from '@app/types/types'; +import { usePopperContainer } from '@app/hooks/usePopperContainer'; + +type AttributeMenuOption = 'Account' | 'Action' | 'Type' | 'Status' | 'Enabled'; + +export interface SchedulerTableToolbarProps { + searchValue: string; + setSearchValue: (value: string) => void; + actionType: ActionTypes | null; + setType: (value: ActionTypes | null) => void; + actionOperation: ActionOperations[] | null; + setOperation: (value: ActionOperations[] | null) => void; + actionStatus: ActionStatus | null; + setStatus: (value: ActionStatus | null) => void; + actionEnabled: boolean | null; + setEnabled: (value: boolean | null) => void; +} + +export const ScheduleActionsTableToolbar: React.FunctionComponent = ({ + searchValue, + setSearchValue, + actionType, + setType, + actionOperation, + setOperation, + actionStatus, + setStatus, + actionEnabled, + setEnabled, +}) => { + const debouncedSearch = React.useMemo(() => debounce(setSearchValue, 300), [setSearchValue]); + + React.useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + + // Set up account name search input + const searchInput = ( + debouncedSearch(value)} + onClear={() => debouncedSearch('')} + /> + ); + + // Actions filter setup + const [isActionMenuOpen, setIsActionMenuOpen] = React.useState(false); + const actionToggleRef = React.useRef(null); + const actionMenuRef = React.useRef(null); + const { containerRef: actionContainerRef, containerElement: actionContainerElement } = usePopperContainer(); + + const handleActionMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleActionClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleActionMenuKeysRef.current = (event: KeyboardEvent) => { + if (isActionMenuOpen && actionMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsActionMenuOpen(!isActionMenuOpen); + actionToggleRef.current?.focus(); + } + } + }; + + handleActionClickOutsideRef.current = (event: MouseEvent) => { + if (isActionMenuOpen && !actionMenuRef.current?.contains(event.target as Node)) { + setIsActionMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleActionMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleActionClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isActionMenuOpen]); + + const onActionMenuToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (actionMenuRef.current) { + const firstElement = actionMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsActionMenuOpen(!isActionMenuOpen); + }; + + function onActionMenuSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { + if (typeof itemId === 'undefined') { + return; + } + + const selectedAction = itemId as ActionOperations; + setOperation( + actionOperation && actionOperation.includes(selectedAction) + ? actionOperation.filter(item => item !== selectedAction) + : selectedAction + ? [selectedAction, ...(actionOperation || [])] + : [] + ); + } + + const actionToggle = ( + 0 && { + badge: {actionOperation.length}, + })} + style={ + { + width: '200px', + } as React.CSSProperties + } + > + Filter by action + + ); + + const actionMenu = ( + + + + + {ActionOperations.POWER_ON} + + + {ActionOperations.POWER_OFF} + + + + + ); + + const actionSelect = ( +
+ +
+ ); + + // Type filter setup + const [isTypeMenuOpen, setIsTypeMenuOpen] = React.useState(false); + const typeToggleRef = React.useRef(null); + const typeMenuRef = React.useRef(null); + const { containerRef: typeContainerRef, containerElement: typeContainerElement } = usePopperContainer(); + + const handleTypeMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleTypeClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleTypeMenuKeysRef.current = (event: KeyboardEvent) => { + if (isTypeMenuOpen && typeMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsTypeMenuOpen(!isTypeMenuOpen); + typeToggleRef.current?.focus(); + } + } + }; + + handleTypeClickOutsideRef.current = (event: MouseEvent) => { + if (isTypeMenuOpen && !typeMenuRef.current?.contains(event.target as Node)) { + setIsTypeMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleTypeMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleTypeClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isTypeMenuOpen]); + + const onTypeMenuToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (typeMenuRef.current) { + const firstElement = typeMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsTypeMenuOpen(!isTypeMenuOpen); + }; + + function onTypeMenuSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { + if (typeof itemId === 'undefined') { + return; + } + + const selectedType = itemId as ActionTypes | null; + // Toggle type selection - clear if already selected, otherwise set new value + setType(actionType === selectedType ? null : selectedType); + setIsTypeMenuOpen(false); + } + + const typeToggleLabel = (t?: ActionTypes | null) => { + if (!t) return 'Filter by type'; + + switch (t) { + case ActionTypes.INSTANT_ACTION: + return 'Instant Action'; + case ActionTypes.SCHEDULED_ACTION: + return 'Scheduled Action'; + case ActionTypes.CRON_ACTION: + return 'Cron Action'; + default: + return 'Filter by type'; + } + }; + + const actionTypeLabel = (t: ActionTypes | null) => { + if (!t) return ''; + if (t === ActionTypes.INSTANT_ACTION) return 'Instant Action'; + if (t === ActionTypes.SCHEDULED_ACTION) return 'Scheduled Action'; + return 'Cron Action'; + }; + + const typeToggle = ( + 1, + })} + style={ + { + width: '200px', + } as React.CSSProperties + } + > + {typeToggleLabel(actionType)} + + ); + + const typeMenu = ( + + + + + Instant Action + + + Scheduled Action + + + Cron Action + + + + + ); + + const typeSelect = ( +
+ +
+ ); + + // Status filter setup + const [isStatusMenuOpen, setIsStatusMenuOpen] = React.useState(false); + const statusToggleRef = React.useRef(null); + const statusMenuRef = React.useRef(null); + const { containerRef: statusContainerRef, containerElement: statusContainerElement } = usePopperContainer(); + + const handleStatusMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleStatusClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleStatusMenuKeysRef.current = (event: KeyboardEvent) => { + if (isStatusMenuOpen && statusMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsStatusMenuOpen(!isStatusMenuOpen); + statusToggleRef.current?.focus(); + } + } + }; + + handleStatusClickOutsideRef.current = (event: MouseEvent) => { + if (isStatusMenuOpen && !statusMenuRef.current?.contains(event.target as Node)) { + setIsStatusMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleStatusMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleStatusClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isStatusMenuOpen]); + + const onStatusMenuToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (statusMenuRef.current) { + const firstElement = statusMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsStatusMenuOpen(!isStatusMenuOpen); + }; + + function onStatusMenuSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { + if (typeof itemId === 'undefined') { + return; + } + + const selectedStatus = itemId as ActionStatus | null; + // Toggle status selection + setStatus(status === selectedStatus ? null : selectedStatus); + setIsStatusMenuOpen(false); + } + + const statusToggle = ( + 1, + })} + style={ + { + width: '200px', + } as React.CSSProperties + } + > + {status || 'Filter by status'} + + ); + + const statusMenu = ( + + + + + {ActionStatus.Success} + + + {ActionStatus.Failed} + + + {ActionStatus.Pending} + + + {ActionStatus.Unknown} + + + + + ); + + const statusSelect = ( +
+ +
+ ); + + // Enabled filter setup + const [isEnabledMenuOpen, setIsEnabledMenuOpen] = React.useState(false); + const enabledToggleRef = React.useRef(null); + const enabledMenuRef = React.useRef(null); + const { containerRef: enabledContainerRef, containerElement: enabledContainerElement } = usePopperContainer(); + + const handleEnabledMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleEnabledClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleEnabledMenuKeysRef.current = (event: KeyboardEvent) => { + if (isEnabledMenuOpen && enabledMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsEnabledMenuOpen(false); + enabledToggleRef.current?.focus(); + } + } + }; + + handleEnabledClickOutsideRef.current = (event: MouseEvent) => { + if (isEnabledMenuOpen && !enabledMenuRef.current?.contains(event.target as Node)) { + setIsEnabledMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleEnabledMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleEnabledClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isEnabledMenuOpen]); + + const onEnabledMenuToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (enabledMenuRef.current) { + const firstElement = enabledMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsEnabledMenuOpen(!isEnabledMenuOpen); + }; + + const onEnabledMenuSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { + if (typeof itemId !== 'string') { + return; + } + + if (itemId === 'enabled') { + setEnabled(true); + } else if (itemId === 'disabled') { + setEnabled(false); + } else { + setEnabled(null); + } + }; + + const enabledToggleLabel = (v: boolean | null) => { + if (v === true) return 'Yes'; + if (v === false) return 'No'; + return 'Filter by enabled'; + }; + + const enabledToggle = ( + + {enabledToggleLabel(actionEnabled)} + + ); + + const enabledMenu = ( + + + + + Yes + + + No + + + + + ); + + const enabledSelect = ( +
+ +
+ ); + + // Attribute selector setup + const [activeAttributeMenu, setActiveAttributeMenu] = React.useState('Account'); + const [isAttributeMenuOpen, setIsAttributeMenuOpen] = React.useState(false); + const attributeToggleRef = React.useRef(null); + const attributeMenuRef = React.useRef(null); + const { containerRef: attributeContainerRef, containerElement: attributeContainerElement } = usePopperContainer(); + + const handleAttributeMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleAttributeClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleAttributeMenuKeysRef.current = (event: KeyboardEvent) => { + if (!isAttributeMenuOpen) { + return; + } + if ( + attributeMenuRef.current?.contains(event.target as Node) || + attributeToggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsAttributeMenuOpen(!isAttributeMenuOpen); + attributeToggleRef.current?.focus(); + } + } + }; + + handleAttributeClickOutsideRef.current = (event: MouseEvent) => { + if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { + setIsAttributeMenuOpen(false); + } + }; + }); + + React.useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => handleAttributeMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleAttributeClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); + }; + }, [isAttributeMenuOpen]); + + const onAttributeToggleClick = (ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + if (attributeMenuRef.current) { + const firstElement = attributeMenuRef.current.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + } + }, 0); + setIsAttributeMenuOpen(!isAttributeMenuOpen); + }; + + const attributeToggle = ( + } + > + {activeAttributeMenu} + + ); + + const attributeMenu = ( + { + const selected = itemId?.toString() as AttributeMenuOption; + setActiveAttributeMenu(selected); + setIsAttributeMenuOpen(!isAttributeMenuOpen); + }} + > + + + Account + Action + Type + Status + Enabled + + + + ); + + const attributeDropdown = ( +
+ +
+ ); + + return ( + { + setSearchValue(''); + setOperation(null); + setType(null); + setStatus(null); + setEnabled(null); + }} + > + + } breakpoint="xl"> + + {attributeDropdown} + setSearchValue('')} + deleteLabelGroup={() => setSearchValue('')} + categoryName="Account" + showToolbarItem={activeAttributeMenu === 'Account'} + > + {searchInput} + + onActionMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setOperation([])} + categoryName="Action" + showToolbarItem={activeAttributeMenu === 'Action'} + > + {actionSelect} + + setType(null)} + deleteLabelGroup={() => setType(null)} + categoryName="Type" + showToolbarItem={activeAttributeMenu === 'Type'} + > + {typeSelect} + + setStatus(null)} + deleteLabelGroup={() => setStatus(null)} + categoryName="Status" + showToolbarItem={activeAttributeMenu === 'Status'} + > + {statusSelect} + + setEnabled(null)} + deleteLabelGroup={() => setEnabled(null)} + categoryName="Enabled" + showToolbarItem={activeAttributeMenu === 'Enabled'} + > + {enabledSelect} + + + + + + ); +}; + +export default ScheduleActionsTableToolbar; diff --git a/src/app/Actions/Scheduler/components/ClusterSelector.tsx b/src/app/Actions/Scheduler/components/ClusterSelector.tsx new file mode 100644 index 0000000..f170853 --- /dev/null +++ b/src/app/Actions/Scheduler/components/ClusterSelector.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { + Button, + FormGroup, + Select, + SelectOption, + MenuToggle, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Tooltip, +} from '@patternfly/react-core'; +import { ClusterResponseApi } from '@api'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +interface ClusterTypeaheadSelectProps { + accountId: string | null; + clusters: ClusterResponseApi[]; + selectedCluster: ClusterResponseApi | null; + onSelectCluster: (cluster: ClusterResponseApi | null) => void; + onClearCluster: () => void; + isDisabled: boolean; +} + +export const ClusterTypeaheadSelect: React.FunctionComponent = ({ + clusters, + selectedCluster, + onClearCluster, + onSelectCluster, +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + + const safeClusters = React.useMemo(() => (Array.isArray(clusters) ? clusters : []), [clusters]); + + const filteredClusters = React.useMemo(() => { + const q = inputValue.trim().toLowerCase(); + if (!q) return safeClusters; + + return safeClusters.filter(a => { + const haystack = `${a.clusterName ?? ''} ${a.clusterId ?? ''}`.toLowerCase(); + return haystack.includes(q); + }); + }, [safeClusters, inputValue]); + + const onSelect = (_event?: React.MouseEvent, value?: string | number) => { + const id = String(value ?? ''); + const acc = safeClusters.find(a => a.clusterId === id) ?? null; + + // Keep input in sync with selection for a predictable UX + setInputValue(acc ? `${acc.clusterName} (${acc.clusterId})` : ''); + onSelectCluster(acc); + setIsOpen(false); + }; + + return ( + + + + ); +}; diff --git a/src/app/ClusterDetails/components/DateTimePicker.tsx b/src/app/Actions/Scheduler/components/DateTimePicker.tsx similarity index 87% rename from src/app/ClusterDetails/components/DateTimePicker.tsx rename to src/app/Actions/Scheduler/components/DateTimePicker.tsx index 2d4e134..164ad63 100644 --- a/src/app/ClusterDetails/components/DateTimePicker.tsx +++ b/src/app/Actions/Scheduler/components/DateTimePicker.tsx @@ -86,7 +86,7 @@ const DateTimePicker: React.FunctionComponent = ({ onChange const [selectedTimeZone, setSelectedTimeZone] = useState('UTC'); // Handle date selection - const onDateChange = (_event: React.FormEvent, value: string, date?: Date) => { + const onDateChange = (_event: React.FormEvent, _value: string, date?: Date) => { if (date) { setSelectedDate(date); updateDateTime(date, selectedTimeZone); @@ -94,7 +94,7 @@ const DateTimePicker: React.FunctionComponent = ({ onChange }; // Handle time selection - const onTimeChange = (_event: React.FormEvent, time: string, hour?: number, minute?: number) => { + const onTimeChange = (_event: React.FormEvent, _time: string, hour?: number, minute?: number) => { if (selectedDate && hour !== undefined && minute !== undefined) { const updatedDate = new Date(selectedDate); updatedDate.setHours(hour, minute); @@ -141,16 +141,16 @@ const DateTimePicker: React.FunctionComponent = ({ onChange is24Hour onChange={onTimeChange} menuAppendTo={document.body} - style={{ zIndex: 9999, width: '120px' }} + style={{ width: '140px', zIndex: 9999 }} /> +
{timeZones.map(zone => ( -
+
{zone}
))} @@ -163,8 +163,13 @@ const DateTimePicker: React.FunctionComponent = ({ onChange appendTo={document.body} zIndex={9999} > - diff --git a/src/app/Actions/Scheduler/components/ModalPowerManagement.tsx b/src/app/Actions/Scheduler/components/ModalPowerManagement.tsx new file mode 100644 index 0000000..6c8b09f --- /dev/null +++ b/src/app/Actions/Scheduler/components/ModalPowerManagement.tsx @@ -0,0 +1,358 @@ +import { + Button, + Checkbox, + FormHelperText, + FormGroup, + Form, + TextInput, + HelperText, + HelperTextItem, + Radio, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import { Modal, ModalVariant } from '@patternfly/react-core/deprecated'; +import React from 'react'; +import { ActionOperations, ActionTypes } from '@app/types/types'; +import DateTimePicker from './DateTimePicker'; +import { AccountTypeaheadSelect } from './AccountSelector'; +import { ClusterTypeaheadSelect } from './ClusterSelector'; +import { ActionStatus } from '@app/types/types'; +import { useUser } from '@app/Contexts/UserContext.tsx'; +import { debug } from '@app/utils/debugLogs'; +import { api, startCluster, stopCluster, AccountResponseApi, ClusterResponseApi, ActionRequestApi } from '@api'; +import { fetchAllPages } from '@app/utils/fetchAllPages'; +import cronValidate from 'cron-validate'; + +interface ModalPowerManagementProps { + isOpen: boolean; + onClose: () => void; + onCreated: () => void; +} + +export const ModalPowerManagement: React.FunctionComponent = ({ + isOpen, + onClose, + onCreated, +}) => { + const { userEmail } = useUser(); + + // Modal From parameters + const [selectedAccount, setSelectedAccount] = React.useState(null); + const [selectedCluster, setSelectedCluster] = React.useState(null); + const [actionOperation, setActionOperation] = React.useState(''); + const [scheduledDateTime, setScheduledDateTime] = React.useState(''); + const [showSchedule, setShowSchedule] = React.useState(false); + const [cronExpression, setCronExpression] = React.useState(''); + const [cronTouched, setCronTouched] = React.useState(false); + const [description, setDescription] = React.useState(''); + const [showDescriptionField, setShowDescriptionField] = React.useState(false); + + // Account/Cluster typeahead vars + const [allAccounts, setAllAccounts] = React.useState([]); + const [allClusters, setAllClusters] = React.useState([]); + + // TODO: restore Loading spinner + //const [loading, setLoading] = React.useState(true); + + // Action type selection + const [actionType, setActionType] = React.useState(ActionTypes.INSTANT_ACTION); + + const isValidCronExpression = (expr: string): boolean => { + if (!expr.trim()) return false; + const result = cronValidate(expr, { preset: 'default' }); + return result.isValid(); + }; + + const isCommonValid = !!selectedAccount && !!selectedCluster && actionOperation.trim() !== ''; + + const isExecutionValid = + actionType === ActionTypes.INSTANT_ACTION || + (actionType === ActionTypes.SCHEDULED_ACTION && scheduledDateTime !== '') || + (actionType === ActionTypes.CRON_ACTION && cronExpression !== '' && isValidCronExpression(cronExpression)); + + const isFormValid = isCommonValid && isExecutionValid; + + // Typeahead clear button functions + const onAccountClearButtonClick = () => { + setSelectedAccount(null); + }; + + const onClusterClearButtonClick = () => { + setSelectedCluster(null); + }; + + // Load accounts when the modal opens. + React.useEffect(() => { + if (!isOpen) return; + + const fetchAccounts = async () => { + //setLoading(true); + try { + const accountFullList = await fetchAllPages(async (page, pageSize) => { + const { data } = await api.accounts.accountsList({ page, page_size: pageSize }); + return { items: data.items || [], count: data.count || 0 }; + }); + setAllAccounts(accountFullList); + } catch (error) { + console.error('Error fetching accounts:', error); + setAllAccounts([]); + } finally { + //setLoading(false); + } + }; + + fetchAccounts(); + }, [isOpen]); + + // Reload clusters every time the selected account changes. + React.useEffect(() => { + if (!isOpen) return; + + // Reset dependent state when account changes / clears + setSelectedCluster(null); + setAllClusters([]); + + const accountId = selectedAccount?.accountId; + if (!accountId) return; + + const fetchClusters = async () => { + //setLoading(true); + try { + const clusterFullList = await fetchAllPages(async (page, pageSize) => { + const { data } = await api.accounts.clustersList(accountId, { page, page_size: pageSize }); + return { items: data.items || [], count: data.count || 0 }; + }); + setAllClusters(clusterFullList); + } catch (error) { + console.error('Error fetching clusters:', error); + setAllClusters([]); + } finally { + //setLoading(false); + } + }; + + fetchClusters(); + }, [isOpen, selectedAccount?.accountId]); + + // Reset modal state when closing to avoid leaking previous selections. + React.useEffect(() => { + if (isOpen) return; + + setShowDescriptionField(false); + setDescription(''); + setShowSchedule(false); + setActionType(ActionTypes.INSTANT_ACTION); + setScheduledDateTime(''); + setCronExpression(''); + setCronTouched(false); + setSelectedAccount(null); + setSelectedCluster(null); + setAllClusters([]); + }, [isOpen]); + + const handlerConfirmActionCreation = async () => { + // Ensure clusterID is not undefined before performing any action + if (!selectedCluster?.clusterId) { + console.error('ClusterID is undefined. Cannot perform scheduled action'); + return; + } + + const finalDescription = showDescriptionField ? description : 'Routine maintenance'; + + // Instant Action run + if (actionType === ActionTypes.INSTANT_ACTION) { + if (actionOperation === ActionOperations.POWER_ON) { + debug('Powering on the cluster'); + startCluster(selectedCluster?.clusterId, userEmail ?? undefined, finalDescription); + } else if (actionOperation === ActionOperations.POWER_OFF) { + debug('Powering off the cluster'); + stopCluster(selectedCluster?.clusterId, userEmail ?? undefined, finalDescription); + } + } else { + // Creating base action. Tunning depending on ActionType + const powerActionRequest = { + accountId: selectedAccount?.accountId, + clusterId: selectedCluster?.clusterId, + enabled: true, + operation: actionOperation, + region: selectedCluster?.region, + status: ActionStatus.Pending, + description: finalDescription, + } as ActionRequestApi; + + // Scheduled Action run + if (actionType === ActionTypes.SCHEDULED_ACTION) { + // TODO: Convert to data validation + if (!scheduledDateTime) { + console.error('Scheduled DateTime is required'); + return; + } + powerActionRequest.type = ActionTypes.SCHEDULED_ACTION; + powerActionRequest.time = scheduledDateTime; + // Cron Action run + } else if (actionType === ActionTypes.CRON_ACTION) { + // TODO: Convert to data validation + if (!cronExpression.trim()) { + console.error('Cron expression is empty'); + return; + } + + powerActionRequest.type = ActionTypes.CRON_ACTION; + powerActionRequest.cronExpression = cronExpression.trim(); + } + + const powerActionRequests: ActionRequestApi[] = [powerActionRequest]; + console.log(powerActionRequest); + await api.actions.actionsCreate(powerActionRequests); + onCreated(); + onClose(); + } + + onClose(); + }; + + // Do not render anything if there is no action + if (!isOpen || !actionType) { + return null; + } + + // Scheduling modal for Schedule action + return ( + + Confirm + , + , + ]} + appendTo={document.body} + > + {/* Account selection */} + +
+ { + setSelectedAccount(account); + }} + onClearAccount={onAccountClearButtonClick} + /> + { + setSelectedCluster(cluster); + }} + isDisabled={!selectedAccount} + onClearCluster={onClusterClearButtonClick} + /> + + {/* Action selection */} + + setActionOperation(ActionOperations.POWER_ON)} + /> + setActionOperation(ActionOperations.POWER_OFF)} + /> + + + {/* Schedule management */} + + { + setShowSchedule(checked); + setActionType(checked ? ActionTypes.SCHEDULED_ACTION : ActionTypes.INSTANT_ACTION); + }} + /> + + {showSchedule && ( + <> + + setActionType(ActionTypes.SCHEDULED_ACTION)} + /> + + {actionType === ActionTypes.SCHEDULED_ACTION && ( + + + + )} + + setActionType(ActionTypes.CRON_ACTION)} + /> + + {actionType === ActionTypes.CRON_ACTION && ( + + setCronExpression(value)} + onBlur={() => setCronTouched(true)} + validated={cronTouched && !isValidCronExpression(cronExpression) ? 'error' : 'default'} + placeholder="0 0 * * *" + /> + + + : undefined + } + > + {cronTouched && !isValidCronExpression(cronExpression) + ? 'Invalid cron expression' + : "Format: minute hour day-of-month month day-of-week (e.g., '0 0 * * *' for daily at midnight)"} + + + + + )} + + )} + + {/* Description management */} + + setDescription(value)} + placeholder="Enter reason" + aria-label="Reason for action" + /> + + +
+ ); +}; diff --git a/src/app/AppLayout/AboutModal.tsx b/src/app/AppLayout/AboutModal.tsx index 8452fb9..a67ddaf 100644 --- a/src/app/AppLayout/AboutModal.tsx +++ b/src/app/AppLayout/AboutModal.tsx @@ -1,4 +1,4 @@ -import { AboutModal, TextContent, TextList, TextListItem } from '@patternfly/react-core'; +import { AboutModal, Content } from '@patternfly/react-core'; import React from 'react'; import brandImg from '../../assets/favicon.png'; import modalBackground from '../../assets/modal_background.png'; @@ -19,22 +19,22 @@ const AboutModalComponent: React.FunctionComponent = ( brandImageSrc={brandImg} backgroundImageSrc={modalBackground} > - - - Version - {APP_VERSION} + + + Version + {APP_VERSION} - Maintainer - {MAINTAINER_NAME} + Maintainer + {MAINTAINER_NAME} - Repository - + Repository + GitHub - - - + + + ); }; diff --git a/src/app/AppLayout/AppLayout.tsx b/src/app/AppLayout/AppLayout.tsx index 60f5322..0d9bb9a 100644 --- a/src/app/AppLayout/AppLayout.tsx +++ b/src/app/AppLayout/AppLayout.tsx @@ -4,6 +4,7 @@ import { Masthead, MastheadToggle, MastheadMain, + MastheadLogo, MastheadBrand, MastheadContent, PageSidebar, @@ -17,12 +18,14 @@ import { MenuToggle, ToolbarGroup, DropdownList, + Tooltip, } from '@patternfly/react-core'; -import BarsIcon from '@patternfly/react-icons/dist/esm/icons/bars-icon'; -import { QuestionCircleIcon, RedhatIcon, ExternalLinkAltIcon } from '@patternfly/react-icons'; + +import { QuestionCircleIcon, ExternalLinkAltIcon, MoonIcon, SunIcon } from '@patternfly/react-icons'; +import logoImg from '../../assets/favicon.png'; import SidebarNavigation from './SidebarNavigation'; import { useUser } from '../Contexts/UserContext'; -import { NavLink } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import AboutModalComponent from './AboutModal'; import { REPOSITORY_URL } from '@app/constants'; interface IAppLayout { @@ -37,6 +40,13 @@ const AppLayout: React.FunctionComponent = ({ children }) => { const [isHelpMenuOpen, setIsHelpMenuOpen] = React.useState(false); const [isAboutModalOpen, setIsAboutModalOpen] = React.useState(false); const [isUserDropdownOpen, setIsUserDropdownOpen] = React.useState(false); + const [isDarkTheme, setIsDarkTheme] = React.useState(() => { + const saved = localStorage.getItem('theme'); + if (saved) return saved === 'dark'; + + // Fallback to browser preference + return window.matchMedia('(prefers-color-scheme: dark)').matches; + }); const isDesktop = () => window.innerWidth >= PF_BREAKPOINT_XL; const previousDesktopState = React.useRef(isDesktop()); @@ -57,7 +67,7 @@ const AppLayout: React.FunctionComponent = ({ children }) => { <> {link.label} {link.isExternal && ( - + {' '} @@ -114,10 +124,37 @@ const AppLayout: React.FunctionComponent = ({ children }) => { }; }, [onResize]); + React.useEffect(() => { + const htmlElement = document.documentElement; + if (isDarkTheme) { + htmlElement.classList.add('pf-v6-theme-dark'); + localStorage.setItem('theme', 'dark'); + } else { + htmlElement.classList.remove('pf-v6-theme-dark'); + localStorage.setItem('theme', 'light'); + } + }, [isDarkTheme]); + + React.useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (e: MediaQueryListEvent) => { + const saved = localStorage.getItem('theme'); + if (!saved) { + setIsDarkTheme(e.matches); + } + }; + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + const toggleTheme = () => { + setIsDarkTheme(prev => !prev); + }; + const headerToolbar = ( - - - + + + = ({ children }) => { position: 'right', }} toggle={toggleRef => ( - setIsHelpMenuOpen(!isHelpMenuOpen)} - isExpanded={isHelpMenuOpen} - > - - + + setIsHelpMenuOpen(!isHelpMenuOpen)} + isExpanded={isHelpMenuOpen} + > + + + )} > {helpDropdownItems} + + + + {isDarkTheme ? : } + + + = ({ children }) => { aria-label="User menu" variant="plainText" onClick={onUserDropdownToggle} - style={{ - color: 'white', - padding: '0 24px', - fontWeight: 'normal', - }} + className="pf-v6-u-px-lg pf-v6-u-font-weight-normal" > {userEmail || 'User'} @@ -172,26 +218,38 @@ const AppLayout: React.FunctionComponent = ({ children }) => { const header = ( - - - - - - - - - ClusterIQ - + + + + + + + } + > + ClusterIQ + + ClusterIQ + + - {headerToolbar} + {headerToolbar} ); @@ -207,7 +265,7 @@ const AppLayout: React.FunctionComponent = ({ children }) => { return ( <> - + {children} setIsAboutModalOpen(false)}> diff --git a/src/app/AppLayout/SidebarNavigation.tsx b/src/app/AppLayout/SidebarNavigation.tsx index 857b9dc..dac570a 100644 --- a/src/app/AppLayout/SidebarNavigation.tsx +++ b/src/app/AppLayout/SidebarNavigation.tsx @@ -5,6 +5,21 @@ import { NavLink, useLocation } from 'react-router-dom'; const SidebarNavigation: React.FunctionComponent = () => { const location = useLocation(); + const isInventoryExpanded = + location.pathname.startsWith('/accounts') || + location.pathname.startsWith('/clusters') || + location.pathname.startsWith('/instances'); + + //const isScanExpanded = location.pathname.startsWith('/scan'); + // + // + // + // Schedule + // + // + // + const isActionsExpanded = location.pathname.startsWith('/actions'); + return (
); - // Attribute selector setup - const [activeAttributeMenu, setActiveAttributeMenu] = React.useState( - 'clusterName' - ); const [isAttributeMenuOpen, setIsAttributeMenuOpen] = React.useState(false); const attributeToggleRef = React.useRef(null); const attributeMenuRef = React.useRef(null); - const attributeContainerRef = React.useRef(null); + const { containerRef: attributeContainerRef, containerElement: attributeContainerElement } = usePopperContainer(); - const handleAttributeMenuKeys = (event: KeyboardEvent) => { - if (!isAttributeMenuOpen) { - return; - } - if ( - attributeMenuRef.current?.contains(event.target as Node) || - attributeToggleRef.current?.contains(event.target as Node) - ) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsAttributeMenuOpen(!isAttributeMenuOpen); - attributeToggleRef.current?.focus(); + const handleAttributeMenuKeysRef = React.useRef<(event: KeyboardEvent) => void>(); + const handleAttributeClickOutsideRef = React.useRef<(event: MouseEvent) => void>(); + + React.useEffect(() => { + handleAttributeMenuKeysRef.current = (event: KeyboardEvent) => { + if (!isAttributeMenuOpen) { + return; } - } - }; + if ( + attributeMenuRef.current?.contains(event.target as Node) || + attributeToggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsAttributeMenuOpen(!isAttributeMenuOpen); + attributeToggleRef.current?.focus(); + } + } + }; - const handleAttributeClickOutside = (event: MouseEvent) => { - if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { - setIsAttributeMenuOpen(false); - } - }; + handleAttributeClickOutsideRef.current = (event: MouseEvent) => { + if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { + setIsAttributeMenuOpen(false); + } + }; + }); React.useEffect(() => { - window.addEventListener('keydown', handleAttributeMenuKeys); - window.addEventListener('click', handleAttributeClickOutside); + const handleKeydown = (event: KeyboardEvent) => handleAttributeMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleAttributeClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); return () => { - window.removeEventListener('keydown', handleAttributeMenuKeys); - window.removeEventListener('click', handleAttributeClickOutside); + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); }; - }, [isAttributeMenuOpen, attributeMenuRef]); + }, [isAttributeMenuOpen]); const onAttributeToggleClick = (ev: React.MouseEvent) => { ev.stopPropagation(); @@ -311,14 +351,13 @@ export const ClustersTableToolbar: React.FunctionComponent { - const selected = itemId as FilterCategory | 'Status' | 'Provider'; + const selected = itemId as 'Cluster Name' | 'Account Name' | 'Status' | 'Provider'; setActiveAttributeMenu(selected); - if (selected === 'clusterName' || selected === 'accountName') { - setFilterCategory(selected); - } setIsAttributeMenuOpen(!isAttributeMenuOpen); }; + const attributeToggle = ( } > - {activeAttributeMenu === 'clusterName' - ? 'Cluster Name' - : activeAttributeMenu === 'accountName' - ? 'Account Name' - : activeAttributeMenu} + {activeAttributeMenu} ); - // Only show Status option in attribute menu for active view const attributeMenu = ( - Cluster Name - Account Name - {!archived && Status} + Cluster Name + Account Name + Status Provider @@ -355,7 +389,7 @@ export const ClustersTableToolbar: React.FunctionComponent
@@ -365,9 +399,11 @@ export const ClustersTableToolbar: React.FunctionComponent { - setFilterValue(''); + setClusterNameSearch(''); + setAccountNameSearch(''); setStatusSelection(null); setProviderSelections(null); + setActiveAttributeMenu('Cluster Name'); }} > @@ -375,29 +411,36 @@ export const ClustersTableToolbar: React.FunctionComponent {attributeDropdown} setFilterValue('')} - deleteChipGroup={() => setFilterValue('')} - categoryName={categoryName} - showToolbarItem={activeAttributeMenu === 'accountName' || activeAttributeMenu === 'clusterName'} + labels={clusterNameSearch !== '' ? [clusterNameSearch] : ([] as string[])} + deleteLabel={() => setClusterNameSearch('')} + deleteLabelGroup={() => setClusterNameSearch('')} + categoryName="Cluster Name" + showToolbarItem={activeAttributeMenu === 'Cluster Name'} + > + {clusterNameInput} + + setAccountNameSearch('')} + deleteLabelGroup={() => setAccountNameSearch('')} + categoryName="Account Name" + showToolbarItem={activeAttributeMenu === 'Account Name'} + > + {accountNameInput} + + setStatusSelection(null)} + deleteLabelGroup={() => setStatusSelection(null)} + categoryName="Status" + showToolbarItem={activeAttributeMenu === 'Status'} > - {searchInput} + {statusSelect} - {!archived && ( - setStatusSelection(null)} - deleteChipGroup={() => setStatusSelection(null)} - categoryName="Status" - showToolbarItem={activeAttributeMenu === 'Status'} - > - {statusSelect} - - )} onProviderMenuSelect(undefined, chip as string)} - deleteChipGroup={() => setProviderSelections([])} + labels={providerSelections || []} + deleteLabel={(_category, chip) => onProviderMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setProviderSelections([])} categoryName="Provider" showToolbarItem={activeAttributeMenu === 'Provider'} > @@ -405,6 +448,14 @@ export const ClustersTableToolbar: React.FunctionComponent
+ + setShowTerminated(checked)} + /> + ); diff --git a/src/app/Clusters/types.ts b/src/app/Clusters/types.ts index f9c62fb..63a84a0 100644 --- a/src/app/Clusters/types.ts +++ b/src/app/Clusters/types.ts @@ -1,22 +1,22 @@ -import { ClusterStates, CloudProvider } from '@app/types/types'; - -export type FilterCategory = 'accountName' | 'clusterName'; +import { ResourceStatusApi, ProviderApi } from '@api'; export interface ClustersTableToolbarProps { - filterCategory: FilterCategory; - setFilterCategory: (category: FilterCategory) => void; - filterValue: string; - setFilterValue: (value: string) => void; - statusSelection: ClusterStates | null; - setStatusSelection: (value: ClusterStates | null) => void; - providerSelections: CloudProvider[] | null; - setProviderSelections: (value: CloudProvider[] | null) => void; - archived: boolean; + clusterNameSearch: string; + setClusterNameSearch: (value: string) => void; + accountNameSearch: string; + setAccountNameSearch: (value: string) => void; + statusSelection: ResourceStatusApi | null; + setStatusSelection: (value: ResourceStatusApi | null) => void; + providerSelections: ProviderApi[] | null; + setProviderSelections: (value: ProviderApi[] | null) => void; + showTerminated: boolean; + setShowTerminated: (value: boolean) => void; } + export interface ClustersTableProps { - filterCategory: FilterCategory; - filterValue: string; + clusterNameSearch: string; + accountNameSearch: string; statusFilter: string | null; - providerSelections: CloudProvider[] | null; - archived: boolean; + providerSelections: ProviderApi[] | null; + showTerminated: boolean; } diff --git a/src/app/Contexts/UserContext.tsx b/src/app/Contexts/UserContext.tsx index 3924a3a..a3c2cff 100644 --- a/src/app/Contexts/UserContext.tsx +++ b/src/app/Contexts/UserContext.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import * as React from 'react'; interface UserContextType { diff --git a/src/app/Dashboard/Dashboard.tsx b/src/app/Dashboard/Dashboard.tsx index d38af56..6a8a9e8 100644 --- a/src/app/Dashboard/Dashboard.tsx +++ b/src/app/Dashboard/Dashboard.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { PageSection, Title } from '@patternfly/react-core'; const Dashboard: React.FunctionComponent = () => ( - + Dashboard Page Title! diff --git a/src/app/Observe/Scheduler/Scheduler.tsx b/src/app/Observe/Scheduler/Scheduler.tsx deleted file mode 100644 index e855e6b..0000000 --- a/src/app/Observe/Scheduler/Scheduler.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { PageSection, PageSectionVariants, Panel, Text, TextContent } from '@patternfly/react-core'; -import SchedulerTableToolbar from './SchedulerTableToolbar'; -import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs'; -import { ClusterActions } from '@app/types/types'; -import { SchedulerTable } from './SchedulerTable'; -import React from 'react'; - -const filterParams = { - accountName: parseAsString.withDefault(''), - action: parseAsArrayOf(parseAsStringEnum(Object.values(ClusterActions))).withDefault([]), - type: parseAsString.withDefault(''), - status: parseAsString.withDefault(''), - enabled: parseAsString.withDefault(''), -}; - -const Scheduler: React.FunctionComponent = () => { - const [{ accountName, action, type, status, enabled }, setQuery] = useQueryStates(filterParams); - - return ( - - - - Scheduled Actions - - - - - setQuery({ accountName: value })} - action={action} - setAction={value => setQuery({ action: value || [] })} - type={type} - setType={value => setQuery({ type: value })} - status={status} - setStatus={value => setQuery({ status: value })} - // TODO. - // If someone will read this 'yes/no' bull**t I'm really sorry. - enabled={enabled as '' | 'yes' | 'no'} - setEnabled={value => setQuery({ enabled: value as '' | 'yes' | 'no' })} - /> - - - - - ); -}; - -export default Scheduler; diff --git a/src/app/Observe/Scheduler/SchedulerTable.tsx b/src/app/Observe/Scheduler/SchedulerTable.tsx deleted file mode 100644 index 6d77cd6..0000000 --- a/src/app/Observe/Scheduler/SchedulerTable.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; -import { - deleteScheduledAction, - disableScheduledAction, - enableScheduledAction, - getScheduledActions, -} from '@app/services/api'; -import { ActionsColumn, IAction, Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; -import React, { useEffect, useState } from 'react'; -import { useTableSort } from '@app/hooks/useTableSort.tsx'; -import { Button, EmptyState, EmptyStateHeader, EmptyStateIcon, Modal, ModalVariant } from '@patternfly/react-core'; -import { TablePagination } from '@app/components/common/TablesPagination'; -import { getPaginatedSlice } from '@app/utils/tablePagination'; -import { SearchIcon } from '@patternfly/react-icons'; -import { apiOperationToClusterAction, ScheduledAction, SchedulerTableProps } from './types'; -import { Link } from 'react-router-dom'; - -const columnNames = { - operation: 'Action', - type: 'Type', - time: 'Time', - accountName: 'Account', - clusterID: 'Cluster', - enabled: 'Enabled', - status: 'Status', -}; - -const EmptyStateNoFound: React.FunctionComponent = () => ( - - } /> - -); - -export const SchedulerTable: React.FunctionComponent = ({ - type, - action, - accountName, - cluster, - status, - enabled, -}) => { - // Pagination settings - const [page, setPage] = React.useState(1); - const [perPage, setPerPage] = React.useState(10); - const [filteredCount, setFilteredCount] = useState(0); - - const [data, setData] = useState([]); - const [loading, setLoading] = useState(true); - const [filteredData, setFilteredData] = useState([]); - const [modalState, setModalState] = useState<{ isOpen: boolean; action: string; selectedId: string | null }>({ - isOpen: false, - action: '', - selectedId: null, - }); - - useEffect(() => { - const fetchData = async () => { - try { - const response = await getScheduledActions(); - setData(response); - } catch (error) { - console.error('Error fetching scheduled actions:', error); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, []); - - // Apply filters when data or filter props change - useEffect(() => { - let filtered = data; - - if (type) { - filtered = filtered.filter(item => item.type === type); - } - - if (accountName) { - filtered = filtered.filter(item => item.target.accountName.toLowerCase().includes(accountName.toLowerCase())); - } - - if (action?.length) { - filtered = filtered.filter(item => { - const normalizedOperation = apiOperationToClusterAction[item.operation] || item.operation; - return action.includes(normalizedOperation as never); - }); - } - - if (cluster) { - filtered = filtered.filter(item => item.target.clusterID.toLowerCase().includes(cluster.toLowerCase())); - } - - if (status) { - filtered = filtered.filter(item => item.status === status); - } - - if (enabled === 'yes') { - filtered = filtered.filter(item => item.enabled); - } else if (enabled === 'no') { - filtered = filtered.filter(item => item.enabled === false); - } - - setFilteredCount(filtered.length); - setFilteredData(getPaginatedSlice(filtered, page, perPage)); - }, [data, type, accountName, action, cluster, status, enabled, page, perPage]); - - const getSortableRowValues = (scheduledAction: ScheduledAction): (string | number | null)[] => { - const { operation, type, time, cronExp, target, status, enabled } = scheduledAction; - return [ - operation, - type, - time || cronExp || null, - target.accountName, - target.clusterID, - enabled ? 'Yes' : 'No', - status, - ]; - }; - - const { sortedData, getSortParams } = useTableSort(filteredData, getSortableRowValues, 2, 'desc'); - - const openModal = (action: 'enable' | 'disable' | 'delete', id: string) => { - setModalState({ - isOpen: true, - action: action, - selectedId: id, - }); - }; - - const closeModal = () => { - setModalState({ - isOpen: false, - action: '', - selectedId: null, - }); - }; - - const handleConfirmAction = async () => { - if (!modalState.selectedId) return; - const id = modalState.selectedId; - - try { - let responseStatus; - - if (modalState.action === 'delete') { - responseStatus = await deleteScheduledAction(id); - if (responseStatus === 200) { - setData(prev => prev.filter(item => item.id !== id)); // Remove from UI only on success - } - } else if (modalState.action === 'enable') { - responseStatus = await enableScheduledAction(id); - if (responseStatus === 200) { - setData(prev => prev.map(item => (item.id === id ? { ...item, enabled: true } : item))); - } - } else if (modalState.action === 'disable') { - responseStatus = await disableScheduledAction(id); - if (responseStatus === 200) { - setData(prev => prev.map(item => (item.id === id ? { ...item, enabled: false } : item))); - } - } - - if (responseStatus !== 200) { - console.error(`Failed to ${modalState.action} action, received status: ${responseStatus}`); - } - } catch (error) { - console.error(`Failed to ${modalState.action} action`, error); - } finally { - closeModal(); - } - }; - - const getRowActions = (scheduledAction: ScheduledAction): IAction[] => [ - { - title: scheduledAction.enabled ? 'Disable' : 'Enable', - onClick: () => openModal(scheduledAction.enabled ? 'disable' : 'enable', scheduledAction.id), - }, - { - title: 'Delete', - onClick: () => openModal('delete', scheduledAction.id), - }, - ]; - - if (loading) return ; - if (filteredCount === 0) return ; - - return ( - <> - - - - - - - - - - - - - - {sortedData.map(action => ( - - - - - - - - - - - ))} - -
{columnNames.operation}{columnNames.type}{columnNames.time}{columnNames.accountName}{columnNames.clusterID}{columnNames.enabled}{columnNames.status}
{apiOperationToClusterAction[action.operation] || action.operation}{action.type}{action.time || action.cronExp}{action.target.accountName} - {action.target.clusterID} - {action.enabled ? 'Yes' : 'No'}{action.status} - -
- - - Confirm - , - , - ]} - > - {`Are you sure you want to ${modalState.action} this scheduled action?`} - - - ); -}; - -export default SchedulerTable; diff --git a/src/app/Observe/Scheduler/SchedulerTableToolbar.tsx b/src/app/Observe/Scheduler/SchedulerTableToolbar.tsx deleted file mode 100644 index 96616f2..0000000 --- a/src/app/Observe/Scheduler/SchedulerTableToolbar.tsx +++ /dev/null @@ -1,641 +0,0 @@ -import { - Badge, - Menu, - MenuContent, - MenuItem, - MenuList, - MenuToggle, - Popper, - SearchInput, - Toolbar, - ToolbarContent, - ToolbarFilter, - ToolbarGroup, - ToolbarItem, - ToolbarToggleGroup, -} from '@patternfly/react-core'; -import { FilterIcon } from '@patternfly/react-icons'; -import React from 'react'; -import debounce from 'lodash.debounce'; -import { ClusterActions, ScheduledActionStatus } from '@app/types/types'; -import { SchedulerTableToolbarProps } from './types'; - -type AttributeMenuOption = 'Account' | 'Action' | 'Type' | 'Status' | 'Enabled'; - -export const SchedulerTableToolbar: React.FunctionComponent = ({ - searchValue, - setSearchValue, - action, - setAction, - status, - setStatus, - type, - setType, - enabled, - setEnabled, -}) => { - const debouncedSearch = React.useMemo(() => debounce(setSearchValue, 300), [setSearchValue]); - - React.useEffect(() => { - return () => { - debouncedSearch.cancel(); - }; - }, [debouncedSearch]); - - // Set up account name search input - const searchInput = ( - debouncedSearch(value)} - onClear={() => debouncedSearch('')} - /> - ); - - // Actions filter setup - const [isActionMenuOpen, setIsActionMenuOpen] = React.useState(false); - const actionToggleRef = React.useRef(null); - const actionMenuRef = React.useRef(null); - const actionContainerRef = React.useRef(null); - - const handleActionMenuKeys = (event: KeyboardEvent) => { - if (isActionMenuOpen && actionMenuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsActionMenuOpen(!isActionMenuOpen); - actionToggleRef.current?.focus(); - } - } - }; - - const handleActionClickOutside = (event: MouseEvent) => { - if (isActionMenuOpen && !actionMenuRef.current?.contains(event.target as Node)) { - setIsActionMenuOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleActionMenuKeys); - window.addEventListener('click', handleActionClickOutside); - return () => { - window.removeEventListener('keydown', handleActionMenuKeys); - window.removeEventListener('click', handleActionClickOutside); - }; - }, [isActionMenuOpen, actionMenuRef]); - - const onActionMenuToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); - setTimeout(() => { - if (actionMenuRef.current) { - const firstElement = actionMenuRef.current.querySelector('li > button:not(:disabled)'); - if (firstElement) { - (firstElement as HTMLElement).focus(); - } - } - }, 0); - setIsActionMenuOpen(!isActionMenuOpen); - }; - - function onActionMenuSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { - if (typeof itemId === 'undefined') { - return; - } - - const selectedAction = itemId as ClusterActions; - setAction( - action && action.includes(selectedAction) - ? action.filter(item => item !== selectedAction) - : selectedAction - ? [selectedAction, ...(action || [])] - : [] - ); - } - - const actionToggle = ( - 0 && { - badge: {action.length}, - })} - style={ - { - width: '200px', - } as React.CSSProperties - } - > - Filter by action - - ); - - const actionMenu = ( - - - - - {ClusterActions.PowerOn} - - - {ClusterActions.PowerOff} - - - - - ); - - const actionSelect = ( -
- -
- ); - - // Type filter setup - const [isTypeMenuOpen, setIsTypeMenuOpen] = React.useState(false); - const typeToggleRef = React.useRef(null); - const typeMenuRef = React.useRef(null); - const typeContainerRef = React.useRef(null); - - const handleTypeMenuKeys = (event: KeyboardEvent) => { - if (isTypeMenuOpen && typeMenuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsTypeMenuOpen(!isTypeMenuOpen); - typeToggleRef.current?.focus(); - } - } - }; - - const handleTypeClickOutside = (event: MouseEvent) => { - if (isTypeMenuOpen && !typeMenuRef.current?.contains(event.target as Node)) { - setIsTypeMenuOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleTypeMenuKeys); - window.addEventListener('click', handleTypeClickOutside); - return () => { - window.removeEventListener('keydown', handleTypeMenuKeys); - window.removeEventListener('click', handleTypeClickOutside); - }; - }, [isTypeMenuOpen, typeMenuRef]); - - const onTypeMenuToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); - setTimeout(() => { - if (typeMenuRef.current) { - const firstElement = typeMenuRef.current.querySelector('li > button:not(:disabled)'); - if (firstElement) { - (firstElement as HTMLElement).focus(); - } - } - }, 0); - setIsTypeMenuOpen(!isTypeMenuOpen); - }; - - function onTypeMenuSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { - if (typeof itemId === 'undefined') { - return; - } - - const selectedType = itemId as string; - // Toggle type selection - clear if already selected, otherwise set new value - setType(type === selectedType ? '' : selectedType); - setIsTypeMenuOpen(false); - } - - const typeToggle = ( - 1, - })} - style={ - { - width: '200px', - } as React.CSSProperties - } - > - {type ? (type === 'scheduled_action' ? 'Scheduled Action' : 'Cron Action') : 'Filter by type'} - - ); - - const typeMenu = ( - - - - - Scheduled Action - - - Cron Action - - - - - ); - - const typeSelect = ( -
- -
- ); - - // Status filter setup - const [isStatusMenuOpen, setIsStatusMenuOpen] = React.useState(false); - const statusToggleRef = React.useRef(null); - const statusMenuRef = React.useRef(null); - const statusContainerRef = React.useRef(null); - - const handleStatusMenuKeys = (event: KeyboardEvent) => { - if (isStatusMenuOpen && statusMenuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsStatusMenuOpen(!isStatusMenuOpen); - statusToggleRef.current?.focus(); - } - } - }; - - const handleStatusClickOutside = (event: MouseEvent) => { - if (isStatusMenuOpen && !statusMenuRef.current?.contains(event.target as Node)) { - setIsStatusMenuOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleStatusMenuKeys); - window.addEventListener('click', handleStatusClickOutside); - return () => { - window.removeEventListener('keydown', handleStatusMenuKeys); - window.removeEventListener('click', handleStatusClickOutside); - }; - }, [isStatusMenuOpen, statusMenuRef]); - - const onStatusMenuToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); - setTimeout(() => { - if (statusMenuRef.current) { - const firstElement = statusMenuRef.current.querySelector('li > button:not(:disabled)'); - if (firstElement) { - (firstElement as HTMLElement).focus(); - } - } - }, 0); - setIsStatusMenuOpen(!isStatusMenuOpen); - }; - - function onStatusMenuSelect(_event: React.MouseEvent | undefined, itemId: string | number | undefined) { - if (typeof itemId === 'undefined') { - return; - } - - const selectedStatus = itemId as string; - // Toggle status selection - setStatus(status === selectedStatus ? '' : selectedStatus); - setIsStatusMenuOpen(false); - } - - const statusToggle = ( - 1, - })} - style={ - { - width: '200px', - } as React.CSSProperties - } - > - {status || 'Filter by status'} - - ); - - const statusMenu = ( - - - - - {ScheduledActionStatus.Success} - - - {ScheduledActionStatus.Failed} - - - {ScheduledActionStatus.Pending} - - - {ScheduledActionStatus.Unknown} - - - - - ); - - const statusSelect = ( -
- -
- ); - - // Enabled filter setup - const [isEnabledMenuOpen, setIsEnabledMenuOpen] = React.useState(false); - const enabledToggleRef = React.useRef(null); - const enabledMenuRef = React.useRef(null); - const enabledContainerRef = React.useRef(null); - - const handleEnabledMenuKeys = (event: KeyboardEvent) => { - if (isEnabledMenuOpen && enabledMenuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsEnabledMenuOpen(false); - enabledToggleRef.current?.focus(); - } - } - }; - - const handleEnabledClickOutside = (event: MouseEvent) => { - if (isEnabledMenuOpen && !enabledMenuRef.current?.contains(event.target as Node)) { - setIsEnabledMenuOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleEnabledMenuKeys); - window.addEventListener('click', handleEnabledClickOutside); - return () => { - window.removeEventListener('keydown', handleEnabledMenuKeys); - window.removeEventListener('click', handleEnabledClickOutside); - }; - }, [isEnabledMenuOpen, enabledMenuRef]); - - const onEnabledMenuToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); - setTimeout(() => { - if (enabledMenuRef.current) { - const firstElement = enabledMenuRef.current.querySelector('li > button:not(:disabled)'); - if (firstElement) { - (firstElement as HTMLElement).focus(); - } - } - }, 0); - setIsEnabledMenuOpen(!isEnabledMenuOpen); - }; - - const onEnabledMenuSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { - if (typeof itemId !== 'string') { - return; - } - - if (itemId === 'yes' || itemId === 'no' || itemId === '') { - setEnabled(itemId); - } - }; - - const enabledToggle = ( - - {enabled === 'yes' ? 'Yes' : 'No'} - - ); - - const enabledMenu = ( - - - - - Yes - - - No - - - - - ); - - const enabledSelect = ( -
- -
- ); - - // Attribute selector setup - const [activeAttributeMenu, setActiveAttributeMenu] = React.useState('Account'); - const [isAttributeMenuOpen, setIsAttributeMenuOpen] = React.useState(false); - const attributeToggleRef = React.useRef(null); - const attributeMenuRef = React.useRef(null); - const attributeContainerRef = React.useRef(null); - - const handleAttributeMenuKeys = (event: KeyboardEvent) => { - if (!isAttributeMenuOpen) { - return; - } - if ( - attributeMenuRef.current?.contains(event.target as Node) || - attributeToggleRef.current?.contains(event.target as Node) - ) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsAttributeMenuOpen(!isAttributeMenuOpen); - attributeToggleRef.current?.focus(); - } - } - }; - - const handleAttributeClickOutside = (event: MouseEvent) => { - if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { - setIsAttributeMenuOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleAttributeMenuKeys); - window.addEventListener('click', handleAttributeClickOutside); - return () => { - window.removeEventListener('keydown', handleAttributeMenuKeys); - window.removeEventListener('click', handleAttributeClickOutside); - }; - }, [isAttributeMenuOpen, attributeMenuRef]); - - const onAttributeToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); - setTimeout(() => { - if (attributeMenuRef.current) { - const firstElement = attributeMenuRef.current.querySelector('li > button:not(:disabled)'); - if (firstElement) { - (firstElement as HTMLElement).focus(); - } - } - }, 0); - setIsAttributeMenuOpen(!isAttributeMenuOpen); - }; - - const attributeToggle = ( - } - > - {activeAttributeMenu} - - ); - - const attributeMenu = ( - { - const selected = itemId?.toString() as AttributeMenuOption; - setActiveAttributeMenu(selected); - setIsAttributeMenuOpen(!isAttributeMenuOpen); - }} - > - - - Account - Action - Type - Status - Enabled - - - - ); - - const attributeDropdown = ( -
- -
- ); - - return ( - { - setSearchValue(''); - setAction(null); - setType(''); - setStatus(''); - setEnabled(''); - }} - > - - } breakpoint="xl"> - - {attributeDropdown} - setSearchValue('')} - deleteChipGroup={() => setSearchValue('')} - categoryName="Account" - showToolbarItem={activeAttributeMenu === 'Account'} - > - {searchInput} - - onActionMenuSelect(undefined, chip as string)} - deleteChipGroup={() => setAction([])} - categoryName="Action" - showToolbarItem={activeAttributeMenu === 'Action'} - > - {actionSelect} - - setType('')} - deleteChipGroup={() => setType('')} - categoryName="Type" - showToolbarItem={activeAttributeMenu === 'Type'} - > - {typeSelect} - - setStatus('')} - deleteChipGroup={() => setStatus('')} - categoryName="Status" - showToolbarItem={activeAttributeMenu === 'Status'} - > - {statusSelect} - - setEnabled('')} - deleteChipGroup={() => setEnabled('')} - categoryName="Enabled" - showToolbarItem={activeAttributeMenu === 'Enabled'} - > - {enabledSelect} - - - - - - ); -}; - -export default SchedulerTableToolbar; diff --git a/src/app/Observe/Scheduler/types.ts b/src/app/Observe/Scheduler/types.ts deleted file mode 100644 index 9634ea5..0000000 --- a/src/app/Observe/Scheduler/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ClusterActions } from '@app/types/types'; - -export interface Target { - accountName: string; - region: string; - clusterID: string; - instances: string[]; -} - -export interface ScheduledAction { - id: string; - type: 'scheduled_action' | 'cron_action'; - time?: string; - cronExp?: string; - operation: string; - target: Target; - status: string; - enabled: boolean; -} - -export interface SchedulerTableToolbarProps { - searchValue: string; - setSearchValue: (value: string) => void; - action: ClusterActions[] | null; - setAction: (value: ClusterActions[] | null) => void; - type: string; - setType: (value: string) => void; - status: string; - setStatus: (value: string) => void; - enabled: 'yes' | 'no' | ''; - setEnabled: (value: 'yes' | 'no' | '') => void; -} - -export interface SchedulerTableProps { - type: string; - action: ClusterActions[]; - accountName: string; - cluster: string; - status: string; - enabled: 'yes' | 'no' | ''; -} - -export const apiOperationToClusterAction: Record = { - PowerOffCluster: ClusterActions.PowerOff, - PowerOnCluster: ClusterActions.PowerOn, -}; - -export const clusterActionToApiOperation: Record = { - [ClusterActions.PowerOff]: 'PowerOffCluster', - [ClusterActions.PowerOn]: 'PowerOnCluster', -}; diff --git a/src/app/Overview/Overview.tsx b/src/app/Overview/Overview.tsx index 6087796..0f6ae04 100644 --- a/src/app/Overview/Overview.tsx +++ b/src/app/Overview/Overview.tsx @@ -8,72 +8,130 @@ import { Grid, GridItem, PageSection, - PageSectionVariants, - TextContent, - Text, + Content, + Alert, + Button, + EmptyState, + EmptyStateBody, + EmptyStateFooter, + EmptyStateActions, } from '@patternfly/react-core'; +import { CubesIcon } from '@patternfly/react-icons'; import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; import { generateCards } from './components/CardData'; -import { CloudProvider } from './types'; -import { renderContent } from './components/CardRenderer'; +import { ProviderApi } from '@api'; +import { renderContent } from './utils/cardRendererUtils.tsx'; import { useDashboardData } from './hooks/useDashboardData'; +import { useEventsData } from './hooks/useEventsData'; +import { DashboardState } from './types'; const AggregateStatusCards: React.FunctionComponent = () => { - const { inventoryData } = useDashboardData(); + const { inventoryData, loading, error } = useDashboardData(); + const { events, loading: eventsLoading, error: eventsError } = useEventsData(); - if (!inventoryData) { + if (loading) { return ; } - const dashboardState = { + if (error || !inventoryData) { + return ( + + + Dashboard unavailable. Refresh to try again. + + + + + + + + ); + } + + const dashboardState: DashboardState = { clustersByStatus: { running: inventoryData?.clusters?.running || 0, stopped: inventoryData?.clusters?.stopped || 0, - unknown: inventoryData?.clusters?.unknown || 0, terminated: inventoryData?.clusters?.archived || 0, }, + instancesByStatus: { + running: inventoryData?.instances?.running || 0, + stopped: inventoryData?.instances?.stopped || 0, + terminated: inventoryData?.instances?.archived || 0, + }, clustersByProvider: { - [CloudProvider.AWS]: inventoryData.providers.aws?.cluster_count || 0, - [CloudProvider.GCP]: inventoryData.providers.gcp?.cluster_count || 0, - [CloudProvider.AZURE]: inventoryData.providers.azure?.cluster_count || 0, + [ProviderApi.AWSProvider]: inventoryData.providers?.aws?.clusterCount || 0, + [ProviderApi.GCPProvider]: inventoryData.providers?.gcp?.clusterCount || 0, + [ProviderApi.AzureProvider]: inventoryData.providers?.azure?.clusterCount || 0, + [ProviderApi.UnknownProvider]: 0, }, accountsByProvider: { - [CloudProvider.AWS]: inventoryData.providers.aws?.account_count || 0, - [CloudProvider.GCP]: inventoryData.providers.gcp?.account_count || 0, - [CloudProvider.AZURE]: inventoryData.providers.azure?.account_count || 0, + [ProviderApi.AWSProvider]: inventoryData.providers?.aws?.accountCount || 0, + [ProviderApi.GCPProvider]: inventoryData.providers?.gcp?.accountCount || 0, + [ProviderApi.AzureProvider]: inventoryData.providers?.azure?.accountCount || 0, + [ProviderApi.UnknownProvider]: 0, }, - instances: inventoryData.instances.count, - lastScanTimestamp: inventoryData.scanner?.last_scan_timestamp, + instances: (inventoryData?.instances?.running || 0) + (inventoryData?.instances?.stopped || 0), + lastScanTimestamp: inventoryData?.scanner?.lastScanTimestamp, }; - const cardData = generateCards(dashboardState); + const cardData = generateCards(dashboardState, events); return ( - - - Overview - + + + Overview + - + - {Object.entries(cardData).map(([, cards], groupIndex) => ( - - - {cards.map((card, cardIndex) => ( - - {card.title} - {renderContent(card.content, card.layout)} - - ))} - + {Object.entries(cardData).map(([groupName, cards], groupIndex) => ( + + {groupName === 'activityCards' ? ( + // Full width Activity card with double height + + {cards[0].title} + + {eventsLoading ? ( + + ) : eventsError ? ( + +

{eventsError}

+

Check the console for more details or try refreshing the page.

+
+ ) : cards[0].customComponent ? ( + cards[0].customComponent + ) : ( + renderContent(cards[0].content, cards[0].layout, cards[0].totalCount) + )} +
+
+ ) : ( + // Regular cards in Gallery + + {cards.map((card, cardIndex) => ( + + + {card.title} + + {renderContent(card.content, card.layout, card.totalCount)} + + ))} + + )}
))}
diff --git a/src/app/Overview/components/ActivityTable.tsx b/src/app/Overview/components/ActivityTable.tsx new file mode 100644 index 0000000..24e99a4 --- /dev/null +++ b/src/app/Overview/components/ActivityTable.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import { EmptyState } from '@patternfly/react-core'; +import { SystemEventResponseApi } from '@api'; +import { Link } from 'react-router-dom'; +import { resolveResourcePath } from '@app/utils/parseFuncs'; +import { CheckCircleIcon, ErrorCircleOIcon, WarningTriangleIcon, InboxIcon } from '@patternfly/react-icons'; + +interface ActivityTableProps { + events: SystemEventResponseApi[]; +} + +const getResultIcon = (result: string) => { + const PATTERNFLY_COLORS = { + success: 'var(--pf-t--global--color--status--success--default)', + danger: 'var(--pf-t--global--color--status--danger--default)', + warning: 'var(--pf-t--global--color--status--warning--default)', + } as const; + + switch (result.toLowerCase()) { + case 'ok': + case 'success': + return ; + case 'failed': + case 'failure': + case 'error': + return ; + case 'warning': + case 'partial': + case 'pending': + return ; + default: + return ; + } +}; + +export const ActivityTable: React.FunctionComponent = ({ events }) => { + if (events.length === 0) { + return ; + } + + return ( + + + + + + + + + + + + {events.map(event => ( + + + + + + + + ))} + +
TimeActionResultResourceTriggered By
{event.timestamp ? new Date(event.timestamp).toLocaleString('es-ES') : '-'}{event.action} + {getResultIcon(event.result)} {event.result} + + + {event.resourceId} + + {event.triggeredBy}
+ ); +}; diff --git a/src/app/Overview/components/CardData.tsx b/src/app/Overview/components/CardData.tsx index ba918df..21cefb1 100644 --- a/src/app/Overview/components/CardData.tsx +++ b/src/app/Overview/components/CardData.tsx @@ -1,10 +1,20 @@ +import React from 'react'; import { CardDefinition, CardLayout, DashboardState } from '../types'; -import { CLOUD_PROVIDERS, STATUSES } from '../constants'; +import { CLOUD_PROVIDERS, STATUSES, TOTAL_COUNT_ICONS } from '../constants'; +import { SystemEventResponseApi } from '@api'; +import { ActivityTable } from './ActivityTable'; -export const generateCards = (state: DashboardState): Record => { - const scannerContent = state.lastScanTimestamp - ? `Last scan: ${new Date(state.lastScanTimestamp).toLocaleString()}` +export const generateCards = ( + state: DashboardState, + events: SystemEventResponseApi[] = [] +): Record => { + const isValidTimestamp = state.lastScanTimestamp && state.lastScanTimestamp !== '0001-01-01T00:00:00Z'; + const scannerContent = isValidTimestamp + ? `${new Date(state.lastScanTimestamp!).toLocaleString()}` : 'No scan data available'; + const totalClusters = (state.clustersByStatus.running || 0) + (state.clustersByStatus.stopped || 0); + const totalInstances = state.instances || 0; + const statusCards = [ { title: 'Clusters', @@ -14,14 +24,28 @@ export const generateCards = (state: DashboardState): Record ({ + icon: status.icon, + value: state.instancesByStatus[key] || 0, + ref: status.route, + })), layout: CardLayout.MULTI_ICON, + totalCount: { + icon: TOTAL_COUNT_ICONS.instances, + value: totalInstances, + label: 'Total', + }, }, { - title: 'Scanners', + title: 'Last Scan Timestamp', content: [{ value: scannerContent }], layout: CardLayout.MULTI_ICON, }, @@ -44,8 +68,18 @@ export const generateCards = (state: DashboardState): Record, + }, + ]; + return { statusCards, providerCards, + activityCards, }; }; diff --git a/src/app/Overview/components/CardRenderer.tsx b/src/app/Overview/components/CardRenderer.tsx index bfba21b..1aaf2b0 100644 --- a/src/app/Overview/components/CardRenderer.tsx +++ b/src/app/Overview/components/CardRenderer.tsx @@ -1,23 +1,38 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import React from 'react'; -import { CardLayout } from '@app/Overview/types.ts'; +import { CardTotalCount } from '@app/Overview/types.ts'; import { Divider, Flex, FlexItem, Stack } from '@patternfly/react-core'; // TODO Avoid any export const RenderSingleIcon: React.FunctionComponent<{ content: any[] }> = ({ content }) => content[0]?.icon; // TODO Avoid any -export const RenderMultiIcon: React.FunctionComponent<{ content: any[] }> = ({ content }) => ( - - {content.map(({ icon, value, ref }, index) => ( - - - {icon} - {ref ? {value} : {value}} - - {content.length > 1 && index < content.length - 1 && } - - ))} - +export const RenderMultiIcon: React.FunctionComponent<{ content: any[]; totalCount?: CardTotalCount }> = ({ + content, + totalCount, +}) => ( + + + {content.map(({ icon, value, ref }, index) => ( + + + {icon} + {ref ? {value} : {value}} + + {content.length > 1 && index < content.length - 1 && } + + ))} + + {totalCount && ( + + {totalCount.icon} + + + {totalCount.label}: {totalCount.value} + + + + )} + ); // TODO Avoid any export const RenderWithSubtitle: React.FC<{ content: any[] }> = ({ content }) => ( @@ -33,14 +48,3 @@ export const RenderWithSubtitle: React.FC<{ content: any[] }> = ({ content }) => ))}
); -// TODO Avoid any -export const renderContent = (content: any[], layout: CardLayout) => { - switch (layout) { - case CardLayout.SINGLE_ICON: - return ; - case CardLayout.MULTI_ICON: - return ; - case CardLayout.WITH_SUBTITLE: - return ; - } -}; diff --git a/src/app/Overview/constants.tsx b/src/app/Overview/constants.tsx index 52d7446..6082293 100644 --- a/src/app/Overview/constants.tsx +++ b/src/app/Overview/constants.tsx @@ -1,72 +1,73 @@ -import { ClusterStates } from '@app/types/types'; +/* eslint-disable react-refresh/only-export-components */ import React from 'react'; import { CheckCircleIcon, ErrorCircleOIcon, - WarningTriangleIcon, OpenshiftIcon, AwsIcon, GoogleIcon, AzureIcon, ArchiveIcon, + DatabaseIcon, + RegistryIcon, } from '@patternfly/react-icons'; -import { CloudProvider } from './types'; +import { ResourceStatusApi, ProviderApi } from '@api'; const PATTERNFLY_COLORS = { - success: 'var(--pf-v5-global--success-color--100)', - danger: 'var(--pf-v5-global--danger-color--100)', - warning: 'var(--pf-v5-global--warning-color--100)', - disabled: 'var(--pf-v5-global--disabled-color--100)', + success: 'var(--pf-t--global--color--status--success--default)', + danger: 'var(--pf-t--global--color--status--danger--default)', + warning: 'var(--pf-t--global--color--status--warning--default)', + disabled: 'var(--pf-t--global--text--color--disabled)', } as const; const CLUSTER_ICON = ; const PROVIDER_ICONS = { - [CloudProvider.AWS]: , - [CloudProvider.GCP]: , - [CloudProvider.AZURE]: , + [ProviderApi.AWSProvider]: , + [ProviderApi.GCPProvider]: , + [ProviderApi.AzureProvider]: , } as const; export const STATUSES = { running: { - key: ClusterStates.Running, + key: ResourceStatusApi.Running, icon: , route: '/clusters?status=Running', }, stopped: { - key: ClusterStates.Stopped, + key: ResourceStatusApi.Stopped, icon: , route: '/clusters?status=Stopped', }, - unknown: { - key: ClusterStates.Unknown, - icon: , - route: '/clusters?status=Unknown', - }, terminated: { - key: ClusterStates.Terminated, + key: ResourceStatusApi.Terminated, icon: , - route: '/clusters?archived=true', + route: '/clusters?status=Terminated', }, } as const; export const CLOUD_PROVIDERS = { - [CloudProvider.AWS]: { - key: CloudProvider.AWS, + [ProviderApi.AWSProvider]: { + key: ProviderApi.AWSProvider, title: 'AWS Clusters', icon: CLUSTER_ICON, - providerIcon: PROVIDER_ICONS[CloudProvider.AWS], + providerIcon: PROVIDER_ICONS[ProviderApi.AWSProvider], }, - [CloudProvider.GCP]: { - key: CloudProvider.GCP, + [ProviderApi.GCPProvider]: { + key: ProviderApi.GCPProvider, title: 'GCP Clusters', icon: CLUSTER_ICON, - providerIcon: PROVIDER_ICONS[CloudProvider.GCP], + providerIcon: PROVIDER_ICONS[ProviderApi.GCPProvider], }, - [CloudProvider.AZURE]: { - key: CloudProvider.AZURE, + [ProviderApi.AzureProvider]: { + key: ProviderApi.AzureProvider, title: 'Azure Clusters', icon: CLUSTER_ICON, - providerIcon: PROVIDER_ICONS[CloudProvider.AZURE], + providerIcon: PROVIDER_ICONS[ProviderApi.AzureProvider], }, } as const; + +export const TOTAL_COUNT_ICONS = { + clusters: , + instances: , +} as const; diff --git a/src/app/Overview/hooks/useDashboardData.ts b/src/app/Overview/hooks/useDashboardData.ts index 091ba67..e13b544 100644 --- a/src/app/Overview/hooks/useDashboardData.ts +++ b/src/app/Overview/hooks/useDashboardData.ts @@ -1,45 +1,27 @@ import { useState, useEffect } from 'react'; -import { getInventoryOverview } from '@app/services/api'; - -interface ProviderDetail { - account_count: number; - cluster_count: number; -} - -interface InventoryData { - clusters: { - running: number; - stopped: number; - unknown: number; - archived: number; - }; - instances: { - count: number; - }; - providers: { - aws: ProviderDetail; - gcp: ProviderDetail; - azure: ProviderDetail; - }; - scanner?: { - last_scan_timestamp: string; - }; -} +import { api, OverviewSummaryApi } from '@api'; export const useDashboardData = () => { - const [inventoryData, setInventoryData] = useState(); + const [inventoryData, setInventoryData] = useState(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { const inventoryOverview = async () => { try { - const data = await getInventoryOverview(); + setLoading(true); + setError(null); + const { data } = await api.overview.overviewList(); setInventoryData(data); } catch { + setError('Failed to fetch inventory data'); console.error('Failed to fetch inventory data.'); + } finally { + setLoading(false); } }; inventoryOverview(); }, []); - return { inventoryData }; + return { inventoryData, loading, error }; }; diff --git a/src/app/Overview/hooks/useEventsData.ts b/src/app/Overview/hooks/useEventsData.ts new file mode 100644 index 0000000..ee0a548 --- /dev/null +++ b/src/app/Overview/hooks/useEventsData.ts @@ -0,0 +1,30 @@ +import { useState, useEffect } from 'react'; +import { api, SystemEventResponseApi } from '@api'; + +export const useEventsData = () => { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchEvents = async () => { + try { + setLoading(true); + setError(null); + console.log('Fetching recent events...'); + const { data } = await api.events.eventsList({ page: 1, page_size: 10 }); + console.log('Events data received:', data); + setEvents(data.items || []); + } catch (err) { + setError('Failed to fetch events'); + console.error('Error fetching events:', err); + } finally { + setLoading(false); + } + }; + + fetchEvents(); + }, []); + + return { events, loading, error }; +}; diff --git a/src/app/Overview/types.ts b/src/app/Overview/types.ts index 18c0b51..80653c8 100644 --- a/src/app/Overview/types.ts +++ b/src/app/Overview/types.ts @@ -1,4 +1,5 @@ import React from 'react'; +import { ProviderApi } from '@api'; export enum CardLayout { SINGLE_ICON = 'icon', @@ -6,28 +7,31 @@ export enum CardLayout { WITH_SUBTITLE = 'withSubtitle', } -export enum CloudProvider { - AWS = 'AWS', - GCP = 'GCP', - AZURE = 'Azure', -} - export interface CardContentItem { icon?: React.ReactNode; value: string | number; ref?: string; } +export interface CardTotalCount { + icon: React.ReactNode; + value: number; + label: string; +} + export interface CardDefinition { title: string; content: CardContentItem[]; layout: CardLayout; + customComponent?: React.ReactNode; + totalCount?: CardTotalCount; } export interface DashboardState { clustersByStatus: Record; - clustersByProvider: Record; - accountsByProvider: Record; + instancesByStatus: Record; + clustersByProvider: Record; + accountsByProvider: Record; instances: number; lastScanTimestamp?: string; } diff --git a/src/app/Overview/utils/cardRendererUtils.tsx b/src/app/Overview/utils/cardRendererUtils.tsx new file mode 100644 index 0000000..8b411b0 --- /dev/null +++ b/src/app/Overview/utils/cardRendererUtils.tsx @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import { CardLayout, CardTotalCount } from '@app/Overview/types.ts'; +import { RenderSingleIcon, RenderMultiIcon, RenderWithSubtitle } from '../components/CardRenderer'; + +export const renderContent = (content: any[], layout: CardLayout, totalCount?: CardTotalCount) => { + switch (layout) { + case CardLayout.SINGLE_ICON: + return ; + case CardLayout.MULTI_ICON: + return ; + case CardLayout.WITH_SUBTITLE: + return ; + } +}; diff --git a/src/app/ServerDetails/ServerDetails.tsx b/src/app/ServerDetails/ServerDetails.tsx index 938eb5d..98ac78d 100644 --- a/src/app/ServerDetails/ServerDetails.tsx +++ b/src/app/ServerDetails/ServerDetails.tsx @@ -4,7 +4,6 @@ import { parseScanTimestamp, parseNumberToCurrency } from 'src/app/utils/parseFu import { useParams } from 'react-router-dom'; import { PageSection, - PageSectionVariants, Tabs, Tab, TabContent, @@ -18,15 +17,15 @@ import { Label, Flex, FlexItem, - Page, - Spinner, LabelGroup, + Bullseye, + Spinner, } from '@patternfly/react-core'; -import { getInstanceByID } from '../services/api'; +import { api, InstanceResponseApi, TagResponseApi } from '@api'; import { Link } from 'react-router-dom'; -import { Instances, Tag } from '@app/types/types'; + interface LabelGroupOverflowProps { - labels: Array; + labels: Array; } const LabelGroupOverflow: React.FunctionComponent = ({ labels }) => ( @@ -42,16 +41,14 @@ const LabelGroupOverflow: React.FunctionComponent = ({ const ServerDetails: React.FunctionComponent = () => { const { instanceID } = useParams(); const [activeTabKey, setActiveTabKey] = React.useState(0); - const [instanceData, setInstanceData] = useState({ - count: 0, - instances: [], - }); + const [instanceData, setInstanceData] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { const fetchData = async () => { try { console.log('Fetching Account Clusters ', instanceID); - const fetchedInstance = await getInstanceByID(instanceID); + if (!instanceID) return; + const { data: fetchedInstance } = await api.instances.instancesDetail(instanceID); setInstanceData(fetchedInstance); console.log('Fetched Account Clusters data:', instanceID); } catch (error) { @@ -62,29 +59,22 @@ const ServerDetails: React.FunctionComponent = () => { }; fetchData(); - }, []); + }, [instanceID]); - const handleTabClick = (event, tabIndex) => { + const handleTabClick = (_event, tabIndex) => { setActiveTabKey(tabIndex); }; const detailsTabContent = ( {loading ? ( -
+ -
+ ) : ( - + <Title headingLevel="h2" size="lg" className="pf-v6-u-mt-sm" id="open-tabs-example-tabs-list-details-title"> Server details @@ -98,42 +88,34 @@ const ServerDetails: React.FunctionComponent = () => { Name {instanceID} Status - - {renderStatusLabel(instanceData.instances[0].status)} - + {renderStatusLabel(instanceData?.status)} Cluster ID - - {instanceData.instances[0].clusterID} - + {instanceData?.clusterId} Cloud Provider - {instanceData.instances[0].provider} + {instanceData?.provider} Labels - + Last scanned at - {parseScanTimestamp(instanceData.instances[0].lastScanTimestamp)} + {parseScanTimestamp(instanceData?.lastScanTimestamp)} Created at - {parseScanTimestamp(instanceData.instances[0].creationTimestamp)} + {parseScanTimestamp(instanceData?.creationTimestamp)} - Daily Cost (aprox) - - {parseNumberToCurrency(instanceData.instances[0].dailyCost)} - Total Cost (aprox) - {parseNumberToCurrency(instanceData.instances[0].totalCost)} + {parseNumberToCurrency(instanceData?.totalCost)} @@ -146,9 +128,9 @@ const ServerDetails: React.FunctionComponent = () => { ); return ( - + {/* Page header */} - + { {/* Page tabs */} - + Details} tabContentId={`tabContent${0}`} /> - + @@ -182,7 +164,7 @@ const ServerDetails: React.FunctionComponent = () => { hidden={1 !== activeTabKey} > - +
); }; diff --git a/src/app/Servers/Servers.tsx b/src/app/Servers/Servers.tsx index 5940bf6..07c8261 100644 --- a/src/app/Servers/Servers.tsx +++ b/src/app/Servers/Servers.tsx @@ -1,47 +1,31 @@ -import { PageSection, PageSectionVariants, Panel, TextContent, Text } from '@patternfly/react-core'; +import { PageSection, Panel, Content } from '@patternfly/react-core'; import React from 'react'; import ServersTableToolbar from './components/ServersTableToolbar'; import ServersTable from './components/ServersTable'; import { parseAsArrayOf, parseAsString, parseAsStringEnum, parseAsBoolean, useQueryStates } from 'nuqs'; -import { ClusterStates, CloudProvider } from '@app/types/types'; -import { useLocation } from 'react-router-dom'; +import { ResourceStatusApi, ProviderApi } from '@api'; const filterParams = { status: { - ...parseAsStringEnum(Object.values(ClusterStates)), - defaultValue: null as ClusterStates | null, + ...parseAsStringEnum(Object.values(ResourceStatusApi)), + defaultValue: null as ResourceStatusApi | null, }, - provider: parseAsArrayOf(parseAsStringEnum(Object.values(CloudProvider))).withDefault([]), + provider: parseAsArrayOf(parseAsStringEnum(Object.values(ProviderApi))).withDefault([]), serverName: parseAsString.withDefault(''), - archived: parseAsBoolean.withDefault(false), + showTerminated: parseAsBoolean.withDefault(false), }; const Servers: React.FunctionComponent = () => { - const location = useLocation(); - const [{ status, provider, serverName, archived }, setQuery] = useQueryStates(filterParams); - - // React to URL changes - React.useEffect(() => { - const params = new URLSearchParams(location.search); - const newArchived = params.get('archived') === 'true'; - - if (archived !== newArchived) { - setQuery({ - archived: newArchived, - status: null, - provider: [], - }); - } - }, [location.search, archived, setQuery]); + const [{ status, provider, serverName, showTerminated }, setQuery] = useQueryStates(filterParams); return ( - - - Servers {archived ? '- History' : '- Active'} - + + + Servers + - + { setStatusSelection={value => setQuery({ status: value })} providerSelections={provider} setProviderSelections={value => setQuery({ provider: value || [] })} - archived={archived} + showTerminated={showTerminated} + setShowTerminated={value => setQuery({ showTerminated: value })} /> diff --git a/src/app/Servers/components/ServersTable.tsx b/src/app/Servers/components/ServersTable.tsx index 217f8c5..6553060 100644 --- a/src/app/Servers/components/ServersTable.tsx +++ b/src/app/Servers/components/ServersTable.tsx @@ -1,84 +1,88 @@ -import { CloudProvider, ClusterStates, Instances } from '@app/types/types'; import { renderStatusLabel } from '@app/utils/renderUtils'; -import { Spinner } from '@patternfly/react-core'; -import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; +import { EmptyState, EmptyStateVariant, EmptyStateBody, Title } from '@patternfly/react-core'; +import { ThProps, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { ServersTableProps } from '../types'; -import { getInstances } from '@app/services/api'; +import { api, InstanceResponseApi } from '@api'; import { TablePagination } from '@app/components/common/TablesPagination'; -import { getPaginatedSlice } from '@app/utils/tablePagination'; +import { searchItems, filterByStatus, filterByProvider, sortItems, paginateItems } from '@app/utils/tableFilters'; +import { fetchAllPages } from '@app/utils/fetchAllPages'; +import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; +import { ServerIcon } from '@patternfly/react-icons'; export const ServersTable: React.FunctionComponent = ({ searchValue, statusSelection, providerSelections, - archived, + showTerminated, }) => { - // Pagination settings - const [page, setPage] = React.useState(1); - const [perPage, setPerPage] = React.useState(10); - - const [instancesData, setInstancesData] = useState({ - count: 0, - instances: [], - }); - - const [filteredData, setFilteredData] = useState({ - count: 0, - instances: [], - }); - + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + const [allInstances, setAllInstances] = useState([]); const [loading, setLoading] = useState(true); + const [activeSortIndex, setActiveSortIndex] = useState(1); + const [activeSortDirection, setActiveSortDirection] = useState<'asc' | 'desc'>('asc'); + useEffect(() => { const fetchData = async () => { try { setLoading(true); - const fetchedInstances = await getInstances(); - setInstancesData(fetchedInstances); + const allItems = await fetchAllPages(async (page, pageSize) => { + const { data } = await api.instances.instancesList({ page, page_size: pageSize }); + return { items: data.items || [], count: data.count || 0 }; + }); + setAllInstances(allItems); } catch (error) { - console.error('Error fetching data:', error); + console.error('Error fetching instances:', error); } finally { setLoading(false); } }; - fetchData(); }, []); - useEffect(() => { - let filtered = instancesData.instances; - // First, apply view-specific filtering - if (archived) { - filtered = filtered.filter(instance => { - const isTerminated = instance.status === ClusterStates.Terminated; - return isTerminated; - }); - } else { - filtered = filtered.filter(instance => { - const isNotTerminated = instance.status !== ClusterStates.Terminated; - return isNotTerminated; - }); - } + let filtered = allInstances; - // Then apply other filters - filtered = filtered.filter(instance => instance.name.toLowerCase().includes(searchValue.toLowerCase())); + // Filter out terminated instances unless showTerminated is true + if (!showTerminated) { + filtered = filtered.filter(instance => instance.status !== 'Terminated'); + } - // Apply status filter only for active view - if (!archived && statusSelection) { - filtered = filtered.filter(instance => instance.status === statusSelection); - } + filtered = searchItems(filtered, searchValue, ['instanceName']); + filtered = filterByStatus(filtered, statusSelection); + filtered = filterByProvider(filtered, providerSelections); - if (providerSelections && providerSelections.length > 0) { - filtered = filtered.filter(instance => providerSelections.includes(instance.provider as CloudProvider)); + if (activeSortIndex !== undefined && activeSortDirection) { + const sortFields: (keyof InstanceResponseApi)[] = [ + 'instanceId', + 'instanceName', + 'status', // placeholder for index 2, though not sortable + 'provider', + 'availabilityZone', + 'instanceType', + ]; + // status column (index 2) is not sortable, so we only sort if index is not 2 + if (activeSortIndex !== 2) { + filtered = sortItems(filtered, sortFields[activeSortIndex], activeSortDirection); } - // Filtered data with pagination - setFilteredData({ - count: filtered.length, - instances: getPaginatedSlice(filtered, page, perPage), - }); - }, [searchValue, instancesData.instances, statusSelection, providerSelections, archived, page, perPage]); + } + + const paginated = paginateItems(filtered, page, perPage); + + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + defaultDirection: 'asc', + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }); const columnNames = { id: 'ID', @@ -89,51 +93,68 @@ export const ServersTable: React.FunctionComponent = ({ instanceType: 'Type', }; + if (loading) { + return ; + } + + if (filtered.length === 0) { + return ( + + No instances found + + } + icon={ServerIcon} + variant={EmptyStateVariant.sm} + > + + {!showTerminated ? ( + <> + There are no active instances. +
+ Toggle 'Show terminated instances' to view all instances. + + ) : ( + 'No instances found.' + )} +
+
+ ); + } + return ( - {loading ? ( -
- -
- ) : ( - - - - - - - - - +
{columnNames.id}{columnNames.name}{columnNames.status}{columnNames.provider}{columnNames.availabilityZone}{columnNames.instanceType}
+ + + + + + + + + + + + {paginated.map(instance => ( + + + + + + + - - - {filteredData.instances.map(instance => ( - - - - - - - - - ))} - -
{columnNames.id}{columnNames.name}{columnNames.status}{columnNames.provider}{columnNames.availabilityZone}{columnNames.instanceType}
+ {instance.instanceId} + + {instance.instanceName} + {renderStatusLabel(instance.status)}{instance.provider}{instance.availabilityZone}{instance.instanceType}
- {instance.id} - - {instance.name} - {renderStatusLabel(instance.status)}{instance.provider}{instance.availabilityZone}{instance.instanceType}
- )} + ))} + + = ({ searchValue, @@ -27,7 +29,8 @@ export const ServersTableToolbar: React.FunctionComponent { const debouncedSearch = React.useMemo(() => debounce(setSearchValue, 300), [setSearchValue]); @@ -51,33 +54,40 @@ export const ServersTableToolbar: React.FunctionComponent(false); const statusToggleRef = React.useRef(null); const statusMenuRef = React.useRef(null); - const statusContainerRef = React.useRef(null); - const handleStatusMenuKeys = (event: KeyboardEvent) => { - if (isStatusMenuOpen && statusMenuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsStatusMenuOpen(!isStatusMenuOpen); - statusToggleRef.current?.focus(); + const { containerRef: statusContainerRef, containerElement: statusContainerElement } = usePopperContainer(); + const handleStatusMenuKeysRef = React.useRef<((event: KeyboardEvent) => void) | undefined>(undefined); + const handleStatusClickOutsideRef = React.useRef<((event: MouseEvent) => void) | undefined>(undefined); + + React.useEffect(() => { + handleStatusMenuKeysRef.current = (event: KeyboardEvent) => { + if (isStatusMenuOpen && statusMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsStatusMenuOpen(!isStatusMenuOpen); + statusToggleRef.current?.focus(); + } } - } - }; + }; - const handleStatusClickOutside = (event: MouseEvent) => { - if (isStatusMenuOpen && !statusMenuRef.current?.contains(event.target as Node)) { - setIsStatusMenuOpen(false); - } - }; + handleStatusClickOutsideRef.current = (event: MouseEvent) => { + if (isStatusMenuOpen && !statusMenuRef.current?.contains(event.target as Node)) { + setIsStatusMenuOpen(false); + } + }; + }); React.useEffect(() => { - window.addEventListener('keydown', handleStatusMenuKeys); - window.addEventListener('click', handleStatusClickOutside); + const handleKeydown = (event: KeyboardEvent) => handleStatusMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleStatusClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); return () => { - window.removeEventListener('keydown', handleStatusMenuKeys); - window.removeEventListener('click', handleStatusClickOutside); + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); }; - }, [isStatusMenuOpen, statusMenuRef]); + }, [isStatusMenuOpen]); - const onStatusToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); + const onStatusToggleClick = (_ev: React.MouseEvent) => { + _ev.stopPropagation(); setTimeout(() => { if (statusMenuRef.current) { const firstElement = statusMenuRef.current.querySelector('li > button:not(:disabled)'); @@ -89,12 +99,12 @@ export const ServersTableToolbar: React.FunctionComponent - {ClusterStates.Unknown} - {ClusterStates.Running} - {ClusterStates.Stopped} + {ResourceStatusApi.Running} + {ResourceStatusApi.Stopped} + {ResourceStatusApi.Terminated} ); - const statusSelect = archived ? null : ( + const statusSelect = (
@@ -142,34 +152,41 @@ export const ServersTableToolbar: React.FunctionComponent(false); const providerToggleRef = React.useRef(null); const providerMenuRef = React.useRef(null); - const providerContainerRef = React.useRef(null); + const { containerRef: providerContainerRef, containerElement: providerContainerElement } = usePopperContainer(); + + const handleProviderMenuKeysRef = React.useRef<((event: KeyboardEvent) => void) | undefined>(undefined); + const handleProviderClickOutsideRef = React.useRef<((event: MouseEvent) => void) | undefined>(undefined); - const handleProviderMenuKeys = (event: KeyboardEvent) => { - if (isProviderMenuOpen && providerMenuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsProviderMenuOpen(!isProviderMenuOpen); - providerToggleRef.current?.focus(); + React.useEffect(() => { + handleProviderMenuKeysRef.current = (event: KeyboardEvent) => { + if (isProviderMenuOpen && providerMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsProviderMenuOpen(!isProviderMenuOpen); + providerToggleRef.current?.focus(); + } } - } - }; + }; - const handleProviderClickOutside = (event: MouseEvent) => { - if (isProviderMenuOpen && !providerMenuRef.current?.contains(event.target as Node)) { - setIsProviderMenuOpen(false); - } - }; + handleProviderClickOutsideRef.current = (event: MouseEvent) => { + if (isProviderMenuOpen && !providerMenuRef.current?.contains(event.target as Node)) { + setIsProviderMenuOpen(false); + } + }; + }); React.useEffect(() => { - window.addEventListener('keydown', handleProviderMenuKeys); - window.addEventListener('click', handleProviderClickOutside); + const handleKeydown = (event: KeyboardEvent) => handleProviderMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleProviderClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); return () => { - window.removeEventListener('keydown', handleProviderMenuKeys); - window.removeEventListener('click', handleProviderClickOutside); + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); }; - }, [isProviderMenuOpen, providerMenuRef]); + }, [isProviderMenuOpen]); - const onProviderMenuToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); + const onProviderMenuToggleClick = (_ev: React.MouseEvent) => { + _ev.stopPropagation(); setTimeout(() => { if (providerMenuRef.current) { const firstElement = providerMenuRef.current.querySelector('li > button:not(:disabled)'); @@ -181,12 +198,12 @@ export const ServersTableToolbar: React.FunctionComponent selection !== provider) @@ -224,16 +241,24 @@ export const ServersTableToolbar: React.FunctionComponent - + AWS - + Google Cloud Azure @@ -249,7 +274,7 @@ export const ServersTableToolbar: React.FunctionComponent @@ -260,37 +285,44 @@ export const ServersTableToolbar: React.FunctionComponent(null); const attributeMenuRef = React.useRef(null); - const attributeContainerRef = React.useRef(null); + const { containerRef: attributeContainerRef, containerElement: attributeContainerElement } = usePopperContainer(); - const handleAttribueMenuKeys = (event: KeyboardEvent) => { - if (!isAttributeMenuOpen) { - return; - } - if ( - attributeMenuRef.current?.contains(event.target as Node) || - attributeToggleRef.current?.contains(event.target as Node) - ) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsAttributeMenuOpen(!isAttributeMenuOpen); - attributeToggleRef.current?.focus(); + const handleAttribueMenuKeysRef = React.useRef<((event: KeyboardEvent) => void) | undefined>(undefined); + const handleAttributeClickOutsideRef = React.useRef<((event: MouseEvent) => void) | undefined>(undefined); + + React.useEffect(() => { + handleAttribueMenuKeysRef.current = (event: KeyboardEvent) => { + if (!isAttributeMenuOpen) { + return; } - } - }; + if ( + attributeMenuRef.current?.contains(event.target as Node) || + attributeToggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsAttributeMenuOpen(!isAttributeMenuOpen); + attributeToggleRef.current?.focus(); + } + } + }; - const handleAttributeClickOutside = (event: MouseEvent) => { - if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { - setIsAttributeMenuOpen(false); - } - }; + handleAttributeClickOutsideRef.current = (event: MouseEvent) => { + if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { + setIsAttributeMenuOpen(false); + } + }; + }); React.useEffect(() => { - window.addEventListener('keydown', handleAttribueMenuKeys); - window.addEventListener('click', handleAttributeClickOutside); + const handleKeydown = (event: KeyboardEvent) => handleAttribueMenuKeysRef.current?.(event); + const handleClick = (event: MouseEvent) => handleAttributeClickOutsideRef.current?.(event); + window.addEventListener('keydown', handleKeydown); + window.addEventListener('click', handleClick); return () => { - window.removeEventListener('keydown', handleAttribueMenuKeys); - window.removeEventListener('click', handleAttributeClickOutside); + window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('click', handleClick); }; - }, [isAttributeMenuOpen, attributeMenuRef]); + }, [isAttributeMenuOpen]); const onAttributeToggleClick = (ev: React.MouseEvent) => { ev.stopPropagation(); @@ -326,7 +358,7 @@ export const ServersTableToolbar: React.FunctionComponent Servers - {!archived && Status} + Status Provider @@ -340,7 +372,7 @@ export const ServersTableToolbar: React.FunctionComponent @@ -360,29 +392,27 @@ export const ServersTableToolbar: React.FunctionComponent {attributeDropdown} setSearchValue('')} - deleteChipGroup={() => setSearchValue('')} + labels={searchValue !== '' ? [searchValue] : ([] as string[])} + deleteLabel={() => setSearchValue('')} + deleteLabelGroup={() => setSearchValue('')} categoryName="Name" showToolbarItem={activeAttributeMenu === 'Servers'} > {searchInput} - {!archived && ( - setStatusSelection(null)} - deleteChipGroup={() => setStatusSelection(null)} - categoryName="Status" - showToolbarItem={activeAttributeMenu === 'Status'} - > - {statusSelect} - - )} onProviderMenuSelect(undefined, chip as string)} - deleteChipGroup={() => setProviderSelections([])} + labels={statusSelection ? [statusSelection] : []} + deleteLabel={() => setStatusSelection(null)} + deleteLabelGroup={() => setStatusSelection(null)} + categoryName="Status" + showToolbarItem={activeAttributeMenu === 'Status'} + > + {statusSelect} + + onProviderMenuSelect(undefined, chip as string)} + deleteLabelGroup={() => setProviderSelections([])} categoryName="Provider" showToolbarItem={activeAttributeMenu === 'Provider'} > @@ -390,6 +420,14 @@ export const ServersTableToolbar: React.FunctionComponent + + setShowTerminated(checked)} + /> + ); diff --git a/src/app/Servers/types.ts b/src/app/Servers/types.ts index ac9a687..45fd8c9 100644 --- a/src/app/Servers/types.ts +++ b/src/app/Servers/types.ts @@ -1,18 +1,19 @@ -import { ClusterStates, CloudProvider } from '@app/types/types'; +import { ResourceStatusApi, ProviderApi } from '@api'; export interface ServersTableProps { searchValue: string; statusSelection: string | null; - providerSelections: CloudProvider[] | null; - archived: boolean; + providerSelections: ProviderApi[] | null; + showTerminated: boolean; } export interface ServersTableToolbarProps { searchValue: string; setSearchValue: (value: string) => void; - statusSelection: ClusterStates | null; - setStatusSelection: (value: ClusterStates | null) => void; - providerSelections: CloudProvider[] | null; - setProviderSelections: (value: CloudProvider[] | null) => void; - archived: boolean; + statusSelection: ResourceStatusApi | null; + setStatusSelection: (value: ResourceStatusApi | null) => void; + providerSelections: ProviderApi[] | null; + setProviderSelections: (value: ProviderApi[] | null) => void; + showTerminated: boolean; + setShowTerminated: (value: boolean) => void; } diff --git a/src/app/components/common/LabelGroupOverflow.tsx b/src/app/components/common/LabelGroupOverflow.tsx index 160366c..f1813a7 100644 --- a/src/app/components/common/LabelGroupOverflow.tsx +++ b/src/app/components/common/LabelGroupOverflow.tsx @@ -1,9 +1,9 @@ -import { Tag } from '@app/types/types'; +import { TagResponseApi } from '@api'; import { LabelGroup, Label } from '@patternfly/react-core'; import React from 'react'; interface LabelGroupOverflowProps { - labels: Array; + labels: Array; } export const LabelGroupOverflow: React.FunctionComponent = ({ labels }) => ( diff --git a/src/app/hooks/usePopperContainer.ts b/src/app/hooks/usePopperContainer.ts new file mode 100644 index 0000000..adfd939 --- /dev/null +++ b/src/app/hooks/usePopperContainer.ts @@ -0,0 +1,11 @@ +import { useState, useCallback } from 'react'; + +export const usePopperContainer = () => { + const [containerElement, setContainerElement] = useState(null); + + const containerRef = useCallback((node: HTMLElement | null) => { + setContainerElement(node); + }, []); + + return { containerRef, containerElement }; +}; diff --git a/src/app/index.tsx b/src/app/index.tsx index f23a0bd..0dd0be4 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -7,12 +7,12 @@ import Clusters from './Clusters/Clusters'; import ClusterDetails from './ClusterDetails/ClusterDetails'; import AccountDetails from './AccountDetails/AccountDetails'; import ServerDetails from './ServerDetails/ServerDetails'; -import AuditLogs from '@app/Observe/AuditLogs/AuditLogs'; +import AuditLogs from './Actions/AuditLogs/AuditLogs'; +import Scheduler from './Actions/Scheduler/Schedule'; import Servers from './Servers/Servers'; import Accounts from './Accounts/Accounts'; import { NuqsAdapter } from 'nuqs/adapters/react'; import { UserProvider } from './Contexts/UserContext'; -import Scheduler from './Observe/Scheduler/Scheduler'; const RouteDebugWrapper = ({ children }: { children: React.ReactNode }) => { const location = useLocation(); @@ -33,13 +33,13 @@ const AppRoutes = (): React.ReactElement => ( } /> } /> - } /> + } /> } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> ); diff --git a/src/app/services/api.js b/src/app/services/api.js deleted file mode 100644 index 83fbf18..0000000 --- a/src/app/services/api.js +++ /dev/null @@ -1,246 +0,0 @@ -import axios from 'axios'; - -// Initialize API client with base settings -const apiClient = axios.create({ - baseURL: '/api', // API proxy path - timeout: 20000, // Request timeout in milliseconds - headers: { - 'Content-Type': 'application/json', - }, -}); - -// Fetch details of a specific cluster by its ID -export async function getCluster(clusterID) { - try { - const response = await apiClient.get(`/clusters/${clusterID}`); - return response.data; - } catch (error) { - console.error(`Error fetching cluster ${clusterID}:`, error); - throw error; - } -} - -// Fetch all clusters -export const getClusters = async () => { - try { - const response = await apiClient.get('/clusters'); - return response.data; - } catch (error) { - console.error('Error fetching clusters:', error); - throw error; - } -}; - -// Fetch details of a specific account by name -export async function getAccountByName(accountName) { - try { - const response = await apiClient.get(`/accounts/${accountName}`); - return response.data; - } catch (error) { - console.error(`Error fetching account ${accountName}:`, error); - throw error; - } -} - -// Fetch all accounts -export const getAccounts = async () => { - try { - const response = await apiClient.get('/accounts'); - return response.data; - } catch (error) { - console.error('Error fetching accounts:', error); - throw error; - } -}; - -// Fetch details of a specific instance by ID -export async function getInstanceByID(instanceID) { - try { - const response = await apiClient.get(`/instances/${instanceID}`); - return response.data; - } catch (error) { - console.error(`Error fetching instance ${instanceID}:`, error); - throw error; - } -} - -// Fetch all instances -export const getInstances = async () => { - try { - const response = await apiClient.get('/instances'); - return response.data; - } catch (error) { - console.error('Error fetching instances:', error); - throw error; - } -}; - -// Fetch all clusters linked to a specific account -export async function getAccountClusters(accountName) { - try { - const response = await apiClient.get(`/accounts/${accountName}/clusters`); - return response.data.clusters; - } catch (error) { - console.error(`Error fetching clusters for account ${accountName}:`, error); - throw error; - } -} - -// Fetch all instances linked to a specific cluster -export async function getClusterInstances(clusterID) { - try { - const response = await apiClient.get(`clusters/${clusterID}/instances`); - return response.data.instances; - } catch (error) { - console.error(`Error fetching instances for cluster ${clusterID}:`, error); - throw error; - } -} - -// Fetch all tags linked to a specific cluster -export async function getClusterTags(clusterID) { - try { - const response = await apiClient.get(`clusters/${clusterID}/tags`); - return response.data; - } catch (error) { - console.error(`Error fetching tags for cluster ${clusterID}:`, error); - throw error; - } -} - -// Start a cluster -export async function startCluster(clusterID, userEmail, description) { - try { - const response = await apiClient.post(`clusters/${clusterID}/power_on`, { - triggered_by: userEmail || 'unknown', - description: description, - }); - - console.log(`Power on request sent for cluster ${clusterID}`); - return response.data; - } catch (error) { - console.error(`Error starting cluster ${clusterID}:`, error); - throw error; - } -} - -// Stop a cluster -export async function stopCluster(clusterID, userEmail, description) { - try { - const response = await apiClient.post(`clusters/${clusterID}/power_off`, { - triggered_by: userEmail || 'unknown', - description: description, - }); - - console.log(`Power off request sent for cluster ${clusterID}`); - return response.data; - } catch (error) { - console.error(`Error stopping cluster ${clusterID}:`, error); - throw error; - } -} - -// Fetch events related to a specific cluster -export async function getClusterEvents(clusterID) { - try { - const response = await apiClient.get(`clusters/${clusterID}/events`); - return response.data.events; - } catch (error) { - console.error(`Failed to fetch events for cluster ${clusterID}:`, error); - throw error; - } -} - -// Fetch all system-wide events -export async function getSystemEvents() { - try { - const response = await apiClient.get(`/events`); - return response.data.events; - } catch (error) { - console.error('Failed to fetch system-wide events:', error); - throw error; - } -} - -// Create a scheduled action -export async function createScheduledAction(actionData) { - console.log('Sending to API:', JSON.stringify(actionData, null, 2)); - - try { - const response = await apiClient.post(`/schedule`, actionData); - if (response.status === 200) { - return response.status; - } - throw new Error(`Unexpected response status: ${response.status}`); - } catch (error) { - console.error(`Failed to create scheduled action:`, error); - throw error; - } -} - -// Delete a scheduled action -export async function deleteScheduledAction(actionID) { - try { - const response = await apiClient.delete(`schedule/${actionID}`); - if (response.status === 200) { - return response.status; - } - throw new Error(`Unexpected response status: ${response.status}`); - } catch (error) { - console.error(`Failed to delete scheduled action ${actionID}:`, error); - throw error; - } -} - -// Enable a scheduled action -export async function enableScheduledAction(actionID) { - try { - const response = await apiClient.patch(`schedule/${actionID}/enable`); - if (response.status === 200) { - return response.status; - } - throw new Error(`Unexpected response status: ${response.status}`); - } catch (error) { - console.error(`Failed to enable scheduled action ${actionID}:`, error); - throw error; - } -} - -// Disable a scheduled action -export async function disableScheduledAction(actionID) { - try { - const response = await apiClient.patch(`schedule/${actionID}/disable`); - if (response.status === 200) { - return response.status; - } - throw new Error(`Unexpected response status: ${response.status}`); - } catch (error) { - console.error(`Failed to disable scheduled action ${actionID}:`, error); - throw error; - } -} - -// Fetch all scheduled actions -export async function getScheduledActions() { - try { - const response = await apiClient.get('/schedule'); - if (response.status === 200) { - return response.data.actions; - } - throw new Error(`Unexpected response status: ${response.status}`); - } catch (error) { - console.error('Failed to fetch scheduled actions:', error); - throw error; - } -} - -// Fetch the inventory overview -export async function getInventoryOverview() { - try { - const response = await apiClient.get(`/overview`); - return response.data; - } catch (error) { - console.log('Failed to fetch inventory overview'); - throw error; - } -} diff --git a/src/app/types/events.ts b/src/app/types/events.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/types/types.tsx b/src/app/types/types.tsx index e58faa6..67fc532 100644 --- a/src/app/types/types.tsx +++ b/src/app/types/types.tsx @@ -1,126 +1,51 @@ -export type ClusterData = { - count: number; - clusters: Cluster[]; -}; - -export type Cluster = { - id: string; - name: string; - provider: string; - status: string; - region: string; - consoleLink: string; - accountName: string; - instanceCount: number; - creationTimestamp: string; - lastScanTimestamp: string; - totalCost: number; - last15DaysCost: number; - lastMonthCost: number; - currentMonthSoFarCost: number; - instances: Instance[]; -}; - -export type AccountData = { - count: number; - accounts: Account[]; -}; - -export type Account = { - id: string; - name: string; - provider: string; - clusterCount: number; - lastScanTimestamp: string; - totalCost: number; - last15DaysCost: number; - lastMonthCost: number; - currentMonthSoFarCost: number; - clusters: Record; -}; - -export type TagData = { - count: number; - tags: Tag[]; -}; - -export type Tag = { - key: string; - value: string; -}; - -export type ClusterPerCP = { - count: number; - accounts: Account[]; -}; - -export type Instance = { - id: string; - name: string; - availabilityZone: string; - instanceType: string; - status: string; - clusterID: string; - provider: string; - lastScanTimestamp: string; - creationTimestamp: string; - dailyCost: number; - totalCost: number; - tags: Array; -}; - -export type Instances = { - count: number; - instances: Instance[]; -}; - -export type AuditEvent = { - id: number; - action_name: string; - account_id?: string; - provider?: string; - event_timestamp: string; - description?: string; - resource_id: string; - resource_type: string; - result: string; - // Should be typed? - severity: string; - triggered_by: string; -}; - -export type AuditEvents = { - count: number; - events: AuditEvent[]; -}; +export enum ResultStatus { + Pending = 'Pending', + Running = 'Running', + Success = 'Success', + Failed = 'Failed', + Warning = 'Warning', + Unknown = 'Unknown', +} -export enum ClusterStates { +export enum ActionStatus { Running = 'Running', - Stopped = 'Stopped', - Terminated = 'Terminated', + Success = 'Success', + Failed = 'Failed', + Pending = 'Pending', Unknown = 'Unknown', } -export enum CloudProvider { - AWS = 'AWS', - GCP = 'GCP', - Azure = 'Azure', +export enum ActionOperations { + POWER_ON = 'PowerOn', + POWER_OFF = 'PowerOff', } -export enum ClusterActions { - PowerOn = 'PowerOn', - PowerOff = 'PowerOff', +export enum ActionTypes { + INSTANT_ACTION = 'instant_action', + SCHEDULED_ACTION = 'scheduled_action', + CRON_ACTION = 'cron_action', } -export enum ResultStatus { - Success = 'Success', - Failed = 'Failed', - Warning = 'Warning', +export interface BaseAction { + type: 'instant_action' | 'scheduled_action' | 'cron_action'; + operation: 'PowerOff' | 'PowerOn'; + target: { + clusterID: string; + }; + status: 'Pending'; + enabled: boolean; } -export enum ScheduledActionStatus { - Success = 'Success', - Failed = 'Failed', - Pending = 'Pending', - Unknown = 'Unknown', +export interface ScheduledAction extends BaseAction { + type: 'scheduled_action'; + time: string; +} + +export interface CronAction extends BaseAction { + type: 'cron_action'; + cronExp: string; +} + +export interface InstantAction { + description?: string; } diff --git a/src/app/utils/fetchAllPages.ts b/src/app/utils/fetchAllPages.ts new file mode 100644 index 0000000..7ee2ac1 --- /dev/null +++ b/src/app/utils/fetchAllPages.ts @@ -0,0 +1,23 @@ +interface PaginatedResponse { + items: T[]; + count: number; +} + +export async function fetchAllPages( + fetchPage: (page: number, pageSize: number) => Promise>, + pageSize: number = 100 +): Promise { + const allItems: T[] = []; + let currentPage = 1; + let hasMore = true; + + while (hasMore) { + const response = await fetchPage(currentPage, pageSize); + allItems.push(...response.items); + + hasMore = response.items.length === pageSize; + currentPage++; + } + + return allItems; +} diff --git a/src/app/utils/parseFuncs.tsx b/src/app/utils/parseFuncs.tsx index cfb8c86..c8f35ad 100644 --- a/src/app/utils/parseFuncs.tsx +++ b/src/app/utils/parseFuncs.tsx @@ -1,12 +1,42 @@ import { parseISO, format } from 'date-fns'; +import { createParser } from 'nuqs'; -export function parseScanTimestamp(ts: string) { +export function parseScanTimestamp(ts: string | undefined) { + if (!ts) return 'N/A'; return format(parseISO(ts), 'HH:mm:ss - dd/MM/yyyy'); } -export function parseNumberToCurrency(value: number) { +export function parseNumberToCurrency(value: number | undefined) { + if (value === undefined || value === null) return '$0.00'; return value.toLocaleString('en-US', { style: 'currency', currency: 'USD', }); } + +export function resolveResourcePath(resourceType: string, resourceName: string): string { + if (resourceType === 'cluster') { + return `/clusters/${resourceName}`; + } + + if (resourceType === 'instance') { + return `/instances/${resourceName}`; + } + + // Fallback / defensive default + return '#'; +} + +// Nullable boolean: "true" -> true, "false" -> false, missing/other -> null +export const parseAsBooleanNullable = createParser({ + parse: value => { + if (value === 'true') return true; + if (value === 'false') return false; + return null; + }, + serialize: value => { + // nuqs expects a string; return empty string to represent "unset" + if (value === null) return ''; + return value ? 'true' : 'false'; + }, +}); diff --git a/src/app/utils/renderUtils.tsx b/src/app/utils/renderUtils.tsx index 9e6c91c..ccbbeb6 100644 --- a/src/app/utils/renderUtils.tsx +++ b/src/app/utils/renderUtils.tsx @@ -1,18 +1,63 @@ -import { ClusterStates, ResultStatus } from '@app/types/types'; import React from 'react'; +import { ActionTypes, ActionStatus, ActionOperations, ResultStatus } from '@app/types/types'; +import { ResourceStatusApi } from '@api'; import { Label } from '@patternfly/react-core'; -import { InfoCircleIcon, ExclamationTriangleIcon, ExclamationCircleIcon, UnknownIcon } from '@patternfly/react-icons'; +import { + PendingIcon, + OnRunningIcon, + InfoCircleIcon, + ExclamationTriangleIcon, + ExclamationCircleIcon, + UnknownIcon, +} from '@patternfly/react-icons'; + +export function renderActionStatusLabel(labelText: string | null | undefined) { + switch (labelText) { + case ActionStatus.Running: + return ; + case ActionStatus.Success: + return ; + case ActionStatus.Failed: + return ; + case ActionStatus.Pending: + return ; + default: + return ; + } +} export function renderStatusLabel(labelText: string | null | undefined) { switch (labelText) { - case ClusterStates.Running: + case ResourceStatusApi.Running: return ; - case ClusterStates.Stopped: + case ResourceStatusApi.Stopped: return ; - case ClusterStates.Terminated: + case ResourceStatusApi.Terminated: + return ; + default: + return ; + } +} + +export function renderActionTypeLabel(labelText: string | null | undefined) { + switch (labelText) { + case ActionTypes.INSTANT_ACTION: + return ; + case ActionTypes.SCHEDULED_ACTION: + return ; + case ActionTypes.CRON_ACTION: + return ; + default: + return ; + } +} + +export function renderOperationLabel(labelText: string | null | undefined) { + switch (labelText) { + case ActionOperations.POWER_ON: + return ; + case ActionOperations.POWER_OFF: return ; - case ClusterStates.Unknown: - return ; default: return ; } @@ -21,9 +66,18 @@ export function renderStatusLabel(labelText: string | null | undefined) { export const getResultIcon = (result: ResultStatus) => { return ( { - [ResultStatus.Success]: , - [ResultStatus.Failed]: , - [ResultStatus.Warning]: , + [ResultStatus.Success]: ( + + ), + [ResultStatus.Running]: , + [ResultStatus.Pending]: , + [ResultStatus.Failed]: ( + + ), + [ResultStatus.Warning]: ( + + ), + [ResultStatus.Unknown]: , }[result] || ); }; diff --git a/src/app/utils/tableFilters.ts b/src/app/utils/tableFilters.ts new file mode 100644 index 0000000..dc58a00 --- /dev/null +++ b/src/app/utils/tableFilters.ts @@ -0,0 +1,58 @@ +export function searchItems(items: T[], query: string, fields: (keyof T)[]): T[] { + if (!query || query.trim() === '') { + return items; + } + + const lowerQuery = query.toLowerCase(); + return items.filter(item => + fields.some(field => { + const value = item[field]; + return value != null && String(value).toLowerCase().includes(lowerQuery); + }) + ); +} + +export function filterByStatus(items: T[], status?: string | null): T[] { + if (!status) { + return items; + } + return items.filter(item => item.status === status); +} + +export function filterByActionType( + items: T[], + actionTypes?: string[] | null +): T[] { + if (!actionTypes || actionTypes.length === 0) { + return items; + } + return items.filter(item => item.actionType && actionTypes.includes(item.actionType)); +} + +export function filterByProvider(items: T[], providers?: string[] | null): T[] { + if (!providers || providers.length === 0) { + return items; + } + return items.filter(item => item.provider && providers.includes(item.provider)); +} + +export function paginateItems(items: T[], page: number, perPage: number): T[] { + const startIndex = (page - 1) * perPage; + const endIndex = startIndex + perPage; + return items.slice(startIndex, endIndex); +} + +export function sortItems(items: T[], field: keyof T, direction: 'asc' | 'desc'): T[] { + return [...items].sort((a, b) => { + const aVal = a[field]; + const bVal = b[field]; + + if (typeof aVal === 'number' && typeof bVal === 'number') { + return direction === 'asc' ? aVal - bVal : bVal - aVal; + } + + const aStr = String(aVal || ''); + const bStr = String(bVal || ''); + return direction === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr); + }); +} diff --git a/src/app/utils/tablePagination.ts b/src/app/utils/tablePagination.ts deleted file mode 100644 index 4cfdd1a..0000000 --- a/src/app/utils/tablePagination.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function getPaginatedSlice(data: T[], page: number, perPage: number): T[] { - const startIndex = (page - 1) * perPage; - const endIndex = startIndex + perPage; - return data.slice(startIndex, endIndex); -} diff --git a/tsconfig.app.json b/tsconfig.app.json index 1e669e9..9b9e408 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -3,6 +3,8 @@ "baseUrl": "./", "types": ["vite/client"], "paths": { + "@api": ["src/api"], + "@app": ["src/app"], "@app/*": ["src/app/*"], "@assets/*": ["node_modules/@patternfly/react-core/dist/styles/assets/*"], "src/*": ["src/*"] diff --git a/vite.config.ts b/vite.config.ts index 61cdd30..83fbcd2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ }, resolve: { alias: { + '@api': path.resolve(__dirname, 'src/api'), '@app': path.resolve(__dirname, 'src/app'), '@assets': path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/styles/assets'), src: path.resolve(__dirname, 'src'), @@ -45,14 +46,14 @@ export default defineConfig({ target: process.env.VITE_CIQ_API_URL || 'http://localhost:8081', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '/api/v1'), - configure: (proxy, _options) => { - proxy.on('error', (err, _req, _res) => { + configure: proxy => { + proxy.on('error', err => { console.log('proxy error', err); }); - proxy.on('proxyReq', (proxyReq, req, _res) => { + proxy.on('proxyReq', (proxyReq, req) => { console.log('sending request to the target:', req.method, req.url); }); - proxy.on('proxyRes', (proxyRes, req, _res) => { + proxy.on('proxyRes', (proxyRes, req) => { console.log('received response from the target:', proxyRes.statusCode, req.url); }); },