mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
[Feature] UI - Projects: Add Projects page with list and create flows
Add a new Projects page under ACCESS CONTROL in the left nav, positioned between Teams and Internal Users. Projects sit between teams and keys in the hierarchy for use-case management. - ProjectsPage with Ant Design table (list, search, pagination) - CreateProjectModal with team-scoped model selection - React-query hooks for /project/list and /project/new - New badge on Projects, removed from Access Groups - Page metadata entry for test compliance Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ea5d551525
commit
2613277ede
8 changed files with 753 additions and 11 deletions
|
|
@ -0,0 +1,70 @@
|
|||
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 ProjectCreateParams {
|
||||
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 createProject = async (
|
||||
accessToken: string,
|
||||
params: ProjectCreateParams,
|
||||
): Promise<ProjectResponse> => {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const url = `${baseUrl}/project/new`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(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 useCreateProject = () => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ProjectResponse, Error, ProjectCreateParams>({
|
||||
mutationFn: async (params) => {
|
||||
if (!accessToken) {
|
||||
throw new Error("Access token is required");
|
||||
}
|
||||
return createProject(accessToken, params);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: projectKeys.all });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
import {
|
||||
getProxyBaseUrl,
|
||||
getGlobalLitellmHeaderName,
|
||||
deriveErrorMessage,
|
||||
handleError,
|
||||
} from "@/components/networking";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProjectBudget {
|
||||
budget_id: string;
|
||||
max_budget: number | null;
|
||||
soft_budget: number | null;
|
||||
max_parallel_requests: number | null;
|
||||
tpm_limit: number | null;
|
||||
rpm_limit: number | null;
|
||||
model_max_budget: Record<string, number> | null;
|
||||
budget_duration: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectResponse {
|
||||
project_id: string;
|
||||
project_alias: string | null;
|
||||
description: string | null;
|
||||
team_id: string | null;
|
||||
budget_id: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
models: string[];
|
||||
spend: number;
|
||||
model_spend: Record<string, number> | null;
|
||||
model_rpm_limit: Record<string, number> | null;
|
||||
model_tpm_limit: Record<string, number> | null;
|
||||
blocked: boolean;
|
||||
object_permission_id: string | null;
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
litellm_budget_table: ProjectBudget | null;
|
||||
}
|
||||
|
||||
// ── Query keys (shared across project hooks) ─────────────────────────────────
|
||||
|
||||
export const projectKeys = createQueryKeys("projects");
|
||||
|
||||
// ── Fetch function ───────────────────────────────────────────────────────────
|
||||
|
||||
const fetchProjects = async (
|
||||
accessToken: string,
|
||||
): Promise<ProjectResponse[]> => {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const url = `${baseUrl}/project/list`;
|
||||
|
||||
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 useProjects = () => {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
|
||||
return useQuery<ProjectResponse[]>({
|
||||
queryKey: projectKeys.list({}),
|
||||
queryFn: async () => fetchProjects(accessToken!),
|
||||
enabled:
|
||||
Boolean(accessToken) && all_admin_roles.includes(userRole || ""),
|
||||
});
|
||||
};
|
||||
|
|
@ -37,6 +37,7 @@ import UIThemeSettings from "@/components/ui_theme_settings";
|
|||
import Usage from "@/components/usage";
|
||||
import UserDashboard from "@/components/user_dashboard";
|
||||
import { AccessGroupsPage } from "@/components/AccessGroups/AccessGroupsPage";
|
||||
import { ProjectsPage } from "@/components/Projects/ProjectsPage";
|
||||
import VectorStoreManagement from "@/components/vector_store_management";
|
||||
import ToolPolicies from "@/components/ToolPolicies";
|
||||
import SpendLogsTable from "@/components/view_logs";
|
||||
|
|
@ -547,6 +548,8 @@ function CreateKeyPageContent() {
|
|||
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "access-groups" ? (
|
||||
<AccessGroupsPage />
|
||||
) : page == "projects" ? (
|
||||
<ProjectsPage />
|
||||
) : page == "vector-stores" ? (
|
||||
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
) : page == "tool-policies" ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,348 @@
|
|||
import { useEffect, useState } from "react";
|
||||
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";
|
||||
|
||||
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();
|
||||
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,
|
||||
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) => {
|
||||
message.error(error.message || "Failed to create project");
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Validation failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
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={
|
||||
<Typography.Text strong style={{ fontSize: 18 }}>
|
||||
Create New Project
|
||||
</Typography.Text>
|
||||
}
|
||||
open={isOpen}
|
||||
onCancel={handleCancel}
|
||||
width={720}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>,
|
||||
<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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
211
ui/litellm-dashboard/src/components/Projects/ProjectsPage.tsx
Normal file
211
ui/litellm-dashboard/src/components/Projects/ProjectsPage.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { useProjects, ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Input,
|
||||
Layout,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
theme,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { LayersIcon, SearchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { CreateProjectModal } from "./ProjectModals/CreateProjectModal";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Content } = Layout;
|
||||
|
||||
export function ProjectsPage() {
|
||||
const { token } = theme.useToken();
|
||||
const { data: projects, isLoading } = useProjects();
|
||||
const { data: teams } = useTeams();
|
||||
|
||||
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchText]);
|
||||
|
||||
// Build a team_id → team_alias lookup from the teams list
|
||||
const teamAliasMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const team of teams ?? []) {
|
||||
map.set(team.team_id, team.team_alias ?? team.team_id);
|
||||
}
|
||||
return map;
|
||||
}, [teams]);
|
||||
|
||||
// ---------- filtered data ----------
|
||||
const filteredProjects = useMemo(() => {
|
||||
const list = projects ?? [];
|
||||
if (!searchText) return list;
|
||||
const lower = searchText.toLowerCase();
|
||||
return list.filter((p) => {
|
||||
const alias = teamAliasMap.get(p.team_id ?? "") ?? "";
|
||||
return (
|
||||
(p.project_alias ?? "").toLowerCase().includes(lower) ||
|
||||
p.project_id.toLowerCase().includes(lower) ||
|
||||
(p.description ?? "").toLowerCase().includes(lower) ||
|
||||
alias.toLowerCase().includes(lower)
|
||||
);
|
||||
});
|
||||
}, [projects, searchText, teamAliasMap]);
|
||||
|
||||
// ---------- Ant Design columns ----------
|
||||
const columns: ColumnsType<ProjectResponse> = [
|
||||
{
|
||||
title: "ID",
|
||||
dataIndex: "project_id",
|
||||
key: "project_id",
|
||||
width: 170,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<Text
|
||||
ellipsis
|
||||
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
|
||||
style={{ fontSize: 14, padding: "1px 8px" }}
|
||||
>
|
||||
{id}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
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);
|
||||
},
|
||||
render: (_: unknown, record: ProjectResponse) => {
|
||||
const alias = teamAliasMap.get(record.team_id ?? "");
|
||||
return alias ?? record.team_id ?? "—";
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Models",
|
||||
key: "models",
|
||||
render: (_: unknown, record: ProjectResponse) => {
|
||||
const models = record.models ?? [];
|
||||
return (
|
||||
<Tooltip title={models.length > 0 ? models.join(", ") : "No models"}>
|
||||
<Tag color="blue" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
|
||||
<Flex align="center" gap={6}>
|
||||
<LayersIcon size={14} />
|
||||
{models.length}
|
||||
</Flex>
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
dataIndex: "blocked",
|
||||
key: "status",
|
||||
render: (blocked: boolean) => (
|
||||
<Tag color={blocked ? "red" : "green"}>
|
||||
{blocked ? "Blocked" : "Active"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
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(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Content
|
||||
style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}
|
||||
>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
Projects
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
Manage projects within your teams
|
||||
</Text>
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsCreateModalVisible(true)}
|
||||
>
|
||||
Create Project
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Card styles={{ body: { padding: 0 } }}>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{ padding: "12px 16px" }}
|
||||
>
|
||||
<Input
|
||||
prefix={<SearchIcon size={16} />}
|
||||
placeholder="Search projects by name, ID, description, or team..."
|
||||
style={{ maxWidth: 400 }}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Flex>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredProjects}
|
||||
rowKey="project_id"
|
||||
loading={isLoading}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
total: filteredProjects.length,
|
||||
onChange: (page) => setCurrentPage(page),
|
||||
size: "small",
|
||||
showTotal: (total) => `${total} projects`,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<CreateProjectModal
|
||||
isOpen={isCreateModalVisible}
|
||||
onClose={() => setIsCreateModalVisible(false)}
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
14
ui/litellm-dashboard/src/components/Projects/types.ts
Normal file
14
ui/litellm-dashboard/src/components/Projects/types.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
teamId: string;
|
||||
teamAlias: string;
|
||||
models: string[];
|
||||
status: "active" | "blocked";
|
||||
spend: number;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
DatabaseOutlined,
|
||||
ExperimentOutlined,
|
||||
FileTextOutlined,
|
||||
FolderOutlined,
|
||||
KeyOutlined,
|
||||
LineChartOutlined,
|
||||
PlayCircleOutlined,
|
||||
|
|
@ -172,6 +173,23 @@ const menuGroups: MenuGroup[] = [
|
|||
{
|
||||
groupLabel: "ACCESS CONTROL",
|
||||
items: [
|
||||
{
|
||||
key: "teams",
|
||||
page: "teams",
|
||||
label: "Teams",
|
||||
icon: <TeamOutlined />,
|
||||
},
|
||||
{
|
||||
key: "projects",
|
||||
page: "projects",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Projects <NewBadge />
|
||||
</span>
|
||||
),
|
||||
icon: <FolderOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "users",
|
||||
page: "users",
|
||||
|
|
@ -179,12 +197,6 @@ const menuGroups: MenuGroup[] = [
|
|||
icon: <UserOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "teams",
|
||||
page: "teams",
|
||||
label: "Teams",
|
||||
icon: <TeamOutlined />,
|
||||
},
|
||||
{
|
||||
key: "organizations",
|
||||
page: "organizations",
|
||||
|
|
@ -195,11 +207,7 @@ const menuGroups: MenuGroup[] = [
|
|||
{
|
||||
key: "access-groups",
|
||||
page: "access-groups",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Access Groups <NewBadge />
|
||||
</span>
|
||||
),
|
||||
label: "Access Groups",
|
||||
icon: <BlockOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const pageDescriptions: Record<string, string> = {
|
|||
users: "Manage internal user accounts and permissions",
|
||||
teams: "Create and manage teams for access control",
|
||||
organizations: "Manage organizations and their members",
|
||||
projects: "Manage projects within teams",
|
||||
"access-groups": "Manage access groups for role-based permissions",
|
||||
budgets: "Set and monitor spending budgets",
|
||||
api_ref: "Browse API documentation and endpoints",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue