diff --git a/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx b/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx new file mode 100644 index 0000000000..a56721787d --- /dev/null +++ b/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import TagTable from "./TagTable"; +import { Tag } from "./types"; + +describe("TagTable", () => { + const mockOnEdit = vi.fn(); + const mockOnDelete = vi.fn(); + const mockOnSelectTag = vi.fn(); + + const mockTag: Tag = { + name: "test-tag", + description: "Test description", + models: ["model-1", "model-2"], + model_info: { + "model-1": "GPT-4", + "model-2": "Claude-3", + }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }; + + const mockDynamicSpendTag: Tag = { + name: "dynamic-spend-tag", + description: + "This is just a spend tag that was passed dynamically in a request. It does not control any LLM models.", + models: [], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }; + + const defaultProps = { + data: [], + onEdit: mockOnEdit, + onDelete: mockOnDelete, + onSelectTag: mockOnSelectTag, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render", () => { + render(); + expect(screen.getByText("Tag Name")).toBeInTheDocument(); + expect(screen.getByText("Description")).toBeInTheDocument(); + expect(screen.getByText("Allowed Models")).toBeInTheDocument(); + expect(screen.getByText("Created")).toBeInTheDocument(); + expect(screen.getByText("Actions")).toBeInTheDocument(); + }); + + it("should display no tags found message when data is empty", () => { + render(); + expect(screen.getByText("No tags found")).toBeInTheDocument(); + }); + + it("should display tag name", () => { + render(); + expect(screen.getByText("test-tag")).toBeInTheDocument(); + }); + + it("should display tag description", () => { + render(); + expect(screen.getByText("Test description")).toBeInTheDocument(); + }); + + it("should display All Models badge when models array is empty", () => { + const tagWithNoModels: Tag = { + ...mockTag, + models: [], + }; + render(); + expect(screen.getByText("All Models")).toBeInTheDocument(); + }); + + it("should display formatted created date", () => { + render(); + const formattedDate = new Date(mockTag.created_at).toLocaleDateString(); + expect(screen.getByText(formattedDate)).toBeInTheDocument(); + }); + + it("should disable tag name button for dynamic spend tags", () => { + render(); + const tagButton = screen.getByRole("button", { name: "dynamic-spend-tag" }); + expect(tagButton).toBeDisabled(); + }); + + it("should disable edit icon for dynamic spend tags", () => { + render(); + const editIcon = screen.getByLabelText("Edit tag (disabled)"); + expect(editIcon).toBeInTheDocument(); + expect(editIcon).toHaveClass("cursor-not-allowed"); + }); + + it("should disable delete icon for dynamic spend tags", () => { + render(); + const deleteIcon = screen.getByLabelText("Delete tag (disabled)"); + expect(deleteIcon).toBeInTheDocument(); + expect(deleteIcon).toHaveClass("cursor-not-allowed"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx b/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx index aa43388893..ce28ac6e6f 100644 --- a/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx @@ -1,18 +1,4 @@ -import React from "react"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - Icon, - Button, - Badge, - Text, -} from "@tremor/react"; -import { PencilAltIcon, TrashIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline"; -import { Tooltip } from "antd"; +import { ChevronDownIcon, ChevronUpIcon, PencilAltIcon, SwitchVerticalIcon, TrashIcon } from "@heroicons/react/outline"; import { ColumnDef, flexRender, @@ -21,6 +7,20 @@ import { SortingState, useReactTable, } from "@tanstack/react-table"; +import { + Badge, + Button, + Icon, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, + Text, +} from "@tremor/react"; +import { Tooltip } from "antd"; +import React from "react"; import { Tag } from "./types"; interface TagTableProps { @@ -30,6 +30,9 @@ interface TagTableProps { onSelectTag: (tagName: string) => void; } +const DYNAMIC_SPEND_TAG_DESCRIPTION = + "This is just a spend tag that was passed dynamically in a request. It does not control any LLM models."; + const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag }) => { const [sorting, setSorting] = React.useState([{ id: "created_at", desc: true }]); @@ -39,14 +42,20 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag accessorKey: "name", cell: ({ row }) => { const tag = row.original; + const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION; return (
- + @@ -68,7 +77,7 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag }, }, { - header: "Allowed LLMs", + header: "Allowed Models", accessorKey: "models", cell: ({ row }) => { const tag = row.original; @@ -102,13 +111,50 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag }, { id: "actions", - header: "", + header: "Actions", cell: ({ row }) => { const tag = row.original; + const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION; return (
- onEdit(tag)} className="cursor-pointer" /> - onDelete(tag.name)} className="cursor-pointer" /> + {isDynamicSpendTag ? ( + + + + ) : ( + + onEdit(tag)} + className="cursor-pointer hover:text-blue-500" + /> + + )} + {isDynamicSpendTag ? ( + + + + ) : ( + + onDelete(tag.name)} + className="cursor-pointer hover:text-red-500" + /> + + )}
); }, diff --git a/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.test.tsx b/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.test.tsx new file mode 100644 index 0000000000..997faf4a00 --- /dev/null +++ b/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CreateTagModal from "./CreateTagModal"; + +describe("CreateTagModal", () => { + const mockOnCancel = vi.fn(); + const mockOnSubmit = vi.fn(); + const mockAvailableModels = [ + { + model_name: "GPT-4", + litellm_params: { model: "gpt-4" }, + model_info: { id: "model-1" }, + }, + { + model_name: "Claude-3", + litellm_params: { model: "claude-3" }, + model_info: { id: "model-2" }, + }, + ]; + + const defaultProps = { + visible: true, + onCancel: mockOnCancel, + onSubmit: mockOnSubmit, + availableModels: mockAvailableModels, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the modal", () => { + render(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Create New Tag")).toBeInTheDocument(); + }); + + it("should submit form with required tag name", async () => { + const user = userEvent.setup(); + render(); + + const tagNameInput = screen.getByLabelText("Tag Name"); + await user.type(tagNameInput, "test-tag"); + + const submitButton = screen.getByRole("button", { name: /Create Tag/i }); + await user.click(submitButton); + + expect(mockOnSubmit).toHaveBeenCalledWith({ + tag_name: "test-tag", + }); + }); + + it("should not submit form when tag name is missing", async () => { + const user = userEvent.setup(); + render(); + + const submitButton = screen.getByRole("button", { name: /Create Tag/i }); + await user.click(submitButton); + + // Form validation should prevent submission + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.tsx b/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.tsx index 4d1909abd9..3412d68452 100644 --- a/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.tsx @@ -1,9 +1,9 @@ -import React from "react"; -import { Button, TextInput, Accordion, AccordionHeader, AccordionBody, Title } from "@tremor/react"; -import { Modal, Form, Select as Select2, Tooltip, Input } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; -import NumericalInput from "../../shared/numerical_input"; +import { Accordion, AccordionBody, AccordionHeader, Button, TextInput, Title } from "@tremor/react"; +import { Form, Input, Modal, Select as Select2, Tooltip } from "antd"; +import React from "react"; import BudgetDurationDropdown from "../../common_components/budget_duration_dropdown"; +import NumericalInput from "../../shared/numerical_input"; interface ModelInfo { model_name: string; @@ -22,12 +22,7 @@ interface CreateTagModalProps { availableModels: ModelInfo[]; } -const CreateTagModal: React.FC = ({ - visible, - onCancel, - onSubmit, - availableModels, -}) => { +const CreateTagModal: React.FC = ({ visible, onCancel, onSubmit, availableModels }) => { const [form] = Form.useForm(); const handleFinish = (values: any) => { @@ -41,25 +36,9 @@ const CreateTagModal: React.FC = ({ }; return ( - -
- + + + @@ -70,15 +49,15 @@ const CreateTagModal: React.FC = ({ - Allowed Models{" "} - + Allowed Models + } name="allowed_llms" > - + {availableModels.map((model) => (
@@ -150,4 +129,3 @@ const CreateTagModal: React.FC = ({ }; export default CreateTagModal; - diff --git a/ui/litellm-dashboard/src/components/tag_management/tag_info.tsx b/ui/litellm-dashboard/src/components/tag_management/tag_info.tsx index 60cde134e6..1c66a107db 100644 --- a/ui/litellm-dashboard/src/components/tag_management/tag_info.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/tag_info.tsx @@ -1,5 +1,15 @@ import React, { useState, useEffect } from "react"; -import { Card, Text, Title, Button, Badge, Accordion, AccordionHeader, AccordionBody, Title as TremorTitle } from "@tremor/react"; +import { + Card, + Text, + Title, + Button, + Badge, + Accordion, + AccordionHeader, + AccordionBody, + Title as TremorTitle, +} from "@tremor/react"; import { Form, Input, Select as Select2, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { fetchUserModels } from "../organisms/create_key_button"; @@ -131,7 +141,7 @@ const TagInfoView: React.FC = ({ tagId, onClose, accessToken, - + @@ -141,15 +151,15 @@ const TagInfoView: React.FC = ({ tagId, onClose, accessToken, - Allowed LLMs{" "} - + Allowed Models + } name="models" > - + {userModels.map((modelId) => ( {getModelDisplayName(modelId)} @@ -228,7 +238,7 @@ const TagInfoView: React.FC = ({ tagId, onClose, accessToken, {tagDetails.description || "-"}
- Allowed LLMs + Allowed Models
{!tagDetails.models || tagDetails.models.length === 0 ? ( All Models @@ -256,30 +266,33 @@ const TagInfoView: React.FC = ({ tagId, onClose, accessToken, Budget & Rate Limits
- {tagDetails.litellm_budget_table.max_budget !== undefined && tagDetails.litellm_budget_table.max_budget !== null && ( -
- Max Budget - ${tagDetails.litellm_budget_table.max_budget} -
- )} + {tagDetails.litellm_budget_table.max_budget !== undefined && + tagDetails.litellm_budget_table.max_budget !== null && ( +
+ Max Budget + ${tagDetails.litellm_budget_table.max_budget} +
+ )} {tagDetails.litellm_budget_table.budget_duration && (
Budget Duration {tagDetails.litellm_budget_table.budget_duration}
)} - {tagDetails.litellm_budget_table.tpm_limit !== undefined && tagDetails.litellm_budget_table.tpm_limit !== null && ( -
- TPM Limit - {tagDetails.litellm_budget_table.tpm_limit.toLocaleString()} -
- )} - {tagDetails.litellm_budget_table.rpm_limit !== undefined && tagDetails.litellm_budget_table.rpm_limit !== null && ( -
- RPM Limit - {tagDetails.litellm_budget_table.rpm_limit.toLocaleString()} -
- )} + {tagDetails.litellm_budget_table.tpm_limit !== undefined && + tagDetails.litellm_budget_table.tpm_limit !== null && ( +
+ TPM Limit + {tagDetails.litellm_budget_table.tpm_limit.toLocaleString()} +
+ )} + {tagDetails.litellm_budget_table.rpm_limit !== undefined && + tagDetails.litellm_budget_table.rpm_limit !== null && ( +
+ RPM Limit + {tagDetails.litellm_budget_table.rpm_limit.toLocaleString()} +
+ )}
)}