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. +
- + + Back to Login +
); 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}