diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts index 1643412d1e9..80cb69495da 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts @@ -50,6 +50,7 @@ const mockKeys: KeyResponse[] = [ config: {}, user_id: "user-1", team_id: null, + project_id: null, max_parallel_requests: 10, metadata: {}, tpm_limit: 1000, @@ -105,6 +106,7 @@ const mockKeys: KeyResponse[] = [ config: {}, user_id: "user-2", team_id: "team-1", + project_id: "project-1", max_parallel_requests: 5, metadata: {}, tpm_limit: 500, @@ -396,6 +398,76 @@ describe("useKeys", () => { }, ); }); + + it("should pass projectID filter to the API", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + keys: [mockKeys[1]], + total_count: 1, + current_page: 1, + total_pages: 1, + }), + }); + + const { result } = renderHook( + () => useKeys(1, 10, { projectID: "project-1" }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const callUrl = mockFetch.mock.calls[0][0]; + expect(callUrl).toContain("project_id=project-1"); + expect(result.current.data?.keys).toHaveLength(1); + expect(result.current.data?.keys[0].project_id).toBe("project-1"); + }); + + it("should pass both projectID and teamID filters to the API", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + keys: [mockKeys[1]], + total_count: 1, + current_page: 1, + total_pages: 1, + }), + }); + + const { result } = renderHook( + () => useKeys(1, 10, { projectID: "project-1", teamID: "team-1" }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const callUrl = mockFetch.mock.calls[0][0]; + expect(callUrl).toContain("project_id=project-1"); + expect(callUrl).toContain("team_id=team-1"); + }); + + it("should not include project_id param when projectID is null", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockKeysResponse, + }); + + const { result } = renderHook( + () => useKeys(1, 10, { projectID: null }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const callUrl = mockFetch.mock.calls[0][0]; + expect(callUrl).not.toContain("project_id"); + }); }); describe("useDeletedKeys", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts index cf477a2e556..fbe5eccb75a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -33,6 +33,7 @@ export interface DeletedKeysResponse { export interface KeyListCallOptions { organizationID?: string | null; teamID?: string | null; + projectID?: string | null; selectedKeyAlias?: string | null; userID?: string | null; keyHash?: string | null; @@ -57,6 +58,7 @@ const keyListCall = async ( const params = new URLSearchParams( Object.entries({ team_id: options.teamID, + project_id: options.projectID, organization_id: options.organizationID, key_alias: options.selectedKeyAlias, key_hash: options.keyHash, diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.tsx index 637771e2299..77beac65ad7 100644 --- a/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.tsx +++ b/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.tsx @@ -17,10 +17,11 @@ import { } from "antd"; import { LoadingOutlined } from "@ant-design/icons"; import { BarChart } from "@tremor/react"; -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 "../common_components/DefaultProxyAdminTag"; import { EditProjectModal } from "./ProjectModals/EditProjectModal"; +import { ProjectKeysSection } from "./ProjectKeysSection"; const { Title, Text } = Typography; const { Content } = Layout; @@ -203,17 +204,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { {/* Keys & Team */} - - - Keys - - } - style={{ height: "100%" }} - > - - + (""); + + const { data, isLoading } = useKeys(page, PAGE_SIZE, { + projectID: projectId, + selectedKeyAlias: keyAlias || null, + }); + + // Reset to page 1 when filter changes + useEffect(() => { + setPage(1); + }, [keyAlias]); + + const keys = data?.keys ?? []; + const totalCount = data?.total_count ?? 0; + + return ( + + + Keys + + } + style={{ height: "100%" }} + > + + } + placeholder="Filter by key name..." + style={{ maxWidth: 220 }} + value={keyAlias} + onChange={(e) => setKeyAlias(e.target.value)} + allowClear + size="small" + /> + `${total} keys`} + /> + + } /> } : false} + /> + + ); +} diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectKeysTable.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectKeysTable.tsx new file mode 100644 index 00000000000..cb80d0e27a5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Projects/ProjectKeysTable.tsx @@ -0,0 +1,58 @@ +import { KeyResponse } from "@/components/key_team_helpers/key_list"; +import { Empty, Table, Tooltip } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import type { SpinProps } from "antd"; +import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; + +interface ProjectKeysTableProps { + keys: KeyResponse[]; + loading?: boolean | SpinProps; +} + +const columns: ColumnsType = [ + { + title: "Key Name", + dataIndex: "key_alias", + key: "key_alias", + render: (alias: string | null) => alias || "—", + }, + { + title: "Owner", + key: "owner", + render: (_: unknown, record: KeyResponse) => { + const email = record.user?.user_email ?? record.user_id ?? null; + if (!email) return "—"; + return ( + + + + ); + }, + }, + { + title: "Created", + dataIndex: "created_at", + key: "created_at", + render: (date: string) => (date ? new Date(date).toLocaleDateString() : "—"), + }, + { + title: "Last Active", + dataIndex: "last_active", + key: "last_active", + render: (date: string | null) => (date ? new Date(date).toLocaleDateString() : "Never"), + }, +]; + +export function ProjectKeysTable({ keys, loading }: ProjectKeysTableProps) { + return ( + }} + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectsPage.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectsPage.tsx index f0b593c2e49..9c75e19ac4e 100644 --- a/ui/litellm-dashboard/src/components/Projects/ProjectsPage.tsx +++ b/ui/litellm-dashboard/src/components/Projects/ProjectsPage.tsx @@ -2,6 +2,7 @@ import { useProjects, ProjectResponse } from "@/app/(dashboard)/hooks/projects/u import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { LoadingOutlined, PlusOutlined } from "@ant-design/icons"; import { + Alert, Button, Card, Flex, @@ -165,6 +166,12 @@ export function ProjectsPage() { + void; + disabled?: boolean; + loading?: boolean; + /** When set, only show projects belonging to this team */ + teamId?: string | null; +} + +const ProjectDropdown: React.FC = ({ + projects, + value, + onChange, + disabled, + loading, + teamId, +}) => { + const filtered = teamId + ? projects?.filter((p) => p.team_id === teamId) + : projects; + + return ( + + ); +}; + +export default ProjectDropdown; diff --git a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx index d54724da2a7..9e79ea2950a 100644 --- a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx @@ -7,10 +7,10 @@ interface TeamDropdownProps { value?: string; onChange?: (value: string) => void; disabled?: boolean; + loading?: boolean; } -const TeamDropdown: React.FC = ({ teams, value, onChange, disabled }) => { - console.log("disabled", disabled); +const TeamDropdown: React.FC = ({ teams, value, onChange, disabled, loading }) => { return ( onChange?.(e.target.value)} + /> + ), +})); + vi.mock("../common_components/AccessGroupSelector", () => ({ default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => ( = ({ team, teams, data, addKey }) => { const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized(); + const { data: projects, isLoading: isProjectsLoading } = useProjects(); const queryClient = useQueryClient(); const [form] = Form.useForm(); const [isModalVisible, setIsModalVisible] = useState(false); @@ -157,6 +160,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { const [promptsList, setPromptsList] = useState([]); const [loggingSettings, setLoggingSettings] = useState([]); const [selectedCreateKeyTeam, setSelectedCreateKeyTeam] = useState(team); + const [selectedProjectId, setSelectedProjectId] = useState(null); const [isCreateUserModalVisible, setIsCreateUserModalVisible] = useState(false); const [newlyCreatedUserId, setNewlyCreatedUserId] = useState(null); const [possibleUIRoles, setPossibleUIRoles] = useState>>({}); @@ -184,6 +188,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { setRouterSettings(null); setRouterSettingsKey((prev) => prev + 1); setSelectedAgentId(null); + setSelectedProjectId(null); }; const handleCancel = () => { @@ -200,6 +205,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { setRouterSettings(null); setRouterSettingsKey((prev) => prev + 1); setSelectedAgentId(null); + setSelectedProjectId(null); }; useEffect(() => { @@ -468,6 +474,14 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { }; useEffect(() => { + if (selectedProjectId) { + // When a project is selected, use the project's models + const project = projects?.find((p) => p.project_id === selectedProjectId); + const projectModels = project?.models ?? []; + setModelsToPick(projectModels); + form.setFieldValue("models", []); + return; + } if (userID && userRole && accessToken) { fetchTeamModels(userID, userRole, accessToken, selectedCreateKeyTeam?.team_id ?? null).then((models) => { let allModels = Array.from(new Set([...(selectedCreateKeyTeam?.models ?? []), ...models])); @@ -475,7 +489,22 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { }); } form.setFieldValue("models", []); - }, [selectedCreateKeyTeam, accessToken, userID, userRole]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedCreateKeyTeam, selectedProjectId, accessToken, userID, userRole]); + + // Sync team when project is selected but teams loaded later (race condition) + useEffect(() => { + if (!selectedProjectId || !teams) return; + const project = projects?.find((p) => p.project_id === selectedProjectId); + if (!project?.team_id) return; + // If team is already set correctly, skip + if (selectedCreateKeyTeam?.team_id === project.team_id) return; + const projectTeam = teams.find((t) => t.team_id === project.team_id) || null; + if (projectTeam) { + setSelectedCreateKeyTeam(projectTeam); + form.setFieldValue("team_id", projectTeam.team_id); + } + }, [teams, selectedProjectId, projects]); // Add a callback function to handle user creation const handleUserCreated = (userId: string) => { @@ -653,9 +682,40 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { > { const selectedTeam = teams?.find((t) => t.team_id === teamId) || null; setSelectedCreateKeyTeam(selectedTeam); + setSelectedProjectId(null); + form.setFieldValue("project_id", undefined); + }} + /> + + + Project{" "} + + + + + } + name="project_id" + className="mt-4" + > + { + if (!projectId) { + setSelectedProjectId(null); + setSelectedCreateKeyTeam(null); + form.setFieldValue("team_id", undefined); + return; + } + setSelectedProjectId(projectId); }} /> @@ -735,9 +795,11 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { } }} > - + {!selectedProjectId && ( + + )} {modelsToPick.map((model: string) => (