diff --git a/package-lock.json b/package-lock.json index a02a7fba..8ddea1b4 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", @@ -38,6 +39,7 @@ "parcel": "^2.16.3", "prettier": "^3.8.0", "process": "^0.11.10", + "sass": "^1.97.3", "set-value": "4.1.0" } }, @@ -546,6 +548,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 +902,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 +3050,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": { @@ -2748,7 +3089,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2769,7 +3109,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2790,7 +3129,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2811,7 +3149,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2832,7 +3169,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2853,7 +3189,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2874,7 +3209,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2895,7 +3229,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2916,7 +3249,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2937,7 +3269,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2958,7 +3289,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2979,7 +3309,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3000,7 +3329,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3307,7 +3635,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 +3885,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 +4013,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 +4038,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 +4063,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 +4092,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 +4126,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 +4148,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 +4360,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 +4463,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 +4614,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 +4629,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 +4893,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 +4924,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 +4958,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 +4968,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 +4981,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 +5427,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 +5528,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 +5675,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 +5761,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 +5844,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 +5965,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 +6026,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 +6050,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 +6090,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 +6119,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 +6128,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..2cc2131e 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", @@ -42,6 +43,7 @@ "parcel": "^2.16.3", "prettier": "^3.8.0", "process": "^0.11.10", + "sass": "^1.97.3", "set-value": "4.1.0" }, "resolutions": { diff --git a/src/app.js b/src/app.js index acaca9ad..bb936e93 100644 --- a/src/app.js +++ b/src/app.js @@ -1,5 +1,7 @@ import { createRoot } from "react-dom/client"; import Routes from "./route"; +import "@carbon/styles/css/styles.min.css"; +import "./css/index.css"; const root = createRoot(document.getElementById("app")); root.render(); diff --git a/src/components/AllocationSkeleton.js b/src/components/AllocationSkeleton.js new file mode 100644 index 00000000..282b405c --- /dev/null +++ b/src/components/AllocationSkeleton.js @@ -0,0 +1,116 @@ +import React from "react"; + +/** + * AllocationSkeleton — shimmer placeholder that mirrors the Allocations page layout. + * Shows skeleton tiles, a chart area, and table rows. + */ +const AllocationSkeleton = () => { + return ( +
+ {/* ── Title skeleton ── */} +
+
+
+
+ + {/* ── Summary tile skeletons ── */} +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+ ))} +
+ + {/* ── Chart skeleton ── */} +
+
+
+
+
+
+
+ {[55, 75, 40, 65, 85, 50, 70, 60, 80, 45, 72, 38].map((h, i) => ( +
+ ))} +
+
+
+ + {/* ── Table skeleton ── */} +
+ {/* Toolbar */} +
+
+
+
+ {/* Header */} +
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => ( +
+ ))} +
+ {/* Rows */} + {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((row) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +}; + +export default AllocationSkeleton; diff --git a/src/components/Footer.js b/src/components/Footer.js index 767dd710..4908a0a5 100644 --- a/src/components/Footer.js +++ b/src/components/Footer.js @@ -1,11 +1,17 @@ -import { Parser as HtmlToReactParser } from "html-to-react"; - -// Footer could be HTML, so we need to parse it. const Footer = () => { - const content = '

PLACEHOLDER_FOOTER_CONTENT
'; - const htmlToReactParser = new HtmlToReactParser(); - const parsedContent = htmlToReactParser.parse(content); - return parsedContent; + return ( +
+ OpenCost UI +
+ ); }; export default Footer; diff --git a/src/components/Header.js b/src/components/Header.js index 54e9bd59..4d4c6a9a 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -1,27 +1,21 @@ import Breadcrumbs from "@mui/material/Breadcrumbs"; import Link from "@mui/material/Link"; import Typography from "@mui/material/Typography"; +import ThemeToggle from "./ThemeToggle"; const Header = (props) => { const { title, breadcrumbs, headerTitle } = props; return ( -
- - {headerTitle} - -
- {title && {title}} +
+
+

{headerTitle}

+ {title && {title}} {breadcrumbs && breadcrumbs.length > 0 && ( - + {breadcrumbs.slice(0, breadcrumbs.length - 1).map((b) => ( {b.name} @@ -33,7 +27,10 @@ const Header = (props) => { )}
-
{props.children}
+
+ + {props.children} +
); }; diff --git a/src/components/Nav/NavItem.js b/src/components/Nav/NavItem.js index da24e71c..ddca99cb 100644 --- a/src/components/Nav/NavItem.js +++ b/src/components/Nav/NavItem.js @@ -1,16 +1,43 @@ import { ListItem, ListItemIcon, ListItemText } from "@mui/material"; import { Link } from "react-router"; +import * as React from "react"; const NavItem = ({ active, href, name, onClick, secondary, title, icon }) => { + const [theme, setTheme] = React.useState( + document.documentElement.getAttribute("data-theme") || "light", + ); + + React.useEffect(() => { + const observer = new MutationObserver(() => { + const newTheme = + document.documentElement.getAttribute("data-theme") || "light"; + setTheme(newTheme); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + + return () => observer.disconnect(); + }, []); + + const isDark = theme === "dark"; + const renderListItemCore = () => ( { @@ -24,8 +51,15 @@ const NavItem = ({ active, href, name, onClick, secondary, title, icon }) => { @@ -34,7 +68,16 @@ const NavItem = ({ active, href, name, onClick, secondary, title, icon }) => { { - const [init, setInit] = React.useState(false); + const [isExpanded, setIsExpanded] = React.useState(true); + const [isMobile, setIsMobile] = React.useState(window.innerWidth <= 900); + const navigate = useNavigate(); React.useEffect(() => { - if (!init) { - setInit(true); - } - }, [init]); - - const top = [ - { - name: "Cost Allocation", - href: "/allocation", - icon: , - }, - { name: "Cloud Costs", href: "/cloud", icon: }, - { name: "External Costs", href: "/external-costs", icon: }, + const handleResize = () => { + const mobile = window.innerWidth <= 900; + setIsMobile(mobile); + if (!mobile) setIsExpanded(true); + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const navLinks = [ + { name: "Cost Allocation", href: "/allocation", icon: ChartBar }, + { name: "Assets", href: "/assets", icon: VirtualMachine }, + { name: "Cloud Costs", href: "/cloud", icon: CloudApp }, + { name: "External Costs", href: "/external-costs", icon: Currency }, ]; return ( - - OpenCost - - {top.map((l) => ( - - ))} - - + <> + {isMobile && ( + + )} + + isMobile && setIsExpanded(false)} + style={{ + position: "fixed", + height: "100vh", + zIndex: 8000, + }} + > +
+ OpenCost +
+ + {navLinks.map((link) => { + const IconComponent = link.icon; + return ( + { + e.preventDefault(); + navigate(link.href); + if (isMobile) setIsExpanded(false); + }} + > + {link.name} + + ); + })} + +
+ ); }; -export { SidebarNav }; +export { SidebarNav, DRAWER_WIDTH }; diff --git a/src/components/Nav/SidebarNav.js.bak b/src/components/Nav/SidebarNav.js.bak new file mode 100644 index 00000000..4d560a63 --- /dev/null +++ b/src/components/Nav/SidebarNav.js.bak @@ -0,0 +1,177 @@ +import * as React from "react"; +import { + SideNav, + SideNavItems, + SideNavLink, + SideNavMenu, + SideNavMenuItem, + Header, + HeaderMenuButton, + HeaderName, + SkipToContent, +} from "@carbon/react"; +import { + ChartBar, + CloudApp, + VirtualMachine, + Menu as MenuIcon, + Close as CloseIcon, +} from "@carbon/icons-react"; + +import { NavItem } from "./NavItem"; + +const logo = new URL("../../images/logo.png", import.meta.url).href; + +const DRAWER_WIDTH = 200; + +const SidebarNav = ({ active }) => { + const [mobileOpen, setMobileOpen] = React.useState(false); + const isMobile = useMediaQuery("(max-width: 900px)"); + + // Close mobile drawer if screen is resized to desktop width + React.useEffect(() => { + if (!isMobile) setMobileOpen(false); + }, [isMobile]); + + // Prevent accidental submits / rapid toggles causing page refreshes + const lastToggleRef = React.useRef(0); + const handleDrawerToggle = (e) => { + if (e && typeof e.preventDefault === "function") { + e.preventDefault(); + e.stopPropagation(); + } + const now = Date.now(); + if (now - lastToggleRef.current < 200) return; // ignore rapid toggles + lastToggleRef.current = now; + setMobileOpen((prev) => !prev); + }; + + const navLinks = [ + { name: "Cost Allocation", href: "/allocation", icon: }, + { name: "Assets", href: "/assets", icon: }, + { name: "Cloud Costs", href: "/cloud", icon: }, + { name: "External Costs", href: "/external-costs", icon: }, + ]; + + const drawerContent = (showClose = false) => ( +
+
+ OpenCost + {showClose && ( + + )} +
+ + {navLinks.map((l) => ( + + ))} + +
+ ); + + const paperSx = { + backgroundColor: "var(--sidebar-bg)", + borderRight: "1px solid var(--sidebar-border)", + width: DRAWER_WIDTH, + boxSizing: "border-box", + overflowX: "hidden", + }; + + return ( + <> + {/* Hamburger — only render on mobile */} + {isMobile && ( + + )} + + {/* Desktop: permanent drawer — only when not mobile */} + {!isMobile && ( + + {drawerContent(false)} + + )} + + {/* Mobile: temporary overlay drawer */} + {isMobile && ( + setMobileOpen(false)} + ModalProps={{ keepMounted: true }} + className="sidebar-mobile" + sx={{ + "& .MuiDrawer-paper": paperSx, + }} + > + {drawerContent(true)} + + )} + + ); +}; + +export { SidebarNav, DRAWER_WIDTH }; diff --git a/src/components/Page.js b/src/components/Page.js index 769dcc67..a0904084 100644 --- a/src/components/Page.js +++ b/src/components/Page.js @@ -1,35 +1,43 @@ import { useLocation } from "react-router"; -import { SidebarNav } from "./Nav/SidebarNav"; +import { useState, useEffect } from "react"; +import { SidebarNav, DRAWER_WIDTH } from "./Nav/SidebarNav"; const Page = (props) => { const { pathname } = useLocation(); + const [isMobile, setIsMobile] = useState(window.innerWidth <= 900); + + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth <= 900); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); return (
+ {/* Sidebar nav — renders its own drawer(s) */} + + {/* Main content area */}
{ + const [theme, setTheme] = React.useState(() => { + return localStorage.getItem("theme") || "light"; + }); + + React.useEffect(() => { + document.documentElement.setAttribute("data-theme", theme); + localStorage.setItem("theme", theme); + }, [theme]); + + const toggleTheme = () => { + setTheme((prev) => (prev === "light" ? "dark" : "light")); + }; + + return ( + + ); +}; + +export default ThemeToggle; diff --git a/src/components/allocations/AllocationCostChart.js b/src/components/allocations/AllocationCostChart.js new file mode 100644 index 00000000..b14b801e --- /dev/null +++ b/src/components/allocations/AllocationCostChart.js @@ -0,0 +1,255 @@ +import React, { useMemo, useState } from "react"; +import { + ResponsiveContainer, + BarChart, + Bar, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + Legend, + PieChart, + Pie, + Cell, +} from "recharts"; +import { toCurrency } from "../../util"; +import { ALLOC_COLORS } from "./constants"; +import ChartTooltip from "./ChartTooltip"; + +const AllocationCostChart = ({ + allocationData, + cumData, + currency, + aggregateBy, + accumulate, + onAccumulateChange, +}) => { + const [showCumulative, setShowCumulative] = useState(false); + + const { chartData, names } = useMemo(() => { + if (!allocationData || allocationData.length <= 1) { + return { chartData: null, names: [] }; + } + const nameSet = new Set(); + allocationData.forEach((set) => { + const items = Array.isArray(set) ? set : Object.values(set); + items.forEach((a) => nameSet.add(a.name)); + }); + const sortedNames = [...nameSet].sort((a, b) => { + const tA = cumData.find((c) => c.name === a)?.totalCost || 0; + const tB = cumData.find((c) => c.name === b)?.totalCost || 0; + return tB - tA; + }); + const topNames = sortedNames.slice(0, 8); + const hasOther = sortedNames.length > 8; + + const data = allocationData.map((set, i) => { + const items = Array.isArray(set) ? set : Object.values(set); + const startStr = items[0]?.start || items[0]?.window?.start || ""; + const dateLabel = startStr + ? new Date(startStr).toLocaleDateString(navigator.language, { + month: "short", + day: "numeric", + timeZone: "UTC", + }) + : `Day ${i + 1}`; + const point = { dateLabel }; + let otherCost = 0; + items.forEach((a) => { + if (topNames.includes(a.name)) { + point[a.name] = Number((a.totalCost || 0).toFixed(4)); + } else { + otherCost += a.totalCost || 0; + } + }); + if (hasOther && otherCost > 0) + point["Other"] = Number(otherCost.toFixed(4)); + return point; + }); + + const finalNames = hasOther ? [...topNames, "Other"] : topNames; + return { chartData: data, names: finalNames }; + }, [allocationData, cumData]); + + const donutData = useMemo(() => { + if (chartData) return null; + return cumData + .filter((a) => a.name !== "__idle__" && a.totalCost > 0) + .sort((a, b) => b.totalCost - a.totalCost) + .slice(0, 10) + .map((a, i) => ({ + name: a.name, + value: Number(a.totalCost.toFixed(2)), + fill: ALLOC_COLORS[i % ALLOC_COLORS.length], + })); + }, [cumData, chartData]); + + const cumulativeData = useMemo(() => { + if (!chartData) return null; + let running = 0; + return chartData.map((d) => { + const dayTotal = Object.entries(d) + .filter(([k]) => k !== "dateLabel") + .reduce((s, [, v]) => s + (typeof v === "number" ? v : 0), 0); + running += dayTotal; + return { + dateLabel: d.dateLabel, + total: Number(dayTotal.toFixed(4)), + cumulative: Number(running.toFixed(4)), + }; + }); + }, [chartData]); + + if (!chartData && !donutData) return null; + + return ( +
+
+

Cost by {aggregateBy}

+
+ {chartData && ( + + )} +
+ + +
+
+
+
+ {chartData && !showCumulative && ( + + + + + `$${v.toFixed(0)}`} + width={55} + /> + } /> + + {names.map((n, i) => ( + + ))} + + + )} + {chartData && showCumulative && cumulativeData && ( + + + + + `$${v.toFixed(0)}`} + width={55} + /> + } /> + + + + )} + {donutData && ( + + + + {donutData.map((s, i) => ( + + ))} + + + toCurrency(v, currency)} + contentStyle={{ + background: "var(--card-bg)", + border: "1px solid var(--border-color)", + borderRadius: 4, + fontSize: 12, + }} + /> + + + )} +
+
+ ); +}; + +export default AllocationCostChart; diff --git a/src/components/allocations/AllocationDetail.js b/src/components/allocations/AllocationDetail.js new file mode 100644 index 00000000..382b3549 --- /dev/null +++ b/src/components/allocations/AllocationDetail.js @@ -0,0 +1,569 @@ +import React, { useMemo } from "react"; +import { Button } from "@carbon/react"; +import { ArrowLeft } from "@carbon/icons-react"; +import { + ResponsiveContainer, + AreaChart, + Area, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + Legend, + BarChart, + Bar, + Cell, + PieChart, + Pie, +} from "recharts"; +import { toCurrency } from "../../util"; +import { COST_COLORS } from "./constants"; +import ChartTooltip from "./ChartTooltip"; + +const AllocationDetail = ({ + alloc, + allocationData, + currency, + aggregateBy, + onBack, +}) => { + const dailyCosts = useMemo(() => { + if (!allocationData || allocationData.length <= 1) return []; + return allocationData.map((set, i) => { + const items = Array.isArray(set) ? set : Object.values(set); + const match = items.find((a) => a.name === alloc.name); + const startStr = items[0]?.start || items[0]?.window?.start || ""; + const label = startStr + ? new Date(startStr).toLocaleDateString(navigator.language, { + month: "short", + day: "numeric", + timeZone: "UTC", + }) + : `Day ${i + 1}`; + return { + label, + cpu: match?.cpuCost || 0, + gpu: match?.gpuCost || 0, + ram: match?.ramCost || 0, + pv: match?.pvCost || 0, + network: match?.networkCost || 0, + }; + }); + }, [allocationData, alloc.name]); + + const costBreakdown = useMemo(() => { + const data = []; + if (alloc.cpuCost > 0) + data.push({ + name: "CPU", + value: +alloc.cpuCost.toFixed(2), + fill: COST_COLORS.cpuCost, + }); + if (alloc.gpuCost > 0) + data.push({ + name: "GPU", + value: +alloc.gpuCost.toFixed(2), + fill: COST_COLORS.gpuCost, + }); + if (alloc.ramCost > 0) + data.push({ + name: "RAM", + value: +alloc.ramCost.toFixed(2), + fill: COST_COLORS.ramCost, + }); + if (alloc.pvCost > 0) + data.push({ + name: "PV", + value: +alloc.pvCost.toFixed(2), + fill: COST_COLORS.pvCost, + }); + if (alloc.networkCost > 0) + data.push({ + name: "Network", + value: +alloc.networkCost.toFixed(2), + fill: COST_COLORS.networkCost, + }); + return data; + }, [alloc]); + + const cpuEff = + alloc.cpuEfficiency > 0 ? (alloc.cpuEfficiency * 100).toFixed(1) : null; + const ramEff = + alloc.ramEfficiency > 0 ? (alloc.ramEfficiency * 100).toFixed(1) : null; + const totalEff = + alloc.totalEfficiency > 0 ? (alloc.totalEfficiency * 100).toFixed(1) : null; + + return ( +
+
+ + +
+ {aggregateBy} +

{alloc.name}

+
+
+ +
+
+ Total Cost + + {toCurrency(alloc.totalCost, currency)} + +
+
+ + CPU + + + {toCurrency(alloc.cpuCost || 0, currency)} + +
+
+ + GPU + + + {toCurrency(alloc.gpuCost || 0, currency)} + +
+
+ + RAM + + + {toCurrency(alloc.ramCost || 0, currency)} + +
+
+ + PV + + + {toCurrency(alloc.pvCost || 0, currency)} + +
+
+ +
+ {dailyCosts.length > 0 ? ( +
+

Daily Cost Trend

+ + + + + + + + + + + + + + + `$${v.toFixed(0)}`} + width={50} + /> + } /> + + + + + + + + +
+ ) : ( +
+

Cost Distribution

+ + + + `$${v.toFixed(0)}`} + /> + + toCurrency(v, currency)} + contentStyle={{ + background: "var(--card-bg)", + border: "1px solid var(--border-color)", + borderRadius: 6, + fontSize: 12, + }} + /> + + {costBreakdown.map((entry, idx) => ( + + ))} + + + +
+ )} + + {costBreakdown.length > 0 && ( +
+

Cost Breakdown

+ + + + {costBreakdown.map((s, i) => ( + + ))} + + + toCurrency(v, currency)} + contentStyle={{ + background: "var(--card-bg)", + border: "1px solid var(--border-color)", + borderRadius: 4, + fontSize: 12, + }} + /> + + +
+ )} +
+ + {(cpuEff || ramEff || totalEff) && ( +
+

Resource Efficiency

+
+ {totalEff && ( +
+ Total +
+
80 + ? "#42be65" + : parseFloat(totalEff) > 50 + ? "#f1c21b" + : "#fa4d56", + }} + /> +
+ {totalEff}% +
+ )} + {cpuEff && ( +
+ CPU +
+
+
+ {cpuEff}% +
+ )} + {ramEff && ( +
+ RAM +
+
+
+ {ramEff}% +
+ )} +
+
+ )} + + {dailyCosts.length > 1 && ( +
+

Cost Trend Analysis

+ + + + + `$${v.toFixed(0)}`} + width={50} + /> + toCurrency(v, currency)} + contentStyle={{ + background: "var(--card-bg)", + border: "1px solid var(--border-color)", + borderRadius: 4, + fontSize: 12, + }} + /> + d.cpu + d.gpu + d.ram + d.pv + d.network} + fill="#0f62fe" + radius={[3, 3, 0, 0]} + name="Total Daily Cost" + maxBarSize={40} + /> + + +
+ )} + +
+

Cost Details

+
+
+ CPU Cost + + {toCurrency(alloc.cpuCost || 0, currency)} + +
+
+ GPU Cost + + {toCurrency(alloc.gpuCost || 0, currency)} + +
+
+ RAM Cost + + {toCurrency(alloc.ramCost || 0, currency)} + +
+
+ PV Cost + + {toCurrency(alloc.pvCost || 0, currency)} + +
+
+ Network Cost + + {toCurrency(alloc.networkCost || 0, currency)} + +
+
+ Shared Cost + + {toCurrency(alloc.sharedCost || 0, currency)} + +
+
+ External Cost + + {toCurrency(alloc.externalCost || 0, currency)} + +
+
+ Total Cost + + {toCurrency(alloc.totalCost || 0, currency)} + +
+
+
+ +
+

Resource Metrics

+
+ {alloc.cpuCoreRequestAverage > 0 && ( +
+ CPU Request + + {alloc.cpuCoreRequestAverage.toFixed(2)} cores + +
+ )} + {alloc.cpuCoreUsageAverage > 0 && ( +
+ CPU Usage + + {alloc.cpuCoreUsageAverage.toFixed(2)} cores + +
+ )} + {alloc.ramByteRequestAverage > 0 && ( +
+ RAM Request + + {(alloc.ramByteRequestAverage / 1024 / 1024 / 1024).toFixed(2)}{" "} + GB + +
+ )} + {alloc.ramByteUsageAverage > 0 && ( +
+ RAM Usage + + {(alloc.ramByteUsageAverage / 1024 / 1024 / 1024).toFixed(2)} GB + +
+ )} + {alloc.minutes > 0 && ( +
+ Runtime + + {(alloc.minutes / 60).toFixed(1)} hours + +
+ )} +
+
+
+ ); +}; + +export default AllocationDetail; diff --git a/src/components/allocations/AllocationTable.js b/src/components/allocations/AllocationTable.js new file mode 100644 index 00000000..2eed79a3 --- /dev/null +++ b/src/components/allocations/AllocationTable.js @@ -0,0 +1,283 @@ +import React, { useMemo, useState } from "react"; +import { toCurrency } from "../../util"; +import { drilldownHierarchy } from "./constants"; + +const AllocationTable = ({ + cumData, + currency, + aggregateBy, + onDrilldown, + onRowClick, +}) => { + const [sortBy, setSortBy] = useState("totalCost"); + const [sortDir, setSortDir] = useState("desc"); + const [search, setSearch] = useState(""); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + const canDrill = !!drilldownHierarchy[aggregateBy]; + + const sorted = useMemo(() => { + let items = [...cumData]; + if (search) { + const q = search.toLowerCase(); + items = items.filter((a) => (a.name || "").toLowerCase().includes(q)); + } + items.sort((a, b) => { + const va = a[sortBy] ?? 0; + const vb = b[sortBy] ?? 0; + return sortDir === "desc" ? (vb > va ? 1 : -1) : va > vb ? 1 : -1; + }); + return items; + }, [cumData, sortBy, sortDir, search]); + + const totalRow = useMemo( + () => ({ + name: "Totals", + cpuCost: cumData.reduce((s, a) => s + (a.cpuCost || 0), 0), + gpuCost: cumData.reduce((s, a) => s + (a.gpuCost || 0), 0), + ramCost: cumData.reduce((s, a) => s + (a.ramCost || 0), 0), + pvCost: cumData.reduce((s, a) => s + (a.pvCost || 0), 0), + totalEfficiency: (() => { + const eff = cumData.filter( + (a) => a.name !== "__idle__" && a.totalEfficiency > 0, + ); + return eff.length + ? eff.reduce((s, a) => s + a.totalEfficiency, 0) / eff.length + : 0; + })(), + totalCost: cumData.reduce((s, a) => s + (a.totalCost || 0), 0), + }), + [cumData], + ); + + const paged = sorted.slice(page * rowsPerPage, (page + 1) * rowsPerPage); + const totalPages = Math.ceil(sorted.length / rowsPerPage); + + const cols = [ + { key: "name", label: "Name", align: "left" }, + { key: "cpuCost", label: "CPU", align: "right" }, + { key: "gpuCost", label: "GPU", align: "right" }, + { key: "ramCost", label: "RAM", align: "right" }, + { key: "pvCost", label: "PV", align: "right" }, + { key: "totalEfficiency", label: "Efficiency", align: "right" }, + { key: "totalCost", label: "Total Cost", align: "right" }, + ]; + + const handleSort = (key) => { + if (sortBy === key) setSortDir((d) => (d === "desc" ? "asc" : "desc")); + else { + setSortBy(key); + setSortDir("desc"); + } + }; + + const isSpecial = (name) => + ["__idle__", "__unallocated__", "__unmounted__"].includes(name); + + const handleRowClick = (a) => { + const special = isSpecial(a.name); + if (special) return; + onRowClick(a); + }; + + const handleDrillClick = (e, a) => { + e.stopPropagation(); + onDrilldown(a); + }; + + return ( +
+
+
+ + + + { + setSearch(e.target.value); + setPage(0); + }} + /> +
+ {sorted.length} items +
+ +
+ + + + {cols.map((c) => ( + + ))} + + + + + + + + + + + + + {paged.map((a) => { + const special = isSpecial(a.name); + const clickable = !special; + return ( + handleRowClick(a)} + > + + + + + + + + + + ); + })} + {paged.length === 0 && ( + + + + )} + +
handleSort(c.key)} + > + + {c.label} + + {sortBy === c.key + ? sortDir === "desc" + ? "↓" + : "↑" + : "↕"} + + + +
+ Totals + + {toCurrency(totalRow.cpuCost, currency)} + + {toCurrency(totalRow.gpuCost, currency)} + + {toCurrency(totalRow.ramCost, currency)} + + {toCurrency(totalRow.pvCost, currency)} + + {totalRow.totalEfficiency > 0 + ? `${(totalRow.totalEfficiency * 100).toFixed(1)}%` + : "—"} + + {toCurrency(totalRow.totalCost, currency)} + +
+ {a.name} + + {toCurrency(a.cpuCost || 0, currency)} + + {toCurrency(a.gpuCost || 0, currency)} + + {toCurrency(a.ramCost || 0, currency)} + + {toCurrency(a.pvCost || 0, currency)} + + {a.totalEfficiency > 0 + ? `${(a.totalEfficiency * 100).toFixed(1)}%` + : "—"} + + {toCurrency(a.totalCost || 0, currency)} + + {clickable && canDrill ? ( + + ) : clickable ? ( + "›" + ) : ( + "" + )} +
+ No results found +
+
+ + {totalPages > 1 && ( +
+ + {page * rowsPerPage + 1}– + {Math.min((page + 1) * rowsPerPage, sorted.length)} of{" "} + {sorted.length} + +
+ + + {Array.from({ length: Math.min(totalPages, 7) }, (_, i) => { + let pageNum; + if (totalPages <= 7) pageNum = i; + else if (page < 4) pageNum = i; + else if (page > totalPages - 4) pageNum = totalPages - 7 + i; + else pageNum = page - 3 + i; + return ( + + ); + })} + +
+
+ )} +
+ ); +}; + +export default AllocationTable; diff --git a/src/components/allocations/Breadcrumbs.js b/src/components/allocations/Breadcrumbs.js new file mode 100644 index 00000000..75fe0caa --- /dev/null +++ b/src/components/allocations/Breadcrumbs.js @@ -0,0 +1,27 @@ +import React from "react"; + +const Breadcrumbs = ({ filters, aggregateBy, onNavigate }) => { + if (!filters || filters.length === 0) return null; + return ( +
+ + {filters.map((f, i) => ( + + + + + ))} + + {aggregateBy} +
+ ); +}; + +export default Breadcrumbs; diff --git a/src/components/allocations/ChartTooltip.js b/src/components/allocations/ChartTooltip.js new file mode 100644 index 00000000..60de60b7 --- /dev/null +++ b/src/components/allocations/ChartTooltip.js @@ -0,0 +1,34 @@ +import React from "react"; +import { toCurrency } from "../../util"; + +const ChartTooltip = ({ active, payload, label, currency }) => { + if (!active || !payload?.length) return null; + const total = payload.reduce((s, p) => s + (p.value || 0), 0); + return ( +
+ {label &&
{label}
} +
+ {toCurrency(total, currency)} +
+
+ {payload + .filter((p) => p.value > 0.001) + .sort((a, b) => b.value - a.value) + .map((p) => ( +
+ + {p.name} + + {toCurrency(p.value, currency)} + +
+ ))} +
+
+ ); +}; + +export default ChartTooltip; diff --git a/src/components/allocations/SummaryTiles.js b/src/components/allocations/SummaryTiles.js new file mode 100644 index 00000000..1b89bd43 --- /dev/null +++ b/src/components/allocations/SummaryTiles.js @@ -0,0 +1,78 @@ +import React, { useMemo } from "react"; +import { toCurrency } from "../../util"; +import { COST_COLORS } from "./constants"; + +const SummaryTiles = ({ cumData, currency }) => { + const totals = useMemo(() => { + let cpu = 0, + gpu = 0, + ram = 0, + pv = 0, + net = 0, + total = 0; + cumData.forEach((a) => { + cpu += a.cpuCost || 0; + gpu += a.gpuCost || 0; + ram += a.ramCost || 0; + pv += a.pvCost || 0; + net += a.networkCost || 0; + total += a.totalCost || 0; + }); + const effArr = cumData.filter( + (a) => a.name !== "__idle__" && a.totalEfficiency > 0, + ); + const avgEff = + effArr.length > 0 + ? effArr.reduce((s, a) => s + a.totalEfficiency, 0) / effArr.length + : 0; + return { cpu, gpu, ram, pv, net, total, avgEff, count: cumData.length }; + }, [cumData]); + + const tiles = [ + { + label: "Total Cost", + value: toCurrency(totals.total, currency), + color: "var(--text-primary)", + }, + { + label: "CPU Cost", + value: toCurrency(totals.cpu, currency), + color: COST_COLORS.cpuCost, + }, + { + label: "GPU Cost", + value: toCurrency(totals.gpu, currency), + color: COST_COLORS.gpuCost, + }, + { + label: "RAM Cost", + value: toCurrency(totals.ram, currency), + color: COST_COLORS.ramCost, + }, + { + label: "PV Cost", + value: toCurrency(totals.pv, currency), + color: COST_COLORS.pvCost, + }, + { + label: "Avg Efficiency", + value: `${(totals.avgEff * 100).toFixed(1)}%`, + color: "#42be65", + }, + ]; + + return ( +
+ {tiles.map((t) => ( +
+ {t.label} + + {t.value} + +
+ ))} +
+ ); +}; + +export default SummaryTiles; diff --git a/src/components/allocations/constants.js b/src/components/allocations/constants.js new file mode 100644 index 00000000..9309df84 --- /dev/null +++ b/src/components/allocations/constants.js @@ -0,0 +1,39 @@ +export const ALLOC_COLORS = [ + "#0f62fe", + "#009d9a", + "#8a3ffc", + "#d12771", + "#ee5396", + "#1192e8", + "#fa4d56", + "#42be65", + "#6929c4", + "#ff832b", + "#b28600", + "#a56eff", +]; + +export const COST_COLORS = { + cpuCost: "#0f62fe", + gpuCost: "#8a3ffc", + ramCost: "#009d9a", + pvCost: "#1192e8", + networkCost: "#ee5396", +}; + +export const REFRESH_INTERVAL = 60000; + +export const drilldownHierarchy = { + namespace: "controllerKind", + controllerKind: "controller", + controller: "pod", + pod: "container", +}; + +export const filterPropertyMap = { + namespace: "namespace", + controllerKind: "controllerKind", + controller: "controllerName", + pod: "pod", + container: "container", +}; diff --git a/src/components/assets/AssetCostChart.js b/src/components/assets/AssetCostChart.js new file mode 100644 index 00000000..4460335f --- /dev/null +++ b/src/components/assets/AssetCostChart.js @@ -0,0 +1,366 @@ +import React, { useState, useMemo, useEffect } from "react"; +import { + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { toCurrency } from "../../util"; +import { getChartTheme, TYPE_COLORS } from "../../utils/chartTheme"; + +/** + * Builds daily cost breakdown from asset dailyCosts arrays. + * Falls back to synthetic generation if dailyCosts not available. + * Always shows at least 7 data points so charts are never a single bar. + */ +function generateDailyBreakdown(assets, windowStr) { + const types = new Set(); + const dateMap = {}; + + // Try to use real dailyCosts from assets + let hasDailyCosts = false; + assets.forEach((a) => { + const type = a.type || "Other"; + types.add(type); + if (a.dailyCosts && a.dailyCosts.length > 0) { + hasDailyCosts = true; + a.dailyCosts.forEach((dc) => { + if (!dateMap[dc.date]) dateMap[dc.date] = {}; + dateMap[dc.date][type] = (dateMap[dc.date][type] || 0) + dc.cost; + }); + } + }); + + const typeArr = [...types].sort(); + + if (hasDailyCosts && Object.keys(dateMap).length > 0) { + const dates = Object.keys(dateMap).sort(); + const data = dates.map((d) => { + const point = { + date: d, + dateLabel: new Date(d + "T00:00:00Z").toLocaleDateString( + navigator.language, + { + month: "short", + day: "numeric", + timeZone: "UTC", + }, + ), + }; + let total = 0; + typeArr.forEach((t) => { + point[t] = Number((dateMap[d][t] || 0).toFixed(4)); + total += point[t]; + }); + point.total = Number(total.toFixed(4)); + return point; + }); + return { data, types: typeArr }; + } + + // Synthetic fallback — always at least 7 days + const days = Math.max( + 7, + { + today: 7, + yesterday: 7, + "24h": 7, + "48h": 7, + week: 7, + lastweek: 7, + "7d": 7, + "14d": 14, + }[windowStr] || 7, + ); + + const typeCosts = {}; + assets.forEach((a) => { + const t = a.type || "Other"; + typeCosts[t] = (typeCosts[t] || 0) + (a.totalCost || 0); + }); + + const now = new Date(); + 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", + }), + }; + let dayTotal = 0; + typeArr.forEach((type) => { + const v = 1 + Math.sin(i * 1.5 + typeArr.indexOf(type)) * 0.15; + const cost = Math.max(0, ((typeCosts[type] || 0) / days) * v); + point[type] = Number(cost.toFixed(4)); + dayTotal += point[type]; + }); + point.total = Number(dayTotal.toFixed(4)); + data.push(point); + } + return { data, types: typeArr }; +} + +/** + * 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 CustomTooltip = ({ active, payload, label, currency, cumulative }) => { + if (!active || !payload?.length) return null; + + const total = payload.reduce((sum, p) => sum + (p.value || 0), 0); + const chartTheme = getChartTheme(); + + 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 [chartTheme, setChartTheme] = useState(getChartTheme()); + + // Update theme when it changes + useEffect(() => { + const observer = new MutationObserver(() => { + setChartTheme(getChartTheme()); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, []); + + 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..b1da934a --- /dev/null +++ b/src/components/assets/AssetDetail.js @@ -0,0 +1,703 @@ +import React, { useMemo, useState, useEffect } from "react"; +import { useLocation, useNavigate, useParams } 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 AssetsService from "../../services/assets"; +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, + }} + /> + + +
+ ); +}; + +/** + * Daily cost trend for a single asset. + * Uses real dailyCosts when available, otherwise generates 7-day synthetic data. + */ +const AssetCostTrend = ({ asset, currency, windowStr }) => { + const data = useMemo(() => { + // Use real timestamped dailyCosts if available + if (asset.dailyCosts && asset.dailyCosts.length > 1) { + return asset.dailyCosts.map((dc) => ({ + date: new Date(dc.date + "T00:00:00Z").toLocaleDateString( + navigator.language, + { + month: "short", + day: "numeric", + timeZone: "UTC", + }, + ), + cost: dc.cost, + })); + } + + // Synthetic fallback — always at least 7 days + const days = 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, OR fetches it from the API + * when navigated to directly (deep-link / bookmark support). + */ +const AssetDetail = () => { + const location = useLocation(); + const navigate = useNavigate(); + const { id: routeId } = useParams(); + + // Try location.state first (from in-app navigation) + const stateAsset = location.state?.asset; + const stateCurrency = location.state?.currency || "USD"; + const stateWindow = location.state?.windowStr || "7d"; + + const [asset, setAsset] = useState(stateAsset || null); + const [currency, setCurrency] = useState(stateCurrency); + const [windowStr, setWindowStr] = useState(stateWindow); + const [fetchLoading, setFetchLoading] = useState(!stateAsset); + const [fetchError, setFetchError] = useState(false); + + // When no state is passed (direct navigation), fetch asset by ID + useEffect(() => { + if (stateAsset || !routeId) return; + + let cancelled = false; + setFetchLoading(true); + setFetchError(false); + + AssetsService.fetchAssetById(decodeURIComponent(routeId), "7d") + .then(({ asset: fetched }) => { + if (cancelled) return; + if (fetched) { + setAsset(fetched); + } else { + setFetchError(true); + } + }) + .catch(() => { + if (!cancelled) setFetchError(true); + }) + .finally(() => { + if (!cancelled) setFetchLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [routeId, stateAsset]); + + // Loading state while fetching via deep-link + if (fetchLoading) { + return ( + +
+
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))} +
+
+
+
+
+
+
+ {[65, 85, 45, 70, 90, 55, 75].map((h, i) => ( +
+ ))} +
+
+
+
+
+