mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
Merge pull request #19050 from BerriAI/litellm_ui_usage_top_x
[Feature] UI - Usage: Allow Top Virtual Keys and Models to Show More Entries
This commit is contained in:
commit
2ea6fcb584
7 changed files with 951 additions and 117 deletions
|
|
@ -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<EntityUsageProps> = ({
|
||||
accessToken,
|
||||
entityType,
|
||||
entityId,
|
||||
userID,
|
||||
userRole,
|
||||
entityList,
|
||||
premiumUser,
|
||||
dateValue,
|
||||
}) => {
|
||||
const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, entityId, entityList, dateValue }) => {
|
||||
const [spendData, setSpendData] = useState<EntitySpendData>({
|
||||
results: [],
|
||||
metadata: {
|
||||
|
|
@ -112,6 +103,8 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
const modelMetrics = processActivityData(spendData, "models", teams || []);
|
||||
const keyMetrics = processActivityData(spendData, "api_keys", teams || []);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
|
||||
|
||||
const fetchSpendData = async () => {
|
||||
if (!accessToken || !dateValue.from || !dateValue.to) return;
|
||||
|
|
@ -204,7 +197,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
...metrics,
|
||||
}))
|
||||
.sort((a, b) => b.spend - a.spend)
|
||||
.slice(0, 5);
|
||||
.slice(0, topModelsLimit);
|
||||
};
|
||||
|
||||
const getTopAPIKeys = () => {
|
||||
|
|
@ -269,7 +262,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
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<EntityUsageProps> = ({
|
|||
<Col numColSpan={1}>
|
||||
<Card>
|
||||
<Title>Top Virtual Keys</Title>
|
||||
<TopKeyView topKeys={getTopAPIKeys()} teams={null} showTags={entityType === "tag"} />
|
||||
<TopKeyView
|
||||
topKeys={getTopAPIKeys()}
|
||||
teams={null}
|
||||
showTags={entityType === "tag"}
|
||||
topKeysLimit={topKeysLimit}
|
||||
setTopKeysLimit={setTopKeysLimit}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
|
|
@ -606,7 +605,11 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
<Col numColSpan={1}>
|
||||
<Card>
|
||||
<Title>{entityType === "agent" ? "Top Agents" : "Top Models"}</Title>
|
||||
<TopModelView topModels={getTopModels()} />
|
||||
<TopModelView
|
||||
topModels={getTopModels()}
|
||||
topModelsLimit={topModelsLimit}
|
||||
setTopModelsLimit={setTopModelsLimit}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<div data-testid="key-info-view">
|
||||
<div>Key Info View for {keyId}</div>
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<TopKeyView {...baseProps} />);
|
||||
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(<TopKeyView {...baseProps} />);
|
||||
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(<TopKeyView {...baseProps} />);
|
||||
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(<TopKeyView {...baseProps} />);
|
||||
expect(screen.getByText(header)).toBeInTheDocument();
|
||||
});
|
||||
it("should display base table column headers", () => {
|
||||
render(<TopKeyView {...baseProps} />);
|
||||
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(<TopKeyView {...baseProps} showTags={true} />);
|
||||
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(<TopKeyView {...baseProps} showTags={false} />);
|
||||
expect(screen.queryByText("Tags")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display key information in table view", () => {
|
||||
render(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
|
|
@ -71,7 +109,6 @@ describe("TopKeyView", () => {
|
|||
],
|
||||
},
|
||||
]}
|
||||
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(<TopKeyView {...baseProps} />);
|
||||
|
||||
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(<TopKeyView {...baseProps} />);
|
||||
|
||||
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(<TopKeyView {...baseProps} />);
|
||||
|
||||
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(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "sk-1234567890abcdef",
|
||||
key_alias: "Test Key",
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/sk-1234\.\.\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display dash for missing key alias", () => {
|
||||
render(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "",
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should format spend values with two decimal places", () => {
|
||||
render(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 123.456,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("$123.46")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display less than 0.01 spend as <$0.01", () => {
|
||||
render(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 0.005,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("<$0.01")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display zero spend correctly", () => {
|
||||
render(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 0,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("$0.00")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display dash for empty tags", () => {
|
||||
render(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 100,
|
||||
tags: [],
|
||||
},
|
||||
]}
|
||||
showTags={true}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText("-").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should display dash for missing tags", () => {
|
||||
render(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
showTags={true}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText("-").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should display first two tags by default and show expand button", () => {
|
||||
render(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 100,
|
||||
tags: [
|
||||
{ tag: "tag-1", usage: 50 },
|
||||
{ tag: "tag-2", usage: 30 },
|
||||
{ tag: "tag-3", usage: 20 },
|
||||
],
|
||||
},
|
||||
]}
|
||||
showTags={true}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 100,
|
||||
tags: [
|
||||
{ tag: "tag-1", usage: 50 },
|
||||
{ tag: "tag-2", usage: 30 },
|
||||
{ tag: "tag-3", usage: 20 },
|
||||
],
|
||||
},
|
||||
]}
|
||||
showTags={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 100,
|
||||
tags: [
|
||||
{ tag: "tag-1", usage: 50 },
|
||||
{ tag: "tag-2", usage: 30 },
|
||||
{ tag: "tag-3", usage: 20 },
|
||||
],
|
||||
},
|
||||
]}
|
||||
showTags={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "Test Key",
|
||||
spend: 100,
|
||||
tags: [
|
||||
{ tag: "tag-low", usage: 10 },
|
||||
{ tag: "tag-high", usage: 50 },
|
||||
{ tag: "tag-medium", usage: 30 },
|
||||
],
|
||||
},
|
||||
]}
|
||||
showTags={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<TopKeyView {...baseProps} topKeys={[]} />);
|
||||
expect(screen.getByText("Key ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("Key Alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display full key alias in table view", () => {
|
||||
render(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: "This is a very long key alias",
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("This is a very long key alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle keys with no alias", () => {
|
||||
render(
|
||||
<TopKeyView
|
||||
{...baseProps}
|
||||
topKeys={[
|
||||
{
|
||||
api_key: "key-123",
|
||||
key_alias: null,
|
||||
spend: 100,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<TopKeyViewProps> = ({ topKeys, teams, showTags = false }) => {
|
||||
const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = false, topKeysLimit, setTopKeysLimit }) => {
|
||||
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
|
|
@ -178,7 +179,17 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex justify-end items-center">
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: "5", value: 5 },
|
||||
{ label: "10", value: 10 },
|
||||
{ label: "25", value: 25 },
|
||||
{ label: "50", value: 50 },
|
||||
]}
|
||||
value={topKeysLimit}
|
||||
onChange={(value) => setTopKeysLimit(value as number)}
|
||||
/>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setViewMode("table")}
|
||||
|
|
@ -196,9 +207,10 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
</div>
|
||||
|
||||
{viewMode === "chart" ? (
|
||||
<div className="relative">
|
||||
<div className="relative max-h-[600px] overflow-y-auto">
|
||||
<BarChart
|
||||
className="mt-4 h-40 cursor-pointer hover:opacity-90"
|
||||
className="mt-4 cursor-pointer hover:opacity-90"
|
||||
style={{ height: Math.min(processedTopKeys.length, topKeysLimit) * 52 }}
|
||||
data={processedTopKeys}
|
||||
index="display_key_alias"
|
||||
categories={["spend"]}
|
||||
|
|
@ -234,7 +246,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="border rounded-lg overflow-hidden max-h-[600px] overflow-y-auto">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={topKeys}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,41 @@
|
|||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import TopModelView from "./TopModelView";
|
||||
|
||||
describe("TopModelView", () => {
|
||||
const mockSetTopModelsLimit = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetTopModelsLimit.mockClear();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
const { container } = render(<TopModelView topModels={[]} />);
|
||||
expect(container).toBeTruthy();
|
||||
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
expect(screen.getByText("Table View")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have a table view button", () => {
|
||||
const { getByText } = render(<TopModelView topModels={[]} />);
|
||||
expect(getByText("Table View")).toBeInTheDocument();
|
||||
it("should display table view button", () => {
|
||||
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have a chart view", () => {
|
||||
const { getByText } = render(<TopModelView topModels={[]} />);
|
||||
expect(getByText("Chart View")).toBeInTheDocument();
|
||||
it("should display chart view button", () => {
|
||||
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
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(<TopModelView topModels={[]} />);
|
||||
expect(getByText(header)).toBeInTheDocument();
|
||||
});
|
||||
it("should display all table column headers", () => {
|
||||
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
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(
|
||||
<TopModelView
|
||||
topModels={[
|
||||
{
|
||||
|
|
@ -37,12 +46,222 @@ describe("TopModelView", () => {
|
|||
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(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
|
||||
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(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
|
||||
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(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
|
||||
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(<TopModelView topModels={manyModels} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
|
||||
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(<TopModelView topModels={models} topModelsLimit={10} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
|
||||
expect(screen.getByText("model-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("model-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should format spend values with two decimal places", () => {
|
||||
render(
|
||||
<TopModelView
|
||||
topModels={[
|
||||
{
|
||||
key: "model-1",
|
||||
spend: 123.456,
|
||||
successful_requests: 100,
|
||||
failed_requests: 5,
|
||||
tokens: 50000,
|
||||
},
|
||||
]}
|
||||
topModelsLimit={5}
|
||||
setTopModelsLimit={mockSetTopModelsLimit}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("$123.46")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display zero values correctly", () => {
|
||||
render(
|
||||
<TopModelView
|
||||
topModels={[
|
||||
{
|
||||
key: "model-1",
|
||||
spend: 0,
|
||||
successful_requests: 0,
|
||||
failed_requests: 0,
|
||||
tokens: 0,
|
||||
},
|
||||
]}
|
||||
topModelsLimit={5}
|
||||
setTopModelsLimit={mockSetTopModelsLimit}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("$0.00")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("0").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should display successful requests with green styling", () => {
|
||||
render(
|
||||
<TopModelView
|
||||
topModels={[
|
||||
{
|
||||
key: "model-1",
|
||||
spend: 100,
|
||||
successful_requests: 50,
|
||||
failed_requests: 5,
|
||||
tokens: 10000,
|
||||
},
|
||||
]}
|
||||
topModelsLimit={5}
|
||||
setTopModelsLimit={mockSetTopModelsLimit}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<TopModelView
|
||||
topModels={[
|
||||
{
|
||||
key: "model-1",
|
||||
spend: 100,
|
||||
successful_requests: 50,
|
||||
failed_requests: 5,
|
||||
tokens: 10000,
|
||||
},
|
||||
]}
|
||||
topModelsLimit={5}
|
||||
setTopModelsLimit={mockSetTopModelsLimit}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<TopModelView
|
||||
topModels={[
|
||||
{
|
||||
key: "model-1",
|
||||
spend: 100,
|
||||
successful_requests: 50,
|
||||
failed_requests: 5,
|
||||
tokens: 1234567,
|
||||
},
|
||||
]}
|
||||
topModelsLimit={5}
|
||||
setTopModelsLimit={mockSetTopModelsLimit}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("1,234,567")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle empty model list", () => {
|
||||
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Spend (USD)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display dash for missing model key", () => {
|
||||
render(
|
||||
<TopModelView
|
||||
topModels={[
|
||||
{
|
||||
key: "",
|
||||
spend: 100,
|
||||
successful_requests: 50,
|
||||
failed_requests: 5,
|
||||
tokens: 10000,
|
||||
},
|
||||
]}
|
||||
topModelsLimit={5}
|
||||
setTopModelsLimit={mockSetTopModelsLimit}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<div className="mb-4 flex justify-end items-center">
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: "5", value: 5 },
|
||||
{ label: "10", value: 10 },
|
||||
{ label: "25", value: 25 },
|
||||
{ label: "50", value: 50 },
|
||||
]}
|
||||
value={topModelsLimit}
|
||||
onChange={(value) => setTopModelsLimit(value as number)}
|
||||
/>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setModelViewMode("table")}
|
||||
|
|
@ -67,26 +82,29 @@ export default function TopModelView({ topModels }: TopModelViewProps) {
|
|||
</div>
|
||||
</div>
|
||||
{modelViewMode === "chart" ? (
|
||||
<div className="relative">
|
||||
<div className="relative max-h-[600px] overflow-y-auto">
|
||||
<BarChart
|
||||
className="mt-4 h-40"
|
||||
data={topModels}
|
||||
className="mt-4 cursor-pointer hover:opacity-90"
|
||||
style={{ height: Math.min(processedTopModels.length, topModelsLimit) * 52 }}
|
||||
data={processedTopModels}
|
||||
index="key"
|
||||
categories={["spend"]}
|
||||
colors={["cyan"]}
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
layout="vertical"
|
||||
yAxisWidth={200}
|
||||
tickGap={5}
|
||||
showLegend={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-auto">
|
||||
<div className="border rounded-lg overflow-hidden max-h-[600px] overflow-y-auto">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={topModels}
|
||||
data={processedTopModels}
|
||||
renderSubComponent={() => <></>}
|
||||
getRowCanExpand={() => false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const [showCustomerBanner, setShowCustomerBanner] = useState(true);
|
||||
const [usageView, setUsageView] = useState<UsageOption>("global");
|
||||
const [showAgentBanner, setShowAgentBanner] = useState(true);
|
||||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
|
||||
const getAllTags = async () => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
|
|
@ -116,7 +118,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ 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<UsagePageProps> = ({ 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<UsagePageProps> = ({ 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<UsagePageProps> = ({ 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<UsagePageProps> = ({ 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<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<Col numColSpan={1}>
|
||||
<Card className="h-full">
|
||||
<Title>Top Virtual Keys</Title>
|
||||
<TopKeyView topKeys={getTopKeys()} teams={null} />
|
||||
<TopKeyView
|
||||
topKeys={getTopKeys(topKeysLimit)}
|
||||
teams={null}
|
||||
topKeysLimit={topKeysLimit}
|
||||
setTopKeysLimit={setTopKeysLimit}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Top Models */}
|
||||
<Col numColSpan={1}>
|
||||
<Card className="h-full">
|
||||
<Title>{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}</Title>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}</Title>
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: "5", value: 5 },
|
||||
{ label: "10", value: 10 },
|
||||
{ label: "25", value: 25 },
|
||||
{ label: "50", value: 50 },
|
||||
]}
|
||||
value={topModelsLimit}
|
||||
onChange={(value) => setTopModelsLimit(value as number)}
|
||||
/>
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
|
|
@ -611,33 +628,46 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<BarChart
|
||||
className="mt-4 h-40"
|
||||
data={modelViewType === "groups" ? getTopModelGroups() : getTopModels()}
|
||||
index="key"
|
||||
categories={["spend"]}
|
||||
colors={["cyan"]}
|
||||
valueFormatter={valueFormatterSpend}
|
||||
layout="vertical"
|
||||
yAxisWidth={200}
|
||||
showLegend={false}
|
||||
customTooltip={({ payload, active }) => {
|
||||
if (!active || !payload?.[0]) return null;
|
||||
const data = payload[0].payload;
|
||||
<div className="relative max-h-[600px] overflow-y-auto">
|
||||
{(() => {
|
||||
const modelData =
|
||||
modelViewType === "groups"
|
||||
? getTopModelGroups(topModelsLimit)
|
||||
: getTopModels(topModelsLimit);
|
||||
return (
|
||||
<div className="bg-white p-4 shadow-lg rounded-lg border">
|
||||
<p className="font-bold">{data.key}</p>
|
||||
<p className="text-cyan-500">Spend: ${formatNumberWithCommas(data.spend, 2)}</p>
|
||||
<p className="text-gray-600">Total Requests: {data.requests.toLocaleString()}</p>
|
||||
<p className="text-green-600">
|
||||
Successful: {data.successful_requests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-red-600">Failed: {data.failed_requests.toLocaleString()}</p>
|
||||
<p className="text-gray-600">Tokens: {data.tokens.toLocaleString()}</p>
|
||||
</div>
|
||||
<BarChart
|
||||
className="mt-4"
|
||||
style={{ height: Math.min(modelData.length, topModelsLimit) * 52 }}
|
||||
data={modelData}
|
||||
index="key"
|
||||
categories={["spend"]}
|
||||
colors={["cyan"]}
|
||||
valueFormatter={valueFormatterSpend}
|
||||
layout="vertical"
|
||||
yAxisWidth={200}
|
||||
showLegend={false}
|
||||
customTooltip={({ payload, active }) => {
|
||||
if (!active || !payload?.[0]) return null;
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-white p-4 shadow-lg rounded-lg border">
|
||||
<p className="font-bold">{data.key}</p>
|
||||
<p className="text-cyan-500">Spend: ${formatNumberWithCommas(data.spend, 2)}</p>
|
||||
<p className="text-gray-600">
|
||||
Total Requests: {data.requests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-green-600">
|
||||
Successful: {data.successful_requests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-red-600">Failed: {data.failed_requests.toLocaleString()}</p>
|
||||
<p className="text-gray-600">Tokens: {data.tokens.toLocaleString()}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
|
|||
|
|
@ -609,7 +609,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
|
|||
<Col numColSpan={1}>
|
||||
<Card className="h-full">
|
||||
<Title>Top Virtual Keys</Title>
|
||||
<TopKeyView topKeys={topKeys} teams={null} />
|
||||
<TopKeyView topKeys={topKeys} teams={null} topKeysLimit={5} setTopKeysLimit={() => {}} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue