diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 5f2921203ff..7147a5ea016 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -9,7 +9,7 @@ import AgentsPanel from "@/components/agents"; import BudgetPanel from "@/components/budgets/budget_panel"; import CacheDashboard from "@/components/cache_dashboard"; import ClaudeCodePluginsPanel from "@/components/claude_code_plugins"; -import { fetchTeams } from "@/components/common_components/fetch_teams"; +import { teamListCall as v2TeamListCall } from "@/app/(dashboard)/hooks/teams/useTeams"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { CostTrackingSettings } from "@/components/CostTrackingSettings"; import GeneralSettings from "@/components/general_settings"; @@ -339,7 +339,9 @@ function CreateKeyPageContent() { fetchUserModels(userID, userRole, accessToken, setUserModels); } if (accessToken && userID && userRole) { - fetchTeams(accessToken, userID, userRole, null, setTeams); + v2TeamListCall(accessToken, 1, 100, { + userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null, + }).then((response) => setTeams(response.teams ?? [])).catch(console.error); } if (accessToken) { fetchOrganizations(accessToken, setOrganizations); diff --git a/ui/litellm-dashboard/src/components/OldTeams.test.tsx b/ui/litellm-dashboard/src/components/OldTeams.test.tsx index 7d64ac9afea..651d1495c61 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.test.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.test.tsx @@ -18,8 +18,8 @@ vi.mock("./networking", () => ({ getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }), })); -vi.mock("./common_components/fetch_teams", () => ({ - fetchTeams: vi.fn(), +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + teamListCall: vi.fn().mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 0 }), })); vi.mock("./molecules/notifications_manager", () => ({ @@ -375,6 +375,9 @@ describe("OldTeams - handleCreate organization handling", () => { organizations={[]} />, ); + await waitFor(() => { + expect(screen.getByTestId("delete-team-button")).toBeInTheDocument(); + }); const deleteTeamButton = screen.getByTestId("delete-team-button"); act(() => { fireEvent.click(deleteTeamButton); @@ -389,7 +392,7 @@ describe("OldTeams - empty state", () => { mockUseOrganizations.mockReturnValue({ data: [] }); }); - it("should display empty state message when teams array is empty", () => { + it("should display empty state message when teams array is empty", async () => { renderWithQueryClient( { />, ); - expect(screen.getByText("No teams found")).toBeInTheDocument(); - expect(screen.getByText("Adjust your filters or create a new team")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("No teams yet")).toBeInTheDocument(); + }); + expect(screen.getByText("Create your first team to organize members and manage access to models.")).toBeInTheDocument(); }); - it("should display empty state message when teams is null", () => { + it("should display empty state message when teams is null", async () => { renderWithQueryClient( { />, ); - expect(screen.getByText("No teams found")).toBeInTheDocument(); - expect(screen.getByText("Adjust your filters or create a new team")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("No teams yet")).toBeInTheDocument(); + }); + expect(screen.getByText("Create your first team to organize members and manage access to models.")).toBeInTheDocument(); }); - it("should not display empty state when teams array has items", () => { + it("should not display empty state when teams array has items", async () => { renderWithQueryClient( { />, ); - expect(screen.queryByText("No teams found")).not.toBeInTheDocument(); - expect(screen.queryByText("Adjust your filters or create a new team")).not.toBeInTheDocument(); - expect(screen.getByText("Test Team")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("Test Team")).toBeInTheDocument(); + }); + expect(screen.queryByText("No teams yet")).not.toBeInTheDocument(); + expect(screen.queryByText("Create your first team to organize members and manage access to models.")).not.toBeInTheDocument(); }); }); @@ -621,12 +630,9 @@ describe("OldTeams - premium props", () => { />, ); - const truncatedTeamId = "team-123456789".slice(0, 7); - const teamButton = await screen.findByRole("button", { - name: new RegExp(`${truncatedTeamId}\\.\\.\\.`), - }); + const teamIdElement = await screen.findByText("team-123456789"); act(() => { - fireEvent.click(teamButton); + fireEvent.click(teamIdElement); }); await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); @@ -798,7 +804,7 @@ describe("OldTeams - access_group_ids in team create", () => { />, ); - const createButton = screen.getByRole("button", { name: /create new team/i }); + const createButton = screen.getAllByRole("button", { name: /create team/i })[0]; act(() => { fireEvent.click(createButton); }); @@ -823,7 +829,8 @@ describe("OldTeams - access_group_ids in team create", () => { const accessGroupInput = screen.getByTestId("access-group-selector"); fireEvent.change(accessGroupInput, { target: { value: "ag-1,ag-2" } }); - const createTeamSubmitButton = screen.getByRole("button", { name: /create team/i }); + const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i }); + const createTeamSubmitButton = createTeamSubmitButtons[createTeamSubmitButtons.length - 1]; fireEvent.click(createTeamSubmitButton); await waitFor(() => { @@ -865,7 +872,7 @@ describe("OldTeams - models dropdown options", () => { expect(fetchAvailableModelsForTeamOrKey).toHaveBeenCalled(); }); - const createButton = screen.getByRole("button", { name: /create new team/i }); + const createButton = screen.getAllByRole("button", { name: /create team/i })[0]; act(() => { fireEvent.click(createButton); }); @@ -884,7 +891,7 @@ describe("OldTeams - organization alias display", () => { mockUseOrganizations.mockReturnValue({ data: [] }); }); - it("should display organization alias instead of organization id", () => { + it("should display organization alias instead of organization id", async () => { const mockOrganizations = [ { organization_id: "org-123", @@ -934,11 +941,13 @@ describe("OldTeams - organization alias display", () => { />, ); - expect(screen.getByText("Test Organization")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("Test Organization")).toBeInTheDocument(); + }); expect(screen.queryByText("org-123")).not.toBeInTheDocument(); }); - it("should display organization id when alias is not found", () => { + it("should display organization id when alias is not found", async () => { mockUseOrganizations.mockReturnValue({ data: [] }); renderWithQueryClient( @@ -968,10 +977,12 @@ describe("OldTeams - organization alias display", () => { />, ); - expect(screen.getByText("org-unknown")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("org-unknown")).toBeInTheDocument(); + }); }); - it("should display N/A when organization_id is null", () => { + it("should display N/A when organization_id is null", async () => { mockUseOrganizations.mockReturnValue({ data: [] }); renderWithQueryClient( @@ -1001,6 +1012,9 @@ describe("OldTeams - organization alias display", () => { />, ); - expect(screen.getByText("N/A")).toBeInTheDocument(); + await waitFor(() => { + // When organization_id is null, the table shows "—" in the Organization column + expect(screen.getAllByText("—").length).toBeGreaterThan(0); + }); }); }); diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx index 702724c9729..8970226b9f7 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.tsx @@ -3,50 +3,54 @@ import AvailableTeamsPanel from "@/components/team/available_teams"; import TeamInfoView from "@/components/team/TeamInfo"; import TeamSSOSettings from "@/components/TeamSSOSettings"; import { isProxyAdminRole } from "@/utils/roles"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { ChevronDownIcon, ChevronRightIcon, RefreshIcon } from "@heroicons/react/outline"; -import { FilterInput } from "@/components/common_components/Filters/FilterInput"; -import { FiltersButton } from "@/components/common_components/Filters/FiltersButton"; -import { ResetFiltersButton } from "@/components/common_components/Filters/ResetFiltersButton"; -import { Search, User } from "lucide-react"; +import { + InfoCircleOutlined, + PlusOutlined, + TeamOutlined, + ReloadOutlined, +} from "@ant-design/icons"; import { Accordion, AccordionBody, AccordionHeader, - Badge, - Button, - Card, - Col, - Grid, - Icon, - Select, - SelectItem, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - Text, TextInput, } from "@tremor/react"; -import { Button as Button2, Form, Input, Modal, Select as Select2, Switch, Tooltip, Typography } from "antd"; -import React, { useEffect, useState } from "react"; -import { formatNumberWithCommas } from "../utils/dataUtils"; +import { + Button, + Card, + Flex, + Form, + Input, + Layout, + Modal, + Pagination, + Progress, + Select, + Space, + Switch, + Table, + Tabs, + Tag, + theme, + Tooltip, + Typography, + message, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import type { SorterResult } from "antd/es/table/interface"; +import { KeyIcon, LayersIcon, SearchIcon, UsersIcon } from "lucide-react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { AntDLoadingSpinner } from "@/components/ui/AntDLoadingSpinner"; +import OrganizationDropdown from "./common_components/OrganizationDropdown"; +import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; +import { teamListCall as v2TeamListCall, type TeamsResponse } from "@/app/(dashboard)/hooks/teams/useTeams"; import AccessGroupSelector from "./common_components/AccessGroupSelector"; import AgentSelector from "./agent_management/AgentSelector"; -import { fetchTeams } from "./common_components/fetch_teams"; import ModelAliasManager from "./common_components/ModelAliasManager"; import PremiumLoggingSettings from "./common_components/PremiumLoggingSettings"; import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "./common_components/RouterSettingsAccordion"; import { fetchAvailableModelsForTeamOrKey, - getModelDisplayName, unfurlWildcardModelsInList, } from "./key_team_helpers/fetch_available_models_team_key"; import type { KeyResponse, Team } from "./key_team_helpers/key_list"; @@ -85,8 +89,7 @@ interface EditTeamModalProps { import { updateExistingKeys } from "@/utils/dataUtils"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; -import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; -import { Member, teamCreateCall, v2TeamListCall } from "./networking"; +import { Member, teamCreateCall } from "./networking"; import { ModelSelect } from "./ModelSelect/ModelSelect"; interface TeamInfo { @@ -182,10 +185,13 @@ const Teams: React.FC = ({ }) => { console.log(`organizations: ${JSON.stringify(organizations)}`); const { data: organizationsData } = useOrganizations(); - const [lastRefreshed, setLastRefreshed] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [fetchError, setFetchError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [totalTeams, setTotalTeams] = useState(0); const [currentOrg, setCurrentOrg] = useState(null); const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState(null); - const [showFilters, setShowFilters] = useState(false); const [filters, setFilters] = useState({ team_id: "", team_alias: "", @@ -193,19 +199,55 @@ const Teams: React.FC = ({ sort_by: "created_at", sort_order: "desc", }); + const searchDebounceRef = useRef | null>(null); + const [isSearching, setIsSearching] = useState(false); + + const fetchTeamsV2 = async (opts: { + page?: number; + size?: number; + sortBy?: string; + sortOrder?: string; + organizationID?: string; + teamAlias?: string; + } = {}) => { + if (!accessToken) return; + const page = opts.page ?? currentPage; + const size = opts.size ?? pageSize; + const sortBy = opts.sortBy ?? filters.sort_by; + const sortOrder = opts.sortOrder ?? filters.sort_order; + const organizationID = opts.organizationID ?? filters.organization_id; + const teamAlias = opts.teamAlias ?? filters.team_alias; + + setIsLoading(true); + setFetchError(null); + try { + const response: TeamsResponse = await v2TeamListCall( + accessToken, + page, + size, + { + organizationID: organizationID || null, + team_alias: teamAlias || null, + userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null, + sortBy: sortBy || null, + sortOrder: sortOrder || null, + }, + ); + setTeams(response.teams ?? []); + setTotalTeams(response.total ?? 0); + } catch (err: any) { + setFetchError(err?.message || "Failed to fetch teams"); + } finally { + setIsLoading(false); + } + }; useEffect(() => { - console.log(`inside useeffect - ${lastRefreshed}`); - if (accessToken) { - // Call your function here - fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); - } - handleRefreshClick(); - }, [lastRefreshed]); + fetchTeamsV2(); + }, [accessToken]); const [form] = Form.useForm(); const [memberForm] = Form.useForm(); - const { Title, Paragraph } = Typography; const [value, setValue] = useState(""); const [editModalVisible, setEditModalVisible] = useState(false); @@ -225,7 +267,6 @@ const Teams: React.FC = ({ // Add this state near the other useState declarations const [guardrailsList, setGuardrailsList] = useState([]); const [policiesList, setPoliciesList] = useState([]); - const [expandedAccordions, setExpandedAccordions] = useState>({}); const [loggingSettings, setLoggingSettings] = useState([]); const [mcpAccessGroups, setMcpAccessGroups] = useState([]); const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); @@ -376,7 +417,7 @@ const Teams: React.FC = ({ try { setIsTeamDeleting(true); await teamDeleteCall(accessToken, teamToDelete.team_id); - await fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); + await fetchTeamsV2(); NotificationsManager.success("Team deleted successfully"); } catch (error) { NotificationsManager.fromBackend("Error deleting the team: " + error); @@ -572,467 +613,447 @@ const Teams: React.FC = ({ return false; }; - const handleRefreshClick = () => { - // Update the 'lastRefreshed' state to the current date and time - const currentDate = new Date(); - setLastRefreshed(currentDate.toLocaleString()); + const handleSearchChange = (value: string) => { + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + setIsSearching(true); + searchDebounceRef.current = setTimeout(async () => { + try { + setFilters((prev) => ({ ...prev, team_alias: value })); + setCurrentPage(1); + await fetchTeamsV2({ page: 1, teamAlias: value }); + } finally { + setIsSearching(false); + } + }, 300); }; - const handleFilterChange = (key: keyof FilterState, value: string) => { + const handleFilterChange = async (key: keyof FilterState, value: string) => { const newFilters = { ...filters, [key]: value }; setFilters(newFilters); - // Call teamListCall with the new filters - if (accessToken) { - v2TeamListCall( + setCurrentPage(1); + if (!accessToken) return; + try { + const response: TeamsResponse = await v2TeamListCall( accessToken, - newFilters.organization_id || null, - null, - newFilters.team_id || null, - newFilters.team_alias || null, - ) - .then((response) => { - if (response && response.teams) { - setTeams(response.teams); - } - }) - .catch((error) => { - console.error("Error fetching teams:", error); - }); - } - }; - - const handleSortChange = (sortBy: string, sortOrder: "asc" | "desc") => { - const newFilters = { - ...filters, - sort_by: sortBy, - sort_order: sortOrder, - }; - setFilters(newFilters); - // Call teamListCall with the new sort parameters - if (accessToken) { - v2TeamListCall( - accessToken, - filters.organization_id || null, - null, - filters.team_id || null, - filters.team_alias || null, - ) - .then((response) => { - if (response && response.teams) { - setTeams(response.teams); - } - }) - .catch((error) => { - console.error("Error fetching teams:", error); - }); + 1, + pageSize, + { + organizationID: newFilters.organization_id || null, + team_alias: newFilters.team_alias || null, + userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null, + sortBy: newFilters.sort_by || null, + sortOrder: newFilters.sort_order || null, + }, + ); + setTeams(response.teams ?? []); + setTotalTeams(response.total ?? 0); + } catch (error) { + console.error("Error fetching teams:", error); } }; const handleFilterReset = () => { - setFilters({ + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + setIsSearching(false); + const resetFilters: FilterState = { team_id: "", team_alias: "", organization_id: "", sort_by: "created_at", sort_order: "desc", - }); - // Reset teams list - if (accessToken) { - v2TeamListCall(accessToken, null, userID || null, null, null) - .then((response) => { - if (response && response.teams) { - setTeams(response.teams); - } - }) - .catch((error) => { - console.error("Error fetching teams:", error); - }); - } + }; + setFilters(resetFilters); + setCurrentPage(1); + fetchTeamsV2({ page: 1, organizationID: "", teamAlias: "", sortBy: "created_at", sortOrder: "desc" }); }; + const { token } = theme.useToken(); + const { Title, Text } = Typography; + const { Content } = Layout; + + const handleRetry = () => { + fetchTeamsV2(); + }; + + const handleTableSort = (_pagination: unknown, _filters: unknown, sorter: SorterResult | SorterResult[]) => { + const s = Array.isArray(sorter) ? sorter[0] : sorter; + const sortBy = s.order ? (s.columnKey as string) : "created_at"; + const sortOrder = s.order === "ascend" ? "asc" : s.order === "descend" ? "desc" : "desc"; + setFilters((prev) => ({ ...prev, sort_by: sortBy, sort_order: sortOrder })); + fetchTeamsV2({ sortBy, sortOrder }); + }; + + const teamColumns: ColumnsType = useMemo(() => [ + { + title: "Team ID", + dataIndex: "team_id", + key: "team_id", + width: 170, + ellipsis: true, + render: (id: string, record: Team) => ( + + setSelectedTeamId(record.team_id)} + > + {id} + + + ), + }, + { + title: "Team Alias", + dataIndex: "team_alias", + key: "team_alias", + ellipsis: true, + sorter: true, + render: (alias: string | undefined) => ( + + {alias || } + + ), + }, + { + title: "Organization", + key: "organization", + width: 160, + ellipsis: true, + render: (_: unknown, record: Team) => { + const orgAlias = getOrganizationAlias(record.organization_id, organizationsData || organizations); + return record.organization_id ? {orgAlias} : ; + }, + }, + { + title: "Resources", + key: "resources", + width: 240, + render: (_: unknown, record: Team) => { + const memberCount = perTeamInfo?.[record.team_id]?.team_info?.members_with_roles?.length ?? 0; + const modelCount = record.models?.length ?? 0; + const keyCount = perTeamInfo?.[record.team_id]?.keys?.length ?? 0; + return ( + + + + + + {memberCount} + + + + + + + + {modelCount} + + + + + + + + {keyCount} + + + + + ); + }, + }, + { + title: "Spend / Budget", + key: "spend", + width: 200, + sorter: true, + render: (_: unknown, record: Team) => { + const spendVal = record.spend ?? 0; + const budgetVal = record.max_budget; + const spendStr = `$${spendVal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + const budgetStr = budgetVal != null + ? `$${budgetVal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + : "Unlimited"; + const percent = budgetVal != null && budgetVal > 0 ? Math.min((spendVal / budgetVal) * 100, 100) : null; + return ( + + + {spendStr} + {" / "}{budgetStr} + + {percent != null && ( + = 90 ? "#ff4d4f" : percent >= 70 ? "#faad14" : "#1677ff"} + style={{ marginBottom: 0 }} + /> + )} + + ); + }, + }, + { + title: "Created", + dataIndex: "created_at", + key: "created_at", + width: 130, + ellipsis: true, + sorter: true, + render: (date: string | undefined) => ( + + {date ? new Date(date).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) : "—"} + + ), + }, + { + title: "Actions", + key: "actions", + width: 120, + align: "right" as const, + render: (_: unknown, record: Team) => ( + + { + navigator.clipboard.writeText(record.team_id) + .then(() => message.success("Team ID copied")) + .catch(() => message.error("Failed to copy")); + }} + /> + {userRole === "Admin" && ( + <> + { + setSelectedTeamId(record.team_id); + setEditTeam(true); + }} + /> + handleDelete(record)} + /> + + )} + + ), + }, + ], [userRole, perTeamInfo, organizationsData, organizations]); + + const displayTeams = useMemo(() => teams ?? [], [teams]); + + const renderTeamsContent = () => { + if (isLoading) { + return ( + + + + ); + } + + if (fetchError) { + return ( + + + Failed to load teams + + + {fetchError} + + + + ); + } + + return ( + + columns={teamColumns} + dataSource={displayTeams} + rowKey="team_id" + pagination={false} + onChange={handleTableSort} + locale={{ + emptyText: ( +
+ +
+ No teams yet +
+
+ + Create your first team to organize members and manage access to models. + +
+ {canCreateOrManageTeams(userRole, userID, organizations) && ( + + )} +
+ ), + }} + scroll={{ x: 1000 }} + size="middle" + /> + ); + }; + + const tabItems = [ + { + key: "your-teams", + label: "Your Teams", + children: ( + <> + + + + } + suffix={isSearching ? : null} + placeholder="Search teams by name..." + onChange={(e) => handleSearchChange(e.target.value)} + allowClear + style={{ maxWidth: 400 }} + /> + handleFilterChange("organization_id", value || "")} + loading={isLoading} + /> + + { + setCurrentPage(page); + setPageSize(size); + fetchTeamsV2({ page, size }); + }} + size="small" + showTotal={(total) => `${total} teams`} + showSizeChanger + pageSizeOptions={["10", "20", "50"]} + /> + + + {renderTeamsContent()} + + + + + ), + }, + { + key: "available-teams", + label: "Available Teams", + children: , + }, + ...(isProxyAdminRole(userRole || "") + ? [ + { + key: "default-settings", + label: "Default Team Settings", + children: , + }, + ] + : []), + ]; + return ( -
- - - {canCreateOrManageTeams(userRole, userID, organizations) && ( - - )} - {selectedTeamId ? ( - { - setTeams((teams) => { - if (teams == null) { - return teams; - } - const updated = teams.map((team) => { - if (data.team_id === team.team_id) { - return updateExistingKeys(team, data); - } - return team; - }); - // Minimal fix: refresh the full team list after an update - if (accessToken) { - fetchTeams(accessToken, userID, userRole, currentOrg, setTeams); - } - return updated; - }); - }} - onClose={() => { - setSelectedTeamId(null); - setEditTeam(false); - }} - accessToken={accessToken} - is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))} - is_proxy_admin={userRole == "Admin"} - userModels={userModels} - editTeam={editTeam} - premiumUser={premiumUser} - /> - ) : ( - - -
- Your Teams - Available Teams - {isProxyAdminRole(userRole || "") && Default Team Settings} -
-
- {lastRefreshed && Last Refreshed: {lastRefreshed}} - -
-
- - - - Click on “Team ID” to view team details and manage team members. - - - - -
-
- {/* Search and Filter Controls */} -
- {/* Team Alias Search */} - handleFilterChange("team_alias", value)} - icon={Search} - /> + + {selectedTeamId ? ( + { + setTeams((teams) => { + if (teams == null) { + return teams; + } + return teams.map((team) => { + if (data.team_id === team.team_id) { + return updateExistingKeys(team, data); + } + return team; + }); + }); + fetchTeamsV2(); + }} + onClose={() => { + setSelectedTeamId(null); + setEditTeam(false); + }} + accessToken={accessToken} + is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))} + is_proxy_admin={userRole == "Admin"} + userModels={userModels} + editTeam={editTeam} + premiumUser={premiumUser} + /> + ) : ( + <> + + + + <TeamOutlined style={{ marginRight: 8 }} /> + Teams + + + Manage teams, members, and their access to models and budgets + + + {canCreateOrManageTeams(userRole, userID, organizations) && ( + + )} + - {/* Filter Button */} - setShowFilters(!showFilters)} - active={showFilters} - hasActiveFilters={!!(filters.team_id || filters.team_alias || filters.organization_id)} - /> + + + )} - {/* Reset Filters Button */} - -
- - {/* Additional Filters */} - {showFilters && ( -
- {/* Team ID Search */} - handleFilterChange("team_id", value)} - icon={User} - /> - - {/* Organization Dropdown */} -
- -
-
- )} -
-
- - - - Team Name - Team ID - Created - Spend (USD) - Budget (USD) - Models - Organization - Info - Actions - - - - - {teams && teams.length > 0 ? ( - teams - .filter((team) => { - if (!currentOrg) return true; - return team.organization_id === currentOrg.organization_id; - }) - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .map((team: any) => ( - - - {team["team_alias"]} - - -
- - - -
-
- - {team.created_at ? new Date(team.created_at).toLocaleDateString() : "N/A"} - - - {formatNumberWithCommas(team["spend"], 4)} - - - {team["max_budget"] !== null && team["max_budget"] !== undefined - ? team["max_budget"] - : "No limit"} - - 3 ? "px-0" : ""} - > -
- {Array.isArray(team.models) ? ( -
- {team.models.length === 0 ? ( - - All Proxy Models - - ) : ( - <> -
- {team.models.length > 3 && ( -
- { - setExpandedAccordions((prev) => ({ - ...prev, - [team.team_id]: !prev[team.team_id], - })); - }} - /> -
- )} -
- {team.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)} - - - ), - )} - {team.models.length > 3 && !expandedAccordions[team.team_id] && ( - - - +{team.models.length - 3}{" "} - {team.models.length - 3 === 1 ? "more model" : "more models"} - - - )} - {expandedAccordions[team.team_id] && ( -
- {team.models.slice(3).map((model: string, index: number) => - model === "all-proxy-models" ? ( - - All Proxy Models - - ) : ( - - - {model.length > 30 - ? `${getModelDisplayName(model).slice(0, 30)}...` - : getModelDisplayName(model)} - - - ), - )} -
- )} -
-
- - )} -
- ) : null} -
-
- - - {getOrganizationAlias(team.organization_id, organizationsData || organizations)} - - - - {perTeamInfo && - team.team_id && - perTeamInfo[team.team_id] && - perTeamInfo[team.team_id].keys && - perTeamInfo[team.team_id].keys.length}{" "} - Keys - - - {perTeamInfo && - team.team_id && - perTeamInfo[team.team_id] && - perTeamInfo[team.team_id].team_info && - perTeamInfo[team.team_id].team_info.members_with_roles && - perTeamInfo[team.team_id].team_info.members_with_roles.length}{" "} - Members - - - - {userRole == "Admin" ? ( - <> - { - setSelectedTeamId(team.team_id); - setEditTeam(true); - }} - dataTestId="edit-team-button" - tooltipText="Edit team" - /> - handleDelete(team)} - dataTestId="delete-team-button" - tooltipText="Delete team" - /> - - ) : null} - -
- )) - ) : ( - - -
- No teams found - Adjust your filters or create a new team -
-
-
- )} -
-
- -
- -
-
- - - - {isProxyAdminRole(userRole || "") && ( - - - - )} -
-
- )} - {canCreateOrManageTeams(userRole, userID, organizations) && ( + {canCreateOrManageTeams(userRole, userID, organizations) && ( = ({ : "" } > - = ({ optionFilterProp="children" > {adminOrgs?.map((org) => ( - + {org.organization_alias}{" "} ({org.organization_id}) - + ))} - + {/* Show message when org admin needs to select organization */} {isOrgAdmin && !isSingleOrg && adminOrgs.length > 1 && (
- + Please select an organization to create a team for. You can only create teams within organizations where you are an admin. @@ -1190,11 +1211,11 @@ const Teams: React.FC = ({ - - daily - weekly - monthly - + @@ -1313,7 +1334,7 @@ const Teams: React.FC = ({ className="mt-8" help="Select existing guardrails or enter new ones" > - = ({ className="mt-8" help="Select existing policies or enter new ones" > - = ({
- + Create custom aliases for models that can be used by team members in API calls. This allows you to create shortcuts for specific models. @@ -1548,14 +1569,12 @@ const Teams: React.FC = ({
- Create Team +
)} - - -
+ ); }; diff --git a/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx b/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx index 488913a734a..2f146aab723 100644 --- a/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx +++ b/ui/litellm-dashboard/src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx @@ -6,6 +6,7 @@ import { ChevronUpIcon, ChevronDownIcon, ExternalLinkIcon, + ClipboardCopyIcon, } from "@heroicons/react/outline"; import { Tooltip } from "antd"; import BaseActionButton from "../BaseActionButton"; @@ -32,6 +33,7 @@ export const TableIconActionButtonMap: Record void; disabled?: boolean; loading?: boolean; + style?: React.CSSProperties; } const OrganizationDropdown: React.FC = ({ @@ -16,16 +19,18 @@ const OrganizationDropdown: React.FC = ({ onChange, disabled, loading, + style, }) => { return ( diff --git a/ui/litellm-dashboard/src/components/ui/AntDLoadingSpinner.tsx b/ui/litellm-dashboard/src/components/ui/AntDLoadingSpinner.tsx new file mode 100644 index 00000000000..9e90f77584a --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/AntDLoadingSpinner.tsx @@ -0,0 +1,12 @@ +import { Spin } from "antd"; +import { LoadingOutlined } from "@ant-design/icons"; + +interface AntDLoadingSpinnerProps { + size?: "small" | "default" | "large"; + fontSize?: number; +} + +export function AntDLoadingSpinner({ size, fontSize }: AntDLoadingSpinnerProps) { + const indicator = ; + return ; +}