diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index abd21765ce0..7b8fcc4896f 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -1,3 +1,4 @@ +import useTeams from "@/app/(dashboard)/hooks/useTeams"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { BarChart, @@ -23,6 +24,7 @@ import { } from "@tremor/react"; import React, { useEffect, useState } from "react"; import { ActivityMetrics, processActivityData } from "../../../activity_metrics"; +import NewBadge from "../../../common_components/NewBadge"; import { UsageExportHeader } from "../../../EntityUsageExport"; import type { EntityType } from "../../../EntityUsageExport/types"; import { @@ -35,11 +37,9 @@ import { import { getProviderLogoAndName } from "../../../provider_info_helpers"; import { BreakdownMetrics, DailyData, EntityMetricWithMetadata, KeyMetricWithMetadata, TagUsage } from "../../types"; import { valueFormatterSpend } from "../../utils/value_formatters"; +import EndpointUsage from "../EndpointUsage/EndpointUsage"; import TopKeyView from "./TopKeyView"; import TopModelView from "./TopModelView"; -import useTeams from "@/app/(dashboard)/hooks/useTeams"; -import EndpointUsage from "../EndpointUsage/EndpointUsage"; -import NewBadge from "../../../common_components/NewBadge"; interface EntityMetrics { metrics: { @@ -87,16 +87,7 @@ interface EntityUsageProps { dateValue: DateRangePickerValue; } -const EntityUsage: React.FC = ({ - accessToken, - entityType, - entityId, - userID, - userRole, - entityList, - premiumUser, - dateValue, -}) => { +const EntityUsage: React.FC = ({ accessToken, entityType, entityId, entityList, dateValue }) => { const [spendData, setSpendData] = useState({ results: [], metadata: { @@ -112,6 +103,8 @@ const EntityUsage: React.FC = ({ const modelMetrics = processActivityData(spendData, "models", teams || []); const keyMetrics = processActivityData(spendData, "api_keys", teams || []); const [selectedTags, setSelectedTags] = useState([]); + const [topKeysLimit, setTopKeysLimit] = useState(5); + const [topModelsLimit, setTopModelsLimit] = useState(5); const fetchSpendData = async () => { if (!accessToken || !dateValue.from || !dateValue.to) return; @@ -204,7 +197,7 @@ const EntityUsage: React.FC = ({ ...metrics, })) .sort((a, b) => b.spend - a.spend) - .slice(0, 5); + .slice(0, topModelsLimit); }; const getTopAPIKeys = () => { @@ -269,7 +262,7 @@ const EntityUsage: React.FC = ({ spend: metrics.metrics.spend, })) .sort((a, b) => b.spend - a.spend) - .slice(0, 5); + .slice(0, topKeysLimit); }; const getProviderSpend = () => { @@ -598,7 +591,13 @@ const EntityUsage: React.FC = ({ Top Virtual Keys - + @@ -606,7 +605,11 @@ const EntityUsage: React.FC = ({ {entityType === "agent" ? "Top Agents" : "Top Models"} - + diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx index 6c074623139..126bf51bd36 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx @@ -1,15 +1,39 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import TopKeyView from "./TopKeyView"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { KeyResponse } from "../../../key_team_helpers/key_list"; +import * as transformKeyInfo from "../../../key_team_helpers/transform_key_info"; +import * as networking from "../../../networking"; +import TopKeyView from "./TopKeyView"; vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ __esModule: true, default: vi.fn(), })); +vi.mock("../../../networking", () => ({ + keyInfoV1Call: vi.fn(), +})); + +vi.mock("../../../key_team_helpers/transform_key_info", () => ({ + transformKeyInfo: vi.fn(), +})); + +vi.mock("../../../templates/key_info_view", () => ({ + default: ({ keyId, onClose }: { keyId: string; onClose: () => void }) => ( +
+
Key Info View for {keyId}
+ +
+ ), +})); + describe("TopKeyView", () => { const mockUseAuthorized = vi.mocked(useAuthorized); + const mockKeyInfoV1Call = vi.mocked(networking.keyInfoV1Call); + const mockTransformKeyInfo = vi.mocked(transformKeyInfo.transformKeyInfo); + const mockAuth = { token: "mock-token", accessToken: "test-token", @@ -20,46 +44,60 @@ describe("TopKeyView", () => { disabledPersonalKeyCreation: false, showSSOBanner: false, }; + + const mockSetTopKeysLimit = vi.fn(); + const baseProps = { topKeys: [], teams: null, showTags: false, + topKeysLimit: 5, + setTopKeysLimit: mockSetTopKeysLimit, }; beforeEach(() => { mockUseAuthorized.mockReturnValue(mockAuth); + mockSetTopKeysLimit.mockClear(); + mockKeyInfoV1Call.mockClear(); + mockTransformKeyInfo.mockClear(); }); it("should render", () => { render(); - expect(screen.getByText("Table View")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument(); }); - it("should have a table view button", () => { + it("should display table view button", () => { render(); - expect(screen.getByText("Table View")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument(); }); - it("should have a chart view", () => { + it("should display chart view button", () => { render(); - expect(screen.getByText("Chart View")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Chart View" })).toBeInTheDocument(); }); - ["Key ID", "Key Alias", "Spend (USD)"].forEach((header) => { - it(`should have a ${header} column`, () => { - render(); - expect(screen.getByText(header)).toBeInTheDocument(); - }); + it("should display base table column headers", () => { + render(); + expect(screen.getByText("Key ID")).toBeInTheDocument(); + expect(screen.getByText("Key Alias")).toBeInTheDocument(); + expect(screen.getByText("Spend (USD)")).toBeInTheDocument(); }); - it("should have a Tags column when showTags is true", () => { + it("should display Tags column when showTags is true", () => { render(); expect(screen.getByText("Tags")).toBeInTheDocument(); }); - it("should show the key's information on the table", () => { + it("should not display Tags column when showTags is false", () => { + render(); + expect(screen.queryByText("Tags")).not.toBeInTheDocument(); + }); + + it("should display key information in table view", () => { render( { ], }, ]} - teams={null} showTags={true} />, ); @@ -80,4 +117,519 @@ describe("TopKeyView", () => { expect(screen.getByText(/tag-2/)).toBeInTheDocument(); expect(screen.getByText("$100.00")).toBeInTheDocument(); }); + + it("should switch to chart view when chart view button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const chartViewButton = screen.getByRole("button", { name: "Chart View" }); + await user.click(chartViewButton); + + expect(chartViewButton).toHaveClass("bg-blue-100"); + }); + + it("should switch to table view when table view button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const chartViewButton = screen.getByRole("button", { name: "Chart View" }); + const tableViewButton = screen.getByRole("button", { name: "Table View" }); + + await user.click(chartViewButton); + await user.click(tableViewButton); + + expect(tableViewButton).toHaveClass("bg-blue-100"); + }); + + it("should call setTopKeysLimit when limit is changed via Segmented control", async () => { + const user = userEvent.setup(); + render(); + + const limit10Radio = screen.getByRole("radio", { name: "10" }); + const limit10Label = limit10Radio.closest("label"); + if (limit10Label) { + await user.click(limit10Label); + } else { + // Fallback: click the div with title="10" + const limit10Div = screen.getByTitle("10"); + await user.click(limit10Div); + } + + expect(mockSetTopKeysLimit).toHaveBeenCalledWith(10); + }); + + it("should display truncated key ID in table", () => { + render( + , + ); + expect(screen.getByText(/sk-1234\.\.\./)).toBeInTheDocument(); + }); + + it("should display dash for missing key alias", () => { + render( + , + ); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("should format spend values with two decimal places", () => { + render( + , + ); + expect(screen.getByText("$123.46")).toBeInTheDocument(); + }); + + it("should display less than 0.01 spend as <$0.01", () => { + render( + , + ); + expect(screen.getByText("<$0.01")).toBeInTheDocument(); + }); + + it("should display zero spend correctly", () => { + render( + , + ); + expect(screen.getByText("$0.00")).toBeInTheDocument(); + }); + + it("should display dash for empty tags", () => { + render( + , + ); + expect(screen.getAllByText("-").length).toBeGreaterThan(0); + }); + + it("should display dash for missing tags", () => { + render( + , + ); + expect(screen.getAllByText("-").length).toBeGreaterThan(0); + }); + + it("should display first two tags by default and show expand button", () => { + render( + , + ); + expect(screen.getByText(/tag-1/)).toBeInTheDocument(); + expect(screen.getByText(/tag-2/)).toBeInTheDocument(); + expect(screen.queryByText(/tag-3/)).not.toBeInTheDocument(); + }); + + it("should expand tags when expand button is clicked", async () => { + const user = userEvent.setup(); + render( + , + ); + + const expandButton = screen.getByTitle("Show all tags"); + await user.click(expandButton); + + expect(screen.getByText(/tag-3/)).toBeInTheDocument(); + }); + + it("should collapse tags when collapse button is clicked", async () => { + const user = userEvent.setup(); + render( + , + ); + + const expandButton = screen.getByTitle("Show all tags"); + await user.click(expandButton); + + expect(screen.getByText(/tag-3/)).toBeInTheDocument(); + + const collapseButton = screen.getByTitle("Show fewer tags"); + await user.click(collapseButton); + + expect(screen.queryByText(/tag-3/)).not.toBeInTheDocument(); + }); + + it("should open modal when key ID is clicked", async () => { + const mockKeyInfo = { key: "info" }; + const mockTransformedData = { transformed: "data" } as unknown as KeyResponse; + mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo); + mockTransformKeyInfo.mockReturnValue(mockTransformedData); + + const user = userEvent.setup(); + render( + , + ); + + const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + if (keyIdButton) { + await user.click(keyIdButton); + } + + await waitFor(() => { + expect(mockKeyInfoV1Call).toHaveBeenCalledWith("test-token", "key-123"); + }); + + await waitFor(() => { + expect(screen.getByTestId("key-info-view")).toBeInTheDocument(); + }); + }); + + it("should close modal when close button is clicked", async () => { + const mockKeyInfo = { key: "info" }; + const mockTransformedData = { transformed: "data" } as unknown as KeyResponse; + mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo); + mockTransformKeyInfo.mockReturnValue(mockTransformedData); + + const user = userEvent.setup(); + render( + , + ); + + const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + if (keyIdButton) { + await user.click(keyIdButton); + } + + await waitFor(() => { + expect(screen.getByTestId("key-info-view")).toBeInTheDocument(); + }); + + const closeButton = screen.getByLabelText("Close"); + await user.click(closeButton); + + await waitFor(() => { + expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument(); + }); + }); + + it("should close modal when escape key is pressed", async () => { + const mockKeyInfo = { key: "info" }; + const mockTransformedData = { transformed: "data" } as unknown as KeyResponse; + mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo); + mockTransformKeyInfo.mockReturnValue(mockTransformedData); + + const user = userEvent.setup(); + render( + , + ); + + const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + if (keyIdButton) { + await user.click(keyIdButton); + } + + await waitFor(() => { + expect(screen.getByTestId("key-info-view")).toBeInTheDocument(); + }); + + await user.keyboard("{Escape}"); + + await waitFor(() => { + expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument(); + }); + }); + + it("should close modal when clicking outside modal", async () => { + const mockKeyInfo = { key: "info" }; + const mockTransformedData = { transformed: "data" } as unknown as KeyResponse; + mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo); + mockTransformKeyInfo.mockReturnValue(mockTransformedData); + + const user = userEvent.setup(); + const { container } = render( + , + ); + + const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + if (keyIdButton) { + await user.click(keyIdButton); + } + + await waitFor(() => { + expect(screen.getByTestId("key-info-view")).toBeInTheDocument(); + }); + + const modalBackdrop = container.querySelector(".fixed.inset-0"); + if (modalBackdrop) { + await user.click(modalBackdrop); + } + + await waitFor(() => { + expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument(); + }); + }); + + it("should not open modal when accessToken is missing", async () => { + mockUseAuthorized.mockReturnValue({ + ...mockAuth, + accessToken: "", + }); + + const user = userEvent.setup(); + render( + , + ); + + const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + if (keyIdButton) { + await user.click(keyIdButton); + } + + await waitFor(() => { + expect(mockKeyInfoV1Call).not.toHaveBeenCalled(); + }); + + expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument(); + }); + + it("should handle error when fetching key info", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + mockKeyInfoV1Call.mockRejectedValue(new Error("Network error")); + + const user = userEvent.setup(); + render( + , + ); + + const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + if (keyIdButton) { + await user.click(keyIdButton); + } + + await waitFor(() => { + expect(mockKeyInfoV1Call).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + consoleErrorSpy.mockRestore(); + }); + + it("should sort tags by usage descending", () => { + render( + , + ); + + const tagElements = screen.getAllByText(/tag-/); + const tagTexts = tagElements.map((el) => el.textContent); + // Tags are truncated to 7 characters + "...", so "tag-high" becomes "tag-hig..." + expect(tagTexts[0]).toMatch(/^tag-hig/); + expect(tagTexts[1]).toMatch(/^tag-med/); + }); + + it("should handle empty key list", () => { + render(); + expect(screen.getByText("Key ID")).toBeInTheDocument(); + expect(screen.getByText("Key Alias")).toBeInTheDocument(); + }); + + it("should display full key alias in table view", () => { + render( + , + ); + expect(screen.getByText("This is a very long key alias")).toBeInTheDocument(); + }); + + it("should handle keys with no alias", () => { + render( + , + ); + expect(screen.getByText("-")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx index db268286007..8903f74f232 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -1,23 +1,24 @@ -import React, { useState } from "react"; -import { BarChart } from "@tremor/react"; -import KeyInfoView from "../../../templates/key_info_view"; -import { keyInfoV1Call } from "../../../networking"; -import { transformKeyInfo } from "../../../key_team_helpers/transform_key_info"; -import { DataTable } from "../../../view_logs/table"; -import { Tooltip } from "antd"; -import { Button } from "@tremor/react"; -import { formatNumberWithCommas } from "../../../../utils/dataUtils"; -import { TagUsage } from "../../types"; -import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline"; +import { BarChart, Button } from "@tremor/react"; +import { Segmented, Tooltip } from "antd"; +import React, { useState } from "react"; +import { formatNumberWithCommas } from "../../../../utils/dataUtils"; +import { transformKeyInfo } from "../../../key_team_helpers/transform_key_info"; +import { keyInfoV1Call } from "../../../networking"; +import KeyInfoView from "../../../templates/key_info_view"; +import { DataTable } from "../../../view_logs/table"; +import { TagUsage } from "../../types"; interface TopKeyViewProps { topKeys: any[]; teams: any[] | null; showTags?: boolean; + topKeysLimit: number; + setTopKeysLimit: (limit: number) => void; } -const TopKeyView: React.FC = ({ topKeys, teams, showTags = false }) => { +const TopKeyView: React.FC = ({ topKeys, teams, showTags = false, topKeysLimit, setTopKeysLimit }) => { const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedKey, setSelectedKey] = useState(null); @@ -178,7 +179,17 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals return ( <> -
+
+ setTopKeysLimit(value as number)} + />
{viewMode === "chart" ? ( -
+
= ({ topKeys, teams, showTags = fals />
) : ( -
+
{ + const mockSetTopModelsLimit = vi.fn(); + + beforeEach(() => { + mockSetTopModelsLimit.mockClear(); + }); + it("should render", () => { - const { container } = render(); - expect(container).toBeTruthy(); + render(); + expect(screen.getByText("Table View")).toBeInTheDocument(); }); - it("should have a table view button", () => { - const { getByText } = render(); - expect(getByText("Table View")).toBeInTheDocument(); + it("should display table view button", () => { + render(); + expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument(); }); - it("should have a chart view", () => { - const { getByText } = render(); - expect(getByText("Chart View")).toBeInTheDocument(); + it("should display chart view button", () => { + render(); + expect(screen.getByRole("button", { name: "Chart View" })).toBeInTheDocument(); }); - ["Model", "Spend (USD)", "Successful", "Failed", "Tokens"].forEach((header) => { - it(`should have a ${header} column`, () => { - const { getByText } = render(); - expect(getByText(header)).toBeInTheDocument(); - }); + it("should display all table column headers", () => { + render(); + expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.getByText("Spend (USD)")).toBeInTheDocument(); + expect(screen.getByText("Successful")).toBeInTheDocument(); + expect(screen.getByText("Failed")).toBeInTheDocument(); + expect(screen.getByText("Tokens")).toBeInTheDocument(); }); - it("should show the model's information on the table", () => { - const { getByText } = render( + it("should display model data in table view", () => { + render( { tokens: 50000, }, ]} + topModelsLimit={5} + setTopModelsLimit={mockSetTopModelsLimit} />, ); - expect(getByText("gpt-4")).toBeInTheDocument(); - expect(getByText("$150.50")).toBeInTheDocument(); - expect(getByText("100")).toBeInTheDocument(); - expect(getByText("5")).toBeInTheDocument(); - expect(getByText("50,000")).toBeInTheDocument(); + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByText("$150.50")).toBeInTheDocument(); + expect(screen.getByText("100")).toBeInTheDocument(); + const failedRequestsCell = screen + .getAllByText("5") + .find((el) => el.closest("span")?.classList.contains("text-red-600")); + expect(failedRequestsCell).toBeDefined(); + expect(screen.getByText("50,000")).toBeInTheDocument(); + }); + + it("should switch to chart view when chart view button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const chartViewButton = screen.getByRole("button", { name: "Chart View" }); + await user.click(chartViewButton); + + expect(chartViewButton).toHaveClass("bg-blue-100"); + }); + + it("should switch to table view when table view button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const chartViewButton = screen.getByRole("button", { name: "Chart View" }); + const tableViewButton = screen.getByRole("button", { name: "Table View" }); + + await user.click(chartViewButton); + await user.click(tableViewButton); + + expect(tableViewButton).toHaveClass("bg-blue-100"); + }); + + it("should call setTopModelsLimit when limit is changed via Segmented control", async () => { + const user = userEvent.setup(); + render(); + + const limit10Radio = screen.getByRole("radio", { name: "10" }); + const limit10Label = limit10Radio.closest("label"); + if (limit10Label) { + await user.click(limit10Label); + } else { + // Fallback: click the div with title="10" + const limit10Div = screen.getByTitle("10"); + await user.click(limit10Div); + } + + expect(mockSetTopModelsLimit).toHaveBeenCalledWith(10); + }); + + it("should display only top N models based on limit", () => { + const manyModels = Array.from({ length: 10 }, (_, i) => ({ + key: `model-${i + 1}`, + spend: 100 + i, + successful_requests: 50 + i, + failed_requests: 5 + i, + tokens: 10000 + i * 1000, + })); + + render(); + + expect(screen.getByText("model-1")).toBeInTheDocument(); + expect(screen.getByText("model-5")).toBeInTheDocument(); + expect(screen.queryByText("model-6")).not.toBeInTheDocument(); + }); + + it("should display all models when limit is greater than model count", () => { + const models = [ + { + key: "model-1", + spend: 100, + successful_requests: 50, + failed_requests: 5, + tokens: 10000, + }, + { + key: "model-2", + spend: 200, + successful_requests: 60, + failed_requests: 6, + tokens: 20000, + }, + ]; + + render(); + + expect(screen.getByText("model-1")).toBeInTheDocument(); + expect(screen.getByText("model-2")).toBeInTheDocument(); + }); + + it("should format spend values with two decimal places", () => { + render( + , + ); + expect(screen.getByText("$123.46")).toBeInTheDocument(); + }); + + it("should display zero values correctly", () => { + render( + , + ); + expect(screen.getByText("$0.00")).toBeInTheDocument(); + expect(screen.getAllByText("0").length).toBeGreaterThan(0); + }); + + it("should display successful requests with green styling", () => { + render( + , + ); + const successfulCell = screen + .getAllByText("50") + .find((el) => el.closest("span")?.classList.contains("text-green-600")); + expect(successfulCell).toBeDefined(); + }); + + it("should display failed requests with red styling", () => { + render( + , + ); + const failedCell = screen.getAllByText("5").find((el) => el.closest("span")?.classList.contains("text-red-600")); + expect(failedCell).toBeDefined(); + }); + + it("should format large token numbers with commas", () => { + render( + , + ); + expect(screen.getByText("1,234,567")).toBeInTheDocument(); + }); + + it("should handle empty model list", () => { + render(); + expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.getByText("Spend (USD)")).toBeInTheDocument(); + }); + + it("should display dash for missing model key", () => { + render( + , + ); + expect(screen.getByText("-")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx index 15ae660d4be..c69ba42f182 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx @@ -1,6 +1,7 @@ import { BarChart } from "@tremor/react"; -import { formatNumberWithCommas } from "../../../../utils/dataUtils"; +import { Segmented } from "antd"; import { useState } from "react"; +import { formatNumberWithCommas } from "../../../../utils/dataUtils"; import { DataTable } from "../../../view_logs/table"; interface TopModel { @@ -13,9 +14,11 @@ interface TopModel { interface TopModelViewProps { topModels: TopModel[]; + topModelsLimit: number; + setTopModelsLimit: (limit: number) => void; } -export default function TopModelView({ topModels }: TopModelViewProps) { +export default function TopModelView({ topModels, topModelsLimit, setTopModelsLimit }: TopModelViewProps) { const [modelViewMode, setModelViewMode] = useState<"chart" | "table">("table"); const columns = [ @@ -48,9 +51,21 @@ export default function TopModelView({ topModels }: TopModelViewProps) { cell: (info: any) => info.getValue()?.toLocaleString() || 0, }, ]; + const processedTopModels = topModels.slice(0, topModelsLimit); + return ( <> -
+
+ setTopModelsLimit(value as number)} + />
{modelViewMode === "chart" ? ( -
+
`$${formatNumberWithCommas(value, 2)}`} layout="vertical" yAxisWidth={200} + tickGap={5} showLegend={false} />
) : ( -
+
<>} getRowCanExpand={() => false} + isLoading={false} />
)} diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index d891fcf604c..aaecbd063b5 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -27,17 +27,19 @@ import { Text, Title, } from "@tremor/react"; -import { Alert } from "antd"; +import { Alert, Segmented } from "antd"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { Button } from "@tremor/react"; import { all_admin_roles } from "../../../utils/roles"; import { ActivityMetrics, processActivityData } from "../../activity_metrics"; import CloudZeroExportModal from "../../cloudzero_export_modal"; +import NewBadge from "../../common_components/NewBadge"; import EntityUsageExportModal from "../../EntityUsageExport"; import { Team } from "../../key_team_helpers/key_list"; import { Organization, tagListCall, userDailyActivityAggregatedCall, userDailyActivityCall } from "../../networking"; @@ -49,12 +51,10 @@ import UserAgentActivity from "../../user_agent_activity"; import ViewUserSpend from "../../view_user_spend"; import { DailyData, KeyMetricWithMetadata, MetricWithMetadata } from "../types"; import { valueFormatterSpend } from "../utils/value_formatters"; +import EndpointUsage from "./EndpointUsage/EndpointUsage"; import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage"; import TopKeyView from "./EntityUsage/TopKeyView"; import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; -import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; -import EndpointUsage from "./EndpointUsage/EndpointUsage"; -import NewBadge from "../../common_components/NewBadge"; interface UsagePageProps { teams: Team[]; @@ -95,6 +95,8 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const [showCustomerBanner, setShowCustomerBanner] = useState(true); const [usageView, setUsageView] = useState("global"); const [showAgentBanner, setShowAgentBanner] = useState(true); + const [topKeysLimit, setTopKeysLimit] = useState(5); + const [topModelsLimit, setTopModelsLimit] = useState(5); const getAllTags = async () => { if (!accessToken) { return; @@ -116,7 +118,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const totalSpend = userSpendData.metadata?.total_spend || 0; // Calculate top models from the breakdown data - const getTopModels = () => { + const getTopModels = (limit: number = 5) => { const modelSpend: { [key: string]: MetricWithMetadata } = {}; userSpendData.results.forEach((day) => { Object.entries(day.breakdown.models || {}).forEach(([model, metrics]) => { @@ -159,10 +161,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { tokens: metrics.metrics.total_tokens, })) .sort((a, b) => b.spend - a.spend) - .slice(0, 5); + .slice(0, limit); }; - const getTopModelGroups = () => { + const getTopModelGroups = (limit: number = 5) => { const modelGroupSpend: { [key: string]: MetricWithMetadata } = {}; userSpendData.results.forEach((day) => { Object.entries(day.breakdown.model_groups || {}).forEach(([modelGroup, metrics]) => { @@ -206,7 +208,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { tokens: metrics.metrics.total_tokens, })) .sort((a, b) => b.spend - a.spend) - .slice(0, 5); + .slice(0, limit); }; // Calculate provider spend from the breakdown data @@ -254,7 +256,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { }; // Calculate top API keys from the breakdown data - const getTopKeys = () => { + const getTopKeys = (limit: number = 5) => { const keySpend: { [key: string]: KeyMetricWithMetadata } = {}; userSpendData.results.forEach((day) => { Object.entries(day.breakdown.api_keys || {}).forEach(([key, metrics]) => { @@ -300,7 +302,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { spend: metrics.metrics.spend, })) .sort((a, b) => b.spend - a.spend) - .slice(0, 5); + .slice(0, limit); }; const fetchUserSpendData = useCallback(async () => { @@ -576,15 +578,30 @@ const UsagePage: React.FC = ({ teams, organizations }) => { Top Virtual Keys - + {/* Top Models */} + {modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}
- {modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"} + setTopModelsLimit(value as number)} + />