diff --git a/package-lock.json b/package-lock.json index a02a7fba..269204df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.28.6", + "@carbon/react": "^1.100.0", "@date-io/core": "^3.2.0", "@date-io/date-fns": "^3.2.1", "@emotion/react": "^11.14.0", @@ -27,13 +28,15 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "react-router": "^7.12.0", - "recharts": "^3.6.0" + "recharts": "^3.6.0", + "sass": "^1.97.3" }, "devDependencies": { "@babel/core": "^7.28.6", "@babel/plugin-proposal-class-properties": "^7.18.6", "@babel/plugin-transform-runtime": "^7.28.5", "@babel/preset-react": "^7.28.5", + "@parcel/transformer-sass": "^2.16.3", "buffer": "^6.0.3", "parcel": "^2.16.3", "prettier": "^3.8.0", @@ -546,6 +549,185 @@ "node": ">=6.9.0" } }, + "node_modules/@carbon/colors": { + "version": "11.46.0", + "resolved": "https://registry.npmjs.org/@carbon/colors/-/colors-11.46.0.tgz", + "integrity": "sha512-YL4BH2hxHkUT0+wMn8cO3sYN7rb9Nnp7rGttoblM0iTy83n/urwRPcxudifRwJLtASQpravCyLHdIC9WnTtIAA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@ibm/telemetry-js": "^1.5.0" + } + }, + "node_modules/@carbon/feature-flags": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@carbon/feature-flags/-/feature-flags-0.32.0.tgz", + "integrity": "sha512-a1rFplSEFPwJ4ZsuwvOaKHgoLqPNhjCJdWY6VTgCoytRZqtgYWqwYFEqQkm9/f1mX1lHr6oK/eBpAcmi0Izuvg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@ibm/telemetry-js": "^1.5.0" + } + }, + "node_modules/@carbon/grid": { + "version": "11.49.0", + "resolved": "https://registry.npmjs.org/@carbon/grid/-/grid-11.49.0.tgz", + "integrity": "sha512-zZfj/sbwJpXboduVFNUXUdV6LmsEH39fNQQMye4V+788sdvs+ErO8L3onBZFpsek5gI4ebwjpWJu2g5szu2+kQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@carbon/layout": "^11.47.0", + "@ibm/telemetry-js": "^1.5.0" + } + }, + "node_modules/@carbon/icon-helpers": { + "version": "10.71.0", + "resolved": "https://registry.npmjs.org/@carbon/icon-helpers/-/icon-helpers-10.71.0.tgz", + "integrity": "sha512-T6KcxkNIa609jPC+8A7u5husSY+mH60lCNNa3ivcOyuREoVYHwnieM7GIECigF/oaGaF5eBzrxYFx2+8mLRk1A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@ibm/telemetry-js": "^1.5.0" + } + }, + "node_modules/@carbon/icons-react": { + "version": "11.74.0", + "resolved": "https://registry.npmjs.org/@carbon/icons-react/-/icons-react-11.74.0.tgz", + "integrity": "sha512-tP/ZwM3e86zDm/8mup1NoObdaBl2xqZlroWP/Z1PQ9bCYOOFelR6r34aObWiDBJVpKb5YwwZWYUrl+/98fmDRQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@carbon/icon-helpers": "^10.71.0", + "@ibm/telemetry-js": "^1.5.0", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@carbon/layout": { + "version": "11.47.0", + "resolved": "https://registry.npmjs.org/@carbon/layout/-/layout-11.47.0.tgz", + "integrity": "sha512-2XR4TVp3uf2IB0WdoZuDcBbc9C8EN/JvZAw9BdHJ3njng8FlUAQUkTFvfoUsJl10868rqA6YeClCElBS4BHofg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@ibm/telemetry-js": "^1.5.0" + } + }, + "node_modules/@carbon/motion": { + "version": "11.40.0", + "resolved": "https://registry.npmjs.org/@carbon/motion/-/motion-11.40.0.tgz", + "integrity": "sha512-QjvjMcC3G289GKYDvrf5dDuyol7SXm0TYaFltx+AkJdU6fptDCJ/qjUL5SdVrsLse3jFuI8rada9tRAL5xHS1g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@ibm/telemetry-js": "^1.5.0" + } + }, + "node_modules/@carbon/react": { + "version": "1.100.0", + "resolved": "https://registry.npmjs.org/@carbon/react/-/react-1.100.0.tgz", + "integrity": "sha512-QlJ/bqiQn3fF3EX1YfVN3+zYvbqiGuMULtsI+wtuMaoEOJcR9FBwUYSP4w7lXTCxQ7WkB/wTLrekVcGgRoNquQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.27.3", + "@carbon/feature-flags": ">=0.32.0", + "@carbon/icons-react": "^11.74.0", + "@carbon/layout": "^11.47.0", + "@carbon/styles": "^1.99.0", + "@carbon/utilities": "^0.15.0", + "@floating-ui/react": "^0.27.4", + "@ibm/telemetry-js": "^1.5.0", + "classnames": "2.5.1", + "copy-to-clipboard": "^3.3.1", + "downshift": "9.0.10", + "es-toolkit": "^1.27.0", + "flatpickr": "4.6.13", + "invariant": "^2.2.3", + "prop-types": "^15.8.1", + "react-fast-compare": "^3.2.2", + "tabbable": "^6.2.0" + }, + "peerDependencies": { + "react": "^16.8.6 || ^17.0.1 || ^18.2.0 || ^19.0.0", + "react-dom": "^16.8.6 || ^17.0.1 || ^18.2.0 || ^19.0.0", + "react-is": "^16.13.1 || ^17.0.2 || ^18.3.1 || ^19.0.0", + "sass": "^1.33.0" + } + }, + "node_modules/@carbon/styles": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/@carbon/styles/-/styles-1.99.0.tgz", + "integrity": "sha512-71iypyzR97h6Z94XRZyel3IEo4+n9TRylKdsYUJASNs7GNIjsIBlwKRn+upUktsyWVNTV1iQ9uzo3UkFcRiEFQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@carbon/colors": "^11.46.0", + "@carbon/feature-flags": ">=0.32.0", + "@carbon/grid": "^11.49.0", + "@carbon/layout": "^11.47.0", + "@carbon/motion": "^11.40.0", + "@carbon/themes": "^11.67.0", + "@carbon/type": "^11.53.0", + "@ibm/plex": "6.0.0-next.6", + "@ibm/plex-mono": "1.1.0", + "@ibm/plex-sans": "1.1.0", + "@ibm/plex-sans-arabic": "1.1.0", + "@ibm/plex-sans-devanagari": "1.1.0", + "@ibm/plex-sans-hebrew": "1.1.0", + "@ibm/plex-sans-thai": "1.1.0", + "@ibm/plex-sans-thai-looped": "1.1.0", + "@ibm/plex-serif": "1.1.0", + "@ibm/telemetry-js": "^1.5.0" + }, + "peerDependencies": { + "sass": "^1.33.0" + }, + "peerDependenciesMeta": { + "sass": { + "optional": true + } + } + }, + "node_modules/@carbon/themes": { + "version": "11.67.0", + "resolved": "https://registry.npmjs.org/@carbon/themes/-/themes-11.67.0.tgz", + "integrity": "sha512-sCjmwxvM7nUdsDPef9g2v07Motvd4EYZJJqJyklMfhm9ZJ1oUfwecpW8rLzXylDsOBhrX9s1oCKWG/JqZF3kig==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@carbon/colors": "^11.46.0", + "@carbon/layout": "^11.47.0", + "@carbon/type": "^11.53.0", + "@ibm/telemetry-js": "^1.5.0", + "color": "^4.0.0" + } + }, + "node_modules/@carbon/type": { + "version": "11.53.0", + "resolved": "https://registry.npmjs.org/@carbon/type/-/type-11.53.0.tgz", + "integrity": "sha512-x3GeJrkvM8wdpBwYbRr6jUsmR2wSRVbIxmPl7kamSFih32+czp7xpt/frG02EAY5xgaEk3N9YCNYspwco42raA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@carbon/grid": "^11.49.0", + "@carbon/layout": "^11.47.0", + "@ibm/telemetry-js": "^1.5.0" + } + }, + "node_modules/@carbon/utilities": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@carbon/utilities/-/utilities-0.15.0.tgz", + "integrity": "sha512-bwneNtLk8khoSIsilr6fBl115BMBrCMqxo/LjwAYiA+GiHes4URC4QYUihg+Ida5bCDpMixDx3RI9IW1UodXLQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@ibm/telemetry-js": "^1.6.1", + "@internationalized/number": "^3.6.1" + } + }, "node_modules/@date-io/core": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@date-io/core/-/core-3.2.0.tgz", @@ -721,6 +903,166 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.17", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.17.tgz", + "integrity": "sha512-LGVZKHwmWGg6MRHjLLgsfyaX2y2aCNgnD1zT/E6B+/h+vxg+nIJUqHPAlTzsHDyqdgEpJ1Np5kxWuFEErXzoGg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.7", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@ibm/plex": { + "version": "6.0.0-next.6", + "resolved": "https://registry.npmjs.org/@ibm/plex/-/plex-6.0.0-next.6.tgz", + "integrity": "sha512-B3uGruTn2rS5gweynLmfSe7yCawSRsJguJJQHVQiqf4rh2RNgJFu8YLE2Zd/JHV0ZXoVMOslcXP2k3hMkxKEyA==", + "license": "OFL-1.1", + "engines": { + "node": ">=14" + } + }, + "node_modules/@ibm/plex-mono": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ibm/plex-mono/-/plex-mono-1.1.0.tgz", + "integrity": "sha512-hpsdRxR3BRJkC6wGM4MZcUFD6C8M+mmK76RtAy/hlsfPro9FzpXVdIWC+G3jeQOXof109dxlUvmeKxpeKUG68A==", + "hasInstallScript": true, + "license": "OFL-1.1", + "dependencies": { + "@ibm/telemetry-js": "^1.6.1" + } + }, + "node_modules/@ibm/plex-sans": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ibm/plex-sans/-/plex-sans-1.1.0.tgz", + "integrity": "sha512-WPgvO6Yfj2w5YbhyAr1tv95RUz4LRJlqN+CmYvBglabXteufP1D1E9BABMde+ZIKdRbFJDoKF5eQzfhpnbgZcQ==", + "hasInstallScript": true, + "license": "OFL-1.1", + "dependencies": { + "@ibm/telemetry-js": "^1.6.1" + } + }, + "node_modules/@ibm/plex-sans-arabic": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ibm/plex-sans-arabic/-/plex-sans-arabic-1.1.0.tgz", + "integrity": "sha512-u8wIS6szLAOFvlBjCFZmtpKIqbhuIuniG2N0J+sio8vV6INH58hP0t0QNYrSl9SZtCv2Fwb4oQGuZJY3kJ4+QA==", + "hasInstallScript": true, + "license": "OFL-1.1", + "dependencies": { + "@ibm/telemetry-js": "^1.6.1" + } + }, + "node_modules/@ibm/plex-sans-devanagari": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ibm/plex-sans-devanagari/-/plex-sans-devanagari-1.1.0.tgz", + "integrity": "sha512-IVNV9NxXZDzcGZRao/xj+kiFwkdLkcw5vNiKwY8wEzzkpjApXJnPhJ0a7mIKNAh8oIadTIF68+iGtzRKK3nXAQ==", + "hasInstallScript": true, + "license": "OFL-1.1", + "dependencies": { + "@ibm/telemetry-js": "^1.6.1" + } + }, + "node_modules/@ibm/plex-sans-hebrew": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ibm/plex-sans-hebrew/-/plex-sans-hebrew-1.1.0.tgz", + "integrity": "sha512-iix0rLpUD0E8dE8q+/t3B7u1or7h6gEzoy6TK9NwP41AN31WE55f2cFwQAXomBDwr0Ozc9sHYy97NutEukZXzQ==", + "hasInstallScript": true, + "license": "OFL-1.1", + "dependencies": { + "@ibm/telemetry-js": "^1.6.1" + } + }, + "node_modules/@ibm/plex-sans-thai": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ibm/plex-sans-thai/-/plex-sans-thai-1.1.0.tgz", + "integrity": "sha512-vk7IrjdO69eEElJpFBppCha/wvU48DFyVuDewcfIf5L6Z11s0vbROANCvKipVPRUz1LE4ron8KoitWGcl3AlfA==", + "hasInstallScript": true, + "license": "OFL-1.1", + "dependencies": { + "@ibm/telemetry-js": "^1.6.1" + } + }, + "node_modules/@ibm/plex-sans-thai-looped": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ibm/plex-sans-thai-looped/-/plex-sans-thai-looped-1.1.0.tgz", + "integrity": "sha512-9zbDGzmtscHgBRTF88y3/92zQx6lmKjz5ZxhgcljilwOpj08BAytDc3mzUl9XGUh+DmOMl0Ql1lk6ecsEYYg2w==", + "hasInstallScript": true, + "license": "OFL-1.1", + "dependencies": { + "@ibm/telemetry-js": "^1.6.1" + } + }, + "node_modules/@ibm/plex-serif": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ibm/plex-serif/-/plex-serif-1.1.0.tgz", + "integrity": "sha512-ORIyWlK8t8mvpFI7AAfhVFH+sacink2l9AjLiKZscmAzLVSa2dqFckrPOXqx4dK/cax567gWwCpJNEYk7xWxBQ==", + "hasInstallScript": true, + "license": "OFL-1.1", + "dependencies": { + "@ibm/telemetry-js": "^1.6.1" + } + }, + "node_modules/@ibm/telemetry-js": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@ibm/telemetry-js/-/telemetry-js-1.11.0.tgz", + "integrity": "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA==", + "license": "Apache-2.0", + "bin": { + "ibmtelemetry": "dist/collect.js" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz", + "integrity": "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2637,6 +2979,26 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@parcel/transformer-sass": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/@parcel/transformer-sass/-/transformer-sass-2.16.3.tgz", + "integrity": "sha512-qcCUCHPiSVjh5LlZfkGBU8Q0+At9Ry+NFYJP13BpKxxgGSNdfQy4A8qLHlyeJJdj+7N35IAkSWDhFu8GOd2KNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/plugin": "2.16.3", + "@parcel/source-map": "^2.1.1", + "sass": "^1.88.0" + }, + "engines": { + "node": ">= 16.0.0", + "parcel": "^2.16.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@parcel/transformer-svg": { "version": "2.16.3", "resolved": "https://registry.npmjs.org/@parcel/transformer-svg/-/transformer-svg-2.16.3.tgz", @@ -2709,7 +3071,7 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3307,7 +3669,6 @@ "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -3558,7 +3919,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3686,6 +4047,21 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -3696,6 +4072,12 @@ "node": ">=6.0" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", @@ -3715,11 +4097,23 @@ "node": ">=6" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3732,9 +4126,18 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3757,6 +4160,12 @@ "node": ">=18" } }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3773,6 +4182,15 @@ "node": ">=18" } }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, "node_modules/core-js-compat": { "version": "3.46.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", @@ -3976,7 +4394,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "detect-libc": "bin/detect-libc.js" @@ -4079,6 +4497,28 @@ "url": "https://dotenvx.com" } }, + "node_modules/downshift": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/downshift/-/downshift-9.0.10.tgz", + "integrity": "sha512-TP/iqV6bBok6eGD5tZ8boM8Xt7/+DZvnVNr8cNIhbAm2oUBd79Tudiccs2hbcV9p7xAgS/ozE7Hxy3a9QqS6Mw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "compute-scroll-into-view": "^3.1.0", + "prop-types": "^15.8.1", + "react-is": "18.2.0", + "tslib": "^2.6.2" + }, + "peerDependencies": { + "react": ">=16.12.0" + } + }, + "node_modules/downshift/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4208,7 +4648,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -4223,6 +4663,12 @@ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "license": "MIT" }, + "node_modules/flatpickr": { + "version": "4.6.13", + "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz", + "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==", + "license": "MIT" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -4481,6 +4927,12 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4506,6 +4958,15 @@ "node": ">=12" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4531,7 +4992,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4541,7 +5002,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -4554,7 +5015,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -5000,7 +5461,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -5101,7 +5562,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/node-gyp-build-optional-packages": { @@ -5248,7 +5709,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -5334,6 +5795,12 @@ "react": "^19.2.3" } }, + "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": "19.2.3", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", @@ -5411,6 +5878,19 @@ "react-dom": ">=16.6.0" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/recharts": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", @@ -5519,6 +5999,26 @@ ], "license": "MIT" }, + "node_modules/sass": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5560,6 +6060,21 @@ "node": ">=11.0" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -5569,6 +6084,15 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -5600,6 +6124,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, "node_modules/term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -5623,7 +6153,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -5632,11 +6162,16 @@ "node": ">=8.0" } }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-fest": { diff --git a/package.json b/package.json index c1856034..2118363c 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ ], "dependencies": { "@babel/runtime": "^7.28.6", + "@carbon/react": "^1.100.0", "@date-io/core": "^3.2.0", "@date-io/date-fns": "^3.2.1", "@emotion/react": "^11.14.0", @@ -31,13 +32,15 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "react-router": "^7.12.0", - "recharts": "^3.6.0" + "recharts": "^3.6.0", + "sass": "^1.97.3" }, "devDependencies": { "@babel/core": "^7.28.6", "@babel/plugin-proposal-class-properties": "^7.18.6", "@babel/plugin-transform-runtime": "^7.28.5", "@babel/preset-react": "^7.28.5", + "@parcel/transformer-sass": "^2.16.3", "buffer": "^6.0.3", "parcel": "^2.16.3", "prettier": "^3.8.0", diff --git a/src/components/Nav/SidebarNav.js b/src/components/Nav/SidebarNav.js index 781184d7..0d34e16c 100644 --- a/src/components/Nav/SidebarNav.js +++ b/src/components/Nav/SidebarNav.js @@ -2,7 +2,7 @@ import * as React from "react"; import { Drawer, List } from "@mui/material"; import { NavItem } from "./NavItem"; -import { BarChart, Cloud } from "@mui/icons-material"; +import { BarChart, Cloud, Storage } from "@mui/icons-material"; const logo = new URL("../../images/logo.png", import.meta.url).href; @@ -23,6 +23,7 @@ const SidebarNav = ({ active }) => { href: "/allocation", icon: , }, + { name: "Assets", href: "/assets", icon: }, { name: "Cloud Costs", href: "/cloud", icon: }, { name: "External Costs", href: "/external-costs", icon: }, ]; diff --git a/src/components/assets/AssetDetailPanel.js b/src/components/assets/AssetDetailPanel.js new file mode 100644 index 00000000..4062771f --- /dev/null +++ b/src/components/assets/AssetDetailPanel.js @@ -0,0 +1,208 @@ +import React from "react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { toCurrency } from "../../util"; +import { formatCores, formatBytes, formatBreakdown } from "./assetUtils"; + +const AssetDetailPanel = ({ asset, onClose }) => { + if (!asset) return null; + + const isNode = asset.type === "Node"; + const isDisk = asset.type === "Disk"; + + const cpuBreakdown = isNode ? formatBreakdown(asset.cpuBreakdown) : null; + const ramBreakdown = isNode ? formatBreakdown(asset.ramBreakdown) : null; + const diskBreakdown = isDisk ? formatBreakdown(asset.breakdown) : null; + + const breakdownData = []; + if (cpuBreakdown) { + breakdownData.push({ + name: "CPU", + User: cpuBreakdown.user, + System: cpuBreakdown.system, + Other: cpuBreakdown.other, + Idle: cpuBreakdown.idle, + }); + } + if (ramBreakdown) { + breakdownData.push({ + name: "RAM", + User: ramBreakdown.user, + System: ramBreakdown.system, + Other: ramBreakdown.other, + Idle: ramBreakdown.idle, + }); + } + if (diskBreakdown) { + breakdownData.push({ + name: "Disk", + User: diskBreakdown.user, + System: diskBreakdown.system, + Other: diskBreakdown.other, + Idle: diskBreakdown.idle, + }); + } + + const rows = [ + { label: "Type", value: asset.type }, + { label: "Category", value: asset.category }, + { label: "Provider", value: asset.provider }, + { label: "Cluster", value: asset.cluster }, + { label: "Project", value: asset.project }, + { label: "Provider ID", value: asset.providerID }, + { label: "Total Cost", value: toCurrency(asset.totalCost, "USD") }, + { + label: "Active Duration", + value: asset.minutes ? `${asset.minutes} min` : undefined, + }, + ]; + + if (isNode) { + rows.push( + { label: "Node Type", value: asset.nodeType }, + { label: "CPU Cores", value: formatCores(asset.cpuCores) }, + { label: "RAM", value: formatBytes(asset.ramBytes) }, + { label: "CPU Cost", value: toCurrency(asset.cpuCost || 0, "USD") }, + { label: "RAM Cost", value: toCurrency(asset.ramCost || 0, "USD") }, + { label: "GPU Cost", value: toCurrency(asset.gpuCost || 0, "USD") }, + { + label: "Preemptible / Spot", + value: asset.preemptible ? "Yes" : "No", + }, + { + label: "Discount", + value: + asset.discount !== undefined + ? `${(asset.discount * 100).toFixed(0)}%` + : undefined, + }, + ); + } + + if (isDisk) { + rows.push( + { label: "Storage Size", value: formatBytes(asset.bytes) }, + { label: "Storage Class", value: asset.storageClass }, + { label: "Volume Name", value: asset.volumeName }, + { label: "Claim Name", value: asset.claimName }, + { label: "Claim Namespace", value: asset.claimNamespace }, + ); + } + + const hasLabels = asset.labels && Object.keys(asset.labels).length > 0; + + return ( +
+
e.stopPropagation()}> +
+
+
+ {asset.type} Details +
+
{asset.name}
+
+ +
+
+ + + {rows.map( + (row, i) => + row.value !== undefined && + row.value !== "" && ( + + + + + ), + )} + +
{row.label}{row.value}
+ + {breakdownData.length > 0 && ( +
+
+ Resource Utilization +
+ + + + + + `${value}%`} /> + + + + + + + +
+ )} + + {hasLabels && ( +
+
Labels
+
+ {Object.entries(asset.labels).map(([k, v]) => ( + + {k}: {v} + + ))} +
+
+ )} +
+
+
+ ); +}; + +export default React.memo(AssetDetailPanel); diff --git a/src/components/assets/AssetsChart.js b/src/components/assets/AssetsChart.js new file mode 100644 index 00000000..451d9b7d --- /dev/null +++ b/src/components/assets/AssetsChart.js @@ -0,0 +1,191 @@ +import React, { useMemo, useState } from "react"; +import { + PieChart, + Pie, + Cell, + Sector, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { toCurrency } from "../../util"; +import { groupAssetsBy, getAssetColor } from "./assetUtils"; +import { assetTypeConfig } from "./tokens"; + +const PROVIDER_COLORS = [ + "#0f62fe", + "#198038", + "#8a3ffc", + "#009d9a", + "#b28600", + "#da1e28", + "#6929c4", + "#002d9c", +]; + +const BarTooltip = ({ active, payload }) => { + if (!active || !payload || !payload.length) return null; + const entry = payload[0]; + return ( +
+
+ {entry.name || entry.payload.name} +
+
+ {toCurrency(entry.value, "USD")} +
+
+ ); +}; + +/* Active sector shape — slightly expanded ring on hover */ +const ActiveShape = (props) => { + const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } = props; + return ( + + ); +}; + +/* Custom legend rendered below the donut */ +const PieLegend = ({ pieData, total }) => ( +
+ {pieData.map((d) => ( +
+ + {d.name} + + {toCurrency(d.value, "USD")} + + + {total > 0 ? `${((d.value / total) * 100).toFixed(1)}%` : "0%"} + +
+ ))} +
+); + +const AssetsChart = ({ assets }) => { + const [activeIndex, setActiveIndex] = useState(-1); + const typeData = useMemo(() => groupAssetsBy(assets, "type"), [assets]); + const providerData = useMemo( + () => groupAssetsBy(assets, "provider"), + [assets], + ); + + if (assets.length === 0) return null; + + const pieData = typeData.map((d) => ({ + name: assetTypeConfig[d.name] ? assetTypeConfig[d.name].label : d.name, + value: d.totalCost, + fill: getAssetColor(d.name), + })); + + const pieTotal = pieData.reduce((sum, d) => sum + d.value, 0); + const hovered = activeIndex >= 0 ? pieData[activeIndex] : null; + + return ( +
+
+
Cost by Asset Type
+
+ + + = 0 ? activeIndex : undefined} + activeShape={ActiveShape} + onMouseEnter={(_, idx) => setActiveIndex(idx)} + onMouseLeave={() => setActiveIndex(-1)} + > + {pieData.map((entry, i) => ( + + ))} + + + + {/* Center label inside the donut hole */} +
+ {hovered ? ( + <> +
{hovered.name}
+
+ {toCurrency(hovered.value, "USD")} +
+ + ) : ( + <> +
Total
+
+ {toCurrency(pieTotal, "USD")} +
+ + )} +
+
+ +
+ +
+
Cost by Provider
+ + + + + toCurrency(v, "USD", 0)} + tick={{ fontSize: 12 }} + /> + } cursor={{ fill: "rgba(0,0,0,0.04)" }} /> + + {providerData.map((entry, i) => ( + + ))} + + + +
+
+ ); +}; + +export default React.memo(AssetsChart); diff --git a/src/components/assets/AssetsControls.js b/src/components/assets/AssetsControls.js new file mode 100644 index 00000000..af1ce7ba --- /dev/null +++ b/src/components/assets/AssetsControls.js @@ -0,0 +1,57 @@ +import React from "react"; +import { Dropdown } from "@carbon/react"; +import { windowOptions, assetTypeOptions, categoryOptions } from "./tokens"; + +const AssetsControls = ({ + window, + setWindow, + assetType, + setAssetType, + category, + setCategory, +}) => { + return ( +
+ (item ? item.name : "")} + selectedItem={ + windowOptions.find((o) => o.value === window) || windowOptions[6] + } + onChange={({ selectedItem }) => setWindow(selectedItem.value)} + size="md" + /> + (item ? item.name : "")} + selectedItem={ + assetTypeOptions.find((o) => o.value === assetType) || + assetTypeOptions[0] + } + onChange={({ selectedItem }) => setAssetType(selectedItem.value)} + size="md" + /> + (item ? item.name : "")} + selectedItem={ + categoryOptions.find((o) => o.value === category) || + categoryOptions[0] + } + onChange={({ selectedItem }) => setCategory(selectedItem.value)} + size="md" + /> +
+ ); +}; + +export default React.memo(AssetsControls); diff --git a/src/components/assets/AssetsSummaryTiles.js b/src/components/assets/AssetsSummaryTiles.js new file mode 100644 index 00000000..3e305087 --- /dev/null +++ b/src/components/assets/AssetsSummaryTiles.js @@ -0,0 +1,56 @@ +import React from "react"; +import { toCurrency } from "../../util"; +import { assetTypeConfig } from "./tokens"; + +const AssetsSummaryTiles = ({ + typeSummary, + grandTotal, + totalCount, + activeType, + onTypeClick, +}) => { + const types = Object.keys(assetTypeConfig); + + return ( +
+
+
+ Total Infrastructure Cost +
+
+ {toCurrency(grandTotal, "USD")} +
+
+ {totalCount} asset{totalCount !== 1 ? "s" : ""} +
+
+ +
+ {types.map((type) => { + const data = typeSummary[type]; + const config = assetTypeConfig[type]; + if (!data) return null; + const isActive = activeType === type; + return ( +
onTypeClick(isActive ? "all" : type)} + > +
{config.label}
+
+ {toCurrency(data.totalCost, "USD")} +
+
+ {data.count} asset{data.count !== 1 ? "s" : ""} +
+
+ ); + })} +
+
+ ); +}; + +export default React.memo(AssetsSummaryTiles); diff --git a/src/components/assets/AssetsTable.js b/src/components/assets/AssetsTable.js new file mode 100644 index 00000000..90083a9a --- /dev/null +++ b/src/components/assets/AssetsTable.js @@ -0,0 +1,209 @@ +import React, { useMemo, useState } from "react"; +import { Tag } from "@carbon/react"; +import { toCurrency } from "../../util"; +import { stableSort, getComparator, getAssetColor } from "./assetUtils"; + +const headers = [ + { key: "name", label: "Name", sortable: true }, + { key: "type", label: "Type", sortable: true }, + { key: "category", label: "Category", sortable: true }, + { key: "provider", label: "Provider", sortable: true }, + { key: "cluster", label: "Cluster", sortable: true }, + { key: "totalCost", label: "Total Cost", sortable: true }, +]; + +const typeTagColor = { + Node: "blue", + Disk: "green", + LoadBalancer: "purple", + Network: "teal", + ClusterManagement: "warm-gray", + Cloud: "red", +}; + +const AssetsTable = ({ assets, onRowClick }) => { + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(25); + const [searchTerm, setSearchTerm] = useState(""); + const [sortKey, setSortKey] = useState("totalCost"); + const [sortDir, setSortDir] = useState("desc"); + + const searched = useMemo(() => { + if (!searchTerm) return assets; + const term = searchTerm.toLowerCase(); + return assets.filter( + (a) => + a.name.toLowerCase().includes(term) || + a.type.toLowerCase().includes(term) || + a.provider.toLowerCase().includes(term) || + a.cluster.toLowerCase().includes(term), + ); + }, [assets, searchTerm]); + + const sorted = useMemo( + () => stableSort(searched, getComparator(sortDir, sortKey)), + [searched, sortKey, sortDir], + ); + + const pageRows = sorted.slice(page * pageSize, (page + 1) * pageSize); + const totalPages = Math.max(1, Math.ceil(searched.length / pageSize)); + + // Reset to first page when filters change + React.useEffect(() => { + setPage(0); + }, [searchTerm, assets]); + + const handleSort = (key) => { + if (sortKey === key) { + setSortDir(sortDir === "desc" ? "asc" : "desc"); + } else { + setSortKey(key); + setSortDir("desc"); + } + }; + + const sortArrow = (key) => { + if (sortKey !== key) return ""; + return sortDir === "desc" ? " \u25BC" : " \u25B2"; + }; + + return ( +
+
+
Infrastructure Assets
+ setSearchTerm(e.target.value)} + /> +
+ + + + {headers.map((h) => ( + + ))} + + + + {pageRows.length === 0 && ( + + + + )} + {pageRows.map((asset) => ( + onRowClick(asset)} + style={{ cursor: "pointer", borderBottom: "1px solid #e0e0e0" }} + onMouseEnter={(e) => + (e.currentTarget.style.background = "#e8e8e8") + } + onMouseLeave={(e) => + (e.currentTarget.style.background = "transparent") + } + > + + + + + + + + ))} + +
h.sortable && handleSort(h.key)} + style={{ + padding: "0.75rem 1rem", + textAlign: "left", + fontSize: "0.875rem", + fontWeight: 600, + background: "#e0e0e0", + borderBottom: "1px solid #c6c6c6", + cursor: h.sortable ? "pointer" : "default", + userSelect: "none", + whiteSpace: "nowrap", + }} + > + {h.label} + {sortArrow(h.key)} +
+ No assets found +
+ {asset.name} + + + {asset.type} + + + {asset.category} + + {asset.provider} + + {asset.cluster} + + {toCurrency(asset.totalCost, "USD")} +
+
+
+ Showing {searched.length === 0 ? 0 : page * pageSize + 1} + {" - "} + {Math.min((page + 1) * pageSize, searched.length)} of{" "} + {searched.length} +
+
+ Rows per page: + + + + Page {page + 1} of {totalPages} + + +
+
+
+ ); +}; + +export default React.memo(AssetsTable); diff --git a/src/components/assets/assetUtils.js b/src/components/assets/assetUtils.js new file mode 100644 index 00000000..bb05482b --- /dev/null +++ b/src/components/assets/assetUtils.js @@ -0,0 +1,143 @@ +import { get, round } from "lodash"; +import { assetTypeConfig } from "./tokens"; + +// Convert the API's object map into an array of asset objects with flattened properties +export function transformAssetsData(dataMap) { + if (!dataMap || typeof dataMap !== "object") return []; + return Object.entries(dataMap).map(([key, asset]) => ({ + key, + name: get(asset, "properties.name", key.split("/").pop()), + type: asset.type || "Unknown", + category: get(asset, "properties.category", "Other"), + provider: get(asset, "properties.provider", "Unknown"), + cluster: get(asset, "properties.cluster", ""), + project: get(asset, "properties.project", ""), + providerID: get(asset, "properties.providerID", ""), + totalCost: asset.totalCost || 0, + labels: asset.labels || {}, + // Node-specific + nodeType: asset.nodeType, + cpuCores: asset.cpuCores, + ramBytes: asset.ramBytes, + cpuCost: asset.cpuCost, + ramCost: asset.ramCost, + gpuCost: asset.gpuCost, + cpuBreakdown: asset.cpuBreakdown, + ramBreakdown: asset.ramBreakdown, + preemptible: asset.preemptible, + discount: asset.discount, + // Disk-specific + storageClass: asset.storageClass, + volumeName: asset.volumeName, + claimName: asset.claimName, + claimNamespace: asset.claimNamespace, + bytes: asset.bytes, + byteHours: asset.byteHours, + breakdown: asset.breakdown, + // Time + start: asset.start, + end: asset.end, + minutes: asset.minutes, + window: asset.window, + })); +} + +// Group assets by type and compute totals + counts +export function computeAssetTypeSummary(assets) { + const summary = {}; + for (const asset of assets) { + const t = asset.type; + if (!summary[t]) { + summary[t] = { type: t, totalCost: 0, count: 0 }; + } + summary[t].totalCost += asset.totalCost; + summary[t].count += 1; + } + return summary; +} + +// Sum all totalCost across assets +export function computeGrandTotal(assets) { + return assets.reduce((sum, a) => sum + a.totalCost, 0); +} + +// Group by any property and sum totalCost +export function groupAssetsBy(assets, property) { + const groups = {}; + for (const asset of assets) { + const val = get(asset, property, "Unknown") || "Unknown"; + if (!groups[val]) { + groups[val] = { name: val, totalCost: 0, count: 0 }; + } + groups[val].totalCost += asset.totalCost; + groups[val].count += 1; + } + return Object.values(groups).sort((a, b) => b.totalCost - a.totalCost); +} + +// Filter assets by type +export function filterAssetsByType(assets, type) { + if (!type || type === "all") return assets; + return assets.filter((a) => a.type === type); +} + +// Filter assets by category +export function filterAssetsByCategory(assets, category) { + if (!category || category === "all") return assets; + return assets.filter((a) => a.category === category); +} + +// Format CPU cores +export function formatCores(cores) { + if (cores === undefined || cores === null) return "—"; + return `${round(cores, 2)} cores`; +} + +// Format bytes to human-readable +export function formatBytes(bytes) { + if (bytes === undefined || bytes === null) return "—"; + const gi = Math.pow(1024, 3); + if (bytes >= gi) return `${round(bytes / gi, 1)} GiB`; + const mi = Math.pow(1024, 2); + if (bytes >= mi) return `${round(bytes / mi, 1)} MiB`; + return `${round(bytes / 1024, 1)} KiB`; +} + +// Format a breakdown object { idle, system, user, other } as percentages +export function formatBreakdown(breakdown) { + if (!breakdown) return null; + return { + idle: round((breakdown.idle || 0) * 100, 1), + system: round((breakdown.system || 0) * 100, 1), + user: round((breakdown.user || 0) * 100, 1), + other: round((breakdown.other || 0) * 100, 1), + }; +} + +// Get the color for an asset type +export function getAssetColor(type) { + return get(assetTypeConfig, [type, "color"], "#9e9e9e"); +} + +// Sorting helpers (same pattern as allocationReport.js) +export function descendingComparator(a, b, orderBy) { + if (get(b, orderBy) < get(a, orderBy)) return -1; + if (get(b, orderBy) > get(a, orderBy)) return 1; + return 0; +} + +export function getComparator(order, orderBy) { + return order === "desc" + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); +} + +export function stableSort(array, comparator) { + const stabilized = array.map((el, index) => [el, index]); + stabilized.sort((a, b) => { + const order = comparator(a[0], b[0]); + if (order !== 0) return order; + return a[1] - b[1]; + }); + return stabilized.map((el) => el[0]); +} diff --git a/src/components/assets/carbonStyles.css b/src/components/assets/carbonStyles.css new file mode 100644 index 00000000..c80b7b5c --- /dev/null +++ b/src/components/assets/carbonStyles.css @@ -0,0 +1,572 @@ +/* Carbon-scoped styles for the Assets page */ +/* Using plain CSS to avoid SCSS/Parcel compatibility issues with @carbon/styles */ + +.carbon-assets-scope { + font-family: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif; + color: #161616; +} + +.carbon-assets-scope *, +.carbon-assets-scope *::before, +.carbon-assets-scope *::after { + box-sizing: border-box; +} + +/* Summary tiles */ +.assets-grand-total { + padding: 1.25rem; + background: #161616; + color: #ffffff; + border-radius: 4px; + margin-bottom: 1rem; +} + +.assets-grand-total-label { + font-size: 0.875rem; + opacity: 0.7; + margin-bottom: 0.25rem; +} + +.assets-grand-total-value { + font-size: 2rem; + font-weight: 600; + line-height: 1.2; +} + +.assets-grand-total-count { + font-size: 0.875rem; + opacity: 0.7; + margin-top: 0.25rem; +} + +.assets-type-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.assets-type-tile { + background: #ffffff; + padding: 1rem; + border-radius: 4px; + cursor: pointer; + transition: box-shadow 0.15s ease; + border: 1px solid #e0e0e0; +} + +.assets-type-tile:hover { + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); +} + +.assets-type-tile--active { + border-color: #0f62fe; + box-shadow: 0 0 0 1px #0f62fe; +} + +.assets-type-tile-label { + font-size: 0.75rem; + color: #525252; + margin-bottom: 0.25rem; +} + +.assets-type-tile-cost { + font-size: 1.25rem; + font-weight: 600; +} + +.assets-type-tile-count { + font-size: 0.75rem; + color: #525252; + margin-top: 0.125rem; +} + +/* Controls row */ +.assets-controls { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; + align-items: flex-end; +} + +/* Carbon Dropdown styling */ +.assets-controls .cds--dropdown__wrapper { + min-width: 200px; +} + +.assets-controls .cds--dropdown__wrapper .cds--label { + font-size: 0.75rem; + font-weight: 600; + color: #525252; + letter-spacing: 0.32px; + margin-bottom: 0.5rem; + display: block; +} + +.assets-controls .cds--dropdown { + background-color: #f4f4f4; + border: none; + border-bottom: 1px solid #8d8d8d; + height: 40px; + outline: none; + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease; + position: relative; +} + +.assets-controls .cds--dropdown:hover { + background-color: #e8e8e8; +} + +.assets-controls .cds--dropdown--open, +.assets-controls .cds--dropdown:focus { + border-bottom-color: #0f62fe; + box-shadow: inset 0 -2px 0 0 #0f62fe; +} + +.assets-controls .cds--dropdown .cds--list-box__field { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 2.5rem 0 1rem; + height: 100%; + cursor: pointer; + background: transparent; + border: none; + width: 100%; + text-align: left; + font-size: 0.875rem; + color: #161616; + outline: none; +} + +.assets-controls .cds--dropdown .cds--list-box__menu-icon { + position: absolute; + right: 1rem; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + pointer-events: none; +} + +.assets-controls .cds--dropdown .cds--list-box__menu-icon svg { + fill: #161616; + width: 16px; + height: 16px; + transition: transform 0.15s ease; +} + +.assets-controls .cds--dropdown--open .cds--list-box__menu-icon svg { + transform: rotate(180deg); +} + +.assets-controls .cds--list-box__menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #ffffff; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + z-index: 9100; + max-height: 220px; + overflow-y: auto; + border: none; + outline: none; +} + +.assets-controls .cds--list-box__menu-item { + padding: 0; + border: none; + background: transparent; +} + +.assets-controls .cds--list-box__menu-item__option { + display: block; + padding: 0.6875rem 1rem; + font-size: 0.875rem; + color: #161616; + cursor: pointer; + text-decoration: none; + border: none; + background: transparent; + width: 100%; + text-align: left; + outline: none; +} + +.assets-controls .cds--list-box__menu-item:hover .cds--list-box__menu-item__option, +.assets-controls .cds--list-box__menu-item--highlighted .cds--list-box__menu-item__option { + background: #e0e0e0; +} + +.assets-controls .cds--list-box__menu-item--active { + background: #e0e0e0; +} + +.assets-controls .cds--list-box__menu-item--active .cds--list-box__menu-item__option { + font-weight: 600; + color: #161616; +} + +.assets-controls .cds--list-box__menu-item--active .cds--list-box__menu-item__option .cds--list-box__menu-item__selected-icon { + display: flex; +} + +.assets-controls .cds--list-box__menu-item__selected-icon { + display: none; + position: absolute; + right: 1rem; + top: 50%; + transform: translateY(-50%); +} + +.assets-controls .cds--list-box__menu-item__selected-icon svg { + fill: #161616; + width: 16px; + height: 16px; +} + +/* Charts — remove blue focus outlines on SVG click */ +.assets-chart-card svg:focus, +.assets-chart-card svg *:focus { + outline: none; +} + +.assets-chart-card .recharts-surface { + outline: none; +} + +/* Charts area */ +.assets-charts { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.assets-chart-card { + flex: 1 1 400px; + background: #ffffff; + padding: 1rem; + border-radius: 4px; + border: 1px solid #e0e0e0; +} + +.assets-chart-title { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.75rem; +} + +/* Donut center hover label */ +.assets-donut-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; +} + +.assets-donut-center-name { + font-size: 0.8125rem; + font-weight: 600; + color: #161616; + line-height: 1.2; +} + +.assets-donut-center-value { + font-size: 0.75rem; + color: #525252; + margin-top: 2px; +} + +/* Pie chart legend */ +.assets-pie-legend { + display: flex; + flex-direction: column; + gap: 0.375rem; + padding: 0.5rem 0.25rem 0; +} + +.assets-pie-legend-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + line-height: 1.4; +} + +.assets-pie-legend-swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; +} + +.assets-pie-legend-name { + flex: 1; + color: #393939; +} + +.assets-pie-legend-value { + font-weight: 600; + color: #161616; + white-space: nowrap; +} + +.assets-pie-legend-pct { + font-size: 0.75rem; + color: #6f6f6f; + min-width: 3rem; + text-align: right; +} + +/* Table section */ +.assets-table-section { + background: #ffffff; + border-radius: 4px; + border: 1px solid #e0e0e0; + overflow: hidden; +} + +.assets-table-section .cds--data-table-container { + padding: 0; +} + +.assets-table-section .cds--data-table thead th { + background: #e0e0e0; +} + +.assets-table-section .cds--data-table tbody tr { + cursor: pointer; +} + +.assets-table-section .cds--data-table tbody tr:hover td { + background: #e8e8e8; +} + +/* Search bar in table */ +.assets-table-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid #e0e0e0; +} + +.assets-table-search { + width: 300px; + padding: 0.5rem 0.75rem; + border: 1px solid #8d8d8d; + border-radius: 0; + font-size: 0.875rem; + outline: none; + background: #ffffff; +} + +.assets-table-search:focus { + border-color: #0f62fe; + box-shadow: inset 0 0 0 1px #0f62fe; +} + +.assets-table-title { + font-size: 1.125rem; + font-weight: 600; +} + +/* Pagination */ +.assets-pagination { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + border-top: 1px solid #e0e0e0; + font-size: 0.875rem; + color: #525252; +} + +.assets-pagination-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.assets-pagination-btn { + padding: 0.25rem 0.5rem; + border: 1px solid #8d8d8d; + background: #ffffff; + cursor: pointer; + font-size: 0.875rem; +} + +.assets-pagination-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.assets-pagination-btn:hover:not(:disabled) { + background: #e0e0e0; +} + +.assets-pagination-select { + padding: 0.25rem 0.5rem; + border: 1px solid #8d8d8d; + font-size: 0.875rem; + background: #ffffff; +} + +/* Tags for asset types */ +.assets-tag { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 1rem; + font-size: 0.75rem; + font-weight: 500; + color: #ffffff; +} + +/* Detail modal overlay */ +.assets-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 9000; +} + +.assets-modal { + background: #ffffff; + width: 90%; + max-width: 720px; + max-height: 85vh; + overflow-y: auto; + border-radius: 4px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); +} + +.assets-modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid #e0e0e0; +} + +.assets-modal-label { + font-size: 0.75rem; + color: #525252; + margin-bottom: 0.25rem; +} + +.assets-modal-heading { + font-size: 1.25rem; + font-weight: 600; +} + +.assets-modal-close { + background: none; + border: none; + font-size: 1.25rem; + cursor: pointer; + padding: 0.25rem; + color: #525252; +} + +.assets-modal-close:hover { + color: #161616; +} + +.assets-modal-body { + padding: 1.5rem; +} + +.assets-detail-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1.5rem; +} + +.assets-detail-table tr { + border-bottom: 1px solid #e0e0e0; +} + +.assets-detail-table td { + padding: 0.5rem 0; +} + +.assets-detail-table td:first-child { + font-weight: 500; + color: #525252; + width: 40%; + padding-right: 1rem; +} + +.assets-detail-section-title { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.75rem; + margin-top: 0.5rem; +} + +.assets-labels-container { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.assets-label-tag { + background: #e0e0e0; + padding: 0.125rem 0.5rem; + border-radius: 0.75rem; + font-size: 0.75rem; + color: #393939; +} + +/* Loading state */ +.assets-loading { + display: flex; + justify-content: center; + align-items: center; + padding: 4rem 0; +} + +/* Error notification */ +.assets-error { + background: #fff1f1; + border-left: 3px solid #da1e28; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 0 4px 4px 0; +} + +.assets-error-title { + font-weight: 600; + color: #da1e28; + margin-bottom: 0.25rem; +} + +.assets-error-subtitle { + font-size: 0.875rem; + color: #525252; +} + +/* Empty state */ +.assets-empty { + text-align: center; + padding: 4rem 2rem; + color: #525252; +} + +.assets-empty-title { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 0.5rem; +} diff --git a/src/components/assets/tokens.js b/src/components/assets/tokens.js new file mode 100644 index 00000000..006d2889 --- /dev/null +++ b/src/components/assets/tokens.js @@ -0,0 +1,44 @@ +import blue from "@mui/material/colors/blue"; +import orange from "@mui/material/colors/orange"; +import green from "@mui/material/colors/green"; +import purple from "@mui/material/colors/purple"; +import teal from "@mui/material/colors/teal"; +import grey from "@mui/material/colors/grey"; + +export const windowOptions = [ + { name: "Today", value: "today" }, + { name: "Yesterday", value: "yesterday" }, + { name: "Last 24h", value: "24h" }, + { name: "Last 48h", value: "48h" }, + { name: "Week-to-date", value: "week" }, + { name: "Last week", value: "lastweek" }, + { name: "Last 7 days", value: "7d" }, + { name: "Last 14 days", value: "14d" }, +]; + +export const assetTypeOptions = [ + { name: "All Types", value: "all" }, + { name: "Node", value: "Node" }, + { name: "Disk", value: "Disk" }, + { name: "LoadBalancer", value: "LoadBalancer" }, + { name: "Network", value: "Network" }, + { name: "ClusterManagement", value: "ClusterManagement" }, + { name: "Cloud", value: "Cloud" }, +]; + +export const categoryOptions = [ + { name: "All Categories", value: "all" }, + { name: "Compute", value: "Compute" }, + { name: "Storage", value: "Storage" }, + { name: "Network", value: "Network" }, + { name: "Management", value: "Management" }, +]; + +export const assetTypeConfig = { + Node: { label: "Nodes", color: blue[500] }, + Disk: { label: "Disks", color: orange[500] }, + LoadBalancer: { label: "Load Balancers", color: green[500] }, + Network: { label: "Network", color: purple[500] }, + ClusterManagement: { label: "Cluster Mgmt", color: teal[500] }, + Cloud: { label: "Cloud", color: grey[500] }, +}; diff --git a/src/pages/Assets.js b/src/pages/Assets.js new file mode 100644 index 00000000..cd373b20 --- /dev/null +++ b/src/pages/Assets.js @@ -0,0 +1,159 @@ +import React, { useEffect, useState, useMemo } from "react"; +import { useLocation, useNavigate } from "react-router"; +import CircularProgress from "@mui/material/CircularProgress"; +import IconButton from "@mui/material/IconButton"; +import RefreshIcon from "@mui/icons-material/Refresh"; + +import Page from "../components/Page"; +import Header from "../components/Header"; +import Footer from "../components/Footer"; + +import AssetsService from "../services/assets"; +import { + transformAssetsData, + computeAssetTypeSummary, + computeGrandTotal, + filterAssetsByType, + filterAssetsByCategory, +} from "../components/assets/assetUtils"; +import AssetsSummaryTiles from "../components/assets/AssetsSummaryTiles"; +import AssetsControls from "../components/assets/AssetsControls"; +import AssetsChart from "../components/assets/AssetsChart"; +import AssetsTable from "../components/assets/AssetsTable"; +import AssetDetailPanel from "../components/assets/AssetDetailPanel"; + +import "../components/assets/carbonStyles.css"; + +const Assets = () => { + const [rawData, setRawData] = useState({}); + const [loading, setLoading] = useState(true); + const [errors, setErrors] = useState([]); + const [selectedAsset, setSelectedAsset] = useState(null); + + const routerLocation = useLocation(); + const searchParams = new URLSearchParams(routerLocation.search); + const navigate = useNavigate(); + + const win = searchParams.get("window") || "7d"; + const typeFilter = searchParams.get("type") || "all"; + const categoryFilter = searchParams.get("category") || "all"; + + // Transform and filter data + const allAssets = useMemo(() => transformAssetsData(rawData), [rawData]); + const filteredAssets = useMemo(() => { + let result = filterAssetsByType(allAssets, typeFilter); + result = filterAssetsByCategory(result, categoryFilter); + return result; + }, [allAssets, typeFilter, categoryFilter]); + const typeSummary = useMemo( + () => computeAssetTypeSummary(allAssets), + [allAssets], + ); + const grandTotal = useMemo(() => computeGrandTotal(allAssets), [allAssets]); + + useEffect(() => { + fetchData(); + }, [win]); + + async function fetchData() { + setLoading(true); + setErrors([]); + try { + const resp = await AssetsService.fetchAssets(win); + if (resp && resp.data) { + setRawData(resp.data); + } else { + setRawData({}); + } + } catch (err) { + setErrors([ + { + title: "Failed to load asset data", + subtitle: err.message || "Please check your OpenCost backend.", + }, + ]); + setRawData({}); + } + setLoading(false); + } + + const updateParam = (key, value) => { + searchParams.set(key, value); + navigate({ search: `?${searchParams.toString()}` }); + }; + + return ( + +
+
+ + + +
+
+ +
+ {!loading && + errors.length > 0 && + errors.map((e, i) => ( +
+
{e.title}
+
{e.subtitle}
+
+ ))} + + updateParam("window", v)} + assetType={typeFilter} + setAssetType={(v) => updateParam("type", v)} + category={categoryFilter} + setCategory={(v) => updateParam("category", v)} + /> + + {loading && ( +
+ +
+ )} + + {!loading && allAssets.length === 0 && errors.length === 0 && ( +
+
No assets found
+
+ Try changing the time range or check that the OpenCost backend is + running. +
+
+ )} + + {!loading && allAssets.length > 0 && ( + <> + updateParam("type", t)} + /> + + + + )} + + {selectedAsset && ( + setSelectedAsset(null)} + /> + )} +
+