From aa093980b18d7f2415bb95ed2de243c6d18bee30 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 11:15:30 -0700 Subject: [PATCH] refactor(ui): migrate ten small dashboard files off antd and tremor Moves the onboarding views, router settings inputs, tag rate limit editor, fallback buttons, created-key display and the shared numerical input onto the in-repo shadcn layer. Each control has a direct equivalent, so this is a like-for-like swap with no layout changes and no new styling. Router settings saves by reading input values straight off the DOM with document.querySelector('input[name="..."]'), a path no test covered. Adds a regression test that types into a field and asserts the typed value reaches the payload, so the name attribute contract stays enforced. Also adds tests for TagRateLimitEditor, which had none and whose RPM cell switched from antd InputNumber to a native number input. --- ui/litellm-dashboard/eslint-suppressions.json | 42 ------ .../onboarding/OnboardingErrorView.test.tsx | 6 +- .../app/onboarding/OnboardingErrorView.tsx | 19 +-- .../onboarding/OnboardingLoadingView.test.tsx | 8 +- .../app/onboarding/OnboardingLoadingView.tsx | 5 +- .../RouterSettings/Fallbacks/AddFallbacks.tsx | 23 ++- .../TagRateLimitEditor.test.tsx | 133 ++++++++++++++++++ .../key_team_helpers/TagRateLimitEditor.tsx | 17 ++- .../LatencyBasedConfiguration.tsx | 2 +- .../ReliabilityRetriesSection.tsx | 2 +- .../TagFilteringToggle.test.tsx | 5 + .../router_settings/TagFilteringToggle.tsx | 10 +- .../components/router_settings/index.test.tsx | 27 ++++ .../src/components/router_settings/index.tsx | 8 +- .../components/shared/CreatedKeyDisplay.tsx | 6 +- .../src/components/shared/numerical_input.tsx | 9 +- 16 files changed, 225 insertions(+), 97 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.test.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index f73e3e6dda3..82697c75b83 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1667,21 +1667,11 @@ "count": 1 } }, - "src/app/onboarding/OnboardingErrorView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/onboarding/OnboardingFormBody.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/onboarding/OnboardingLoadingView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/AIHub/ModelHubTable.test.tsx": { "max-params": { "count": 1 @@ -1884,9 +1874,6 @@ } }, "src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2425,11 +2412,6 @@ "count": 1 } }, - "src/components/key_team_helpers/TagRateLimitEditor.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/key_team_helpers/fetch_available_models_team_key.tsx": { "local/filename-pascal-case": { "count": 1 @@ -2761,17 +2743,9 @@ "count": 1 } }, - "src/components/router_settings/LatencyBasedConfiguration.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/router_settings/ReliabilityRetriesSection.tsx": { "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/router_settings/RoutingStrategySelector.tsx": { @@ -2779,18 +2753,10 @@ "count": 1 } }, - "src/components/router_settings/TagFilteringToggle.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/router_settings/index.tsx": { "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "prefer-const": { "count": 2 } @@ -2832,11 +2798,6 @@ "count": 4 } }, - "src/components/shared/CreatedKeyDisplay.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/shared/advanced_date_picker.tsx": { "local/filename-pascal-case": { "count": 1 @@ -2901,9 +2862,6 @@ "src/components/shared/numerical_input.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/shared/table_cells/cell_tooltip.tsx": { diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx index bfbfb8fdd5d..f0bfdfef40d 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx @@ -9,6 +9,11 @@ describe("OnboardingErrorView", () => { expect(screen.getByText("Failed to load invitation")).toBeInTheDocument(); }); + it("should expose the failure as an alert to assistive technology", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("Failed to load invitation"); + }); + it("should show the expiry description", () => { render(); expect(screen.getByText("The invitation link may be invalid or expired.")).toBeInTheDocument(); @@ -16,7 +21,6 @@ describe("OnboardingErrorView", () => { it("should render a Back to Login link pointing to /ui/login/", () => { render(); - // antd Button with href renders as an element const link = screen.getByRole("link", { name: "Back to Login" }); expect(link).toHaveAttribute("href", "/ui/login/"); }); diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx index 3de9a9ffaae..2fa35205a82 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx @@ -1,18 +1,21 @@ import React from "react"; -import { Alert, Button } from "antd"; +import { CircleAlert } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { buttonVariants } from "@/components/ui/button"; import { getLoginUrl } from "@/utils/returnUrlUtils"; export function OnboardingErrorView() { return (
- + + + Failed to load invitation + The invitation link may be invalid or expired. +
); diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx index 21c5ccf69d0..755647fa3fc 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx @@ -1,12 +1,12 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import { OnboardingLoadingView } from "./OnboardingLoadingView"; describe("OnboardingLoadingView", () => { - it("should render a spinner container", () => { - const { container } = render(); - expect(container.firstChild).toBeInTheDocument(); + it("should expose the loading state to assistive technology", () => { + render(); + expect(screen.getByRole("status", { name: "Loading invitation" })).toBeInTheDocument(); }); it("should apply centering layout classes", () => { diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.tsx index 7efa1d2504f..4d5d2a1371e 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.tsx @@ -1,11 +1,10 @@ import React from "react"; -import { Spin } from "antd"; -import { LoadingOutlined } from "@ant-design/icons"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; export function OnboardingLoadingView() { return (
- } size="large" /> +
); } diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx index 6b2950b450d..2bd17bffaba 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx @@ -4,9 +4,9 @@ * Works with forms - reads from and writes to router_settings.fallbacks */ -import { Button as TremorButton } from "@tremor/react"; -import { Button } from "antd"; import React, { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import MessageManager from "@/components/molecules/message_manager"; import NotificationManager from "../../../molecules/notifications_manager"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; @@ -119,13 +119,10 @@ export default function AddFallbacks({ accessToken, value = [], onChange }: AddF return (
- setIsModalVisible(true)} - icon={() => +} - > + 0 && (
- -
diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.test.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.test.tsx new file mode 100644 index 00000000000..ba53837dc4d --- /dev/null +++ b/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.test.tsx @@ -0,0 +1,133 @@ +import React, { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import { TagRateLimitEditor, TagRateLimitEntry, tagLimitsToRows, tagRowsToLimits } from "./TagRateLimitEditor"; + +// The editor is controlled, so multi-character typing only behaves realistically +// when the parent feeds each change back in. +function Harness({ initial = [] as TagRateLimitEntry[], onValue }: { initial?: TagRateLimitEntry[]; onValue?: any }) { + const [rows, setRows] = useState(initial); + return ( + { + setRows(next); + onValue?.(next); + }} + /> + ); +} + +const rowsWith = (tag: string, rpm: number | null): TagRateLimitEntry[] => [{ id: "r1", tag, rpm_limit: rpm }]; + +describe("TagRateLimitEditor", () => { + it("should render one tag and one RPM field per row", () => { + render(); + expect(screen.getByRole("textbox", { name: "Tag" })).toHaveValue("cell-1"); + expect(screen.getByRole("spinbutton", { name: "RPM limit" })).toHaveValue(100); + }); + + it("should add a row when Add Tag Limit is clicked", async () => { + const user = userEvent.setup(); + render(); + expect(screen.queryAllByRole("textbox", { name: "Tag" })).toHaveLength(0); + + await user.click(screen.getByRole("button", { name: /add tag limit/i })); + + expect(screen.getAllByRole("textbox", { name: "Tag" })).toHaveLength(1); + }); + + it("should let the user type a tag name", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole("textbox", { name: "Tag" }), "cell-2"); + + expect(screen.getByRole("textbox", { name: "Tag" })).toHaveValue("cell-2"); + }); + + // The RPM cell feeds tagRowsToLimits, which drops any entry whose limit is not + // typeof "number". A string would silently discard the user's limit. + it("should record the typed RPM limit as a number, not a string", async () => { + const user = userEvent.setup(); + const seen: TagRateLimitEntry[][] = []; + render( seen.push(v)} />); + + await user.type(screen.getByRole("spinbutton", { name: "RPM limit" }), "60"); + + const latest = seen[seen.length - 1][0]; + expect(latest.rpm_limit).toBe(60); + expect(typeof latest.rpm_limit).toBe("number"); + }); + + it("should reset the RPM limit to null when the field is cleared", async () => { + const user = userEvent.setup(); + const seen: TagRateLimitEntry[][] = []; + render( seen.push(v)} />); + + await user.clear(screen.getByRole("spinbutton", { name: "RPM limit" })); + + expect(seen[seen.length - 1][0].rpm_limit).toBeNull(); + }); + + it("should remove only the clicked row", async () => { + const user = userEvent.setup(); + const initial: TagRateLimitEntry[] = [ + { id: "r1", tag: "keep-me", rpm_limit: 10 }, + { id: "r2", tag: "delete-me", rpm_limit: 20 }, + ]; + render(); + + await user.click(screen.getAllByRole("button", { name: "Remove tag limit" })[1]); + + const tags = screen.getAllByRole("textbox", { name: "Tag" }); + expect(tags).toHaveLength(1); + expect(tags[0]).toHaveValue("keep-me"); + }); + + it("should not submit the surrounding form when a row is removed", async () => { + const user = userEvent.setup(); + let submitted = false; + render( +
{ + submitted = true; + }} + > + + , + ); + + await user.click(screen.getByRole("button", { name: "Remove tag limit" })); + + expect(submitted).toBe(false); + expect(screen.queryAllByRole("textbox", { name: "Tag" })).toHaveLength(0); + }); +}); + +describe("tagRowsToLimits", () => { + it("should map named rows with numeric limits into the rpm map", () => { + expect(tagRowsToLimits([{ id: "a", tag: "cell-1", rpm_limit: 60 }])).toEqual({ tag_rpm_limit: { "cell-1": 60 } }); + }); + + it("should drop rows with a blank tag or a null limit", () => { + const rows: TagRateLimitEntry[] = [ + { id: "a", tag: " ", rpm_limit: 60 }, + { id: "b", tag: "cell-2", rpm_limit: null }, + ]; + expect(tagRowsToLimits(rows)).toEqual({ tag_rpm_limit: {} }); + }); +}); + +describe("tagLimitsToRows", () => { + it("should rebuild rows from a stored rpm map", () => { + const rows = tagLimitsToRows({ "cell-1": 60 }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ tag: "cell-1", rpm_limit: 60 }); + }); + + it("should ignore non-numeric entries", () => { + expect(tagLimitsToRows({ "cell-1": "sixty" })).toEqual([]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.tsx index ee022ee9a75..151e1593765 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.tsx @@ -1,5 +1,6 @@ -import { Button, Input, InputNumber } from "antd"; import React from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; export interface TagRateLimitEntry { // Stable identity for React list keys so deleting a middle row doesn't shift @@ -72,25 +73,29 @@ export function TagRateLimitEditor({ value, onChange }: TagRateLimitEditorProps) {value.map((row, idx) => (
updateRow(idx, "tag", e.target.value)} placeholder="Tag (e.g. cell-1)" style={{ width: 180 }} /> - updateRow(idx, "rpm_limit", v ?? null)} + value={row.rpm_limit ?? ""} + onChange={(e) => updateRow(idx, "rpm_limit", e.target.value === "" ? null : Number(e.target.value))} placeholder="RPM" style={{ width: 120 }} /> -
))} - +
); diff --git a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx index cbfd5b2f7fa..bcca3e698a5 100644 --- a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx +++ b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; -import { Button } from "antd"; +import { Button } from "@/components/ui/button"; import MessageManager from "@/components/molecules/message_manager"; interface CreatedKeyDisplayProps { @@ -41,9 +41,7 @@ const CreatedKeyDisplay: React.FC = ({ apiKey }) => { - + ); diff --git a/ui/litellm-dashboard/src/components/shared/numerical_input.tsx b/ui/litellm-dashboard/src/components/shared/numerical_input.tsx index 2682635dc27..c8c5d353a6d 100644 --- a/ui/litellm-dashboard/src/components/shared/numerical_input.tsx +++ b/ui/litellm-dashboard/src/components/shared/numerical_input.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { NumberInput } from "@tremor/react"; +import { Input } from "@/components/ui/input"; interface NumericalInputProps { step?: number; @@ -7,7 +7,7 @@ interface NumericalInputProps { placeholder?: string; min?: number; max?: number; - onChange?: any; // Using any to avoid type conflicts with Tremor's NumberInput + onChange?: any; // Using any to avoid type conflicts with callers that pass antd Form handlers [key: string]: any; } @@ -20,7 +20,7 @@ interface NumericalInputProps { * @param {number} [props.min] - Minimum value * @param {number} [props.max] - Maximum value * @param {Function} [props.onChange] - On change handler - * @param {any} props.rest - Additional props passed to NumberInput + * @param {any} props.rest - Additional props passed to Input */ const NumericalInput: React.FC = ({ step = 0.01, @@ -32,7 +32,8 @@ const NumericalInput: React.FC = ({ ...rest }) => { return ( - event.currentTarget.blur()} step={step} style={style}