diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index cf2b21dc835..921f4d2603d 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -12,6 +12,7 @@ "@headlessui/tailwindcss": "^0.2.0", "@heroicons/react": "^1.0.6", "@remixicon/react": "^4.1.1", + "@tanstack/react-pacer": "^0.2.0", "@tanstack/react-query": "^5.64.1", "@tanstack/react-table": "^8.20.6", "@tremor/react": "^3.13.3", @@ -772,6 +773,19 @@ "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" } }, + "node_modules/@tanstack/pacer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@tanstack/pacer/-/pacer-0.2.0.tgz", + "integrity": "sha512-fUJs3NpSwtAL/tfq8kuYdgvm9HbbJvHsOG6aHY2dFDfff0NBFNwjvyGreWZZRPs2zgoIbr4nOk+rRV7aQgmf+A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.64.1", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.64.1.tgz", @@ -781,6 +795,26 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/react-pacer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-pacer/-/react-pacer-0.2.0.tgz", + "integrity": "sha512-KU5GtjkKSeNdYCilen5Dc+Pu/6BPQbsQshKrUUjrg7URyJIiGBCz6ZZFre1QjDz/aeUeqUJWMWSm+2Dsh64v+w==", + "license": "MIT", + "dependencies": { + "@tanstack/pacer": "0.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/@tanstack/react-query": { "version": "5.64.1", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.64.1.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 4864bc2ca06..60801371646 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -13,6 +13,7 @@ "@headlessui/tailwindcss": "^0.2.0", "@heroicons/react": "^1.0.6", "@remixicon/react": "^4.1.1", + "@tanstack/react-pacer": "^0.2.0", "@tanstack/react-query": "^5.64.1", "@tanstack/react-table": "^8.20.6", "@tremor/react": "^3.13.3", diff --git a/ui/litellm-dashboard/src/components/create_user_button.tsx b/ui/litellm-dashboard/src/components/create_user_button.tsx index eeed238d857..90eae5eac9e 100644 --- a/ui/litellm-dashboard/src/components/create_user_button.tsx +++ b/ui/litellm-dashboard/src/components/create_user_button.tsx @@ -24,6 +24,7 @@ const { Option } = Select; import { Tooltip } from "antd"; import { InfoCircleOutlined } from '@ant-design/icons'; import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; +import { useQueryClient } from "@tanstack/react-query"; interface CreateuserProps { userID: string; @@ -50,6 +51,7 @@ const Createuser: React.FC = ({ onUserCreated, isEmbedded = false, }) => { + const queryClient = useQueryClient(); const [uiSettings, setUISettings] = useState(null); const [form] = Form.useForm(); const [isModalVisible, setIsModalVisible] = useState(false); @@ -130,6 +132,7 @@ const Createuser: React.FC = ({ } console.log("formValues in create user:", formValues); const response = await userCreateCall(accessToken, null, formValues); + await queryClient.invalidateQueries({ queryKey: ['userList'] }) console.log("user create Response:", response); setApiuser(true); const user_id = response.data?.user_id || response.user_id; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 416c39e9c80..80ae37b0b22 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -5,6 +5,7 @@ import { all_admin_roles } from "@/utils/roles"; import { message } from "antd"; import { TagNewRequest, TagUpdateRequest, TagDeleteRequest, TagInfoRequest, TagListResponse, TagInfoResponse } from "./tag_management/types"; import { Team } from "./key_team_helpers/key_list"; +import { UserInfo } from "./view_users/types"; const isLocal = process.env.NODE_ENV === "development"; export const proxyBaseUrl = isLocal ? "http://localhost:4000" : null; @@ -670,6 +671,13 @@ export const teamDeleteCall = async (accessToken: String, teamID: String) => { } }; +export type UserListResponse = { + page: number, + page_size: number, + total: number, + total_pages: number, + users: UserInfo[] +} export const userListCall = async ( accessToken: String, @@ -748,7 +756,7 @@ export const userListCall = async ( throw new Error("Network response was not ok"); } - const data = await response.json(); + const data = await response.json() as UserListResponse; console.log("/user/list API Response:", data); return data; // Handle success - you might want to update some state or UI based on the created key @@ -2914,7 +2922,7 @@ export const getPossibleUserRoles = async (accessToken: String) => { const errorData = await response.text(); throw new Error("Network response was not ok"); } - const data = await response.json(); + const data = await response.json() as Record>; console.log("response from user/available_role", data); return data; // Handle success - you might want to update some state or UI based on the created key @@ -3600,7 +3608,10 @@ export const userUpdateUserCall = async ( throw new Error("Network response was not ok"); } - const data = await response.json(); + const data = await response.json() as { + user_id: string; + data: UserInfo; + }; console.log("API Response:", data); //message.success("User role updated"); return data; diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index 0e807862ffc..8bf6fd3efed 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -1,58 +1,36 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import { - Card, - Title, - Subtitle, - Table, - TableHead, - TableHeaderCell, - TableRow, - TableCell, - TableBody, Tab, - Text, TabGroup, TabList, TabPanels, - Metric, - Grid, TabPanel, Select, SelectItem, - Dialog, - DialogPanel, - Icon, - TextInput, - NumberInput, } from "@tremor/react"; import { message } from "antd"; -import { Modal } from "antd"; import { userInfoCall, userUpdateUserCall, getPossibleUserRoles, userListCall, + UserListResponse, } from "./networking"; -import { Badge, BadgeDelta, Button } from "@tremor/react"; -import RequestAccess from "./request_model_access"; +import { Button } from "@tremor/react"; import CreateUser from "./create_user_button"; import EditUserModal from "./edit_user"; -import Paragraph from "antd/es/skeleton/Paragraph"; -import { - PencilAltIcon, - InformationCircleIcon, - TrashIcon, -} from "@heroicons/react/outline"; import { userDeleteCall } from "./networking"; import { columns } from "./view_users/columns"; import { UserDataTable } from "./view_users/table"; import { UserInfo } from "./view_users/types"; -import BulkCreateUsers from "./bulk_create_users_button"; import SSOSettings from "./SSOSettings"; import debounce from "lodash/debounce"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { updateExistingKeys } from "@/utils/dataUtils"; +import { useDebouncedState } from '@tanstack/react-pacer/debouncer' interface ViewUserDashboardProps { accessToken: string | null; @@ -64,22 +42,6 @@ interface ViewUserDashboardProps { setKeys: React.Dispatch>; } -interface UserListResponse { - users: any[] | null; - total: number; - page: number; - page_size: number; - total_pages: number; -} - -interface CreateuserProps { - userID: string; - accessToken: string; - teams: any[]; - possibleUIRoles: Record>; - onUserCreated: () => Promise; -} - interface FilterState { email: string; user_id: string; @@ -99,144 +61,75 @@ if (isLocal != true) { console.log = function() {}; } +const DEFAULT_PAGE_SIZE = 25; + +const initialFilters: FilterState = { + email: "", + user_id: "", + user_role: "", + sso_user_id: "", + team: "", + model: "", + min_spend: null, + max_spend: null, + sort_by: "created_at", + sort_order: "desc" +} + const ViewUserDashboard: React.FC = ({ accessToken, token, - keys, userRole, userID, teams, - setKeys, }) => { - const [userListResponse, setUserListResponse] = useState(null); - const [endUsers, setEndUsers] = useState(null); + const queryClient = useQueryClient(); const [currentPage, setCurrentPage] = useState(1); - const [openDialogId, setOpenDialogId] = React.useState(null); - const [selectedItem, setSelectedItem] = useState(null); const [editModalVisible, setEditModalVisible] = useState(false); const [selectedUser, setSelectedUser] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [userToDelete, setUserToDelete] = useState(null); - const [possibleUIRoles, setPossibleUIRoles] = useState< - Record> - >({}); - const defaultPageSize = 25; - const [searchTerm, setSearchTerm] = useState(""); const [activeTab, setActiveTab] = useState("users"); - const [filters, setFilters] = useState({ - email: "", - user_id: "", - user_role: "", - sso_user_id: "", - team: "", - model: "", - min_spend: null, - max_spend: null, - sort_by: "created_at", - sort_order: "desc" - }); + const [filters, setFilters] = useState(initialFilters); + const [debouncedFilters, setDebouncedFilters, debouncer] = useDebouncedState(filters, { wait: 300 }) const [showFilters, setShowFilters] = useState(false); - const [showColumnDropdown, setShowColumnDropdown] = useState(false); - const [selectedFilter, setSelectedFilter] = useState("Email"); - const filtersRef = useRef(null); - const lastSearchTimestamp = useRef(0); - - // check if window is not undefined - if (typeof window !== "undefined") { - window.addEventListener("beforeunload", function () { - // Clear session storage - sessionStorage.clear(); - }); - } - const handleDelete = (userId: string) => { setUserToDelete(userId); setIsDeleteModalOpen(true); }; - const handleFilterChange = (key: keyof FilterState, value: string | number | null) => { - const newFilters = { ...filters, [key]: value }; - setFilters(newFilters); - console.log("called from handleFilterChange - newFilters:", JSON.stringify(newFilters)); - debouncedSearch(newFilters); + useEffect(() => { + return () => { + debouncer.cancel() + } + }, [debouncer]) + + const updateFilters = (update: Partial) => { + setFilters((previousFilters) => { + const newFilters = {...previousFilters, ...update }; + setDebouncedFilters(newFilters); + return newFilters; + }) }; const handleSortChange = (sortBy: string, sortOrder: 'asc' | 'desc') => { - const newFilters = { - ...filters, - sort_by: sortBy, - sort_order: sortOrder - }; - setFilters(newFilters); - debouncedSearch(newFilters); + updateFilters({ sort_by: sortBy, sort_order: sortOrder }); }; - // Create a debounced version of the search function - const debouncedSearch = useCallback( - debounce(async (filters: FilterState) => { - if (!accessToken || !token || !userRole || !userID) { - return; - } - - const currentTimestamp = Date.now(); - lastSearchTimestamp.current = currentTimestamp; - - try { - // Make the API call using userListCall with all filter parameters - const data = await userListCall( - accessToken, - filters.user_id ? [filters.user_id] : null, - 1, // Reset to first page when searching - defaultPageSize, - filters.email || null, - filters.user_role || null, - filters.team || null, - filters.sso_user_id || null, - filters.sort_by, - filters.sort_order - ); - - // Only update state if this is the most recent search - if (currentTimestamp === lastSearchTimestamp.current) { - if (data) { - setUserListResponse(data); - console.log("called from debouncedSearch filters:", JSON.stringify(filters)); - console.log("called from debouncedSearch data:", JSON.stringify(data)); - } - } - } catch (error) { - console.error("Error searching users:", error); - } - }, 300), - [accessToken, token, userRole, userID] - ); - - // Cleanup the debounced function on component unmount - useEffect(() => { - return () => { - debouncedSearch.cancel(); - }; - }, [debouncedSearch]); - - const handleSearch = (value: string) => { - setSearchTerm(value); - if (value === "") { - refreshUserData(); // Reset to original data when search is cleared - } else { - debouncedSearch(filters); - } - }; const confirmDelete = async () => { if (userToDelete && accessToken) { try { await userDeleteCall(accessToken, [userToDelete]); - message.success("User deleted successfully"); + // Update the user list after deletion - if (userListResponse) { - const updatedUserData = userListResponse.users?.filter(user => user.user_id !== userToDelete); - setUserListResponse({ ...userListResponse, users: updatedUserData || [] }); - } + queryClient.setQueriesData({ queryKey: ['userList'] }, (previousData) => { + if (previousData === undefined) return previousData; + const updatedUsers = previousData.users.filter(user => user.user_id !== userToDelete); + return { ...previousData, users: updatedUsers }; + }) + + message.success("User deleted successfully"); } catch (error) { console.error("Error deleting user:", error); message.error("Failed to delete user"); @@ -264,141 +157,68 @@ const ViewUserDashboard: React.FC = ({ } try { - await userUpdateUserCall(accessToken, editedUser, null); + const response = await userUpdateUserCall(accessToken, editedUser, null); + queryClient.setQueriesData({ queryKey: ['userList'] }, (previousData) => { + if (previousData === undefined) return previousData; + const updatedUsers = previousData.users.map(user => { + if (user.user_id === response.data.user_id) { + return updateExistingKeys(user, response.data); + } + return user; + }); + + return { ...previousData, users: updatedUsers }; + }) + message.success(`User ${editedUser.user_id} updated successfully`); } catch (error) { console.error("There was an error updating the user", error); } - if (userListResponse) { - const updatedUserData = userListResponse.users?.map((user) => - user.user_id === editedUser.user_id ? editedUser : user - ); - setUserListResponse({ ...userListResponse, users: updatedUserData || [] }); - } setSelectedUser(null); setEditModalVisible(false); // Close the modal }; - const refreshUserData = async () => { - console.log("called from refreshUserData"); - if (!accessToken || !token || !userRole || !userID) { - return; - } - - try { - const userDataResponse = await userInfoCall( - accessToken, - null, - userRole, - true, - currentPage, - defaultPageSize - ); - - // Update session storage with new data - sessionStorage.setItem( - `userList_${currentPage}`, - JSON.stringify(userDataResponse) - ); - console.log("called from refreshUserData"); - setUserListResponse(userDataResponse); - } catch (error) { - console.error("Error refreshing user data:", error); - } - }; - const handlePageChange = async (newPage: number) => { - if (!accessToken || !token || !userRole || !userID) { - return; - } - - try { - const userDataResponse = await userListCall( - accessToken, - filters.user_id ? [filters.user_id] : null, - newPage, - defaultPageSize, - filters.email || null, - filters.user_role || null, - filters.team || null, - filters.sso_user_id || null, - filters.sort_by, - filters.sort_order - ); - - // Update session storage with new data - sessionStorage.setItem( - `userList_${newPage}`, - JSON.stringify(userDataResponse) - ); - - setUserListResponse(userDataResponse); - setCurrentPage(newPage); - } catch (error) { - console.error("Error changing page:", error); - } + setCurrentPage(newPage); }; - useEffect(() => { - if (!accessToken || !token || !userRole || !userID) { - return; - } - const fetchData = async () => { - try { - // Check session storage first - const cachedUserData = sessionStorage.getItem(`userList_${currentPage}`); - if (cachedUserData) { - const parsedData = JSON.parse(cachedUserData); - setUserListResponse(parsedData); - console.log("called from useEffect"); - } else { - // Fetch from API using userListCall with current filters - const userDataResponse = await userListCall( - accessToken, - filters.user_id ? [filters.user_id] : null, - currentPage, - defaultPageSize, - filters.email || null, - filters.user_role || null, - filters.team || null, - filters.sso_user_id || null, - filters.sort_by, - filters.sort_order - ); + const userListQuery = useQuery({ + queryKey: ['userList', { debouncedFilter: debouncedFilters, currentPage }], + queryFn: async () => { + if (!accessToken) throw new Error('Access token required'); - // Store in session storage - sessionStorage.setItem( - `userList_${currentPage}`, - JSON.stringify(userDataResponse) - ); + return await userListCall( + accessToken, + debouncedFilters.user_id ? [debouncedFilters.user_id] : null, + currentPage, + DEFAULT_PAGE_SIZE, + debouncedFilters.email || null, + debouncedFilters.user_role || null, + debouncedFilters.team || null, + debouncedFilters.sso_user_id || null, + debouncedFilters.sort_by, + debouncedFilters.sort_order + ); + }, + enabled: Boolean(accessToken && token && userRole && userID), + placeholderData: (previousData) => previousData + }); + const userListResponse = userListQuery.data - setUserListResponse(userDataResponse); - console.log("called from useEffect 2"); - } + const userRolesQuery = useQuery>>({ + queryKey: ['userRoles'], + initialData: () => ({}), + queryFn: async () => { + if (!accessToken) throw new Error('Access token required'); + return await getPossibleUserRoles(accessToken); + }, + enabled: Boolean(accessToken && token && userRole && userID), + }); + const possibleUIRoles = userRolesQuery.data - // Fetch roles if not cached - const cachedRoles = sessionStorage.getItem('possibleUserRoles'); - if (cachedRoles) { - setPossibleUIRoles(JSON.parse(cachedRoles)); - } else { - const availableUserRoles = await getPossibleUserRoles(accessToken); - sessionStorage.setItem('possibleUserRoles', JSON.stringify(availableUserRoles)); - setPossibleUIRoles(availableUserRoles); - } - } catch (error) { - console.error("There was an error fetching the model data", error); - } - }; - - if (accessToken && token && userRole && userID) { - fetchData(); - } - - }, [accessToken, token, userRole, userID]); - - if (!userListResponse) { - return
Loading...
; + if (userListQuery.isLoading) { + return
Loading...
} if (!accessToken || !token || !userRole || !userID) { @@ -448,7 +268,7 @@ const ViewUserDashboard: React.FC = ({ placeholder="Search by email..." className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value={filters.email} - onChange={(e) => handleFilterChange('email', e.target.value)} + onChange={(e) => updateFilters({ email: e.target.value })} /> = ({
handleFilterChange('team', value)} + onValueChange={(value) => updateFilters({ team: value })} placeholder="Select Team" > {teams?.map((team) => ( @@ -588,7 +397,7 @@ const ViewUserDashboard: React.FC = ({ placeholder="Filter by SSO ID" className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value={filters.sso_user_id} - onChange={(e) => handleFilterChange('sso_user_id', e.target.value)} + onChange={(e) => updateFilters({ sso_user_id : e.target.value })} />
@@ -641,9 +450,9 @@ const ViewUserDashboard: React.FC = ({