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 <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-24 10:15:29 +00:00
parent b861fd3fd4
commit 7546589e82
No known key found for this signature in database
5 changed files with 967 additions and 682 deletions

View file

@ -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(<UserAgentActivity {...defaultProps} />);
// 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();
});

View file

@ -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<UserAgentActivityProps> = ({ 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 (
<div className="space-y-2">
<Select
value=""
onValueChange={(v) => {
if (v) onChange([...selected, v]);
}}
>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{remaining.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No more options
</div>
) : (
remaining.map((opt) => (
<SelectItem key={opt.value} value={opt.value} title={opt.title}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.map((v) => {
const opt = options.find((o) => o.value === v);
return (
<Badge
key={v}
variant="secondary"
className="flex items-center gap-1 max-w-[240px]"
>
<span className="truncate">{opt?.label ?? v}</span>
<button
type="button"
onClick={() => onChange(selected.filter((s) => s !== v))}
className="inline-flex items-center justify-center rounded-full hover:bg-muted-foreground/20"
aria-label={`Remove ${opt?.label ?? v}`}
>
<X size={12} />
</button>
</Badge>
);
})}
</div>
)}
</div>
);
}
const UserAgentActivity: React.FC<UserAgentActivityProps> = ({
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<ActiveUsersAnalyticsResponse>({ results: [] });
const [wauData, setWauData] = useState<ActiveUsersAnalyticsResponse>({ results: [] });
const [mauData, setMauData] = useState<ActiveUsersAnalyticsResponse>({ results: [] });
const [summaryData, setSummaryData] = useState<TagSummaryResponse>({ results: [] });
const [dauData, setDauData] = useState<ActiveUsersAnalyticsResponse>({
results: [],
});
const [wauData, setWauData] = useState<ActiveUsersAnalyticsResponse>({
results: [],
});
const [mauData, setMauData] = useState<ActiveUsersAnalyticsResponse>({
results: [],
});
const [summaryData, setSummaryData] = useState<TagSummaryResponse>({
results: [],
});
const [userAgentFilter, setUserAgentFilter] = useState<string>("");
const [userAgentFilter] = useState<string>("");
// Tag filtering state
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
// 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<UserAgentActivityProps> = ({ 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<UserAgentActivityProps> = ({ 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<UserAgentActivityProps> = ({ 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<UserAgentActivityProps> = ({ 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<UserAgentActivityProps> = ({ 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<UserAgentActivityProps> = ({ accessToken, user
{} as Record<string, number>,
);
// 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<UserAgentActivityProps> = ({ 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<UserAgentActivityProps> = ({ 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<UserAgentActivityProps> = ({ 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<UserAgentActivityProps> = ({ 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<UserAgentActivityProps> = ({ 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<UserAgentActivityProps> = ({ 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<UserAgentActivityProps> = ({ 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<UserAgentActivityProps> = ({ 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 (
<div className="space-y-6 mt-6">
{/* Summary Section Card */}
<Card>
<Card className="p-6">
<div className="space-y-6">
<div className="flex justify-between items-start">
<div>
<Title>Summary by User Agent</Title>
<Subtitle>Performance metrics for different user agents</Subtitle>
<h2 className="text-2xl font-semibold m-0">
Summary by User Agent
</h2>
<p className="text-sm text-muted-foreground m-0">
Performance metrics for different user agents
</p>
</div>
{/* User Agent Filter */}
<div className="w-96">
<Text className="text-sm font-medium block mb-2">Filter by User Agents</Text>
<Select
mode="multiple"
placeholder="All User Agents"
<Label className="text-sm font-medium block mb-2">
Filter by User Agents
</Label>
<TagMultiSelect
value={selectedTags}
onChange={setSelectedTags}
style={{ width: "100%" }}
showSearch={true}
allowClear={true}
loading={tagsLoading}
optionFilterProp="label"
className="rounded-md"
maxTagCount="responsive"
>
{availableTags.map((tag) => {
const userAgent = extractUserAgent(tag);
const displayName = userAgent.length > 50 ? `${userAgent.substring(0, 50)}...` : userAgent;
return (
<Select.Option key={tag} value={tag} label={displayName} title={userAgent}>
{displayName}
</Select.Option>
);
})}
</Select>
options={tagOptions}
placeholder="All User Agents"
/>
</div>
</div>
{/* Date Range Picker is controlled by parent component */}
{/* Top 4 User Agents Cards */}
{summaryLoading ? (
<ChartLoader isDateChanging={false} />
) : (
<Grid numItems={4} className="gap-4">
<div className="grid grid-cols-4 gap-4">
{(summaryData.results || []).slice(0, 4).map((tag, index) => {
const userAgent = extractUserAgent(tag.tag);
const displayName = truncateUserAgent(userAgent);
return (
<Card key={index}>
<Tooltip title={userAgent} placement="top">
<Title className="truncate">{displayName}</Title>
</Tooltip>
<Card key={index} className="p-4">
<TooltipProvider>
<ShTooltip>
<TooltipTrigger asChild>
<h3 className="text-lg font-semibold truncate">
{displayName}
</h3>
</TooltipTrigger>
<TooltipContent>{userAgent}</TooltipContent>
</ShTooltip>
</TooltipProvider>
<div className="mt-4 space-y-3">
<div>
<Text className="text-sm text-gray-600">Success Requests</Text>
<Metric className="text-lg">{formatAbbreviatedNumber(tag.successful_requests)}</Metric>
<span className="text-sm text-muted-foreground">
Success Requests
</span>
<div className="text-lg font-semibold">
{formatAbbreviatedNumber(tag.successful_requests)}
</div>
</div>
<div>
<Text className="text-sm text-gray-600">Total Tokens</Text>
<Metric className="text-lg">{formatAbbreviatedNumber(tag.total_tokens)}</Metric>
<span className="text-sm text-muted-foreground">
Total Tokens
</span>
<div className="text-lg font-semibold">
{formatAbbreviatedNumber(tag.total_tokens)}
</div>
</div>
<div>
<Text className="text-sm text-gray-600">Total Cost</Text>
<Metric className="text-lg">${formatAbbreviatedNumber(tag.total_spend, 4)}</Metric>
<span className="text-sm text-muted-foreground">
Total Cost
</span>
<div className="text-lg font-semibold">
${formatAbbreviatedNumber(tag.total_spend, 4)}
</div>
</div>
</div>
</Card>
);
})}
{/* Fill remaining slots if less than 4 agents */}
{Array.from({ length: Math.max(0, 4 - (summaryData.results || []).length) }).map((_, index) => (
<Card key={`empty-${index}`}>
<Title>No Data</Title>
{Array.from({
length: Math.max(0, 4 - (summaryData.results || []).length),
}).map((_, index) => (
<Card key={`empty-${index}`} className="p-4">
<h3 className="text-lg font-semibold">No Data</h3>
<div className="mt-4 space-y-3">
<div>
<Text className="text-sm text-gray-600">Success Requests</Text>
<Metric className="text-lg">-</Metric>
<span className="text-sm text-muted-foreground">
Success Requests
</span>
<div className="text-lg font-semibold">-</div>
</div>
<div>
<Text className="text-sm text-gray-600">Total Tokens</Text>
<Metric className="text-lg">-</Metric>
<span className="text-sm text-muted-foreground">
Total Tokens
</span>
<div className="text-lg font-semibold">-</div>
</div>
<div>
<Text className="text-sm text-gray-600">Total Cost</Text>
<Metric className="text-lg">-</Metric>
<span className="text-sm text-muted-foreground">
Total Cost
</span>
<div className="text-lg font-semibold">-</div>
</div>
</div>
</Card>
))}
</Grid>
</div>
)}
</div>
</Card>
{/* Main TabGroup for DAU/WAU/MAU vs Per User Usage */}
<Card>
<TabGroup>
<TabList className="mb-6">
<Tab>DAU/WAU/MAU</Tab>
<Tab>Per User Usage (Last 30 Days)</Tab>
</TabList>
{/* Main Tabs for DAU/WAU/MAU vs Per User Usage */}
<Card className="p-6">
<Tabs defaultValue="dau-wau-mau">
<TabsList className="mb-6">
<TabsTrigger value="dau-wau-mau">DAU/WAU/MAU</TabsTrigger>
<TabsTrigger value="per-user">
Per User Usage (Last 30 Days)
</TabsTrigger>
</TabsList>
<TabPanels>
{/* DAU/WAU/MAU Tab Panel */}
<TabPanel>
<div className="mb-6">
<Title>DAU, WAU & MAU per Agent</Title>
<Subtitle>Active users across different time periods</Subtitle>
</div>
<TabsContent value="dau-wau-mau">
<div className="mb-6">
<h2 className="text-2xl font-semibold m-0">
DAU, WAU & MAU per Agent
</h2>
<p className="text-sm text-muted-foreground m-0">
Active users across different time periods
</p>
</div>
<TabGroup>
<TabList className="mb-6">
<Tab>DAU</Tab>
<Tab>WAU</Tab>
<Tab>MAU</Tab>
</TabList>
<Tabs defaultValue="dau">
<TabsList className="mb-6">
<TabsTrigger value="dau">DAU</TabsTrigger>
<TabsTrigger value="wau">WAU</TabsTrigger>
<TabsTrigger value="mau">MAU</TabsTrigger>
</TabsList>
<TabPanels>
<TabPanel>
<div className="mb-4">
<Title className="text-lg">Daily Active Users - Last 7 Days</Title>
</div>
{dauLoading ? (
<ChartLoader isDateChanging={false} />
) : (
<BarChart
data={dailyChartData}
index="date"
categories={allDauTags.map(extractUserAgent)}
valueFormatter={(value: number) => formatAbbreviatedNumber(value)}
yAxisWidth={60}
showLegend={true}
stack={true}
/>
)}
</TabPanel>
<TabsContent value="dau">
<div className="mb-4">
<h3 className="text-lg font-semibold">
Daily Active Users - Last 7 Days
</h3>
</div>
{dauLoading ? (
<ChartLoader isDateChanging={false} />
) : (
<BarChart
data={dailyChartData}
index="date"
categories={allDauTags.map(extractUserAgent)}
valueFormatter={(value: number) =>
formatAbbreviatedNumber(value)
}
yAxisWidth={60}
showLegend={true}
stack={true}
/>
)}
</TabsContent>
<TabPanel>
<div className="mb-4">
<Title className="text-lg">Weekly Active Users - Last 7 Weeks</Title>
</div>
{wauLoading ? (
<ChartLoader isDateChanging={false} />
) : (
<BarChart
data={weeklyChartData}
index="week"
categories={allWauTags.map(extractUserAgent)}
valueFormatter={(value: number) => formatAbbreviatedNumber(value)}
yAxisWidth={60}
showLegend={true}
stack={true}
/>
)}
</TabPanel>
<TabsContent value="wau">
<div className="mb-4">
<h3 className="text-lg font-semibold">
Weekly Active Users - Last 7 Weeks
</h3>
</div>
{wauLoading ? (
<ChartLoader isDateChanging={false} />
) : (
<BarChart
data={weeklyChartData}
index="week"
categories={allWauTags.map(extractUserAgent)}
valueFormatter={(value: number) =>
formatAbbreviatedNumber(value)
}
yAxisWidth={60}
showLegend={true}
stack={true}
/>
)}
</TabsContent>
<TabPanel>
<div className="mb-4">
<Title className="text-lg">Monthly Active Users - Last 7 Months</Title>
</div>
{mauLoading ? (
<ChartLoader isDateChanging={false} />
) : (
<BarChart
data={monthlyChartData}
index="month"
categories={allMauTags.map(extractUserAgent)}
valueFormatter={(value: number) => formatAbbreviatedNumber(value)}
yAxisWidth={60}
showLegend={true}
stack={true}
/>
)}
</TabPanel>
</TabPanels>
</TabGroup>
</TabPanel>
<TabsContent value="mau">
<div className="mb-4">
<h3 className="text-lg font-semibold">
Monthly Active Users - Last 7 Months
</h3>
</div>
{mauLoading ? (
<ChartLoader isDateChanging={false} />
) : (
<BarChart
data={monthlyChartData}
index="month"
categories={allMauTags.map(extractUserAgent)}
valueFormatter={(value: number) =>
formatAbbreviatedNumber(value)
}
yAxisWidth={60}
showLegend={true}
stack={true}
/>
)}
</TabsContent>
</Tabs>
</TabsContent>
{/* Per User Usage Tab Panel */}
<TabPanel>
<PerUserUsage
accessToken={accessToken}
selectedTags={selectedTags}
formatAbbreviatedNumber={formatAbbreviatedNumber}
/>
</TabPanel>
</TabPanels>
</TabGroup>
<TabsContent value="per-user">
<PerUserUsage
accessToken={accessToken}
selectedTags={selectedTags}
formatAbbreviatedNumber={formatAbbreviatedNumber}
/>
</TabsContent>
</Tabs>
</Card>
</div>
);

View file

@ -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<React.SetStateAction<object[] | null>>;
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<ViewUserDashboardProps> = ({ accessToken, token, userRole, userID, teams, orgAdminOrgIds }) => {
const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
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<ViewUserDashboardProps> = ({ accessToken, toke
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDeletingUser, setIsDeletingUser] = useState(false);
const [userToDelete, setUserToDelete] = useState<UserInfo | null>(null);
const [activeTab, setActiveTab] = useState("users");
const [filters, setFilters] = useState<FilterState>(initialFilters);
const [debouncedFilters, setDebouncedFilters, debouncer] = useDebouncedState(filters, { wait: 300 });
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
const [debouncedFilters, setDebouncedFilters, debouncer] = useDebouncedState(
filters,
{ wait: 300 },
);
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] =
useState(false);
const [invitationLinkData, setInvitationLinkData] =
useState<InvitationLink | null>(null);
const [baseUrl, setBaseUrl] = useState<string | null>(null);
const [selectedUsers, setSelectedUsers] = useState<UserInfo[]>([]);
const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false);
@ -106,7 +116,6 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
setBaseUrl(getProxyBaseUrl());
}, []);
// Fetch available models for bulk edit
useEffect(() => {
const fetchUserModels = async () => {
try {
@ -114,8 +123,14 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ 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<ViewUserDashboardProps> = ({ accessToken, toke
setIsDeletingUser(true);
await userDeleteCall(accessToken, [userToDelete.user_id]);
// Update the user list after deletion
queryClient.setQueriesData<UserListResponse>({ 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<UserListResponse>(
{ 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<ViewUserDashboardProps> = ({ accessToken, toke
try {
const response = await userUpdateUserCall(accessToken, editedUser, null);
queryClient.setQueriesData<UserListResponse>({ 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<UserListResponse>(
{ 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<ViewUserDashboardProps> = ({ 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<ViewUserDashboardProps> = ({ 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<ViewUserDashboardProps> = ({ 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 (
<div className="w-full p-8 overflow-hidden">
<div className="flex items-center justify-between mb-4">
<div className="flex space-x-3">
{userListQuery.isLoading ? (
<>
<Skeleton.Button active size="default" shape="default" style={{ width: 110, height: 36 }} />
<Skeleton.Button active size="default" shape="default" style={{ width: 145, height: 36 }} />
<Skeleton.Button active size="default" shape="default" style={{ width: 110, height: 36 }} />
<Skeleton className="h-9 w-[110px]" />
<Skeleton className="h-9 w-[145px]" />
<Skeleton className="h-9 w-[110px]" />
</>
) : userID && accessToken ? (
<>
<CreateUserButton userID={userID} accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
<CreateUserButton
userID={userID}
accessToken={accessToken}
teams={teams}
possibleUIRoles={possibleUIRoles}
/>
{isProxyAdmin && (
<Button
onClick={handleToggleSelectionMode}
type={selectionMode ? "primary" : "default"}
variant={selectionMode ? "default" : "outline"}
className="flex items-center"
>
{selectionMode ? "Cancel Selection" : "Select Users"}
@ -318,7 +378,11 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
)}
{isProxyAdmin && selectionMode && (
<Button type="primary" onClick={handleBulkEdit} disabled={selectedUsers.length === 0} className="flex items-center">
<Button
onClick={handleBulkEdit}
disabled={selectedUsers.length === 0}
className="flex items-center"
>
Bulk Edit ({selectedUsers.length} selected)
</Button>
)}
@ -328,90 +392,43 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
</div>
{isProxyAdmin ? (
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(index === 0 ? "users" : "settings")}>
<TabList className="mb-4">
<Tab>Users</Tab>
<Tab>Default User Settings</Tab>
</TabList>
<Tabs defaultValue="users">
<TabsList className="mb-4">
<TabsTrigger value="users">Users</TabsTrigger>
<TabsTrigger value="settings">Default User Settings</TabsTrigger>
</TabsList>
<TabPanels>
<TabPanel>
<UserDataTable
data={userListQuery.data?.users || []}
columns={tableColumns}
isLoading={userListQuery.isLoading}
<TabsContent value="users">
<UserDataTable
{...dataTableProps}
enableSelection={selectionMode}
selectedUsers={selectedUsers}
/>
</TabsContent>
<TabsContent value="settings">
{!userID || !userRole || !accessToken ? (
<div className="flex flex-col gap-3 p-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : (
<DefaultUserSettings
accessToken={accessToken}
userRole={userRole}
onSortChange={handleSortChange}
currentSort={{
sortBy: filters.sort_by,
sortOrder: filters.sort_order,
}}
possibleUIRoles={possibleUIRoles}
handleEdit={(user) => {
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}
/>
</TabPanel>
<TabPanel>
{!userID || !userRole || !accessToken ? (
<div className="flex justify-center items-center h-64">
<Skeleton active paragraph={{ rows: 4 }} />
</div>
) : (
<DefaultUserSettings
accessToken={accessToken}
possibleUIRoles={possibleUIRoles}
userID={userID}
userRole={userRole}
/>
)}
</TabPanel>
</TabPanels>
</TabGroup>
)}
</TabsContent>
</Tabs>
) : (
<UserDataTable
data={userListQuery.data?.users || []}
columns={tableColumns}
isLoading={userListQuery.isLoading}
accessToken={accessToken}
userRole={userRole}
onSortChange={handleSortChange}
currentSort={{
sortBy: filters.sort_by,
sortOrder: filters.sort_order,
}}
possibleUIRoles={possibleUIRoles}
handleEdit={(user) => {
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<ViewUserDashboardProps> = ({ 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) },
]}

View file

@ -90,8 +90,15 @@ describe("UserInfoView", () => {
});
it("should render the user alias after loading", async () => {
const user = userEvent.setup();
render(<UserInfoView {...defaultProps} />);
// 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(() => {