mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
Feat/customer dashboard (#21532)
* feat: add customer dashboard to UI Add a comprehensive customer management dashboard following the Teams page pattern. Features: - Customer list view with filtering and search - Create new customers with budget, model, and region settings - Edit customer details including blocking/unblocking - Delete customers with confirmation - View customer spend and budget information - Filter by status (active/blocked) and region (US/EU) Components: - CustomersView: Main view component with state management - CustomersTable: Table component displaying customer data - CustomersFilters: Search and filter controls - CustomersHeaderTabs: Tab navigation with refresh - Modals: Create, Edit, and Delete customer modals - useFetchCustomers: Custom hook for data fetching API Integration: - Added customerCreateCall, customerUpdateCall, customerDeleteCall - Added Customer interface type definition - Integrated with existing allEndUsersCall Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ui/): add new customer dashboard to the ui allows seeing all the end users/customers that have been created on litellm visibility --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4773838561
commit
f8ef5a80e7
15 changed files with 1570 additions and 0 deletions
|
|
@ -0,0 +1,226 @@
|
|||
import React, { useState } from "react";
|
||||
import { Button, Card, TabPanel, Text } from "@tremor/react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import CustomersTable from "@/app/(dashboard)/customers/components/CustomersTable";
|
||||
import CustomerInfo from "@/app/(dashboard)/customers/components/CustomerInfo";
|
||||
import CreateCustomerModal from "@/app/(dashboard)/customers/components/modals/CreateCustomerModal";
|
||||
import DeleteCustomerModal from "@/app/(dashboard)/customers/components/modals/DeleteCustomerModal";
|
||||
import CustomersHeaderTabs from "@/app/(dashboard)/customers/components/CustomersHeaderTabs";
|
||||
import CustomersFilters from "@/app/(dashboard)/customers/components/CustomersFilters";
|
||||
import type { Customer, NewCustomerData } from "@/app/(dashboard)/customers/types";
|
||||
import { customerDeleteCall } from "@/components/networking";
|
||||
|
||||
interface CustomersViewProps {
|
||||
customers: Customer[];
|
||||
setCustomers: React.Dispatch<React.SetStateAction<Customer[]>>;
|
||||
accessToken: string | null;
|
||||
userID: string | null;
|
||||
userRole: string | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
user_id: string;
|
||||
alias: string;
|
||||
blocked: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
const CustomersView: React.FC<CustomersViewProps> = ({
|
||||
customers,
|
||||
setCustomers,
|
||||
accessToken,
|
||||
userID,
|
||||
userRole,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
user_id: "",
|
||||
alias: "",
|
||||
blocked: "",
|
||||
region: "",
|
||||
});
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
|
||||
const [customerDetailDefaultTab, setCustomerDetailDefaultTab] = useState<"overview" | "settings">("overview");
|
||||
const [lastRefreshed, setLastRefreshed] = useState(new Date().toLocaleString("en-US"));
|
||||
|
||||
const handleCreateCustomer = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const { allEndUsersCall } = await import("@/components/networking");
|
||||
const listData = await allEndUsersCall(accessToken);
|
||||
if (listData) {
|
||||
setCustomers(Array.isArray(listData) ? listData : []);
|
||||
}
|
||||
setShowCreateModal(false);
|
||||
} catch (error) {
|
||||
console.error("Error refreshing customers:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditCustomer = (customer: Customer) => {
|
||||
setSelectedCustomerId(customer.user_id);
|
||||
setSelectedCustomer(customer);
|
||||
setCustomerDetailDefaultTab("settings");
|
||||
};
|
||||
|
||||
const handleViewInfo = (customer: Customer) => {
|
||||
setSelectedCustomerId(customer.user_id);
|
||||
setSelectedCustomer(customer);
|
||||
setCustomerDetailDefaultTab("overview");
|
||||
};
|
||||
|
||||
const handleCloseCustomerInfo = () => {
|
||||
setSelectedCustomerId(null);
|
||||
setSelectedCustomer(null);
|
||||
};
|
||||
|
||||
const handleUpdateCustomer = (updated: Customer) => {
|
||||
setCustomers((prev) =>
|
||||
prev.map((c) => (c.user_id === updated.user_id ? updated : c))
|
||||
);
|
||||
setSelectedCustomer(updated);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (customer: Customer) => {
|
||||
setSelectedCustomer(customer);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedCustomer || !accessToken) return;
|
||||
|
||||
try {
|
||||
await customerDeleteCall(accessToken, selectedCustomer.user_id);
|
||||
setCustomers(
|
||||
customers.filter((c) => c.user_id !== selectedCustomer.user_id)
|
||||
);
|
||||
setShowDeleteModal(false);
|
||||
setSelectedCustomer(null);
|
||||
} catch (error) {
|
||||
console.error("Error deleting customer:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLastRefreshed(new Date().toLocaleString("en-US"));
|
||||
// Trigger a re-fetch if needed
|
||||
};
|
||||
|
||||
const handleFilterChange = (key: keyof FilterState, value: string) => {
|
||||
setFilters({ ...filters, [key]: value });
|
||||
};
|
||||
|
||||
const handleFilterReset = () => {
|
||||
setFilters({
|
||||
user_id: "",
|
||||
alias: "",
|
||||
blocked: "",
|
||||
region: "",
|
||||
});
|
||||
};
|
||||
|
||||
const filteredCustomers = customers.filter((c) => {
|
||||
const matchesUserId = !filters.user_id || c.user_id.toLowerCase().includes(filters.user_id.toLowerCase());
|
||||
const matchesAlias = !filters.alias || (c.alias && c.alias.toLowerCase().includes(filters.alias.toLowerCase()));
|
||||
const matchesBlocked =
|
||||
!filters.blocked ||
|
||||
(filters.blocked === "active" && !c.blocked) ||
|
||||
(filters.blocked === "blocked" && c.blocked);
|
||||
const matchesRegion = !filters.region || c.allowed_model_region === filters.region;
|
||||
|
||||
return matchesUserId && matchesAlias && matchesBlocked && matchesRegion;
|
||||
});
|
||||
|
||||
const initialCustomerForDetail =
|
||||
selectedCustomerId && selectedCustomer?.user_id === selectedCustomerId
|
||||
? selectedCustomer
|
||||
: customers.find((c) => c.user_id === selectedCustomerId) ?? null;
|
||||
|
||||
if (selectedCustomerId) {
|
||||
return (
|
||||
<div className="w-full max-w-full px-4 py-4 md:px-6">
|
||||
<CustomerInfo
|
||||
customerId={selectedCustomerId}
|
||||
initialCustomer={initialCustomerForDetail}
|
||||
onClose={handleCloseCustomerInfo}
|
||||
onUpdate={handleUpdateCustomer}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
defaultTab={customerDetailDefaultTab}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-full px-4 py-4 md:px-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{(userRole === "Admin" || userRole === "Org Admin") && (
|
||||
<Button className="w-fit" onClick={() => setShowCreateModal(true)}>
|
||||
+ Create New Customer
|
||||
</Button>
|
||||
)}
|
||||
<Text className="text-sm text-gray-500">
|
||||
Customers are end-users of an AI application (e.g. users of your internal chat UI).
|
||||
</Text>
|
||||
|
||||
<CustomersHeaderTabs lastRefreshed={lastRefreshed} onRefresh={handleRefresh} userRole={userRole}>
|
||||
<TabPanel>
|
||||
<Text className="block mb-3">
|
||||
Click on “Customer ID” to view customer details and manage settings.
|
||||
</Text>
|
||||
<Card className="w-full overflow-hidden flex flex-col min-h-[400px]">
|
||||
<div className="border-b px-4 sm:px-6 py-4 shrink-0">
|
||||
<CustomersFilters
|
||||
filters={filters}
|
||||
showFilters={showFilters}
|
||||
onToggleFilters={setShowFilters}
|
||||
onChange={(key, value) => handleFilterChange(key as keyof FilterState, value)}
|
||||
onReset={handleFilterReset}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-auto flex-1 min-h-0">
|
||||
<CustomersTable
|
||||
customers={filteredCustomers}
|
||||
userRole={userRole}
|
||||
onEdit={handleEditCustomer}
|
||||
onDelete={handleDeleteClick}
|
||||
onViewInfo={handleViewInfo}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</CustomersHeaderTabs>
|
||||
|
||||
{(userRole === "Admin" || userRole === "Org Admin") && (
|
||||
<>
|
||||
<CreateCustomerModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreate={handleCreateCustomer}
|
||||
/>
|
||||
<DeleteCustomerModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setSelectedCustomer(null);
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
customerName={selectedCustomer?.alias || ""}
|
||||
customerId={selectedCustomer?.user_id || ""}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomersView;
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import React from "react";
|
||||
import { Form, Input, InputNumber, Select as AntSelect, Switch } from "antd";
|
||||
import type { FormInstance } from "antd";
|
||||
|
||||
interface CustomerFormFieldsProps {
|
||||
form: FormInstance;
|
||||
mode: "create" | "edit";
|
||||
disabledFields?: string[];
|
||||
}
|
||||
|
||||
const defaultModelOptions = [
|
||||
{ value: "", label: "None" },
|
||||
{ value: "gpt-4o", label: "gpt-4o" },
|
||||
{ value: "gpt-4o-mini", label: "gpt-4o-mini" },
|
||||
{ value: "gpt-4-turbo", label: "gpt-4-turbo" },
|
||||
{ value: "claude-3-sonnet", label: "claude-3-sonnet" },
|
||||
{ value: "claude-3-opus", label: "claude-3-opus" },
|
||||
{ value: "claude-3-haiku", label: "claude-3-haiku" },
|
||||
];
|
||||
|
||||
const regionOptions = [
|
||||
{ value: "", label: "Any region" },
|
||||
{ value: "us", label: "US" },
|
||||
{ value: "eu", label: "EU" },
|
||||
];
|
||||
|
||||
const CustomerFormFields: React.FC<CustomerFormFieldsProps> = ({
|
||||
form,
|
||||
mode,
|
||||
disabledFields = [],
|
||||
}) => {
|
||||
const isDisabled = (field: string) => disabledFields.includes(field);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Alias" name="alias">
|
||||
<Input placeholder={mode === "create" ? "e.g. Acme Corp" : "Customer alias"} />
|
||||
</Form.Item>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Item label="Max Budget" name="max_budget">
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
placeholder={mode === "create" ? "e.g. 500" : "No limit"}
|
||||
min={0}
|
||||
disabled={isDisabled("max_budget")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Budget ID" name="budget_id">
|
||||
<Input placeholder="e.g. free_tier" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Item label="Default Model" name="default_model">
|
||||
<AntSelect
|
||||
placeholder={mode === "create" ? "Select model..." : "None"}
|
||||
allowClear={mode === "edit"}
|
||||
options={defaultModelOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Allowed Region" name="allowed_model_region">
|
||||
<AntSelect
|
||||
placeholder="Any region"
|
||||
allowClear={mode === "edit"}
|
||||
options={regionOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item label="Budget Duration" name="budget_duration">
|
||||
<Input placeholder={mode === "create" ? "e.g. 30d, 24h, 60m" : "e.g. 30d, 24h"} />
|
||||
</Form.Item>
|
||||
|
||||
{mode === "edit" && (
|
||||
<Form.Item label="Blocked" name="blocked" valuePropName="checked">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch />
|
||||
<span className="text-sm text-gray-500">
|
||||
{form.getFieldValue("blocked")
|
||||
? "This customer is currently blocked from making requests"
|
||||
: "This customer can make requests normally"}
|
||||
</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerFormFields;
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Button, Form, Input, Tabs } from "antd";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/outline";
|
||||
import { Badge, Card, Grid, Text, Title } from "@tremor/react";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { customerUpdateCall } from "@/components/networking";
|
||||
import type { Customer } from "@/app/(dashboard)/customers/types";
|
||||
import CustomerFormFields from "@/app/(dashboard)/customers/components/CustomerFormFields";
|
||||
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
|
||||
import AgentSelector from "@/components/agent_management/AgentSelector";
|
||||
import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
|
||||
import ObjectPermissionsView from "@/components/object_permissions_view";
|
||||
|
||||
export interface CustomerInfoProps {
|
||||
customerId: string;
|
||||
initialCustomer: Customer | null;
|
||||
onClose: () => void;
|
||||
onUpdate: (customer: Customer) => void;
|
||||
accessToken: string | null;
|
||||
userRole: string | null;
|
||||
defaultTab?: "overview" | "settings";
|
||||
}
|
||||
|
||||
const CustomerInfo: React.FC<CustomerInfoProps> = ({
|
||||
customerId,
|
||||
initialCustomer,
|
||||
onClose,
|
||||
onUpdate,
|
||||
accessToken,
|
||||
userRole,
|
||||
defaultTab = "overview",
|
||||
}) => {
|
||||
const [customer, setCustomer] = useState<Customer | null>(initialCustomer);
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomer(initialCustomer);
|
||||
}, [initialCustomer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (customer) {
|
||||
const op = customer.object_permission;
|
||||
form.setFieldsValue({
|
||||
...customer,
|
||||
max_budget: customer.litellm_budget_table?.max_budget,
|
||||
budget_duration: customer.litellm_budget_table?.budget_duration,
|
||||
allowed_mcp_servers_and_groups: {
|
||||
servers: op?.mcp_servers ?? [],
|
||||
accessGroups: op?.mcp_access_groups ?? [],
|
||||
},
|
||||
allowed_agents_and_groups: {
|
||||
agents: op?.agents ?? [],
|
||||
accessGroups: op?.agent_access_groups ?? [],
|
||||
},
|
||||
mcp_tool_permissions: op?.mcp_tool_permissions ?? {},
|
||||
});
|
||||
}
|
||||
}, [customer, form]);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (!customerId) return;
|
||||
const success = await utilCopyToClipboard(customerId);
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const buildObjectPermission = (values: Record<string, any>) => {
|
||||
const objPerm: {
|
||||
mcp_servers?: string[];
|
||||
mcp_access_groups?: string[];
|
||||
mcp_tool_permissions?: Record<string, string[]>;
|
||||
agents?: string[];
|
||||
agent_access_groups?: string[];
|
||||
} = {};
|
||||
const mcp = values.allowed_mcp_servers_and_groups;
|
||||
if (mcp && (mcp.servers?.length > 0 || mcp.accessGroups?.length > 0)) {
|
||||
if (mcp.servers?.length) objPerm.mcp_servers = mcp.servers;
|
||||
if (mcp.accessGroups?.length) objPerm.mcp_access_groups = mcp.accessGroups;
|
||||
}
|
||||
if (values.mcp_tool_permissions && Object.keys(values.mcp_tool_permissions).length > 0) {
|
||||
objPerm.mcp_tool_permissions = values.mcp_tool_permissions;
|
||||
}
|
||||
const agents = values.allowed_agents_and_groups;
|
||||
if (agents && (agents.agents?.length > 0 || agents.accessGroups?.length > 0)) {
|
||||
if (agents.agents?.length) objPerm.agents = agents.agents;
|
||||
if (agents.accessGroups?.length) objPerm.agent_access_groups = agents.accessGroups;
|
||||
}
|
||||
return Object.keys(objPerm).length > 0 ? objPerm : undefined;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!customer || !accessToken) return;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
const {
|
||||
allowed_mcp_servers_and_groups,
|
||||
allowed_agents_and_groups,
|
||||
mcp_tool_permissions,
|
||||
...rest
|
||||
} = values;
|
||||
const object_permission = buildObjectPermission(values);
|
||||
const updated: Customer = {
|
||||
...customer,
|
||||
...rest,
|
||||
...(object_permission ? { object_permission } : {}),
|
||||
};
|
||||
await customerUpdateCall(accessToken, updated);
|
||||
setCustomer(updated);
|
||||
onUpdate(updated);
|
||||
} catch (error) {
|
||||
console.error("Validation failed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canEdit = userRole === "Admin" || userRole === "Org Admin";
|
||||
|
||||
if (!customer && !initialCustomer) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Button type="text" icon={<ArrowLeftIcon className="h-4 w-4" />} onClick={onClose} className="mb-4">
|
||||
Back to Customers
|
||||
</Button>
|
||||
<Text>Customer not found.</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const c = customer ?? initialCustomer!;
|
||||
const displayName = c.alias || c.user_id;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftIcon className="h-4 w-4" />}
|
||||
onClick={onClose}
|
||||
className="mb-4"
|
||||
>
|
||||
Back to Customers
|
||||
</Button>
|
||||
<Title>{displayName}</Title>
|
||||
<div className="flex items-center gap-2">
|
||||
<Text className="text-gray-500 font-mono text-sm">{c.user_id}</Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={copied ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||||
onClick={copyToClipboard}
|
||||
className={copied ? "text-green-600" : "text-gray-500 hover:text-gray-700"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as "overview" | "settings")}
|
||||
className="mb-4"
|
||||
items={[
|
||||
{
|
||||
key: "overview",
|
||||
label: "Overview",
|
||||
children: (
|
||||
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
|
||||
<Card>
|
||||
<Text>Spend (USD)</Text>
|
||||
<div className="mt-2">
|
||||
<Title>${formatNumberWithCommas(c.spend, 4)}</Title>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Budget (USD)</Text>
|
||||
<div className="mt-2">
|
||||
<Text>
|
||||
{c.litellm_budget_table?.max_budget != null
|
||||
? `$${formatNumberWithCommas(c.litellm_budget_table.max_budget, 4)}`
|
||||
: "No limit"}
|
||||
</Text>
|
||||
{c.litellm_budget_table?.budget_duration && (
|
||||
<Text className="text-gray-500 block">Duration: {c.litellm_budget_table.budget_duration}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Default Model</Text>
|
||||
<div className="mt-2">
|
||||
{c.default_model ? (
|
||||
<Badge color="gray">{c.default_model}</Badge>
|
||||
) : (
|
||||
<Text className="text-gray-500">—</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Region</Text>
|
||||
<div className="mt-2">
|
||||
<Text>{c.allowed_model_region ? c.allowed_model_region.toUpperCase() : "Any"}</Text>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Status</Text>
|
||||
<div className="mt-2">
|
||||
{c.blocked ? (
|
||||
<Badge color="red">Blocked</Badge>
|
||||
) : (
|
||||
<Badge color="green">Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={{
|
||||
object_permission_id: "",
|
||||
vector_stores: [],
|
||||
mcp_servers: c.object_permission?.mcp_servers ?? [],
|
||||
mcp_access_groups: c.object_permission?.mcp_access_groups ?? [],
|
||||
mcp_tool_permissions: c.object_permission?.mcp_tool_permissions ?? {},
|
||||
agents: c.object_permission?.agents ?? [],
|
||||
agent_access_groups: c.object_permission?.agent_access_groups ?? [],
|
||||
}}
|
||||
variant="card"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</Grid>
|
||||
),
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
key: "settings",
|
||||
label: "Settings",
|
||||
children: (
|
||||
<Card className="overflow-y-auto max-h-[65vh]">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>Customer Settings</Title>
|
||||
</div>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
<Form.Item label="Customer ID">
|
||||
<Input value={c.user_id} disabled className="font-mono bg-gray-50" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Spend (USD)">
|
||||
<Input value={c.spend.toFixed(4)} disabled className="bg-gray-50" />
|
||||
</Form.Item>
|
||||
<CustomerFormFields form={form} mode="edit" />
|
||||
|
||||
<div className="pt-6 mt-6 border-t border-gray-200">
|
||||
<Text className="font-semibold text-gray-900 block mb-3">MCP Servers / Access Groups</Text>
|
||||
<Form.Item
|
||||
name="allowed_mcp_servers_and_groups"
|
||||
help="Select MCP servers or access groups this customer can access"
|
||||
>
|
||||
<MCPServerSelector
|
||||
onChange={(val: any) => form.setFieldValue("allowed_mcp_servers_and_groups", val)}
|
||||
value={form.getFieldValue("allowed_mcp_servers_and_groups")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select MCP servers or access groups (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="mcp_tool_permissions" initialValue={{}} hidden>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.allowed_mcp_servers_and_groups !== currentValues.allowed_mcp_servers_and_groups ||
|
||||
prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<div className="mb-6">
|
||||
<MCPToolPermissions
|
||||
accessToken={accessToken || ""}
|
||||
selectedServers={form.getFieldValue("allowed_mcp_servers_and_groups")?.servers || []}
|
||||
toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}}
|
||||
onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 mt-6 border-t border-gray-200">
|
||||
<Text className="font-semibold text-gray-900 block mb-3">Agents / Access Groups</Text>
|
||||
<Form.Item
|
||||
name="allowed_agents_and_groups"
|
||||
help="Select agents or access groups this customer can access"
|
||||
>
|
||||
<AgentSelector
|
||||
onChange={(val: any) => form.setFieldValue("allowed_agents_and_groups", val)}
|
||||
value={form.getFieldValue("allowed_agents_and_groups")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select agents or access groups (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item className="mt-6 mb-0">
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerInfo;
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import React from "react";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
import { Filter, RotateCcw, Search } from "lucide-react";
|
||||
import { Select } from "antd";
|
||||
|
||||
interface CustomersFiltersProps {
|
||||
filters: {
|
||||
user_id: string;
|
||||
alias: string;
|
||||
blocked: string;
|
||||
region: string;
|
||||
};
|
||||
showFilters: boolean;
|
||||
onToggleFilters: (show: boolean) => void;
|
||||
onChange: (key: string, value: string) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const CustomersFilters: React.FC<CustomersFiltersProps> = ({
|
||||
filters,
|
||||
showFilters,
|
||||
onToggleFilters,
|
||||
onChange,
|
||||
onReset,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<TextInput
|
||||
placeholder="Search by Customer ID or Name..."
|
||||
value={filters.user_id || filters.alias}
|
||||
onChange={(e) => onChange("user_id", e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onToggleFilters(!showFilters)}
|
||||
icon={Filter}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onReset}
|
||||
icon={RotateCcw}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="flex items-center gap-4 p-3 bg-gray-50 rounded-md border border-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium text-gray-600">Status:</label>
|
||||
<Select
|
||||
value={filters.blocked || "all"}
|
||||
onChange={(value) => onChange("blocked", value)}
|
||||
style={{ width: 120 }}
|
||||
size="small"
|
||||
>
|
||||
<Select.Option value="">All</Select.Option>
|
||||
<Select.Option value="active">Active</Select.Option>
|
||||
<Select.Option value="blocked">Blocked</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium text-gray-600">Region:</label>
|
||||
<Select
|
||||
value={filters.region || "all"}
|
||||
onChange={(value) => onChange("region", value)}
|
||||
style={{ width: 120 }}
|
||||
size="small"
|
||||
>
|
||||
<Select.Option value="">All</Select.Option>
|
||||
<Select.Option value="us">US</Select.Option>
|
||||
<Select.Option value="eu">EU</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomersFilters;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import React from "react";
|
||||
import { Tab, TabGroup, TabList, TabPanels, Text } from "@tremor/react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface CustomersHeaderTabsProps {
|
||||
lastRefreshed: string;
|
||||
onRefresh: () => void;
|
||||
userRole: string | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const CustomersHeaderTabs: React.FC<CustomersHeaderTabsProps> = ({
|
||||
lastRefreshed,
|
||||
onRefresh,
|
||||
userRole,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<TabGroup className="w-full">
|
||||
<div className="flex items-center justify-between gap-4 mb-2">
|
||||
<TabList className="mt-2">
|
||||
<Tab>Your Customers</Tab>
|
||||
{(userRole === "Admin" || userRole === "Org Admin") && (
|
||||
<Tab>Customer Settings</Tab>
|
||||
)}
|
||||
</TabList>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 shrink-0">
|
||||
<Text>Last Refreshed: {lastRefreshed}</Text>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TabPanels>{children}</TabPanels>
|
||||
</TabGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomersHeaderTabs;
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Badge,
|
||||
Text,
|
||||
} from "@tremor/react";
|
||||
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { Tooltip } from "antd";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import type { Customer } from "@/app/(dashboard)/customers/types";
|
||||
|
||||
interface CustomersTableProps {
|
||||
customers: Customer[];
|
||||
userRole: string | null;
|
||||
onEdit: (customer: Customer) => void;
|
||||
onDelete: (customer: Customer) => void;
|
||||
onViewInfo: (customer: Customer) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const CustomersTable: React.FC<CustomersTableProps> = ({
|
||||
customers,
|
||||
userRole,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewInfo,
|
||||
isLoading,
|
||||
}) => {
|
||||
const truncateId = (id: string) => {
|
||||
if (id.length <= 10) return id;
|
||||
return id.substring(0, 7) + "...";
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Text>Loading customers...</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Customer Name</TableHeaderCell>
|
||||
<TableHeaderCell>Customer ID</TableHeaderCell>
|
||||
<TableHeaderCell>Spend (USD)</TableHeaderCell>
|
||||
<TableHeaderCell>Budget (USD)</TableHeaderCell>
|
||||
<TableHeaderCell>Default Model</TableHeaderCell>
|
||||
<TableHeaderCell>Region</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
{(userRole === "Admin" || userRole === "Org Admin") && (
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{customers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-12">
|
||||
<Text>No customers found.</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
customers.map((customer) => (
|
||||
<TableRow key={customer.user_id}>
|
||||
<TableCell>{customer.alias || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title={customer.user_id}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100"
|
||||
onClick={() => onViewInfo(customer)}
|
||||
>
|
||||
{truncateId(customer.user_id)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>{formatNumberWithCommas(customer.spend, 4)}</TableCell>
|
||||
<TableCell>
|
||||
{customer.litellm_budget_table?.max_budget !== null &&
|
||||
customer.litellm_budget_table?.max_budget !== undefined
|
||||
? customer.litellm_budget_table.max_budget.toString()
|
||||
: "No limit"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{customer.default_model ? (
|
||||
<Badge color="gray">{customer.default_model}</Badge>
|
||||
) : (
|
||||
<Text>—</Text>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{customer.allowed_model_region ? (
|
||||
<Text>{customer.allowed_model_region.toUpperCase()}</Text>
|
||||
) : (
|
||||
<Text>—</Text>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{customer.blocked ? (
|
||||
<Badge color="red">Blocked</Badge>
|
||||
) : (
|
||||
<Badge color="green">Active</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
{(userRole === "Admin" || userRole === "Org Admin") && (
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => onEdit(customer)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => onDelete(customer)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomersTable;
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Modal, Form, Input } from "antd";
|
||||
import { Accordion, AccordionBody, AccordionHeader } from "@tremor/react";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Tooltip } from "antd";
|
||||
import type { NewCustomerData } from "@/app/(dashboard)/customers/types";
|
||||
import CustomerFormFields from "@/app/(dashboard)/customers/components/CustomerFormFields";
|
||||
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
|
||||
import AgentSelector from "@/components/agent_management/AgentSelector";
|
||||
import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { customerCreateCall, fetchMCPAccessGroups } from "@/components/networking";
|
||||
|
||||
interface CreateCustomerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (customer?: NewCustomerData) => void;
|
||||
}
|
||||
|
||||
const CreateCustomerModal: React.FC<CreateCustomerModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreate,
|
||||
}) => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMCPAccessGroups = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
await fetchMCPAccessGroups(accessToken);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch MCP access groups:", error);
|
||||
}
|
||||
};
|
||||
if (isOpen) loadMCPAccessGroups();
|
||||
}, [accessToken, isOpen]);
|
||||
|
||||
const buildObjectPermission = (values: Record<string, any>) => {
|
||||
const objPerm: {
|
||||
mcp_servers?: string[];
|
||||
mcp_access_groups?: string[];
|
||||
mcp_tool_permissions?: Record<string, string[]>;
|
||||
agents?: string[];
|
||||
agent_access_groups?: string[];
|
||||
} = {};
|
||||
const mcp = values.allowed_mcp_servers_and_groups;
|
||||
if (mcp && (mcp.servers?.length > 0 || mcp.accessGroups?.length > 0)) {
|
||||
if (mcp.servers?.length) objPerm.mcp_servers = mcp.servers;
|
||||
if (mcp.accessGroups?.length) objPerm.mcp_access_groups = mcp.accessGroups;
|
||||
}
|
||||
if (values.mcp_tool_permissions && Object.keys(values.mcp_tool_permissions).length > 0) {
|
||||
objPerm.mcp_tool_permissions = values.mcp_tool_permissions;
|
||||
}
|
||||
const agents = values.allowed_agents_and_groups;
|
||||
if (agents && (agents.agents?.length > 0 || agents.accessGroups?.length > 0)) {
|
||||
if (agents.agents?.length) objPerm.agents = agents.agents;
|
||||
if (agents.accessGroups?.length) objPerm.agent_access_groups = agents.accessGroups;
|
||||
}
|
||||
return Object.keys(objPerm).length > 0 ? objPerm : undefined;
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
const { allowed_mcp_servers_and_groups, allowed_agents_and_groups, mcp_tool_permissions, ...rest } = values;
|
||||
const payload: any = { ...rest };
|
||||
const object_permission = buildObjectPermission(values);
|
||||
if (object_permission) payload.object_permission = object_permission;
|
||||
await customerCreateCall(accessToken, payload);
|
||||
form.resetFields();
|
||||
onCreate();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Validation failed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Create New Customer"
|
||||
open={isOpen}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={loading}
|
||||
okText="Create Customer"
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="mt-4">
|
||||
<Form.Item
|
||||
label="User ID"
|
||||
name="user_id"
|
||||
rules={[{ required: true, message: "Please enter a user ID" }]}
|
||||
>
|
||||
<Input placeholder="e.g. customer-007" />
|
||||
</Form.Item>
|
||||
|
||||
<CustomerFormFields form={form} mode="create" />
|
||||
|
||||
<Form.Item label="Metadata (JSON)" name="metadata" initialValue="{}">
|
||||
<Input.TextArea rows={3} className="font-mono" placeholder="{}" />
|
||||
</Form.Item>
|
||||
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
<b>MCP Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed MCP Servers{" "}
|
||||
<Tooltip title="Select which MCP servers or access groups this customer can access">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_mcp_servers_and_groups"
|
||||
initialValue={{ servers: [], accessGroups: [] }}
|
||||
className="mt-4"
|
||||
help="Select MCP servers or access groups this customer can access"
|
||||
>
|
||||
<MCPServerSelector
|
||||
onChange={(val: any) => form.setFieldValue("allowed_mcp_servers_and_groups", val)}
|
||||
value={form.getFieldValue("allowed_mcp_servers_and_groups")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select MCP servers or access groups (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="mcp_tool_permissions" initialValue={{}} hidden>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.allowed_mcp_servers_and_groups !== currentValues.allowed_mcp_servers_and_groups ||
|
||||
prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<div className="mt-6">
|
||||
<MCPToolPermissions
|
||||
accessToken={accessToken || ""}
|
||||
selectedServers={form.getFieldValue("allowed_mcp_servers_and_groups")?.servers || []}
|
||||
toolPermissions={form.getFieldValue("mcp_tool_permissions") || {}}
|
||||
onChange={(toolPerms) => form.setFieldsValue({ mcp_tool_permissions: toolPerms })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
<b>Agent Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed Agents{" "}
|
||||
<Tooltip title="Select which agents or access groups this customer can access">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_agents_and_groups"
|
||||
initialValue={{ agents: [], accessGroups: [] }}
|
||||
className="mt-4"
|
||||
help="Select agents or access groups this customer can access"
|
||||
>
|
||||
<AgentSelector
|
||||
onChange={(val: any) => form.setFieldValue("allowed_agents_and_groups", val)}
|
||||
value={form.getFieldValue("allowed_agents_and_groups")}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select agents or access groups (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateCustomerModal;
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Modal, Form, Input, InputNumber, Select as AntSelect, Switch } from "antd";
|
||||
import type { Customer } from "@/app/(dashboard)/customers/types";
|
||||
|
||||
interface CustomerInfoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
customer: Customer | null;
|
||||
onSave: (customer: Customer) => void;
|
||||
}
|
||||
|
||||
const CustomerInfoModal: React.FC<CustomerInfoModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
customer,
|
||||
onSave,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (customer) {
|
||||
form.setFieldsValue({
|
||||
...customer,
|
||||
max_budget: customer.litellm_budget_table?.max_budget,
|
||||
budget_duration: customer.litellm_budget_table?.budget_duration,
|
||||
});
|
||||
}
|
||||
}, [customer, form]);
|
||||
|
||||
const handleOk = async () => {
|
||||
if (!customer) return;
|
||||
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
onSave({
|
||||
...customer,
|
||||
...values,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Validation failed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!customer) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Customer Details"
|
||||
open={isOpen}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={loading}
|
||||
okText="Save Changes"
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
className="mt-4"
|
||||
>
|
||||
<Form.Item label="Customer ID">
|
||||
<Input
|
||||
value={customer.user_id}
|
||||
disabled
|
||||
className="font-mono bg-gray-50"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Alias"
|
||||
name="alias"
|
||||
>
|
||||
<Input placeholder="Customer alias" />
|
||||
</Form.Item>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Item label="Spend (USD)">
|
||||
<Input
|
||||
value={customer.spend.toFixed(4)}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Max Budget"
|
||||
name="max_budget"
|
||||
>
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
placeholder="No limit"
|
||||
min={0}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Budget ID"
|
||||
name="budget_id"
|
||||
>
|
||||
<Input placeholder="e.g. free_tier" />
|
||||
</Form.Item>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Item
|
||||
label="Default Model"
|
||||
name="default_model"
|
||||
>
|
||||
<AntSelect placeholder="None">
|
||||
<AntSelect.Option value="">None</AntSelect.Option>
|
||||
<AntSelect.Option value="gpt-4o">gpt-4o</AntSelect.Option>
|
||||
<AntSelect.Option value="gpt-4o-mini">gpt-4o-mini</AntSelect.Option>
|
||||
<AntSelect.Option value="gpt-4-turbo">gpt-4-turbo</AntSelect.Option>
|
||||
<AntSelect.Option value="claude-3-sonnet">claude-3-sonnet</AntSelect.Option>
|
||||
<AntSelect.Option value="claude-3-opus">claude-3-opus</AntSelect.Option>
|
||||
<AntSelect.Option value="claude-3-haiku">claude-3-haiku</AntSelect.Option>
|
||||
</AntSelect>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Allowed Region"
|
||||
name="allowed_model_region"
|
||||
>
|
||||
<AntSelect placeholder="Any region">
|
||||
<AntSelect.Option value="">Any region</AntSelect.Option>
|
||||
<AntSelect.Option value="us">US</AntSelect.Option>
|
||||
<AntSelect.Option value="eu">EU</AntSelect.Option>
|
||||
</AntSelect>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Budget Duration"
|
||||
name="budget_duration"
|
||||
>
|
||||
<Input placeholder="e.g. 30d, 24h" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Blocked"
|
||||
name="blocked"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch />
|
||||
<span className="text-sm text-gray-500">
|
||||
{form.getFieldValue("blocked")
|
||||
? "This customer is currently blocked from making requests"
|
||||
: "This customer can make requests normally"}
|
||||
</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerInfoModal;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import React from "react";
|
||||
import { Modal } from "antd";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
interface DeleteCustomerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
customerName: string;
|
||||
customerId: string;
|
||||
}
|
||||
|
||||
const DeleteCustomerModal: React.FC<DeleteCustomerModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
customerName,
|
||||
customerId,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
<span>Delete Customer</span>
|
||||
</div>
|
||||
}
|
||||
open={isOpen}
|
||||
onOk={onConfirm}
|
||||
onCancel={onClose}
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="Cancel"
|
||||
>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-gray-900 mb-2">
|
||||
Are you sure you want to delete this customer?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
This will permanently delete{" "}
|
||||
<span className="font-medium text-gray-700">
|
||||
{customerName || customerId}
|
||||
</span>{" "}
|
||||
<span className="text-gray-400">({customerId})</span>. This action
|
||||
cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteCustomerModal;
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { allEndUsersCall } from "@/components/networking";
|
||||
|
||||
interface UseFetchCustomersProps {
|
||||
setCustomers: (customers: any[]) => void;
|
||||
}
|
||||
|
||||
const useFetchCustomers = ({ setCustomers }: UseFetchCustomersProps) => {
|
||||
const [lastRefreshed, setLastRefreshed] = useState(
|
||||
new Date().toLocaleString("en-US")
|
||||
);
|
||||
|
||||
const onRefreshClick = useCallback(
|
||||
async (accessToken: string | null) => {
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
const data = await allEndUsersCall(accessToken);
|
||||
if (data) {
|
||||
setCustomers(data);
|
||||
}
|
||||
setLastRefreshed(new Date().toLocaleString("en-US"));
|
||||
} catch (error) {
|
||||
console.error("Error fetching customers:", error);
|
||||
}
|
||||
},
|
||||
[setCustomers]
|
||||
);
|
||||
|
||||
return {
|
||||
lastRefreshed,
|
||||
onRefreshClick,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFetchCustomers;
|
||||
45
ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx
Normal file
45
ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import CustomersView from "@/app/(dashboard)/customers/CustomersView";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useEffect, useState } from "react";
|
||||
import { allEndUsersCall, type Customer } from "@/components/networking";
|
||||
|
||||
const CustomersPage = () => {
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCustomers = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await allEndUsersCall(accessToken);
|
||||
if (response) {
|
||||
setCustomers(Array.isArray(response) ? response : []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching customers:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCustomers();
|
||||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<CustomersView
|
||||
customers={customers}
|
||||
setCustomers={setCustomers}
|
||||
accessToken={accessToken}
|
||||
userID={userId}
|
||||
userRole={userRole}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomersPage;
|
||||
36
ui/litellm-dashboard/src/app/(dashboard)/customers/types.ts
Normal file
36
ui/litellm-dashboard/src/app/(dashboard)/customers/types.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
export interface CustomerObjectPermission {
|
||||
mcp_servers?: string[];
|
||||
mcp_access_groups?: string[];
|
||||
mcp_tool_permissions?: Record<string, string[]>;
|
||||
agents?: string[];
|
||||
agent_access_groups?: string[];
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
user_id: string;
|
||||
alias: string | null;
|
||||
spend: number;
|
||||
allowed_model_region: string | null;
|
||||
default_model: string | null;
|
||||
budget_id: string | null;
|
||||
blocked: boolean;
|
||||
max_budget?: number | null;
|
||||
budget_duration?: string | null;
|
||||
litellm_budget_table?: {
|
||||
max_budget: number | null;
|
||||
budget_duration: string | null;
|
||||
} | null;
|
||||
object_permission?: CustomerObjectPermission | null;
|
||||
}
|
||||
|
||||
export interface NewCustomerData {
|
||||
user_id: string;
|
||||
alias?: string;
|
||||
max_budget?: string;
|
||||
budget_id?: string;
|
||||
default_model?: string;
|
||||
allowed_model_region?: string;
|
||||
budget_duration?: string;
|
||||
metadata?: string;
|
||||
object_permission?: CustomerObjectPermission;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import APIReferenceView from "@/app/(dashboard)/api-reference/APIReferenceView";
|
|||
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
|
||||
import OldModelDashboard from "@/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView";
|
||||
import PlaygroundPage from "@/app/(dashboard)/playground/page";
|
||||
import CustomersPage from "@/app/(dashboard)/customers/page";
|
||||
import AdminPanel from "@/components/AdminPanel";
|
||||
import AgentsPanel from "@/components/agents";
|
||||
import BudgetPanel from "@/components/budgets/budget_panel";
|
||||
|
|
@ -459,6 +460,8 @@ function CreateKeyPageContent() {
|
|||
premiumUser={premiumUser}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
) : page == "customers" ? (
|
||||
<CustomersPage />
|
||||
) : page == "organizations" ? (
|
||||
<Organizations
|
||||
organizations={organizations}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
BgColorsOutlined,
|
||||
BlockOutlined,
|
||||
BookOutlined,
|
||||
ContactsOutlined,
|
||||
CreditCardOutlined,
|
||||
DatabaseOutlined,
|
||||
ExperimentOutlined,
|
||||
|
|
@ -172,6 +173,17 @@ const menuGroups: MenuGroup[] = [
|
|||
label: "Teams",
|
||||
icon: <TeamOutlined />,
|
||||
},
|
||||
{
|
||||
key: "customers",
|
||||
page: "customers",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Customers <NewBadge />
|
||||
</span>
|
||||
),
|
||||
icon: <ContactsOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "organizations",
|
||||
page: "organizations",
|
||||
|
|
|
|||
|
|
@ -194,6 +194,20 @@ export interface Organization {
|
|||
};
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
user_id: string;
|
||||
alias: string | null;
|
||||
spend: number;
|
||||
allowed_model_region: string | null;
|
||||
default_model: string | null;
|
||||
budget_id: string | null;
|
||||
blocked: boolean;
|
||||
litellm_budget_table?: {
|
||||
max_budget: number | null;
|
||||
budget_duration: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface CredentialItem {
|
||||
credential_name: string;
|
||||
credential_values: any;
|
||||
|
|
@ -2511,6 +2525,97 @@ export const allEndUsersCall = async (accessToken: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const customerListCall = async (accessToken: string) => {
|
||||
return allEndUsersCall(accessToken);
|
||||
};
|
||||
|
||||
export const customerCreateCall = async (accessToken: string, data: any) => {
|
||||
try {
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/customer/new` : `/customer/new`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log("Customer created:", responseData);
|
||||
return responseData;
|
||||
} catch (error) {
|
||||
console.error("Failed to create customer:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const customerUpdateCall = async (accessToken: string, data: any) => {
|
||||
try {
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/customer/update` : `/customer/update`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log("Customer updated:", responseData);
|
||||
return responseData;
|
||||
} catch (error) {
|
||||
console.error("Failed to update customer:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const customerDeleteCall = async (accessToken: string, userId: string) => {
|
||||
try {
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/customer/delete` : `/customer/delete`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ user_ids: [userId] }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log("Customer deleted:", responseData);
|
||||
return responseData;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete customer:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const userFilterUICall = async (accessToken: string, params: URLSearchParams) => {
|
||||
try {
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/user/filter/ui` : `/user/filter/ui`;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue