From aea8e320489420b3359db37cc62524a27f6c6d1f Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Mar 2026 22:05:26 -0700 Subject: [PATCH] [Fix] UI: Team table refresh, infinite team dropdown, leftnav for dashboard routes - OldTeams: refresh table via fetchTeamsV2 after team create instead of appending - TeamDropdown: rewrite with useInfiniteTeams for paginated fetch, scroll-to-load, and debounced search - Update all TeamDropdown consumers to use the new self-fetching API - Dashboard layout: switch from Sidebar2 to SidebarProvider (leftnav) - Leftnav: add MIGRATED_PAGES routing for path-based navigation (api-reference) - Navbar: remove chat button Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(dashboard)/hooks/teams/useTeams.ts | 39 +++++- .../src/app/(dashboard)/layout.tsx | 34 ++++- .../src/components/CreateUserButton.tsx | 6 +- .../src/components/OldTeams.tsx | 12 +- .../src/components/ToolDetail.tsx | 1 - .../src/components/add_model/AddModelForm.tsx | 3 +- .../src/components/agents/add_agent_form.tsx | 5 +- .../common_components/team_dropdown.tsx | 130 ++++++++++++++---- .../src/components/leftnav.tsx | 40 +++++- .../src/components/navbar.tsx | 43 +----- .../organisms/create_key_button.test.tsx | 23 +++- .../organisms/create_key_button.tsx | 16 +-- 12 files changed, 238 insertions(+), 114 deletions(-) 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 a86b5cd51f6..f74a71e901e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts @@ -1,4 +1,4 @@ -import { keepPreviousData, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query"; +import { keepPreviousData, useInfiniteQuery, 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"; @@ -124,6 +124,43 @@ export const useTeam = (teamId?: string) => { }); }; +const infiniteTeamKeys = createQueryKeys("infiniteTeams"); + +export const useInfiniteTeams = ( + pageSize: number = 50, + search?: string, + organizationId?: string | null, +) => { + const { accessToken, userId, userRole } = useAuthorized(); + const isAdmin = userRole === "Admin" || userRole === "Admin Viewer"; + + return useInfiniteQuery({ + queryKey: infiniteTeamKeys.list({ + filters: { + pageSize, + ...(search && { search }), + ...(organizationId && { organizationId }), + ...(userId && { userId }), + }, + }), + queryFn: async ({ pageParam }) => { + return await teamListCall(accessToken!, pageParam as number, pageSize, { + team_alias: search || undefined, + organizationID: organizationId, + userID: !isAdmin ? userId : undefined, + }); + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => { + if (lastPage.page < lastPage.total_pages) { + return lastPage.page + 1; + } + return undefined; + }, + enabled: Boolean(accessToken), + }); +}; + const deletedTeamListCall = async ( accessToken: string, page: number, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 1cf7adf1ea9..94dd6eb3cf1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -3,7 +3,7 @@ import React, { Suspense, useEffect, useState } from "react"; import Navbar from "@/components/navbar"; import { ThemeProvider } from "@/contexts/ThemeContext"; -import Sidebar2 from "@/app/(dashboard)/components/Sidebar2"; +import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useRouter, useSearchParams } from "next/navigation"; import { DebugWarningBanner } from "@/components/DebugWarningBanner"; @@ -23,6 +23,17 @@ function withBase(path: string): string { } /** -------------------------------- */ +/** + * Pages that have been migrated to path-based routing under (dashboard)/. + * When the leftnav triggers one of these, navigate to the path route instead + * of the legacy query-param root page. + * + * Key = legacy page id used in leftnav, Value = route segment under (dashboard)/ + */ +const MIGRATED_PAGES: Record = { + "api-reference": "api-reference", +}; + function LayoutContent({ children }: { children: React.ReactNode }) { const router = useRouter(); const searchParams = useSearchParams(); @@ -32,10 +43,17 @@ function LayoutContent({ children }: { children: React.ReactNode }) { return searchParams.get("page") || "api-keys"; }); - const updatePage = (newPage: string) => { - const newSearchParams = new URLSearchParams(searchParams); - newSearchParams.set("page", newPage); - router.push(withBase(`/?${newSearchParams.toString()}`)); // always under BASE + const handleSetPage = (newPage: string) => { + // If the page has been migrated to path routing, navigate there + const migratedRoute = MIGRATED_PAGES[newPage]; + if (migratedRoute) { + router.push(withBase(migratedRoute)); + setPage(newPage); + return; + } + + // Otherwise, navigate back to the legacy root page with query params + router.push(withBase(`?page=${newPage}`)); setPage(newPage); }; @@ -65,7 +83,11 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
- +
{children}
diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.tsx index fbfcb402766..fc29887a5da 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.tsx @@ -210,9 +210,7 @@ export const CreateUserButton: React.FC = ({ - + @@ -294,7 +292,7 @@ export const CreateUserButton: React.FC = ({ name="team_id" help="If selected, user will be added as a 'user' role to the team." > - + = ({ } } - const response: any = await teamCreateCall(accessToken, formValues); - if (teams !== null) { - setTeams([...teams, response]); - } else { - setTeams([response]); - } - console.log(`response for team create call: ${response}`); + await teamCreateCall(accessToken, formValues); NotificationsManager.success("Team created"); + await fetchTeamsV2({ + page: currentPage, + size: pageSize, + }); form.resetFields(); setLoggingSettings([]); setModelAliases({}); diff --git a/ui/litellm-dashboard/src/components/ToolDetail.tsx b/ui/litellm-dashboard/src/components/ToolDetail.tsx index ed0f866acb8..b2a7d9bfbe4 100644 --- a/ui/litellm-dashboard/src/components/ToolDetail.tsx +++ b/ui/litellm-dashboard/src/components/ToolDetail.tsx @@ -387,7 +387,6 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) { {blockScope === "team" ? ( setBlockTeamId(id || null)} /> diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index 2b3f23a35ae..65e239d58ea 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -131,7 +131,6 @@ const AddModelForm: React.FC = ({ tooltip="Select the team for which you want to add this model" > { setTeamAdminSelectedTeam(value); }} @@ -325,7 +324,7 @@ const AddModelForm: React.FC = ({ }, ]} > - + )} {isAdmin && ( diff --git a/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx b/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx index b6d96f0445b..046b28640c3 100644 --- a/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/components/agents/add_agent_form.tsx @@ -723,10 +723,7 @@ const AddAgentForm: React.FC = ({ name="team_id" tooltip="Optionally assign this agent to a team. The agent and its key will belong to the selected team." > - + diff --git a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx index 9e79ea2950a..75243c4d91f 100644 --- a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx @@ -1,46 +1,120 @@ -import React from "react"; +import React, { useMemo, useState, type UIEvent } from "react"; import { Select } from "antd"; +import { LoadingOutlined } from "@ant-design/icons"; +import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; +import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { Team } from "../key_team_helpers/key_list"; interface TeamDropdownProps { - teams?: Team[] | null; value?: string; onChange?: (value: string) => void; + /** Callback with the full Team object (or null on clear). */ + onTeamSelect?: (team: Team | null) => void; disabled?: boolean; - loading?: boolean; + /** Filter teams by organization. */ + organizationId?: string | null; + pageSize?: number; } -const TeamDropdown: React.FC = ({ teams, value, onChange, disabled, loading }) => { +const SCROLL_THRESHOLD = 0.8; +const DEBOUNCE_MS = 300; + +const TeamDropdown: React.FC = ({ + value, + onChange, + onTeamSelect, + disabled, + organizationId, + pageSize = 50, +}) => { + const [searchInput, setSearchInput] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { + wait: DEBOUNCE_MS, + }); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useInfiniteTeams( + pageSize, + debouncedSearch || undefined, + organizationId, + ); + + const teams = useMemo(() => { + if (!data?.pages) return []; + const seen = new Set(); + const result: Team[] = []; + for (const page of data.pages) { + for (const team of page.teams) { + if (seen.has(team.team_id)) continue; + seen.add(team.team_id); + result.push(team); + } + } + return result; + }, [data]); + + const options = useMemo( + () => + teams.map((team) => ({ + label: `${team.team_alias} (${team.team_id})`, + value: team.team_id, + })), + [teams], + ); + + const handlePopupScroll = (e: UIEvent) => { + const target = e.currentTarget; + const scrollRatio = + (target.scrollTop + target.clientHeight) / target.scrollHeight; + if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }; + + const handleSearch = (val: string) => { + setSearchInput(val); + setDebouncedSearch(val); + }; + + const handleChange = (teamId: string | undefined) => { + onChange?.(teamId ?? ""); + if (onTeamSelect) { + const team = teamId ? teams.find((t) => t.team_id === teamId) ?? null : null; + onTeamSelect(team); + } + }; + return ( + filterOption={false} + onSearch={handleSearch} + searchValue={searchInput} + onPopupScroll={handlePopupScroll} + loading={isLoading} + notFoundContent={isLoading ? : "No teams found"} + options={options} + popupRender={(menu) => ( + <> + {menu} + {isFetchingNextPage && ( +
+ +
+ )} + + )} + /> ); }; diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 09ab3809427..48df5cdde80 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -18,7 +18,6 @@ import { FolderOutlined, KeyOutlined, LineChartOutlined, - MessageOutlined, PlayCircleOutlined, RobotOutlined, SafetyOutlined, @@ -36,8 +35,34 @@ import { all_admin_roles, internalUserRoles, isAdminRole, isUserTeamAdminForAnyT import NewBadge from "./common_components/NewBadge"; import type { Organization } from "./networking"; import UsageIndicator from "./UsageIndicator"; +import { serverRootPath } from "./networking"; const { Sider } = Layout; +/** + * Pages migrated to path-based routing under (dashboard)/. + * Key = legacy page id, Value = route segment. + * Keep in sync with MIGRATED_PAGES in (dashboard)/layout.tsx and + * LEGACY_REDIRECTS in app/page.tsx. + */ +const MIGRATED_PAGES: Record = { + "api-reference": "api-reference", +}; + +/** Build an absolute href for a migrated page, respecting base URL + serverRootPath. */ +function migratedHref(routeSegment: string): string { + const raw = process.env.NEXT_PUBLIC_BASE_URL ?? ""; + const trimmed = raw.replace(/^\/+|\/+$/g, ""); + let base = trimmed ? `/${trimmed}/` : "/"; + + if (serverRootPath && serverRootPath !== "/") { + const cleanRoot = serverRootPath.replace(/\/+$/, ""); + const cleanBase = base.replace(/^\/+/, ""); + base = `${cleanRoot}/${cleanBase}`; + } + + return `${base}${routeSegment}`; +} + // Define the props type interface SidebarProps { setPage: (page: string) => void; @@ -379,6 +404,11 @@ const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapse // Navigate to page helper const navigateToPage = (page: string) => { + // For migrated pages, just call setPage — the parent layout handles routing + if (MIGRATED_PAGES[page]) { + setPage(page); + return; + } const newSearchParams = new URLSearchParams(window.location.search); newSearchParams.set("page", page); window.history.pushState(null, "", `?${newSearchParams.toString()}`); @@ -405,9 +435,11 @@ const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapse ); } - const params = new URLSearchParams(window.location.search); - params.set("page", page); - const href = `?${params.toString()}`; + // For migrated pages, generate a path-based href for right-click "Open in new tab" + const migratedRoute = MIGRATED_PAGES[page]; + const href = migratedRoute + ? migratedHref(migratedRoute) + : (() => { const params = new URLSearchParams(window.location.search); params.set("page", page); return `?${params.toString()}`; })(); return ( = ({ }) => { const baseUrl = getProxyBaseUrl(); const [logoutUrl, setLogoutUrl] = useState(""); - const { data: uiConfig } = useUIConfig(); - const uiRoot = uiConfig?.server_root_path && uiConfig.server_root_path !== "/" - ? uiConfig.server_root_path.replace(/\/+$/, "") - : ""; - const chatHref = `${uiRoot}/ui/chat`; const { logoUrl } = useTheme(); const { data: healthData } = useHealthReadiness(); const version = healthData?.litellm_version; @@ -146,41 +140,6 @@ const Navbar: React.FC = ({ {/* Right side nav items */}
- {/* Chat CTA — always visible, opens in new tab */} - { (e.currentTarget as HTMLAnchorElement).style.background = "#0958d9"; }} - onMouseLeave={(e) => { (e.currentTarget as HTMLAnchorElement).style.background = "#1677ff"; }} - > - - Chat - - NEW - - {/* Dark mode is currently a work in progress. To test, you can change 'false' to 'true' below. diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx index 8cf97cde6ed..3ad59cb3693 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx @@ -214,19 +214,30 @@ vi.mock("../common_components/PassThroughRoutesSelector", () => ({ default: () = vi.mock("../common_components/PremiumLoggingSettings", () => ({ default: () => null })); vi.mock("../common_components/RateLimitTypeFormItem", () => ({ default: () => null })); vi.mock("../common_components/RouterSettingsAccordion", () => ({ default: () => null })); +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useInfiniteTeams: () => ({ + data: { + pages: [{ teams: [ + { team_id: "team-1", team_alias: "Team One" }, + { team_id: "team-2", team_alias: "Team Two" }, + ], total: 2, page: 1, page_size: 50, total_pages: 1 }], + }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + }), +})); vi.mock("../common_components/team_dropdown", () => ({ - default: ({ teams, onChange, disabled }: { teams?: any[]; onChange?: (v: string) => void; disabled?: boolean }) => ( + default: ({ onChange, disabled }: { onChange?: (v: string) => void; disabled?: boolean }) => ( ), })); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 2c9f08f06ef..76888262e5a 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -810,19 +810,17 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp help={keyOwner === "service_account" ? "required" : ""} > t.organization_id === selectedOrganizationId) : teams} disabled={selectedProjectId !== null} - loading={!teams} - onChange={(teamId) => { - const selectedTeam = teams?.find((t) => t.team_id === teamId) || null; - setSelectedCreateKeyTeam(selectedTeam); + organizationId={selectedOrganizationId} + onTeamSelect={(team) => { + setSelectedCreateKeyTeam(team); setSelectedProjectId(null); form.setFieldValue("project_id", undefined); // Auto-populate org from team for non-admin users - if (selectedTeam?.organization_id) { - setSelectedOrganizationId(selectedTeam.organization_id); - form.setFieldValue("organization_id", selectedTeam.organization_id); - } else if (!teamId) { + if (team?.organization_id) { + setSelectedOrganizationId(team.organization_id); + form.setFieldValue("organization_id", team.organization_id); + } else if (!team) { setSelectedOrganizationId(null); form.setFieldValue("organization_id", undefined); }