diff --git a/ui/litellm-dashboard/docs/CYCLES.md b/ui/litellm-dashboard/docs/CYCLES.md index 5d1a19c3acc..8b4b4efaac3 100644 --- a/ui/litellm-dashboard/docs/CYCLES.md +++ b/ui/litellm-dashboard/docs/CYCLES.md @@ -77,6 +77,18 @@ Layer abbreviations: **TS** (tsc --noEmit), **Lint** (eslint), **Vitest** - cycle 1: TS ✓ | Lint ✓ | Vitest ✓ (4/4) | Build ✓ | Parity ⏭ | Snap ⏭ - Final status: **done (organization_view.tsx; OrganizationFilters and the route page already had no banned imports)** +## 4. Tag Management (tag-management) + +- Cycles used: 1 / 7 +- Layer outcomes per cycle: + - cycle 1: TS ✓ | Lint ✓ | Vitest ✓ (14/14) | Build ✓ | Parity ⏭ | Snap ⏭ +- Final status: **done** +- Scope: TagSelector (rewritten as chip-input over shadcn Popover/Input/Badge), + TagTable (shadcn Table + tanstack-react-table), CreateTagModal (rhf + Dialog), + index (shadcn Dialog for delete confirmation), tag_info (rhf + Card/Accordion). + Tests updated to use semantic queries (button disabled state) and to match + the rhf submit shape (object containing tag_name). + ## 24. Guardrails Monitor (guardrails-monitor) - Cycles used: 1 / 7 diff --git a/ui/litellm-dashboard/src/components/tag_management/TagSelector.test.tsx b/ui/litellm-dashboard/src/components/tag_management/TagSelector.test.tsx index 4b02dffa406..d137a4dfab2 100644 --- a/ui/litellm-dashboard/src/components/tag_management/TagSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/TagSelector.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import TagSelector from "./TagSelector"; describe("TagSelector", () => { @@ -8,14 +8,19 @@ describe("TagSelector", () => { }); it("should allow creating new tags", () => { - const { container } = render( {}} accessToken="test-token" />); + const onChange = vi.fn(); + const { container } = render( + , + ); const tagSelector = container.querySelector("input"); expect(tagSelector).toBeInTheDocument(); if (tagSelector) { fireEvent.change(tagSelector, { target: { value: "new-tag" } }); expect(tagSelector).toHaveValue("new-tag"); fireEvent.keyDown(tagSelector, { key: "Enter" }); - expect(tagSelector).toHaveValue("new-tag"); + // Post phase-1: pressing Enter commits the tag (clears the input); + // onChange fires with the new tag. + expect(onChange).toHaveBeenCalledWith(["new-tag"]); } }); }); diff --git a/ui/litellm-dashboard/src/components/tag_management/TagSelector.tsx b/ui/litellm-dashboard/src/components/tag_management/TagSelector.tsx index 736c8dc61cd..2894e3c145a 100644 --- a/ui/litellm-dashboard/src/components/tag_management/TagSelector.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/TagSelector.tsx @@ -1,7 +1,15 @@ -import React, { useEffect, useState } from "react"; -import { Select } from "antd"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Tag } from "./types"; import { tagListCall } from "../networking"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; interface TagSelectorProps { onChange: (selectedTags: string[]) => void; @@ -10,47 +18,139 @@ interface TagSelectorProps { accessToken: string; } -const TagSelector: React.FC = ({ onChange, value, className, accessToken }) => { +/** + * Free-form tag input. Supports: + * - selecting from a server-fetched suggestion list (popover) + * - typing a new tag and pressing Enter / typing a `,` to add it + * - removing existing tags via the X icon on each chip + * + * Replaces antd ` ({ - label: tag.name, - value: tag.name, - title: tag.description || tag.name, - }))} - optionFilterProp="label" - tokenSeparators={[","]} - maxTagCount="responsive" - allowClear - style={{ width: "100%" }} - /> + 0} onOpenChange={setOpen}> + +
inputRef.current?.focus()} + > + {selected.map((name) => ( + + {name} + + + ))} + { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + className="border-0 shadow-none focus-visible:ring-0 h-7 px-1 flex-1 min-w-[120px]" + /> +
+
+ e.preventDefault()} + > + {suggestions.map((t) => ( + + ))} + +
); }; diff --git a/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx b/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx index a56721787d5..f0cdcb5be84 100644 --- a/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx @@ -87,15 +87,18 @@ describe("TagTable", () => { 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"); + // Post phase-1 migration the action buttons are + + + + + + + {isDynamicSpendTag + ? "You cannot view the information of a dynamically generated spend tag" + : tag.name} + - + ); }, }, @@ -70,9 +89,16 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag cell: ({ row }) => { const tag = row.original; return ( - - {tag.description || "-"} - + + + + {tag.description || "-"} + + {tag.description && ( + {tag.description} + )} + + ); }, }, @@ -82,18 +108,21 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag cell: ({ row }) => { const tag = row.original; return ( -
+
{tag?.models?.length === 0 ? ( - - All Models - + All Models ) : ( tag?.models?.map((modelId) => ( - - - {tag.model_info?.[modelId] || modelId} + + + + + {tag.model_info?.[modelId] || modelId} + + + {`ID: ${modelId}`} - + )) )}
@@ -104,10 +133,11 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag header: "Created", accessorKey: "created_at", sortingFn: "datetime", - cell: ({ row }) => { - const tag = row.original; - return {new Date(tag.created_at).toLocaleDateString()}; - }, + cell: ({ row }) => ( + + {new Date(row.original.created_at).toLocaleDateString()} + + ), }, { id: "actions", @@ -116,45 +146,57 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag const tag = row.original; const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION; return ( -
- {isDynamicSpendTag ? ( - - +
+ + + + + + + {isDynamicSpendTag + ? "Dynamically generated spend tags cannot be edited" + : "Edit tag"} + - ) : ( - - onEdit(tag)} - className="cursor-pointer hover:text-blue-500" - /> + + + + + + + + {isDynamicSpendTag + ? "Dynamically generated spend tags cannot be deleted" + : "Delete tag"} + - )} - {isDynamicSpendTag ? ( - - - - ) : ( - - onDelete(tag.name)} - className="cursor-pointer hover:text-red-500" - /> - - )} +
); }, @@ -164,9 +206,7 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag const table = useReactTable({ data, columns, - state: { - sorting, - }, + state: { sorting }, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), @@ -177,39 +217,50 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag
- + {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( -
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )}
{header.id !== "actions" && (
{header.column.getIsSorted() ? ( { - asc: , - desc: , + asc: ( + + ), + desc: ( + + ), }[header.column.getIsSorted() as string] ) : ( - + )}
)}
-
+
))} ))} - + {table.getRowModel().rows.length > 0 ? ( table.getRowModel().rows.map((row) => ( @@ -219,7 +270,7 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag key={cell.id} className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${ cell.column.id === "actions" - ? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]" + ? "sticky right-0 bg-background shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]" : "" }`} > @@ -230,8 +281,11 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag )) ) : ( - -
+ +

No tags found

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 index 997faf4a004..509af21e10d 100644 --- a/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.test.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.test.tsx @@ -40,15 +40,18 @@ describe("CreateTagModal", () => { const user = userEvent.setup(); render(); - const tagNameInput = screen.getByLabelText("Tag Name"); + // The label includes a required indicator; match by partial text via regex. + 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", - }); + // Post phase-1: rhf submits the full default values bag, not just the + // touched fields. Assert the tag_name made it through. + expect(mockOnSubmit).toHaveBeenCalledWith( + expect.objectContaining({ tag_name: "test-tag" }), + ); }); it("should not submit form when tag name is missing", async () => { 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 4b1f7d34853..2fe5e71e86e 100644 --- a/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/components/CreateTagModal.tsx @@ -1,130 +1,308 @@ -import { InfoCircleOutlined } from "@ant-design/icons"; -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 { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Info, X } from "lucide-react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; import BudgetDurationDropdown from "../../common_components/budget_duration_dropdown"; import NumericalInput from "../../shared/numerical_input"; interface ModelInfo { model_name: string; - litellm_params: { - model: string; - }; - model_info: { - id: string; - }; + litellm_params: { model: string }; + model_info: { id: string }; } +interface CreateTagFormValues { + tag_name: string; + description: string; + allowed_llms: string[]; + max_budget: number | null; + budget_duration: string | null; +} + +const defaultValues: CreateTagFormValues = { + tag_name: "", + description: "", + allowed_llms: [], + max_budget: null, + budget_duration: null, +}; + interface CreateTagModalProps { visible: boolean; onCancel: () => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any onSubmit: (values: any) => void; availableModels: ModelInfo[]; } -const CreateTagModal: React.FC = ({ visible, onCancel, onSubmit, availableModels }) => { - const [form] = Form.useForm(); +function InfoTip({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + ); +} - const handleFinish = (values: any) => { +const CreateTagModal: React.FC = ({ + visible, + onCancel, + onSubmit, + availableModels, +}) => { + const form = useForm({ + defaultValues, + mode: "onSubmit", + }); + + const handleFinish = form.handleSubmit((values) => { onSubmit(values); - form.resetFields(); - }; + form.reset(defaultValues); + }); const handleCancel = () => { - form.resetFields(); + form.reset(defaultValues); onCancel(); }; + // Multi-select scaffolding (chips + add via Select). + const allowedLlms = form.watch("allowed_llms"); + const remainingModels = availableModels.filter( + (m) => !allowedLlms.includes(m.model_info.id), + ); + return ( - -
- - - - - - - - - - Allowed Models - - - - - } - name="allowed_llms" - > - - {availableModels.map((model) => ( - -
- {model.model_name} - ({model.model_info.id}) -
-
- ))} -
-
- - - - Budget & Rate Limits (Optional) - - - - Max Budget (USD){" "} - - - - - } - name="max_budget" - > - - - - Reset Budget{" "} - - - - - } - name="budget_duration" - > - form.setFieldValue("budget_duration", value)} /> - - -
-

- TPM/RPM limits for tags are not currently supported. If you need this feature, please{" "} - - create a GitHub issue - - . -

+ (!o ? handleCancel() : undefined)} + > + + + Create New Tag + + Define a new tag with allowed models and optional budget / rate + limits. + + + + +
+ + + {form.formState.errors.tag_name && ( +

+ {form.formState.errors.tag_name.message as string} +

+ )}
- - -
- -
- - +
+ +