Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 0 additions & 18 deletions ui/litellm-dashboard/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -984,11 +984,6 @@
"count": 2
}
},
"src/app/(dashboard)/projects/_components/ProjectsPage.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/prompts/_components/add_prompt_form.tsx": {
"no-restricted-imports": {
"count": 1
Expand Down Expand Up @@ -1528,14 +1523,6 @@
"count": 1
}
},
"src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx": {
"no-restricted-imports": {
"count": 1
Expand Down Expand Up @@ -2095,11 +2082,6 @@
"count": 1
}
},
"src/components/pass_through_settings.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/per_user_usage.tsx": {
"no-restricted-imports": {
"count": 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import ModelGroupAliasSettings from "../../../components/model_group_alias_settings";
import ModelInfoView from "../../../components/model_info_view";
import NotificationsManager from "../../../components/molecules/notifications_manager";
import PassThroughSettings from "../../../components/pass_through_settings";
import PassThroughSettings from "../../../components/PassThroughSettings/PassThroughSettings";
import TeamInfoView from "../../../components/team/TeamInfo";
import useAuthorized from "../hooks/useAuthorized";

Expand All @@ -52,7 +52,7 @@

const HEALTH_PAGE_SIZE = 50;

const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, teams }) => {

Check warning on line 55 in ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Arrow function has a complexity of 24. Maximum allowed is 20
const { accessToken, token, userRole, userId: userID } = useAuthorized();
const [addModelForm] = Form.useForm();
const [lastRefreshed, setLastRefreshed] = useState("");
Expand Down Expand Up @@ -108,13 +108,13 @@

const allModelsOnProxy = useMemo<string[]>(() => {
if (!modelDataResponse?.data) return [];
return modelDataResponse.data.map((model: any) => model.model_name);

Check warning on line 111 in ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
}, [modelDataResponse?.data]);

const healthModelIdsOnProxy = useMemo<string[]>(() => {
if (!healthModelDataResponse?.data) return [];
return healthModelDataResponse.data
.map((model: any) => model.model_info?.id)

Check warning on line 117 in ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
.filter((id: string | undefined): id is string => Boolean(id));
}, [healthModelDataResponse?.data]);

Expand All @@ -130,12 +130,12 @@
const processedModelData = useMemo(() => {
if (!modelDataResponse?.data) return { data: [] };
return transformModelData(modelDataResponse, getProviderFromModel);
}, [modelDataResponse?.data, getProviderFromModel]);

Check warning on line 133 in ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

React Hook useMemo has a missing dependency: 'modelDataResponse'. Either include it or remove the dependency array

const processedHealthModelData = useMemo(() => {
if (!healthModelDataResponse?.data) return { data: [] };
return transformModelData(healthModelDataResponse, getProviderFromModel);
}, [healthModelDataResponse?.data, getProviderFromModel]);

Check warning on line 138 in ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

React Hook useMemo has a missing dependency: 'healthModelDataResponse'. Either include it or remove the dependency array

const healthPaginationMeta = useMemo(() => {
return {
Expand Down Expand Up @@ -239,7 +239,7 @@
};

useEffect(() => {
if (!accessToken || !token || !userRole || !userID || !modelDataResponse) {

Check warning on line 242 in ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Boolean expression combines 5 conditions; extract it into a named variable
return;
}
let active = true;
Expand All @@ -254,7 +254,7 @@
};
}, [accessToken, token, userRole, userID, modelDataResponse, fetchRouterSettings, applyRouterSettings]);

const isLoading = isLoadingModels || isLoadingModelCostMap || isLoadingCredentials || isLoadingUISettings;

Check warning on line 257 in ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Boolean expression combines 4 conditions; extract it into a named variable

// Admin Viewer can view all models read-only — page render proceeds; the
// individual write-action tabs (Add Model, LLM Credentials, etc.) are
Expand All @@ -264,10 +264,10 @@
try {
const values = await addModelForm.validateFields();
await handleAddModelSubmit(values, accessToken, addModelForm, handleRefreshClick);
} catch (error: any) {

Check warning on line 267 in ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
const errorMessages =
error.errorFields
?.map((field: any) => {

Check warning on line 270 in ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
return `${field.name.join(".")}: ${field.errors.join(", ")}`;
})
.join(" | ") || "Unknown validation error";
Expand All @@ -275,7 +275,7 @@
}
};

Object.keys(Providers).find((key) => (Providers as { [index: string]: any })[key] === selectedProvider);

Check warning on line 278 in ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
// If a team is selected, render TeamInfoView in full page layout
if (selectedTeamId) {
return (
Expand Down Expand Up @@ -396,7 +396,6 @@
accessToken={accessToken}
userRole={userRole}
userID={userID}
modelData={processedModelData}
premiumUser={premiumUser}
/>
</TabPanel>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ vi.mock("@/components/common_components/DefaultProxyAdminTag", () => ({
default: ({ userId }: { userId: string }) => <span>{userId}</span>,
}));

vi.mock("./ProjectKeysSection", () => ({
ProjectKeysSection: ({ projectId }: { projectId: string }) => (
<div data-testid="project-keys-section">{projectId}</div>
),
}));

const mockProject: ProjectResponse = {
project_id: "proj-1",
project_alias: "My Project",
Expand Down Expand Up @@ -98,6 +104,11 @@ describe("ProjectDetail", () => {
expect(screen.getByRole("heading", { name: "My Project" })).toBeInTheDocument();
});

it("should render the project keys section for the project", () => {
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(screen.getByTestId("project-keys-section")).toHaveTextContent("proj-1");
});

it("should display 'Active' for a non-blocked project", () => {
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(screen.getByText("Active")).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ import {
} from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { BarChart } from "@/components/shared/charts";
import { ArrowLeftIcon, DollarSignIcon, EditIcon, KeyIcon, UsersIcon } from "lucide-react";
import { ArrowLeftIcon, DollarSignIcon, EditIcon, UsersIcon } from "lucide-react";
import { useMemo, useState } from "react";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
import { EditProjectModal } from "./ProjectModals/EditProjectModal";
import { ProjectKeysSection } from "./ProjectKeysSection";

const { Title, Text } = Typography;
const { Content } = Layout;
Expand Down Expand Up @@ -203,17 +204,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
{/* Keys & Team */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<KeyIcon size={16} />
Keys
</Flex>
}
style={{ height: "100%" }}
>
<Empty description="No keys to display" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
<ProjectKeysSection projectId={projectId} />
</Col>
<Col xs={24} lg={12}>
<Card
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe("ProjectKeysSection", () => {
isLoading: false,
});
renderWithProviders(<ProjectKeysSection projectId="proj-1" />);
expect(screen.getByText("42 keys")).toBeInTheDocument();
expect(screen.getByTestId("pagination-range")).toHaveTextContent("of 42");
});

it("should show 'No keys found' when the project has no keys", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { LoadingOutlined } from "@ant-design/icons";
import { Card, Flex, Input, Pagination, Spin } from "antd";
import { PaginationState } from "@tanstack/react-table";
import { Card, Flex, Input } from "antd";
import { KeyIcon, SearchIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { ProjectKeysTable } from "./ProjectKeysTable";
Expand All @@ -12,17 +12,16 @@ interface ProjectKeysSectionProps {
const PAGE_SIZE = 5;

export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
const [page, setPage] = useState(1);
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE });
const [keyAlias, setKeyAlias] = useState<string>("");

const { data, isLoading } = useKeys(page, PAGE_SIZE, {
const { data, isLoading } = useKeys(pagination.pageIndex + 1, pagination.pageSize, {
projectID: projectId,
selectedKeyAlias: keyAlias || null,
});

// Reset to page 1 when filter changes
useEffect(() => {
setPage(1);
setPagination((current) => ({ ...current, pageIndex: 0 }));
}, [keyAlias]);

const keys = data?.keys ?? [];
Expand All @@ -38,7 +37,7 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
}
style={{ height: "100%" }}
>
<Flex justify="space-between" align="center" style={{ marginBottom: 12 }}>
<Flex justify="flex-start" align="center" style={{ marginBottom: 12 }}>
<Input
prefix={<SearchIcon size={14} />}
placeholder="Filter by key name..."
Expand All @@ -48,19 +47,13 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
allowClear
size="small"
/>
<Pagination
current={page}
total={totalCount}
pageSize={PAGE_SIZE}
onChange={setPage}
size="small"
showSizeChanger={false}
showTotal={(total) => `${total} keys`}
/>
</Flex>
<ProjectKeysTable
keys={keys}
loading={isLoading ? { indicator: <Spin indicator={<LoadingOutlined spin />} /> } : false}
totalCount={totalCount}
isLoading={isLoading}
pagination={pagination}
onPaginationChange={setPagination}
/>
</Card>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "../../../../../tests/test-utils";
import { ProjectKeysTable } from "./ProjectKeysTable";
import { KeyResponse } from "@/components/key_team_helpers/key_list";
Expand All @@ -7,6 +8,13 @@ vi.mock("@/components/common_components/DefaultProxyAdminTag", () => ({
default: ({ userId }: { userId: string }) => <span data-testid="owner-tag">{userId}</span>,
}));

const defaultProps = {
totalCount: 0,
isLoading: false,
pagination: { pageIndex: 0, pageSize: 5 },
onPaginationChange: vi.fn(),
};

function makeKey(overrides: Partial<KeyResponse> = {}): KeyResponse {
return {
token: "tok-abc123",
Expand Down Expand Up @@ -70,52 +78,78 @@ function makeKey(overrides: Partial<KeyResponse> = {}): KeyResponse {

describe("ProjectKeysTable", () => {
it("should render", () => {
renderWithProviders(<ProjectKeysTable keys={[]} />);
renderWithProviders(<ProjectKeysTable {...defaultProps} keys={[]} />);
expect(screen.getByRole("table")).toBeInTheDocument();
});

it("should display 'No keys found' when the keys list is empty", () => {
renderWithProviders(<ProjectKeysTable keys={[]} />);
renderWithProviders(<ProjectKeysTable {...defaultProps} keys={[]} />);
expect(screen.getByText("No keys found")).toBeInTheDocument();
});

it("should display the key alias when provided", () => {
renderWithProviders(<ProjectKeysTable keys={[makeKey({ key_alias: "My API Key" })]} />);
renderWithProviders(<ProjectKeysTable {...defaultProps} keys={[makeKey({ key_alias: "My API Key" })]} />);
expect(screen.getByText("My API Key")).toBeInTheDocument();
});

it("should display '—' when the key alias is null", () => {
// Provide a user_id so only the alias column shows "—" (not the owner column too)
renderWithProviders(<ProjectKeysTable keys={[makeKey({ key_alias: null as any, user_id: "owner-1" })]} />);
renderWithProviders(
<ProjectKeysTable {...defaultProps} keys={[makeKey({ key_alias: null as any, user_id: "owner-1" })]} />,
);
expect(screen.getByText("—")).toBeInTheDocument();
});

it("should display the owner using user.user_email when available", () => {
const key = makeKey({ user: { user_id: "u1", user_email: "alice@example.com" } });
renderWithProviders(<ProjectKeysTable keys={[key]} />);
const key = makeKey({ user: { user_id: "u1", user_email: "alice@example.com", user_alias: null } });
renderWithProviders(<ProjectKeysTable {...defaultProps} keys={[key]} />);
expect(screen.getByTestId("owner-tag")).toHaveTextContent("alice@example.com");
});

it("should fall back to user_id when user.user_email is absent", () => {
const key = makeKey({ user_id: "user-99" });
renderWithProviders(<ProjectKeysTable keys={[key]} />);
renderWithProviders(<ProjectKeysTable {...defaultProps} keys={[key]} />);
expect(screen.getByTestId("owner-tag")).toHaveTextContent("user-99");
});

it("should display 'Never' in the Last Active column when last_active is null", () => {
renderWithProviders(<ProjectKeysTable keys={[makeKey({ last_active: null })]} />);
renderWithProviders(<ProjectKeysTable {...defaultProps} keys={[makeKey({ last_active: null })]} />);
expect(screen.getByText("Never")).toBeInTheDocument();
});

it("should display a formatted date in the Last Active column when last_active is provided", () => {
renderWithProviders(<ProjectKeysTable keys={[makeKey({ last_active: "2024-06-15T10:00:00Z" })]} />);
renderWithProviders(
<ProjectKeysTable {...defaultProps} keys={[makeKey({ last_active: "2024-06-15T10:00:00Z" })]} />,
);
expect(screen.queryByText("Never")).not.toBeInTheDocument();
});

it("should render multiple keys as separate rows", () => {
const keys = [makeKey({ token: "tok-1", key_alias: "Key One" }), makeKey({ token: "tok-2", key_alias: "Key Two" })];
renderWithProviders(<ProjectKeysTable keys={keys} />);
renderWithProviders(<ProjectKeysTable {...defaultProps} keys={keys} />);
expect(screen.getByText("Key One")).toBeInTheDocument();
expect(screen.getByText("Key Two")).toBeInTheDocument();
});

it("should show skeleton rows while loading", () => {
renderWithProviders(<ProjectKeysTable {...defaultProps} keys={[]} isLoading />);
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
expect(screen.queryByText("No keys found")).not.toBeInTheDocument();
});

it("should show the server-side total in the pagination footer", () => {
renderWithProviders(<ProjectKeysTable {...defaultProps} keys={[makeKey()]} totalCount={42} />);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-5 of 42");
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 9");
});

it("should request the next page through the pagination footer", async () => {
const user = userEvent.setup();
const onPaginationChange = vi.fn();
renderWithProviders(
<ProjectKeysTable {...defaultProps} keys={[makeKey()]} totalCount={42} onPaginationChange={onPaginationChange} />,
);
await user.click(screen.getByTestId("pagination-next"));
expect(onPaginationChange).toHaveBeenCalled();
});
});
Loading
Loading