diff --git a/console/package-lock.json b/console/package-lock.json index 5b9c185c..5690a93f 100644 --- a/console/package-lock.json +++ b/console/package-lock.json @@ -8,6 +8,7 @@ "name": "cluster-iq-console", "version": "0.6.0", "dependencies": { + "@patternfly/react-charts": "^8.5.1", "@patternfly/react-core": "^6.4.0", "@patternfly/react-icons": "^6.4.0", "@patternfly/react-styles": "^6.4.0", @@ -23,7 +24,24 @@ "nuqs": "^2.2.3", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.28.0" + "react-router-dom": "^6.28.0", + "victory-area": "^37.3.6", + "victory-axis": "^37.3.6", + "victory-bar": "^37.3.6", + "victory-box-plot": "^37.3.6", + "victory-chart": "^37.3.6", + "victory-core": "^37.3.6", + "victory-create-container": "^37.3.6", + "victory-cursor-container": "^37.3.6", + "victory-group": "^37.3.6", + "victory-legend": "^37.3.6", + "victory-line": "^37.3.6", + "victory-pie": "^37.3.6", + "victory-scatter": "^37.3.6", + "victory-stack": "^37.3.6", + "victory-tooltip": "^37.3.6", + "victory-voronoi-container": "^37.3.6", + "victory-zoom-container": "^37.3.6" }, "devDependencies": { "@eslint/compat": "^1.2.4", @@ -1059,6 +1077,97 @@ "node": ">= 8" } }, + "node_modules/@patternfly/react-charts": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/@patternfly/react-charts/-/react-charts-8.5.1.tgz", + "integrity": "sha512-3PUeicoG1dAcAP0TW425H0rPML3MP3DHkWH4WkguOLQgmpgRfDdRWa+ePKc2K7U1cOLl8hpQm1CVMhyamePBQw==", + "license": "MIT", + "dependencies": { + "@patternfly/react-styles": "^6.5.1", + "@patternfly/react-tokens": "^6.5.1", + "hoist-non-react-statics": "^3.3.2", + "lodash": "^4.17.23", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "echarts": "^5.6.0 || ^6.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19", + "victory-area": "^37.3.6", + "victory-axis": "^37.3.6", + "victory-bar": "^37.3.6", + "victory-box-plot": "^37.3.6", + "victory-chart": "^37.3.6", + "victory-core": "^37.3.6", + "victory-create-container": "^37.3.6", + "victory-cursor-container": "^37.3.6", + "victory-group": "^37.3.6", + "victory-legend": "^37.3.6", + "victory-line": "^37.3.6", + "victory-pie": "^37.3.6", + "victory-scatter": "^37.3.6", + "victory-stack": "^37.3.6", + "victory-tooltip": "^37.3.6", + "victory-voronoi-container": "^37.3.6", + "victory-zoom-container": "^37.3.6" + }, + "peerDependenciesMeta": { + "echarts": { + "optional": true + }, + "victory-area": { + "optional": true + }, + "victory-axis": { + "optional": true + }, + "victory-bar": { + "optional": true + }, + "victory-box-plot": { + "optional": true + }, + "victory-chart": { + "optional": true + }, + "victory-core": { + "optional": true + }, + "victory-create-container": { + "optional": true + }, + "victory-cursor-container": { + "optional": true + }, + "victory-group": { + "optional": true + }, + "victory-legend": { + "optional": true + }, + "victory-line": { + "optional": true + }, + "victory-pie": { + "optional": true + }, + "victory-scatter": { + "optional": true + }, + "victory-stack": { + "optional": true + }, + "victory-tooltip": { + "optional": true + }, + "victory-voronoi-container": { + "optional": true + }, + "victory-zoom-container": { + "optional": true + } + } + }, "node_modules/@patternfly/react-core": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.4.0.tgz", @@ -1088,9 +1197,9 @@ } }, "node_modules/@patternfly/react-styles": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.4.0.tgz", - "integrity": "sha512-EXmHA67s5sy+Wy/0uxWoUQ52jr9lsH2wV3QcgtvVc5zxpyBX89gShpqv4jfVqaowznHGDoL6fVBBrSe9BYOliQ==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.5.1.tgz", + "integrity": "sha512-yQMzUbbf6qYM/v3JbPvaCJTgxRbOKoEw229XZmnnM8gDvp8ECiI7LqihrAOK/NA6T6M3DDgsRMd2JurUBhPDEw==", "license": "MIT" }, "node_modules/@patternfly/react-table": { @@ -1112,9 +1221,9 @@ } }, "node_modules/@patternfly/react-tokens": { - "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==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.5.1.tgz", + "integrity": "sha512-zwepLsIQTL0Lf4R2/PIBOk1m+pm0hYVT3lktf2H4+Y87eRIifwMRb19c+pr4hj4ckGvHs+WxwjTfTj2Qqwn5rw==", "license": "MIT" }, "node_modules/@remix-run/router": { @@ -1482,6 +1591,69 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -2224,6 +2396,127 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2341,6 +2634,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-4.0.1.tgz", + "integrity": "sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag==", + "license": "ISC" + }, + "node_modules/delaunay-find": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/delaunay-find/-/delaunay-find-0.0.6.tgz", + "integrity": "sha512-1+almjfrnR7ZamBk0q3Nhg6lqSe6Le4vL0WJDSMx4IDbQwTpUTXPjxC00lqLBT8MYsJpPCbI16sIkw9cPsbi7Q==", + "license": "ISC", + "dependencies": { + "delaunator": "^4.0.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3393,6 +3701,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3441,6 +3758,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -3884,6 +4210,12 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4492,6 +4824,12 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -5324,6 +5662,389 @@ "punycode": "^2.1.0" } }, + "node_modules/victory-area": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-area/-/victory-area-37.3.6.tgz", + "integrity": "sha512-wVC8LKrZJLiSySNuJLRCB449qZTsPiRyzLlNoJwe21y+XA/a2HJbmJSeywmo8P153aX8viKe1H8ygDsTFXQhHw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-axis": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-axis/-/victory-axis-37.3.6.tgz", + "integrity": "sha512-Vi0dZvgmXmnCdoqc49WckeG5cMXnl7FTtqVhXu9JweA9cgCnkZabBd5mRvAjblb3Lo4j0HZCSPKHYWUPW70qZg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-bar": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-bar/-/victory-bar-37.3.6.tgz", + "integrity": "sha512-jdATFRWL1LUW/yEpKWx/aId2BiU2o1pPF9+Kh1TFISBduJoI4ZqvZD90H1QK4f/z50PikqiqiDECaKoKM1jfOQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-box-plot": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-box-plot/-/victory-box-plot-37.3.6.tgz", + "integrity": "sha512-GOucnD63h14ScBuISC/nd1GBTEx6gIZfLE+0P0gyeH1poBKq0trTTvpQDvAMuGR8zICfEETG3ltmUMCwRrFyUg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-brush-container": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-brush-container/-/victory-brush-container-37.3.6.tgz", + "integrity": "sha512-LfZ2CgX1cYAqCtYxcSB68OfZS2v0T2VLXoEArd0lCXfRBY1Gya7GacCUcuo7GoK9XOXeslx7S/U95aVutt1VLg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-chart": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-chart/-/victory-chart-37.3.6.tgz", + "integrity": "sha512-IkPo/W4AJ7bPu902TGER09OseR9ODm+FQAKfOBw4JsdEhZZ7BiG9zgd/25+x0r5EsTLu81CYGQVkBa+ZazcOlA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-axis": "37.3.6", + "victory-core": "37.3.6", + "victory-polar-axis": "37.3.6", + "victory-shared-events": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-core": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-core/-/victory-core-37.3.6.tgz", + "integrity": "sha512-aFgO6KokxPbUCPznZP5UPhOdI22pMuwDXKDt6eoQOnkVim66Ia+K95TQar2nwVKGYV5j26aKVf/n9blwphGJRw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "react-fast-compare": "^3.2.0", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-create-container": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-create-container/-/victory-create-container-37.3.6.tgz", + "integrity": "sha512-Uf5bFQvqUsXCjqpvBW4LhrdrHkM6dBqxYgub6FCsBb86f84xZQ3vY7jFkg/JfvF0oGKMoWXYYrYLC1sk+fcWVA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-brush-container": "37.3.6", + "victory-core": "37.3.6", + "victory-cursor-container": "37.3.6", + "victory-selection-container": "37.3.6", + "victory-voronoi-container": "37.3.6", + "victory-zoom-container": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-cursor-container": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-cursor-container/-/victory-cursor-container-37.3.6.tgz", + "integrity": "sha512-+Oiw57d5nE+iq8As8RvepknzmNtKq1Gsc50u1X3IRd4jXtX8zqZrgXGlVZ+BP/tkLsWnGYVjKulwKBf2oaEUuw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-group": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-group/-/victory-group-37.3.6.tgz", + "integrity": "sha512-kgy/Azl5BxwlJAV0KDPGypv35TMrOD1J2ZxnJW2Wyyq+e8i0GGBIv5MoBzou64BRsDlS9V0CYRIjnkHgrBpB5w==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-core": "37.3.6", + "victory-shared-events": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-legend": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-legend/-/victory-legend-37.3.6.tgz", + "integrity": "sha512-vRRrhj3/ENqKVLdaBMzEmR83N6BOjox1bthYT1eJjN2H5SIK35bxn30IkiV/Pz3y627EqZe4TAWaxc0jiJlCiA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-line": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-line/-/victory-line-37.3.6.tgz", + "integrity": "sha512-Ke817uf/qFbN9jU7Dba7CrcHXYO5wAZuKKnyeHJmLDeQeFST0773xejnIuC+dBgZipjFr4KIbSd+VcUafFNE1g==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-pie": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-pie/-/victory-pie-37.3.6.tgz", + "integrity": "sha512-tvdgAZ/HQWlo3KDDe0XAVbizHuaNMbgkkiF7zfA7Ww+3bHSs+0P9dsDtK2xP365D8gBCOv8pWmuzvKRhzNbqeA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-polar-axis": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-polar-axis/-/victory-polar-axis-37.3.6.tgz", + "integrity": "sha512-RpFsCkzHezJq5P+C/wtVdjEHX25JIFsSgs6qYSnfr/hayaFbWgK5HhRFpriQm5hg61cx47WxAOLyHvzf0nasvw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-scatter": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-scatter/-/victory-scatter-37.3.6.tgz", + "integrity": "sha512-fp95zMTPXgW1cmTowzDXhn+KxePMVDrzU0lotsHQMdBV7eB+ioXdu9hORlx4VHmMYg2ihsGwRTF+VAZ7rGxphA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-selection-container": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-selection-container/-/victory-selection-container-37.3.6.tgz", + "integrity": "sha512-gd3qODDlBtLEJM7+2jCXk2YcLBUmIpYEEHswytMhwc6zihxXipGBUHRulhLj/I05mKay2gaOAg5ewiJHd4Awgw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-shared-events": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-shared-events/-/victory-shared-events-37.3.6.tgz", + "integrity": "sha512-ygrbOtzLUTbtKebacZKyQRekhSAROnAvMkVI/PKsAGsz0ClY9P7qDEJG7eTUUygjO6ax0tI6WNE6JogQzeD1gw==", + "license": "MIT", + "dependencies": { + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-stack": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-stack/-/victory-stack-37.3.6.tgz", + "integrity": "sha512-ldod04RdqGJGH5p5eWXCofdTkbhZqIp3iwW7NpxSbMDLs8zPQIVvDFVtuJgMwQiC5vnIpbhMmxVeFbr8m64ZKA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-core": "37.3.6", + "victory-shared-events": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-tooltip": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-tooltip/-/victory-tooltip-37.3.6.tgz", + "integrity": "sha512-vqaJS9noauOqDDBBAV9Ln9duOY/i17h1DCfCPAqhwPFyvFbwKvAub9zPTeYWAm/14VvWX5O/0yekFCVbcC7hjg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/victory-voronoi-container": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-voronoi-container/-/victory-voronoi-container-37.3.6.tgz", + "integrity": "sha512-qAAG0rMuK7A4EoJ4cyUk5wNdOW+HuCXNKPOko+hYK6wWOYXJvFhiglYyA85a695YyAXECc6JyJS/crm4IOEFag==", + "license": "MIT", + "dependencies": { + "delaunay-find": "0.0.6", + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-core": "37.3.6", + "victory-tooltip": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-zoom-container": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-zoom-container/-/victory-zoom-container-37.3.6.tgz", + "integrity": "sha512-AGL+k20mI44OL5b0VgIxlmnNSefIoFmbbim5NraPmIxbtns9qQW/56ivIncJcYomBungIx99gUpsEpcQaMNHgQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/console/package.json b/console/package.json index 0bb0a900..314f783c 100644 --- a/console/package.json +++ b/console/package.json @@ -10,6 +10,7 @@ "clean": "rm -Rf dist" }, "dependencies": { + "@patternfly/react-charts": "^8.5.1", "@patternfly/react-core": "^6.4.0", "@patternfly/react-icons": "^6.4.0", "@patternfly/react-styles": "^6.4.0", @@ -25,7 +26,24 @@ "nuqs": "^2.2.3", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.28.0" + "react-router-dom": "^6.28.0", + "victory-area": "^37.3.6", + "victory-axis": "^37.3.6", + "victory-bar": "^37.3.6", + "victory-box-plot": "^37.3.6", + "victory-chart": "^37.3.6", + "victory-core": "^37.3.6", + "victory-create-container": "^37.3.6", + "victory-cursor-container": "^37.3.6", + "victory-group": "^37.3.6", + "victory-legend": "^37.3.6", + "victory-line": "^37.3.6", + "victory-pie": "^37.3.6", + "victory-scatter": "^37.3.6", + "victory-stack": "^37.3.6", + "victory-tooltip": "^37.3.6", + "victory-voronoi-container": "^37.3.6", + "victory-zoom-container": "^37.3.6" }, "devDependencies": { "@eslint/compat": "^1.2.4", diff --git a/console/src/api/data-contracts.ts b/console/src/api/data-contracts.ts index 39c6544e..b58f35ca 100644 --- a/console/src/api/data-contracts.ts +++ b/console/src/api/data-contracts.ts @@ -237,11 +237,25 @@ export interface InstancesSummaryApi { stopped?: number; } +export interface TopItemApi { + name?: string; + clusterCount?: number; +} + +export interface AccountCostApi { + accountName?: string; + currentMonthCost?: number; +} + export interface OverviewSummaryApi { clusters?: ClusterSummaryApi; instances?: InstancesSummaryApi; providers?: ProvidersSummaryApi; scanner?: ScannerApi; + topRegions?: TopItemApi[]; + topOwners?: TopItemApi[]; + clustersByPartner?: TopItemApi[]; + costPerAccount?: AccountCostApi[]; } export interface PostResponseApi { diff --git a/console/src/app/Overview/Overview.css b/console/src/app/Overview/Overview.css new file mode 100644 index 00000000..02715451 --- /dev/null +++ b/console/src/app/Overview/Overview.css @@ -0,0 +1,4 @@ +.overview-card { + border: 1px solid var(--pf-t--global--border--color--default); + border-radius: var(--pf-t--global--border--radius--medium); +} diff --git a/console/src/app/Overview/Overview.tsx b/console/src/app/Overview/Overview.tsx index 0f6ae04f..8374cce7 100644 --- a/console/src/app/Overview/Overview.tsx +++ b/console/src/app/Overview/Overview.tsx @@ -5,8 +5,6 @@ import { CardBody, CardTitle, Gallery, - Grid, - GridItem, PageSection, Content, Alert, @@ -19,11 +17,14 @@ import { import { CubesIcon } from '@patternfly/react-icons'; import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; import { generateCards } from './components/CardData'; -import { ProviderApi } from '@api'; +import { PartnerDonutChart } from './components/PartnerDonutChart'; +import { TopMetricCard } from './components/TopMetricCard'; +import { ProviderApi, TopItemApi } from '@api'; import { renderContent } from './utils/cardRendererUtils.tsx'; import { useDashboardData } from './hooks/useDashboardData'; import { useEventsData } from './hooks/useEventsData'; import { DashboardState } from './types'; +import './Overview.css'; const AggregateStatusCards: React.FunctionComponent = () => { const { inventoryData, loading, error } = useDashboardData(); @@ -56,11 +57,6 @@ const AggregateStatusCards: React.FunctionComponent = () => { stopped: inventoryData?.clusters?.stopped || 0, terminated: inventoryData?.clusters?.archived || 0, }, - instancesByStatus: { - running: inventoryData?.instances?.running || 0, - stopped: inventoryData?.instances?.stopped || 0, - terminated: inventoryData?.instances?.archived || 0, - }, clustersByProvider: { [ProviderApi.AWSProvider]: inventoryData.providers?.aws?.clusterCount || 0, [ProviderApi.GCPProvider]: inventoryData.providers?.gcp?.clusterCount || 0, @@ -73,10 +69,19 @@ const AggregateStatusCards: React.FunctionComponent = () => { [ProviderApi.AzureProvider]: inventoryData.providers?.azure?.accountCount || 0, [ProviderApi.UnknownProvider]: 0, }, - instances: (inventoryData?.instances?.running || 0) + (inventoryData?.instances?.stopped || 0), lastScanTimestamp: inventoryData?.scanner?.lastScanTimestamp, + topRegions: inventoryData?.topRegions || [], + topOwners: inventoryData?.topOwners || [], + clustersByPartner: inventoryData?.clustersByPartner || [], + costPerAccount: inventoryData?.costPerAccount || [], }; + const costAsTopItems: TopItemApi[] = (dashboardState.costPerAccount || []).map(a => ({ + name: a.accountName, + clusterCount: a.currentMonthCost, + })); + const formatCost = (v: number) => `$${v.toFixed(2)}`; + const cardData = generateCards(dashboardState, events); return ( @@ -87,54 +92,63 @@ const AggregateStatusCards: React.FunctionComponent = () => { - - {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 - + {/* Row 1: Summary cards */} + + {cardData.summaryCards.map((card, cardIndex) => ( + + - {cards.map((card, cardIndex) => ( - - - {card.title} - - {renderContent(card.content, card.layout, card.totalCount)} - - ))} - + {card.title} + + {renderContent(card.content, card.layout, card.totalCount)} + + ))} + + + {/* Row 2: Partner chart + ranked lists */} +
+ +
+ + + + +
+
+ + {/* Row 4: Recent Events */} + + {cardData.activityCards[0].title} + + {eventsLoading ? ( + + ) : eventsError ? ( + +

{eventsError}

+

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

+
+ ) : cardData.activityCards[0].customComponent ? ( + cardData.activityCards[0].customComponent + ) : ( + renderContent( + cardData.activityCards[0].content, + cardData.activityCards[0].layout, + cardData.activityCards[0].totalCount + ) )} -
- ))} -
+ + +
); diff --git a/console/src/app/Overview/components/CardData.tsx b/console/src/app/Overview/components/CardData.tsx index 21cefb19..53ee81e7 100644 --- a/console/src/app/Overview/components/CardData.tsx +++ b/console/src/app/Overview/components/CardData.tsx @@ -12,35 +12,53 @@ export const generateCards = ( 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 = [ + const totalAccounts = Object.values(state.accountsByProvider).reduce((sum, count) => sum + count, 0); + const totalClustersByProvider = + Object.values(state.clustersByProvider).reduce((sum, count) => sum + count, 0) - + (state.clustersByStatus.terminated || 0); + const totalClustersByStatus = (state.clustersByStatus.running || 0) + (state.clustersByStatus.stopped || 0); + + const summaryCards: CardDefinition[] = [ { - title: 'Clusters', - content: Object.entries(STATUSES).map(([key, status]) => ({ - icon: status.icon, - value: state.clustersByStatus[key] || 0, - ref: status.route, + title: 'Accounts', + content: Object.values(CLOUD_PROVIDERS).map(provider => ({ + icon: provider.providerIcon, + value: state.accountsByProvider[provider.key] ?? 0, + ref: `/accounts?provider=${provider.key}`, + })), + layout: CardLayout.MULTI_ICON, + totalCount: { + icon: TOTAL_COUNT_ICONS.clusters, + value: totalAccounts, + label: 'Total', + }, + }, + { + title: 'Clusters by Provider', + content: Object.values(CLOUD_PROVIDERS).map(provider => ({ + icon: provider.icon, + value: state.clustersByProvider[provider.key] ?? 0, + ref: `/clusters?provider=${provider.key}`, })), layout: CardLayout.MULTI_ICON, totalCount: { icon: TOTAL_COUNT_ICONS.clusters, - value: totalClusters, + value: totalClustersByProvider, label: 'Total', }, }, { - title: 'Instances', + title: 'Clusters by Status', content: Object.entries(STATUSES).map(([key, status]) => ({ icon: status.icon, - value: state.instancesByStatus[key] || 0, + value: state.clustersByStatus[key] || 0, ref: status.route, })), layout: CardLayout.MULTI_ICON, totalCount: { - icon: TOTAL_COUNT_ICONS.instances, - value: totalInstances, + icon: TOTAL_COUNT_ICONS.clusters, + value: totalClustersByStatus, label: 'Total', }, }, @@ -51,35 +69,17 @@ export const generateCards = ( }, ]; - const providerCards = Object.values(CLOUD_PROVIDERS).map(provider => ({ - title: provider.title, - content: [ - { - value: `${state.clustersByProvider[provider.key] ?? 0} Cluster(s)`, - icon: provider.icon, - ref: `/clusters?provider=${provider.key}`, - }, - { - value: `${state.accountsByProvider[provider.key] ?? 0} Account(s)`, - icon: provider.providerIcon, - ref: `/accounts?provider=${provider.key}`, - }, - ], - layout: CardLayout.MULTI_ICON, - })); - - const activityCards = [ + const activityCards: CardDefinition[] = [ { title: 'Recent events', - content: [], // Empty content since we're using customComponent + content: [], layout: CardLayout.MULTI_ICON, customComponent: , }, ]; return { - statusCards, - providerCards, + summaryCards, activityCards, }; }; diff --git a/console/src/app/Overview/components/CostBarChart.tsx b/console/src/app/Overview/components/CostBarChart.tsx new file mode 100644 index 00000000..769ed932 --- /dev/null +++ b/console/src/app/Overview/components/CostBarChart.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { Chart, ChartBar, ChartAxis, ChartGroup, ChartThemeColor } from '@patternfly/react-charts/victory'; +import { AccountCostApi } from '@api'; + +interface CostBarChartProps { + data: AccountCostApi[]; +} + +const axisTextStyle = { fill: 'var(--pf-t--global--text--color--regular)' }; + +export const CostBarChart: React.FC = ({ data }) => { + const chartData = (data || []).map(item => ({ + x: item.accountName || 'Unknown', + y: item.currentMonthCost ?? 0, + })); + + const maxCost = Math.max(...chartData.map(d => d.y), 1); + + return ( + + Cost per Account (Current Month) + + {chartData.length === 0 ? ( + No cost data available + ) : ( +
+ + + `$${t.toFixed(0)}`} + style={{ tickLabels: { ...axisTextStyle, fontSize: 12 } }} + /> + + + + +
+ )} +
+
+ ); +}; diff --git a/console/src/app/Overview/components/PartnerDonutChart.tsx b/console/src/app/Overview/components/PartnerDonutChart.tsx new file mode 100644 index 00000000..bc2cd359 --- /dev/null +++ b/console/src/app/Overview/components/PartnerDonutChart.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { ChartDonut, ChartThemeColor } from '@patternfly/react-charts/victory'; +import { TopItemApi } from '@api'; + +interface PartnerDonutChartProps { + data: TopItemApi[]; +} + +const DONUT_COLORS = ['#06c', '#4cb140', '#009596', '#f4c145', '#ec7a08', '#7d1007', '#8481dd']; + +export const PartnerDonutChart: React.FC = ({ data }) => { + const chartData = (data || []).map(item => ({ + x: item.name || 'Unknown', + y: item.clusterCount ?? 0, + })); + + const total = chartData.reduce((sum, d) => sum + d.y, 0); + + return ( + + Clusters by Partner + + {chartData.length === 0 ? ( + No partner data available + ) : ( +
+
+ DONUT_COLORS[index % DONUT_COLORS.length], + }, + }} + /> +
+
{total}
+
Clusters
+
+
+
+ {chartData.map((d, i) => ( +
+ + + {d.x}: {d.y} + +
+ ))} +
+
+ )} +
+
+ ); +}; diff --git a/console/src/app/Overview/components/TopMetricCard.tsx b/console/src/app/Overview/components/TopMetricCard.tsx new file mode 100644 index 00000000..6996199f --- /dev/null +++ b/console/src/app/Overview/components/TopMetricCard.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { TopItemApi } from '@api'; + +interface TopMetricCardProps { + title: string; + items: TopItemApi[]; + formatValue?: (value: number) => string; +} + +export const TopMetricCard: React.FC = ({ title, items, formatValue }) => { + if (!items || items.length === 0) { + return ( + + {title} + + No data available + + + ); + } + + return ( + + {title} + +
+ {items.map((item, index) => ( +
+ {item.name || 'Unknown'} + + {formatValue ? formatValue(item.clusterCount ?? 0) : (item.clusterCount ?? 0)} + +
+ ))} +
+
+
+ ); +}; diff --git a/console/src/app/Overview/constants.tsx b/console/src/app/Overview/constants.tsx index 6082293f..14ec30ef 100644 --- a/console/src/app/Overview/constants.tsx +++ b/console/src/app/Overview/constants.tsx @@ -23,9 +23,9 @@ const PATTERNFLY_COLORS = { const CLUSTER_ICON = ; const PROVIDER_ICONS = { - [ProviderApi.AWSProvider]: , - [ProviderApi.GCPProvider]: , - [ProviderApi.AzureProvider]: , + [ProviderApi.AWSProvider]: , + [ProviderApi.GCPProvider]: , + [ProviderApi.AzureProvider]: , } as const; export const STATUSES = { diff --git a/console/src/app/Overview/types.ts b/console/src/app/Overview/types.ts index 80653c87..1f1a1638 100644 --- a/console/src/app/Overview/types.ts +++ b/console/src/app/Overview/types.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { ProviderApi } from '@api'; +import { ProviderApi, TopItemApi, AccountCostApi } from '@api'; export enum CardLayout { SINGLE_ICON = 'icon', @@ -29,9 +29,11 @@ export interface CardDefinition { export interface DashboardState { clustersByStatus: Record; - instancesByStatus: Record; clustersByProvider: Record; accountsByProvider: Record; - instances: number; lastScanTimestamp?: string; + topRegions: TopItemApi[]; + topOwners: TopItemApi[]; + clustersByPartner: TopItemApi[]; + costPerAccount: AccountCostApi[]; } diff --git a/db/test_files/load_example_data.sql b/db/test_files/load_example_data.sql index cacd945d..30e2d99a 100644 --- a/db/test_files/load_example_data.sql +++ b/db/test_files/load_example_data.sql @@ -2,19 +2,10 @@ -- psql postgresql://user:password@pgsql:5432/clusteriq < load_example_data.sql BEGIN; --- Limpia datos previos (si los hubiera) +-- Clean previous data TRUNCATE action_runs, schedule, targets, expenses, tags, instances, clusters, accounts RESTART IDENTITY CASCADE; --- Inserta 3 cuentas (una por proveedor) y guarda sus IDs -WITH ins AS ( - INSERT INTO accounts (account_id, account_name, provider, last_scan_ts) - VALUES - ('111111111111', 'aws-account-demo', 'AWS', now() - INTERVAL '1 day'), - ('gcp-project-1', 'gcp-project-demo', 'GCP', now() - INTERVAL '2 days'), - ('subs-00000001', 'azure-sub-demo', 'Azure', now() - INTERVAL '3 days') - RETURNING id, provider -) -SELECT * FROM ins; +-- Drop existing expense partitions to avoid conflicts DO $$ DECLARE r RECORD; @@ -29,201 +20,450 @@ BEGIN END $$; - +-- Create expense partitions for 3 months DO $$ DECLARE cur_start DATE := date_trunc('month', current_date)::date; cur_end DATE := (cur_start + INTERVAL '1 month')::date; - prev_start DATE := date_trunc('month', current_date - INTERVAL '1 month')::date; prev_end DATE := cur_start; - - prev_prev_start DATE := date_trunc('month', current_date - INTERVAL '2 month')::date; - prev_prev_end DATE := prev_start; - - cur_suffix TEXT := to_char(cur_start, 'YYYY_MM'); - prev_suffix TEXT := to_char(prev_start, 'YYYY_MM'); - prev_prev_suffix TEXT := to_char(prev_prev_start, 'YYYY_MM'); - - part_name TEXT; - sql TEXT; + prev2_start DATE := date_trunc('month', current_date - INTERVAL '2 month')::date; + prev2_end DATE := prev_start; + part_name TEXT; BEGIN - -- Partición del mes anterior anterior: expenses_YYYY_MM - part_name := format('expenses_%s', prev_prev_suffix); + part_name := format('expenses_%s', to_char(prev2_start, 'YYYY_MM')); IF to_regclass(part_name) IS NULL THEN - sql := format( - 'CREATE TABLE %I PARTITION OF expenses - FOR VALUES FROM (%L) TO (%L);', - part_name, prev_prev_start, prev_prev_end - ); - EXECUTE sql; + EXECUTE format('CREATE TABLE %I PARTITION OF expenses FOR VALUES FROM (%L) TO (%L)', part_name, prev2_start, prev2_end); END IF; - -- Partición del mes anterior: expenses_YYYY_MM - part_name := format('expenses_%s', prev_suffix); + part_name := format('expenses_%s', to_char(prev_start, 'YYYY_MM')); IF to_regclass(part_name) IS NULL THEN - sql := format( - 'CREATE TABLE %I PARTITION OF expenses - FOR VALUES FROM (%L) TO (%L);', - part_name, prev_start, prev_end - ); - EXECUTE sql; + EXECUTE format('CREATE TABLE %I PARTITION OF expenses FOR VALUES FROM (%L) TO (%L)', part_name, prev_start, prev_end); END IF; - -- Partición del mes en curso: expenses_YYYY_MM - part_name := format('expenses_%s', cur_suffix); + part_name := format('expenses_%s', to_char(cur_start, 'YYYY_MM')); IF to_regclass(part_name) IS NULL THEN - sql := format( - 'CREATE TABLE %I PARTITION OF expenses - FOR VALUES FROM (%L) TO (%L);', - part_name, cur_start, cur_end - ); - EXECUTE sql; + EXECUTE format('CREATE TABLE %I PARTITION OF expenses FOR VALUES FROM (%L) TO (%L)', part_name, cur_start, cur_end); END IF; END $$; - - --- Función auxiliar para obtener un STATUS aleatorio --- (usamos un SELECT al vuelo dentro del DO; no se define nada permanente) --- Generación principal +-- ============================================================================ +-- Accounts: 5 accounts across 3 providers +-- ============================================================================ +INSERT INTO accounts (account_id, account_name, provider, last_scan_ts) VALUES + ('111111111111', 'rh-engineering-prod', 'AWS', now() - INTERVAL '2 hours'), + ('222222222222', 'rh-engineering-dev', 'AWS', now() - INTERVAL '2 hours'), + ('333333333333', 'rh-qe-staging', 'AWS', now() - INTERVAL '2 hours'), + ('gcp-proj-001', 'rh-platform-gcp', 'GCP', now() - INTERVAL '2 hours'), + ('azure-sub-01', 'rh-services-azure', 'Azure', now() - INTERVAL '2 hours'); + +-- ============================================================================ +-- Main data generation +-- ============================================================================ DO $$ DECLARE - -- cuentas - r_acc RECORD; - - -- clusters - n_clusters INT; - r_clu_id BIGINT; - r_clu_name TEXT; - r_clu_infra TEXT; - r_region TEXT; - - -- instancias - n_insts INT; - r_ins_id BIGINT; - r_ins_name TEXT; - r_az TEXT; - st STATUS; - - -- fechas/importe expenses - d DATE; - amt NUMERIC(12,2); - base_cost NUMERIC(12,2); - day_offset INT; + v_acc_id INT; + v_clu_pk BIGINT; + v_ins_pk BIGINT; + + -- Cluster definition arrays + v_name TEXT; + v_infra TEXT; + v_region TEXT; + v_status STATUS; + v_owner TEXT; + v_provider CLOUD_PROVIDER; + v_age INT; + v_partner TEXT; + + -- Instance vars + v_ins_name TEXT; + v_ins_type TEXT; + v_az TEXT; + v_ins_status STATUS; + + -- Expense vars + d DATE; + base_cost NUMERIC(12,2); + amt NUMERIC(12,2); + + -- Tag key pools + partners TEXT[] := ARRAY['Red Hat', 'Accenture', 'IBM', 'Deloitte', 'Wipro', 'Infosys', 'TCS']; + owners TEXT[] := ARRAY['jsmith@redhat.com', 'agarcia@redhat.com', 'mchen@redhat.com', 'pjones@redhat.com', 'lbrown@redhat.com', 'klee@redhat.com', 'ssingh@redhat.com', 'twilson@redhat.com']; + teams TEXT[] := ARRAY['Platform', 'SRE', 'QE', 'Performance', 'Security', 'DevOps', 'Middleware']; + envs TEXT[] := ARRAY['production', 'staging', 'development', 'qa', 'perf-test', 'sandbox']; + + -- AWS regions + aws_regions TEXT[] := ARRAY['us-east-1', 'us-east-2', 'us-west-2', 'eu-west-1', 'eu-central-1', 'ap-southeast-1']; + -- GCP regions + gcp_regions TEXT[] := ARRAY['us-central1', 'europe-west1', 'europe-west3', 'asia-east1']; + -- Azure regions + az_regions TEXT[] := ARRAY['eastus', 'westeurope', 'northeurope', 'southeastasia']; + + -- Instance type pools + aws_types TEXT[] := ARRAY['m5.xlarge', 'm5.2xlarge', 'r5.xlarge', 'r5.2xlarge', 'c5.2xlarge', 'c5.4xlarge', 'm6i.xlarge', 'm6i.2xlarge']; + gcp_types TEXT[] := ARRAY['e2-standard-4', 'e2-standard-8', 'n2-standard-4', 'n2-standard-8', 'n2-highmem-4']; + az_types TEXT[] := ARRAY['Standard_D4s_v3', 'Standard_D8s_v3', 'Standard_E4s_v3', 'Standard_E8s_v3']; + + -- Cluster definitions: (account_index, name, status, region_index, owner_index, partner_index, base_daily_cost) + -- We'll generate these programmatically per account + n_instances INT; + cost_base NUMERIC(12,2); + BEGIN - -- Itera por cada cuenta creada - FOR r_acc IN - SELECT id, provider FROM accounts ORDER BY id - LOOP - -- nº de clusters aleatorio por cuenta: 4–10 - n_clusters := 4 + floor(random() * 7)::INT; - - FOR i IN 1..n_clusters LOOP - -- Región “plausible” por proveedor - IF r_acc.provider = 'AWS'::cloud_provider THEN - r_region := ('us-east-' || (1 + floor(random()*2))::INT); - ELSIF r_acc.provider = 'GCP'::cloud_provider THEN - r_region := ('europe-west' || (1 + floor(random()*2))::INT); - ELSE - r_region := ('westeurope'); - END IF; - - r_clu_name := format('%s-cluster-%s', lower(r_acc.provider::TEXT), i); - r_clu_infra := format('%s-infra-%s', lower(r_acc.provider::TEXT), i); - - INSERT INTO clusters ( - cluster_name, cluster_id, infra_id, provider, status, region, account_id, - console_link, last_scan_ts, created_at, age, owner - ) - VALUES ( - r_clu_name, - r_clu_name || r_clu_infra, - r_clu_infra, - r_acc.provider, - (ARRAY['Running','Stopped','Unknown']::status[])[1 + floor(random()*3)::INT], - r_region, - r_acc.id, - 'https://console.example.local', - now() - make_interval(days => (1 + floor(random()*5))::INT), - now() - make_interval(days => (10 + floor(random()*90))::INT), - 10 + floor(random()*90)::INT, - 'team@example.com' - ) - RETURNING id INTO r_clu_id; - - -- nº de instancias por cluster: 6–12 - n_insts := 6 + floor(random() * 7)::INT; - - FOR j IN 1..n_insts LOOP - -- Status aleatorio (más prob. de Running) - st := (ARRAY['Running','Running','Running','Stopped','Unknown']::status[])[1 + floor(random()*5)::INT]; - - -- AZ derivada de la región - r_az := r_region || chr(97 + floor(random()*3)::INT); -- a/b/c - - r_ins_name := format('%s-%s-%s', r_clu_name, r_az, j); - - INSERT INTO instances ( - instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, - status, last_scan_ts, created_at, age - ) - VALUES ( - r_ins_name, - 'id-' || r_ins_name, - r_clu_id, - r_acc.provider, - (ARRAY['t3.micro','t3.medium','m6g.large','c6i.large']::TEXT[])[1 + floor(random()*4)::INT], - r_az, - st, - now() - make_interval(days => (0 + floor(random()*3))::INT), - now() - make_interval(days => (20 + floor(random()*200))::INT), - 20 + floor(random()*200)::INT - ) - RETURNING id INTO r_ins_id; - - -- Tags fijas por instancia - INSERT INTO tags(key, value, instance_id) - VALUES - ('name', r_ins_name, r_ins_id), - ('owner', 'john.doe@example.com', r_ins_id); - - -- Expenses: 60 días hacia atrás (>= 40) - base_cost := (ARRAY[0.75, 1.25, 1.80, 2.40]::NUMERIC[])[1 + floor(random()*4)::INT]; - FOR day_offset IN 0..59 LOOP - d := (current_date - day_offset); - -- Variación diaria suave - amt := round( GREATEST(0.10, base_cost * (0.8 + random()*0.6))::NUMERIC, 2); - INSERT INTO expenses(instance_id, date, amount) - VALUES (r_ins_id, d, amt); - END LOOP; - END LOOP; - END LOOP; + -- ======================================================================== + -- Account 1: rh-engineering-prod (AWS) — 6 clusters, heavy usage + -- ======================================================================== + SELECT id INTO v_acc_id FROM accounts WHERE account_id = '111111111111'; + v_provider := 'AWS'; + + -- Cluster 1: Large production cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-prod-east', 'ocp-prod-east-abc12', 'abc12', v_provider, 'Running', 'us-east-1', v_acc_id, 'https://console-openshift-console.apps.ocp-prod-east.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '120 days', 120, 'jsmith@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..8 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-prod-east', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-east-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '120 days', 120) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'jsmith@redhat.com', v_ins_pk), ('Partner', 'Red Hat', v_ins_pk), ('Team', 'Platform', v_ins_pk), ('Environment', 'production', v_ins_pk); + cost_base := 3.50 + random() * 2.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.50, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 2: Staging cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-staging-east', 'ocp-staging-east-def34', 'def34', v_provider, 'Running', 'us-east-1', v_acc_id, 'https://console-openshift-console.apps.ocp-staging-east.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '90 days', 90, 'agarcia@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..5 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-staging-east', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-east-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '90 days', 90) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'agarcia@redhat.com', v_ins_pk), ('Partner', 'Accenture', v_ins_pk), ('Team', 'SRE', v_ins_pk), ('Environment', 'staging', v_ins_pk); + cost_base := 2.20 + random() * 1.5; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.30, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 3: Stopped weekend cluster + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-weekend-west', 'ocp-weekend-west-ghi56', 'ghi56', v_provider, 'Stopped', 'us-west-2', v_acc_id, 'https://console-openshift-console.apps.ocp-weekend-west.example.com', now() - INTERVAL '3 hours', now() - INTERVAL '60 days', 60, 'mchen@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..4 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-weekend-west', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-west-2' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Stopped', now() - INTERVAL '3 hours', now() - INTERVAL '60 days', 60) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'mchen@redhat.com', v_ins_pk), ('Partner', 'IBM', v_ins_pk), ('Team', 'QE', v_ins_pk), ('Environment', 'development', v_ins_pk); + cost_base := 1.80 + random() * 1.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.10, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 4: EU production cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-prod-eu', 'ocp-prod-eu-jkl78', 'jkl78', v_provider, 'Running', 'eu-west-1', v_acc_id, 'https://console-openshift-console.apps.ocp-prod-eu.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '200 days', 200, 'pjones@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..6 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-prod-eu', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'eu-west-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '200 days', 200) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'pjones@redhat.com', v_ins_pk), ('Partner', 'Deloitte', v_ins_pk), ('Team', 'Security', v_ins_pk), ('Environment', 'production', v_ins_pk); + cost_base := 4.00 + random() * 2.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.80, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 5: Terminated old cluster + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-legacy-east', 'ocp-legacy-east-mno90', 'mno90', v_provider, 'Terminated', 'us-east-2', v_acc_id, '', now() - INTERVAL '30 days', now() - INTERVAL '365 days', 365, 'lbrown@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..3 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-legacy-east', j); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, 'm5.xlarge', 'us-east-2a', 'Terminated', now() - INTERVAL '30 days', now() - INTERVAL '365 days', 365) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'lbrown@redhat.com', v_ins_pk), ('Partner', 'Red Hat', v_ins_pk), ('Team', 'Platform', v_ins_pk), ('Environment', 'production', v_ins_pk); + END LOOP; + + -- Cluster 6: Frankfurt perf-test cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-perf-fra', 'ocp-perf-fra-pqr12', 'pqr12', v_provider, 'Running', 'eu-central-1', v_acc_id, 'https://console-openshift-console.apps.ocp-perf-fra.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '45 days', 45, 'ssingh@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..6 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-perf-fra', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'eu-central-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '45 days', 45) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'ssingh@redhat.com', v_ins_pk), ('Partner', 'Wipro', v_ins_pk), ('Team', 'Performance', v_ins_pk), ('Environment', 'perf-test', v_ins_pk); + cost_base := 5.00 + random() * 3.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(1.00, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- ======================================================================== + -- Account 2: rh-engineering-dev (AWS) — 4 clusters, moderate usage + -- ======================================================================== + SELECT id INTO v_acc_id FROM accounts WHERE account_id = '222222222222'; + + -- Cluster 7: Dev cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-dev-east', 'ocp-dev-east-stu34', 'stu34', v_provider, 'Running', 'us-east-1', v_acc_id, 'https://console-openshift-console.apps.ocp-dev-east.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '30 days', 30, 'klee@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..4 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-dev-east', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-east-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '30 days', 30) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'klee@redhat.com', v_ins_pk), ('Partner', 'Infosys', v_ins_pk), ('Team', 'DevOps', v_ins_pk), ('Environment', 'development', v_ins_pk); + cost_base := 1.50 + random() * 1.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.20, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 8: Sandbox, stopped + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-sandbox', 'ocp-sandbox-vwx56', 'vwx56', v_provider, 'Stopped', 'us-east-2', v_acc_id, 'https://console-openshift-console.apps.ocp-sandbox.example.com', now() - INTERVAL '5 hours', now() - INTERVAL '15 days', 15, 'twilson@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..3 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-sandbox', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-east-2' || chr(97 + (j % 2)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Stopped', now() - INTERVAL '5 hours', now() - INTERVAL '15 days', 15) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'twilson@redhat.com', v_ins_pk), ('Partner', 'TCS', v_ins_pk), ('Team', 'QE', v_ins_pk), ('Environment', 'sandbox', v_ins_pk); + cost_base := 0.80 + random() * 0.5; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.05, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 9: CI cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-ci-west', 'ocp-ci-west-yza78', 'yza78', v_provider, 'Running', 'us-west-2', v_acc_id, 'https://console-openshift-console.apps.ocp-ci-west.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '75 days', 75, 'jsmith@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..5 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-ci-west', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-west-2' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '75 days', 75) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'jsmith@redhat.com', v_ins_pk), ('Partner', 'Red Hat', v_ins_pk), ('Team', 'SRE', v_ins_pk), ('Environment', 'qa', v_ins_pk); + cost_base := 2.50 + random() * 1.5; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.30, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 10: Terminated dev cluster + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-dev-old', 'ocp-dev-old-bcd90', 'bcd90', v_provider, 'Terminated', 'us-east-1', v_acc_id, '', now() - INTERVAL '45 days', now() - INTERVAL '180 days', 180, 'agarcia@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..3 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-dev-old', j); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, 'm5.xlarge', 'us-east-1a', 'Terminated', now() - INTERVAL '45 days', now() - INTERVAL '180 days', 180) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'agarcia@redhat.com', v_ins_pk), ('Partner', 'Accenture', v_ins_pk), ('Team', 'DevOps', v_ins_pk), ('Environment', 'development', v_ins_pk); END LOOP; + + -- ======================================================================== + -- Account 3: rh-qe-staging (AWS) — 3 clusters + -- ======================================================================== + SELECT id INTO v_acc_id FROM accounts WHERE account_id = '333333333333'; + + -- Cluster 11: QE main cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-qe-main', 'ocp-qe-main-efg12', 'efg12', v_provider, 'Running', 'us-east-1', v_acc_id, 'https://console-openshift-console.apps.ocp-qe-main.example.com', now() - INTERVAL '1 hour', now() - INTERVAL '100 days', 100, 'mchen@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..6 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-qe-main', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-east-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '100 days', 100) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'mchen@redhat.com', v_ins_pk), ('Partner', 'IBM', v_ins_pk), ('Team', 'QE', v_ins_pk), ('Environment', 'qa', v_ins_pk); + cost_base := 2.80 + random() * 1.5; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.40, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 12: QE nightly, stopped + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-qe-nightly', 'ocp-qe-nightly-hij34', 'hij34', v_provider, 'Stopped', 'us-west-2', v_acc_id, 'https://console-openshift-console.apps.ocp-qe-nightly.example.com', now() - INTERVAL '6 hours', now() - INTERVAL '50 days', 50, 'lbrown@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..4 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-qe-nightly', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'us-west-2' || chr(97 + (j % 2)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Stopped', now() - INTERVAL '6 hours', now() - INTERVAL '50 days', 50) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'lbrown@redhat.com', v_ins_pk), ('Partner', 'Deloitte', v_ins_pk), ('Team', 'QE', v_ins_pk), ('Environment', 'qa', v_ins_pk); + cost_base := 1.20 + random() * 0.8; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.10, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 13: APAC QE cluster, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('ocp-qe-apac', 'ocp-qe-apac-klm56', 'klm56', v_provider, 'Running', 'ap-southeast-1', v_acc_id, 'https://console-openshift-console.apps.ocp-qe-apac.example.com', now() - INTERVAL '2 hours', now() - INTERVAL '25 days', 25, 'ssingh@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..4 LOOP + v_ins_name := format('i-%s-node-%s', 'ocp-qe-apac', j); + v_ins_type := aws_types[1 + floor(random()*array_length(aws_types,1))::INT]; + v_az := 'ap-southeast-1' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '2 hours', now() - INTERVAL '25 days', 25) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'ssingh@redhat.com', v_ins_pk), ('Partner', 'Wipro', v_ins_pk), ('Team', 'Performance', v_ins_pk), ('Environment', 'perf-test', v_ins_pk); + cost_base := 2.00 + random() * 1.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.25, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- ======================================================================== + -- Account 4: rh-platform-gcp (GCP) — 3 clusters + -- ======================================================================== + SELECT id INTO v_acc_id FROM accounts WHERE account_id = 'gcp-proj-001'; + v_provider := 'GCP'; + + -- Cluster 14: GCP production, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('gke-prod-eu', 'gke-prod-eu-nop78', 'nop78', v_provider, 'Running', 'europe-west1', v_acc_id, 'https://console.cloud.google.com/kubernetes/clusters/gke-prod-eu', now() - INTERVAL '1 hour', now() - INTERVAL '150 days', 150, 'pjones@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..5 LOOP + v_ins_name := format('gke-%s-node-%s', 'prod-eu', j); + v_ins_type := gcp_types[1 + floor(random()*array_length(gcp_types,1))::INT]; + v_az := 'europe-west1-' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '150 days', 150) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'pjones@redhat.com', v_ins_pk), ('Partner', 'Red Hat', v_ins_pk), ('Team', 'Middleware', v_ins_pk), ('Environment', 'production', v_ins_pk); + cost_base := 3.00 + random() * 2.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.50, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 15: GCP staging, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('gke-staging-us', 'gke-staging-us-qrs90', 'qrs90', v_provider, 'Running', 'us-central1', v_acc_id, 'https://console.cloud.google.com/kubernetes/clusters/gke-staging-us', now() - INTERVAL '1 hour', now() - INTERVAL '80 days', 80, 'klee@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..4 LOOP + v_ins_name := format('gke-%s-node-%s', 'staging-us', j); + v_ins_type := gcp_types[1 + floor(random()*array_length(gcp_types,1))::INT]; + v_az := 'us-central1-' || chr(97 + (j % 3)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '80 days', 80) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'klee@redhat.com', v_ins_pk), ('Partner', 'TCS', v_ins_pk), ('Team', 'Platform', v_ins_pk), ('Environment', 'staging', v_ins_pk); + cost_base := 2.00 + random() * 1.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.30, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 16: GCP dev, stopped + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('gke-dev-asia', 'gke-dev-asia-tuv12', 'tuv12', v_provider, 'Stopped', 'asia-east1', v_acc_id, 'https://console.cloud.google.com/kubernetes/clusters/gke-dev-asia', now() - INTERVAL '8 hours', now() - INTERVAL '20 days', 20, 'agarcia@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..3 LOOP + v_ins_name := format('gke-%s-node-%s', 'dev-asia', j); + v_ins_type := gcp_types[1 + floor(random()*array_length(gcp_types,1))::INT]; + v_az := 'asia-east1-' || chr(97 + (j % 2)); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Stopped', now() - INTERVAL '8 hours', now() - INTERVAL '20 days', 20) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'agarcia@redhat.com', v_ins_pk), ('Partner', 'Infosys', v_ins_pk), ('Team', 'DevOps', v_ins_pk), ('Environment', 'development', v_ins_pk); + cost_base := 1.00 + random() * 0.5; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.10, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- ======================================================================== + -- Account 5: rh-services-azure (Azure) — 3 clusters + -- ======================================================================== + SELECT id INTO v_acc_id FROM accounts WHERE account_id = 'azure-sub-01'; + v_provider := 'Azure'; + + -- Cluster 17: Azure production, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('aro-prod-westeu', 'aro-prod-westeu-wxy34', 'wxy34', v_provider, 'Running', 'westeurope', v_acc_id, 'https://portal.azure.com/aro-prod-westeu', now() - INTERVAL '1 hour', now() - INTERVAL '110 days', 110, 'twilson@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..5 LOOP + v_ins_name := format('aro-%s-node-%s', 'prod-westeu', j); + v_ins_type := az_types[1 + floor(random()*array_length(az_types,1))::INT]; + v_az := 'westeurope-' || (j % 3 + 1); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '1 hour', now() - INTERVAL '110 days', 110) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'twilson@redhat.com', v_ins_pk), ('Partner', 'Accenture', v_ins_pk), ('Team', 'Security', v_ins_pk), ('Environment', 'production', v_ins_pk); + cost_base := 3.80 + random() * 2.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.60, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 18: Azure staging, running + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('aro-staging-north', 'aro-staging-north-zab56', 'zab56', v_provider, 'Running', 'northeurope', v_acc_id, 'https://portal.azure.com/aro-staging-north', now() - INTERVAL '2 hours', now() - INTERVAL '40 days', 40, 'mchen@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..3 LOOP + v_ins_name := format('aro-%s-node-%s', 'staging-north', j); + v_ins_type := az_types[1 + floor(random()*array_length(az_types,1))::INT]; + v_az := 'northeurope-' || (j % 3 + 1); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Running', now() - INTERVAL '2 hours', now() - INTERVAL '40 days', 40) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'mchen@redhat.com', v_ins_pk), ('Partner', 'IBM', v_ins_pk), ('Team', 'Middleware', v_ins_pk), ('Environment', 'staging', v_ins_pk); + cost_base := 2.50 + random() * 1.0; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.30, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + + -- Cluster 19: Azure APAC dev, stopped + INSERT INTO clusters (cluster_name, cluster_id, infra_id, provider, status, region, account_id, console_link, last_scan_ts, created_at, age, owner) + VALUES ('aro-dev-seasia', 'aro-dev-seasia-cde78', 'cde78', v_provider, 'Stopped', 'southeastasia', v_acc_id, 'https://portal.azure.com/aro-dev-seasia', now() - INTERVAL '10 hours', now() - INTERVAL '10 days', 10, 'jsmith@redhat.com') + RETURNING id INTO v_clu_pk; + FOR j IN 1..3 LOOP + v_ins_name := format('aro-%s-node-%s', 'dev-seasia', j); + v_ins_type := az_types[1 + floor(random()*array_length(az_types,1))::INT]; + v_az := 'southeastasia-' || (j % 2 + 1); + INSERT INTO instances (instance_id, instance_name, cluster_id, provider, instance_type, availability_zone, status, last_scan_ts, created_at, age) + VALUES (v_ins_name, v_ins_name, v_clu_pk, v_provider, v_ins_type, v_az, 'Stopped', now() - INTERVAL '10 hours', now() - INTERVAL '10 days', 10) + RETURNING id INTO v_ins_pk; + INSERT INTO tags(key, value, instance_id) VALUES ('Name', v_ins_name, v_ins_pk), ('Owner', 'jsmith@redhat.com', v_ins_pk), ('Partner', 'Deloitte', v_ins_pk), ('Team', 'SRE', v_ins_pk), ('Environment', 'development', v_ins_pk); + cost_base := 1.20 + random() * 0.6; + FOR day_offset IN 0..59 LOOP d := current_date - day_offset; amt := round(GREATEST(0.10, cost_base * (0.85 + random()*0.30))::NUMERIC, 2); INSERT INTO expenses(instance_id, date, amount) VALUES (v_ins_pk, d, amt); END LOOP; + END LOOP; + END $$; --- Generating scheduled actions (with targets) +-- ============================================================================ +-- Scheduled actions (with targets) +-- ============================================================================ DO $$ DECLARE v_target_id BIGINT; v_cluster_id BIGINT; v_operation ACTION_OPERATION; BEGIN - FOR g IN 1..3 LOOP - SELECT id INTO v_cluster_id FROM clusters ORDER BY random() LIMIT 1; + -- 4 scheduled actions on random clusters + FOR g IN 1..4 LOOP + SELECT id INTO v_cluster_id FROM clusters WHERE status != 'Terminated' ORDER BY random() LIMIT 1; v_operation := (ARRAY['PowerOn','PowerOff'])[1 + (random()*1)::int]::ACTION_OPERATION; INSERT INTO targets (target_type, select_all) VALUES ('Cluster', false) RETURNING id INTO v_target_id; INSERT INTO target_clusters (target_id, cluster_id) VALUES (v_target_id, v_cluster_id); - INSERT INTO schedule (type, time, cron_exp, operation, target, status, enabled) - VALUES ('scheduled_action', now() + (g * interval '1 day'), NULL, v_operation, v_target_id, 'Pending', (random() > 0.5)); + INSERT INTO schedule (type, time, cron_exp, operation, target, status, enabled, requester, description) + VALUES ('scheduled_action', now() + (g * interval '1 day'), NULL, v_operation, v_target_id, 'Pending', true, 'scheduler@clusteriq', format('Scheduled %s for cluster', v_operation)); END LOOP; END $$; --- Generating cron-based actions (with targets) +-- ============================================================================ +-- Cron-based actions (with targets) +-- ============================================================================ DO $$ DECLARE v_target_id BIGINT; @@ -232,42 +472,74 @@ DECLARE v_cron TEXT; BEGIN FOR g IN 1..3 LOOP - SELECT id INTO v_cluster_id FROM clusters ORDER BY random() LIMIT 1; + SELECT id INTO v_cluster_id FROM clusters WHERE status != 'Terminated' ORDER BY random() LIMIT 1; v_operation := (ARRAY['PowerOn','PowerOff'])[1 + (random()*1)::int]::ACTION_OPERATION; - v_cron := (ARRAY['0 6 * * *', '0 0 * * 0', '*/30 * * * *'])[g]; + v_cron := (ARRAY['0 8 * * 1-5', '0 20 * * 1-5', '0 6 * * *'])[g]; INSERT INTO targets (target_type, select_all) VALUES ('Cluster', false) RETURNING id INTO v_target_id; INSERT INTO target_clusters (target_id, cluster_id) VALUES (v_target_id, v_cluster_id); - INSERT INTO schedule (type, time, cron_exp, operation, target, status, enabled) - VALUES ('cron_action', NULL, v_cron, v_operation, v_target_id, 'Pending', (random() > 0.5)); + INSERT INTO schedule (type, time, cron_exp, operation, target, status, enabled, requester, description) + VALUES ('cron_action', NULL, v_cron, v_operation, v_target_id, 'Pending', true, 'scheduler@clusteriq', format('Recurring %s (%s)', v_operation, v_cron)); END LOOP; END $$; --- Generating events -INSERT INTO events ( - event_timestamp, requester, action, resource_id, resource_type, result, description, severity -) -SELECT - now() - (random() * interval '10 days') AS event_timestamp, - (ARRAY['scanner','agent','api','scheduler','user'])[1 + floor(random()*5)], - (ARRAY['scan','PowerOn','PowerOff','RestartCluster','Terminate'])[1 + floor(random()*5)], - (1 + floor(random()*20))::int AS resource_id, - CASE WHEN random() < 0.6 THEN 'Instance'::RESOURCE_TYPE ELSE 'Cluster'::RESOURCE_TYPE END AS resource_type, - (ARRAY['Pending','Running','Failed','Success','Unknown'])[1 + floor(random()*5)]::ACTION_STATUS AS result, - 'auto-generated dev event' AS description, - (ARRAY['info','warning','error','notice'])[1 + floor(random()*4)] AS severity -FROM generate_series(1,15); - +-- ============================================================================ +-- Events: realistic audit trail +-- ============================================================================ +DO $$ +DECLARE + v_cluster RECORD; + v_account RECORD; +BEGIN + -- Scan events (one per account, recent) + FOR v_account IN SELECT id, account_name FROM accounts LOOP + INSERT INTO events (event_timestamp, requester, action, resource_id, resource_type, result, description, severity) + VALUES (now() - (random() * interval '2 hours'), 'scanner@clusteriq', 'Scan', v_account.id, 'Account', 'Success', format('Inventory scan completed for %s', v_account.account_name), 'info'); + END LOOP; -COMMIT; + -- PowerOn/PowerOff events on clusters + FOR v_cluster IN SELECT id, cluster_name, status FROM clusters WHERE status != 'Terminated' ORDER BY random() LIMIT 8 LOOP + INSERT INTO events (event_timestamp, requester, action, resource_id, resource_type, result, description, severity) + VALUES ( + now() - (random() * interval '5 days'), + (ARRAY['jsmith@redhat.com', 'agarcia@redhat.com', 'scheduler@clusteriq', 'agent@clusteriq'])[1 + floor(random()*4)::INT], + CASE WHEN v_cluster.status = 'Running' THEN 'PowerOn' ELSE 'PowerOff' END, + v_cluster.id, + 'Cluster', + 'Success', + format('%s cluster %s', CASE WHEN v_cluster.status = 'Running' THEN 'Started' ELSE 'Stopped' END, v_cluster.cluster_name), + 'info' + ); + END LOOP; --- Checks (opcionales) --- SELECT provider, COUNT(*) clusters FROM clusters JOIN accounts a ON a.id = clusters.account_id GROUP BY provider; --- SELECT COUNT(*) AS total_instances FROM instances; --- SELECT COUNT(*) AS total_expenses FROM expenses; + -- A few failed events + INSERT INTO events (event_timestamp, requester, action, resource_id, resource_type, result, description, severity) + SELECT + now() - (random() * interval '7 days'), + 'agent@clusteriq', + 'PowerOff', + (SELECT id FROM clusters WHERE status = 'Running' ORDER BY random() LIMIT 1), + 'Cluster', + 'Failed', + 'Timeout waiting for instances to stop', + 'error' + FROM generate_series(1, 2); + + -- Warning events + INSERT INTO events (event_timestamp, requester, action, resource_id, resource_type, result, description, severity) + VALUES + (now() - interval '1 day', 'scanner@clusteriq', 'Scan', (SELECT id FROM accounts ORDER BY random() LIMIT 1), 'Account', 'Success', 'Scan completed with warnings: 2 instances unreachable', 'warning'), + (now() - interval '3 days', 'scheduler@clusteriq', 'PowerOn', (SELECT id FROM clusters WHERE status = 'Stopped' ORDER BY random() LIMIT 1), 'Cluster', 'Success', 'Scheduled power-on executed', 'info'); +END +$$; --- SELECT * FROM account_cluster_count ORDER BY account_id; --- SELECT * FROM account_costs ORDER BY id; --- SELECT * FROM account_full_view ORDER BY id; +-- ============================================================================ +-- Refresh materialized views +-- ============================================================================ +REFRESH MATERIALIZED VIEW m_accounts_full_view; +REFRESH MATERIALIZED VIEW m_clusters_full_view; +REFRESH MATERIALIZED VIEW m_instances_full_view; +REFRESH MATERIALIZED VIEW m_instances_full_view_with_tags; +COMMIT; diff --git a/deployments/compose/compose-devel.yaml b/deployments/compose/compose-devel.yaml index c8014c9a..e3b62fd2 100644 --- a/deployments/compose/compose-devel.yaml +++ b/deployments/compose/compose-devel.yaml @@ -154,7 +154,7 @@ services: ' environment: CIQ_MAX_DATA_AGE: "365" - CIQ_DB_PRELOAD_DATA: "false" + CIQ_DB_PRELOAD_DATA: "true" volumes: - ./../../db/sql/init.sql:/init.sql:ro,Z - ./../../db/sql/cron.sql:/cron.sql:ro,Z diff --git a/internal/db_client/db_client.go b/internal/db_client/db_client.go index 27edb834..fbb7bca7 100644 --- a/internal/db_client/db_client.go +++ b/internal/db_client/db_client.go @@ -92,6 +92,11 @@ func (d *DBClient) QueryRowContext(ctx context.Context, dest interface{}, query return d.db.GetContext(ctx, dest, query, args...) } +// QuerySelectContext executes a raw SQL query and scans the result rows into dest (a slice pointer). +func (d *DBClient) QuerySelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error { + return d.db.SelectContext(ctx, dest, query, args...) +} + func (d *DBClient) SelectWithContext(ctx context.Context, dest interface{}, table string, opts models.ListOptions, orderColumn string, columns ...string) error { builder := d.NewSelectBuilder(columns...).From(table) diff --git a/internal/inventory/summary.go b/internal/inventory/summary.go index 949bbf21..0fe68fed 100644 --- a/internal/inventory/summary.go +++ b/internal/inventory/summary.go @@ -4,10 +4,14 @@ import "time" // OverviewSummary represents the comprehensive overview of the system's inventory. type OverviewSummary struct { - Clusters ClustersSummary - Instances InstancesSummary - Providers ProvidersSummary - Scanner Scanner + Clusters ClustersSummary + Instances InstancesSummary + Providers ProvidersSummary + Scanner Scanner + TopRegions []TopItem + TopOwners []TopItem + ClustersByPartner []TopItem + CostPerAccount []AccountCost } // ClustersSummary provides a summary of cluster counts by status. @@ -37,6 +41,18 @@ type ProviderDetails struct { ClusterCount int } +// TopItem represents a ranked item with a name and cluster count. +type TopItem struct { + Name string `db:"name"` + ClusterCount int `db:"cluster_count"` +} + +// AccountCost represents an account with its current month cost. +type AccountCost struct { + AccountName string `db:"account_name"` + CurrentMonthCost float64 `db:"current_month_so_far_cost"` +} + // Scanner provides information about the last inventory scan. type Scanner struct { LastScanTimestamp time.Time diff --git a/internal/models/dto/overview_dto.go b/internal/models/dto/overview_dto.go index 1f3dacb0..288069f5 100644 --- a/internal/models/dto/overview_dto.go +++ b/internal/models/dto/overview_dto.go @@ -6,12 +6,28 @@ import ( "github.com/RHEcosystemAppEng/cluster-iq/internal/inventory" ) +// TopItem represents a ranked item with a name and cluster count. +type TopItem struct { + Name string `json:"name"` + ClusterCount int `json:"clusterCount"` +} // @name TopItem + +// AccountCost represents an account with its current month cost. +type AccountCost struct { + AccountName string `json:"accountName"` + CurrentMonthCost float64 `json:"currentMonthCost"` +} // @name AccountCost + // OverviewSummary represents the comprehensive overview of the system's inventory. type OverviewSummary struct { - Clusters ClusterSummary `json:"clusters"` - Instances InstancesSummary `json:"instances"` - Providers ProvidersSummary `json:"providers"` - Scanner Scanner `json:"scanner"` + Clusters ClusterSummary `json:"clusters"` + Instances InstancesSummary `json:"instances"` + Providers ProvidersSummary `json:"providers"` + Scanner Scanner `json:"scanner"` + TopRegions []TopItem `json:"topRegions"` + TopOwners []TopItem `json:"topOwners"` + ClustersByPartner []TopItem `json:"clustersByPartner"` + CostPerAccount []AccountCost `json:"costPerAccount"` } // @name OverviewSummary // ClusterSummary provides a summary of cluster counts by status. @@ -49,10 +65,14 @@ type Scanner struct { // ToOverviewSummaryDTO converts an inventory OverviewSummary to a DTO. func ToOverviewSummaryDTO(model inventory.OverviewSummary) OverviewSummary { return OverviewSummary{ - Clusters: toClusterSummaryDTO(model.Clusters), - Instances: toInstancesSummaryDTO(model.Instances), - Providers: toProvidersSummaryDTO(model.Providers), - Scanner: toScannerDTO(model.Scanner), + Clusters: toClusterSummaryDTO(model.Clusters), + Instances: toInstancesSummaryDTO(model.Instances), + Providers: toProvidersSummaryDTO(model.Providers), + Scanner: toScannerDTO(model.Scanner), + TopRegions: toTopItemsDTO(model.TopRegions), + TopOwners: toTopItemsDTO(model.TopOwners), + ClustersByPartner: toTopItemsDTO(model.ClustersByPartner), + CostPerAccount: toAccountCostsDTO(model.CostPerAccount), } } @@ -92,3 +112,25 @@ func toScannerDTO(model inventory.Scanner) Scanner { LastScanTimestamp: model.LastScanTimestamp, } } + +func toTopItemsDTO(items []inventory.TopItem) []TopItem { + result := make([]TopItem, len(items)) + for i, item := range items { + result[i] = TopItem{ + Name: item.Name, + ClusterCount: item.ClusterCount, + } + } + return result +} + +func toAccountCostsDTO(costs []inventory.AccountCost) []AccountCost { + result := make([]AccountCost, len(costs)) + for i, cost := range costs { + result[i] = AccountCost{ + AccountName: cost.AccountName, + CurrentMonthCost: cost.CurrentMonthCost, + } + } + return result +} diff --git a/internal/models/dto/overview_dto_test.go b/internal/models/dto/overview_dto_test.go index c1d9def1..b37d6944 100644 --- a/internal/models/dto/overview_dto_test.go +++ b/internal/models/dto/overview_dto_test.go @@ -35,6 +35,10 @@ func testToOverviewSummaryDTO_Correct(t *testing.T) { Scanner: inventory.Scanner{ LastScanTimestamp: now, }, + TopRegions: []inventory.TopItem{{Name: "us-east-1", ClusterCount: 8}}, + TopOwners: []inventory.TopItem{{Name: "jsmith", ClusterCount: 5}}, + ClustersByPartner: []inventory.TopItem{{Name: "Acme", ClusterCount: 3}}, + CostPerAccount: []inventory.AccountCost{{AccountName: "my-account", CurrentMonthCost: 1234.56}}, } dto := ToOverviewSummaryDTO(model) @@ -54,8 +58,21 @@ func testToOverviewSummaryDTO_Correct(t *testing.T) { assert.Equal(t, 5, dto.Providers.Azure.AccountCount) assert.Equal(t, 6, dto.Providers.Azure.ClusterCount) - // inventory.Scanner uses *time.Time, DTO uses time.Time assert.Equal(t, now, dto.Scanner.LastScanTimestamp) + + assert.Len(t, dto.TopRegions, 1) + assert.Equal(t, "us-east-1", dto.TopRegions[0].Name) + assert.Equal(t, 8, dto.TopRegions[0].ClusterCount) + + assert.Len(t, dto.TopOwners, 1) + assert.Equal(t, "jsmith", dto.TopOwners[0].Name) + + assert.Len(t, dto.ClustersByPartner, 1) + assert.Equal(t, "Acme", dto.ClustersByPartner[0].Name) + + assert.Len(t, dto.CostPerAccount, 1) + assert.Equal(t, "my-account", dto.CostPerAccount[0].AccountName) + assert.InDelta(t, 1234.56, dto.CostPerAccount[0].CurrentMonthCost, 0.01) } // TestToClusterSummaryDTO verifies toClusterSummaryDTO conversion. @@ -133,3 +150,47 @@ func testToScannerDTO_Correct(t *testing.T) { assert.Equal(t, now, dto.LastScanTimestamp) } + +// TestToTopItemsDTO verifies toTopItemsDTO conversion. +func TestToTopItemsDTO(t *testing.T) { + t.Run("Convert TopItems", func(t *testing.T) { + items := []inventory.TopItem{ + {Name: "us-east-1", ClusterCount: 8}, + {Name: "eu-west-1", ClusterCount: 3}, + } + result := toTopItemsDTO(items) + + assert.Len(t, result, 2) + assert.Equal(t, "us-east-1", result[0].Name) + assert.Equal(t, 8, result[0].ClusterCount) + assert.Equal(t, "eu-west-1", result[1].Name) + assert.Equal(t, 3, result[1].ClusterCount) + }) + + t.Run("Convert empty TopItems", func(t *testing.T) { + result := toTopItemsDTO([]inventory.TopItem{}) + assert.Len(t, result, 0) + }) +} + +// TestToAccountCostsDTO verifies toAccountCostsDTO conversion. +func TestToAccountCostsDTO(t *testing.T) { + t.Run("Convert AccountCosts", func(t *testing.T) { + costs := []inventory.AccountCost{ + {AccountName: "prod", CurrentMonthCost: 5000.50}, + {AccountName: "dev", CurrentMonthCost: 1200.00}, + } + result := toAccountCostsDTO(costs) + + assert.Len(t, result, 2) + assert.Equal(t, "prod", result[0].AccountName) + assert.InDelta(t, 5000.50, result[0].CurrentMonthCost, 0.01) + assert.Equal(t, "dev", result[1].AccountName) + assert.InDelta(t, 1200.00, result[1].CurrentMonthCost, 0.01) + }) + + t.Run("Convert empty AccountCosts", func(t *testing.T) { + result := toAccountCostsDTO([]inventory.AccountCost{}) + assert.Len(t, result, 0) + }) +} diff --git a/internal/repositories/account_repository.go b/internal/repositories/account_repository.go index 73384458..f832ecbb 100644 --- a/internal/repositories/account_repository.go +++ b/internal/repositories/account_repository.go @@ -52,6 +52,7 @@ type AccountRepository interface { GetAccountClustersByID(ctx context.Context, accountID string) ([]db.ClusterDBResponse, error) GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstancePendingExpenseDB, error) GetScannerTimestamp(ctx context.Context) (time.Time, error) + GetCostPerAccount(ctx context.Context) ([]inventory.AccountCost, error) CreateAccount(ctx context.Context, accounts []inventory.Account) error UpdateAccount(ctx context.Context, accountID string, patch dto.AccountPatchRequest) error DeleteAccount(ctx context.Context, accountID string) error @@ -181,6 +182,18 @@ func (r *accountRepositoryImpl) GetExpenseUpdateInstances(ctx context.Context, a return instances, nil } +// GetCostPerAccount returns all accounts with their current month cost. +func (r *accountRepositoryImpl) GetCostPerAccount(ctx context.Context) ([]inventory.AccountCost, error) { + var costs []inventory.AccountCost + query := `SELECT account_name, current_month_so_far_cost + FROM m_accounts_full_view + ORDER BY current_month_so_far_cost DESC` + if err := r.db.QuerySelectContext(ctx, &costs, query); err != nil { + return nil, fmt.Errorf("failed to get cost per account: %w", err) + } + return costs, nil +} + // Create inserts multiple accounts into the database in a transaction. // // Parameters: diff --git a/internal/repositories/cluster_repository.go b/internal/repositories/cluster_repository.go index e2fd1cb6..81a38f93 100644 --- a/internal/repositories/cluster_repository.go +++ b/internal/repositories/cluster_repository.go @@ -74,6 +74,9 @@ type ClusterRepository interface { GetClustersOnAccount(ctx context.Context, accountName string) ([]db.ClusterDBResponse, error) GetInstancesOnCluster(ctx context.Context, clusterID string) ([]db.InstanceDBResponse, error) GetClustersOverview(ctx context.Context) (inventory.ClustersSummary, error) + GetTopRegions(ctx context.Context, limit int) ([]inventory.TopItem, error) + GetTopOwners(ctx context.Context, limit int) ([]inventory.TopItem, error) + GetClustersByPartner(ctx context.Context) ([]inventory.TopItem, error) CreateClusters(ctx context.Context, clusters []inventory.Cluster) error UpdateCluster(ctx context.Context, clusterID string, patch dto.ClusterPatchRequest) error UpdateClusterStatusByClusterID(ctx context.Context, status string, clusterID string) error @@ -281,6 +284,52 @@ func (r *clusterRepositoryImpl) GetClustersOverview(ctx context.Context) (invent return countsDB, nil } +// GetTopRegions returns the top N regions by cluster count, excluding terminated clusters. +func (r *clusterRepositoryImpl) GetTopRegions(ctx context.Context, limit int) ([]inventory.TopItem, error) { + var items []inventory.TopItem + query := `SELECT region AS name, COUNT(*) AS cluster_count + FROM clusters + WHERE status != 'Terminated' + GROUP BY region + ORDER BY cluster_count DESC + LIMIT $1` + if err := r.db.QuerySelectContext(ctx, &items, query, limit); err != nil { + return nil, fmt.Errorf("failed to get top regions: %w", err) + } + return items, nil +} + +// GetTopOwners returns the top N owners by cluster count, excluding terminated clusters. +func (r *clusterRepositoryImpl) GetTopOwners(ctx context.Context, limit int) ([]inventory.TopItem, error) { + var items []inventory.TopItem + query := `SELECT owner AS name, COUNT(*) AS cluster_count + FROM clusters + WHERE status != 'Terminated' AND owner != '' + GROUP BY owner + ORDER BY cluster_count DESC + LIMIT $1` + if err := r.db.QuerySelectContext(ctx, &items, query, limit); err != nil { + return nil, fmt.Errorf("failed to get top owners: %w", err) + } + return items, nil +} + +// GetClustersByPartner returns cluster counts grouped by the Partner tag. +func (r *clusterRepositoryImpl) GetClustersByPartner(ctx context.Context) ([]inventory.TopItem, error) { + var items []inventory.TopItem + query := `SELECT t.value AS name, COUNT(DISTINCT c.cluster_id) AS cluster_count + FROM tags t + JOIN instances i ON t.instance_id = i.id + JOIN clusters c ON i.cluster_id = c.id + WHERE t.key = 'Partner' AND c.status != 'Terminated' + GROUP BY t.value + ORDER BY cluster_count DESC` + if err := r.db.QuerySelectContext(ctx, &items, query); err != nil { + return nil, fmt.Errorf("failed to get clusters by partner: %w", err) + } + return items, nil +} + // CreateClusters inserts a list of clusters into the database in a transaction. // // Parameters: diff --git a/internal/services/overview_service.go b/internal/services/overview_service.go index 14e39bda..6292ee2d 100644 --- a/internal/services/overview_service.go +++ b/internal/services/overview_service.go @@ -9,6 +9,8 @@ import ( "github.com/RHEcosystemAppEng/cluster-iq/internal/repositories" ) +const defaultTopLimit = 5 + // OverviewService defines the interface for overview-related business logic. type OverviewService interface { GetOverview(ctx context.Context) (inventory.OverviewSummary, error) @@ -60,6 +62,30 @@ func (s *overviewServiceImpl) GetOverview(ctx context.Context) (inventory.Overvi } overview.Scanner.LastScanTimestamp = scannerTimestamp + topRegions, err := s.clusterRepo.GetTopRegions(ctx, defaultTopLimit) + if err != nil { + return inventory.OverviewSummary{}, fmt.Errorf("failed to get top regions: %w", err) + } + overview.TopRegions = topRegions + + topOwners, err := s.clusterRepo.GetTopOwners(ctx, defaultTopLimit) + if err != nil { + return inventory.OverviewSummary{}, fmt.Errorf("failed to get top owners: %w", err) + } + overview.TopOwners = topOwners + + clustersByPartner, err := s.clusterRepo.GetClustersByPartner(ctx) + if err != nil { + return inventory.OverviewSummary{}, fmt.Errorf("failed to get clusters by partner: %w", err) + } + overview.ClustersByPartner = clustersByPartner + + costPerAccount, err := s.accountRepo.GetCostPerAccount(ctx) + if err != nil { + return inventory.OverviewSummary{}, fmt.Errorf("failed to get cost per account: %w", err) + } + overview.CostPerAccount = costPerAccount + return overview, nil } diff --git a/test/integration/api_overview_integration_test.go b/test/integration/api_overview_integration_test.go index 3ab7390a..2fcc61a3 100644 --- a/test/integration/api_overview_integration_test.go +++ b/test/integration/api_overview_integration_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "reflect" + "slices" "testing" "time" @@ -55,26 +56,45 @@ func testGetOverview(t *testing.T) { Scanner: dto.Scanner{ LastScanTimestamp: lastScanTS, }, + TopRegions: []dto.TopItem{}, + TopOwners: []dto.TopItem{}, + ClustersByPartner: []dto.TopItem{}, + CostPerAccount: []dto.AccountCost{ + {AccountName: "aws-account-demo", CurrentMonthCost: 0}, + {AccountName: "azure-sub-demo", CurrentMonthCost: 0}, + {AccountName: "gcp-project-demo", CurrentMonthCost: 0}, + }, } - // Getting accounts data resp, err := http.Get(APIOverviewURL) if err != nil { t.Fatalf("Failed to make request: %v", err) } defer resp.Body.Close() - // Check response code checkHTTPResponseCode(t, resp, expectedHTTPCode) - // Decode the JSON response var response dto.OverviewSummary if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { t.Fatalf("Failed to decode response body: %v", err) } - // Comparing data + sortAccountCosts(response.CostPerAccount) + sortAccountCosts(expectedOverviewResponse.CostPerAccount) + if !reflect.DeepEqual(response, expectedOverviewResponse) { t.Fatalf("Expected Overview: '%+v', got: '%+v'", expectedOverviewResponse, response) } } + +func sortAccountCosts(costs []dto.AccountCost) { + slices.SortFunc(costs, func(a, b dto.AccountCost) int { + if a.AccountName < b.AccountName { + return -1 + } + if a.AccountName > b.AccountName { + return 1 + } + return 0 + }) +}