[Feature] UI - Projects: Add Project Details page with Edit modal

- Add ProjectDetailsPage with header, details card, spend/budget progress,
  model spend bar chart, keys placeholder, and team info card
- Refactor CreateProjectModal into base form pattern (ProjectBaseForm)
  shared between Create and Edit flows
- Add EditProjectModal with pre-filled form data from backend
- Add useProjectDetails and useUpdateProject hooks
- Add duplicate key validation for model limits and metadata
- Wire project ID click in table to navigate to detail view
- Move pagination inline with search bar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-02-27 21:35:21 -08:00
parent 03720ec08c
commit 061703abc0
8 changed files with 1084 additions and 305 deletions

View file

@ -0,0 +1,63 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { ProjectResponse, projectKeys } from "./useProjects";
// ── Fetch function ───────────────────────────────────────────────────────────
const fetchProjectDetails = async (
accessToken: string,
projectId: string,
): Promise<ProjectResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/project/info?project_id=${encodeURIComponent(projectId)}`;
const response = await fetch(url, {
method: "GET",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
};
// ── Hook ─────────────────────────────────────────────────────────────────────
export const useProjectDetails = (projectId?: string) => {
const { accessToken, userRole } = useAuthorized();
const queryClient = useQueryClient();
return useQuery<ProjectResponse>({
queryKey: projectKeys.detail(projectId!),
queryFn: async () => fetchProjectDetails(accessToken!, projectId!),
enabled:
Boolean(accessToken && projectId) &&
all_admin_roles.includes(userRole || ""),
// Seed from the list cache when available
initialData: () => {
if (!projectId) return undefined;
const projects = queryClient.getQueryData<ProjectResponse[]>(
projectKeys.list({}),
);
return projects?.find((p) => p.project_id === projectId);
},
});
};

View file

@ -0,0 +1,75 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { ProjectResponse, projectKeys } from "./useProjects";
// ── Types ────────────────────────────────────────────────────────────────────
export interface ProjectUpdateParams {
project_alias?: string;
description?: string;
team_id?: string;
models?: string[];
max_budget?: number;
blocked?: boolean;
metadata?: Record<string, unknown>;
model_rpm_limit?: Record<string, number>;
model_tpm_limit?: Record<string, number>;
}
// ── Fetch function ───────────────────────────────────────────────────────────
const updateProject = async (
accessToken: string,
projectId: string,
params: ProjectUpdateParams,
): Promise<ProjectResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/project/update`;
const response = await fetch(url, {
method: "POST",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ project_id: projectId, ...params }),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
};
// ── Hook ─────────────────────────────────────────────────────────────────────
export const useUpdateProject = () => {
const { accessToken } = useAuthorized();
const queryClient = useQueryClient();
return useMutation<
ProjectResponse,
Error,
{ projectId: string; params: ProjectUpdateParams }
>({
mutationFn: async ({ projectId, params }) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return updateProject(accessToken, projectId, params);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: projectKeys.all });
},
});
};

View file

@ -0,0 +1,326 @@
import { useProjectDetails } from "@/app/(dashboard)/hooks/projects/useProjectDetails";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import {
Button,
Card,
Col,
Descriptions,
Empty,
Flex,
Layout,
Progress,
Row,
Spin,
Tag,
theme,
Typography,
} from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { BarChart } from "@tremor/react";
import { ArrowLeftIcon, DollarSignIcon, EditIcon, KeyIcon, UsersIcon } from "lucide-react";
import { useMemo, useState } from "react";
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;
models?: string[];
max_budget?: number | null;
budget_duration?: string | null;
spend?: number;
members_with_roles?: { user_id: string; role: string }[];
}
interface ProjectDetailProps {
projectId: string;
onBack: () => void;
}
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 [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<string, number>;
return Object.entries(raw)
.map(([model, value]) => ({ model, spend: value }))
.sort((a, b) => b.spend - a.spend);
}, [project?.model_spend]);
if (isLoading) {
return (
<Content
style={{
padding: token.paddingLG,
paddingInline: token.paddingLG * 2,
}}
>
<Flex justify="center" align="center" style={{ minHeight: 300 }}>
<Spin indicator={<LoadingOutlined spin />} size="large" />
</Flex>
</Content>
);
}
if (!project) {
return (
<Content
style={{
padding: token.paddingLG,
paddingInline: token.paddingLG * 2,
}}
>
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" style={{ marginBottom: 16 }} />
<Empty description="Project not found" />
</Content>
);
}
return (
<Content style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" />
<div>
<Flex align="center" gap={8}>
<Title level={2} style={{ margin: 0 }}>
{project.project_alias ?? project.project_id}
</Title>
<Tag color={project.blocked ? "red" : "green"}>{project.blocked ? "Blocked" : "Active"}</Tag>
</Flex>
<Text type="secondary">
ID: <Text copyable>{project.project_id}</Text>
</Text>
</div>
</div>
<Button type="primary" icon={<EditIcon size={16} />} onClick={() => setIsEditModalVisible(true)}>
Edit Project
</Button>
</div>
{/* Project Details */}
<Row style={{ marginBottom: 24 }}>
<Card>
<Descriptions title="Project Details" column={1}>
<Descriptions.Item label="Description">{project.description || "\u2014"}</Descriptions.Item>
<Descriptions.Item label="Created">
{new Date(project.created_at).toLocaleString()}
{project.created_by && (
<Text>
&nbsp;{"by"}&nbsp;
<DefaultProxyAdminTag userId={project.created_by} />
</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="Last Updated">
{new Date(project.updated_at).toLocaleString()}
{project.updated_by && (
<Text>
&nbsp;{"by"}&nbsp;
<DefaultProxyAdminTag userId={project.updated_by} />
</Text>
)}
</Descriptions.Item>
</Descriptions>
</Card>
</Row>
{/* Spend / Budget */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={8}>
<Card
title={
<Flex align="center" gap={8}>
<DollarSignIcon size={16} />
Budget
</Flex>
}
style={{ height: "100%" }}
>
<Flex vertical gap={16}>
<div>
<Text strong style={{ fontSize: 28, lineHeight: 1 }}>
${spend.toFixed(2)}
</Text>
<br />
<Text type="secondary">{hasLimit ? `of $${maxBudget.toFixed(2)} budget` : "No budget limit"}</Text>
</div>
{hasLimit && (
<div>
<Progress percent={Math.round(spendPercent * 10) / 10} strokeColor={spendColor} showInfo={false} />
<Text type="secondary" style={{ fontSize: 12 }}>
{(Math.round(spendPercent * 10) / 10).toFixed(1)}% utilized
</Text>
</div>
)}
</Flex>
</Card>
</Col>
<Col xs={24} lg={16}>
<Card title="Spend by Model" style={{ height: "100%" }}>
{modelSpendData.length > 0 ? (
<BarChart
data={modelSpendData}
index="model"
categories={["spend"]}
colors={["cyan"]}
layout="vertical"
valueFormatter={(value) => `$${value.toFixed(4)}`}
yAxisWidth={140}
showLegend={false}
style={{ height: Math.max(modelSpendData.length * 40, 120) }}
/>
) : (
<Empty description="No model spend recorded yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</Col>
</Row>
{/* Keys & Team */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<KeyIcon size={16} />
Keys
</Flex>
}
style={{ height: "100%" }}
>
<Empty description="No keys to display" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<UsersIcon size={16} />
Team
</Flex>
}
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";
return (
<Flex vertical gap={12}>
{/* Team name + ID */}
<div>
<Text strong style={{ fontSize: 16 }}>
{teamInfo.team_alias || teamInfo.team_id}
</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
ID:{" "}
<Text copyable style={{ fontSize: 12 }}>
{teamInfo.team_id}
</Text>
</Text>
</div>
{/* Models */}
<div>
<Text type="secondary" style={{ fontSize: 12, display: "block", marginBottom: 4 }}>
Models
</Text>
{(teamInfo.models?.length ?? 0) > 0 ? (
<Flex wrap="wrap" gap={4} style={{ maxHeight: 60, overflow: "hidden" }}>
{teamInfo.models?.map((m: string) => (
<Tag key={m} style={{ margin: 0 }}>
{m}
</Tag>
))}
</Flex>
) : (
<Text type="secondary">All models</Text>
)}
</div>
{/* Budget + Spend compact */}
<div>
<Flex justify="space-between" align="center" style={{ marginBottom: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Spend
</Text>
<Text style={{ fontSize: 12 }}>
${teamSpend.toFixed(2)}
{teamHasLimit ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{" "}
/ ${teamBudget.toFixed(2)}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
{" "}
(Unlimited)
</Text>
)}
</Text>
</Flex>
{teamHasLimit && (
<Progress
percent={Math.round(teamPercent * 10) / 10}
strokeColor={teamColor}
size="small"
showInfo={false}
/>
)}
</div>
{/* Members */}
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 12 }}>
Members
</Text>
<Text style={{ fontSize: 12 }}>{teamInfo.members_with_roles?.length ?? 0}</Text>
</Flex>
</Flex>
);
})()
) : project.team_id ? (
<Flex justify="center" align="center" style={{ padding: 16 }}>
<Spin indicator={<LoadingOutlined spin />} size="small" />
</Flex>
) : (
<Empty description="No team assigned" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</Col>
</Row>
{/* Edit Modal */}
<EditProjectModal isOpen={isEditModalVisible} project={project} onClose={() => setIsEditModalVisible(false)} />
</Content>
);
}

View file

@ -1,95 +1,39 @@
import { useEffect, useState } from "react";
import { Modal, Form, Button, Typography, message } from "antd";
import { FolderAddOutlined } from "@ant-design/icons";
import {
Alert,
Modal,
Form,
Input,
Select,
Switch,
InputNumber,
Collapse,
Button,
Col,
Flex,
Row,
Space,
Divider,
Typography,
message,
} from "antd";
import { FolderAddOutlined, PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useCreateProject, ProjectCreateParams } from "@/app/(dashboard)/hooks/projects/useCreateProject";
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";
useCreateProject,
ProjectCreateParams,
} from "@/app/(dashboard)/hooks/projects/useCreateProject";
import {
ProjectBaseForm,
ProjectFormValues,
} from "./ProjectBaseForm";
import { buildProjectApiParams } from "./projectFormUtils";
interface CreateProjectModalProps {
isOpen: boolean;
onClose: () => void;
}
export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps) {
const [form] = Form.useForm();
const { accessToken, userId, userRole } = useAuthorized();
const { data: teams } = useTeams();
export function CreateProjectModal({
isOpen,
onClose,
}: CreateProjectModalProps) {
const [form] = Form.useForm<ProjectFormValues>();
const createMutation = useCreateProject();
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [modelsToPick, setModelsToPick] = useState<string[]>([]);
// Fetch team-scoped models when team selection changes
useEffect(() => {
if (userId && userRole && accessToken && selectedTeam) {
fetchTeamModels(userId, userRole, accessToken, selectedTeam.team_id).then((models) => {
const allModels = Array.from(new Set([...(selectedTeam.models ?? []), ...models]));
setModelsToPick(allModels);
});
} else {
setModelsToPick([]);
}
form.setFieldValue("models", []);
}, [selectedTeam, accessToken, userId, userRole, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
// Build model-specific limits from the dynamic form list
const modelRpmLimit: Record<string, number> = {};
const modelTpmLimit: Record<string, number> = {};
for (const entry of values.modelLimits ?? []) {
if (entry.model) {
if (entry.rpm != null) modelRpmLimit[entry.model] = entry.rpm;
if (entry.tpm != null) modelTpmLimit[entry.model] = entry.tpm;
}
}
// Build metadata from the dynamic form list
const metadata: Record<string, unknown> = {};
for (const entry of values.metadata ?? []) {
if (entry.key) metadata[entry.key] = entry.value;
}
const params: ProjectCreateParams = {
project_alias: values.project_alias,
description: values.description,
...buildProjectApiParams(values),
team_id: values.team_id,
models: values.models ?? [],
max_budget: values.max_budget,
blocked: values.isBlocked ?? false,
...(Object.keys(modelRpmLimit).length > 0 && { model_rpm_limit: modelRpmLimit }),
...(Object.keys(modelTpmLimit).length > 0 && { model_tpm_limit: modelTpmLimit }),
...(Object.keys(metadata).length > 0 && { metadata }),
};
createMutation.mutate(params, {
onSuccess: () => {
message.success("Project created successfully");
form.resetFields();
setSelectedTeam(null);
setModelsToPick([]);
onClose();
},
onError: (error) => {
@ -103,16 +47,9 @@ export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps)
const handleCancel = () => {
form.resetFields();
setSelectedTeam(null);
setModelsToPick([]);
onClose();
};
const handleTeamChange = (teamId: string) => {
const team = teams?.find((t) => t.team_id === teamId) ?? null;
setSelectedTeam(team);
};
return (
<Modal
title={
@ -123,226 +60,23 @@ export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps)
open={isOpen}
onCancel={handleCancel}
width={720}
destroyOnClose
footer={[
<Button key="cancel" onClick={handleCancel}>
Cancel
</Button>,
<Button key="submit" type="primary" icon={<FolderAddOutlined />} loading={createMutation.isPending} onClick={handleSubmit}>
<Button
key="submit"
type="primary"
icon={<FolderAddOutlined />}
loading={createMutation.isPending}
onClick={handleSubmit}
>
Create Project
</Button>,
]}
>
<Form
form={form}
layout="vertical"
initialValues={{
isBlocked: false,
}}
style={{ marginTop: 24 }}
>
{/* Basic Info */}
<Typography.Text
strong
style={{ fontSize: 13, color: "#374151", textTransform: "uppercase", letterSpacing: "0.05em" }}
>
Basic Information
</Typography.Text>
<Divider style={{ marginTop: 8, marginBottom: 16 }} />
<Row gutter={24}>
<Col span={12}>
<Form.Item
name="project_alias"
label="Project Name"
rules={[{ required: true, message: "Please enter a project name" }]}
>
<Input placeholder="e.g. Customer Support Bot" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="team_id" label="Team" rules={[{ required: true, message: "Please select a team" }]}>
<Select
showSearch
placeholder="Search or select a team"
onChange={handleTeamChange}
allowClear
optionLabelProp="label"
filterOption={(input, option) => {
const team = teams?.find((t) => t.team_id === option?.value);
if (!team) return false;
const search = input.toLowerCase().trim();
return (
(team.team_alias || "").toLowerCase().includes(search) ||
team.team_id.toLowerCase().includes(search)
);
}}
>
{teams?.map((team) => (
<Select.Option key={team.team_id} value={team.team_id} label={team.team_alias || team.team_id}>
<span style={{ fontWeight: 500 }}>{team.team_alias}</span>{" "}
<span style={{ color: "#9ca3af" }}>({team.team_id})</span>
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Form.Item name="description" label="Description">
<Input.TextArea placeholder="Describe the purpose of this project" rows={3} />
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Form.Item
name="models"
label="Allowed Models (scoped to selected team's models)"
help={!selectedTeam ? "Select a team first to see available models" : undefined}
>
<Select
mode="multiple"
placeholder={selectedTeam ? "Select models" : "Select a team first"}
disabled={!selectedTeam}
allowClear
maxTagCount="responsive"
onChange={(values) => {
if (values.includes("all-team-models")) {
form.setFieldsValue({ models: ["all-team-models"] });
}
}}
>
<Select.Option key="all-team-models" value="all-team-models">
All Team Models
</Select.Option>
{modelsToPick.map((model) => (
<Select.Option key={model} value={model}>
{getModelDisplayName(model)}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item name="max_budget" label="Max Budget (USD)">
<InputNumber prefix="$" style={{ width: "100%" }} placeholder="0.00" min={0} precision={2} />
</Form.Item>
</Col>
</Row>
{/* Advanced Settings */}
<Row>
<Col span={24}>
<Collapse ghost style={{ background: "#f9fafb", borderRadius: 8, border: "1px solid #e5e7eb" }}>
<Collapse.Panel
header={
<Typography.Text strong style={{ color: "#374151" }}>
Advanced Settings
</Typography.Text>
}
key="1"
>
<Flex align="center" gap={12}>
<Typography.Text strong>Block Project</Typography.Text>
<Form.Item name="isBlocked" valuePropName="checked" noStyle>
<Switch />
</Form.Item>
</Flex>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.isBlocked !== cur.isBlocked}>
{({ getFieldValue }) =>
getFieldValue("isBlocked") ? (
<Alert
banner
type="warning"
showIcon
message="All API requests using keys under this project will be rejected."
style={{ marginTop: 12 }}
/>
) : null
}
</Form.Item>
<Divider />
<Typography.Text strong style={{ display: "block", marginBottom: 12 }}>
Model-Specific Limits
</Typography.Text>
<Form.List name="modelLimits">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: "flex", marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[name, "model"]}
rules={[{ required: true, message: "Missing model" }]}
>
<Input placeholder="Model name (e.g. gpt-4)" />
</Form.Item>
<Form.Item {...restField} name={[name, "tpm"]}>
<InputNumber placeholder="TPM Limit" min={0} />
</Form.Item>
<Form.Item {...restField} name={[name, "rpm"]}>
<InputNumber placeholder="RPM Limit" min={0} />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: "#ef4444" }} />
</Space>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add Model Limit
</Button>
</Form.Item>
</>
)}
</Form.List>
<Divider />
<Typography.Text strong style={{ display: "block", marginBottom: 12 }}>
Metadata
</Typography.Text>
<Form.List name="metadata">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: "flex", marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[name, "key"]}
rules={[{ required: true, message: "Missing key" }]}
>
<Input placeholder="Key" />
</Form.Item>
<Form.Item
{...restField}
name={[name, "value"]}
rules={[{ required: true, message: "Missing value" }]}
>
<Input placeholder="Value" />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: "#ef4444" }} />
</Space>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add Key-Value Pair
</Button>
</Form.Item>
</>
)}
</Form.List>
</Collapse.Panel>
</Collapse>
</Col>
</Row>
</Form>
<ProjectBaseForm form={form} />
</Modal>
);
}

View file

@ -0,0 +1,126 @@
import { useEffect } from "react";
import { Modal, Form, Button, Typography, message } from "antd";
import { SaveOutlined } from "@ant-design/icons";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import {
useUpdateProject,
ProjectUpdateParams,
} from "@/app/(dashboard)/hooks/projects/useUpdateProject";
import { ProjectBaseForm, ProjectFormValues } from "./ProjectBaseForm";
import { buildProjectApiParams } from "./projectFormUtils";
interface EditProjectModalProps {
isOpen: boolean;
project: ProjectResponse;
onClose: () => void;
onSuccess?: () => void;
}
export function EditProjectModal({
isOpen,
project,
onClose,
onSuccess,
}: EditProjectModalProps) {
const [form] = Form.useForm<ProjectFormValues>();
const updateMutation = useUpdateProject();
// Populate form with existing project data when modal opens
useEffect(() => {
if (isOpen && project) {
// Model limits are stored inside metadata by the backend
const metadataObj = (project.metadata ?? {}) as Record<string, unknown>;
const rpmLimits = (metadataObj.model_rpm_limit ?? {}) as Record<string, number>;
const tpmLimits = (metadataObj.model_tpm_limit ?? {}) as Record<string, number>;
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],
});
}
// Filter out internal keys from user-facing metadata
const internalKeys = new Set(["model_rpm_limit", "model_tpm_limit"]);
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,
modelLimits: modelLimits.length > 0 ? modelLimits : undefined,
metadata: metadata.length > 0 ? metadata : undefined,
});
}
}, [isOpen, project, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const params: ProjectUpdateParams = {
...buildProjectApiParams(values),
team_id: values.team_id,
};
updateMutation.mutate(
{ projectId: project.project_id, params },
{
onSuccess: () => {
message.success("Project updated successfully");
onSuccess?.();
onClose();
},
onError: (error) => {
message.error(error.message || "Failed to update project");
},
},
);
} catch (error) {
console.error("Validation failed:", error);
}
};
return (
<Modal
title={
<Typography.Text strong style={{ fontSize: 18 }}>
Edit Project
</Typography.Text>
}
open={isOpen}
onCancel={onClose}
width={720}
destroyOnHidden
footer={[
<Button key="cancel" onClick={onClose}>
Cancel
</Button>,
<Button
key="submit"
type="primary"
icon={<SaveOutlined />}
loading={updateMutation.isPending}
onClick={handleSubmit}
>
Save Changes
</Button>,
]}
>
<ProjectBaseForm form={form} />
</Modal>
);
}

View file

@ -0,0 +1,401 @@
import { useEffect, 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 { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
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";
export interface ProjectFormValues {
project_alias: string;
team_id: string;
description?: string;
models: string[];
max_budget?: number;
isBlocked: boolean;
modelLimits?: { model: string; tpm?: number; rpm?: number }[];
metadata?: { key: string; value: string }[];
}
interface ProjectBaseFormProps {
form: FormInstance<ProjectFormValues>;
}
export function ProjectBaseForm({
form,
}: ProjectBaseFormProps) {
const { accessToken, userId, userRole } = useAuthorized();
const { data: teams } = useTeams();
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [modelsToPick, setModelsToPick] = useState<string[]>([]);
// 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);
}
}
}, [teamIdValue, teams, selectedTeam?.team_id]);
// Fetch team-scoped models when team selection changes
useEffect(() => {
if (userId && userRole && accessToken && selectedTeam) {
fetchTeamModels(userId, userRole, accessToken, selectedTeam.team_id).then(
(models) => {
const allModels = Array.from(
new Set([...(selectedTeam.models ?? []), ...models]),
);
setModelsToPick(allModels);
},
);
} else {
setModelsToPick([]);
}
}, [selectedTeam, accessToken, userId, userRole]);
const handleTeamChange = (teamId: string) => {
const team = teams?.find((t) => t.team_id === teamId) ?? null;
setSelectedTeam(team);
form.setFieldValue("models", []);
};
return (
<Form
form={form}
layout="vertical"
name="project_form"
initialValues={{ isBlocked: false }}
style={{ marginTop: 24 }}
>
{/* Basic Info */}
<Typography.Text
strong
style={{
fontSize: 13,
color: "#374151",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Basic Information
</Typography.Text>
<Divider style={{ marginTop: 8, marginBottom: 16 }} />
<Row gutter={24}>
<Col span={12}>
<Form.Item
name="project_alias"
label="Project Name"
rules={[
{ required: true, message: "Please enter a project name" },
]}
>
<Input placeholder="e.g. Customer Support Bot" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="team_id"
label="Team"
rules={[{ required: true, message: "Please select a team" }]}
>
<Select
showSearch
placeholder="Search or select a team"
onChange={handleTeamChange}
allowClear
optionLabelProp="label"
filterOption={(input, option) => {
const team = teams?.find((t) => t.team_id === option?.value);
if (!team) return false;
const search = input.toLowerCase().trim();
return (
(team.team_alias || "").toLowerCase().includes(search) ||
team.team_id.toLowerCase().includes(search)
);
}}
>
{teams?.map((team) => (
<Select.Option
key={team.team_id}
value={team.team_id}
label={team.team_alias || team.team_id}
>
<span style={{ fontWeight: 500 }}>{team.team_alias}</span>{" "}
<span style={{ color: "#9ca3af" }}>({team.team_id})</span>
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Form.Item name="description" label="Description">
<Input.TextArea
placeholder="Describe the purpose of this project"
rows={3}
/>
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Form.Item
name="models"
label="Allowed Models (scoped to selected team's models)"
help={
!selectedTeam
? "Select a team first to see available models"
: undefined
}
>
<Select
mode="multiple"
placeholder={
selectedTeam ? "Select models" : "Select a team first"
}
disabled={!selectedTeam}
allowClear
maxTagCount="responsive"
onChange={(values) => {
if (values.includes("all-team-models")) {
form.setFieldsValue({ models: ["all-team-models"] });
}
}}
>
<Select.Option key="all-team-models" value="all-team-models">
All Team Models
</Select.Option>
{modelsToPick.map((model) => (
<Select.Option key={model} value={model}>
{getModelDisplayName(model)}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item name="max_budget" label="Max Budget (USD)">
<InputNumber
prefix="$"
style={{ width: "100%" }}
placeholder="0.00"
min={0}
precision={2}
/>
</Form.Item>
</Col>
</Row>
{/* Advanced Settings */}
<Row>
<Col span={24}>
<Collapse
ghost
style={{
background: "#f9fafb",
borderRadius: 8,
border: "1px solid #e5e7eb",
}}
items={[
{
key: "1",
label: (
<Typography.Text strong style={{ color: "#374151" }}>
Advanced Settings
</Typography.Text>
),
children: (
<>
<Flex align="center" gap={12}>
<Typography.Text strong>Block Project</Typography.Text>
<Form.Item name="isBlocked" valuePropName="checked" noStyle>
<Switch />
</Form.Item>
</Flex>
<Form.Item
noStyle
shouldUpdate={(prev, cur) => prev.isBlocked !== cur.isBlocked}
>
{({ getFieldValue }) =>
getFieldValue("isBlocked") ? (
<Alert
banner
type="warning"
showIcon
message="All API requests using keys under this project will be rejected."
style={{ marginTop: 12 }}
/>
) : null
}
</Form.Item>
<Divider />
<Typography.Text
strong
style={{ display: "block", marginBottom: 12 }}
>
Model-Specific Limits
</Typography.Text>
<Form.List name="modelLimits">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space
key={key}
style={{ display: "flex", marginBottom: 8 }}
align="baseline"
>
<Form.Item
{...restField}
name={[name, "model"]}
rules={[
{ required: true, message: "Missing model" },
{
validator: (_, value) => {
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();
},
},
]}
>
<Input placeholder="Model name (e.g. gpt-4)" />
</Form.Item>
<Form.Item {...restField} name={[name, "tpm"]}>
<InputNumber placeholder="TPM Limit" min={0} />
</Form.Item>
<Form.Item {...restField} name={[name, "rpm"]}>
<InputNumber placeholder="RPM Limit" min={0} />
</Form.Item>
<MinusCircleOutlined
onClick={() => remove(name)}
style={{ color: "#ef4444" }}
/>
</Space>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
block
icon={<PlusOutlined />}
>
Add Model Limit
</Button>
</Form.Item>
</>
)}
</Form.List>
<Divider />
<Typography.Text
strong
style={{ display: "block", marginBottom: 12 }}
>
Metadata
</Typography.Text>
<Form.List name="metadata">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space
key={key}
style={{ display: "flex", marginBottom: 8 }}
align="baseline"
>
<Form.Item
{...restField}
name={[name, "key"]}
rules={[
{ required: true, message: "Missing key" },
{
validator: (_, value) => {
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();
},
},
]}
>
<Input placeholder="Key" />
</Form.Item>
<Form.Item
{...restField}
name={[name, "value"]}
rules={[
{ required: true, message: "Missing value" },
]}
>
<Input placeholder="Value" />
</Form.Item>
<MinusCircleOutlined
onClick={() => remove(name)}
style={{ color: "#ef4444" }}
/>
</Space>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
block
icon={<PlusOutlined />}
>
Add Key-Value Pair
</Button>
</Form.Item>
</>
)}
</Form.List>
</>
),
},
]}
/>
</Col>
</Row>
</Form>
);
}

View file

@ -0,0 +1,36 @@
import { ProjectFormValues } from "./ProjectBaseForm";
/**
* Transforms ProjectFormValues into the flat API param shape
* shared by both create and update endpoints.
*/
export function buildProjectApiParams(values: ProjectFormValues) {
const modelRpmLimit: Record<string, number> = {};
const modelTpmLimit: Record<string, number> = {};
for (const entry of values.modelLimits ?? []) {
if (entry.model) {
if (entry.rpm != null) modelRpmLimit[entry.model] = entry.rpm;
if (entry.tpm != null) modelTpmLimit[entry.model] = entry.tpm;
}
}
const metadata: Record<string, unknown> = {};
for (const entry of values.metadata ?? []) {
if (entry.key) metadata[entry.key] = entry.value;
}
return {
project_alias: values.project_alias,
description: values.description,
models: values.models ?? [],
max_budget: values.max_budget,
blocked: values.isBlocked ?? false,
...(Object.keys(modelRpmLimit).length > 0 && {
model_rpm_limit: modelRpmLimit,
}),
...(Object.keys(modelTpmLimit).length > 0 && {
model_tpm_limit: modelTpmLimit,
}),
...(Object.keys(metadata).length > 0 && { metadata }),
};
}

View file

@ -1,13 +1,15 @@
import { useProjects, ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { PlusOutlined } from "@ant-design/icons";
import { LoadingOutlined, PlusOutlined } from "@ant-design/icons";
import {
Button,
Card,
Flex,
Input,
Layout,
Pagination,
Space,
Spin,
Table,
Tag,
theme,
@ -18,6 +20,7 @@ import type { ColumnsType } from "antd/es/table";
import { LayersIcon, SearchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { CreateProjectModal } from "./ProjectModals/CreateProjectModal";
import { ProjectDetail } from "./ProjectDetailsPage";
const { Title, Text } = Typography;
const { Content } = Layout;
@ -25,8 +28,9 @@ const { Content } = Layout;
export function ProjectsPage() {
const { token } = theme.useToken();
const { data: projects, isLoading } = useProjects();
const { data: teams } = useTeams();
const { data: teams, isLoading: isTeamsLoading } = useTeams();
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [searchText, setSearchText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
@ -74,6 +78,7 @@ export function ProjectsPage() {
ellipsis
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
style={{ fontSize: 14, padding: "1px 8px" }}
onClick={() => setSelectedProjectId(id)}
>
{id}
</Text>
@ -96,8 +101,11 @@ export function ProjectsPage() {
return aAlias.localeCompare(bAlias);
},
render: (_: unknown, record: ProjectResponse) => {
const alias = teamAliasMap.get(record.team_id ?? "");
return alias ?? record.team_id ?? "—";
if (!record.team_id) return "—";
const alias = teamAliasMap.get(record.team_id);
if (alias) return alias;
if (isTeamsLoading) return <Spin indicator={<LoadingOutlined spin />} size="small" />;
return record.team_id;
},
},
{
@ -144,6 +152,15 @@ export function ProjectsPage() {
},
];
if (selectedProjectId) {
return (
<ProjectDetail
projectId={selectedProjectId}
onBack={() => setSelectedProjectId(null)}
/>
);
}
return (
<Content
style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}
@ -184,21 +201,22 @@ export function ProjectsPage() {
onChange={(e) => setSearchText(e.target.value)}
allowClear
/>
<Pagination
current={currentPage}
total={filteredProjects.length}
pageSize={pageSize}
onChange={(page) => setCurrentPage(page)}
size="small"
showTotal={(total) => `${total} projects`}
showSizeChanger={false}
/>
</Flex>
<Table
columns={columns}
dataSource={filteredProjects}
dataSource={filteredProjects.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
rowKey="project_id"
loading={isLoading}
pagination={{
current: currentPage,
pageSize,
total: filteredProjects.length,
onChange: (page) => setCurrentPage(page),
size: "small",
showTotal: (total) => `${total} projects`,
showSizeChanger: false,
}}
pagination={false}
/>
</Card>