From 10c89b4e8318f74720962248f10790fb80f1cda5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 11:51:52 +0000 Subject: [PATCH] feat(ui): migrate policies chrome to shadcn - add_attachment_form: antd Form + Select -> RHF + shadcn Dialog + MultiSelect/SuggestibleTagInput - add_policy_form: antd Form + Modal + TextInput/Textarea -> RHF + shadcn Dialog + Input/Textarea - ai_suggestion_modal: antd Modal/Select/Checkbox/Tooltip/TextArea -> shadcn Dialog/Select/Checkbox/Tooltip/Textarea - index: Tremor TabGroup + antd Alert -> shadcn Tabs + Alert - pipeline_flow_builder: chrome-only migration; Tremor Button/TextInput + antd Select/Spin -> shadcn Button/Input/Select + LoaderCircle spinner (StepCard/Connector/PolicyVersionsSidebar internals untouched) - policy_test_panel: antd Form + Select -> RHF + shadcn Select/TagInput - template_parameter_modal: antd Modal/Radio/Select + Tremor Button/TextInput -> shadcn Dialog/RadioGroup/Select/Input/Button - Add ai_suggestion_modal.tsx to no-raw-tailwind-colors override (status/result categorical palettes kept raw) Co-authored-by: yuneng-jiang --- ui/litellm-dashboard/.eslintrc.json | 1 + .../policies/add_attachment_form.tsx | 639 ++++++++---- .../components/policies/add_policy_form.tsx | 908 +++++++++++------- .../policies/ai_suggestion_modal.tsx | 204 +++- .../src/components/policies/index.tsx | 371 +++---- .../policies/pipeline_flow_builder.tsx | 225 +++-- .../components/policies/policy_test_panel.tsx | 331 +++++-- .../policies/template_parameter_modal.tsx | 570 +++++++---- 8 files changed, 2187 insertions(+), 1062 deletions(-) diff --git a/ui/litellm-dashboard/.eslintrc.json b/ui/litellm-dashboard/.eslintrc.json index 14d1717172b..3fc9318498c 100644 --- a/ui/litellm-dashboard/.eslintrc.json +++ b/ui/litellm-dashboard/.eslintrc.json @@ -148,6 +148,7 @@ "src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx", "src/components/policies/impact_preview_alert.tsx", "src/components/policies/impact_popover.tsx", + "src/components/policies/ai_suggestion_modal.tsx", "src/components/DeletedKeysPage/DeletedKeysPage.tsx", "src/components/DeletedTeamsPage/DeletedTeamsPage.tsx", "src/components/molecules/models/columns.tsx", diff --git a/ui/litellm-dashboard/src/components/policies/add_attachment_form.tsx b/ui/litellm-dashboard/src/components/policies/add_attachment_form.tsx index f9bc5accc35..e574d9f4742 100644 --- a/ui/litellm-dashboard/src/components/policies/add_attachment_form.tsx +++ b/ui/litellm-dashboard/src/components/policies/add_attachment_form.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useState, useEffect } from "react"; -import { Form, Select } from "antd"; +import React, { useCallback, useState, useEffect, useMemo } from "react"; +import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -7,7 +7,18 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { X } from "lucide-react"; import { Policy } from "./types"; import { teamListCall, @@ -30,6 +41,370 @@ interface AddAttachmentFormProps { createAttachment: (accessToken: string, attachmentData: any) => Promise; } +interface AttachmentFormValues { + policy_names: string[]; + teams: string[]; + keys: string[]; + models: string[]; + tags: string[]; +} + +function MultiSelect({ + value, + onChange, + options, + placeholder, + emptyText, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: { label: string; value: string }[]; + placeholder: string; + emptyText: string; +}) { + const selected = useMemo(() => value ?? [], [value]); + const remaining = useMemo( + () => options.filter((o) => !selected.includes(o.value)), + [options, selected], + ); + + return ( +
+ + {selected.length > 0 && ( +
+ {selected.map((v) => { + const opt = options.find((o) => o.value === v); + return ( + + {opt?.label ?? v} + + + ); + })} +
+ )} +
+ ); +} + +/** + * Tag input that also supports selecting from a known list of suggestions + * (teams, keys, models). Mirrors antd's Select mode="tags" behaviour. + */ +function SuggestibleTagInput({ + value, + onChange, + options, + placeholder, + loadingText, + loading, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: { label: string; value: string }[]; + placeholder: string; + loadingText?: string; + loading?: boolean; +}) { + const [draft, setDraft] = useState(""); + const selected = value ?? []; + const remaining = useMemo( + () => options.filter((o) => !selected.includes(o.value)), + [options, selected], + ); + + const commit = (raw: string) => { + const parts = raw + .split(",") + .map((p) => p.trim()) + .filter(Boolean); + const next = [...selected]; + for (const p of parts) { + if (!next.includes(p)) next.push(p); + } + onChange(next); + setDraft(""); + }; + + return ( +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + if (draft.trim()) commit(draft); + } else if (e.key === "Backspace" && !draft && selected.length > 0) { + onChange(selected.slice(0, -1)); + } + }} + onBlur={() => { + if (draft.trim()) commit(draft); + }} + /> + {options.length > 0 && ( + + )} +
+ {selected.length > 0 && ( +
+ {selected.map((v) => ( + + {v} + + + ))} +
+ )} +
+ ); +} + +interface FieldsProps { + policyOptions: { label: string; value: string }[]; + scopeType: "global" | "specific"; + onScopeTypeChange: (scope: "global" | "specific") => void; + availableTeams: string[]; + availableKeys: string[]; + availableModels: string[]; + isLoadingTeams: boolean; + isLoadingKeys: boolean; + isLoadingModels: boolean; +} + +function AttachmentFormFields({ + policyOptions, + scopeType, + onScopeTypeChange, + availableTeams, + availableKeys, + availableModels, + isLoadingTeams, + isLoadingKeys, + isLoadingModels, +}: FieldsProps) { + const { control, formState } = useFormContext(); + + return ( + <> +
+ + + (v && v.length > 0) || "Please select at least one policy", + }} + render={({ field }) => ( + + )} + /> + {formState.errors.policy_names && ( +

+ {formState.errors.policy_names.message as string} +

+ )} +
+ +
+ Scope +
+
+ +
+ + onScopeTypeChange(v as "global" | "specific")} + className="flex flex-col gap-2" + > + + + +
+ + {scopeType === "specific" && ( + <> +
+ +

+ Select team aliases or enter custom patterns. Supports wildcards + (e.g., healthcare-*). +

+ ( + ({ label: t, value: t }))} + placeholder="Enter team alias and press Enter" + loadingText="Loading teams..." + loading={isLoadingTeams} + /> + )} + /> +
+ +
+ +

+ Select key aliases or enter custom patterns. Supports wildcards + (e.g., dev-*). +

+ ( + ({ label: k, value: k }))} + placeholder="Enter key alias and press Enter" + loadingText="Loading keys..." + loading={isLoadingKeys} + /> + )} + /> +
+ +
+ +

+ Model names this attachment applies to. Supports wildcards (e.g., + gpt-4*). Leave empty to apply to all models. +

+ ( + ({ label: m, value: m }))} + placeholder="Enter model name and press Enter" + loadingText="Loading models..." + loading={isLoadingModels} + /> + )} + /> +
+ +
+ +

+ Matches tags from key/team metadata.tags or tags + passed dynamically in the request body. Use * as a + suffix wildcard (e.g., prod-* matches{" "} + prod-us, prod-eu). +

+ ( + + )} + /> +
+ + )} + + ); +} + const AddAttachmentForm: React.FC = ({ visible, onClose, @@ -38,7 +413,15 @@ const AddAttachmentForm: React.FC = ({ policies, createAttachment, }) => { - const [form] = Form.useForm(); + const form = useForm({ + defaultValues: { + policy_names: [], + teams: [], + keys: [], + models: [], + tags: [], + }, + }); const [isSubmitting, setIsSubmitting] = useState(false); const [scopeType, setScopeType] = useState<"global" | "specific">("global"); const [availableTeams, setAvailableTeams] = useState([]); @@ -125,29 +508,32 @@ const AddAttachmentForm: React.FC = ({ }, [visible, accessToken, loadTeamsKeysAndModels]); const resetForm = () => { - form.resetFields(); + form.reset({ + policy_names: [], + teams: [], + keys: [], + models: [], + tags: [], + }); setScopeType("global"); setImpactResult(null); }; const handlePreviewImpact = async () => { if (!accessToken) return; - try { - await form.validateFields(["policy_names"]); - } catch { - return; - } + const valid = await form.trigger("policy_names"); + if (!valid) return; setIsEstimating(true); try { - const { policy_names = [] } = form.getFieldsValue(true); - const firstPolicy = policy_names?.[0]; + const values = form.getValues(); + const firstPolicy = values.policy_names?.[0]; if (!firstPolicy) return; const data = buildAttachmentData( { - ...form.getFieldsValue(true), + ...values, policy_name: firstPolicy, }, - scopeType + scopeType, ); const result = await estimateAttachmentImpactCall(accessToken, data); setImpactResult(result); @@ -163,16 +549,14 @@ const AddAttachmentForm: React.FC = ({ onClose(); }; - const handleSubmit = async () => { + const onSubmit = form.handleSubmit(async (values) => { try { setIsSubmitting(true); - await form.validateFields(); if (!accessToken) { throw new Error("No access token available"); } - const values = form.getFieldsValue(true); const selectedPolicyNames: string[] = values.policy_names || []; const results = await Promise.allSettled( @@ -182,30 +566,34 @@ const AddAttachmentForm: React.FC = ({ ...values, policy_name: policyName, }, - scopeType + scopeType, ); return createAttachment(accessToken, data); - }) + }), ); - const successCount = results.filter((r) => r.status === "fulfilled").length; - const failed = results.filter((r) => r.status === "rejected") as PromiseRejectedResult[]; + const successCount = results.filter( + (r) => r.status === "fulfilled", + ).length; + const failed = results.filter( + (r) => r.status === "rejected", + ) as PromiseRejectedResult[]; if (successCount > 0 && failed.length === 0) { NotificationsManager.success( successCount === 1 ? "Attachment created successfully" - : `${successCount} attachments created successfully` + : `${successCount} attachments created successfully`, ); } else if (successCount > 0 && failed.length > 0) { NotificationsManager.fromBackend( - `${successCount} attachments created, ${failed.length} failed` + `${successCount} attachments created, ${failed.length} failed`, ); } else { throw new Error( failed[0]?.reason instanceof Error ? failed[0].reason.message - : "Failed to create attachments" + : "Failed to create attachments", ); } @@ -215,12 +603,13 @@ const AddAttachmentForm: React.FC = ({ } catch (error) { console.error("Failed to create attachment:", error); NotificationsManager.fromBackend( - "Failed to create attachment: " + (error instanceof Error ? error.message : String(error)) + "Failed to create attachment: " + + (error instanceof Error ? error.message : String(error)), ); } finally { setIsSubmitting(false); } - }; + }); const policyOptions = policies.map((p) => ({ label: p.policy_name, @@ -232,180 +621,46 @@ const AddAttachmentForm: React.FC = ({ open={visible} onOpenChange={(o) => (!o ? handleClose() : undefined)} > - + Create Policy Attachment -
- - ({ - label: team, - value: team, - }))} - tokenSeparators={[","]} - showSearch - filterOption={(input, option) => - (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) - } - style={{ width: "100%" }} - /> - - - - ({ - label: model, - value: model, - }))} - tokenSeparators={[","]} - showSearch - filterOption={(input, option) => - (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) - } - style={{ width: "100%" }} - /> - - - - Matches tags from key/team metadata.tags or - tags passed dynamically in the request body. Use{" "} - * as a suffix wildcard (e.g.,{" "} - prod-* matches prod-us,{" "} - prod-eu). - - } - > - { + if (v) onChange([...selected, v]); + }} + > + + + + + {remaining.length === 0 ? ( +
+ {emptyText} +
+ ) : ( + remaining.map((opt) => ( + + {opt.label} + + )) + )} +
+ + {selected.length > 0 && ( +
+ {selected.map((v) => { + const opt = options.find((o) => o.value === v); + return ( + + {opt?.label ?? v} + + + ); + })} +
+ )} + + ); +} + // ───────────────────────────────────────────────────────────────────────────── // Mode Picker (Step 1) - shown first when creating a new policy +// The categorical palette (indigo accent) is intentionally kept raw to preserve +// the existing branded look. See docs/DEVIATIONS.md. // ───────────────────────────────────────────────────────────────────────────── -interface ModePicker { +interface ModePickerProps { selected: "simple" | "flow_builder"; onSelect: (mode: "simple" | "flow_builder") => void; } -const ModePicker: React.FC = ({ selected, onSelect }) => ( -
+const ModePicker: React.FC = ({ selected, onSelect }) => ( +
{/* Simple Mode Card */} + {/* eslint-disable-next-line litellm-ui/no-raw-tailwind-colors */}
onSelect("simple")} - style={{ - flex: 1, - padding: "24px 20px", - border: `2px solid ${selected === "simple" ? "#4f46e5" : "#e5e7eb"}`, - borderRadius: 12, - cursor: "pointer", - backgroundColor: selected === "simple" ? "#eef2ff" : "#fff", - transition: "all 0.15s ease", - }} + className={`flex-1 rounded-xl cursor-pointer transition-all p-6 border-2 ${ + selected === "simple" + ? "border-indigo-600 bg-indigo-50" + : "border-border bg-background" + }`} > + {/* eslint-disable-next-line litellm-ui/no-raw-tailwind-colors */}
- +
- - Simple Mode - - +
Simple Mode
+

Pick guardrails from a list. All run in parallel. - +

{/* Flow Builder Card */} + {/* eslint-disable-next-line litellm-ui/no-raw-tailwind-colors */}
onSelect("flow_builder")} - style={{ - flex: 1, - padding: "24px 20px", - border: `2px solid ${selected === "flow_builder" ? "#4f46e5" : "#e5e7eb"}`, - borderRadius: 12, - cursor: "pointer", - backgroundColor: selected === "flow_builder" ? "#eef2ff" : "#fff", - transition: "all 0.15s ease", - position: "relative", - }} + className={`flex-1 rounded-xl cursor-pointer transition-all p-6 border-2 relative ${ + selected === "flow_builder" + ? "border-indigo-600 bg-indigo-50" + : "border-border bg-background" + }`} > - NEW - + + {/* eslint-disable-next-line litellm-ui/no-raw-tailwind-colors */}
- +
- - Flow Builder - - +
Flow Builder
+

Define steps, conditions, and error responses. - +

); +// ───────────────────────────────────────────────────────────────────────────── +// Form fields +// ───────────────────────────────────────────────────────────────────────────── + +interface PolicyFieldsProps { + isEditing: boolean; + modelConditionType: "model" | "regex"; + onModelConditionTypeChange: (t: "model" | "regex") => void; + policyOptions: { label: string; value: string }[]; + guardrailOptions: { label: string; value: string }[]; + availableModels: string[]; + resolvedGuardrails: string[]; +} + +function PolicyFields({ + isEditing, + modelConditionType, + onModelConditionTypeChange, + policyOptions, + guardrailOptions, + availableModels, + resolvedGuardrails, +}: PolicyFieldsProps) { + const { control, register, formState } = useFormContext(); + + return ( + <> +
+ + + {formState.errors.policy_name && ( +

+ {formState.errors.policy_name.message as string} +

+ )} +
+ +
+ +