From 7546589e821edaa8cc99de80d74187e3fb2010be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 10:15:29 +0000 Subject: [PATCH] feat(ui): migrate users views + user_info detail to shadcn Migrates view_users (list dashboard), view_users/user_info_view (user detail + edit + add-to-team modal), and user_agent_activity (DAU/WAU/ MAU analytics) off Tremor TabGroup/Card/Grid/Text/Title/Metric and off antd Modal/Form/Select/Tooltip/Button/Skeleton. All three now use shadcn Tabs + Card + Dialog + react-hook-form (in the add-team modal), keeping the existing Tremor BarChart (chart exemption) and Tag-chip multi-select shim. Tests updated to use Radix tab roles and role='alertdialog' for the DeleteResourceModal selector. Co-authored-by: yuneng-jiang --- .../components/user_agent_activity.test.tsx | 16 +- .../src/components/user_agent_activity.tsx | 501 +++++++---- .../src/components/view_users.tsx | 264 +++--- .../view_users/user_info_view.test.tsx | 17 +- .../components/view_users/user_info_view.tsx | 851 +++++++++++------- 5 files changed, 967 insertions(+), 682 deletions(-) diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx index 7b67720e9e5..f673d1dea0c 100644 --- a/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx +++ b/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx @@ -1,4 +1,5 @@ -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; import UserAgentActivity from "./user_agent_activity"; import * as networking from "./networking"; @@ -146,6 +147,7 @@ describe("UserAgentActivity", () => { }); it("should switch between DAU, WAU, and MAU tabs", async () => { + const user = userEvent.setup(); render(); // Wait for data to load @@ -158,20 +160,16 @@ describe("UserAgentActivity", () => { // Check default DAU tab content expect(screen.getByText("Daily Active Users - Last 7 Days")).toBeInTheDocument(); - // Find all WAU tab buttons (there might be multiple) - const wauTabs = screen.getAllByText("WAU"); - fireEvent.click(wauTabs[0]); + // Switch to WAU tab (use role=tab to target the actual Radix tab trigger) + await user.click(screen.getByRole("tab", { name: /^WAU$/ })); - // Check WAU tab content await waitFor(() => { expect(screen.getByText("Weekly Active Users - Last 7 Weeks")).toBeInTheDocument(); }); - // Find all MAU tab buttons - const mauTabs = screen.getAllByText("MAU"); - fireEvent.click(mauTabs[0]); + // Switch to MAU tab + await user.click(screen.getByRole("tab", { name: /^MAU$/ })); - // Check MAU tab content await waitFor(() => { expect(screen.getByText("Monthly Active Users - Last 7 Months")).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.tsx index 456bbe90b2c..3002685569d 100644 --- a/ui/litellm-dashboard/src/components/user_agent_activity.tsx +++ b/ui/litellm-dashboard/src/components/user_agent_activity.tsx @@ -1,27 +1,37 @@ -import React, { useState, useEffect } from "react"; -// eslint-disable-next-line litellm-ui/no-banned-ui-imports +import React, { useEffect, useMemo, useState } from "react"; +import { X } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; import { - Card, - Title, - Text, - Grid, - BarChart, - Metric, - Subtitle, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, -} from "@tremor/react"; -import { Select, Tooltip } from "antd"; -import { userAgentSummaryCall, tagDauCall, tagWauCall, tagMauCall, tagDistinctCall } from "./networking"; -import PerUserUsage from "./per_user_usage"; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Tooltip as ShTooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + // eslint-disable-next-line litellm-ui/no-banned-ui-imports -import { DateRangePickerValue } from "@tremor/react"; +import { BarChart, DateRangePickerValue } from "@tremor/react"; + +import { + userAgentSummaryCall, + tagDauCall, + tagWauCall, + tagMauCall, + tagDistinctCall, +} from "./networking"; +import PerUserUsage from "./per_user_usage"; import { ChartLoader } from "./shared/chart_loader"; -// New interfaces for the updated API response interface TagActiveUsersResponse { tag: string; active_users: number; @@ -52,41 +62,120 @@ interface DistinctTagResponse { tag: string; } -interface DistinctTagsResponse { - results: DistinctTagResponse[]; -} - interface UserAgentActivityProps { accessToken: string | null; userRole: string | null; dateValue: DateRangePickerValue; - onDateChange?: (value: DateRangePickerValue) => void; // Optional - not used anymore + onDateChange?: (value: DateRangePickerValue) => void; } -const UserAgentActivity: React.FC = ({ accessToken, userRole, dateValue, onDateChange }) => { - // Maximum number of categories to show in charts to prevent color palette overflow +/** + * shadcn Select + badge chip multi-select for tag filtering. + */ +function TagMultiSelect({ + value, + onChange, + options, + placeholder, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: { label: string; value: string; title?: string }[]; + placeholder: string; +}) { + const selected = useMemo(() => value ?? [], [value]); + const remaining = useMemo( + () => options.filter((o) => !selected.includes(o.value)), + [options, selected], + ); + + return ( +
+ + {selected.length > 0 && ( +
+ {selected.map((v) => { + const opt = options.find((o) => o.value === v); + return ( + + {opt?.label ?? v} + + + ); + })} +
+ )} +
+ ); +} + +const UserAgentActivity: React.FC = ({ + accessToken, + userRole, + dateValue, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onDateChange, +}) => { const MAX_CATEGORIES = 10; - // Separate state for each endpoint - const [dauData, setDauData] = useState({ results: [] }); - const [wauData, setWauData] = useState({ results: [] }); - const [mauData, setMauData] = useState({ results: [] }); - const [summaryData, setSummaryData] = useState({ results: [] }); + const [dauData, setDauData] = useState({ + results: [], + }); + const [wauData, setWauData] = useState({ + results: [], + }); + const [mauData, setMauData] = useState({ + results: [], + }); + const [summaryData, setSummaryData] = useState({ + results: [], + }); - const [userAgentFilter, setUserAgentFilter] = useState(""); + const [userAgentFilter] = useState(""); - // Tag filtering state const [availableTags, setAvailableTags] = useState([]); const [selectedTags, setSelectedTags] = useState([]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [tagsLoading, setTagsLoading] = useState(false); - // Separate loading states for each endpoint const [dauLoading, setDauLoading] = useState(false); const [wauLoading, setWauLoading] = useState(false); const [mauLoading, setMauLoading] = useState(false); const [summaryLoading, setSummaryLoading] = useState(false); - // Use today's date as the end date for all API calls const today = new Date(); const fetchAvailableTags = async () => { @@ -179,12 +268,11 @@ const UserAgentActivity: React.FC = ({ accessToken, user } }; - // Effect to fetch available tags on mount useEffect(() => { fetchAvailableTags(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [accessToken]); - // Effect for DAU/WAU/MAU data (independent of date picker) useEffect(() => { if (!accessToken) return; @@ -195,9 +283,9 @@ const UserAgentActivity: React.FC = ({ accessToken, user }, 50); return () => clearTimeout(timeoutId); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [accessToken, userAgentFilter, selectedTags]); - // Effect for summary data (depends on date picker) useEffect(() => { if (!dateValue.from || !dateValue.to) return; @@ -206,9 +294,9 @@ const UserAgentActivity: React.FC = ({ accessToken, user }, 50); return () => clearTimeout(timeoutId); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [accessToken, dateValue, selectedTags]); - // Helper function to extract user agent from tag const extractUserAgent = (tag: string): string => { if (tag.startsWith("User-Agent: ")) { return tag.replace("User-Agent: ", ""); @@ -216,7 +304,6 @@ const UserAgentActivity: React.FC = ({ accessToken, user return tag; }; - // Helper function to truncate user agent name (used with Ant Design Tooltip) const truncateUserAgent = (userAgent: string): string => { if (userAgent.length > 15) { return userAgent.substring(0, 15) + "..."; @@ -224,9 +311,7 @@ const UserAgentActivity: React.FC = ({ accessToken, user return userAgent; }; - // Get all user agents for each chart type based on their specific data const getAllTagsForData = (data: TagActiveUsersResponse[]) => { - // Aggregate total active users per tag const tagTotals = data.reduce( (acc, item) => { acc[item.tag] = (acc[item.tag] || 0) + item.active_users; @@ -235,7 +320,6 @@ const UserAgentActivity: React.FC = ({ accessToken, user {} as Record, ); - // Sort by total active users and return all tags return Object.entries(tagTotals) .sort(([, a], [, b]) => b - a) .map(([tag]) => tag); @@ -245,20 +329,17 @@ const UserAgentActivity: React.FC = ({ accessToken, user const allWauTags = getAllTagsForData(wauData.results).slice(0, MAX_CATEGORIES); const allMauTags = getAllTagsForData(mauData.results).slice(0, MAX_CATEGORIES); - // Prepare daily chart data (DAU) - always show last 7 days const generateDailyChartData = () => { const chartData: any[] = []; const endDate = new Date(); - // Generate all 7 days for (let i = 6; i >= 0; i--) { const date = new Date(endDate); date.setDate(date.getDate() - i); - const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD format + const dateStr = date.toISOString().split("T")[0]; const dayEntry: any = { date: dateStr }; - // Initialize all user agents to 0 allDauTags.forEach((tag) => { const userAgent = extractUserAgent(tag); dayEntry[userAgent] = 0; @@ -267,7 +348,6 @@ const UserAgentActivity: React.FC = ({ accessToken, user chartData.push(dayEntry); } - // Fill in actual data dauData.results.forEach((item) => { const userAgent = extractUserAgent(item.tag); const dayEntry = chartData.find((d) => d.date === item.date); @@ -281,15 +361,12 @@ const UserAgentActivity: React.FC = ({ accessToken, user const dailyChartData = generateDailyChartData(); - // Prepare weekly chart data (WAU) - always show all 7 weeks const generateWeeklyChartData = () => { const chartData: any[] = []; - // Generate all 7 weeks (Week 1 through Week 7) for (let weekNum = 1; weekNum <= 7; weekNum++) { const weekEntry: any = { week: `Week ${weekNum}` }; - // Initialize all user agents to 0 allWauTags.forEach((tag) => { const userAgent = extractUserAgent(tag); weekEntry[userAgent] = 0; @@ -298,10 +375,8 @@ const UserAgentActivity: React.FC = ({ accessToken, user chartData.push(weekEntry); } - // Fill in actual data wauData.results.forEach((item) => { const userAgent = extractUserAgent(item.tag); - // Extract week number from the date field (e.g., "Week 1 (Jul 27)" -> "Week 1") const weekMatch = item.date.match(/Week (\d+)/); if (weekMatch) { const weekLabel = `Week ${weekMatch[1]}`; @@ -317,15 +392,12 @@ const UserAgentActivity: React.FC = ({ accessToken, user const weeklyChartData = generateWeeklyChartData(); - // Prepare monthly chart data (MAU) - always show all 7 months const generateMonthlyChartData = () => { const chartData: any[] = []; - // Generate all 7 months (Month 1 through Month 7) for (let monthNum = 1; monthNum <= 7; monthNum++) { const monthEntry: any = { month: `Month ${monthNum}` }; - // Initialize all user agents to 0 allMauTags.forEach((tag) => { const userAgent = extractUserAgent(tag); monthEntry[userAgent] = 0; @@ -334,10 +406,8 @@ const UserAgentActivity: React.FC = ({ accessToken, user chartData.push(monthEntry); } - // Fill in actual data mauData.results.forEach((item) => { const userAgent = extractUserAgent(item.tag); - // Extract month number from the date field (e.g., "Month 1 (Jul)" -> "Month 1") const monthMatch = item.date.match(/Month (\d+)/); if (monthMatch) { const monthLabel = `Month ${monthMatch[1]}`; @@ -353,8 +423,10 @@ const UserAgentActivity: React.FC = ({ accessToken, user const monthlyChartData = generateMonthlyChartData(); - // Format numbers with K, M abbreviations - const formatAbbreviatedNumber = (value: number, decimalPlaces: number = 0): string => { + const formatAbbreviatedNumber = ( + value: number, + decimalPlaces: number = 0, + ): string => { if (value >= 100000000) { return (value / 1000000).toFixed(decimalPlaces) + "M"; } else if (value >= 10000000) { @@ -370,197 +442,228 @@ const UserAgentActivity: React.FC = ({ accessToken, user } }; + const tagOptions = availableTags.map((tag) => { + const userAgent = extractUserAgent(tag); + const displayName = + userAgent.length > 50 ? `${userAgent.substring(0, 50)}...` : userAgent; + return { label: displayName, value: tag, title: userAgent }; + }); + return (
{/* Summary Section Card */} - +
- Summary by User Agent - Performance metrics for different user agents +

+ Summary by User Agent +

+

+ Performance metrics for different user agents +

- {/* User Agent Filter */}
- Filter by User Agents - + options={tagOptions} + placeholder="All User Agents" + />
- {/* Date Range Picker is controlled by parent component */} - {/* Top 4 User Agents Cards */} {summaryLoading ? ( ) : ( - +
{(summaryData.results || []).slice(0, 4).map((tag, index) => { const userAgent = extractUserAgent(tag.tag); const displayName = truncateUserAgent(userAgent); return ( - - - {displayName} - + + + + +

+ {displayName} +

+
+ {userAgent} +
+
- Success Requests - {formatAbbreviatedNumber(tag.successful_requests)} + + Success Requests + +
+ {formatAbbreviatedNumber(tag.successful_requests)} +
- Total Tokens - {formatAbbreviatedNumber(tag.total_tokens)} + + Total Tokens + +
+ {formatAbbreviatedNumber(tag.total_tokens)} +
- Total Cost - ${formatAbbreviatedNumber(tag.total_spend, 4)} + + Total Cost + +
+ ${formatAbbreviatedNumber(tag.total_spend, 4)} +
); })} - {/* Fill remaining slots if less than 4 agents */} - {Array.from({ length: Math.max(0, 4 - (summaryData.results || []).length) }).map((_, index) => ( - - No Data + {Array.from({ + length: Math.max(0, 4 - (summaryData.results || []).length), + }).map((_, index) => ( + +

No Data

- Success Requests - - + + Success Requests + +
-
- Total Tokens - - + + Total Tokens + +
-
- Total Cost - - + + Total Cost + +
-
))} - +
)}
- {/* Main TabGroup for DAU/WAU/MAU vs Per User Usage */} - - - - DAU/WAU/MAU - Per User Usage (Last 30 Days) - + {/* Main Tabs for DAU/WAU/MAU vs Per User Usage */} + + + + DAU/WAU/MAU + + Per User Usage (Last 30 Days) + + - - {/* DAU/WAU/MAU Tab Panel */} - -
- DAU, WAU & MAU per Agent - Active users across different time periods -
+ +
+

+ DAU, WAU & MAU per Agent +

+

+ Active users across different time periods +

+
- - - DAU - WAU - MAU - + + + DAU + WAU + MAU + - - -
- Daily Active Users - Last 7 Days -
- {dauLoading ? ( - - ) : ( - formatAbbreviatedNumber(value)} - yAxisWidth={60} - showLegend={true} - stack={true} - /> - )} -
+ +
+

+ Daily Active Users - Last 7 Days +

+
+ {dauLoading ? ( + + ) : ( + + formatAbbreviatedNumber(value) + } + yAxisWidth={60} + showLegend={true} + stack={true} + /> + )} +
- -
- Weekly Active Users - Last 7 Weeks -
- {wauLoading ? ( - - ) : ( - formatAbbreviatedNumber(value)} - yAxisWidth={60} - showLegend={true} - stack={true} - /> - )} -
+ +
+

+ Weekly Active Users - Last 7 Weeks +

+
+ {wauLoading ? ( + + ) : ( + + formatAbbreviatedNumber(value) + } + yAxisWidth={60} + showLegend={true} + stack={true} + /> + )} +
- -
- Monthly Active Users - Last 7 Months -
- {mauLoading ? ( - - ) : ( - formatAbbreviatedNumber(value)} - yAxisWidth={60} - showLegend={true} - stack={true} - /> - )} -
-
-
-
+ +
+

+ Monthly Active Users - Last 7 Months +

+
+ {mauLoading ? ( + + ) : ( + + formatAbbreviatedNumber(value) + } + yAxisWidth={60} + showLegend={true} + stack={true} + /> + )} +
+
+ - {/* Per User Usage Tab Panel */} - - - - -
+ + + +
); diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index 1c1dfa85c80..fcd691463e9 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -1,10 +1,12 @@ -// eslint-disable-next-line litellm-ui/no-banned-ui-imports -import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; import React, { useEffect, useState } from "react"; -import { Button } from "antd"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + import BulkEditUserModal from "./BulkEditUsers"; import { CreateUserButton } from "./CreateUserButton"; +import DefaultUserSettings from "./DefaultUserSettings"; import EditUserModal from "./edit_user"; import { getPossibleUserRoles, @@ -20,17 +22,12 @@ import { updateExistingKeys } from "@/utils/dataUtils"; import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Typography } from "antd"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; import NotificationsManager from "./molecules/notifications_manager"; import { modelAvailableCall, userDeleteCall } from "./networking"; -import DefaultUserSettings from "./DefaultUserSettings"; import { columns } from "./view_users/columns"; import { UserDataTable } from "./view_users/table"; import { UserInfo } from "./view_users/types"; -import { Skeleton } from "antd"; - -const { Text, Title } = Typography; interface ViewUserDashboardProps { accessToken: string | null; @@ -40,7 +37,9 @@ interface ViewUserDashboardProps { userID: string | null; teams: any[] | null; setKeys: React.Dispatch>; - orgAdminOrgIds?: Array<{organization_id: string, organization_alias: string}> | null; + orgAdminOrgIds?: + | Array<{ organization_id: string; organization_alias: string }> + | null; } interface FilterState { @@ -71,7 +70,14 @@ const initialFilters: FilterState = { sort_order: "desc", }; -const ViewUserDashboard: React.FC = ({ accessToken, token, userRole, userID, teams, orgAdminOrgIds }) => { +const ViewUserDashboard: React.FC = ({ + accessToken, + token, + userRole, + userID, + teams, + orgAdminOrgIds, +}) => { const isProxyAdmin = userRole ? isProxyAdminRole(userRole) : false; const queryClient = useQueryClient(); const [currentPage, setCurrentPage] = useState(1); @@ -80,11 +86,15 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeletingUser, setIsDeletingUser] = useState(false); const [userToDelete, setUserToDelete] = useState(null); - const [activeTab, setActiveTab] = useState("users"); const [filters, setFilters] = useState(initialFilters); - const [debouncedFilters, setDebouncedFilters, debouncer] = useDebouncedState(filters, { wait: 300 }); - const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false); - const [invitationLinkData, setInvitationLinkData] = useState(null); + const [debouncedFilters, setDebouncedFilters, debouncer] = useDebouncedState( + filters, + { wait: 300 }, + ); + const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = + useState(false); + const [invitationLinkData, setInvitationLinkData] = + useState(null); const [baseUrl, setBaseUrl] = useState(null); const [selectedUsers, setSelectedUsers] = useState([]); const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false); @@ -106,7 +116,6 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke setBaseUrl(getProxyBaseUrl()); }, []); - // Fetch available models for bulk edit useEffect(() => { const fetchUserModels = async () => { try { @@ -114,8 +123,14 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke return; } - const model_available = await modelAvailableCall(accessToken, userID, userRole); - let available_model_names = model_available["data"].map((element: { id: string }) => element.id); + const model_available = await modelAvailableCall( + accessToken, + userID, + userRole, + ); + let available_model_names = model_available["data"].map( + (element: { id: string }) => element.id, + ); console.log("available_model_names:", available_model_names); setUserModels(available_model_names); } catch (error) { @@ -159,12 +174,16 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke setIsDeletingUser(true); await userDeleteCall(accessToken, [userToDelete.user_id]); - // Update the user list after deletion - queryClient.setQueriesData({ queryKey: ["userList"] }, (previousData) => { - if (previousData === undefined) return previousData; - const updatedUsers = previousData.users.filter((user) => user.user_id !== userToDelete.user_id); - return { ...previousData, users: updatedUsers }; - }); + queryClient.setQueriesData( + { queryKey: ["userList"] }, + (previousData) => { + if (previousData === undefined) return previousData; + const updatedUsers = previousData.users.filter( + (user) => user.user_id !== userToDelete.user_id, + ); + return { ...previousData, users: updatedUsers }; + }, + ); NotificationsManager.success("User deleted successfully"); } catch (error) { @@ -197,25 +216,29 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke try { 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; - }); + 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 }; - }); + return { ...previousData, users: updatedUsers }; + }, + ); - NotificationsManager.success(`User ${editedUser.user_id} updated successfully`); + NotificationsManager.success( + `User ${editedUser.user_id} updated successfully`, + ); } catch (error) { console.error("There was an error updating the user", error); } setSelectedUser(null); setEditModalVisible(false); - // Close the modal }; const handlePageChange = async (newPage: number) => { @@ -241,14 +264,16 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke }; const handleBulkEditSuccess = () => { - // Refresh the user list queryClient.invalidateQueries({ queryKey: ["userList"] }); setSelectedUsers([]); setSelectionMode(false); }; const userListQuery = useQuery({ - queryKey: ["userList", { debouncedFilter: debouncedFilters, currentPage, orgAdminOrgIds }], + queryKey: [ + "userList", + { debouncedFilter: debouncedFilters, currentPage, orgAdminOrgIds }, + ], queryFn: async () => { if (!accessToken) throw new Error("Access token required"); @@ -263,7 +288,9 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke debouncedFilters.sso_user_id || null, debouncedFilters.sort_by, debouncedFilters.sort_order, - orgAdminOrgIds ? orgAdminOrgIds.map((o) => o.organization_id) : null, + orgAdminOrgIds + ? orgAdminOrgIds.map((o) => o.organization_id) + : null, ); }, enabled: Boolean(accessToken && token && userRole && userID), @@ -290,27 +317,60 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke }, handleDelete, handleResetPassword, - () => { }, // placeholder function, will be overridden in UserDataTable + () => {}, // placeholder function, will be overridden in UserDataTable ); + const dataTableProps = { + data: userListQuery.data?.users || [], + columns: tableColumns, + isLoading: userListQuery.isLoading, + accessToken, + userRole, + onSortChange: handleSortChange, + currentSort: { + sortBy: filters.sort_by, + sortOrder: filters.sort_order, + }, + possibleUIRoles, + handleEdit: (user: UserInfo) => { + setSelectedUser(user); + setEditModalVisible(true); + }, + handleDelete, + handleResetPassword, + onSelectionChange: handleSelectionChange, + filters, + updateFilters, + initialFilters, + teams, + userListResponse, + currentPage, + handlePageChange, + }; + return (
{userListQuery.isLoading ? ( <> - - - + + + ) : userID && accessToken ? ( <> - + {isProxyAdmin && ( )} @@ -328,90 +392,43 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke
{isProxyAdmin ? ( - setActiveTab(index === 0 ? "users" : "settings")}> - - Users - Default User Settings - + + + Users + Default User Settings + - - - + + + + + {!userID || !userRole || !accessToken ? ( +
+ + + + +
+ ) : ( + { - setSelectedUser(user); - setEditModalVisible(true); - }} - handleDelete={handleDelete} - handleResetPassword={handleResetPassword} - enableSelection={selectionMode} - selectedUsers={selectedUsers} - onSelectionChange={handleSelectionChange} - filters={filters} - updateFilters={updateFilters} - initialFilters={initialFilters} - teams={teams} - userListResponse={userListResponse} - currentPage={currentPage} - handlePageChange={handlePageChange} + userID={userID} + userRole={userRole} /> -
- - - {!userID || !userRole || !accessToken ? ( -
- -
- ) : ( - - )} -
-
-
+ )} + + ) : ( { - setSelectedUser(user); - setEditModalVisible(true); - }} - handleDelete={handleDelete} - handleResetPassword={handleResetPassword} + {...dataTableProps} enableSelection={false} selectedUsers={[]} - onSelectionChange={handleSelectionChange} - filters={filters} - updateFilters={updateFilters} - initialFilters={initialFilters} - teams={teams} - userListResponse={userListResponse} - currentPage={currentPage} - handlePageChange={handlePageChange} /> )} @@ -435,7 +452,10 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke { label: "Global Proxy Role", value: - (userToDelete && possibleUIRoles?.[userToDelete.user_role]?.ui_label) || userToDelete?.user_role || "-", + (userToDelete && + possibleUIRoles?.[userToDelete.user_role]?.ui_label) || + userToDelete?.user_role || + "-", }, { label: "Total Spend (USD)", value: userToDelete?.spend?.toFixed(2) }, ]} diff --git a/ui/litellm-dashboard/src/components/view_users/user_info_view.test.tsx b/ui/litellm-dashboard/src/components/view_users/user_info_view.test.tsx index 481a2c6668d..5afdd61579f 100644 --- a/ui/litellm-dashboard/src/components/view_users/user_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/view_users/user_info_view.test.tsx @@ -90,8 +90,15 @@ describe("UserInfoView", () => { }); it("should render the user alias after loading", async () => { + const user = userEvent.setup(); render(); + // The user alias is shown on the Details tab; activate it first. + await waitFor(() => { + expect(screen.getByRole("tab", { name: /details/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("tab", { name: /details/i })); + const aliases = await screen.findAllByText("Test Alias"); expect(aliases.length).toBeGreaterThan(0); }); @@ -203,9 +210,13 @@ describe("UserInfoView", () => { expect(screen.getByText("Remove from Team")).toBeInTheDocument(); }); - // The DeleteResourceModal's OK button has text "Delete" - find it within the modal - const modal = screen.getByText("Remove from Team").closest(".ant-modal") as HTMLElement; - const deleteConfirmButton = within(modal).getByRole("button", { name: /delete/i }); + // The DeleteResourceModal's OK button has text "Delete" - find it within the alert dialog + const modal = screen + .getByText("Remove from Team") + .closest('[role="alertdialog"]') as HTMLElement; + const deleteConfirmButton = within(modal).getByRole("button", { + name: /delete/i, + }); await user.click(deleteConfirmButton); await waitFor(() => { diff --git a/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx index 21b55306f0b..8739248f229 100644 --- a/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx +++ b/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx @@ -1,10 +1,47 @@ import React, { useState } from "react"; -// eslint-disable-next-line litellm-ui/no-banned-ui-imports import { - Card, Text, Button, Grid, Tab, TabList, TabGroup, TabPanel, TabPanels, Title, - Table, TableHead, TableBody, TableRow, TableHeaderCell, TableCell, -} from "@tremor/react"; -import { ArrowLeft as ArrowLeftIcon, Trash2 as TrashIcon, RefreshCcw as RefreshIcon, Plus as PlusIcon } from "lucide-react"; + ArrowLeft as ArrowLeftIcon, + Trash2 as TrashIcon, + RefreshCcw as RefreshIcon, + Plus as PlusIcon, + Copy as CopyIcon, + Check as CheckIcon, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + import { userGetInfoV2, UserInfoV2Response, @@ -19,12 +56,13 @@ import { teamMemberDeleteCall, Member, } from "../networking"; -import { Button as AntdButton, Modal, Select as AntdSelect, Form, Tooltip } from "antd"; import { rolesWithWriteAccess } from "../../utils/roles"; import { UserEditView } from "../user_edit_view"; import OnboardingModal, { InvitationLink } from "../onboarding_link"; -import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; -import { CopyIcon, CheckIcon } from "lucide-react"; +import { + formatNumberWithCommas, + copyToClipboard as utilCopyToClipboard, +} from "@/utils/dataUtils"; import NotificationsManager from "../molecules/notifications_manager"; import { getBudgetDurationLabel } from "../common_components/budget_duration_dropdown"; import DeleteResourceModal from "../common_components/DeleteResourceModal"; @@ -63,18 +101,26 @@ export default function UserInfoView({ const [isLoading, setIsLoading] = useState(true); const [isEditing, setIsEditing] = useState(startInEditMode); const [userModels, setUserModels] = useState([]); - const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false); - const [invitationLinkData, setInvitationLinkData] = useState(null); + const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = + useState(false); + const [invitationLinkData, setInvitationLinkData] = + useState(null); const [baseUrl, setBaseUrl] = useState(null); - const [activeTab, setActiveTab] = useState(initialTab); + const [activeTab, setActiveTab] = useState( + initialTab === 1 ? "details" : "overview", + ); const [copiedStates, setCopiedStates] = useState>({}); const [isTeamsExpanded, setIsTeamsExpanded] = useState(false); const [isAddTeamModalOpen, setIsAddTeamModalOpen] = useState(false); const [isRemoveTeamModalOpen, setIsRemoveTeamModalOpen] = useState(false); - const [teamToRemove, setTeamToRemove] = useState(null); + const [teamToRemove, setTeamToRemove] = useState( + null, + ); const [isAddingTeam, setIsAddingTeam] = useState(false); const [isRemovingTeam, setIsRemovingTeam] = useState(false); - const [allTeams, setAllTeams] = useState>([]); + const [allTeams, setAllTeams] = useState< + Array<{ team_id: string; team_alias: string }> + >([]); const [selectedTeamId, setSelectedTeamId] = useState(""); const [selectedRole, setSelectedRole] = useState("user"); const [isLoadingTeams, setIsLoadingTeams] = useState(false); @@ -84,14 +130,15 @@ export default function UserInfoView({ }, []); React.useEffect(() => { - console.log(`userId: ${userId}, userRole: ${userRole}, accessToken: ${accessToken}`); + console.log( + `userId: ${userId}, userRole: ${userRole}, accessToken: ${accessToken}`, + ); const fetchData = async () => { try { if (!accessToken) return; const data = await userGetInfoV2(accessToken, userId); setUserData(data); - // Fetch team details for display (team aliases) if (data.teams && data.teams.length > 0) { try { const teamPromises = data.teams.map(async (teamId: string) => { @@ -108,14 +155,23 @@ export default function UserInfoView({ const teams = await Promise.all(teamPromises); setTeamDetails(teams); } catch { - // Fall back to just team IDs - setTeamDetails(data.teams.map((id: string) => ({ team_id: id, team_alias: null }))); + setTeamDetails( + data.teams.map((id: string) => ({ + team_id: id, + team_alias: null, + })), + ); } } - // Fetch available models - const modelDataResponse = await modelAvailableCall(accessToken, userId, userRole || ""); - const availableModels = modelDataResponse.data.map((model: any) => model.id); + const modelDataResponse = await modelAvailableCall( + accessToken, + userId, + userRole || "", + ); + const availableModels = modelDataResponse.data.map( + (model: any) => model.id, + ); setUserModels(availableModels); } catch (error) { console.error("Error fetching user data:", error); @@ -139,7 +195,7 @@ export default function UserInfoView({ (teams || []).map((t: any) => ({ team_id: t.team_id, team_alias: t.team_alias || t.team_id, - })) + })), ); } catch (error) { console.error("Error fetching teams:", error); @@ -155,7 +211,30 @@ export default function UserInfoView({ fetchAllTeams(); }; - const handleAddTeamSubmit = async () => { + const refreshTeams = async () => { + if (!accessToken) return; + const data = await userGetInfoV2(accessToken, userId); + setUserData(data); + if (data.teams && data.teams.length > 0) { + const teamPromises = data.teams.map(async (teamId: string) => { + try { + const teamData = await teamInfoCall(accessToken, teamId); + return { + team_id: teamId, + team_alias: teamData?.team_info?.team_alias || null, + }; + } catch { + return { team_id: teamId, team_alias: null }; + } + }); + setTeamDetails(await Promise.all(teamPromises)); + } else { + setTeamDetails([]); + } + }; + + const handleAddTeamSubmit = async (e?: React.FormEvent) => { + if (e) e.preventDefault(); if (!accessToken || !selectedTeamId) return; setIsAddingTeam(true); try { @@ -166,25 +245,12 @@ export default function UserInfoView({ await teamMemberAddCall(accessToken, selectedTeamId, member); NotificationsManager.success("User added to team successfully"); setIsAddTeamModalOpen(false); - // Re-fetch user data to refresh teams - const data = await userGetInfoV2(accessToken, userId); - setUserData(data); - if (data.teams && data.teams.length > 0) { - const teamPromises = data.teams.map(async (teamId: string) => { - try { - const teamData = await teamInfoCall(accessToken, teamId); - return { team_id: teamId, team_alias: teamData?.team_info?.team_alias || null }; - } catch { - return { team_id: teamId, team_alias: null }; - } - }); - setTeamDetails(await Promise.all(teamPromises)); - } else { - setTeamDetails([]); - } + await refreshTeams(); } catch (error: any) { console.error("Error adding user to team:", error); - NotificationsManager.fromBackend(error?.message || "Failed to add user to team"); + NotificationsManager.fromBackend( + error?.message || "Failed to add user to team", + ); } finally { setIsAddingTeam(false); } @@ -207,25 +273,12 @@ export default function UserInfoView({ NotificationsManager.success("User removed from team successfully"); setIsRemoveTeamModalOpen(false); setTeamToRemove(null); - // Re-fetch user data to refresh teams - const data = await userGetInfoV2(accessToken, userId); - setUserData(data); - if (data.teams && data.teams.length > 0) { - const teamPromises = data.teams.map(async (teamId: string) => { - try { - const teamData = await teamInfoCall(accessToken, teamId); - return { team_id: teamId, team_alias: teamData?.team_info?.team_alias || null }; - } catch { - return { team_id: teamId, team_alias: null }; - } - }); - setTeamDetails(await Promise.all(teamPromises)); - } else { - setTeamDetails([]); - } + await refreshTeams(); } catch (error: any) { console.error("Error removing user from team:", error); - NotificationsManager.fromBackend(error?.message || "Failed to remove user from team"); + NotificationsManager.fromBackend( + error?.message || "Failed to remove user from team", + ); } finally { setIsRemovingTeam(false); } @@ -237,7 +290,7 @@ export default function UserInfoView({ }; const availableTeamsForAdd = allTeams.filter( - (t) => !teamDetails.some((td) => td.team_id === t.team_id) + (t) => !teamDetails.some((td) => td.team_id === t.team_id), ); const handleResetPassword = async () => { @@ -282,9 +335,9 @@ export default function UserInfoView({ try { if (!accessToken || !userData) return; - const response = await userUpdateUserCall(accessToken, formValues, null); + // Response is unused here; local state is updated from form values + await userUpdateUserCall(accessToken, formValues, null); - // Update local state with new values setUserData({ ...userData, user_email: formValues.user_email ?? userData.user_email, @@ -306,10 +359,15 @@ export default function UserInfoView({ if (isLoading) { return (
- - Loading user data... + Loading user data...
); } @@ -317,10 +375,15 @@ export default function UserInfoView({ if (!userData) { return (
- - User not found + User not found
); } @@ -335,7 +398,6 @@ export default function UserInfoView({ } }; - // Build a legacy-compatible shape for UserEditView const userDataForEdit = { user_id: userData.user_id, user_info: { @@ -353,36 +415,56 @@ export default function UserInfoView({
- - {userData.user_email || "User"} +

+ {userData.user_email || "User"} +

- {userData.user_id} - : } + + {userData.user_id} + +
{userRole && rolesWithWriteAccess.includes(userRole) && (
-
@@ -400,7 +482,8 @@ export default function UserInfoView({ { label: "Global Proxy Role", value: - (userData.user_role && possibleUIRoles?.[userData.user_role]?.ui_label) || + (userData.user_role && + possibleUIRoles?.[userData.user_role]?.ui_label) || userData.user_role || "-", }, @@ -417,228 +500,263 @@ export default function UserInfoView({ confirmLoading={isDeletingUser} /> - - - Overview - Details - + + + Overview + Details + - - {/* Overview Panel */} - - - - Spend -
- ${formatNumberWithCommas(userData.spend || 0, 4)} - - of{" "} - {userData.max_budget !== null - ? `$${formatNumberWithCommas(userData.max_budget, 4)}` - : "Unlimited"} - -
-
+ +
+ + Spend +
+

+ ${formatNumberWithCommas(userData.spend || 0, 4)} +

+ + of{" "} + {userData.max_budget !== null + ? `$${formatNumberWithCommas(userData.max_budget, 4)}` + : "Unlimited"} + +
+
- -
- Teams - {isProxyAdmin && ( - - )} -
-
- {teamDetails.length > 0 ? ( -
- - - - Team Name - {isProxyAdmin && Actions} - - - - {teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team) => ( - - {team.team_alias || team.team_id} - {isProxyAdmin && ( - -
-
- ) : ( - No teams - )} - {!isTeamsExpanded && teamDetails.length > 20 && ( - - )} - {isTeamsExpanded && teamDetails.length > 20 && ( - - )} -
-
- - - Personal Models -
- {userData.models?.length && userData.models?.length > 0 ? ( - userData.models?.map((model, index) => {model}) - ) : ( - All proxy models - )} -
-
- - - - {/* Details Panel */} - - -
- User Settings - {!isEditing && userRole && rolesWithWriteAccess.includes(userRole) && ( - + +
+ Teams + {isProxyAdmin && ( + )}
- - {isEditing && userData ? ( - setIsEditing(false)} - onSubmit={handleUserUpdate} - teams={teamDetails} - accessToken={accessToken} - userID={userId} - userRole={userRole} - userModels={userModels} - possibleUIRoles={possibleUIRoles} - /> - ) : ( -
-
- User ID -
- {userData.user_id} - : } - onClick={() => copyToClipboard(userData.user_id, "user-id")} - className={`left-2 z-10 transition-all duration-200 ${ - copiedStates["user-id"] - ? "text-green-600 bg-green-50 border-green-200" - : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" - }`} - /> -
+
+ {teamDetails.length > 0 ? ( +
+ + + + Team Name + {isProxyAdmin && ( + + Actions + + )} + + + + {teamDetails + .slice( + 0, + isTeamsExpanded ? teamDetails.length : 20, + ) + .map((team) => ( + + + {team.team_alias || team.team_id} + + {isProxyAdmin && ( + + + + )} + + ))} + +
+ ) : ( + No teams + )} + {!isTeamsExpanded && teamDetails.length > 20 && ( + + )} + {isTeamsExpanded && teamDetails.length > 20 && ( + + )} +
+ -
- Email - {userData.user_email || "Not Set"} -
+ + Personal Models +
+ {userData.models?.length && userData.models?.length > 0 ? ( + userData.models?.map((model, index) => ( +
{model}
+ )) + ) : ( + All proxy models + )} +
+
+
+ -
- User Alias - {userData.user_alias || "Not Set"} -
+ + +
+

User Settings

+ {!isEditing && + userRole && + rolesWithWriteAccess.includes(userRole) && ( + + )} +
-
- Global Proxy Role - {userData.user_role || "Not Set"} -
- -
- Created - - {userData.created_at - ? new Date(userData.created_at).toLocaleString() - : "Unknown"} - -
- -
- Last Updated - - {userData.updated_at - ? new Date(userData.updated_at).toLocaleString() - : "Unknown"} - -
- -
- Personal Models -
- {userData.models?.length && userData.models?.length > 0 ? ( - userData.models?.map((model, index) => ( - - {model} - - )) + {isEditing && userData ? ( + setIsEditing(false)} + onSubmit={handleUserUpdate} + teams={teamDetails} + accessToken={accessToken} + userID={userId} + userRole={userRole} + userModels={userModels} + possibleUIRoles={possibleUIRoles} + /> + ) : ( +
+
+ User ID +
+ {userData.user_id} +
-
- -
- Max Budget - - {userData.max_budget !== null && userData.max_budget !== undefined - ? `$${formatNumberWithCommas(userData.max_budget, 4)}` - : "Unlimited"} - -
- -
- Budget Reset - {getBudgetDurationLabel(userData.budget_duration ?? null)} -
- -
- Metadata -
-                      {JSON.stringify(userData.metadata || {}, null, 2)}
-                    
+
- )} - - - - + +
+ Email + {userData.user_email || "Not Set"} +
+ +
+ User Alias + {userData.user_alias || "Not Set"} +
+ +
+ Global Proxy Role + {userData.user_role || "Not Set"} +
+ +
+ Created + + {userData.created_at + ? new Date(userData.created_at).toLocaleString() + : "Unknown"} + +
+ +
+ Last Updated + + {userData.updated_at + ? new Date(userData.updated_at).toLocaleString() + : "Unknown"} + +
+ +
+ Personal Models +
+ {userData.models?.length && userData.models?.length > 0 ? ( + userData.models?.map((model, index) => ( + + {model} + + )) + ) : ( + All proxy models + )} +
+
+ +
+ Max Budget + + {userData.max_budget !== null && + userData.max_budget !== undefined + ? `$${formatNumberWithCommas(userData.max_budget, 4)}` + : "Unlimited"} + +
+ +
+ Budget Reset + + {getBudgetDurationLabel(userData.budget_duration ?? null)} + +
+ +
+ Metadata +
+                    {JSON.stringify(userData.metadata || {}, null, 2)}
+                  
+
+
+ )} + + + + {/* Add to Team Modal */} - setIsAddTeamModalOpen(false)} - footer={null} - width={500} - maskClosable={!isAddingTeam} + onOpenChange={(o) => + !isAddingTeam && !o ? setIsAddTeamModalOpen(false) : undefined + } > -
- - { - const team = availableTeamsForAdd.find((t) => t.team_id === option?.value); - if (!team) return false; - return team.team_alias.toLowerCase().includes(input.toLowerCase()); - }} - loading={isLoadingTeams} - > - {availableTeamsForAdd.map((team) => ( - - {team.team_alias} - - ))} - - + + + Add User to Team + + Add the user to an existing team. + + + +
+ + +
- - - - - user - - Can view team info, but not manage it - - - - - admin - - Can create team keys, add members, and manage settings - - - - +
+ + +
-
- - {isAddingTeam ? "Adding..." : "Add to Team"} - -
- -
+ + + + + +
); }