From 592510ec18b880fc5bea533af18afa317dd1e67d Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 9 Jul 2026 18:18:52 -0700 Subject: [PATCH] feat(ui): shadcn charts foundation with tremor-compatible wrappers (#32668) --- ui/litellm-dashboard/eslint-metrics.json | 4 +- ui/litellm-dashboard/eslint-suppressions.json | 74 ++-- ui/litellm-dashboard/package-lock.json | 221 ++++++++++-- ui/litellm-dashboard/package.json | 1 + .../_components/ScoreChart.test.tsx | 38 +- .../_components/ScoreChart.tsx | 50 +-- .../shared/charts/area_chart.test.tsx | 36 ++ .../components/shared/charts/area_chart.tsx | 92 +++++ .../shared/charts/bar_chart.test.tsx | 119 +++++++ .../components/shared/charts/bar_chart.tsx | 119 +++++++ .../shared/charts/chart_legend.test.tsx | 32 ++ .../components/shared/charts/chart_legend.tsx | 25 ++ .../shared/charts/chart_tooltip.test.tsx | 101 ++++++ .../shared/charts/chart_tooltip.tsx | 97 ++++++ .../src/components/shared/charts/colors.ts | 58 ++++ .../shared/charts/donut_chart.test.tsx | 38 ++ .../components/shared/charts/donut_chart.tsx | 67 ++++ .../src/components/shared/charts/index.ts | 12 + .../src/components/ui/card.tsx | 86 +++++ .../src/components/ui/chart.test.tsx | 38 ++ .../src/components/ui/chart.tsx | 324 ++++++++++++++++++ .../src/components/ui/ref-forwarding.test.tsx | 42 +++ ui/litellm-dashboard/tests/setupTests.ts | 37 +- 23 files changed, 1582 insertions(+), 129 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/charts/area_chart.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/charts/area_chart.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/charts/chart_legend.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/charts/chart_legend.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/charts/colors.ts create mode 100644 ui/litellm-dashboard/src/components/shared/charts/donut_chart.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/charts/donut_chart.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/charts/index.ts create mode 100644 ui/litellm-dashboard/src/components/ui/card.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/chart.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/chart.tsx diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index d69b3e1f729..2e204c63a48 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,6 +1,6 @@ { - "@typescript-eslint/no-explicit-any": 1980, - "complexity": 128, + "@typescript-eslint/no-explicit-any": 1978, + "complexity": 129, "local/no-large-inline-object-arg": 512, "local/no-long-condition-chain": 233, "max-depth": 59, diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index b490cf71768..32ab92cbcc9 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -4,6 +4,14 @@ "count": 1 } }, + "src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": { + "no-restricted-imports": { + "count": 1 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, "src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx": { "no-restricted-imports": { "count": 1 @@ -156,16 +164,6 @@ "count": 8 } }, - "src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.test.tsx": { - "react/display-name": { - "count": 1 - } - }, - "src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts": { "no-restricted-syntax": { "count": 1 @@ -373,6 +371,22 @@ "count": 1 } }, + "src/app/(dashboard)/old-usage/_components/usage.tsx": { + "no-restricted-imports": { + "count": 2 + }, + "react-hooks/immutability": { + "count": 1 + }, + "react-hooks/purity": { + "count": 1 + } + }, + "src/app/(dashboard)/organizations/_components/organizations.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": { "no-restricted-imports": { "count": 1 @@ -649,6 +663,14 @@ "count": 1 } }, + "src/app/(dashboard)/router-settings/_components/general_settings.tsx": { + "no-nested-ternary": { + "count": 3 + }, + "no-restricted-imports": { + "count": 2 + } + }, "src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx": { "no-restricted-imports": { "count": 1 @@ -851,14 +873,6 @@ "count": 1 } }, - "src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/components/CreateUserButton.tsx": { "no-restricted-imports": { "count": 1 @@ -1520,14 +1534,6 @@ "count": 1 } }, - "src/app/(dashboard)/router-settings/_components/general_settings.tsx": { - "no-nested-ternary": { - "count": 3 - }, - "no-restricted-imports": { - "count": 2 - } - }, "src/components/guardrails.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -2028,11 +2034,6 @@ "count": 1 } }, - "src/app/(dashboard)/organizations/_components/organizations.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/page_utils.test.ts": { "max-nested-callbacks": { "count": 3 @@ -2371,17 +2372,6 @@ "count": 1 } }, - "src/app/(dashboard)/old-usage/_components/usage.tsx": { - "no-restricted-imports": { - "count": 2 - }, - "react-hooks/immutability": { - "count": 1 - }, - "react-hooks/purity": { - "count": 1 - } - }, "src/components/user_agent_activity.tsx": { "no-restricted-imports": { "count": 2 diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index ae3660f59e9..56c0a4f9500 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -34,6 +34,7 @@ "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", "react-syntax-highlighter": "15.6.6", + "recharts": "3.9.2", "remark-gfm": "4.0.1", "tailwind-merge": "3.4.0", "uuid": "14.0.0" @@ -2927,6 +2928,32 @@ "npm": ">=9.5.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", @@ -3284,6 +3311,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3804,6 +3843,42 @@ "react-dom": ">=16.6.0" } }, + "node_modules/@tremor/react/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/@tremor/react/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/@tremor/react/node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tremor/react/node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -3814,6 +3889,28 @@ "url": "https://github.com/sponsors/dcastil" } }, + "node_modules/@tremor/react/node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -4069,6 +4166,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", @@ -6309,6 +6412,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -6872,9 +6985,9 @@ } }, "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/expect-type": { @@ -6901,9 +7014,9 @@ "license": "MIT" }, "node_modules/fast-equals": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", - "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -7688,6 +7801,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", + "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -11589,7 +11712,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT" }, "node_modules/react-json-view-lite": { @@ -11631,6 +11753,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -11707,26 +11852,33 @@ } }, "node_modules/recharts": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", - "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz", + "integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==", "license": "MIT", + "workspaces": [ + "www" + ], "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^18.3.1", - "react-smooth": "^4.0.4", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/recharts-scale": { @@ -11738,12 +11890,6 @@ "decimal.js-light": "^2.4.1" } }, - "node_modules/recharts/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -11758,6 +11904,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -13429,9 +13590,9 @@ } }, "node_modules/victory-vendor": { - "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 1b0ce315e4d..1747a40da56 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -50,6 +50,7 @@ "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", "react-syntax-highlighter": "15.6.6", + "recharts": "3.9.2", "remark-gfm": "4.0.1", "tailwind-merge": "3.4.0", "uuid": "14.0.0" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.test.tsx index 3a36eb9621e..dba34ea9a86 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.test.tsx @@ -1,35 +1,9 @@ import React from "react"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect } from "vitest"; import { screen } from "@testing-library/react"; import { renderWithProviders } from "../../../../../tests/test-utils"; import { ScoreChart } from "./ScoreChart"; -vi.mock("@tremor/react", async (importOriginal) => { - const actual = await importOriginal(); - // Re-apply the global Button/Tooltip overrides from tests/setupTests.ts. A file-level - // vi.mock fully replaces the setup-level mock, so without this the real Tremor Button - // leaks through and its useTooltip(300) schedules a native setTimeout that can fire - // post-teardown -> "window is not defined". - return { - ...actual, - BarChart: ({ data, categories }: { data: any[]; categories: string[] }) => ( -
- {data.map((d, i) => ( - - {d.date}: {categories.map((c) => `${c}=${d[c]}`).join(", ")} - - ))} -
- ), - Button: React.forwardRef(({ children, ...props }, ref) => ( - - )), - Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}, - }; -}); - describe("ScoreChart", () => { it("should render the title", () => { renderWithProviders(); @@ -55,10 +29,14 @@ describe("ScoreChart", () => { { date: "2026-03-02", passed: 15, blocked: 1 }, ]; - renderWithProviders(); + const { container } = renderWithProviders(); expect(screen.queryByText("No chart data for this period")).not.toBeInTheDocument(); - expect(screen.getByText(/2026-03-01/)).toBeInTheDocument(); - expect(screen.getByText(/2026-03-02/)).toBeInTheDocument(); + expect(screen.getByText("passed")).toBeInTheDocument(); + expect(screen.getByText("blocked")).toBeInTheDocument(); + expect(screen.getAllByText(/2026-03-01/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/2026-03-02/).length).toBeGreaterThan(0); + const bars = container.querySelectorAll(".recharts-bar"); + expect(bars).toHaveLength(2); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.tsx index daa6054a552..bc11a6fd3e0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.tsx @@ -1,9 +1,10 @@ -import { BarChart, Card, Title } from "@tremor/react"; import React from "react"; +import { BarChart } from "@/components/shared/charts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; /** * Overview chart: Request Outcomes Over Time (passed vs blocked). - * Uses Tremor BarChart with stacked data. Data from usage/overview API (chart array). + * Stacked bar chart. Data from usage/overview API (chart array). */ interface ScoreChartProps { data?: Array<{ date: string; passed: number; blocked: number }>; @@ -13,26 +14,31 @@ export function ScoreChart({ data }: ScoreChartProps) { const chartData = data && data.length > 0 ? data : []; return ( - - Request Outcomes Over Time -
- {chartData.length > 0 ? ( - v.toLocaleString()} - yAxisWidth={48} - showLegend={true} - stack={true} - /> - ) : ( -
- No chart data for this period -
- )} -
+ + + Request Outcomes Over Time + + +
+ {chartData.length > 0 ? ( + v.toLocaleString()} + yAxisWidth={48} + showLegend={true} + stack={true} + className="h-full" + /> + ) : ( +
+ No chart data for this period +
+ )} +
+
); } diff --git a/ui/litellm-dashboard/src/components/shared/charts/area_chart.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/area_chart.test.tsx new file mode 100644 index 00000000000..cd033c5ce27 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/area_chart.test.tsx @@ -0,0 +1,36 @@ +import { render } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { AreaChart } from "./area_chart"; + +const data = [ + { date: "2026-03-01", tokens: 100, requests: 10 }, + { date: "2026-03-02", tokens: 150, requests: 12 }, +]; + +describe("AreaChart", () => { + it("renders one area per category with the mapped stroke colors", () => { + const { container } = render( + , + ); + + const curves = Array.from(container.querySelectorAll("path.recharts-area-curve")); + expect(curves).toHaveLength(2); + const strokes = new Set(curves.map((curve) => curve.getAttribute("stroke"))); + expect(strokes).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"])); + }); + + it("renders a fade-out gradient fill per category", () => { + const { container } = render( + , + ); + + const gradients = container.querySelectorAll("defs linearGradient"); + expect(gradients).toHaveLength(2); + const areas = Array.from(container.querySelectorAll("path.recharts-area-area")); + expect(areas).toHaveLength(2); + for (const area of areas) { + expect(area.getAttribute("fill")).toMatch(/^url\(#fill-/); + } + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/charts/area_chart.tsx b/ui/litellm-dashboard/src/components/shared/charts/area_chart.tsx new file mode 100644 index 00000000000..794baa13cf7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/area_chart.tsx @@ -0,0 +1,92 @@ +"use client"; + +import * as React from "react"; +import { Area, AreaChart as RechartsAreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, type ChartConfig } from "@/components/ui/chart"; +import { cn } from "@/lib/cva.config"; +import { ValueTooltip, type ChartTooltipComponent } from "./chart_tooltip"; +import { categoryFills, type ChartColor } from "./colors"; + +export type AreaChartProps> = { + data: readonly TDatum[]; + index: string; + categories: readonly string[]; + colors?: readonly ChartColor[]; + valueFormatter?: (value: number) => string; + yAxisWidth?: number; + showLegend?: boolean; + showGridLines?: boolean; + showTooltip?: boolean; + customTooltip?: ChartTooltipComponent; + className?: string; + style?: React.CSSProperties; +}; + +export function AreaChart>({ + data, + index, + categories, + colors, + valueFormatter, + yAxisWidth = 56, + showLegend = true, + showGridLines = true, + showTooltip = true, + customTooltip, + className, + style, +}: AreaChartProps) { + const gradientId = React.useId().replace(/:/g, ""); + const fills = categoryFills(categories.length, colors); + const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }])); + const TooltipContent = customTooltip ?? ValueTooltip; + + return ( + + + + {categories.map((category, i) => ( + + + + + ))} + + {showGridLines && } + + + {showTooltip && ( + ( + + )} + /> + )} + {showLegend && ( + } + /> + )} + {categories.map((category, i) => ( + + ))} + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx new file mode 100644 index 00000000000..d5253c86c6f --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx @@ -0,0 +1,119 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { BarChart } from "./bar_chart"; + +const data = [ + { date: "2026-03-01", passed: 10, blocked: 2 }, + { date: "2026-03-02", passed: 15, blocked: 1 }, +]; + +describe("BarChart", () => { + it("renders one bar series per category with the mapped tremor colors", () => { + const { container } = render( + , + ); + + const rectangles = Array.from(container.querySelectorAll("path.recharts-rectangle")); + expect(rectangles).toHaveLength(4); + const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill"))); + expect(fills).toEqual(new Set(["var(--color-green-500, #22c55e)", "var(--color-red-500, #ef4444)"])); + }); + + it("falls back to the tremor default color cycle when no colors are passed", () => { + const { container } = render(); + + const fills = new Set( + Array.from(container.querySelectorAll("path.recharts-rectangle")).map((rect) => rect.getAttribute("fill")), + ); + expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"])); + }); + + it("fires onValueChange with the datum and clicked category", () => { + const onValueChange = vi.fn(); + const { container } = render( + , + ); + + const firstRect = container.querySelector("path.recharts-rectangle"); + expect(firstRect).not.toBeNull(); + fireEvent.click(firstRect!); + + expect(onValueChange).toHaveBeenCalledTimes(1); + const expectedClickItem = { + date: "2026-03-01", + passed: 10, + blocked: 2, + categoryClicked: "passed", + }; + expect(onValueChange).toHaveBeenCalledWith(expectedClickItem); + }); + + it("renders category labels on the y axis in vertical layout", () => { + render( + , + ); + + expect(screen.getAllByText("alpha").length).toBeGreaterThan(0); + expect(screen.getAllByText("beta").length).toBeGreaterThan(0); + }); + + it("applies valueFormatter to the value axis ticks", () => { + render( + `${v} req`} + />, + ); + + expect(screen.getAllByText(/ req$/).length).toBeGreaterThan(0); + }); + + it("renders a legend by default, matching tremor, and hides it when showLegend is false", () => { + const { container, rerender } = render( + , + ); + expect(screen.getByText("passed")).toBeInTheDocument(); + expect(container.querySelector(".recharts-legend-wrapper")).not.toBeNull(); + + rerender(); + expect(screen.queryByText("passed")).not.toBeInTheDocument(); + }); + + it("emits no per-chart style tag; colors flow through fills, not CSS vars", () => { + const { container } = render( + , + ); + expect(container.querySelector("style")).toBeNull(); + }); + + it("stacks bars into a single column per index when stack is set", () => { + const { container } = render( + , + ); + + const xPositions = Array.from(container.querySelectorAll("path.recharts-rectangle")).map( + (rect) => rect.getAttribute("d")?.split(",")[0], + ); + expect(new Set(xPositions).size).toBe(2); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx new file mode 100644 index 00000000000..6ee3319dc10 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx @@ -0,0 +1,119 @@ +"use client"; + +import * as React from "react"; +import { Bar, BarChart as RechartsBarChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, type ChartConfig } from "@/components/ui/chart"; +import { cn } from "@/lib/cva.config"; +import { ValueTooltip, type ChartTooltipComponent } from "./chart_tooltip"; +import { categoryFills, type ChartColor } from "./colors"; + +export type BarChartProps> = { + data: readonly TDatum[]; + index: string; + categories: readonly string[]; + colors?: readonly ChartColor[]; + valueFormatter?: (value: number) => string; + stack?: boolean; + layout?: "horizontal" | "vertical"; + yAxisWidth?: number; + tickGap?: number; + showLegend?: boolean; + showXAxis?: boolean; + showGridLines?: boolean; + showTooltip?: boolean; + customTooltip?: ChartTooltipComponent; + onValueChange?: (item: TDatum & { categoryClicked: string }) => void; + className?: string; + style?: React.CSSProperties; +}; + +export function BarChart>({ + data, + index, + categories, + colors, + valueFormatter, + stack = false, + layout = "horizontal", + yAxisWidth = 56, + tickGap = 5, + showLegend = true, + showXAxis = true, + showGridLines = true, + showTooltip = true, + customTooltip, + onValueChange, + className, + style, +}: BarChartProps) { + const fills = categoryFills(categories.length, colors); + const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }])); + const vertical = layout === "vertical"; + const TooltipContent = customTooltip ?? ValueTooltip; + + return ( + + + {showGridLines && } + {vertical ? ( + + ) : ( + + )} + {vertical ? ( + + ) : ( + + )} + {showTooltip && ( + ( + + )} + /> + )} + {showLegend && ( + } + /> + )} + {categories.map((category, i) => ( + { + if (item.payload) onValueChange({ ...item.payload, categoryClicked: category }); + } + : undefined + } + /> + ))} + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/charts/chart_legend.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/chart_legend.test.tsx new file mode 100644 index 00000000000..889927aca43 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/chart_legend.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { CustomLegend } from "./chart_legend"; + +describe("CustomLegend", () => { + it("renders title-cased category names without the metrics prefix", () => { + render(); + + expect(screen.getByText("Total Tokens")).toBeInTheDocument(); + expect(screen.getByText("Spend")).toBeInTheDocument(); + }); + + it("matches colors to categories by index with theme-var values", () => { + const { container } = render( + , + ); + + const dots = Array.from(container.querySelectorAll('span[style*="background-color"]')); + expect(dots[0]?.getAttribute("style")).toContain("--color-blue-500"); + expect(dots[1]?.getAttribute("style")).toContain("--color-green-500"); + }); + + it("cycles colors when there are more categories than colors", () => { + const { container } = render( + , + ); + + const dots = Array.from(container.querySelectorAll('span[style*="background-color"]')); + expect(dots[2]?.getAttribute("style")).toContain("--color-blue-500"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/charts/chart_legend.tsx b/ui/litellm-dashboard/src/components/shared/charts/chart_legend.tsx new file mode 100644 index 00000000000..da252d8bf63 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/chart_legend.tsx @@ -0,0 +1,25 @@ +"use client"; + +import * as React from "react"; +import { formatCategoryName } from "./chart_tooltip"; +import { chartColorValue, type ChartColor } from "./colors"; + +export const CustomLegend = ({ + categories, + colors, +}: { + categories: readonly string[]; + colors: readonly ChartColor[]; +}) => ( +
+ {categories.map((category, idx) => ( +
+ +

{formatCategoryName(category)}

+
+ ))} +
+); diff --git a/ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.test.tsx new file mode 100644 index 00000000000..7afc7532760 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { CustomTooltip, ValueTooltip, type ChartTooltipProps } from "./chart_tooltip"; + +const metricsPayload = ( + dataKey: string, + value: number, + color = "#3b82f6", +): NonNullable[number] => + ({ + dataKey, + value, + color, + payload: { + date: "2026-01-15", + metrics: { + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + spend: 1234.567, + api_requests: 10, + }, + }, + }) as NonNullable[number]; + +describe("CustomTooltip", () => { + it("returns null when not active or payload is empty", () => { + const inactive = render( + , + ); + expect(inactive.container.firstChild).toBeNull(); + + const empty = render(); + expect(empty.container.firstChild).toBeNull(); + }); + + it("renders the label and title-cased category names without the metrics prefix", () => { + render(); + + expect(screen.getByText("2026-01-15")).toBeInTheDocument(); + expect(screen.getByText("Total Tokens")).toBeInTheDocument(); + expect(screen.getByText("1,000")).toBeInTheDocument(); + }); + + it("formats spend values as dollars with two decimals", () => { + render(); + + expect(screen.getByText("$1,234.57")).toBeInTheDocument(); + }); + + it("shows N/A for metrics missing from the row payload", () => { + render(); + + expect(screen.getByText("N/A")).toBeInTheDocument(); + }); + + it("uses the series color for the indicator dot", () => { + const { container } = render( + , + ); + + const dot = container.querySelector('span[style*="background-color"]'); + expect(dot?.getAttribute("style")).toContain("--color-blue-500"); + }); +}); + +describe("ValueTooltip", () => { + const payload = [ + { + dataKey: "passed", + name: "passed", + value: 1000, + color: "#22c55e", + payload: { date: "2026-01-15", passed: 1000 }, + } as NonNullable[number], + ]; + + it("returns null when not active", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders label, series name, and locale-formatted value by default", () => { + render(); + + expect(screen.getByText("2026-01-15")).toBeInTheDocument(); + expect(screen.getByText("passed")).toBeInTheDocument(); + expect(screen.getByText("1,000")).toBeInTheDocument(); + }); + + it("applies the valueFormatter to values", () => { + render( `$${v}`} />); + + expect(screen.getByText("$1000")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.tsx b/ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.tsx new file mode 100644 index 00000000000..2644b8f720c --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.tsx @@ -0,0 +1,97 @@ +"use client"; + +import * as React from "react"; +import type { TooltipContentProps, TooltipValueType } from "recharts"; + +export type ChartTooltipProps = Pick< + TooltipContentProps, + "active" | "payload" | "label" +>; + +export type ChartTooltipComponent = React.ComponentType; + +export const formatCategoryName = (name: string): string => + name + .replace("metrics.", "") + .replace(/_/g, " ") + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + +export const ValueTooltip = ({ + active, + payload, + label, + valueFormatter, +}: ChartTooltipProps & { valueFormatter?: (value: number) => string }) => { + if (!active || !payload || payload.length === 0) return null; + + const formatValue = (value: unknown): string => { + if (typeof value === "number") return valueFormatter ? valueFormatter(value) : value.toLocaleString(); + return value == null ? "" : String(value); + }; + + return ( +
+ {label != null &&

{String(label)}

} +
+ {payload.map((item, idx) => ( +
+
+ + {String(item.name ?? item.dataKey ?? "")} +
+ {formatValue(item.value)} +
+ ))} +
+
+ ); +}; + +const rawMetricValue = (row: unknown, dataKey: string): number | undefined => { + if (typeof row !== "object" || row === null || !("metrics" in row)) return undefined; + const metrics = (row as { metrics: unknown }).metrics; + if (typeof metrics !== "object" || metrics === null) return undefined; + const metricKey = dataKey.substring(dataKey.indexOf(".") + 1); + const value = (metrics as Record)[metricKey]; + return typeof value === "number" ? value : undefined; +}; + +const formatMetricValue = (rawValue: number | undefined, isSpend: boolean): string => { + if (rawValue === undefined) return "N/A"; + if (isSpend) return `$${rawValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + return rawValue.toLocaleString(); +}; + +export const CustomTooltip = ({ active, payload, label }: ChartTooltipProps) => { + if (!active || !payload || payload.length === 0) return null; + + return ( +
+

{label == null ? "" : String(label)}

+ {payload.map((item) => { + const dataKey = item.dataKey?.toString(); + if (!dataKey || !item.payload) return null; + + const formattedValue = formatMetricValue(rawMetricValue(item.payload, dataKey), dataKey.includes("spend")); + + return ( +
+
+ +

{formatCategoryName(dataKey)}

+
+

{formattedValue}

+
+ ); + })} +
+ ); +}; diff --git a/ui/litellm-dashboard/src/components/shared/charts/colors.ts b/ui/litellm-dashboard/src/components/shared/charts/colors.ts new file mode 100644 index 00000000000..c30f58e9e4d --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/colors.ts @@ -0,0 +1,58 @@ +export const CHART_COLOR_HEX = { + slate: "#64748b", + gray: "#6b7280", + zinc: "#71717a", + neutral: "#737373", + stone: "#78716c", + red: "#ef4444", + orange: "#f97316", + amber: "#f59e0b", + yellow: "#eab308", + lime: "#84cc16", + green: "#22c55e", + emerald: "#10b981", + teal: "#14b8a6", + cyan: "#06b6d4", + sky: "#0ea5e9", + blue: "#3b82f6", + indigo: "#6366f1", + violet: "#8b5cf6", + purple: "#a855f7", + fuchsia: "#d946ef", + pink: "#ec4899", + rose: "#f43f5e", +} as const; + +export type ChartColor = keyof typeof CHART_COLOR_HEX; + +export const DEFAULT_COLOR_CYCLE: readonly ChartColor[] = [ + "blue", + "cyan", + "sky", + "indigo", + "violet", + "purple", + "fuchsia", + "slate", + "gray", + "zinc", + "neutral", + "stone", + "red", + "orange", + "amber", + "yellow", + "lime", + "green", + "emerald", + "teal", + "pink", + "rose", +]; + +export const chartColorValue = (color: ChartColor): string => `var(--color-${color}-500, ${CHART_COLOR_HEX[color]})`; + +export const categoryFills = (count: number, colors?: readonly ChartColor[]): readonly string[] => { + const cycle = colors && colors.length > 0 ? colors : DEFAULT_COLOR_CYCLE; + return Array.from({ length: count }, (_, i) => chartColorValue(cycle[i % cycle.length])); +}; diff --git a/ui/litellm-dashboard/src/components/shared/charts/donut_chart.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/donut_chart.test.tsx new file mode 100644 index 00000000000..123c6cad0ec --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/donut_chart.test.tsx @@ -0,0 +1,38 @@ +import { render } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { DonutChart } from "./donut_chart"; + +const data = [ + { provider: "openai", spend: 40 }, + { provider: "anthropic", spend: 30 }, + { provider: "bedrock", spend: 20 }, +]; + +describe("DonutChart", () => { + it("renders one sector per datum, cycling the given colors", () => { + const { container } = render( + , + ); + + const sectors = Array.from(container.querySelectorAll(".recharts-pie-sector path")); + expect(sectors).toHaveLength(3); + expect(sectors.map((sector) => sector.getAttribute("fill"))).toEqual([ + "var(--color-cyan-500, #06b6d4)", + "var(--color-blue-500, #3b82f6)", + "var(--color-cyan-500, #06b6d4)", + ]); + }); + + it("renders a full pie when variant is pie and a hollow donut otherwise", () => { + const { container: donut } = render(); + const { container: pie } = render( + , + ); + + const donutPath = donut.querySelector(".recharts-pie-sector path")?.getAttribute("d") ?? ""; + const piePath = pie.querySelector(".recharts-pie-sector path")?.getAttribute("d") ?? ""; + expect(donutPath).not.toEqual(piePath); + expect((donutPath.match(/A/g) ?? []).length).toBeGreaterThan((piePath.match(/A/g) ?? []).length); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/charts/donut_chart.tsx b/ui/litellm-dashboard/src/components/shared/charts/donut_chart.tsx new file mode 100644 index 00000000000..c2ce8c02e35 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/donut_chart.tsx @@ -0,0 +1,67 @@ +"use client"; + +import * as React from "react"; +import { Cell, Pie, PieChart } from "recharts"; +import { ChartContainer, ChartTooltip, type ChartConfig } from "@/components/ui/chart"; +import { cn } from "@/lib/cva.config"; +import { ValueTooltip } from "./chart_tooltip"; +import { categoryFills, type ChartColor } from "./colors"; + +export type DonutChartProps> = { + data: readonly TDatum[]; + index: string; + category: string; + colors?: readonly ChartColor[]; + variant?: "donut" | "pie"; + valueFormatter?: (value: number) => string; + showTooltip?: boolean; + className?: string; + style?: React.CSSProperties; +}; + +export function DonutChart>({ + data, + index, + category, + colors, + variant = "donut", + valueFormatter, + showTooltip = true, + className, + style, +}: DonutChartProps) { + const fills = categoryFills(data.length, colors); + const config: ChartConfig = Object.fromEntries( + data.map((datum, i) => { + const name = String(datum[index] ?? i); + return [name, { label: name }]; + }), + ); + + return ( + + + {showTooltip && ( + ( + + )} + /> + )} + + {data.map((datum, i) => ( + + ))} + + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/charts/index.ts b/ui/litellm-dashboard/src/components/shared/charts/index.ts new file mode 100644 index 00000000000..ba0a7544ddb --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/index.ts @@ -0,0 +1,12 @@ +export { AreaChart, type AreaChartProps } from "./area_chart"; +export { BarChart, type BarChartProps } from "./bar_chart"; +export { CustomLegend } from "./chart_legend"; +export { + CustomTooltip, + ValueTooltip, + formatCategoryName, + type ChartTooltipComponent, + type ChartTooltipProps, +} from "./chart_tooltip"; +export { CHART_COLOR_HEX, DEFAULT_COLOR_CYCLE, categoryFills, chartColorValue, type ChartColor } from "./colors"; +export { DonutChart, type DonutChartProps } from "./donut_chart"; diff --git a/ui/litellm-dashboard/src/components/ui/card.tsx b/ui/litellm-dashboard/src/components/ui/card.tsx new file mode 100644 index 00000000000..3fc0aa65264 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/card.tsx @@ -0,0 +1,86 @@ +import * as React from "react"; + +import { cn } from "@/lib/cva.config"; + +const Card = React.forwardRef & { size?: "default" | "sm" }>( + ({ className, size = "default", ...props }, ref) => ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className, + )} + {...props} + /> + ), +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardDescription.displayName = "CardDescription"; + +const CardAction = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardAction.displayName = "CardAction"; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/ui/litellm-dashboard/src/components/ui/chart.test.tsx b/ui/litellm-dashboard/src/components/ui/chart.test.tsx new file mode 100644 index 00000000000..8b70a6e3246 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/chart.test.tsx @@ -0,0 +1,38 @@ +import { render } from "@testing-library/react"; +import * as React from "react"; +import { describe, expect, it } from "vitest"; +import { ChartContainer } from "./chart"; + +describe("ChartStyle hardening", () => { + it("sanitizes config keys and strips structural characters from color values", () => { + const { container } = render( + " }, + }} + > + + , + ); + + const style = container.querySelector("style"); + expect(style).not.toBeNull(); + const css = style!.innerHTML; + + expect(css).toContain("--color-metrics_total_tokens: var(--color-blue-500, #3b82f6);"); + expect(css).not.toContain("metrics.total_tokens"); + expect(css).toContain("--color-evil_key:"); + expect(css).not.toContain("<"); + expect((css.match(/{/g) ?? []).length).toBe((css.match(/}/g) ?? []).length); + }); + + it("emits no style tag when no config entry has a color", () => { + const { container } = render( + + + , + ); + expect(container.querySelector("style")).toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ui/chart.tsx b/ui/litellm-dashboard/src/components/ui/chart.tsx new file mode 100644 index 00000000000..14e10b9f06f --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/chart.tsx @@ -0,0 +1,324 @@ +"use client"; + +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; +import type { TooltipValueType } from "recharts"; + +import { cn } from "@/lib/cva.config"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const; +type TooltipNameType = number | string; + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ({ color?: string; theme?: never } | { color?: never; theme: Record }) +>; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef<"div"> & { + config: ChartConfig; + children: React.ComponentProps["children"]; + initialDimension?: { + width: number; + height: number; + }; + } +>(({ id, className, children, config, initialDimension = INITIAL_DIMENSION, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +}); +ChartContainer.displayName = "ChartContainer"; + +const cssVarName = (key: string) => key.replace(/[^a-zA-Z0-9_-]/g, "_"); +const cssColorValue = (color: string) => color.replace(/[;{}<>]/g, ""); + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color); + + if (!colorConfig.length) { + return null; + } + + return ( +