From cd59cffadc3a277957ba9b00221f1cea82cb7c82 Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Sun, 8 Feb 2026 23:24:12 +0530 Subject: [PATCH 01/19] feat: add Assets page with summary tiles and table - Implemented the Assets page to display asset data. - Created a new service for fetching assets from the OpenCost API with mock data fallback. - Added constants and configuration for asset types and window options. - Developed CSS styles for the Assets page, including layout, notifications, and table design. - Integrated summary tiles and filter functionality for asset types. Signed-off-by: Abhishek-Punhani --- package-lock.json | 548 ++++++++++++++++++- package.json | 1 + src/components/Nav/SidebarNav.js | 3 +- src/components/assets/AssetsSummaryTiles.js | 90 ++++ src/components/assets/AssetsTable.js | 539 +++++++++++++++++++ src/components/assets/tokens.js | 54 ++ src/css/assets.css | 554 ++++++++++++++++++++ src/pages/Assets.js | 198 +++++++ src/route.js | 2 + src/services/assets.js | 298 +++++++++++ 10 files changed, 2271 insertions(+), 16 deletions(-) create mode 100644 src/components/assets/AssetsSummaryTiles.js create mode 100644 src/components/assets/AssetsTable.js create mode 100644 src/components/assets/tokens.js create mode 100644 src/css/assets.css create mode 100644 src/pages/Assets.js create mode 100644 src/services/assets.js diff --git a/package-lock.json b/package-lock.json index a02a7fba..64e11780 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", @@ -546,6 +547,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 +901,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", @@ -2709,7 +3049,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 +3647,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 +3897,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 +4025,22 @@ "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", + "peer": true, + "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 +4051,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 +4076,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 +4105,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 +4139,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 +4161,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 +4373,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 +4476,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 +4627,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 +4642,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 +4906,13 @@ "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", + "peer": true + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4506,6 +4938,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 +4972,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 +4982,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 +4995,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 +5441,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 +5542,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 +5689,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 +5775,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 +5858,20 @@ "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", + "peer": true, + "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 +5980,27 @@ ], "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", + "peer": true, + "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 +6042,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 +6066,16 @@ "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", + "peer": true, + "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 +6107,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 +6136,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 +6145,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..dc1d445c 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", 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/AssetsSummaryTiles.js b/src/components/assets/AssetsSummaryTiles.js new file mode 100644 index 00000000..5229ec78 --- /dev/null +++ b/src/components/assets/AssetsSummaryTiles.js @@ -0,0 +1,90 @@ +import React from "react"; +import { toCurrency } from "../../util"; + +/** + * AssetsSummaryTiles renders 4 summary metric tiles at the top of the Assets page. + * Clicking a tile filters the table to that asset type. + * + * Props: + * - assets: array of processed asset objects + * - currency: ISO currency code + * - activeTab: currently selected tab key + * - onTabChange: callback to switch tab (filter by type) + */ +const AssetsSummaryTiles = ({ assets, currency, activeTab, onTabChange }) => { + const totalCost = assets.reduce((sum, a) => sum + (a.totalCost || 0), 0); + const nodeCount = assets.filter((a) => a.type === "Node").length; + const diskCount = assets.filter((a) => a.type === "Disk").length; + const lbCount = assets.filter((a) => a.type === "LoadBalancer").length; + + const tiles = [ + { + key: "all", + label: "Total Cost", + value: toCurrency(totalCost, currency), + sub: `${assets.length} assets`, + className: "assets-summary-tile--total", + }, + { + key: "Node", + label: "Nodes", + value: nodeCount, + sub: toCurrency( + assets + .filter((a) => a.type === "Node") + .reduce((s, a) => s + a.totalCost, 0), + currency, + ), + className: "assets-summary-tile--nodes", + }, + { + key: "Disk", + label: "Disks", + value: diskCount, + sub: toCurrency( + assets + .filter((a) => a.type === "Disk") + .reduce((s, a) => s + a.totalCost, 0), + currency, + ), + className: "assets-summary-tile--disks", + }, + { + key: "LoadBalancer", + label: "Load Balancers", + value: lbCount, + sub: toCurrency( + assets + .filter((a) => a.type === "LoadBalancer") + .reduce((s, a) => s + a.totalCost, 0), + currency, + ), + className: "assets-summary-tile--lbs", + }, + ]; + + return ( +
+ {tiles.map((tile) => ( +
onTabChange(tile.key)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onTabChange(tile.key); + }} + > + {tile.label} + {tile.value} + {tile.sub} +
+ ))} +
+ ); +}; + +export default AssetsSummaryTiles; diff --git a/src/components/assets/AssetsTable.js b/src/components/assets/AssetsTable.js new file mode 100644 index 00000000..f06a7df5 --- /dev/null +++ b/src/components/assets/AssetsTable.js @@ -0,0 +1,539 @@ +import React, { useState, useMemo } from "react"; +import { toCurrency } from "../../util"; +import { assetTypeTagColor } from "./tokens"; + +/** + * Renders a CPU/RAM utilization bar with user/system/idle breakdown. + */ +const UtilizationBar = ({ breakdown, label }) => { + if (!breakdown) return null; + const user = Math.round((breakdown.user || 0) * 100); + const system = Math.round((breakdown.system || 0) * 100); + const idle = 100 - user - system; + + return ( +
+
+ {label} + {user + system}% utilized +
+
+
+
+
+
+
+ User {user}% + System {system}% + Idle {idle}% +
+
+ ); +}; + +/** + * Renders the expanded detail content for a Node asset. + */ +const NodeDetail = ({ asset, currency }) => ( +
+
+
Compute Resources
+
+ Instance Type + {asset.nodeType || "—"} +
+
+ CPU Cores + {asset.cpuCores || 0} +
+
+ RAM + {formatBytes(asset.ramBytes)} +
+ {asset.gpuCount > 0 && ( +
+ GPU Count + {asset.gpuCount} +
+ )} + {asset.preemptible > 0 && ( +
+ Preemptible + Yes +
+ )} +
+
+
Cost Breakdown
+
+ CPU Cost + + {toCurrency(asset.cpuCost, currency)} + +
+
+ RAM Cost + + {toCurrency(asset.ramCost, currency)} + +
+ {asset.gpuCost > 0 && ( +
+ GPU Cost + + {toCurrency(asset.gpuCost, currency)} + +
+ )} + {asset.adjustment !== 0 && ( +
+ Adjustment + + {toCurrency(asset.adjustment, currency)} + +
+ )} + {asset.discount > 0 && ( +
+ Discount + + {Math.round(asset.discount * 100)}% + +
+ )} +
+
+
Utilization
+ +
+ +
+
+); + +/** + * Renders the expanded detail content for a Disk asset. + */ +const DiskDetail = ({ asset, currency }) => ( +
+
+
Storage Details
+
+ Storage Class + {asset.storageClass || "—"} +
+
+ Capacity + {formatBytes(asset.bytes)} +
+
+ Peak Usage + {formatBytes(asset.byteUsageMax)} +
+ {asset.claimName && ( +
+ PVC Claim + + {asset.claimNamespace}/{asset.claimName} + +
+ )} +
+
+
Utilization
+ +
+
+); + +/** + * Renders the expanded detail content for a LoadBalancer asset. + */ +const LoadBalancerDetail = ({ asset }) => ( +
+
+
Load Balancer Details
+ {asset.ip && ( +
+ IP Address + {asset.ip} +
+ )} +
+ Scope + + {asset.private ? "Private" : "Public"} + +
+
+
+); + +/** + * Generic detail for Network, Cloud, and ClusterManagement types. + */ +const GenericDetail = ({ asset, currency }) => ( +
+
+
Properties
+
+ Provider + + {asset.properties?.provider || "—"} + +
+
+ Service + {asset.properties?.service || "—"} +
+
+ Project + {asset.properties?.project || "—"} +
+
+ Total Cost + + {toCurrency(asset.totalCost, currency)} + +
+
+ {asset.labels && Object.keys(asset.labels).length > 0 && ( +
+
Labels
+
+ {Object.entries(asset.labels) + .slice(0, 8) + .map(([k, v]) => ( + + {k}: {v} + + ))} + {Object.keys(asset.labels).length > 8 && ( + + +{Object.keys(asset.labels).length - 8} more + + )} +
+
+ )} +
+); + +/** + * Renders type-specific expanded detail for an asset row. + */ +const AssetExpandedDetail = ({ asset, currency }) => { + const detailMap = { + Node: NodeDetail, + Disk: DiskDetail, + LoadBalancer: LoadBalancerDetail, + }; + const DetailComponent = detailMap[asset.type] || GenericDetail; + + return ( +
+ + {/* Show labels for Node and Disk types too */} + {asset.type !== "Cloud" && + asset.type !== "Network" && + asset.type !== "ClusterManagement" && + asset.labels && + Object.keys(asset.labels).length > 0 && ( +
+
+ Labels +
+
+ {Object.entries(asset.labels) + .slice(0, 10) + .map(([k, v]) => ( + + {k}: {v} + + ))} + {Object.keys(asset.labels).length > 10 && ( + + +{Object.keys(asset.labels).length - 10} more + + )} +
+
+ )} +
+ ); +}; + +/** + * Format bytes to human readable (GiB, MiB, etc.) + */ +function formatBytes(bytes) { + if (!bytes || bytes === 0) return "0 B"; + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + let i = 0; + let val = bytes; + while (val >= 1024 && i < units.length - 1) { + val /= 1024; + i++; + } + return `${val.toFixed(val >= 100 ? 0 : val >= 10 ? 1 : 2)} ${units[i]}`; +} + +/** + * AssetsTable — clean custom table with expandable rows, search, sort, pagination. + * + * Props: + * - assets: array of processed asset objects + * - currency: ISO currency code + */ +const columns = [ + { key: "name", label: "Name" }, + { key: "type", label: "Type" }, + { key: "category", label: "Category" }, + { key: "provider", label: "Provider" }, + { key: "cluster", label: "Cluster" }, + { key: "totalCost", label: "Total Cost", align: "right" }, +]; + +const AssetsTable = ({ assets, currency }) => { + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const [searchText, setSearchText] = useState(""); + const [sortKey, setSortKey] = useState("totalCost"); + const [sortDir, setSortDir] = useState("desc"); + const [expandedRows, setExpandedRows] = useState(new Set()); + + const toggleRow = (id) => { + setExpandedRows((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleSort = (key) => { + if (sortKey === key) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir(key === "totalCost" ? "desc" : "asc"); + } + }; + + // Extract cell value for sorting/display + const getCellValue = (asset, key) => { + switch (key) { + case "name": + return asset.properties?.name || "—"; + case "type": + return asset.type || "—"; + case "category": + return asset.properties?.category || "—"; + case "provider": + return asset.properties?.provider || "—"; + case "cluster": + return asset.properties?.cluster || "—"; + case "totalCost": + return asset.totalCost || 0; + default: + return ""; + } + }; + + // Filter + const filtered = useMemo(() => { + if (!searchText) return assets; + const q = searchText.toLowerCase(); + return assets.filter( + (a) => + (a.properties?.name || "").toLowerCase().includes(q) || + (a.type || "").toLowerCase().includes(q) || + (a.properties?.provider || "").toLowerCase().includes(q) || + (a.properties?.cluster || "").toLowerCase().includes(q), + ); + }, [assets, searchText]); + + // Sort + const sorted = useMemo(() => { + return [...filtered].sort((a, b) => { + const va = getCellValue(a, sortKey); + const vb = getCellValue(b, sortKey); + let cmp = 0; + if (typeof va === "number" && typeof vb === "number") { + cmp = va - vb; + } else { + cmp = String(va).localeCompare(String(vb)); + } + return sortDir === "asc" ? cmp : -cmp; + }); + }, [filtered, sortKey, sortDir]); + + // Paginate + const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize)); + const safePage = Math.min(page, totalPages); + const paginated = useMemo(() => { + const start = (safePage - 1) * pageSize; + return sorted.slice(start, start + pageSize); + }, [sorted, safePage, pageSize]); + + const startItem = sorted.length === 0 ? 0 : (safePage - 1) * pageSize + 1; + const endItem = Math.min(safePage * pageSize, sorted.length); + + return ( +
+ {/* Search bar */} +
+ + { + setSearchText(e.target.value); + setPage(1); + }} + /> + {filtered.length} results +
+ + {/* Table */} + + + + + ))} + + + + {paginated.map((asset, idx) => { + const rowId = asset._id || String(idx); + const isOpen = expandedRows.has(rowId); + return ( + + + + + + + + + + + {isOpen && ( + + + + )} + + ); + })} + {paginated.length === 0 && ( + + + + )} + +
+ {columns.map((col) => ( + handleSort(col.key)} + > + {col.label} + + {sortKey === col.key ? (sortDir === "asc" ? "↑" : "↓") : "↕"} + +
+ + + + {getCellValue(asset, "name")} + + + + {asset.type} + + {getCellValue(asset, "category")}{getCellValue(asset, "provider")}{getCellValue(asset, "cluster")} + + {toCurrency(asset.totalCost || 0, currency)} + +
+ +
+ No assets match your search. +
+ + {/* Pagination */} +
+
+ Showing {startItem}–{endItem} of {sorted.length} +
+
+ + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( + + ))} + +
+
+
+ ); +}; + +export default AssetsTable; diff --git a/src/components/assets/tokens.js b/src/components/assets/tokens.js new file mode 100644 index 00000000..3812897b --- /dev/null +++ b/src/components/assets/tokens.js @@ -0,0 +1,54 @@ +/** + * Constants and configuration for the Assets page. + * Follows the tokens pattern used by cloudCost/tokens.js and externalCosts/tokens.js. + */ + +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 assetTypeTabs = [ + { key: "all", label: "All Assets" }, + { key: "Node", label: "Nodes" }, + { key: "Disk", label: "Disks" }, + { key: "LoadBalancer", label: "Load Balancers" }, + { key: "Network", label: "Network" }, + { key: "ClusterManagement", label: "Management" }, + { key: "Cloud", label: "Cloud" }, +]; + +// Maps asset type to a Carbon Tag color for visual distinction +export const assetTypeTagColor = { + Node: "blue", + Disk: "teal", + LoadBalancer: "purple", + Network: "cyan", + ClusterManagement: "warm-gray", + Cloud: "magenta", +}; + +// Maps asset category to a human-readable label +export const categoryLabels = { + Compute: "Compute", + Storage: "Storage", + Network: "Network", + Management: "Management", + Other: "Other", +}; + +// Table column definitions for the main assets table +export const tableHeaders = [ + { key: "name", header: "Name" }, + { key: "type", header: "Type" }, + { key: "category", header: "Category" }, + { key: "provider", header: "Provider" }, + { key: "cluster", header: "Cluster" }, + { key: "totalCost", header: "Total Cost" }, +]; diff --git a/src/css/assets.css b/src/css/assets.css new file mode 100644 index 00000000..d7bd99be --- /dev/null +++ b/src/css/assets.css @@ -0,0 +1,554 @@ +/* + * Assets page — clean, minimal, Grafana-inspired dashboard. + * Scoped under .assets-page to avoid MUI conflicts. + */ + +/* ── Base ───────────────────────────────────────────── */ +.assets-page { + font-family: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif; + -webkit-font-smoothing: antialiased; + padding-top: 0.5rem; + max-width: 1440px; +} + +/* ── Demo notification ─────────────────────────────── */ +.assets-page .assets-notification { + display: flex; + align-items: center; + gap: 0.5rem; + background: #edf5ff; + border-left: 3px solid #0f62fe; + padding: 0.625rem 1rem; + margin-bottom: 1.25rem; + font-size: 0.8125rem; + color: #161616; + border-radius: 2px; +} +.assets-page .assets-notification .notif-icon { + font-size: 1rem; + color: #0f62fe; + flex-shrink: 0; +} +.assets-page .assets-notification strong { + font-weight: 600; + margin-right: 0.25rem; +} +.assets-page .assets-notification .notif-dismiss { + margin-left: auto; + background: none; + border: none; + cursor: pointer; + color: #6f6f6f; + font-size: 1.1rem; + padding: 2px 6px; + line-height: 1; +} +.assets-page .assets-notification .notif-dismiss:hover { + color: #161616; +} + +/* ── Summary tiles ─────────────────────────────────── */ +.assets-summary-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +} + +@media (max-width: 900px) { + .assets-summary-row { + grid-template-columns: repeat(2, 1fr); + } +} + +.assets-summary-tile { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 4px; + border-top: 3px solid #0f62fe; + padding: 1.25rem 1.5rem 1rem; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + flex-direction: column; + gap: 0.25rem; + user-select: none; +} +.assets-summary-tile:hover { + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + border-color: #c6c6c6; +} +.assets-summary-tile.active { + background: #edf5ff; + border-color: #0f62fe; + border-top-color: #0f62fe; +} +.assets-summary-tile--total { + border-top-color: #161616; +} +.assets-summary-tile--nodes { + border-top-color: #0f62fe; +} +.assets-summary-tile--disks { + border-top-color: #009d9a; +} +.assets-summary-tile--lbs { + border-top-color: #8a3ffc; +} +.assets-summary-tile .tile-label { + font-size: 0.6875rem; + font-weight: 600; + color: #6f6f6f; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.assets-summary-tile .tile-value { + font-size: 1.75rem; + font-weight: 300; + color: #161616; + line-height: 1.2; + letter-spacing: -0.5px; +} +.assets-summary-tile .tile-sub { + font-size: 0.75rem; + color: #8d8d8d; + font-weight: 400; +} + +/* ── Filter tabs (custom, Grafana-style) ───────────── */ +.assets-filter-bar { + display: flex; + align-items: center; + gap: 0.375rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} +.assets-filter-btn { + background: #fff; + border: 1px solid #d0d5dd; + border-radius: 16px; + padding: 0.3125rem 0.875rem; + font-size: 0.8125rem; + font-weight: 500; + color: #525252; + cursor: pointer; + transition: all 0.12s ease; + font-family: inherit; + line-height: 1.4; + white-space: nowrap; +} +.assets-filter-btn:hover { + background: #f4f4f4; + border-color: #a8a8a8; + color: #161616; +} +.assets-filter-btn.active { + background: #0f62fe; + border-color: #0f62fe; + color: #fff; +} +.assets-filter-btn .filter-count { + display: inline-block; + background: rgba(0, 0, 0, 0.08); + border-radius: 10px; + padding: 0 0.375rem; + margin-left: 0.375rem; + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.5; + min-width: 1rem; + text-align: center; +} +.assets-filter-btn.active .filter-count { + background: rgba(255, 255, 255, 0.25); +} + +/* ── Table ─────────────────────────────────────────── */ +.assets-table-container { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 4px; + overflow: hidden; + margin-bottom: 2rem; +} + +/* Search bar above table */ +.assets-search-bar { + display: flex; + align-items: center; + padding: 0.625rem 1rem; + border-bottom: 1px solid #e0e0e0; + background: #fafafa; +} +.assets-search-bar input { + flex: 1; + border: none; + background: transparent; + font-size: 0.8125rem; + color: #161616; + outline: none; + padding: 0.375rem 0.5rem; + font-family: inherit; +} +.assets-search-bar input::placeholder { + color: #a8a8a8; +} +.assets-search-bar .search-icon { + color: #8d8d8d; + font-size: 1rem; + flex-shrink: 0; +} +.assets-search-bar .result-count { + font-size: 0.75rem; + color: #8d8d8d; + white-space: nowrap; + margin-left: 0.75rem; +} + +/* Custom clean table */ +.assets-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} +.assets-table thead { + position: sticky; + top: 0; + z-index: 1; +} +.assets-table th { + background: #f4f4f4; + text-align: left; + padding: 0.625rem 1rem; + font-weight: 600; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #525252; + border-bottom: 1px solid #e0e0e0; + cursor: pointer; + white-space: nowrap; + user-select: none; +} +.assets-table th:hover { + background: #e8e8e8; +} +.assets-table th .sort-indicator { + color: #a8a8a8; + margin-left: 4px; + font-size: 0.75rem; +} +.assets-table th.sorted .sort-indicator { + color: #0f62fe; +} +.assets-table th.col-expand { + width: 40px; + cursor: default; +} +.assets-table th.col-expand:hover { + background: #f4f4f4; +} +.assets-table th.col-cost { + text-align: right; +} + +.assets-table td { + padding: 0.75rem 1rem; + color: #161616; + border-bottom: 1px solid #f0f0f0; + vertical-align: middle; +} + +.assets-table tbody tr { + transition: background 0.1s; +} +.assets-table tbody tr:hover { + background: #f9f9f9; +} +.assets-table tbody tr.expanded { + background: #f4f4f4; +} + +/* Expand toggle */ +.assets-table .expand-btn { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: #6f6f6f; + font-size: 0.875rem; + transition: transform 0.15s; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 2px; +} +.assets-table .expand-btn:hover { + background: #e0e0e0; + color: #161616; +} +.assets-table .expand-btn.open { + transform: rotate(90deg); +} + +/* Asset name cell */ +.asset-name-cell { + font-weight: 500; + color: #161616; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Type badge */ +.type-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 10px; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.2px; + text-transform: capitalize; + white-space: nowrap; +} +.type-badge--Node { + background: #d0e2ff; + color: #0043ce; +} +.type-badge--Disk { + background: #d9fbfb; + color: #005d5d; +} +.type-badge--LoadBalancer { + background: #e8daff; + color: #6929c4; +} +.type-badge--Network { + background: #bae6ff; + color: #003a6d; +} +.type-badge--ClusterManagement { + background: #e8e8e8; + color: #393939; +} +.type-badge--Cloud { + background: #ffd6e8; + color: #9f1853; +} + +/* Cost cell */ +.cost-cell { + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; + display: block; + color: #161616; +} + +/* ── Expanded row detail ───────────────────────────── */ +.asset-expanded-row-content { + background: #fafafa; + border-bottom: 1px solid #e0e0e0; +} +.asset-expanded-detail { + padding: 1.25rem 1.5rem 1.25rem 3.5rem; +} + +.asset-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 2rem; +} + +.asset-detail-section h6 { + font-size: 0.625rem; + font-weight: 700; + color: #8d8d8d; + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 0.75rem; + padding-bottom: 0.375rem; + border-bottom: 1px solid #e0e0e0; +} + +.asset-detail-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.3125rem 0; + font-size: 0.8125rem; +} +.asset-detail-row .detail-label { + color: #6f6f6f; + font-weight: 400; +} +.asset-detail-row .detail-value { + font-weight: 500; + color: #161616; + font-variant-numeric: tabular-nums; +} + +/* ── Utilization bars ──────────────────────────────── */ +.utilization-bar-container { + margin-top: 0.375rem; +} +.utilization-bar { + height: 6px; + background: #e0e0e0; + border-radius: 3px; + overflow: hidden; + display: flex; +} +.utilization-bar .bar-segment { + height: 100%; + transition: width 0.3s ease; +} +.utilization-bar .bar-user { + background: #0f62fe; +} +.utilization-bar .bar-system { + background: #6929c4; +} +.utilization-bar .bar-idle { + background: #e0e0e0; +} + +.utilization-legend { + display: flex; + gap: 0.75rem; + margin-top: 0.375rem; + font-size: 0.6875rem; + color: #6f6f6f; +} +.utilization-legend span::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 4px; + vertical-align: middle; +} +.utilization-legend .legend-user::before { + background: #0f62fe; +} +.utilization-legend .legend-system::before { + background: #6929c4; +} +.utilization-legend .legend-idle::before { + background: #e0e0e0; +} + +/* ── Labels / tags ─────────────────────────────────── */ +.asset-labels-area { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.5rem; +} +.label-tag { + display: inline-block; + padding: 0.125rem 0.5rem; + background: #f4f4f4; + border: 1px solid #e0e0e0; + border-radius: 2px; + font-size: 0.6875rem; + color: #525252; + font-family: "IBM Plex Mono", monospace; + white-space: nowrap; +} + +/* ── Pagination ────────────────────────────────────── */ +.assets-pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625rem 1rem; + border-top: 1px solid #e0e0e0; + background: #fafafa; + font-size: 0.8125rem; + color: #525252; +} +.pagination-info { + font-size: 0.75rem; + color: #6f6f6f; +} +.pagination-controls { + display: flex; + align-items: center; + gap: 0.25rem; +} +.pagination-controls button { + background: #fff; + border: 1px solid #d0d5dd; + border-radius: 4px; + padding: 0.25rem 0.625rem; + font-size: 0.8125rem; + color: #161616; + cursor: pointer; + font-family: inherit; + min-width: 32px; + line-height: 1.5; +} +.pagination-controls button:hover:not(:disabled) { + background: #f4f4f4; + border-color: #a8a8a8; +} +.pagination-controls button:disabled { + opacity: 0.4; + cursor: default; +} +.pagination-controls button.active { + background: #0f62fe; + border-color: #0f62fe; + color: #fff; +} +.pagination-controls select { + border: 1px solid #d0d5dd; + border-radius: 4px; + padding: 0.25rem 0.5rem; + font-size: 0.8125rem; + color: #161616; + background: #fff; + font-family: inherit; + cursor: pointer; + margin-right: 0.5rem; +} + +/* ── Loading / empty states ────────────────────────── */ +.assets-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 6rem 0; + color: #6f6f6f; + font-size: 0.875rem; + gap: 0.75rem; +} +.loading-spinner { + width: 20px; + height: 20px; + border: 2px solid #e0e0e0; + border-top-color: #0f62fe; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.assets-empty { + text-align: center; + padding: 5rem 2rem; + color: #6f6f6f; +} +.assets-empty p { + font-size: 0.875rem; + max-width: 400px; + margin: 0 auto; +} diff --git a/src/pages/Assets.js b/src/pages/Assets.js new file mode 100644 index 00000000..0173d14e --- /dev/null +++ b/src/pages/Assets.js @@ -0,0 +1,198 @@ +import React, { useEffect, useState, useMemo, useRef } from "react"; +import { useLocation, useNavigate } from "react-router"; + +import Page from "../components/Page"; +import Header from "../components/Header"; +import Footer from "../components/Footer"; +import AssetsSummaryTiles from "../components/assets/AssetsSummaryTiles"; +import AssetsTable from "../components/assets/AssetsTable"; +import { windowOptions, assetTypeTabs } from "../components/assets/tokens"; +import AssetsService from "../services/assets"; +import "../css/assets.css"; + +const Assets = React.memo(() => { + const routerLocation = useLocation(); + const navigate = useNavigate(); + const searchParams = new URLSearchParams(routerLocation.search); + + // URL-driven state + const windowParam = searchParams.get("window") || windowOptions[0].value; + const tabParam = searchParams.get("tab") || "all"; + + // Component state + const [assets, setAssets] = useState([]); + const [loading, setLoading] = useState(true); + const [isMock, setIsMock] = useState(false); + const [currency] = useState("USD"); + const mounted = useRef(true); + + // Update URL params without full re-render + const updateParams = (updates) => { + const params = new URLSearchParams(routerLocation.search); + Object.entries(updates).forEach(([k, v]) => { + if (v === null || v === undefined) { + params.delete(k); + } else { + params.set(k, v); + } + }); + navigate(`${routerLocation.pathname}?${params.toString()}`, { + replace: true, + }); + }; + + // Fetch assets when window changes + useEffect(() => { + mounted.current = true; + + async function fetchData() { + setLoading(true); + try { + const result = await AssetsService.fetchAssets(windowParam); + if (!mounted.current) return; + + // The API returns { code, data: [ { assetKey: assetObj, ... } ] } + // Convert the flat object into an array with _id set from the key + const raw = result.data; + let parsed = []; + if (raw && raw.data && Array.isArray(raw.data) && raw.data.length > 0) { + const assetMap = raw.data[0]; + parsed = Object.entries(assetMap).map(([key, asset]) => ({ + ...asset, + _id: key, + })); + } + + setAssets(parsed); + setIsMock(result.isMock); + } catch (err) { + if (!mounted.current) return; + console.error("Failed to fetch assets:", err); + setAssets([]); + } finally { + if (mounted.current) setLoading(false); + } + } + + fetchData(); + + return () => { + mounted.current = false; + }; + }, [windowParam]); + + // Filter assets by the active tab + const filteredAssets = useMemo(() => { + if (!tabParam || tabParam === "all") return assets; + return assets.filter((a) => a.type === tabParam); + }, [assets, tabParam]); + + // Count assets by type for filter buttons + const typeCounts = useMemo(() => { + const counts = { all: assets.length }; + assetTypeTabs.forEach((t) => { + if (t.key !== "all") { + counts[t.key] = assets.filter((a) => a.type === t.key).length; + } + }); + return counts; + }, [assets]); + + // Find selected window option for dropdown + const selectedWindow = useMemo( + () => + windowOptions.find((w) => w.value === windowParam) || windowOptions[0], + [windowParam], + ); + + const [showNotif, setShowNotif] = useState(true); + + return ( + +
+ +
+ +
+ {isMock && showNotif && ( +
+ + + Demo mode + Unable to reach the OpenCost API. Displaying sample data. + + +
+ )} + + {loading ? ( +
+
+ Loading assets... +
+ ) : assets.length === 0 ? ( +
+

No asset data available for the selected window.

+
+ ) : ( + <> + updateParams({ tab: key })} + /> + + {/* Filter pills — Grafana style */} +
+ {assetTypeTabs + .filter((t) => t.key === "all" || (typeCounts[t.key] || 0) > 0) + .map((t) => ( + + ))} +
+ + + + )} +
+ +
+ + ); +}); + +export default Assets; diff --git a/src/route.js b/src/route.js index 919717b2..d80f83c2 100644 --- a/src/route.js +++ b/src/route.js @@ -4,6 +4,7 @@ import { LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; import Allocations from "./pages/Allocations.js"; +import Assets from "./pages/Assets.js"; import CloudCosts from "./pages/CloudCosts.js"; import ExternalCosts from "./pages/ExternalCosts.js"; @@ -16,6 +17,7 @@ const RouteSet = () => { } /> } /> + } /> } /> } /> diff --git a/src/services/assets.js b/src/services/assets.js new file mode 100644 index 00000000..46806799 --- /dev/null +++ b/src/services/assets.js @@ -0,0 +1,298 @@ +import client from "./api_client"; + +/** + * Mock asset data matching the real /assets API response format. + * Covers all 6 asset types: Node, Disk, LoadBalancer, Network, Cloud, ClusterManagement. + * Structure mirrors: https://www.opencost.io/docs/integrations/api-examples#assets-example + */ +function getMockAssets() { + return { + "GCP/__undefined__/demo-project/Compute/demo-cluster/Node/Kubernetes/gke-demo-pool-a1b2c3d4-x1y2/gke-demo-pool-a1b2c3d4-x1y2": + { + type: "Node", + properties: { + category: "Compute", + provider: "GCP", + project: "demo-project", + service: "Kubernetes", + cluster: "demo-cluster", + name: "gke-demo-pool-a1b2c3d4-x1y2", + providerID: "gke-demo-pool-a1b2c3d4-x1y2", + }, + labels: { + kubernetes_io_arch: "amd64", + kubernetes_io_os: "linux", + node_kubernetes_io_instance_type: "e2-medium", + topology_kubernetes_io_region: "us-central1", + topology_kubernetes_io_zone: "us-central1-a", + }, + window: { start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z" }, + start: "2026-02-01T00:00:00Z", + end: "2026-02-08T00:00:00Z", + minutes: 10080, + nodeType: "e2-medium", + cpuCores: 2, + ramBytes: 4294967296, + cpuCoreHours: 336, + ramByteHours: 1441151880806.4, + GPUHours: 0, + cpuBreakdown: { idle: 0.62, other: 0.03, system: 0.12, user: 0.23 }, + ramBreakdown: { idle: 0.45, other: 0.05, system: 0.18, user: 0.32 }, + preemptible: 0, + discount: 0.3, + cpuCost: 8.42, + gpuCost: 0, + gpuCount: 0, + ramCost: 4.18, + adjustment: -0.25, + totalCost: 12.35, + }, + "GCP/__undefined__/demo-project/Compute/demo-cluster/Node/Kubernetes/gke-demo-pool-e5f6g7h8-a3b4/gke-demo-pool-e5f6g7h8-a3b4": + { + type: "Node", + properties: { + category: "Compute", + provider: "GCP", + project: "demo-project", + service: "Kubernetes", + cluster: "demo-cluster", + name: "gke-demo-pool-e5f6g7h8-a3b4", + providerID: "gke-demo-pool-e5f6g7h8-a3b4", + }, + labels: { + kubernetes_io_arch: "amd64", + kubernetes_io_os: "linux", + node_kubernetes_io_instance_type: "e2-standard-4", + topology_kubernetes_io_region: "us-central1", + topology_kubernetes_io_zone: "us-central1-b", + }, + window: { start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z" }, + start: "2026-02-01T00:00:00Z", + end: "2026-02-08T00:00:00Z", + minutes: 10080, + nodeType: "e2-standard-4", + cpuCores: 4, + ramBytes: 17179869184, + cpuCoreHours: 672, + ramByteHours: 5764607523034.112, + GPUHours: 0, + cpuBreakdown: { idle: 0.35, other: 0.05, system: 0.15, user: 0.45 }, + ramBreakdown: { idle: 0.28, other: 0.02, system: 0.2, user: 0.5 }, + preemptible: 0, + discount: 0.3, + cpuCost: 16.84, + gpuCost: 0, + gpuCount: 0, + ramCost: 16.72, + adjustment: -0.5, + totalCost: 33.06, + }, + "AWS/__undefined__/prod-account/Compute/prod-cluster/Node/Kubernetes/ip-10-0-1-42.ec2.internal/ip-10-0-1-42.ec2.internal": + { + type: "Node", + properties: { + category: "Compute", + provider: "AWS", + project: "prod-account", + service: "Kubernetes", + cluster: "prod-cluster", + name: "ip-10-0-1-42.ec2.internal", + providerID: "i-0abc123def456789a", + }, + labels: { + kubernetes_io_arch: "amd64", + kubernetes_io_os: "linux", + node_kubernetes_io_instance_type: "m5.xlarge", + topology_kubernetes_io_region: "us-east-1", + topology_kubernetes_io_zone: "us-east-1a", + }, + window: { start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z" }, + start: "2026-02-01T00:00:00Z", + end: "2026-02-08T00:00:00Z", + minutes: 10080, + nodeType: "m5.xlarge", + cpuCores: 4, + ramBytes: 17179869184, + cpuCoreHours: 672, + ramByteHours: 5764607523034.112, + GPUHours: 0, + cpuBreakdown: { idle: 0.2, other: 0.05, system: 0.1, user: 0.65 }, + ramBreakdown: { idle: 0.15, other: 0.05, system: 0.1, user: 0.7 }, + preemptible: 0, + discount: 0, + cpuCost: 22.68, + gpuCost: 0, + gpuCount: 0, + ramCost: 18.14, + adjustment: 0, + totalCost: 40.82, + }, + "GCP/__undefined__/demo-project/Storage/demo-cluster/Disk/Kubernetes/pvc-abc123/pvc-abc123": + { + type: "Disk", + properties: { + category: "Storage", + provider: "GCP", + project: "demo-project", + service: "Kubernetes", + cluster: "demo-cluster", + name: "pvc-abc123", + providerID: "pvc-abc123", + }, + labels: {}, + window: { start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z" }, + start: "2026-02-01T00:00:00Z", + end: "2026-02-08T00:00:00Z", + minutes: 10080, + byteHours: 1443109011456, + bytes: 10737418240, + byteHoursUsed: 432932703436.8, + byteUsageMax: 3221225472, + breakdown: { idle: 0.7, other: 0, system: 0.1, user: 0.2 }, + adjustment: 0, + totalCost: 1.47, + storageClass: "standard-rwo", + volumeName: "pvc-abc123", + claimName: "prometheus-server", + claimNamespace: "prometheus-system", + }, + "GCP/__undefined__/demo-project/Storage/demo-cluster/Disk/Kubernetes/pvc-def456/pvc-def456": + { + type: "Disk", + properties: { + category: "Storage", + provider: "GCP", + project: "demo-project", + service: "Kubernetes", + cluster: "demo-cluster", + name: "pvc-def456", + providerID: "pvc-def456", + }, + labels: {}, + window: { start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z" }, + start: "2026-02-01T00:00:00Z", + end: "2026-02-08T00:00:00Z", + minutes: 10080, + byteHours: 5772436045824, + bytes: 53687091200, + byteHoursUsed: 2886218022912, + byteUsageMax: 26843545600, + breakdown: { idle: 0.5, other: 0, system: 0, user: 0.5 }, + adjustment: 0, + totalCost: 5.88, + storageClass: "pd-ssd", + volumeName: "pvc-def456", + claimName: "data-postgres-0", + claimNamespace: "database", + }, + "AWS/__undefined__/prod-account/Network/prod-cluster/LoadBalancer/Kubernetes/ab1234-elb/ab1234-elb": + { + type: "LoadBalancer", + properties: { + category: "Network", + provider: "AWS", + project: "prod-account", + service: "Kubernetes", + cluster: "prod-cluster", + name: "ab1234-elb", + providerID: "ab1234-elb", + }, + labels: {}, + window: { start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z" }, + start: "2026-02-01T00:00:00Z", + end: "2026-02-08T00:00:00Z", + minutes: 10080, + adjustment: 0, + totalCost: 18.14, + private: false, + ip: "52.14.23.189", + }, + "GCP/__undefined__/demo-project/Network/demo-cluster/Network/Kubernetes/__unmounted__/__unmounted__": + { + type: "Network", + properties: { + category: "Network", + provider: "GCP", + project: "demo-project", + service: "Kubernetes", + cluster: "demo-cluster", + name: "__unmounted__", + providerID: "__unmounted__", + }, + labels: {}, + window: { start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z" }, + start: "2026-02-01T00:00:00Z", + end: "2026-02-08T00:00:00Z", + minutes: 10080, + adjustment: 0, + totalCost: 2.34, + }, + "GCP/__undefined__/demo-project/Management/demo-cluster/ClusterManagement/Kubernetes/demo-cluster/demo-cluster": + { + type: "ClusterManagement", + properties: { + category: "Management", + provider: "GCP", + project: "demo-project", + service: "Kubernetes", + cluster: "demo-cluster", + name: "demo-cluster", + providerID: "demo-cluster", + }, + labels: {}, + window: { start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z" }, + start: "2026-02-01T00:00:00Z", + end: "2026-02-08T00:00:00Z", + minutes: 10080, + adjustment: 0, + totalCost: 24.36, + }, + "AWS/__undefined__/prod-account/Other/prod-cluster/Cloud/AWS/nat-gateway-prod/nat-gateway-prod": + { + type: "Cloud", + properties: { + category: "Other", + provider: "AWS", + project: "prod-account", + service: "AWS", + cluster: "prod-cluster", + name: "nat-gateway-prod", + providerID: "nat-gateway-prod", + }, + labels: {}, + window: { start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z" }, + start: "2026-02-01T00:00:00Z", + end: "2026-02-08T00:00:00Z", + minutes: 10080, + adjustment: 0, + totalCost: 6.72, + }, + }; +} + +class AssetsService { + /** + * Fetches asset data from the OpenCost API. + * @param {string} win - Time window (e.g., "7d", "today", "lastweek") + * @returns {Promise<{data: object, isMock: boolean}>} + */ + async fetchAssets(win) { + const params = { window: win }; + + try { + const result = await client.get("/model/assets", { params }); + return { data: result.data, isMock: false }; + } catch (error) { + console.warn( + "Assets API unavailable, using mock data for development:", + error.message, + ); + return { + data: { code: 200, data: [getMockAssets()] }, + isMock: true, + }; + } + } +} + +export default new AssetsService(); From fc303f6f990ebd6d53fc33b5533be2db003d4642 Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Sun, 8 Feb 2026 23:53:54 +0530 Subject: [PATCH 02/19] feat: enhance asset details and summary tiles with additional metrics and styling Signed-off-by: Abhishek-Punhani --- src/components/assets/AssetsSummaryTiles.js | 88 +++++--- src/components/assets/AssetsTable.js | 216 +++++++++++++++++++- src/css/assets.css | 11 +- src/services/assets.js | 27 +++ 4 files changed, 307 insertions(+), 35 deletions(-) diff --git a/src/components/assets/AssetsSummaryTiles.js b/src/components/assets/AssetsSummaryTiles.js index 5229ec78..bccb4dfc 100644 --- a/src/components/assets/AssetsSummaryTiles.js +++ b/src/components/assets/AssetsSummaryTiles.js @@ -13,54 +13,78 @@ import { toCurrency } from "../../util"; */ const AssetsSummaryTiles = ({ assets, currency, activeTab, onTabChange }) => { const totalCost = assets.reduce((sum, a) => sum + (a.totalCost || 0), 0); - const nodeCount = assets.filter((a) => a.type === "Node").length; - const diskCount = assets.filter((a) => a.type === "Disk").length; - const lbCount = assets.filter((a) => a.type === "LoadBalancer").length; - const tiles = [ - { - key: "all", - label: "Total Cost", - value: toCurrency(totalCost, currency), - sub: `${assets.length} assets`, - className: "assets-summary-tile--total", - }, + // Count and cost per type + const typeSummary = (type) => { + const items = assets.filter((a) => a.type === type); + return { + count: items.length, + cost: items.reduce((s, a) => s + (a.totalCost || 0), 0), + }; + }; + + const nodes = typeSummary("Node"); + const disks = typeSummary("Disk"); + const lbs = typeSummary("LoadBalancer"); + const networks = typeSummary("Network"); + const mgmt = typeSummary("ClusterManagement"); + const cloud = typeSummary("Cloud"); + + // Only include type tiles that have assets + const typeTiles = [ { key: "Node", label: "Nodes", - value: nodeCount, - sub: toCurrency( - assets - .filter((a) => a.type === "Node") - .reduce((s, a) => s + a.totalCost, 0), - currency, - ), + data: nodes, className: "assets-summary-tile--nodes", }, { key: "Disk", label: "Disks", - value: diskCount, - sub: toCurrency( - assets - .filter((a) => a.type === "Disk") - .reduce((s, a) => s + a.totalCost, 0), - currency, - ), + data: disks, className: "assets-summary-tile--disks", }, { key: "LoadBalancer", label: "Load Balancers", - value: lbCount, - sub: toCurrency( - assets - .filter((a) => a.type === "LoadBalancer") - .reduce((s, a) => s + a.totalCost, 0), - currency, - ), + data: lbs, className: "assets-summary-tile--lbs", }, + { + key: "Network", + label: "Network", + data: networks, + className: "assets-summary-tile--network", + }, + { + key: "ClusterManagement", + label: "Management", + data: mgmt, + className: "assets-summary-tile--mgmt", + }, + { + key: "Cloud", + label: "Cloud", + data: cloud, + className: "assets-summary-tile--cloud", + }, + ].filter((t) => t.data.count > 0); + + const tiles = [ + { + key: "all", + label: "Total Cost", + value: toCurrency(totalCost, currency), + sub: `${assets.length} assets`, + className: "assets-summary-tile--total", + }, + ...typeTiles.map((t) => ({ + key: t.key, + label: t.label, + value: t.data.count, + sub: toCurrency(t.data.cost, currency), + className: t.className, + })), ]; return ( diff --git a/src/components/assets/AssetsTable.js b/src/components/assets/AssetsTable.js index f06a7df5..ce19824e 100644 --- a/src/components/assets/AssetsTable.js +++ b/src/components/assets/AssetsTable.js @@ -45,6 +45,12 @@ const NodeDetail = ({ asset, currency }) => ( Instance Type {asset.nodeType || "—"}
+ {asset.pool && ( +
+ Node Pool + {asset.pool} +
+ )}
CPU Cores {asset.cpuCores || 0} @@ -65,6 +71,12 @@ const NodeDetail = ({ asset, currency }) => ( Yes
)} + {asset.properties?.account && ( +
+ Account + {asset.properties.account} +
+ )}
Cost Breakdown
@@ -110,7 +122,38 @@ const NodeDetail = ({ asset, currency }) => (
+ {asset.overhead && ( +
+
+ CPU Overhead + + {Math.round((asset.overhead.cpuOverheadFraction || 0) * 100)}% + +
+
+ RAM Overhead + + {Math.round((asset.overhead.ramOverheadFraction || 0) * 100)}% + +
+
+ Overhead Cost + + {Math.round((asset.overhead.overheadCostFraction || 0) * 100)}% + +
+
+ )}
+ {asset.minutes > 0 && ( +
+
Time Coverage
+
+ Duration + {formatMinutes(asset.minutes)} +
+
+ )}
); @@ -133,6 +176,12 @@ const DiskDetail = ({ asset, currency }) => ( Peak Usage {formatBytes(asset.byteUsageMax)}
+ {asset.volumeName && ( +
+ Volume Name + {asset.volumeName} +
+ )} {asset.claimName && (
PVC Claim @@ -141,18 +190,50 @@ const DiskDetail = ({ asset, currency }) => (
)} + {asset.local > 0 && ( +
+ Local Disk + Yes +
+ )} +
+
+
Cost
+
+ Total Cost + + {toCurrency(asset.totalCost, currency)} + +
+ {asset.adjustment !== 0 && asset.adjustment !== undefined && ( +
+ Adjustment + + {toCurrency(asset.adjustment, currency)} + +
+ )}
Utilization
+ {asset.minutes > 0 && ( +
+
Time Coverage
+
+ Duration + {formatMinutes(asset.minutes)} +
+
+ )}
); /** * Renders the expanded detail content for a LoadBalancer asset. */ -const LoadBalancerDetail = ({ asset }) => ( +const LoadBalancerDetail = ({ asset, currency }) => (
Load Balancer Details
@@ -169,11 +250,102 @@ const LoadBalancerDetail = ({ asset }) => (
+
+
Cost
+
+ Total Cost + + {toCurrency(asset.totalCost, currency)} + +
+ {asset.adjustment !== 0 && asset.adjustment !== undefined && ( +
+ Adjustment + + {toCurrency(asset.adjustment, currency)} + +
+ )} +
+ {asset.minutes > 0 && ( +
+
Time Coverage
+
+ Duration + {formatMinutes(asset.minutes)} +
+
+ )} ); /** - * Generic detail for Network, Cloud, and ClusterManagement types. + * Renders the expanded detail content for a Cloud asset. + */ +const CloudDetail = ({ asset, currency }) => ( +
+
+
Cloud Details
+
+ Provider + + {asset.properties?.provider || "—"} + +
+
+ Service + {asset.properties?.service || "—"} +
+
+ Project + {asset.properties?.project || "—"} +
+ {asset.properties?.account && ( +
+ Account + {asset.properties.account} +
+ )} +
+
+
Cost
+
+ Total Cost + + {toCurrency(asset.totalCost, currency)} + +
+ {asset.credit !== 0 && asset.credit !== undefined && ( +
+ Credit + + {toCurrency(asset.credit, currency)} + +
+ )} + {asset.adjustment !== 0 && asset.adjustment !== undefined && ( +
+ Adjustment + + {toCurrency(asset.adjustment, currency)} + +
+ )} +
+ {asset.minutes > 0 && ( +
+
Time Coverage
+
+ Duration + {formatMinutes(asset.minutes)} +
+
+ )} +
+); + +/** + * Generic detail for Network and ClusterManagement types. */ const GenericDetail = ({ asset, currency }) => (
@@ -193,13 +365,39 @@ const GenericDetail = ({ asset, currency }) => ( Project {asset.properties?.project || "—"}
+ {asset.properties?.account && ( +
+ Account + {asset.properties.account} +
+ )} + +
+
Cost
Total Cost {toCurrency(asset.totalCost, currency)}
+ {asset.adjustment !== 0 && asset.adjustment !== undefined && ( +
+ Adjustment + + {toCurrency(asset.adjustment, currency)} + +
+ )}
+ {asset.minutes > 0 && ( +
+
Time Coverage
+
+ Duration + {formatMinutes(asset.minutes)} +
+
+ )} {asset.labels && Object.keys(asset.labels).length > 0 && (
Labels
@@ -230,6 +428,7 @@ const AssetExpandedDetail = ({ asset, currency }) => { Node: NodeDetail, Disk: DiskDetail, LoadBalancer: LoadBalancerDetail, + Cloud: CloudDetail, }; const DetailComponent = detailMap[asset.type] || GenericDetail; @@ -290,6 +489,19 @@ function formatBytes(bytes) { return `${val.toFixed(val >= 100 ? 0 : val >= 10 ? 1 : 2)} ${units[i]}`; } +/** + * Format minutes to human readable (e.g. "7d 0h", "23h 45m"). + */ +function formatMinutes(mins) { + if (!mins || mins <= 0) return "—"; + const days = Math.floor(mins / 1440); + const hours = Math.floor((mins % 1440) / 60); + const minutes = Math.round(mins % 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + /** * AssetsTable — clean custom table with expandable rows, search, sort, pagination. * diff --git a/src/css/assets.css b/src/css/assets.css index d7bd99be..eff0162b 100644 --- a/src/css/assets.css +++ b/src/css/assets.css @@ -50,7 +50,7 @@ /* ── Summary tiles ─────────────────────────────────── */ .assets-summary-row { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } @@ -95,6 +95,15 @@ .assets-summary-tile--lbs { border-top-color: #8a3ffc; } +.assets-summary-tile--network { + border-top-color: #1192e8; +} +.assets-summary-tile--mgmt { + border-top-color: #8d8d8d; +} +.assets-summary-tile--cloud { + border-top-color: #d12771; +} .assets-summary-tile .tile-label { font-size: 0.6875rem; font-weight: 600; diff --git a/src/services/assets.js b/src/services/assets.js index 46806799..9fc95e93 100644 --- a/src/services/assets.js +++ b/src/services/assets.js @@ -13,6 +13,7 @@ function getMockAssets() { properties: { category: "Compute", provider: "GCP", + account: "demo-gcp-account", project: "demo-project", service: "Kubernetes", cluster: "demo-cluster", @@ -25,12 +26,14 @@ function getMockAssets() { node_kubernetes_io_instance_type: "e2-medium", topology_kubernetes_io_region: "us-central1", topology_kubernetes_io_zone: "us-central1-a", + cloud_google_com_gke_nodepool: "demo-pool", }, window: { start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z" }, start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z", minutes: 10080, nodeType: "e2-medium", + pool: "demo-pool", cpuCores: 2, ramBytes: 4294967296, cpuCoreHours: 336, @@ -46,6 +49,11 @@ function getMockAssets() { ramCost: 4.18, adjustment: -0.25, totalCost: 12.35, + overhead: { + cpuOverheadFraction: 0.06, + ramOverheadFraction: 0.09, + overheadCostFraction: 0.075, + }, }, "GCP/__undefined__/demo-project/Compute/demo-cluster/Node/Kubernetes/gke-demo-pool-e5f6g7h8-a3b4/gke-demo-pool-e5f6g7h8-a3b4": { @@ -53,6 +61,7 @@ function getMockAssets() { properties: { category: "Compute", provider: "GCP", + account: "demo-gcp-account", project: "demo-project", service: "Kubernetes", cluster: "demo-cluster", @@ -65,12 +74,14 @@ function getMockAssets() { node_kubernetes_io_instance_type: "e2-standard-4", topology_kubernetes_io_region: "us-central1", topology_kubernetes_io_zone: "us-central1-b", + cloud_google_com_gke_nodepool: "demo-pool", }, window: { start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z" }, start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z", minutes: 10080, nodeType: "e2-standard-4", + pool: "demo-pool", cpuCores: 4, ramBytes: 17179869184, cpuCoreHours: 672, @@ -86,6 +97,11 @@ function getMockAssets() { ramCost: 16.72, adjustment: -0.5, totalCost: 33.06, + overhead: { + cpuOverheadFraction: 0.04, + ramOverheadFraction: 0.07, + overheadCostFraction: 0.055, + }, }, "AWS/__undefined__/prod-account/Compute/prod-cluster/Node/Kubernetes/ip-10-0-1-42.ec2.internal/ip-10-0-1-42.ec2.internal": { @@ -93,6 +109,7 @@ function getMockAssets() { properties: { category: "Compute", provider: "AWS", + account: "prod-account", project: "prod-account", service: "Kubernetes", cluster: "prod-cluster", @@ -105,12 +122,14 @@ function getMockAssets() { node_kubernetes_io_instance_type: "m5.xlarge", topology_kubernetes_io_region: "us-east-1", topology_kubernetes_io_zone: "us-east-1a", + eks_amazonaws_com_nodegroup: "prod-workers", }, window: { start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z" }, start: "2026-02-01T00:00:00Z", end: "2026-02-08T00:00:00Z", minutes: 10080, nodeType: "m5.xlarge", + pool: "prod-workers", cpuCores: 4, ramBytes: 17179869184, cpuCoreHours: 672, @@ -126,6 +145,11 @@ function getMockAssets() { ramCost: 18.14, adjustment: 0, totalCost: 40.82, + overhead: { + cpuOverheadFraction: 0.05, + ramOverheadFraction: 0.08, + overheadCostFraction: 0.065, + }, }, "GCP/__undefined__/demo-project/Storage/demo-cluster/Disk/Kubernetes/pvc-abc123/pvc-abc123": { @@ -155,6 +179,7 @@ function getMockAssets() { volumeName: "pvc-abc123", claimName: "prometheus-server", claimNamespace: "prometheus-system", + local: 0, }, "GCP/__undefined__/demo-project/Storage/demo-cluster/Disk/Kubernetes/pvc-def456/pvc-def456": { @@ -184,6 +209,7 @@ function getMockAssets() { volumeName: "pvc-def456", claimName: "data-postgres-0", claimNamespace: "database", + local: 0, }, "AWS/__undefined__/prod-account/Network/prod-cluster/LoadBalancer/Kubernetes/ab1234-elb/ab1234-elb": { @@ -265,6 +291,7 @@ function getMockAssets() { end: "2026-02-08T00:00:00Z", minutes: 10080, adjustment: 0, + credit: -1.25, totalCost: 6.72, }, }; From 94bf7b18ea224b6592b062255cb64b658f60fb72 Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Mon, 9 Feb 2026 00:17:26 +0530 Subject: [PATCH 03/19] feat(assets): enhance billing dashboard with auto-refresh and cost chart - Updated assets.css for a cleaner billing-dashboard design, including new header controls and cost tiles. - Implemented auto-refresh functionality in Assets.js, allowing assets to refresh every 60 seconds. - Added AssetCostChart component to visualize costs over time. - Improved UI elements such as dropdowns and buttons for better user experience. - Introduced last updated timestamp with auto-refresh indication. - Updated routing to include asset detail view. Signed-off-by: Abhishek-Punhani --- src/components/assets/AssetCostChart.js | 319 ++++++++ src/components/assets/AssetDetail.js | 607 +++++++++++++++ src/components/assets/AssetsSummaryTiles.js | 128 ++-- src/components/assets/AssetsTable.js | 807 +++++--------------- src/css/assets.css | 766 +++++++++++++------ src/pages/Assets.js | 125 +-- src/route.js | 2 + 7 files changed, 1794 insertions(+), 960 deletions(-) create mode 100644 src/components/assets/AssetCostChart.js create mode 100644 src/components/assets/AssetDetail.js diff --git a/src/components/assets/AssetCostChart.js b/src/components/assets/AssetCostChart.js new file mode 100644 index 00000000..12bf4f50 --- /dev/null +++ b/src/components/assets/AssetCostChart.js @@ -0,0 +1,319 @@ +import React, { useState, useMemo } from "react"; +import { + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { toCurrency } from "../../util"; + +/** + * Generates synthetic daily cost breakdown from aggregated asset data. + * When real per-day data is available from the API, swap this out. + */ +function generateDailyBreakdown(assets, windowStr) { + const windowDays = { + today: 1, + yesterday: 1, + "24h": 1, + "48h": 2, + week: 7, + lastweek: 7, + "7d": 7, + "14d": 14, + }; + + const days = windowDays[windowStr] || 7; + const now = new Date(); + + // Aggregate costs by type + const typeCosts = {}; + let totalCost = 0; + assets.forEach((a) => { + const type = a.type || "Other"; + typeCosts[type] = (typeCosts[type] || 0) + (a.totalCost || 0); + totalCost += a.totalCost || 0; + }); + + const dailyCost = totalCost / days; + const types = Object.keys(typeCosts); + + // Generate daily data points + const data = []; + for (let i = days - 1; i >= 0; i--) { + const date = new Date(now); + date.setDate(date.getDate() - i); + date.setHours(0, 0, 0, 0); + + const point = { + date: date.toISOString(), + dateLabel: date.toLocaleDateString(navigator.language, { + month: "short", + day: "numeric", + timeZone: "UTC", + }), + }; + + // Distribute cost per type with slight daily variance + let dayTotal = 0; + types.forEach((type) => { + const typeDailyCost = typeCosts[type] / days; + // Apply a small deterministic variance based on day index + const variance = 1 + Math.sin(i * 1.5 + types.indexOf(type)) * 0.15; + const cost = Math.max(0, typeDailyCost * variance); + point[type] = Number(cost.toFixed(4)); + dayTotal += point[type]; + }); + point.total = Number(dayTotal.toFixed(4)); + data.push(point); + } + + return { data, types }; +} + +/** + * Compute cumulative totals from daily data. + */ +function toCumulative(data, types) { + const running = {}; + types.forEach((t) => (running[t] = 0)); + + return data.map((d) => { + const point = { ...d }; + types.forEach((t) => { + running[t] += d[t] || 0; + point[t] = Number(running[t].toFixed(4)); + }); + point.total = types.reduce((sum, t) => sum + point[t], 0); + return point; + }); +} + +const TYPE_COLORS = { + Node: "#0f62fe", + Disk: "#009d9a", + LoadBalancer: "#8a3ffc", + Network: "#1192e8", + ClusterManagement: "#8d8d8d", + Cloud: "#d12771", +}; + +const CustomTooltip = ({ active, payload, label, currency, cumulative }) => { + if (!active || !payload?.length) return null; + + const total = payload.reduce((sum, p) => sum + (p.value || 0), 0); + + return ( +
+
{label}
+
+ {cumulative ? "Cumulative: " : "Daily: "} + {toCurrency(total, currency)} +
+
+ {payload + .filter((p) => p.value > 0) + .sort((a, b) => b.value - a.value) + .map((p) => ( +
+ + {p.name} + + {toCurrency(p.value, currency)} + +
+ ))} +
+
+ ); +}; + +/** + * AssetCostChart — stacked area/bar chart showing cost over time. + * + * Props: + * - assets: array of asset objects + * - currency: ISO currency code + * - windowStr: current window string (e.g. "7d") + */ +const AssetCostChart = ({ assets, currency, windowStr }) => { + const [cumulative, setCumulative] = useState(false); + const [chartType, setChartType] = useState("area"); // "area" or "bar" + + const { data: dailyData, types } = useMemo( + () => generateDailyBreakdown(assets, windowStr), + [assets, windowStr], + ); + + const chartData = useMemo( + () => (cumulative ? toCumulative(dailyData, types) : dailyData), + [dailyData, types, cumulative], + ); + + if (!assets.length) return null; + + return ( +
+
+

Cost Over Time

+
+ +
+ + +
+
+
+
+ + {chartType === "area" ? ( + + + {types.map((type) => ( + + + + + ))} + + + + `$${v.toFixed(0)}`} + width={60} + /> + + } + /> + + {types.map((type) => ( + + ))} + + ) : ( + + + + `$${v.toFixed(0)}`} + width={60} + /> + + } + /> + + {types.map((type) => ( + + ))} + + )} + +
+
+ ); +}; + +export default AssetCostChart; diff --git a/src/components/assets/AssetDetail.js b/src/components/assets/AssetDetail.js new file mode 100644 index 00000000..cca1c9da --- /dev/null +++ b/src/components/assets/AssetDetail.js @@ -0,0 +1,607 @@ +import React, { useMemo } from "react"; +import { useLocation, useNavigate } from "react-router"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + PieChart, + Pie, + Cell, + Legend, +} from "recharts"; +import { toCurrency } from "../../util"; +import Page from "../Page"; +import Header from "../Header"; +import Footer from "../Footer"; +import "../../css/assets.css"; + +const TYPE_COLORS = { + Node: "#0f62fe", + Disk: "#009d9a", + LoadBalancer: "#8a3ffc", + Network: "#1192e8", + ClusterManagement: "#8d8d8d", + Cloud: "#d12771", +}; + +const COST_COLORS = { + cpuCost: "#0f62fe", + ramCost: "#009d9a", + gpuCost: "#8a3ffc", + storageCost: "#1192e8", + networkCost: "#ee5396", + lbCost: "#6929c4", + adjustment: "#fa4d56", + credit: "#42be65", +}; + +function formatBytes(bytes) { + if (!bytes || bytes === 0) return "0 B"; + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + let i = 0; + let val = bytes; + while (val >= 1024 && i < units.length - 1) { + val /= 1024; + i++; + } + return `${val.toFixed(val >= 100 ? 0 : val >= 10 ? 1 : 2)} ${units[i]}`; +} + +function formatMinutes(mins) { + if (!mins || mins <= 0) return "—"; + const days = Math.floor(mins / 1440); + const hours = Math.floor((mins % 1440) / 60); + const minutes = Math.round(mins % 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +/** + * UtilizationBar component for the detail view. + */ +const UtilizationBar = ({ breakdown, label }) => { + if (!breakdown) return null; + const user = Math.round((breakdown.user || 0) * 100); + const system = Math.round((breakdown.system || 0) * 100); + const idle = 100 - user - system; + + return ( +
+
+ {label} + {user + system}% +
+
+
+
+
+
+
+ User {user}% + System {system}% + Idle {idle}% +
+
+ ); +}; + +/** + * Cost breakdown donut chart for the asset detail page. + */ +const CostDonut = ({ asset, currency }) => { + const slices = []; + + if (asset.cpuCost > 0) + slices.push({ + name: "CPU", + value: asset.cpuCost, + fill: COST_COLORS.cpuCost, + }); + if (asset.ramCost > 0) + slices.push({ + name: "RAM", + value: asset.ramCost, + fill: COST_COLORS.ramCost, + }); + if (asset.gpuCost > 0) + slices.push({ + name: "GPU", + value: asset.gpuCost, + fill: COST_COLORS.gpuCost, + }); + if (asset.type === "Disk" && asset.totalCost > 0) + slices.push({ + name: "Storage", + value: asset.totalCost, + fill: COST_COLORS.storageCost, + }); + if (asset.type === "LoadBalancer" && asset.totalCost > 0) + slices.push({ + name: "LB", + value: asset.totalCost, + fill: COST_COLORS.lbCost, + }); + + // For generic types, just show total + if (slices.length === 0 && asset.totalCost > 0) { + slices.push({ + name: asset.type, + value: asset.totalCost, + fill: TYPE_COLORS[asset.type] || "#6f6f6f", + }); + } + + if (slices.length === 0) return null; + + return ( +
+ + + + {slices.map((s, i) => ( + + ))} + + ( + {name} + )} + /> + toCurrency(val, currency)} + contentStyle={{ + background: "#fff", + border: "1px solid #e0e0e0", + borderRadius: 4, + fontSize: 12, + }} + /> + + +
+ ); +}; + +/** + * Simulated daily cost trend for this single asset. + */ +const AssetCostTrend = ({ asset, currency, windowStr }) => { + const data = useMemo(() => { + const windowDays = { + today: 1, + yesterday: 1, + "24h": 1, + "48h": 2, + week: 7, + lastweek: 7, + "7d": 7, + "14d": 14, + }; + const days = windowDays[windowStr] || 7; + const dailyCost = (asset.totalCost || 0) / days; + const now = new Date(); + + return Array.from({ length: days }, (_, i) => { + const date = new Date(now); + date.setDate(date.getDate() - (days - 1 - i)); + const variance = 1 + Math.sin(i * 2.1) * 0.12; + return { + date: date.toLocaleDateString(navigator.language, { + month: "short", + day: "numeric", + timeZone: "UTC", + }), + cost: Number((dailyCost * variance).toFixed(4)), + }; + }); + }, [asset, windowStr]); + + return ( +
+ + + + + `$${v.toFixed(1)}`} + width={50} + /> + toCurrency(val, currency)} + contentStyle={{ + background: "#fff", + border: "1px solid #e0e0e0", + borderRadius: 4, + fontSize: 12, + }} + /> + + + +
+ ); +}; + +/** + * AssetDetail — full-page detail view for a single asset. + * Receives asset data via location state. + */ +const AssetDetail = () => { + const location = useLocation(); + const navigate = useNavigate(); + const { asset, currency, windowStr } = location.state || {}; + + if (!asset) { + return ( + +
+
+
+

No asset data. Please go back and select an asset.

+ +
+
+