diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.tsx
index 977258c4dcf..d668890aba8 100644
--- a/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.tsx
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.tsx
@@ -1,31 +1,32 @@
-import { useProjectDetails } from "@/app/(dashboard)/hooks/projects/useProjectDetails";
-import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
+import { useMemo, useState } from "react";
+import { toast } from "sonner";
import {
- Button,
- Card,
- Col,
- Descriptions,
- Empty,
- Flex,
- Layout,
- Progress,
- Row,
- Spin,
- Tag,
- theme,
- Typography,
-} from "antd";
-import { Loader2 as LoadingOutlined } from "lucide-react";
+ ArrowLeft,
+ Copy,
+ DollarSign,
+ Edit,
+ Key,
+ Loader2,
+ Users,
+} from "lucide-react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { BarChart } from "@tremor/react";
-import { ArrowLeftIcon, DollarSignIcon, EditIcon, KeyIcon, UsersIcon } from "lucide-react";
-import { useMemo, useState } from "react";
+import { useProjectDetails } from "@/app/(dashboard)/hooks/projects/useProjectDetails";
+import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Progress } from "@/components/ui/progress";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import { EditProjectModal } from "./ProjectModals/EditProjectModal";
-const { Title, Text } = Typography;
-const { Content } = Layout;
-
interface TeamInfoShape {
team_id: string;
team_alias?: string;
@@ -41,20 +42,50 @@ interface ProjectDetailProps {
onBack: () => void;
}
+function EmptyState({ description }: { description: string }) {
+ return (
+
+ );
+}
+
+function CopyableId({ value }: { value: string }) {
+ return (
+
+
+
+
+
+ Copy
+
+
+ );
+}
+
export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const { data: project, isLoading } = useProjectDetails(projectId);
const { data: teamData } = useTeam(project?.team_id ?? undefined);
// teamInfoCall returns { team_id, team_info: {...}, keys, team_memberships }
- const teamInfo: TeamInfoShape | undefined = ((teamData as unknown as { team_info?: TeamInfoShape })?.team_info ??
- teamData) as TeamInfoShape | undefined;
- const { token } = theme.useToken();
+ const teamInfo: TeamInfoShape | undefined = ((teamData as unknown as {
+ team_info?: TeamInfoShape;
+ })?.team_info ?? teamData) as TeamInfoShape | undefined;
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const spend = project?.spend ?? 0;
const maxBudget = project?.litellm_budget_table?.max_budget ?? null;
const hasLimit = maxBudget != null && maxBudget > 0;
const spendPercent = hasLimit ? Math.min((spend / maxBudget) * 100, 100) : 0;
- const spendColor = spendPercent >= 90 ? "#f5222d" : spendPercent >= 70 ? "#faad14" : "#52c41a";
const modelSpendData = useMemo(() => {
const raw = (project?.model_spend ?? {}) as Record;
@@ -65,263 +96,257 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
if (isLoading) {
return (
-
-
- } size="large" />
-
-
+
);
}
if (!project) {
return (
-
- } onClick={onBack} type="text" style={{ marginBottom: 16 }} />
-
-
+
);
}
return (
-
+
{/* Header */}
-
-
-
} onClick={onBack} type="text" />
+
+
+
-
-
+
+
{project.project_alias ?? project.project_id}
-
- {project.blocked ? "Blocked" : "Active"}
-
-
- ID: {project.project_id}
-
+
+
+ {project.blocked ? "Blocked" : "Active"}
+
+
+
+ ID:
+
-
} onClick={() => setIsEditModalVisible(true)}>
+
{/* Project Details */}
-
-
-
- {project.description || "\u2014"}
-
+
+ Project Details
+
+
+
- Description
+ - {project.description || "\u2014"}
+
+
+
- Created
+ -
{new Date(project.created_at).toLocaleString()}
{project.created_by && (
-
- {"by"}
+ <>
+ by
-
+ >
)}
-
-
+
+
+
+
- Last Updated
+ -
{new Date(project.updated_at).toLocaleString()}
{project.updated_by && (
-
- {"by"}
+ <>
+ by
-
+ >
)}
-
-
-
-
+
+
+
+
{/* Spend / Budget */}
-
-
-
-
- Budget
-
- }
- style={{ height: "100%" }}
- >
-
+
+
+
+
+ Budget
+
+
+
+
+ ${spend.toFixed(2)}
+
+
+
+ {hasLimit
+ ? `of $${maxBudget.toFixed(2)} budget`
+ : "No budget limit"}
+
+
+ {hasLimit && (
-
- ${spend.toFixed(2)}
-
-
-
{hasLimit ? `of $${maxBudget.toFixed(2)} budget` : "No budget limit"}
+
+
+ {(Math.round(spendPercent * 10) / 10).toFixed(1)}% utilized
+
- {hasLimit && (
-
-
-
- {(Math.round(spendPercent * 10) / 10).toFixed(1)}% utilized
-
-
- )}
-
-
-
-
-
- {modelSpendData.length > 0 ? (
- `$${value.toFixed(4)}`}
- yAxisWidth={140}
- showLegend={false}
- style={{ height: Math.max(modelSpendData.length * 40, 120) }}
- />
- ) : (
-
)}
-
-
-
+
+
+
+ Spend by Model
+ {modelSpendData.length > 0 ? (
+ `$${value.toFixed(4)}`}
+ yAxisWidth={140}
+ showLegend={false}
+ style={{ height: Math.max(modelSpendData.length * 40, 120) }}
+ />
+ ) : (
+
+ )}
+
+
{/* Keys & Team */}
-
-
-
-
- Keys
-
- }
- style={{ height: "100%" }}
- >
-
-
-
-
-
-
- Team
-
- }
- style={{ height: "100%" }}
- >
- {teamInfo ? (
- (() => {
- const teamBudget = teamInfo.max_budget ?? null;
- const teamSpend = teamInfo.spend ?? 0;
- const teamHasLimit = teamBudget != null && teamBudget > 0;
- const teamPercent = teamHasLimit ? Math.min((teamSpend / teamBudget) * 100, 100) : 0;
- const teamColor = teamPercent >= 90 ? "#f5222d" : teamPercent >= 70 ? "#faad14" : "#52c41a";
+
+
+
+
+ Keys
+
+
+
+
+
+
+ Team
+
+ {teamInfo ? (
+ (() => {
+ const teamBudget = teamInfo.max_budget ?? null;
+ const teamSpend = teamInfo.spend ?? 0;
+ const teamHasLimit = teamBudget != null && teamBudget > 0;
+ const teamPercent = teamHasLimit
+ ? Math.min((teamSpend / teamBudget) * 100, 100)
+ : 0;
- return (
-
- {/* Team name + ID */}
-
-
- {teamInfo.team_alias || teamInfo.team_id}
-
-
-
- ID:{" "}
-
- {teamInfo.team_id}
-
-
+ return (
+
+ {/* Team name + ID */}
+
+
+ {teamInfo.team_alias || teamInfo.team_id}
-
- {/* Models */}
-
-
- Models
-
- {(teamInfo.models?.length ?? 0) > 0 ? (
-
- {teamInfo.models?.map((m: string) => (
-
- {m}
-
- ))}
-
- ) : (
-
All models
- )}
+
+ ID:
+
- {/* Budget + Spend compact */}
-
-
-
- Spend
-
-
- ${teamSpend.toFixed(2)}
- {teamHasLimit ? (
-
- {" "}
- / ${teamBudget.toFixed(2)}
-
- ) : (
-
- {" "}
- (Unlimited)
-
- )}
-
-
- {teamHasLimit && (
-
- )}
+ {/* Models */}
+
+
+ Models
+ {(teamInfo.models?.length ?? 0) > 0 ? (
+
+ {teamInfo.models?.map((m: string) => (
+
+ {m}
+
+ ))}
+
+ ) : (
+
+ All models
+
+ )}
+
- {/* Members */}
-
-
- Members
-
- {teamInfo.members_with_roles?.length ?? 0}
-
-
- );
- })()
- ) : project.team_id ? (
-
- } size="small" />
-
- ) : (
-
- )}
-
-
-
+ {/* Budget + Spend compact */}
+
+
+
+ Spend
+
+
+ ${teamSpend.toFixed(2)}
+ {teamHasLimit ? (
+
+ {" "}
+ / ${teamBudget.toFixed(2)}
+
+ ) : (
+
+ {" "}
+ (Unlimited)
+
+ )}
+
+
+ {teamHasLimit && (
+
+ )}
+
+
+ {/* Members */}
+
+
+ Members
+
+
+ {teamInfo.members_with_roles?.length ?? 0}
+
+
+
+ );
+ })()
+ ) : project.team_id ? (
+
+
+
+ ) : (
+
+ )}
+
+
{/* Edit Modal */}
-
setIsEditModalVisible(false)} />
-
+ setIsEditModalVisible(false)}
+ />
+
);
}
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.test.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.test.tsx
index 12fd80179b0..4c18aab9fb2 100644
--- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.test.tsx
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.test.tsx
@@ -11,6 +11,17 @@ vi.mock("@/app/(dashboard)/hooks/projects/useCreateProject", () => ({
// Mock the form to keep tests focused on modal behavior
vi.mock("./ProjectBaseForm", () => ({
ProjectBaseForm: () => ,
+ emptyProjectFormValues: {
+ project_alias: "",
+ team_id: "",
+ description: "",
+ models: [],
+ max_budget: undefined,
+ isBlocked: false,
+ guardrails: undefined,
+ modelLimits: undefined,
+ metadata: undefined,
+ },
}));
describe("CreateProjectModal", () => {
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx
index 000a722a434..805ad4531d9 100644
--- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx
@@ -1,13 +1,15 @@
import { FolderPlus } from "lucide-react";
+import { useEffect } from "react";
+import { FormProvider, useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
+ DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import { Form } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import {
useCreateProject,
@@ -16,6 +18,7 @@ import {
import {
ProjectBaseForm,
ProjectFormValues,
+ emptyProjectFormValues,
} from "./ProjectBaseForm";
import { buildProjectApiParams } from "./projectFormUtils";
@@ -28,61 +31,71 @@ export function CreateProjectModal({
isOpen,
onClose,
}: CreateProjectModalProps) {
- const [form] = Form.useForm();
+ const form = useForm({
+ defaultValues: emptyProjectFormValues,
+ mode: "onSubmit",
+ });
const createMutation = useCreateProject();
- const handleSubmit = async () => {
- try {
- const values = await form.validateFields();
- const params: ProjectCreateParams = {
- ...buildProjectApiParams(values),
- team_id: values.team_id,
- };
-
- createMutation.mutate(params, {
- onSuccess: () => {
- MessageManager.success("Project created successfully");
- form.resetFields();
- onClose();
- },
- onError: (error) => {
- MessageManager.error(error.message || "Failed to create project");
- },
- });
- } catch (error) {
- console.error("Validation failed:", error);
- }
- };
+ useEffect(() => {
+ if (isOpen) form.reset(emptyProjectFormValues);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOpen]);
const handleCancel = () => {
- form.resetFields();
+ form.reset(emptyProjectFormValues);
onClose();
};
+ const onSubmit = form.handleSubmit((values) => {
+ const params: ProjectCreateParams = {
+ ...buildProjectApiParams(values),
+ team_id: values.team_id,
+ };
+ createMutation.mutate(params, {
+ onSuccess: () => {
+ MessageManager.success("Project created successfully");
+ form.reset(emptyProjectFormValues);
+ onClose();
+ },
+ onError: (error) => {
+ MessageManager.error(error.message || "Failed to create project");
+ },
+ });
+ });
+
return (
);
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.test.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.test.tsx
index 905dbcd985e..f4d9db0a458 100644
--- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.test.tsx
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.test.tsx
@@ -11,6 +11,17 @@ vi.mock("@/app/(dashboard)/hooks/projects/useUpdateProject", () => ({
vi.mock("./ProjectBaseForm", () => ({
ProjectBaseForm: () => ,
+ emptyProjectFormValues: {
+ project_alias: "",
+ team_id: "",
+ description: "",
+ models: [],
+ max_budget: undefined,
+ isBlocked: false,
+ guardrails: undefined,
+ modelLimits: undefined,
+ metadata: undefined,
+ },
}));
const mockProject: ProjectResponse = {
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.tsx
index abb080c7a32..d74c14ff9be 100644
--- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.tsx
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.tsx
@@ -1,21 +1,26 @@
import { useEffect } from "react";
+import { FormProvider, useForm } from "react-hook-form";
+import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
+ DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import { Form } from "antd";
-import { Save } from "lucide-react";
import MessageManager from "@/components/molecules/message_manager";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import {
useUpdateProject,
ProjectUpdateParams,
} from "@/app/(dashboard)/hooks/projects/useUpdateProject";
-import { ProjectBaseForm, ProjectFormValues } from "./ProjectBaseForm";
+import {
+ ProjectBaseForm,
+ ProjectFormValues,
+ emptyProjectFormValues,
+} from "./ProjectBaseForm";
import { buildProjectApiParams } from "./projectFormUtils";
interface EditProjectModalProps {
@@ -25,119 +30,128 @@ interface EditProjectModalProps {
onSuccess?: () => void;
}
+function buildDefaultsFromProject(
+ project: ProjectResponse,
+): ProjectFormValues {
+ const metadataObj = (project.metadata ?? {}) as Record;
+ const rpmLimits = (metadataObj.model_rpm_limit ?? {}) as Record<
+ string,
+ number
+ >;
+ const tpmLimits = (metadataObj.model_tpm_limit ?? {}) as Record<
+ string,
+ number
+ >;
+ const guardrails = (
+ Array.isArray(metadataObj.guardrails) ? metadataObj.guardrails : []
+ ) as string[];
+
+ const modelLimits: ProjectFormValues["modelLimits"] = [];
+ const allLimitModels = new Set([
+ ...Object.keys(rpmLimits),
+ ...Object.keys(tpmLimits),
+ ]);
+ for (const model of allLimitModels) {
+ modelLimits.push({
+ model,
+ rpm: rpmLimits[model],
+ tpm: tpmLimits[model],
+ });
+ }
+
+ const internalKeys = new Set([
+ "model_rpm_limit",
+ "model_tpm_limit",
+ "guardrails",
+ ]);
+ const metadata: ProjectFormValues["metadata"] = [];
+ for (const [key, value] of Object.entries(metadataObj)) {
+ if (!internalKeys.has(key)) {
+ metadata.push({ key, value: String(value) });
+ }
+ }
+
+ return {
+ project_alias: project.project_alias ?? "",
+ team_id: project.team_id ?? "",
+ description: project.description ?? "",
+ models: project.models ?? [],
+ max_budget: project.litellm_budget_table?.max_budget ?? undefined,
+ isBlocked: project.blocked,
+ guardrails: guardrails.length > 0 ? guardrails : undefined,
+ modelLimits: modelLimits.length > 0 ? modelLimits : undefined,
+ metadata: metadata.length > 0 ? metadata : undefined,
+ };
+}
+
export function EditProjectModal({
isOpen,
project,
onClose,
onSuccess,
}: EditProjectModalProps) {
- const [form] = Form.useForm();
+ const form = useForm({
+ defaultValues: emptyProjectFormValues,
+ mode: "onSubmit",
+ });
const updateMutation = useUpdateProject();
useEffect(() => {
if (isOpen && project) {
- const metadataObj = (project.metadata ?? {}) as Record;
- const rpmLimits = (metadataObj.model_rpm_limit ?? {}) as Record<
- string,
- number
- >;
- const tpmLimits = (metadataObj.model_tpm_limit ?? {}) as Record<
- string,
- number
- >;
- const guardrails = (
- Array.isArray(metadataObj.guardrails) ? metadataObj.guardrails : []
- ) as string[];
-
- const modelLimits: ProjectFormValues["modelLimits"] = [];
- const allLimitModels = new Set([
- ...Object.keys(rpmLimits),
- ...Object.keys(tpmLimits),
- ]);
- for (const model of allLimitModels) {
- modelLimits.push({
- model,
- rpm: rpmLimits[model],
- tpm: tpmLimits[model],
- });
- }
-
- const internalKeys = new Set([
- "model_rpm_limit",
- "model_tpm_limit",
- "guardrails",
- ]);
- const metadata: ProjectFormValues["metadata"] = [];
- for (const [key, value] of Object.entries(metadataObj)) {
- if (!internalKeys.has(key)) {
- metadata.push({ key, value: String(value) });
- }
- }
-
- form.setFieldsValue({
- project_alias: project.project_alias ?? "",
- team_id: project.team_id ?? "",
- description: project.description ?? "",
- models: project.models ?? [],
- max_budget: project.litellm_budget_table?.max_budget ?? undefined,
- isBlocked: project.blocked,
- guardrails: guardrails.length > 0 ? guardrails : undefined,
- modelLimits: modelLimits.length > 0 ? modelLimits : undefined,
- metadata: metadata.length > 0 ? metadata : undefined,
- });
+ form.reset(buildDefaultsFromProject(project));
}
- }, [isOpen, project, form]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOpen, project]);
- const handleSubmit = async () => {
- try {
- const values = await form.validateFields();
- const params: ProjectUpdateParams = {
- ...buildProjectApiParams(values),
- team_id: values.team_id,
- };
+ const onSubmit = form.handleSubmit((values) => {
+ const params: ProjectUpdateParams = {
+ ...buildProjectApiParams(values),
+ team_id: values.team_id,
+ };
- updateMutation.mutate(
- { projectId: project.project_id, params },
- {
- onSuccess: () => {
- MessageManager.success("Project updated successfully");
- onSuccess?.();
- onClose();
- },
- onError: (error) => {
- MessageManager.error(error.message || "Failed to update project");
- },
+ updateMutation.mutate(
+ { projectId: project.project_id, params },
+ {
+ onSuccess: () => {
+ MessageManager.success("Project updated successfully");
+ onSuccess?.();
+ onClose();
},
- );
- } catch (error) {
- console.error("Validation failed:", error);
- }
- };
+ onError: (error) => {
+ MessageManager.error(error.message || "Failed to update project");
+ },
+ },
+ );
+ });
return (
);
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.test.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.test.tsx
index d8532146566..7323683d2fd 100644
--- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.test.tsx
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.test.tsx
@@ -1,9 +1,13 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
+import { FormProvider, useForm } from "react-hook-form";
import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils";
-import { Form } from "antd";
-import { ProjectBaseForm, ProjectFormValues } from "./ProjectBaseForm";
+import {
+ ProjectBaseForm,
+ ProjectFormValues,
+ emptyProjectFormValues,
+} from "./ProjectBaseForm";
const mockUseTeams = vi.fn();
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
@@ -22,9 +26,21 @@ vi.mock("@/components/key_team_helpers/fetch_available_models_team_key", () => (
getModelDisplayName: (model: string) => model,
}));
-function FormWrapper() {
- const [form] = Form.useForm();
- return ;
+function FormWrapper({
+ defaults,
+}: {
+ defaults?: Partial;
+} = {}) {
+ const form = useForm({
+ defaultValues: { ...emptyProjectFormValues, ...defaults },
+ });
+ return (
+
+
+
+ );
}
describe("ProjectBaseForm", () => {
@@ -34,7 +50,7 @@ describe("ProjectBaseForm", () => {
it("should render", () => {
renderWithProviders();
- expect(screen.getByLabelText("Project Name")).toBeInTheDocument();
+ expect(screen.getByLabelText(/Project Name/)).toBeInTheDocument();
});
it("should show a 'Basic Information' section heading", () => {
@@ -44,7 +60,9 @@ describe("ProjectBaseForm", () => {
it("should show a Project Name input", () => {
renderWithProviders();
- expect(screen.getByPlaceholderText("e.g. Customer Support Bot")).toBeInTheDocument();
+ expect(
+ screen.getByPlaceholderText("e.g. Customer Support Bot"),
+ ).toBeInTheDocument();
});
it("should show a Team select", () => {
@@ -54,12 +72,13 @@ describe("ProjectBaseForm", () => {
it("should show a Description textarea", () => {
renderWithProviders();
- expect(screen.getByPlaceholderText("Describe the purpose of this project")).toBeInTheDocument();
+ expect(
+ screen.getByPlaceholderText("Describe the purpose of this project"),
+ ).toBeInTheDocument();
});
it("should show the models select as disabled when no team is selected", () => {
renderWithProviders();
- // The models select should be disabled — its placeholder indicates no team yet
expect(screen.getByText("Select a team first")).toBeInTheDocument();
});
@@ -73,8 +92,7 @@ describe("ProjectBaseForm", () => {
isLoading: false,
});
renderWithProviders();
- // The form label "Team" is associated with the combobox input inside the Select
- await user.click(screen.getByLabelText("Team"));
+ await user.click(screen.getByLabelText(/Team/));
await waitFor(() => {
expect(screen.getByText("Engineering")).toBeInTheDocument();
});
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.tsx
index 490f44b247b..6db9894e928 100644
--- a/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.tsx
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.tsx
@@ -1,28 +1,38 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import {
- Alert,
- Col,
- Collapse,
- Divider,
- Flex,
- Form,
- Input,
- InputNumber,
- Row,
- Select,
- Space,
- Switch,
- Typography,
- Button,
-} from "antd";
-import type { FormInstance } from "antd";
-import { Plus as PlusOutlined, MinusCircle as MinusCircleOutlined } from "lucide-react";
+ Controller,
+ useFieldArray,
+ useFormContext,
+ useWatch,
+} from "react-hook-form";
+import { ChevronDown, Minus, Plus, X } from "lucide-react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Team } from "../../key_team_helpers/key_list";
import { fetchTeamModels } from "../../organisms/create_key_button";
import { getModelDisplayName } from "../../key_team_helpers/fetch_available_models_team_key";
import { getGuardrailsList } from "@/components/networking";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
export interface ProjectFormValues {
project_alias: string;
@@ -36,19 +46,239 @@ export interface ProjectFormValues {
metadata?: { key: string; value: string }[];
}
-interface ProjectBaseFormProps {
- form: FormInstance;
+export const emptyProjectFormValues: ProjectFormValues = {
+ project_alias: "",
+ team_id: "",
+ description: "",
+ models: [],
+ max_budget: undefined,
+ isBlocked: false,
+ guardrails: undefined,
+ modelLimits: undefined,
+ metadata: undefined,
+};
+
+const ALL_TEAM_MODELS = "all-team-models";
+
+/**
+ * Multi-select rendered with shadcn Select + chip list below. Accepts a fixed
+ * list of options. Mirrors the canonical pattern in
+ * `AccessGroupBaseForm.tsx`.
+ */
+function MultiSelect({
+ value,
+ onChange,
+ options,
+ placeholder,
+ emptyText,
+ disabled,
+}: {
+ value: string[];
+ onChange: (next: string[]) => void;
+ options: { label: string; value: string }[];
+ placeholder: string;
+ emptyText: string;
+ disabled?: boolean;
+}) {
+ const selected = useMemo(() => value ?? [], [value]);
+ const remaining = useMemo(
+ () => options.filter((o) => !selected.includes(o.value)),
+ [options, selected],
+ );
+
+ return (
+
+
+ {selected.length > 0 && (
+
+ {selected.map((v) => {
+ const opt = options.find((o) => o.value === v);
+ return (
+
+ {opt?.label ?? v}
+
+
+ );
+ })}
+
+ )}
+
+ );
}
-export function ProjectBaseForm({
- form,
-}: ProjectBaseFormProps) {
+/**
+ * Free-form tag input. Renders a combobox with known options and accepts
+ * new entries on Enter / comma. Used for Guardrails.
+ */
+function TagsSelect({
+ value,
+ onChange,
+ options,
+ placeholder,
+}: {
+ value: string[];
+ onChange: (next: string[]) => void;
+ options: { label: string; value: string }[];
+ placeholder: string;
+}) {
+ const [draft, setDraft] = useState("");
+ const selected = value ?? [];
+ const remaining = options.filter((o) => !selected.includes(o.value));
+
+ const addDraft = () => {
+ const v = draft.trim();
+ if (!v) return;
+ if (selected.includes(v)) {
+ setDraft("");
+ return;
+ }
+ onChange([...selected, v]);
+ setDraft("");
+ };
+
+ return (
+
+
+ setDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === ",") {
+ e.preventDefault();
+ addDraft();
+ }
+ }}
+ onBlur={() => {
+ if (draft.trim()) addDraft();
+ }}
+ />
+ {remaining.length > 0 && (
+
+ )}
+
+ {selected.length > 0 && (
+
+ {selected.map((v) => (
+
+ {v}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+function SectionHeader({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Fields-only block — expected to be rendered inside a parent form managed
+ * by `react-hook-form` via a `FormProvider`. See `CreateProjectModal` /
+ * `EditProjectModal` for the integration.
+ */
+export function ProjectBaseForm() {
+ const {
+ control,
+ register,
+ setValue,
+ formState: { errors },
+ } = useFormContext();
+
const { accessToken, userId, userRole } = useAuthorized();
const { data: teams } = useTeams();
const [selectedTeam, setSelectedTeam] = useState(null);
const [modelsToPick, setModelsToPick] = useState([]);
const [guardrailsList, setGuardrailsList] = useState([]);
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+
+ const teamIdValue = useWatch({ control, name: "team_id" });
+ const isBlockedValue = useWatch({ control, name: "isBlocked" });
+
+ const {
+ fields: modelLimitFields,
+ append: appendModelLimit,
+ remove: removeModelLimit,
+ } = useFieldArray({ control, name: "modelLimits" as const });
+ const {
+ fields: metadataFields,
+ append: appendMetadata,
+ remove: removeMetadata,
+ } = useFieldArray({ control, name: "metadata" as const });
useEffect(() => {
const fetchGuardrails = async () => {
@@ -56,7 +286,7 @@ export function ProjectBaseForm({
try {
const response = await getGuardrailsList(accessToken);
const names = response.guardrails.map(
- (g: { guardrail_name: string }) => g.guardrail_name
+ (g: { guardrail_name: string }) => g.guardrail_name,
);
setGuardrailsList(names);
} catch (error) {
@@ -66,18 +296,17 @@ export function ProjectBaseForm({
fetchGuardrails();
}, [accessToken]);
- // Sync selectedTeam from form value (needed for edit mode pre-fill)
- const teamIdValue = Form.useWatch("team_id", form);
useEffect(() => {
if (teamIdValue && teams) {
const team = teams.find((t) => t.team_id === teamIdValue) ?? null;
if (team && team.team_id !== selectedTeam?.team_id) {
setSelectedTeam(team);
}
+ } else if (!teamIdValue && selectedTeam) {
+ setSelectedTeam(null);
}
- }, [teamIdValue, teams, selectedTeam?.team_id]);
+ }, [teamIdValue, teams, selectedTeam]);
- // Fetch team-scoped models when team selection changes
useEffect(() => {
if (userId && userRole && accessToken && selectedTeam) {
fetchTeamModels(userId, userRole, accessToken, selectedTeam.team_id).then(
@@ -93,346 +322,410 @@ export function ProjectBaseForm({
}
}, [selectedTeam, accessToken, userId, userRole]);
- const handleTeamChange = (teamId: string) => {
- const team = teams?.find((t) => t.team_id === teamId) ?? null;
- setSelectedTeam(team);
- form.setFieldValue("models", []);
- };
+ const teamOptions = (teams ?? []).map((team) => ({
+ label: team.team_alias || team.team_id,
+ value: team.team_id,
+ subLabel: team.team_id,
+ }));
+
+ const modelOptions = [
+ { label: "All Team Models", value: ALL_TEAM_MODELS },
+ ...modelsToPick.map((m) => ({
+ label: getModelDisplayName(m),
+ value: m,
+ })),
+ ];
+
+ const guardrailOptions = guardrailsList.map((name) => ({
+ label: name,
+ value: name,
+ }));
return (
-
+
+
+
+
+
+
+ $
+
+
+ v === "" || v === null || v === undefined
+ ? undefined
+ : Number(v),
+ min: { value: 0, message: "Must be \u2265 0" },
+ })}
+ />
+
+ {errors.max_budget && (
+
+ {errors.max_budget.message as string}
+
+ )}
+
+
{/* Advanced Settings */}
-
-
-
- Advanced Settings
-
- ),
- children: (
- <>
-
- Block Project
-
-
-
-
- prev.isBlocked !== cur.isBlocked}
- >
- {({ getFieldValue }) =>
- getFieldValue("isBlocked") ? (
-
- ) : null
- }
-
+
+
+
+
+
+
+
+
+ (
+
+ )}
+ />
+
+ {isBlockedValue && (
+
+
+ All API requests using keys under this project will be
+ rejected.
+
+
+ )}
-
+
-
+
+ (
+
+ )}
+ />
+
+ Select existing guardrails or enter new ones
+
+
+
+
+
+
+
+
+ {modelLimitFields.map((field, index) => {
+ const rowErrors = errors.modelLimits?.[index];
+ return (
+
+ );
+ })}
+
+
+
-
+
-
+
+
+ {metadataFields.map((field, index) => {
+ const rowErrors = errors.metadata?.[index];
+ return (
+
- Model-Specific Limits
-
-
- {(fields, { add, remove }) => (
- <>
- {fields.map(({ key, name, ...restField }) => (
-
- {
- if (!value) return Promise.resolve();
- const all = form.getFieldValue("modelLimits") ?? [];
- const dupes = all.filter(
- (entry: { model?: string }) => entry?.model === value,
- );
- if (dupes.length > 1) {
- return Promise.reject(new Error("Duplicate model"));
- }
- return Promise.resolve();
- },
- },
- ]}
- >
-
-
-
-
-
-
-
-
- remove(name)}
- style={{ color: "#ef4444" }}
- />
-
- ))}
-
-
-
- >
- )}
-
-
-
-
-
- Metadata
-
-
- {(fields, { add, remove }) => (
- <>
- {fields.map(({ key, name, ...restField }) => (
-
- {
- if (!value) return Promise.resolve();
- const all = form.getFieldValue("metadata") ?? [];
- const dupes = all.filter(
- (entry: { key?: string }) => entry?.key === value,
- );
- if (dupes.length > 1) {
- return Promise.reject(new Error("Duplicate key"));
- }
- return Promise.resolve();
- },
- },
- ]}
- >
-
-
-
-
-
- remove(name)}
- style={{ color: "#ef4444" }}
- />
-
- ))}
-
-
-
- >
- )}
-
- >
- ),
- },
- ]}
- />
-
-
-
+
+
{
+ if (!value) return true;
+ const dupes = (
+ formValues.metadata ?? []
+ ).filter(
+ (e: { key?: string }) => e?.key === value,
+ );
+ return dupes.length > 1
+ ? "Duplicate key"
+ : true;
+ },
+ })}
+ />
+ {rowErrors?.key && (
+
+ {rowErrors.key.message as string}
+
+ )}
+
+
+
+ {rowErrors?.value && (
+
+ {rowErrors.value.message as string}
+
+ )}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
);
}
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectsPage.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectsPage.tsx
index 95bf85a8416..df09fddf06e 100644
--- a/ui/litellm-dashboard/src/components/Projects/ProjectsPage.tsx
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectsPage.tsx
@@ -7,14 +7,33 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { Table } from "antd";
-import type { ColumnsType } from "antd/es/table";
+import {
+ SortState,
+ TableHeaderSortDropdown,
+} from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getSortedRowModel,
+ SortingState,
+ useReactTable,
+} from "@tanstack/react-table";
+import { cn } from "@/lib/utils";
import {
ChevronLeft,
ChevronRight,
@@ -31,10 +50,13 @@ export function ProjectsPage() {
const { data: projects, isLoading } = useProjects();
const { data: teams, isLoading: isTeamsLoading } = useTeams();
- const [selectedProjectId, setSelectedProjectId] = useState
(null);
+ const [selectedProjectId, setSelectedProjectId] = useState(
+ null,
+ );
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [searchText, setSearchText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
+ const [sorting, setSorting] = useState([]);
const pageSize = 10;
useEffect(() => {
@@ -50,7 +72,6 @@ export function ProjectsPage() {
return map;
}, [teams]);
- // ---------- filtered data ----------
const filteredProjects = useMemo(() => {
const list = projects ?? [];
if (!searchText) return list;
@@ -66,107 +87,143 @@ export function ProjectsPage() {
});
}, [projects, searchText, teamAliasMap]);
- // ---------- Ant Design columns ----------
- const columns: ColumnsType = [
- {
- title: "ID",
- dataIndex: "project_id",
- key: "project_id",
- width: 170,
- render: (id: string) => (
-
-
-
- setSelectedProjectId(id)}
- className="text-blue-500 bg-blue-50 hover:bg-blue-100 dark:bg-blue-950/30 dark:hover:bg-blue-950/60 text-sm cursor-pointer px-2 py-0.5 rounded inline-block truncate max-w-[160px]"
- >
- {id}
-
-
- {id}
-
-
- ),
- },
- {
- title: "Name",
- dataIndex: "project_alias",
- key: "project_alias",
- sorter: (a, b) => (a.project_alias ?? "").localeCompare(b.project_alias ?? ""),
- render: (alias: string | null) => alias ?? "—",
- },
- {
- title: "Team",
- key: "team",
- sorter: (a, b) => {
- const aAlias = teamAliasMap.get(a.team_id ?? "") ?? "";
- const bAlias = teamAliasMap.get(b.team_id ?? "") ?? "";
- return aAlias.localeCompare(bAlias);
+ const columnDefs = useMemo[]>(
+ () => [
+ {
+ id: "project_id",
+ accessorKey: "project_id",
+ header: () => ID,
+ enableSorting: false,
+ size: 170,
+ cell: ({ row }) => {
+ const id = row.original.project_id;
+ return (
+
+
+
+
+
+ {id}
+
+
+ );
+ },
},
- render: (_: unknown, record: ProjectResponse) => {
- if (!record.team_id) return "—";
- const alias = teamAliasMap.get(record.team_id);
- if (alias) return alias;
- if (isTeamsLoading)
- return ;
- return record.team_id;
+ {
+ id: "project_alias",
+ accessorKey: "project_alias",
+ header: () => Name,
+ enableSorting: true,
+ sortingFn: (a, b) =>
+ (a.original.project_alias ?? "").localeCompare(
+ b.original.project_alias ?? "",
+ ),
+ cell: ({ getValue }) => (getValue() as string | null) ?? "—",
},
- },
- {
- title: "Models",
- key: "models",
- render: (_: unknown, record: ProjectResponse) => {
- const models = record.models ?? [];
- return (
-
-
-
-
-
- {models.length}
-
-
-
- {models.length > 0 ? models.join(", ") : "No models"}
-
-
-
- );
+ {
+ id: "team",
+ header: () => Team,
+ enableSorting: true,
+ sortingFn: (a, b) => {
+ const aAlias = teamAliasMap.get(a.original.team_id ?? "") ?? "";
+ const bAlias = teamAliasMap.get(b.original.team_id ?? "") ?? "";
+ return aAlias.localeCompare(bAlias);
+ },
+ cell: ({ row }) => {
+ const record = row.original;
+ if (!record.team_id) return "—";
+ const alias = teamAliasMap.get(record.team_id);
+ if (alias) return alias;
+ if (isTeamsLoading)
+ return ;
+ return record.team_id;
+ },
},
- },
- {
- title: "Status",
- dataIndex: "blocked",
- key: "status",
- render: (blocked: boolean) => (
-
- {blocked ? "Blocked" : "Active"}
-
- ),
- },
- {
- title: "Created",
- dataIndex: "created_at",
- key: "created_at",
- sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
- responsive: ["lg"],
- render: (date: string) => new Date(date).toLocaleDateString(),
- },
- {
- title: "Updated",
- dataIndex: "updated_at",
- key: "updated_at",
- responsive: ["xl"],
- render: (date: string) => new Date(date).toLocaleDateString(),
- },
- ];
+ {
+ id: "models",
+ header: () => Models,
+ enableSorting: false,
+ cell: ({ row }) => {
+ const models = row.original.models ?? [];
+ return (
+
+
+
+
+
+ {models.length}
+
+
+
+ {models.length > 0 ? models.join(", ") : "No models"}
+
+
+
+ );
+ },
+ },
+ {
+ id: "status",
+ accessorKey: "blocked",
+ header: () => Status,
+ enableSorting: false,
+ cell: ({ row }) => (
+
+ {row.original.blocked ? "Blocked" : "Active"}
+
+ ),
+ },
+ {
+ id: "created_at",
+ accessorKey: "created_at",
+ header: () => Created,
+ enableSorting: true,
+ sortingFn: (a, b) =>
+ new Date(a.original.created_at).getTime() -
+ new Date(b.original.created_at).getTime(),
+ cell: ({ getValue }) =>
+ new Date(getValue() as string).toLocaleDateString(),
+ },
+ {
+ id: "updated_at",
+ accessorKey: "updated_at",
+ header: () => Updated,
+ enableSorting: false,
+ cell: ({ getValue }) =>
+ new Date(getValue() as string).toLocaleDateString(),
+ },
+ ],
+ [teamAliasMap, isTeamsLoading],
+ );
+
+ const table = useReactTable({
+ data: filteredProjects,
+ columns: columnDefs,
+ state: { sorting },
+ onSortingChange: setSorting,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getRowId: (row) => row.project_id,
+ });
+
+ const sortedRows = table.getRowModel().rows;
+ const paginatedRows = sortedRows.slice(
+ (currentPage - 1) * pageSize,
+ currentPage * pageSize,
+ );
+ const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize));
if (selectedProjectId) {
return (
@@ -177,8 +234,6 @@ export function ProjectsPage() {
);
}
- const totalPages = Math.max(1, Math.ceil(filteredProjects.length / pageSize));
-
return (
@@ -207,7 +262,7 @@ export function ProjectsPage() {
- {filteredProjects.length} projects
+ {sortedRows.length} projects
-
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ const canSort = header.column.getCanSort();
+ const isSorted = header.column.getIsSorted();
+ return (
+
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+ {canSort && (
+
{
+ if (newState === false) {
+ setSorting([]);
+ } else {
+ setSorting([
+ {
+ id: header.column.id,
+ desc: newState === "desc",
+ },
+ ]);
+ }
+ }}
+ columnId={header.column.id}
+ />
+ )}
+
+
+ );
+ })}
+
+ ))}
+
+
+ {isLoading ? (
+
+
+ Loading…
+
+
+ ) : paginatedRows.length === 0 ? (
+
+
+ No projects found
+
+
+ ) : (
+ paginatedRows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
+
+ ))
+ )}
+
+