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 1f6eb8eeb6..73daf954c1 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,11 +18,9 @@ export interface KeysResponse { total_pages: number; } -export interface DeletedKeyResponse { - token: string; - token_id: string; - key_name: string; - key_alias: string; +export interface DeletedKeyResponse extends KeyResponse { + deleted_at: string; + deleted_by: string; } export interface DeletedKeysResponse { @@ -27,22 +30,83 @@ export interface DeletedKeysResponse { 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, @@ -50,13 +114,16 @@ export const useKeys = (page: number, pageSize: number): UseQueryResult => { +export const useDeletedKeys = ( + page: number, + pageSize: number, + options: KeyListCallOptions = {}, +): UseQueryResult => { const { accessToken } = useAuthorized(); return useQuery({ - queryKey: deletedKeyKeys.list({ page, limit: pageSize }), - queryFn: async () => - await keyListCall(accessToken!, null, null, null, null, null, page, pageSize, null, null, null, "deleted"), + 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, 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 2beebb1871..1e29e6ef43 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/DeletedKeysTable/DeletedKeysTable.tsx b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx index 6a39109e55..e0d2c05bd8 100644 --- a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx @@ -65,11 +65,12 @@ export function DeletedKeysTable({ accessorKey: "token", header: "Key ID", size: 150, + maxSize: 250, cell: (info) => { const value = info.getValue() as string; return ( - + {value || "-"} @@ -81,11 +82,12 @@ export function DeletedKeysTable({ accessorKey: "key_alias", header: "Key Alias", size: 150, + maxSize: 200, cell: (info) => { const value = info.getValue() as string; return ( - + {value ?? "-"} @@ -97,10 +99,11 @@ export function DeletedKeysTable({ accessorKey: "team_alias", header: "Team Alias", size: 120, + maxSize: 180, cell: (info) => { const value = info.getValue() as string; return ( - + {value || "-"} ); @@ -111,19 +114,26 @@ export function DeletedKeysTable({ accessorKey: "spend", header: "Spend (USD)", size: 100, - cell: (info) => formatNumberWithCommas(info.getValue() as number, 4), + 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; - if (maxBudget === null) { - return "Unlimited"; - } - return `$${formatNumberWithCommas(maxBudget)}`; + return ( + + {maxBudget === null ? "Unlimited" : `$${formatNumberWithCommas(maxBudget)}`} + + ); }, }, { @@ -131,11 +141,12 @@ export function DeletedKeysTable({ accessorKey: "user_email", header: "User Email", size: 160, + maxSize: 250, cell: (info) => { const value = info.getValue() as string; return ( - + {value ?? "-"} @@ -147,11 +158,12 @@ export function DeletedKeysTable({ accessorKey: "user_id", header: "User ID", size: 120, + maxSize: 200, cell: (info) => { const userId = info.getValue() as string | null; return ( - + {userId || "-"} @@ -163,9 +175,14 @@ export function DeletedKeysTable({ accessorKey: "created_at", header: "Created At", size: 120, + maxSize: 140, cell: (info) => { const value = info.getValue(); - return value ? new Date(value as string).toLocaleDateString() : "-"; + return ( + + {value ? new Date(value as string).toLocaleDateString() : "-"} + + ); }, }, { @@ -173,11 +190,12 @@ export function DeletedKeysTable({ 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 || "-"} @@ -189,9 +207,14 @@ export function DeletedKeysTable({ 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() : "-"; + return ( + + {value ? new Date(value).toLocaleDateString() : "-"} + + ); }, }, { @@ -199,11 +222,12 @@ export function DeletedKeysTable({ 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 || "-"} @@ -293,6 +317,7 @@ export function DeletedKeysTable({ className={`py-1 h-8 relative hover:bg-gray-50`} style={{ width: header.getSize(), + maxWidth: header.column.columnDef.maxSize, position: "relative", }} onMouseEnter={() => { @@ -366,7 +391,7 @@ export function DeletedKeysTable({ key={cell.id} style={{ width: cell.column.getSize(), - maxWidth: "8-x", + maxWidth: cell.column.columnDef.maxSize, whiteSpace: "pre-wrap", overflow: "hidden", }} 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 0000000000..d065ba8f29 --- /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.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx new file mode 100644 index 0000000000..821e9c50a3 --- /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/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 2fdac26faf..82894dfb0e 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -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, { @@ -8161,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 a701788626..7081e34a63 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -29,6 +29,7 @@ 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; @@ -504,6 +505,7 @@ export default function SpendLogsTable({ Request Logs Audit Logs Deleted Keys + Deleted Teams @@ -750,6 +752,7 @@ export default function SpendLogsTable({ /> +