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 c4ffb7041aa..16ae03044c9 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 @@ -3,14 +3,32 @@ import { renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React, { ReactNode } from "react"; import { useKeys } from "./useKeys"; -import { keyListCall } from "@/components/networking"; import type { KeyResponse } from "@/components/key_team_helpers/key_list"; -// Mock the networking function +// Mock the networking utilities vi.mock("@/components/networking", () => ({ - keyListCall: vi.fn(), + getProxyBaseUrl: vi.fn().mockReturnValue(""), + getGlobalLitellmHeaderName: vi.fn().mockReturnValue("Authorization"), + deriveErrorMessage: vi.fn((errorData: any) => { + return ( + (errorData?.error && (errorData.error.message || errorData.error)) || + errorData?.message || + errorData?.detail || + errorData?.error || + JSON.stringify(errorData) + ); + }), + handleError: vi.fn(), })); +// Mock global fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +// Mock console methods to avoid noise in tests +vi.spyOn(console, "log").mockImplementation(() => {}); +vi.spyOn(console, "error").mockImplementation(() => {}); + // Mock useAuthorized hook - we can override this in individual tests const mockUseAuthorized = vi.fn(); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ @@ -164,6 +182,9 @@ describe("useKeys", () => { disabledPersonalKeyCreation: null, showSSOBanner: false, }); + + // Reset fetch mock + mockFetch.mockClear(); }); const wrapper = ({ children }: { children: ReactNode }) => @@ -171,7 +192,10 @@ describe("useKeys", () => { it("should return keys data when query is successful", async () => { // Mock successful API call - (keyListCall as any).mockResolvedValue(mockKeysResponse); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockKeysResponse, + }); const { result } = renderHook(() => useKeys(1, 10), { wrapper }); @@ -187,25 +211,28 @@ describe("useKeys", () => { expect(result.current.data).toEqual(mockKeysResponse); expect(result.current.error).toBeNull(); - expect(keyListCall).toHaveBeenCalledWith( - "test-access-token", - null, // organizationID - null, // teamID - null, // selectedKeyAlias - null, // userID - null, // keyHash - 1, // page - 10, // pageSize + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + "/key/list?page=1&size=10&return_full_object=true&include_team_keys=true&include_created_by_keys=true", + { + method: "GET", + headers: { + Authorization: "Bearer test-access-token", + "Content-Type": "application/json", + }, + }, ); - expect(keyListCall).toHaveBeenCalledTimes(1); }); it("should handle error when keyListCall fails", async () => { const errorMessage = "Failed to fetch keys"; - const testError = new Error(errorMessage); + const errorResponse = { error: errorMessage }; // Mock failed API call - (keyListCall as any).mockRejectedValue(testError); + mockFetch.mockResolvedValueOnce({ + ok: false, + json: async () => errorResponse, + }); const { result } = renderHook(() => useKeys(1, 10), { wrapper }); @@ -218,19 +245,20 @@ describe("useKeys", () => { expect(result.current.isError).toBe(true); }); - expect(result.current.error).toEqual(testError); + expect(result.current.error).toBeDefined(); + expect(result.current.error?.message).toBe(errorMessage); expect(result.current.data).toBeUndefined(); - expect(keyListCall).toHaveBeenCalledWith( - "test-access-token", - null, // organizationID - null, // teamID - null, // selectedKeyAlias - null, // userID - null, // keyHash - 1, // page - 10, // pageSize + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + "/key/list?page=1&size=10&return_full_object=true&include_team_keys=true&include_created_by_keys=true", + { + method: "GET", + headers: { + Authorization: "Bearer test-access-token", + "Content-Type": "application/json", + }, + }, ); - expect(keyListCall).toHaveBeenCalledTimes(1); }); it("should not execute query when accessToken is missing", async () => { @@ -254,12 +282,15 @@ describe("useKeys", () => { expect(result.current.isFetched).toBe(false); // API should not be called - expect(keyListCall).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); }); it("should pass correct page and pageSize parameters to the API", async () => { // Mock successful API call - (keyListCall as any).mockResolvedValue(mockKeysResponse); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockKeysResponse, + }); const page = 2; const pageSize = 20; @@ -271,15 +302,15 @@ describe("useKeys", () => { expect(result.current.isLoading).toBe(false); }); - expect(keyListCall).toHaveBeenCalledWith( - "test-access-token", - null, // organizationID - null, // teamID - null, // selectedKeyAlias - null, // userID - null, // keyHash - page, // page - pageSize, // pageSize + expect(mockFetch).toHaveBeenCalledWith( + `/key/list?page=${page}&size=${pageSize}&return_full_object=true&include_team_keys=true&include_created_by_keys=true`, + { + method: "GET", + headers: { + Authorization: "Bearer test-access-token", + "Content-Type": "application/json", + }, + }, ); }); @@ -291,7 +322,10 @@ describe("useKeys", () => { current_page: 1, total_pages: 0, }; - (keyListCall as any).mockResolvedValue(emptyResponse); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => emptyResponse, + }); const { result } = renderHook(() => useKeys(1, 10), { wrapper }); @@ -302,15 +336,15 @@ describe("useKeys", () => { }); expect(result.current.data).toEqual(emptyResponse); - expect(keyListCall).toHaveBeenCalledWith( - "test-access-token", - null, // organizationID - null, // teamID - null, // selectedKeyAlias - null, // userID - null, // keyHash - 1, // page - 10, // pageSize + expect(mockFetch).toHaveBeenCalledWith( + "/key/list?page=1&size=10&return_full_object=true&include_team_keys=true&include_created_by_keys=true", + { + method: "GET", + headers: { + Authorization: "Bearer test-access-token", + "Content-Type": "application/json", + }, + }, ); }); @@ -318,7 +352,7 @@ describe("useKeys", () => { const timeoutError = new Error("Network timeout"); // Mock network timeout - (keyListCall as any).mockRejectedValue(timeoutError); + mockFetch.mockRejectedValueOnce(timeoutError); const { result } = renderHook(() => useKeys(1, 10), { wrapper }); @@ -338,7 +372,10 @@ describe("useKeys", () => { current_page: 2, total_pages: 2, }; - (keyListCall as any).mockResolvedValue(paginatedResponse); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => paginatedResponse, + }); const { result } = renderHook(() => useKeys(2, 10), { wrapper }); @@ -348,15 +385,15 @@ describe("useKeys", () => { }); expect(result.current.data).toEqual(paginatedResponse); - expect(keyListCall).toHaveBeenCalledWith( - "test-access-token", - null, // organizationID - null, // teamID - null, // selectedKeyAlias - null, // userID - null, // keyHash - 2, // page - 10, // pageSize + expect(mockFetch).toHaveBeenCalledWith( + "/key/list?page=2&size=10&return_full_object=true&include_team_keys=true&include_created_by_keys=true", + { + method: "GET", + headers: { + Authorization: "Bearer test-access-token", + "Content-Type": "application/json", + }, + }, ); }); }); 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 cd3df1a3820..73daf954c12 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -1,6 +1,11 @@ import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; -import { keyListCall } from "@/components/networking"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; import { KeyResponse } from "@/components/key_team_helpers/key_list"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; @@ -13,24 +18,114 @@ export interface KeysResponse { total_pages: number; } +export interface DeletedKeyResponse extends KeyResponse { + deleted_at: string; + deleted_by: string; +} + +export interface DeletedKeysResponse { + keys: DeletedKeyResponse[]; + total_count: number; + current_page: number; + total_pages: number; +} + +export interface KeyListCallOptions { + organizationID?: string | null; + teamID?: string | null; + selectedKeyAlias?: string | null; + userID?: string | null; + keyHash?: string | null; + sortBy?: string | null; + sortOrder?: string | null; + expand?: string | null; + status?: string | null; +} + +const keyListCall = async ( + accessToken: string, + page: number, + pageSize: number, + options: KeyListCallOptions = {}, +) => { + /** + * Get all available keys on proxy + */ + try { + const baseUrl = getProxyBaseUrl(); + + const params = new URLSearchParams( + Object.entries({ + team_id: options.teamID, + organization_id: options.organizationID, + key_alias: options.selectedKeyAlias, + key_hash: options.keyHash, + user_id: options.userID, + page, + size: pageSize, + sort_by: options.sortBy, + sort_order: options.sortOrder, + expand: options.expand, + status: options.status, + return_full_object: "true", + include_team_keys: "true", + include_created_by_keys: "true", + }) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => [key, String(value)]), + ); + + const url = `${baseUrl ? `${baseUrl}/key/list` : "/key/list"}?${params}`; + + const response = await fetch(url, { + method: "GET", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + console.log("/key/list API Response:", data); + return data; + } catch (error) { + console.error("Failed to list keys:", error); + throw error; + } +}; + export const useKeys = (page: number, pageSize: number): UseQueryResult => { const { accessToken } = useAuthorized(); return useQuery({ queryKey: keyKeys.list({ page, limit: pageSize }), - queryFn: async () => - await keyListCall( - accessToken!, - null, // organizationID - null, // teamID - null, // selectedKeyAlias - null, // userID - null, // keyHash - page, - pageSize, - ), + queryFn: async () => await keyListCall(accessToken!, page, pageSize), enabled: Boolean(accessToken), staleTime: 30000, // 30 seconds placeholderData: keepPreviousData, }); }; + +export const deletedKeyKeys = createQueryKeys("deletedKeys"); +export const useDeletedKeys = ( + page: number, + pageSize: number, + options: KeyListCallOptions = {}, +): UseQueryResult => { + const { accessToken } = useAuthorized(); + + return useQuery({ + queryKey: deletedKeyKeys.list({ page, limit: pageSize, ...options }), + queryFn: async () => await keyListCall(accessToken!, page, pageSize, { ...options, status: "deleted" }), + enabled: Boolean(accessToken), + staleTime: 30000, // 30 seconds + placeholderData: keepPreviousData, + }); +}; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts index 2beebb18718..1e29e6ef435 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts @@ -1,9 +1,93 @@ -import { useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query"; +import { keepPreviousData, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query"; import { Team } from "@/components/key_team_helpers/key_list"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { fetchTeams } from "@/app/(dashboard)/networking"; import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory"; import { teamInfoCall } from "@/components/networking"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; + +export interface TeamsResponse { + teams: Team[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface DeletedTeam extends Team { + deleted_at: string; + deleted_by: string; +} + + +export interface TeamListCallOptions { + organizationID?: string | null; + teamID?: string | null; + team_alias?: string | null; + userID?: string | null; + sortBy?: string | null; + sortOrder?: string | null; + status?: string | null; +} + +const teamListCall = async ( + accessToken: string, + page: number, + pageSize: number, + options: TeamListCallOptions = {}, +) => { + /** + * Get all available teams on proxy + */ + try { + const baseUrl = getProxyBaseUrl(); + + const params = new URLSearchParams( + Object.entries({ + team_id: options.teamID, + organization_id: options.organizationID, + team_alias: options.team_alias, + user_id: options.userID, + page, + page_size: pageSize, + sort_by: options.sortBy, + sort_order: options.sortOrder, + status: options.status, + }) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => [key, String(value)]), + ); + + const url = `${baseUrl ? `${baseUrl}/v2/team/list` : "/v2/team/list"}?${params}`; + + const response = await fetch(url, { + method: "GET", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + console.log("/v2/team/list API Response:", data); + return data; + } catch (error) { + console.error("Failed to list teams:", error); + throw error; + } +}; const teamKeys = createQueryKeys("teams"); export const useTeams = (): UseQueryResult => { @@ -39,3 +123,80 @@ export const useTeam = (teamId?: string) => { }, }); }; + +const deletedTeamListCall = async ( + accessToken: string, + page: number, + pageSize: number, + options: TeamListCallOptions = {}, +) => { + /** + * Get deleted teams from proxy + */ + try { + const baseUrl = getProxyBaseUrl(); + + const params = new URLSearchParams( + Object.entries({ + team_id: options.teamID, + organization_id: options.organizationID, + team_alias: options.team_alias, + user_id: options.userID, + page, + page_size: pageSize, + sort_by: options.sortBy, + sort_order: options.sortOrder, + status: "deleted", + }) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => [key, String(value)]), + ); + + const url = `${baseUrl ? `${baseUrl}/team/list` : "/team/list"}?${params}`; + + const response = await fetch(url, { + method: "GET", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + console.log("/team/list?status=deleted API Response:", data); + + // Extract teams array from response if it's wrapped in a response object + // Otherwise return the data directly if it's already an array + if (data && typeof data === 'object' && 'teams' in data) { + return data.teams as DeletedTeam[]; + } + return data as DeletedTeam[]; + } catch (error) { + console.error("Failed to list deleted teams:", error); + throw error; + } +}; + +export const deletedTeamKeys = createQueryKeys("deletedTeams"); +export const useDeletedTeams = ( + page: number, + pageSize: number, + options: TeamListCallOptions = {}, +): UseQueryResult => { + const { accessToken } = useAuthorized(); + + return useQuery({ + queryKey: deletedTeamKeys.list({ page, limit: pageSize, ...options }), + queryFn: async () => await deletedTeamListCall(accessToken!, page, pageSize, options), + enabled: Boolean(accessToken), + staleTime: 30000, // 30 seconds + placeholderData: keepPreviousData, + }); +}; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysPage.test.tsx b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysPage.test.tsx new file mode 100644 index 00000000000..46df98a31de --- /dev/null +++ b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysPage.test.tsx @@ -0,0 +1,102 @@ +import { screen } from "@testing-library/react"; +import { vi, it, expect, beforeEach, MockedFunction } from "vitest"; +import { renderWithProviders } from "../../../tests/test-utils"; +import DeletedKeysPage from "./DeletedKeysPage"; +import { useDeletedKeys, DeletedKeyResponse } from "@/app/(dashboard)/hooks/keys/useKeys"; + +vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ + useDeletedKeys: vi.fn(), +})); + +const mockUseDeletedKeys = useDeletedKeys as MockedFunction; + +const mockDeletedKey: DeletedKeyResponse = { + token: "sk-1234567890abcdef", + token_id: "key-1", + key_name: "test-key", + key_alias: "Test Key Alias", + spend: 5.5, + max_budget: 100, + expires: "2024-12-31T23:59:59Z", + models: ["gpt-3.5-turbo"], + aliases: {}, + config: {}, + user_id: "user-1", + team_id: "team-1", + max_parallel_requests: 10, + metadata: {}, + tpm_limit: 1000, + rpm_limit: 100, + duration: "30d", + budget_duration: "1m", + budget_reset_at: "2024-12-01T00:00:00Z", + allowed_cache_controls: [], + allowed_routes: [], + permissions: {}, + model_spend: {}, + model_max_budget: {}, + soft_budget_cooldown: false, + blocked: false, + litellm_budget_table: {}, + organization_id: "org-1", + created_at: "2024-11-01T10:00:00Z", + updated_at: "2024-11-15T10:00:00Z", + team_spend: 5.5, + team_alias: "Test Team", + team_tpm_limit: 5000, + team_rpm_limit: 500, + team_max_budget: 500, + team_models: ["gpt-3.5-turbo"], + team_blocked: false, + soft_budget: 50, + team_model_aliases: {}, + team_member_spend: 0, + team_metadata: {}, + end_user_id: "end-user-1", + end_user_tpm_limit: 100, + end_user_rpm_limit: 10, + end_user_max_budget: 10, + last_refreshed_at: Date.now(), + api_key: "sk-1234567890abcdef", + user_role: "user", + rpm_limit_per_model: {}, + tpm_limit_per_model: {}, + user_tpm_limit: 1000, + user_rpm_limit: 100, + user_email: "user@example.com", + deleted_at: "2024-11-15T10:00:00Z", + deleted_by: "user-1", +}; + +beforeEach(() => { + vi.clearAllMocks(); + + mockUseDeletedKeys.mockReturnValue({ + data: { + keys: [mockDeletedKey], + total_count: 1, + current_page: 1, + total_pages: 1, + }, + isPending: false, + isFetching: false, + } as any); +}); + +it("should render DeletedKeysPage component", () => { + renderWithProviders(); + + expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); +}); + +it("should handle loading state", () => { + mockUseDeletedKeys.mockReturnValue({ + data: undefined, + isPending: true, + isFetching: false, + } as any); + + renderWithProviders(); + + expect(screen.getByText("🚅 Loading keys...")).toBeInTheDocument(); +}); diff --git a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysPage.tsx b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysPage.tsx new file mode 100644 index 00000000000..0ca6438a095 --- /dev/null +++ b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysPage.tsx @@ -0,0 +1,27 @@ +"use client"; +import { useState } from "react"; +import { useDeletedKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { DeletedKeysTable } from "./DeletedKeysTable/DeletedKeysTable"; + +export default function DeletedKeysPage() { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize] = useState(50); + + const { + data: keysData, + isPending: isLoading, + isFetching, + } = useDeletedKeys(pageIndex + 1, pageSize); + + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.test.tsx b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.test.tsx new file mode 100644 index 00000000000..081ae0a80b5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.test.tsx @@ -0,0 +1,101 @@ +import { screen } from "@testing-library/react"; +import { vi, it, expect, beforeEach } from "vitest"; +import { renderWithProviders } from "../../../../tests/test-utils"; +import { DeletedKeysTable } from "./DeletedKeysTable"; +import { DeletedKeyResponse } from "@/app/(dashboard)/hooks/keys/useKeys"; + +const mockDeletedKey: DeletedKeyResponse = { + token: "sk-1234567890abcdef", + token_id: "key-1", + key_name: "test-key", + key_alias: "Test Key Alias", + spend: 5.5, + max_budget: 100, + expires: "2024-12-31T23:59:59Z", + models: ["gpt-3.5-turbo"], + aliases: {}, + config: {}, + user_id: "user-1", + team_id: "team-1", + max_parallel_requests: 10, + metadata: {}, + tpm_limit: 1000, + rpm_limit: 100, + duration: "30d", + budget_duration: "1m", + budget_reset_at: "2024-12-01T00:00:00Z", + allowed_cache_controls: [], + allowed_routes: [], + permissions: {}, + model_spend: {}, + model_max_budget: {}, + soft_budget_cooldown: false, + blocked: false, + litellm_budget_table: {}, + organization_id: "org-1", + created_at: "2024-11-01T10:00:00Z", + updated_at: "2024-11-15T10:00:00Z", + team_spend: 5.5, + team_alias: "Test Team", + team_tpm_limit: 5000, + team_rpm_limit: 500, + team_max_budget: 500, + team_models: ["gpt-3.5-turbo"], + team_blocked: false, + soft_budget: 50, + team_model_aliases: {}, + team_member_spend: 0, + team_metadata: {}, + end_user_id: "end-user-1", + end_user_tpm_limit: 100, + end_user_rpm_limit: 10, + end_user_max_budget: 10, + last_refreshed_at: Date.now(), + api_key: "sk-1234567890abcdef", + user_role: "user", + rpm_limit_per_model: {}, + tpm_limit_per_model: {}, + user_tpm_limit: 1000, + user_rpm_limit: 100, + user_email: "user@example.com", + deleted_at: "2024-11-15T10:00:00Z", + deleted_by: "user-1", +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +it("should render DeletedKeysTable component", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); +}); + +it("should display key information correctly", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); + expect(screen.getByText("sk-1234567890abcdef")).toBeInTheDocument(); + expect(screen.getByText("Showing 1 - 1 of 1 results")).toBeInTheDocument(); +}); diff --git a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx new file mode 100644 index 00000000000..e0d2c05bd86 --- /dev/null +++ b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx @@ -0,0 +1,422 @@ +"use client"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + PaginationState, + SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from "@tremor/react"; +import { Tooltip } from "antd"; +import React, { useState } from "react"; +import { KeyResponse } from "../../key_team_helpers/key_list"; + +interface DeletedKeysTableProps { + keys: KeyResponse[]; + totalCount: number; + isLoading: boolean; + isFetching: boolean; + pageIndex: number; + pageSize: number; + onPageChange: (pageIndex: number) => void; +} + +export function DeletedKeysTable({ + keys, + totalCount, + isLoading, + isFetching, + pageIndex, + pageSize, + onPageChange, +}: DeletedKeysTableProps) { + const [sorting, setSorting] = useState([ + { + id: "deleted_at", + desc: true, + }, + ]); + + const [tablePagination, setTablePagination] = useState({ + pageIndex, + pageSize, + }); + + // Sync pagination state when prop changes + React.useEffect(() => { + setTablePagination({ pageIndex, pageSize }); + }, [pageIndex, pageSize]); + + const columns: ColumnDef[] = [ + { + id: "token", + accessorKey: "token", + header: "Key ID", + size: 150, + maxSize: 250, + cell: (info) => { + const value = info.getValue() as string; + return ( + + + {value || "-"} + + + ); + }, + }, + { + id: "key_alias", + accessorKey: "key_alias", + header: "Key Alias", + size: 150, + maxSize: 200, + cell: (info) => { + const value = info.getValue() as string; + return ( + + + {value ?? "-"} + + + ); + }, + }, + { + id: "team_alias", + accessorKey: "team_alias", + header: "Team Alias", + size: 120, + maxSize: 180, + cell: (info) => { + const value = info.getValue() as string; + return ( + + {value || "-"} + + ); + }, + }, + { + id: "spend", + accessorKey: "spend", + header: "Spend (USD)", + size: 100, + maxSize: 140, + cell: (info) => ( + + {formatNumberWithCommas(info.getValue() as number, 4)} + + ), + }, + { + id: "max_budget", + accessorKey: "max_budget", + header: "Budget (USD)", + size: 110, + maxSize: 150, + cell: (info) => { + const maxBudget = info.getValue() as number | null; + return ( + + {maxBudget === null ? "Unlimited" : `$${formatNumberWithCommas(maxBudget)}`} + + ); + }, + }, + { + id: "user_email", + accessorKey: "user_email", + header: "User Email", + size: 160, + maxSize: 250, + cell: (info) => { + const value = info.getValue() as string; + return ( + + + {value ?? "-"} + + + ); + }, + }, + { + id: "user_id", + accessorKey: "user_id", + header: "User ID", + size: 120, + maxSize: 200, + cell: (info) => { + const userId = info.getValue() as string | null; + return ( + + + {userId || "-"} + + + ); + }, + }, + { + id: "created_at", + accessorKey: "created_at", + header: "Created At", + size: 120, + maxSize: 140, + cell: (info) => { + const value = info.getValue(); + return ( + + {value ? new Date(value as string).toLocaleDateString() : "-"} + + ); + }, + }, + { + id: "created_by", + accessorKey: "created_by", + header: "Created By", + size: 120, + maxSize: 180, + cell: (info) => { + const value = (info.row.original as any).created_by as string | null | undefined; + return ( + + + {value || "-"} + + + ); + }, + }, + { + id: "deleted_at", + accessorKey: "deleted_at", + header: "Deleted At", + size: 120, + maxSize: 140, + cell: (info) => { + const value = (info.row.original as any).deleted_at as string | null | undefined; + return ( + + {value ? new Date(value).toLocaleDateString() : "-"} + + ); + }, + }, + { + id: "deleted_by", + accessorKey: "deleted_by", + header: "Deleted By", + size: 120, + maxSize: 180, + cell: (info) => { + const value = (info.row.original as any).deleted_by as string | null | undefined; + return ( + + + {value || "-"} + + + ); + }, + }, + ]; + + const table = useReactTable({ + data: keys, + columns, + columnResizeMode: "onChange", + columnResizeDirection: "ltr", + state: { + sorting, + pagination: tablePagination, + }, + onSortingChange: setSorting, + onPaginationChange: (updater) => { + const newPagination = typeof updater === "function" ? updater(tablePagination) : updater; + setTablePagination(newPagination); + onPageChange(newPagination.pageIndex); + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + enableSorting: true, + manualSorting: false, + manualPagination: true, + pageCount: Math.ceil(totalCount / pageSize), + }); + + const { pageIndex: currentPageIndex } = table.getState().pagination; + const start = currentPageIndex * pageSize + 1; + const end = Math.min((currentPageIndex + 1) * pageSize, totalCount); + const rangeLabel = `${start} - ${end}`; + + return ( +
+
+
+ {isLoading || isFetching ? ( + Loading... + ) : ( + + Showing {rangeLabel} of {totalCount} results + + )} + +
+ {isLoading || isFetching ? ( + Loading... + ) : ( + + Page {currentPageIndex + 1} of {table.getPageCount()} + + )} + + + + +
+
+
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + { + const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); + if (resizer) { + (resizer as HTMLElement).style.opacity = "0.5"; + } + }} + onMouseLeave={() => { + const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); + if (resizer && !header.column.getIsResizing()) { + (resizer as HTMLElement).style.opacity = "0"; + } + }} + onClick={header.column.getToggleSortingHandler()} + > +
+
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+
+ {header.column.getIsSorted() ? ( + { + asc: , + desc: , + }[header.column.getIsSorted() as string] + ) : ( + + )} +
+
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={`resizer ${table.options.columnResizeDirection} ${header.column.getIsResizing() ? "isResizing" : ""}`} + style={{ + position: "absolute", + right: 0, + top: 0, + height: "100%", + width: "5px", + background: header.column.getIsResizing() ? "#3b82f6" : "transparent", + cursor: "col-resize", + userSelect: "none", + touchAction: "none", + opacity: header.column.getIsResizing() ? 1 : 0, + }} + /> +
+ + ))} + + ))} + + + {isLoading || isFetching ? ( + + +
+

🚅 Loading keys...

+
+
+
+ ) : keys.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + +
+

No deleted keys found

+
+
+
+ )} +
+
+
+
+
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.test.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.test.tsx new file mode 100644 index 00000000000..77d2e94067e --- /dev/null +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.test.tsx @@ -0,0 +1,56 @@ +import { screen } from "@testing-library/react"; +import { vi, it, expect, beforeEach, MockedFunction } from "vitest"; +import { renderWithProviders } from "../../../tests/test-utils"; +import DeletedTeamsPage from "./DeletedTeamsPage"; +import { useDeletedTeams, DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams"; + +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useDeletedTeams: vi.fn(), +})); + +const mockUseDeletedTeams = useDeletedTeams as MockedFunction; + +const mockDeletedTeam: DeletedTeam = { + team_id: "team-1", + team_alias: "Test Team", + models: ["gpt-3.5-turbo", "gpt-4"], + max_budget: 500, + budget_duration: "1m", + tpm_limit: 5000, + rpm_limit: 500, + organization_id: "org-1", + created_at: "2024-10-01T10:00:00Z", + keys: [], + members_with_roles: [], + deleted_at: "2024-11-15T10:00:00Z", + deleted_by: "user-1", + spend: 100.5, +}; + +beforeEach(() => { + vi.clearAllMocks(); + + mockUseDeletedTeams.mockReturnValue({ + data: [mockDeletedTeam], + isPending: false, + isFetching: false, + } as any); +}); + +it("should render DeletedTeamsPage component", () => { + renderWithProviders(); + + expect(screen.getByText("Test Team")).toBeInTheDocument(); +}); + +it("should handle loading state", () => { + mockUseDeletedTeams.mockReturnValue({ + data: undefined, + isPending: true, + isFetching: false, + } as any); + + renderWithProviders(); + + expect(screen.getByText("🚅 Loading teams...")).toBeInTheDocument(); +}); diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.tsx new file mode 100644 index 00000000000..d065ba8f291 --- /dev/null +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.tsx @@ -0,0 +1,19 @@ +"use client"; +import { useDeletedTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { DeletedTeamsTable } from "./DeletedTeamsTable/DeletedTeamsTable"; + +export default function DeletedTeamsPage() { + const { + data: teamsData, + isPending: isLoading, + isFetching, + } = useDeletedTeams(1, 100); + + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.test.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.test.tsx new file mode 100644 index 00000000000..803e3a0f2f5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.test.tsx @@ -0,0 +1,44 @@ +import { screen } from "@testing-library/react"; +import { vi, it, expect, beforeEach } from "vitest"; +import { renderWithProviders } from "../../../../tests/test-utils"; +import { DeletedTeamsTable } from "./DeletedTeamsTable"; +import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams"; + +const mockDeletedTeam: DeletedTeam = { + team_id: "team-1", + team_alias: "Test Team", + models: ["gpt-3.5-turbo", "gpt-4"], + max_budget: 500, + budget_duration: "1m", + tpm_limit: 5000, + rpm_limit: 500, + organization_id: "org-1", + created_at: "2024-10-01T10:00:00Z", + keys: [], + members_with_roles: [], + deleted_at: "2024-11-15T10:00:00Z", + deleted_by: "user-1", + spend: 100.5, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +it("should render DeletedTeamsTable component", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Test Team")).toBeInTheDocument(); +}); + +it("should display team information correctly", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Test Team")).toBeInTheDocument(); + expect(screen.getByText("team-1")).toBeInTheDocument(); + expect(screen.getByText("Showing 1 team")).toBeInTheDocument(); +}); diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx new file mode 100644 index 00000000000..821e9c50a3a --- /dev/null +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx @@ -0,0 +1,362 @@ +"use client"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, + Badge, + Text, +} from "@tremor/react"; +import { Tooltip } from "antd"; +import React, { useState } from "react"; +import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; + +interface DeletedTeamsTableProps { + teams: DeletedTeam[]; + isLoading: boolean; + isFetching: boolean; +} + +export function DeletedTeamsTable({ + teams, + isLoading, + isFetching, +}: DeletedTeamsTableProps) { + const [sorting, setSorting] = useState([ + { + id: "deleted_at", + desc: true, + }, + ]); + + const columns: ColumnDef[] = [ + { + id: "team_alias", + accessorKey: "team_alias", + header: "Team Name", + size: 150, + maxSize: 200, + cell: (info) => { + const value = info.getValue() as string; + return ( + + + {value || "-"} + + + ); + }, + }, + { + id: "team_id", + accessorKey: "team_id", + header: "Team ID", + size: 150, + maxSize: 250, + cell: (info) => { + const value = info.getValue() as string; + return ( + + + {value || "-"} + + + ); + }, + }, + { + id: "created_at", + accessorKey: "created_at", + header: "Created", + size: 120, + maxSize: 140, + cell: (info) => { + const value = info.getValue(); + return ( + + {value ? new Date(value as string).toLocaleDateString() : "-"} + + ); + }, + }, + { + id: "spend", + accessorKey: "spend", + header: "Spend (USD)", + size: 100, + maxSize: 140, + cell: (info) => { + const spend = (info.row.original as any).spend as number | undefined; + return ( + + {spend !== undefined ? formatNumberWithCommas(spend, 4) : "-"} + + ); + }, + }, + { + id: "max_budget", + accessorKey: "max_budget", + header: "Budget (USD)", + size: 110, + maxSize: 150, + cell: (info) => { + const maxBudget = info.getValue() as number | null; + return ( + + {maxBudget === null || maxBudget === undefined ? "No limit" : `$${formatNumberWithCommas(maxBudget)}`} + + ); + }, + }, + { + id: "models", + accessorKey: "models", + header: "Models", + size: 200, + maxSize: 300, + cell: (info) => { + const models = info.getValue() as string[]; + if (!Array.isArray(models) || models.length === 0) { + return ( + + All Proxy Models + + ); + } + return ( +
+ {models.slice(0, 3).map((model: string, index: number) => + model === "all-proxy-models" ? ( + + All Proxy Models + + ) : ( + + + {model.length > 30 + ? `${getModelDisplayName(model).slice(0, 30)}...` + : getModelDisplayName(model)} + + + ), + )} + {models.length > 3 && ( + + + +{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"} + + + )} +
+ ); + }, + }, + { + id: "organization_id", + accessorKey: "organization_id", + header: "Organization", + size: 150, + maxSize: 200, + cell: (info) => { + const value = info.getValue() as string; + return ( + + + {value || "-"} + + + ); + }, + }, + { + id: "deleted_at", + accessorKey: "deleted_at", + header: "Deleted At", + size: 120, + maxSize: 140, + cell: (info) => { + const value = (info.row.original as any).deleted_at as string | null | undefined; + return ( + + {value ? new Date(value).toLocaleDateString() : "-"} + + ); + }, + }, + { + id: "deleted_by", + accessorKey: "deleted_by", + header: "Deleted By", + size: 120, + maxSize: 180, + cell: (info) => { + const value = (info.row.original as any).deleted_by as string | null | undefined; + return ( + + + {value || "-"} + + + ); + }, + }, + ]; + + const table = useReactTable({ + data: teams, + columns, + columnResizeMode: "onChange", + columnResizeDirection: "ltr", + state: { + sorting, + }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + enableSorting: true, + manualSorting: false, + }); + + return ( +
+
+
+ {isLoading || isFetching ? ( + Loading... + ) : ( + + Showing {teams.length} {teams.length === 1 ? "team" : "teams"} + + )} +
+
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + { + const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); + if (resizer) { + (resizer as HTMLElement).style.opacity = "0.5"; + } + }} + onMouseLeave={() => { + const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); + if (resizer && !header.column.getIsResizing()) { + (resizer as HTMLElement).style.opacity = "0"; + } + }} + onClick={header.column.getToggleSortingHandler()} + > +
+
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+
+ {header.column.getIsSorted() ? ( + { + asc: , + desc: , + }[header.column.getIsSorted() as string] + ) : ( + + )} +
+
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={`resizer ${table.options.columnResizeDirection} ${header.column.getIsResizing() ? "isResizing" : ""}`} + style={{ + position: "absolute", + right: 0, + top: 0, + height: "100%", + width: "5px", + background: header.column.getIsResizing() ? "#3b82f6" : "transparent", + cursor: "col-resize", + userSelect: "none", + touchAction: "none", + opacity: header.column.getIsResizing() ? 1 : 0, + }} + /> +
+ + ))} + + ))} + + + {isLoading || isFetching ? ( + + +
+

🚅 Loading teams...

+
+
+
+ ) : teams.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + +
+

No deleted teams found

+
+
+
+ )} +
+
+
+
+
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index a04fbf3943d..5511a671db7 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -14,6 +14,7 @@ export interface Team { created_at: string; keys: KeyResponse[]; members_with_roles: Member[]; + spend: number; } export interface KeyResponse { diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 920448e8ac1..82894dfb0e2 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -53,7 +53,7 @@ const defaultServerRootPath = "/"; export let serverRootPath = defaultServerRootPath; export let proxyBaseUrl = defaultProxyBaseUrl; if (isLocal != true) { - console.log = function () {}; + console.log = function () { }; } const getWindowLocation = () => { @@ -239,7 +239,7 @@ export interface CredentialsResponse { let lastErrorTime = 0; -const handleError = async (errorData: string | any) => { +export const handleError = async (errorData: string | any) => { const currentTime = Date.now(); if (currentTime - lastErrorTime > 60000) { // 60000 milliseconds = 60 seconds @@ -310,6 +310,11 @@ export function setGlobalLitellmHeaderName(headerName: string = "Authorization") globalLitellmHeaderName = headerName; } +// Function to get the global header name +export function getGlobalLitellmHeaderName(): string { + return globalLitellmHeaderName; +} + export const makeModelGroupPublic = async (accessToken: string, modelGroups: string[]) => { const url = proxyBaseUrl ? `${proxyBaseUrl}/model_group/make_public` : `/model_group/make_public`; const response = await fetch(url, { @@ -3270,6 +3275,7 @@ export const keyListCall = async ( sortBy: string | null = null, sortOrder: string | null = null, expand: string | null = null, + status: string | null = null, ) => { /** * Get all available teams on proxy @@ -3319,6 +3325,10 @@ export const keyListCall = async ( queryParams.append("expand", expand); } + if (status) { + queryParams.append("status", status); + } + queryParams.append("return_full_object", "true"); queryParams.append("include_team_keys", "true"); queryParams.append("include_created_by_keys", "true"); @@ -8156,7 +8166,7 @@ export const perUserAnalyticsCall = async ( } }; -const deriveErrorMessage = (errorData: any): string => { +export const deriveErrorMessage = (errorData: any): string => { return ( (errorData?.error && (errorData.error.message || errorData.error)) || errorData?.message || diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 2a94284fca5..7081e34a637 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -28,6 +28,8 @@ import AuditLogs from "./audit_logs"; import { getTimeRangeDisplay } from "./logs_utils"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { truncateString } from "@/utils/textUtils"; +import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage"; +import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage"; interface SpendLogsTableProps { accessToken: string | null; @@ -355,7 +357,7 @@ export default function SpendLogsTable({ sessionLogs.data?.data?.map((log) => ({ ...log, onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash), - onSessionClick: (sessionId: string) => {}, + onSessionClick: (sessionId: string) => { }, })) || []; // Add this function to handle manual refresh @@ -502,6 +504,8 @@ export default function SpendLogsTable({ Request Logs Audit Logs + Deleted Keys + Deleted Teams @@ -537,7 +541,7 @@ export default function SpendLogsTable({ data={sessionData} renderSubComponent={RequestViewer} getRowCanExpand={() => true} - // Optionally: add session-specific row expansion state + // Optionally: add session-specific row expansion state /> ) : ( @@ -597,9 +601,8 @@ export default function SpendLogsTable({ {quickSelectOptions.map((option) => (