mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
[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:
parent
03720ec08c
commit
061703abc0
8 changed files with 1084 additions and 305 deletions
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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>
|
||||
{"by"}
|
||||
<DefaultProxyAdminTag userId={project.created_by} />
|
||||
</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Last Updated">
|
||||
{new Date(project.updated_at).toLocaleString()}
|
||||
{project.updated_by && (
|
||||
<Text>
|
||||
{"by"}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue