mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-25 18:54:30 +00:00
(UI) Fixes for managing Internal Users (#8786)
* allow bulk adding internal users * allow sorting users by created at * cleanup * clean up user table * show total num users * show per user error when bulk adding users * fix - don't allow creating duplicate internal users in DB * ui flow fix for bulk adding users * allow adding user in multiple teams with models * correctly extract info * working invitation link * fix fill in table after bulk add * fix the results from creating new users in bulkd * bulk invite users * fix view user flow * fix ui type errors * fix type errors * fix type errors
This commit is contained in:
parent
3a13d5419a
commit
f8e43296fb
10 changed files with 1134 additions and 215 deletions
|
@ -15,7 +15,7 @@ import asyncio
|
|||
import traceback
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
import fastapi
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
|
@ -71,6 +71,35 @@ def _update_internal_new_user_params(data_json: dict, data: NewUserRequest) -> d
|
|||
return data_json
|
||||
|
||||
|
||||
async def _check_duplicate_user_email(
|
||||
user_email: Optional[str], prisma_client: Any
|
||||
) -> None:
|
||||
"""
|
||||
Helper function to check if a user email already exists in the database.
|
||||
|
||||
Args:
|
||||
user_email (Optional[str]): Email to check
|
||||
prisma_client (Any): Database client instance
|
||||
|
||||
Raises:
|
||||
Exception: If database is not connected
|
||||
HTTPException: If user with email already exists
|
||||
"""
|
||||
if user_email:
|
||||
if prisma_client is None:
|
||||
raise Exception("Database not connected")
|
||||
|
||||
existing_user = await prisma_client.db.litellm_usertable.find_first(
|
||||
where={"user_email": user_email}
|
||||
)
|
||||
|
||||
if existing_user is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": f"User with email {user_email} already exists"},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/new",
|
||||
tags=["Internal User management"],
|
||||
|
@ -137,7 +166,15 @@ async def new_user(
|
|||
}'
|
||||
```
|
||||
"""
|
||||
from litellm.proxy.proxy_server import general_settings, proxy_logging_obj
|
||||
try:
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings,
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
)
|
||||
|
||||
# Check for duplicate email
|
||||
await _check_duplicate_user_email(data.user_email, prisma_client)
|
||||
|
||||
data_json = data.json() # type: ignore
|
||||
data_json = _update_internal_new_user_params(data_json, data)
|
||||
|
@ -146,7 +183,9 @@ async def new_user(
|
|||
# Add User to Team and Organization
|
||||
# if team_id passed add this user to the team
|
||||
if data_json.get("team_id", None) is not None:
|
||||
from litellm.proxy.management_endpoints.team_endpoints import team_member_add
|
||||
from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
team_member_add,
|
||||
)
|
||||
|
||||
try:
|
||||
await team_member_add(
|
||||
|
@ -232,6 +271,11 @@ async def new_user(
|
|||
budget_duration=response.get("budget_duration", None),
|
||||
model_max_budget=response.get("model_max_budget", None),
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
"/user/new: Exception occured - {}".format(str(e))
|
||||
)
|
||||
raise handle_exception_on_proxy(e)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
15
ui/litellm-dashboard/package-lock.json
generated
15
ui/litellm-dashboard/package-lock.json
generated
|
@ -15,6 +15,7 @@
|
|||
"@tanstack/react-query": "^5.64.1",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tremor/react": "^3.13.3",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"antd": "^5.13.2",
|
||||
"fs": "^0.0.1-security",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
|
@ -22,6 +23,7 @@
|
|||
"moment": "^2.30.1",
|
||||
"next": "^14.2.15",
|
||||
"openai": "^4.28.0",
|
||||
"papaparse": "^5.5.2",
|
||||
"react": "^18",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^18",
|
||||
|
@ -981,6 +983,14 @@
|
|||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/papaparse": {
|
||||
"version": "5.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.15.tgz",
|
||||
"integrity": "sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
|
||||
|
@ -5334,6 +5344,11 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/papaparse": {
|
||||
"version": "5.5.2",
|
||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.2.tgz",
|
||||
"integrity": "sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA=="
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
"@tanstack/react-query": "^5.64.1",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tremor/react": "^3.13.3",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"antd": "^5.13.2",
|
||||
"fs": "^0.0.1-security",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
|
@ -23,6 +24,7 @@
|
|||
"moment": "^2.30.1",
|
||||
"next": "^14.2.15",
|
||||
"openai": "^4.28.0",
|
||||
"papaparse": "^5.5.2",
|
||||
"react": "^18",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^18",
|
||||
|
|
645
ui/litellm-dashboard/src/components/bulk_create_users_button.tsx
Normal file
645
ui/litellm-dashboard/src/components/bulk_create_users_button.tsx
Normal file
|
@ -0,0 +1,645 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Button as TremorButton, Text } from "@tremor/react";
|
||||
import { Modal, Table, Upload, message } from "antd";
|
||||
import { UploadOutlined, DownloadOutlined } from "@ant-design/icons";
|
||||
import { userCreateCall, invitationCreateCall, getProxyUISettings } from "./networking";
|
||||
import Papa from "papaparse";
|
||||
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/outline";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { InvitationLink } from "./onboarding_link";
|
||||
|
||||
interface BulkCreateUsersProps {
|
||||
accessToken: string;
|
||||
teams: any[] | null;
|
||||
possibleUIRoles: null | Record<string, Record<string, string>>;
|
||||
onUsersCreated?: () => void;
|
||||
}
|
||||
|
||||
interface UserData {
|
||||
user_email: string;
|
||||
user_role: string;
|
||||
teams?: string | string[];
|
||||
metadata?: string;
|
||||
max_budget?: string | number;
|
||||
budget_duration?: string;
|
||||
models?: string | string[];
|
||||
status?: string;
|
||||
error?: string;
|
||||
rowNumber?: number;
|
||||
isValid?: boolean;
|
||||
key?: string;
|
||||
invitation_link?: string;
|
||||
}
|
||||
|
||||
// Define an interface for the UI settings
|
||||
interface UISettings {
|
||||
PROXY_BASE_URL: string | null;
|
||||
PROXY_LOGOUT_URL: string | null;
|
||||
DEFAULT_TEAM_DISABLED: boolean;
|
||||
SSO_ENABLED: boolean;
|
||||
}
|
||||
|
||||
const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
|
||||
accessToken,
|
||||
teams,
|
||||
possibleUIRoles,
|
||||
onUsersCreated,
|
||||
}) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [parsedData, setParsedData] = useState<UserData[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [uiSettings, setUISettings] = useState<UISettings | null>(null);
|
||||
const [baseUrl, setBaseUrl] = useState("http://localhost:4000");
|
||||
|
||||
useEffect(() => {
|
||||
// Get UI settings
|
||||
const fetchUISettings = async () => {
|
||||
try {
|
||||
const uiSettingsResponse = await getProxyUISettings(accessToken);
|
||||
setUISettings(uiSettingsResponse);
|
||||
} catch (error) {
|
||||
console.error("Error fetching UI settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUISettings();
|
||||
|
||||
// Set base URL
|
||||
const base = new URL("/", window.location.href);
|
||||
setBaseUrl(base.toString());
|
||||
}, [accessToken]);
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const template = [
|
||||
["user_email", "user_role", "teams", "max_budget", "budget_duration", "models"],
|
||||
["user@example.com", "internal_user", "team-id-1,team-id-2", "100", "30d", "gpt-3.5-turbo,gpt-4"],
|
||||
];
|
||||
|
||||
const csv = Papa.unparse(template);
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "bulk_users_template.csv";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleFileUpload = (file: File) => {
|
||||
setParseError(null);
|
||||
Papa.parse(file, {
|
||||
complete: (results) => {
|
||||
const headers = results.data[0] as string[];
|
||||
const requiredColumns = ['user_email', 'user_role'];
|
||||
|
||||
// Check if all required columns are present
|
||||
const missingColumns = requiredColumns.filter(col => !headers.includes(col));
|
||||
if (missingColumns.length > 0) {
|
||||
setParseError(`Your CSV is missing these required columns: ${missingColumns.join(', ')}`);
|
||||
setParsedData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userData = results.data.slice(1).map((row: any, index: number) => {
|
||||
const user: UserData = {
|
||||
user_email: row[headers.indexOf("user_email")]?.trim() || '',
|
||||
user_role: row[headers.indexOf("user_role")]?.trim() || '',
|
||||
teams: row[headers.indexOf("teams")]?.trim(),
|
||||
max_budget: row[headers.indexOf("max_budget")]?.trim(),
|
||||
budget_duration: row[headers.indexOf("budget_duration")]?.trim(),
|
||||
models: row[headers.indexOf("models")]?.trim(),
|
||||
rowNumber: index + 2,
|
||||
isValid: true,
|
||||
error: '',
|
||||
};
|
||||
|
||||
// Validate the row
|
||||
const errors: string[] = [];
|
||||
if (!user.user_email) errors.push('Email is required');
|
||||
if (!user.user_role) errors.push('Role is required');
|
||||
if (user.user_email && !user.user_email.includes('@')) errors.push('Invalid email format');
|
||||
|
||||
// Validate user role
|
||||
const validRoles = ['proxy_admin', 'proxy_admin_view_only', 'internal_user', 'internal_user_view_only'];
|
||||
if (user.user_role && !validRoles.includes(user.user_role)) {
|
||||
errors.push(`Invalid role. Must be one of: ${validRoles.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate max_budget if provided
|
||||
if (user.max_budget && isNaN(parseFloat(user.max_budget.toString()))) {
|
||||
errors.push('Max budget must be a number');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
user.isValid = false;
|
||||
user.error = errors.join(', ');
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
const validData = userData.filter(user => user.isValid);
|
||||
setParsedData(userData);
|
||||
|
||||
if (validData.length === 0) {
|
||||
setParseError('No valid users found in the CSV. Please check the errors below.');
|
||||
} else if (validData.length < userData.length) {
|
||||
setParseError(`Found ${userData.length - validData.length} row(s) with errors. Please correct them before proceeding.`);
|
||||
} else {
|
||||
message.success(`Successfully parsed ${validData.length} users`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
setParseError(`Error parsing CSV: ${errorMessage}`);
|
||||
setParsedData([]);
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
setParseError(`Failed to parse CSV file: ${error.message}`);
|
||||
setParsedData([]);
|
||||
},
|
||||
header: false,
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleBulkCreate = async () => {
|
||||
setIsProcessing(true);
|
||||
const updatedData = parsedData.map(user => ({ ...user, status: 'pending' }));
|
||||
setParsedData(updatedData);
|
||||
|
||||
let anySuccessful = false;
|
||||
|
||||
for (let index = 0; index < updatedData.length; index++) {
|
||||
const user = updatedData[index];
|
||||
try {
|
||||
// Convert teams from comma-separated string to array if provided
|
||||
const processedUser = { ...user };
|
||||
if (processedUser.teams && typeof processedUser.teams === 'string') {
|
||||
processedUser.teams = processedUser.teams.split(',').map(team => team.trim());
|
||||
}
|
||||
|
||||
// Convert models from comma-separated string to array if provided
|
||||
if (processedUser.models && typeof processedUser.models === 'string') {
|
||||
processedUser.models = processedUser.models.split(',').map(model => model.trim());
|
||||
}
|
||||
|
||||
// Convert max_budget to number if provided
|
||||
if (processedUser.max_budget && processedUser.max_budget.toString().trim() !== '') {
|
||||
processedUser.max_budget = parseFloat(processedUser.max_budget.toString());
|
||||
}
|
||||
|
||||
|
||||
const response = await userCreateCall(accessToken, null, processedUser);
|
||||
console.log('Full response:', response);
|
||||
|
||||
// Check if response has key or user_id, indicating success
|
||||
if (response && (response.key || response.user_id)) {
|
||||
anySuccessful = true;
|
||||
console.log('Success case triggered');
|
||||
const user_id = response.data?.user_id || response.user_id;
|
||||
|
||||
// Create invitation link for the user
|
||||
try {
|
||||
if (!uiSettings?.SSO_ENABLED) {
|
||||
// Regular invitation flow
|
||||
const invitationData = await invitationCreateCall(accessToken, user_id);
|
||||
const invitationUrl = new URL(`/ui?invitation_id=${invitationData.id}`, baseUrl).toString();
|
||||
|
||||
setParsedData(current =>
|
||||
current.map((u, i) =>
|
||||
i === index ? {
|
||||
...u,
|
||||
status: 'success',
|
||||
key: response.key || response.user_id,
|
||||
invitation_link: invitationUrl
|
||||
} : u
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// SSO flow - just use the base URL
|
||||
const invitationUrl = new URL("/ui", baseUrl).toString();
|
||||
|
||||
setParsedData(current =>
|
||||
current.map((u, i) =>
|
||||
i === index ? {
|
||||
...u,
|
||||
status: 'success',
|
||||
key: response.key || response.user_id,
|
||||
invitation_link: invitationUrl
|
||||
} : u
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (inviteError) {
|
||||
console.error('Error creating invitation:', inviteError);
|
||||
setParsedData(current =>
|
||||
current.map((u, i) =>
|
||||
i === index ? {
|
||||
...u,
|
||||
status: 'success',
|
||||
key: response.key || response.user_id,
|
||||
error: 'User created but failed to generate invitation link'
|
||||
} : u
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log('Error case triggered');
|
||||
const errorMessage = response?.error || 'Failed to create user';
|
||||
console.log('Error message:', errorMessage);
|
||||
setParsedData(current =>
|
||||
current.map((u, i) =>
|
||||
i === index ? { ...u, status: 'failed', error: errorMessage } : u
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Caught error:', error);
|
||||
const errorMessage = (error as any)?.response?.data?.error ||
|
||||
(error as Error)?.message ||
|
||||
String(error);
|
||||
setParsedData(current =>
|
||||
current.map((u, i) =>
|
||||
i === index ? { ...u, status: 'failed', error: errorMessage } : u
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
|
||||
// Call the callback if any users were successfully created
|
||||
if (anySuccessful && onUsersCreated) {
|
||||
onUsersCreated();
|
||||
}
|
||||
};
|
||||
|
||||
const downloadResults = () => {
|
||||
const results = parsedData.map(user => ({
|
||||
user_email: user.user_email,
|
||||
user_role: user.user_role,
|
||||
status: user.status,
|
||||
key: user.key || '',
|
||||
invitation_link: user.invitation_link || '',
|
||||
error: user.error || ''
|
||||
}));
|
||||
|
||||
const csv = Papa.unparse(results);
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "bulk_users_results.csv";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Row",
|
||||
dataIndex: "rowNumber",
|
||||
key: "rowNumber",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "Email",
|
||||
dataIndex: "user_email",
|
||||
key: "user_email",
|
||||
},
|
||||
{
|
||||
title: "Role",
|
||||
dataIndex: "user_role",
|
||||
key: "user_role",
|
||||
},
|
||||
{
|
||||
title: "Teams",
|
||||
dataIndex: "teams",
|
||||
key: "teams",
|
||||
},
|
||||
{
|
||||
title: "Budget",
|
||||
dataIndex: "max_budget",
|
||||
key: "max_budget",
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
key: 'status',
|
||||
render: (_: any, record: UserData) => {
|
||||
if (!record.isValid) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
|
||||
<span className="text-red-500">Invalid</span>
|
||||
</div>
|
||||
{record.error && (
|
||||
<span className="text-sm text-red-500 ml-7">{record.error}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!record.status || record.status === 'pending') {
|
||||
return <span className="text-gray-500">Pending</span>;
|
||||
}
|
||||
if (record.status === 'success') {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
|
||||
<span className="text-green-500">Success</span>
|
||||
</div>
|
||||
{record.invitation_link && (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xs text-gray-500 truncate max-w-[150px]">
|
||||
{record.invitation_link}
|
||||
</span>
|
||||
<CopyToClipboard
|
||||
text={record.invitation_link}
|
||||
onCopy={() => message.success("Invitation link copied!")}
|
||||
>
|
||||
<button className="ml-1 text-blue-500 text-xs hover:text-blue-700">
|
||||
Copy
|
||||
</button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
|
||||
<span className="text-red-500">Failed</span>
|
||||
</div>
|
||||
{record.error && (
|
||||
<span className="text-sm text-red-500 ml-7">
|
||||
{JSON.stringify(record.error)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<TremorButton className="mx-auto mb-0" onClick={() => setIsModalVisible(true)}>
|
||||
+ Bulk Invite Users
|
||||
</TremorButton>
|
||||
|
||||
<Modal
|
||||
title="Bulk Invite Users"
|
||||
visible={isModalVisible}
|
||||
width={800}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
bodyStyle={{ maxHeight: '70vh', overflow: 'auto' }}
|
||||
footer={null}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{/* Step indicator */}
|
||||
{parsedData.length === 0 ? (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">1</div>
|
||||
<h3 className="text-lg font-medium">Download and fill the template</h3>
|
||||
</div>
|
||||
|
||||
<div className="ml-11 mb-6">
|
||||
<p className="mb-4">Add multiple users at once by following these steps:</p>
|
||||
<ol className="list-decimal list-inside space-y-2 ml-2 mb-4">
|
||||
<li>Download our CSV template</li>
|
||||
<li>Add your users' information to the spreadsheet</li>
|
||||
<li>Save the file and upload it here</li>
|
||||
<li>After creation, download the results file containing the API keys for each user</li>
|
||||
</ol>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-md border border-gray-200 mb-4">
|
||||
<h4 className="font-medium mb-2">Template Column Names</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 flex-shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">user_email</p>
|
||||
<p className="text-sm text-gray-600">User's email address (required)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 flex-shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">user_role</p>
|
||||
<p className="text-sm text-gray-600">User's role (one of: "proxy_admin", "proxy_admin_view_only", "internal_user", "internal_user_view_only")</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 flex-shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">teams</p>
|
||||
<p className="text-sm text-gray-600">Comma-separated team IDs (e.g., "team-1,team-2")</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 flex-shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">max_budget</p>
|
||||
<p className="text-sm text-gray-600">Maximum budget as a number (e.g., "100")</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 flex-shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">budget_duration</p>
|
||||
<p className="text-sm text-gray-600">Budget reset period (e.g., "30d", "1mo")</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 flex-shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">models</p>
|
||||
<p className="text-sm text-gray-600">Comma-separated allowed models (e.g., "gpt-3.5-turbo,gpt-4")</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TremorButton
|
||||
onClick={downloadTemplate}
|
||||
size="lg"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
<DownloadOutlined className="mr-2" /> Download CSV Template
|
||||
</TremorButton>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">2</div>
|
||||
<h3 className="text-lg font-medium">Upload your completed CSV</h3>
|
||||
</div>
|
||||
|
||||
<div className="ml-11">
|
||||
<Upload
|
||||
beforeUpload={handleFileUpload}
|
||||
accept=".csv"
|
||||
maxCount={1}
|
||||
showUploadList={false}
|
||||
>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-500 transition-colors cursor-pointer">
|
||||
<UploadOutlined className="text-3xl text-gray-400 mb-2" />
|
||||
<p className="mb-1">Drag and drop your CSV file here</p>
|
||||
<p className="text-sm text-gray-500 mb-3">or</p>
|
||||
<TremorButton size="sm">Browse files</TremorButton>
|
||||
</div>
|
||||
</Upload>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">3</div>
|
||||
<h3 className="text-lg font-medium">
|
||||
{parsedData.some(user => user.status === 'success' || user.status === 'failed')
|
||||
? "User Creation Results"
|
||||
: "Review and create users"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{parseError && (
|
||||
<div className="ml-11 mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<Text className="text-red-600 font-medium">{parseError}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ml-11">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="flex items-center">
|
||||
{parsedData.some(user => user.status === 'success' || user.status === 'failed') ? (
|
||||
<div className="flex items-center">
|
||||
<Text className="text-lg font-medium mr-3">Creation Summary</Text>
|
||||
<Text className="text-sm bg-green-100 text-green-800 px-2 py-1 rounded mr-2">
|
||||
{parsedData.filter(d => d.status === 'success').length} Successful
|
||||
</Text>
|
||||
{parsedData.some(d => d.status === 'failed') && (
|
||||
<Text className="text-sm bg-red-100 text-red-800 px-2 py-1 rounded">
|
||||
{parsedData.filter(d => d.status === 'failed').length} Failed
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<Text className="text-lg font-medium mr-3">User Preview</Text>
|
||||
<Text className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||
{parsedData.filter(d => d.isValid).length} of {parsedData.length} users valid
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!parsedData.some(user => user.status === 'success' || user.status === 'failed') && (
|
||||
<div className="flex space-x-3">
|
||||
<TremorButton
|
||||
onClick={() => {
|
||||
setParsedData([]);
|
||||
setParseError(null);
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
Back
|
||||
</TremorButton>
|
||||
<TremorButton
|
||||
onClick={handleBulkCreate}
|
||||
disabled={parsedData.filter(d => d.isValid).length === 0 || isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Creating...' : `Create ${parsedData.filter(d => d.isValid).length} Users`}
|
||||
</TremorButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parsedData.some(user => user.status === 'success') && (
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<div className="flex items-start">
|
||||
<div className="mr-3 mt-1">
|
||||
<CheckCircleIcon className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium text-blue-800">User creation complete</Text>
|
||||
<Text className="block text-sm text-blue-700 mt-1">
|
||||
<span className="font-medium">Next step:</span> Download the credentials file containing API keys and invitation links.
|
||||
Users will need these API keys to make LLM requests through LiteLLM.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table
|
||||
dataSource={parsedData}
|
||||
columns={columns}
|
||||
size="small"
|
||||
pagination={{ pageSize: 5 }}
|
||||
scroll={{ y: 300 }}
|
||||
rowClassName={(record) => !record.isValid ? 'bg-red-50' : ''}
|
||||
/>
|
||||
|
||||
{!parsedData.some(user => user.status === 'success' || user.status === 'failed') && (
|
||||
<div className="flex justify-end mt-4">
|
||||
<TremorButton
|
||||
onClick={() => {
|
||||
setParsedData([]);
|
||||
setParseError(null);
|
||||
}}
|
||||
variant="secondary"
|
||||
className="mr-3"
|
||||
>
|
||||
Back
|
||||
</TremorButton>
|
||||
<TremorButton
|
||||
onClick={handleBulkCreate}
|
||||
disabled={parsedData.filter(d => d.isValid).length === 0 || isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Creating...' : `Create ${parsedData.filter(d => d.isValid).length} Users`}
|
||||
</TremorButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedData.some(user => user.status === 'success' || user.status === 'failed') && (
|
||||
<div className="flex justify-end mt-4">
|
||||
<TremorButton
|
||||
onClick={() => {
|
||||
setParsedData([]);
|
||||
setParseError(null);
|
||||
}}
|
||||
variant="secondary"
|
||||
className="mr-3"
|
||||
>
|
||||
Start New Bulk Import
|
||||
</TremorButton>
|
||||
<TremorButton
|
||||
onClick={downloadResults}
|
||||
variant="primary"
|
||||
className="flex items-center"
|
||||
>
|
||||
<DownloadOutlined className="mr-2" /> Download User Credentials
|
||||
</TremorButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkCreateUsersButton;
|
|
@ -19,6 +19,7 @@ import {
|
|||
invitationCreateCall,
|
||||
getProxyUISettings,
|
||||
} from "./networking";
|
||||
import BulkCreateUsers from "./bulk_create_users_button";
|
||||
const { Option } = Select;
|
||||
|
||||
interface CreateuserProps {
|
||||
|
@ -148,16 +149,23 @@ const Createuser: React.FC<CreateuserProps> = ({
|
|||
message.success("API user Created");
|
||||
form.resetFields();
|
||||
localStorage.removeItem("userData" + userID);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.detail || error?.message || "Error creating the user";
|
||||
message.error(errorMessage);
|
||||
console.error("Error creating the user:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Button2 className="mx-auto mb-0" onClick={() => setIsModalVisible(true)}>
|
||||
+ Invite User
|
||||
</Button2>
|
||||
<BulkCreateUsers
|
||||
accessToken={accessToken}
|
||||
teams={teams}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
/>
|
||||
<Modal
|
||||
title="Invite User"
|
||||
visible={isModalVisible}
|
||||
|
|
|
@ -542,7 +542,7 @@ export const userCreateCall = async (
|
|||
const errorData = await response.text();
|
||||
handleError(errorData);
|
||||
console.error("Error response from the server:", errorData);
|
||||
throw new Error("Network response was not ok");
|
||||
throw new Error(errorData);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
|
|
@ -45,6 +45,10 @@ import {
|
|||
} from "@heroicons/react/outline";
|
||||
|
||||
import { userDeleteCall } from "./networking";
|
||||
import { columns } from "./view_users/columns";
|
||||
import { UserDataTable } from "./view_users/table";
|
||||
import { UserInfo } from "./view_users/types";
|
||||
import BulkCreateUsers from "./bulk_create_users_button";
|
||||
|
||||
interface ViewUserDashboardProps {
|
||||
accessToken: string | null;
|
||||
|
@ -64,6 +68,14 @@ interface UserListResponse {
|
|||
total_pages: number;
|
||||
}
|
||||
|
||||
interface CreateuserProps {
|
||||
userID: string;
|
||||
accessToken: string;
|
||||
teams: any[];
|
||||
possibleUIRoles: Record<string, Record<string, string>>;
|
||||
onUserCreated: () => Promise<void>;
|
||||
}
|
||||
|
||||
const isLocal = process.env.NODE_ENV === "development";
|
||||
const proxyBaseUrl = isLocal ? "http://localhost:4000" : null;
|
||||
if (isLocal != true) {
|
||||
|
@ -86,13 +98,14 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
const [openDialogId, setOpenDialogId] = React.useState<null | number>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<null | any>(null);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [selectedUser, setSelectedUser] = useState<UserInfo | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [userToDelete, setUserToDelete] = useState<string | null>(null);
|
||||
const [possibleUIRoles, setPossibleUIRoles] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
const defaultPageSize = 25;
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// check if window is not undefined
|
||||
if (typeof window !== "undefined") {
|
||||
|
@ -160,6 +173,34 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
// Close the modal
|
||||
};
|
||||
|
||||
const refreshUserData = async () => {
|
||||
if (!accessToken || !token || !userRole || !userID) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userDataResponse = await userInfoCall(
|
||||
accessToken,
|
||||
null,
|
||||
userRole,
|
||||
true,
|
||||
currentPage,
|
||||
defaultPageSize
|
||||
);
|
||||
|
||||
// Update session storage with new data
|
||||
sessionStorage.setItem(
|
||||
`userList_${currentPage}`,
|
||||
JSON.stringify(userDataResponse)
|
||||
);
|
||||
|
||||
setUserListResponse(userDataResponse);
|
||||
setUserData(userDataResponse.users || []);
|
||||
} catch (error) {
|
||||
console.error("Error refreshing user data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !token || !userRole || !userID) {
|
||||
return;
|
||||
|
@ -221,126 +262,38 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
if (!userData) return null;
|
||||
|
||||
const totalPages = userListResponse?.total_pages || 0;
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setUserData([]); // Clear current users
|
||||
setCurrentPage(newPage);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
Showing Page {currentPage } of {totalPages}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<button
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-l focus:outline-none"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<button
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-r focus:outline-none"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
const tableColumns = columns(
|
||||
possibleUIRoles,
|
||||
(user) => {
|
||||
setSelectedUser(user);
|
||||
setEditModalVisible(true);
|
||||
},
|
||||
handleDelete
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%" }}>
|
||||
<Grid className="gap-2 p-2 h-[90vh] w-full mt-8">
|
||||
<div className="w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-semibold">Users</h1>
|
||||
<div className="flex space-x-3">
|
||||
<CreateUser
|
||||
userID={userID}
|
||||
accessToken={accessToken}
|
||||
teams={teams}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
/>
|
||||
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[90vh] mb-4">
|
||||
<div className="mb-4 mt-1"></div>
|
||||
<TabGroup>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<Table className="mt-5">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>User ID</TableHeaderCell>
|
||||
<TableHeaderCell>User Email</TableHeaderCell>
|
||||
<TableHeaderCell>Role</TableHeaderCell>
|
||||
<TableHeaderCell>User Spend ($ USD)</TableHeaderCell>
|
||||
<TableHeaderCell>User Max Budget ($ USD)</TableHeaderCell>
|
||||
<TableHeaderCell>API Keys</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{userData.map((user: any) => (
|
||||
<TableRow key={user.user_id}>
|
||||
<TableCell>{user.user_id || "-"}</TableCell>
|
||||
<TableCell>{user.user_email || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{possibleUIRoles?.[user?.user_role]?.ui_label || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.spend ? user.spend?.toFixed(2) : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.max_budget !== null ? user.max_budget : "Unlimited"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Grid numItems={2}>
|
||||
{user.key_count > 0 ? (
|
||||
<Badge size={"xs"} color={"indigo"}>
|
||||
{user.key_count} Keys
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size={"xs"} color={"gray"}>
|
||||
No Keys
|
||||
</Badge>
|
||||
)}
|
||||
{/* <Text>{user.key_aliases.filter(key => key !== null).length} Keys</Text> */}
|
||||
</Grid>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setEditModalVisible(true);
|
||||
}}
|
||||
>
|
||||
View Keys
|
||||
</Icon>
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
onClick={() => handleDelete(user.user_id)}
|
||||
>
|
||||
Delete
|
||||
</Icon>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-1"></div>
|
||||
<div className="flex-1 flex justify-between items-center"></div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<UserDataTable
|
||||
data={userData || []}
|
||||
columns={tableColumns}
|
||||
isLoading={!userData}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Existing Modals */}
|
||||
<EditUserModal
|
||||
visible={editModalVisible}
|
||||
possibleUIRoles={possibleUIRoles}
|
||||
|
@ -348,6 +301,8 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
user={selectedUser}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{isDeleteModalOpen && (
|
||||
<div className="fixed z-10 inset-0 overflow-y-auto">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
|
@ -395,9 +350,6 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{renderPagination()}
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
110
ui/litellm-dashboard/src/components/view_users/columns.tsx
Normal file
110
ui/litellm-dashboard/src/components/view_users/columns.tsx
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge, Grid, Icon } from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import { UserInfo } from "./types";
|
||||
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
|
||||
export const columns = (
|
||||
possibleUIRoles: Record<string, Record<string, string>>,
|
||||
handleEdit: (user: UserInfo) => void,
|
||||
handleDelete: (userId: string) => void,
|
||||
): ColumnDef<UserInfo>[] => [
|
||||
{
|
||||
header: "User ID",
|
||||
accessorKey: "user_id",
|
||||
cell: ({ row }) => (
|
||||
<Tooltip title={row.original.user_id}>
|
||||
<span className="text-xs">{row.original.user_id ? `${row.original.user_id.slice(0, 4)}...` : "-"}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "User Email",
|
||||
accessorKey: "user_email",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.user_email || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Role",
|
||||
accessorKey: "user_role",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">
|
||||
{possibleUIRoles?.[row.original.user_role]?.ui_label || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "User Spend ($ USD)",
|
||||
accessorKey: "spend",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">
|
||||
{row.original.spend ? row.original.spend.toFixed(2) : "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "User Max Budget ($ USD)",
|
||||
accessorKey: "max_budget",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">
|
||||
{row.original.max_budget !== null ? row.original.max_budget : "Unlimited"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "API Keys",
|
||||
accessorKey: "key_count",
|
||||
cell: ({ row }) => (
|
||||
<Grid numItems={2}>
|
||||
{row.original.key_count > 0 ? (
|
||||
<Badge size="xs" color="indigo">
|
||||
{row.original.key_count} Keys
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="xs" color="gray">
|
||||
No Keys
|
||||
</Badge>
|
||||
)}
|
||||
</Grid>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">
|
||||
{row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Updated At",
|
||||
accessorKey: "updated_at",
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">
|
||||
{row.original.updated_at ? new Date(row.original.updated_at).toLocaleDateString() : "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2">
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => handleEdit(row.original)}
|
||||
/>
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => handleDelete(row.original.user_id)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
133
ui/litellm-dashboard/src/components/view_users/table.tsx
Normal file
133
ui/litellm-dashboard/src/components/view_users/table.tsx
Normal file
|
@ -0,0 +1,133 @@
|
|||
import { Fragment } from "react";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import React from "react";
|
||||
import {
|
||||
Table,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@tremor/react";
|
||||
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
|
||||
import { UserInfo } from "./types";
|
||||
|
||||
interface UserDataTableProps {
|
||||
data: UserInfo[];
|
||||
columns: ColumnDef<UserInfo, any>[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function UserDataTable({
|
||||
data = [],
|
||||
columns,
|
||||
isLoading = false,
|
||||
}: UserDataTableProps) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([
|
||||
{ id: "created_at", desc: true }
|
||||
]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
enableSorting: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1">
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
className={`py-1 h-8 ${
|
||||
header.id === 'actions'
|
||||
? 'sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]'
|
||||
: ''
|
||||
}`}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center">
|
||||
{header.isPlaceholder ? null : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{header.id !== 'actions' && (
|
||||
<div className="w-4">
|
||||
{header.column.getIsSorted() ? (
|
||||
{
|
||||
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
|
||||
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />
|
||||
}[header.column.getIsSorted() as string]
|
||||
) : (
|
||||
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>🚅 Loading users...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="h-8">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
|
||||
cell.column.id === 'actions'
|
||||
? 'sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>No users found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
10
ui/litellm-dashboard/src/components/view_users/types.ts
Normal file
10
ui/litellm-dashboard/src/components/view_users/types.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export interface UserInfo {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
user_role: string;
|
||||
spend: number;
|
||||
max_budget: number | null;
|
||||
key_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue