mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(ui): shadcn charts foundation with tremor-compatible wrappers (#32668)
This commit is contained in:
parent
eec948dcb8
commit
592510ec18
23 changed files with 1582 additions and 129 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
221
ui/litellm-dashboard/package-lock.json
generated
221
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@tremor/react")>();
|
||||
// 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[] }) => (
|
||||
<div data-testid="bar-chart">
|
||||
{data.map((d, i) => (
|
||||
<span key={i}>
|
||||
{d.date}: {categories.map((c) => `${c}=${d[c]}`).join(", ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Button: React.forwardRef<HTMLButtonElement, any>(({ children, ...props }, ref) => (
|
||||
<button {...props} ref={ref}>
|
||||
{children}
|
||||
</button>
|
||||
)),
|
||||
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("ScoreChart", () => {
|
||||
it("should render the title", () => {
|
||||
renderWithProviders(<ScoreChart />);
|
||||
|
|
@ -55,10 +29,14 @@ describe("ScoreChart", () => {
|
|||
{ date: "2026-03-02", passed: 15, blocked: 1 },
|
||||
];
|
||||
|
||||
renderWithProviders(<ScoreChart data={data} />);
|
||||
const { container } = renderWithProviders(<ScoreChart data={data} />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Card className="bg-white border border-gray-200">
|
||||
<Title className="text-base font-semibold text-gray-900 mb-4">Request Outcomes Over Time</Title>
|
||||
<div className="h-80 min-h-[280px]">
|
||||
{chartData.length > 0 ? (
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="date"
|
||||
categories={["passed", "blocked"]}
|
||||
colors={["green", "red"]}
|
||||
valueFormatter={(v) => v.toLocaleString()}
|
||||
yAxisWidth={48}
|
||||
showLegend={true}
|
||||
stack={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-500">
|
||||
No chart data for this period
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Request Outcomes Over Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-80 min-h-[280px]">
|
||||
{chartData.length > 0 ? (
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="date"
|
||||
categories={["passed", "blocked"]}
|
||||
colors={["green", "red"]}
|
||||
valueFormatter={(v) => v.toLocaleString()}
|
||||
yAxisWidth={48}
|
||||
showLegend={true}
|
||||
stack={true}
|
||||
className="h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-500">
|
||||
No chart data for this period
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<AreaChart data={data} index="date" categories={["tokens", "requests"]} colors={["blue", "cyan"]} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<AreaChart data={data} index="date" categories={["tokens", "requests"]} colors={["blue", "cyan"]} />,
|
||||
);
|
||||
|
||||
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-/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<TDatum extends Record<string, unknown>> = {
|
||||
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<TDatum extends Record<string, unknown>>({
|
||||
data,
|
||||
index,
|
||||
categories,
|
||||
colors,
|
||||
valueFormatter,
|
||||
yAxisWidth = 56,
|
||||
showLegend = true,
|
||||
showGridLines = true,
|
||||
showTooltip = true,
|
||||
customTooltip,
|
||||
className,
|
||||
style,
|
||||
}: AreaChartProps<TDatum>) {
|
||||
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 (
|
||||
<ChartContainer config={config} className={cn("aspect-auto h-80 w-full", className)} style={style}>
|
||||
<RechartsAreaChart data={[...data]}>
|
||||
<defs>
|
||||
{categories.map((category, i) => (
|
||||
<linearGradient key={category} id={`fill-${gradientId}-${i}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={fills[i]} stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor={fills[i]} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{showGridLines && <CartesianGrid vertical={false} />}
|
||||
<XAxis dataKey={index} tickLine={false} axisLine={false} minTickGap={5} interval="equidistantPreserveStart" />
|
||||
<YAxis width={yAxisWidth} tickLine={false} axisLine={false} tickFormatter={valueFormatter} />
|
||||
{showTooltip && (
|
||||
<ChartTooltip
|
||||
content={({ active, payload, label }) => (
|
||||
<TooltipContent
|
||||
active={active}
|
||||
payload={payload}
|
||||
label={label}
|
||||
{...(customTooltip ? {} : { valueFormatter })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showLegend && (
|
||||
<ChartLegend
|
||||
verticalAlign="top"
|
||||
content={<ChartLegendContent className="justify-end text-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
{categories.map((category, i) => (
|
||||
<Area
|
||||
key={category}
|
||||
type="linear"
|
||||
dataKey={category}
|
||||
stroke={fills[i]}
|
||||
strokeWidth={2}
|
||||
fill={`url(#fill-${gradientId}-${i})`}
|
||||
fillOpacity={1}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</RechartsAreaChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
<BarChart data={data} index="date" categories={["passed", "blocked"]} colors={["green", "red"]} />,
|
||||
);
|
||||
|
||||
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(<BarChart data={data} index="date" categories={["passed", "blocked"]} />);
|
||||
|
||||
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(
|
||||
<BarChart
|
||||
data={data}
|
||||
index="date"
|
||||
categories={["passed", "blocked"]}
|
||||
colors={["green", "red"]}
|
||||
onValueChange={onValueChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<BarChart
|
||||
data={[
|
||||
{ key: "alpha", spend: 12 },
|
||||
{ key: "beta", spend: 7 },
|
||||
]}
|
||||
index="key"
|
||||
categories={["spend"]}
|
||||
colors={["cyan"]}
|
||||
layout="vertical"
|
||||
yAxisWidth={120}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("alpha").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("beta").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("applies valueFormatter to the value axis ticks", () => {
|
||||
render(
|
||||
<BarChart
|
||||
data={data}
|
||||
index="date"
|
||||
categories={["passed"]}
|
||||
colors={["green"]}
|
||||
valueFormatter={(v) => `${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(
|
||||
<BarChart data={data} index="date" categories={["passed"]} colors={["green"]} />,
|
||||
);
|
||||
expect(screen.getByText("passed")).toBeInTheDocument();
|
||||
expect(container.querySelector(".recharts-legend-wrapper")).not.toBeNull();
|
||||
|
||||
rerender(<BarChart data={data} index="date" categories={["passed"]} colors={["green"]} showLegend={false} />);
|
||||
expect(screen.queryByText("passed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("emits no per-chart style tag; colors flow through fills, not CSS vars", () => {
|
||||
const { container } = render(
|
||||
<BarChart data={data} index="date" categories={["passed", "blocked"]} colors={["green", "red"]} />,
|
||||
);
|
||||
expect(container.querySelector("style")).toBeNull();
|
||||
});
|
||||
|
||||
it("stacks bars into a single column per index when stack is set", () => {
|
||||
const { container } = render(
|
||||
<BarChart data={data} index="date" categories={["passed", "blocked"]} colors={["green", "red"]} stack={true} />,
|
||||
);
|
||||
|
||||
const xPositions = Array.from(container.querySelectorAll("path.recharts-rectangle")).map(
|
||||
(rect) => rect.getAttribute("d")?.split(",")[0],
|
||||
);
|
||||
expect(new Set(xPositions).size).toBe(2);
|
||||
});
|
||||
});
|
||||
119
ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx
Normal file
119
ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx
Normal file
|
|
@ -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<TDatum extends Record<string, unknown>> = {
|
||||
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<TDatum extends Record<string, unknown>>({
|
||||
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<TDatum>) {
|
||||
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 (
|
||||
<ChartContainer config={config} className={cn("aspect-auto h-80 w-full", className)} style={style}>
|
||||
<RechartsBarChart data={[...data]} layout={layout}>
|
||||
{showGridLines && <CartesianGrid horizontal={!vertical} vertical={vertical} />}
|
||||
{vertical ? (
|
||||
<XAxis
|
||||
type="number"
|
||||
hide={!showXAxis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={tickGap}
|
||||
tickFormatter={valueFormatter}
|
||||
/>
|
||||
) : (
|
||||
<XAxis
|
||||
dataKey={index}
|
||||
hide={!showXAxis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={tickGap}
|
||||
interval="equidistantPreserveStart"
|
||||
/>
|
||||
)}
|
||||
{vertical ? (
|
||||
<YAxis type="category" dataKey={index} width={yAxisWidth} tickLine={false} axisLine={false} interval={0} />
|
||||
) : (
|
||||
<YAxis width={yAxisWidth} tickLine={false} axisLine={false} tickFormatter={valueFormatter} />
|
||||
)}
|
||||
{showTooltip && (
|
||||
<ChartTooltip
|
||||
content={({ active, payload, label }) => (
|
||||
<TooltipContent
|
||||
active={active}
|
||||
payload={payload}
|
||||
label={label}
|
||||
{...(customTooltip ? {} : { valueFormatter })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showLegend && (
|
||||
<ChartLegend
|
||||
verticalAlign="top"
|
||||
content={<ChartLegendContent className="justify-end text-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
{categories.map((category, i) => (
|
||||
<Bar
|
||||
key={category}
|
||||
dataKey={category}
|
||||
fill={fills[i]}
|
||||
stackId={stack ? "stack" : undefined}
|
||||
isAnimationActive={false}
|
||||
onClick={
|
||||
onValueChange
|
||||
? (item: { payload?: TDatum }) => {
|
||||
if (item.payload) onValueChange({ ...item.payload, categoryClicked: category });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</RechartsBarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(<CustomLegend categories={["metrics.total_tokens", "metrics.spend"]} colors={["blue", "green"]} />);
|
||||
|
||||
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(
|
||||
<CustomLegend categories={["metrics.first", "metrics.second"]} colors={["blue", "green"]} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<CustomLegend categories={["metrics.a", "metrics.b", "metrics.c"]} colors={["blue", "green"]} />,
|
||||
);
|
||||
|
||||
const dots = Array.from(container.querySelectorAll('span[style*="background-color"]'));
|
||||
expect(dots[2]?.getAttribute("style")).toContain("--color-blue-500");
|
||||
});
|
||||
});
|
||||
|
|
@ -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[];
|
||||
}) => (
|
||||
<div className="flex items-center justify-end space-x-4">
|
||||
{categories.map((category, idx) => (
|
||||
<div key={category} className="flex items-center space-x-2">
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full ring-4 ring-white"
|
||||
style={{ backgroundColor: chartColorValue(colors[idx % colors.length]) }}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">{formatCategoryName(category)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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<ChartTooltipProps["payload"]>[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<ChartTooltipProps["payload"]>[number];
|
||||
|
||||
describe("CustomTooltip", () => {
|
||||
it("returns null when not active or payload is empty", () => {
|
||||
const inactive = render(
|
||||
<CustomTooltip active={false} payload={[metricsPayload("metrics.total_tokens", 1000)]} label="2026-01-15" />,
|
||||
);
|
||||
expect(inactive.container.firstChild).toBeNull();
|
||||
|
||||
const empty = render(<CustomTooltip active={true} payload={[]} label="2026-01-15" />);
|
||||
expect(empty.container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the label and title-cased category names without the metrics prefix", () => {
|
||||
render(<CustomTooltip active={true} payload={[metricsPayload("metrics.total_tokens", 1000)]} label="2026-01-15" />);
|
||||
|
||||
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(<CustomTooltip active={true} payload={[metricsPayload("metrics.spend", 1234.567)]} label="2026-01-15" />);
|
||||
|
||||
expect(screen.getByText("$1,234.57")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows N/A for metrics missing from the row payload", () => {
|
||||
render(<CustomTooltip active={true} payload={[metricsPayload("metrics.nonexistent", 1000)]} label="2026-01-15" />);
|
||||
|
||||
expect(screen.getByText("N/A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the series color for the indicator dot", () => {
|
||||
const { container } = render(
|
||||
<CustomTooltip
|
||||
active={true}
|
||||
payload={[metricsPayload("metrics.total_tokens", 1000, "var(--color-blue-500, #3b82f6)")]}
|
||||
label="2026-01-15"
|
||||
/>,
|
||||
);
|
||||
|
||||
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<ChartTooltipProps["payload"]>[number],
|
||||
];
|
||||
|
||||
it("returns null when not active", () => {
|
||||
const { container } = render(<ValueTooltip active={false} payload={payload} label="2026-01-15" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders label, series name, and locale-formatted value by default", () => {
|
||||
render(<ValueTooltip active={true} payload={payload} label="2026-01-15" />);
|
||||
|
||||
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(<ValueTooltip active={true} payload={payload} label="2026-01-15" valueFormatter={(v) => `$${v}`} />);
|
||||
|
||||
expect(screen.getByText("$1000")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { TooltipContentProps, TooltipValueType } from "recharts";
|
||||
|
||||
export type ChartTooltipProps = Pick<
|
||||
TooltipContentProps<TooltipValueType, string | number>,
|
||||
"active" | "payload" | "label"
|
||||
>;
|
||||
|
||||
export type ChartTooltipComponent = React.ComponentType<ChartTooltipProps>;
|
||||
|
||||
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 (
|
||||
<div className="min-w-32 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||
{label != null && <p className="mb-1.5 font-medium text-foreground">{String(label)}</p>}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, idx) => (
|
||||
<div
|
||||
key={String(item.dataKey ?? item.name ?? idx)}
|
||||
className="flex w-full items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: item.color }} />
|
||||
<span className="text-muted-foreground">{String(item.name ?? item.dataKey ?? "")}</span>
|
||||
</div>
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">{formatValue(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<string, unknown>)[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 (
|
||||
<div className="w-56 rounded-lg border border-border/50 bg-background p-2 text-xs shadow-xl">
|
||||
<p className="font-medium text-foreground">{label == null ? "" : String(label)}</p>
|
||||
{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 (
|
||||
<div key={dataKey} className="flex items-center justify-between space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full ring-2 ring-white drop-shadow-md"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<p className="font-medium text-muted-foreground">{formatCategoryName(dataKey)}</p>
|
||||
</div>
|
||||
<p className="font-medium text-foreground">{formattedValue}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
58
ui/litellm-dashboard/src/components/shared/charts/colors.ts
Normal file
58
ui/litellm-dashboard/src/components/shared/charts/colors.ts
Normal file
|
|
@ -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]));
|
||||
};
|
||||
|
|
@ -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(
|
||||
<DonutChart data={data} index="provider" category="spend" colors={["cyan", "blue"]} />,
|
||||
);
|
||||
|
||||
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(<DonutChart data={data} index="provider" category="spend" colors={["cyan"]} />);
|
||||
const { container: pie } = render(
|
||||
<DonutChart data={data} index="provider" category="spend" colors={["cyan"]} variant="pie" />,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<TDatum extends Record<string, unknown>> = {
|
||||
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<TDatum extends Record<string, unknown>>({
|
||||
data,
|
||||
index,
|
||||
category,
|
||||
colors,
|
||||
variant = "donut",
|
||||
valueFormatter,
|
||||
showTooltip = true,
|
||||
className,
|
||||
style,
|
||||
}: DonutChartProps<TDatum>) {
|
||||
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 (
|
||||
<ChartContainer config={config} className={cn("aspect-auto h-40 w-full", className)} style={style}>
|
||||
<PieChart>
|
||||
{showTooltip && (
|
||||
<ChartTooltip
|
||||
content={({ active, payload, label }) => (
|
||||
<ValueTooltip active={active} payload={payload} label={label} valueFormatter={valueFormatter} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Pie
|
||||
data={[...data]}
|
||||
dataKey={category}
|
||||
nameKey={index}
|
||||
innerRadius={variant === "pie" ? "0%" : "75%"}
|
||||
outerRadius="100%"
|
||||
strokeWidth={1}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{data.map((datum, i) => (
|
||||
<Cell key={String(datum[index] ?? i)} fill={fills[i]} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
12
ui/litellm-dashboard/src/components/shared/charts/index.ts
Normal file
12
ui/litellm-dashboard/src/components/shared/charts/index.ts
Normal file
|
|
@ -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";
|
||||
86
ui/litellm-dashboard/src/components/ui/card.tsx
Normal file
86
ui/litellm-dashboard/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div"> & { size?: "default" | "sm" }>(
|
||||
({ className, size = "default", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 [--card-spacing:--spacing(6)] has-[>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<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-title"
|
||||
className={cn("text-base leading-normal font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardAction = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-action"
|
||||
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardAction.displayName = "CardAction";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} data-slot="card-content" className={cn("px-(--card-spacing)", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center rounded-b-xl px-(--card-spacing) [.border-t]:pt-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||
38
ui/litellm-dashboard/src/components/ui/chart.test.tsx
Normal file
38
ui/litellm-dashboard/src/components/ui/chart.test.tsx
Normal file
|
|
@ -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(
|
||||
<ChartContainer
|
||||
config={{
|
||||
"metrics.total_tokens": { label: "Total Tokens", color: "var(--color-blue-500, #3b82f6)" },
|
||||
"evil}key": { label: "evil", color: "red;}</style><img src=x onerror=alert(1)>" },
|
||||
}}
|
||||
>
|
||||
<svg />
|
||||
</ChartContainer>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ChartContainer config={{ passed: { label: "passed" } }}>
|
||||
<svg />
|
||||
</ChartContainer>,
|
||||
);
|
||||
expect(container.querySelector("style")).toBeNull();
|
||||
});
|
||||
});
|
||||
324
ui/litellm-dashboard/src/components/ui/chart.tsx
Normal file
324
ui/litellm-dashboard/src/components/ui/chart.tsx
Normal file
|
|
@ -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<keyof typeof THEMES, string> })
|
||||
>;
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentPropsWithoutRef<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["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 (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
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 (
|
||||
<style
|
||||
// eslint-disable-next-line react/no-danger -- vendored shadcn template; emits per-chart CSS vars from our own config values, no user input
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
|
||||
return color ? ` --color-${cssVarName(key)}: ${cssColorValue(color)};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentPropsWithoutRef<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
} & Omit<RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>, "accessibilityLayer">
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value = !labelKey && typeof label === "string" ? config[label]?.label ?? label : itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color;
|
||||
const indicatorVariants = {
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
indicatorVariants,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">{itemConfig?.label ?? item.name}</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number" ? item.value.toLocaleString() : String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltipContent";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentPropsWithoutRef<"div"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
} & RechartsPrimitive.DefaultLegendContentProps
|
||||
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ChartLegendContent.displayName = "ChartLegendContent";
|
||||
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||
}
|
||||
|
||||
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
|
||||
|
|
@ -3,6 +3,8 @@ import * as React from "react";
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Button } from "./button";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./card";
|
||||
import { ChartContainer } from "./chart";
|
||||
import { Input } from "./input";
|
||||
import { Label } from "./label";
|
||||
import { Separator } from "./separator";
|
||||
|
|
@ -47,6 +49,46 @@ describe("ui primitives forward refs to their DOM node", () => {
|
|||
expect(ref.current).toBeInstanceOf(SVGSVGElement);
|
||||
});
|
||||
|
||||
it("Card family", () => {
|
||||
const card = React.createRef<HTMLDivElement>();
|
||||
const header = React.createRef<HTMLDivElement>();
|
||||
const title = React.createRef<HTMLDivElement>();
|
||||
const description = React.createRef<HTMLDivElement>();
|
||||
const action = React.createRef<HTMLDivElement>();
|
||||
const content = React.createRef<HTMLDivElement>();
|
||||
const footer = React.createRef<HTMLDivElement>();
|
||||
|
||||
render(
|
||||
<Card ref={card}>
|
||||
<CardHeader ref={header}>
|
||||
<CardTitle ref={title}>t</CardTitle>
|
||||
<CardDescription ref={description}>d</CardDescription>
|
||||
<CardAction ref={action}>a</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent ref={content}>c</CardContent>
|
||||
<CardFooter ref={footer}>f</CardFooter>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(card.current).toBeInstanceOf(HTMLDivElement);
|
||||
expect(header.current).toBeInstanceOf(HTMLDivElement);
|
||||
expect(title.current).toBeInstanceOf(HTMLDivElement);
|
||||
expect(description.current).toBeInstanceOf(HTMLDivElement);
|
||||
expect(action.current).toBeInstanceOf(HTMLDivElement);
|
||||
expect(content.current).toBeInstanceOf(HTMLDivElement);
|
||||
expect(footer.current).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it("ChartContainer", () => {
|
||||
const ref = React.createRef<HTMLDivElement>();
|
||||
render(
|
||||
<ChartContainer ref={ref} config={{ passed: { label: "passed", color: "#22c55e" } }}>
|
||||
<svg />
|
||||
</ChartContainer>,
|
||||
);
|
||||
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it("Table family", () => {
|
||||
const table = React.createRef<HTMLTableElement>();
|
||||
const caption = React.createRef<HTMLTableCaptionElement>();
|
||||
|
|
|
|||
|
|
@ -220,10 +220,41 @@ if (!URL.revokeObjectURL) {
|
|||
URL.revokeObjectURL = () => {};
|
||||
}
|
||||
|
||||
// Mock ResizeObserver for components that use it (e.g., Tremor UI components)
|
||||
// This prevents "ResizeObserver is not defined" errors in JSDOM
|
||||
// Mock ResizeObserver for components that use it (recharts, Tremor UI components).
|
||||
// JSDOM has no layout, so for observers inside a shadcn ChartContainer ([data-slot="chart"])
|
||||
// the mock immediately reports a fixed 800x400 box; recharts renders nothing until it
|
||||
// observes a size. Scoped to chart subtrees only: firing for every observer re-enters
|
||||
// React mid-effect for tremor/headlessui consumers whose tests assume the old no-op
|
||||
// (chart text would duplicate getByText targets, popover clicks go stale). Widen or
|
||||
// drop the scoping once tremor is gone.
|
||||
const MOCK_RESIZE_BOX = { inlineSize: 800, blockSize: 400 };
|
||||
const MOCK_RESIZE_RECT: DOMRectReadOnly = {
|
||||
width: 800,
|
||||
height: 400,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 400,
|
||||
right: 800,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
private readonly callback: ResizeObserverCallback;
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
observe(target: Element) {
|
||||
if (!target.closest('[data-slot="chart"]')) return;
|
||||
const entry: ResizeObserverEntry = {
|
||||
target,
|
||||
contentRect: MOCK_RESIZE_RECT,
|
||||
borderBoxSize: [MOCK_RESIZE_BOX],
|
||||
contentBoxSize: [MOCK_RESIZE_BOX],
|
||||
devicePixelContentBoxSize: [MOCK_RESIZE_BOX],
|
||||
};
|
||||
this.callback([entry], this);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue