diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 9de171397db..cad2874c1e6 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -984,11 +984,6 @@ "count": 2 } }, - "src/app/(dashboard)/projects/_components/ProjectsPage.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/prompts/_components/add_prompt_form.tsx": { "no-restricted-imports": { "count": 1 @@ -1528,14 +1523,6 @@ "count": 1 } }, - "src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx": { "no-restricted-imports": { "count": 1 @@ -2095,11 +2082,6 @@ "count": 1 } }, - "src/components/pass_through_settings.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/per_user_usage.tsx": { "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index 4bf4af0c7f6..aac5405ce6b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -26,7 +26,7 @@ import HealthCheckComponent from "../../../components/model_dashboard/HealthChec import ModelGroupAliasSettings from "../../../components/model_group_alias_settings"; import ModelInfoView from "../../../components/model_info_view"; import NotificationsManager from "../../../components/molecules/notifications_manager"; -import PassThroughSettings from "../../../components/pass_through_settings"; +import PassThroughSettings from "../../../components/PassThroughSettings/PassThroughSettings"; import TeamInfoView from "../../../components/team/TeamInfo"; import useAuthorized from "../hooks/useAuthorized"; @@ -396,7 +396,6 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te accessToken={accessToken} userRole={userRole} userID={userID} - modelData={processedModelData} premiumUser={premiumUser} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx index 6f8676360c4..61d42f2aac6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx @@ -22,6 +22,12 @@ vi.mock("@/components/common_components/DefaultProxyAdminTag", () => ({ default: ({ userId }: { userId: string }) => {userId}, })); +vi.mock("./ProjectKeysSection", () => ({ + ProjectKeysSection: ({ projectId }: { projectId: string }) => ( +
{projectId}
+ ), +})); + const mockProject: ProjectResponse = { project_id: "proj-1", project_alias: "My Project", @@ -98,6 +104,11 @@ describe("ProjectDetail", () => { expect(screen.getByRole("heading", { name: "My Project" })).toBeInTheDocument(); }); + it("should render the project keys section for the project", () => { + renderWithProviders(); + expect(screen.getByTestId("project-keys-section")).toHaveTextContent("proj-1"); + }); + it("should display 'Active' for a non-blocked project", () => { renderWithProviders(); expect(screen.getByText("Active")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx index 2bc8bc31129..fd043031b26 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx @@ -17,10 +17,11 @@ import { } from "antd"; import { LoadingOutlined } from "@ant-design/icons"; import { BarChart } from "@/components/shared/charts"; -import { ArrowLeftIcon, DollarSignIcon, EditIcon, KeyIcon, UsersIcon } from "lucide-react"; +import { ArrowLeftIcon, DollarSignIcon, EditIcon, UsersIcon } from "lucide-react"; import { useMemo, useState } from "react"; import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; import { EditProjectModal } from "./ProjectModals/EditProjectModal"; +import { ProjectKeysSection } from "./ProjectKeysSection"; const { Title, Text } = Typography; const { Content } = Layout; @@ -203,17 +204,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { {/* Keys & Team */} - - - Keys - - } - style={{ height: "100%" }} - > - - + { isLoading: false, }); renderWithProviders(); - expect(screen.getByText("42 keys")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("of 42"); }); it("should show 'No keys found' when the project has no keys", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx index 9f60db596e8..4266b238e21 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx @@ -1,6 +1,6 @@ import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; -import { LoadingOutlined } from "@ant-design/icons"; -import { Card, Flex, Input, Pagination, Spin } from "antd"; +import { PaginationState } from "@tanstack/react-table"; +import { Card, Flex, Input } from "antd"; import { KeyIcon, SearchIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { ProjectKeysTable } from "./ProjectKeysTable"; @@ -12,17 +12,16 @@ interface ProjectKeysSectionProps { const PAGE_SIZE = 5; export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) { - const [page, setPage] = useState(1); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE }); const [keyAlias, setKeyAlias] = useState(""); - const { data, isLoading } = useKeys(page, PAGE_SIZE, { + const { data, isLoading } = useKeys(pagination.pageIndex + 1, pagination.pageSize, { projectID: projectId, selectedKeyAlias: keyAlias || null, }); - // Reset to page 1 when filter changes useEffect(() => { - setPage(1); + setPagination((current) => ({ ...current, pageIndex: 0 })); }, [keyAlias]); const keys = data?.keys ?? []; @@ -38,7 +37,7 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) { } style={{ height: "100%" }} > - + } placeholder="Filter by key name..." @@ -48,19 +47,13 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) { allowClear size="small" /> - `${total} keys`} - /> } /> } : false} + totalCount={totalCount} + isLoading={isLoading} + pagination={pagination} + onPaginationChange={setPagination} /> ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.test.tsx index c0f12f77cfb..3685d30b414 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; import { renderWithProviders, screen } from "../../../../../tests/test-utils"; import { ProjectKeysTable } from "./ProjectKeysTable"; import { KeyResponse } from "@/components/key_team_helpers/key_list"; @@ -7,6 +8,13 @@ vi.mock("@/components/common_components/DefaultProxyAdminTag", () => ({ default: ({ userId }: { userId: string }) => {userId}, })); +const defaultProps = { + totalCount: 0, + isLoading: false, + pagination: { pageIndex: 0, pageSize: 5 }, + onPaginationChange: vi.fn(), +}; + function makeKey(overrides: Partial = {}): KeyResponse { return { token: "tok-abc123", @@ -70,52 +78,78 @@ function makeKey(overrides: Partial = {}): KeyResponse { describe("ProjectKeysTable", () => { it("should render", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getByRole("table")).toBeInTheDocument(); }); it("should display 'No keys found' when the keys list is empty", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getByText("No keys found")).toBeInTheDocument(); }); it("should display the key alias when provided", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getByText("My API Key")).toBeInTheDocument(); }); it("should display '—' when the key alias is null", () => { // Provide a user_id so only the alias column shows "—" (not the owner column too) - renderWithProviders(); + renderWithProviders( + , + ); expect(screen.getByText("—")).toBeInTheDocument(); }); it("should display the owner using user.user_email when available", () => { - const key = makeKey({ user: { user_id: "u1", user_email: "alice@example.com" } }); - renderWithProviders(); + const key = makeKey({ user: { user_id: "u1", user_email: "alice@example.com", user_alias: null } }); + renderWithProviders(); expect(screen.getByTestId("owner-tag")).toHaveTextContent("alice@example.com"); }); it("should fall back to user_id when user.user_email is absent", () => { const key = makeKey({ user_id: "user-99" }); - renderWithProviders(); + renderWithProviders(); expect(screen.getByTestId("owner-tag")).toHaveTextContent("user-99"); }); it("should display 'Never' in the Last Active column when last_active is null", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getByText("Never")).toBeInTheDocument(); }); it("should display a formatted date in the Last Active column when last_active is provided", () => { - renderWithProviders(); + renderWithProviders( + , + ); expect(screen.queryByText("Never")).not.toBeInTheDocument(); }); it("should render multiple keys as separate rows", () => { const keys = [makeKey({ token: "tok-1", key_alias: "Key One" }), makeKey({ token: "tok-2", key_alias: "Key Two" })]; - renderWithProviders(); + renderWithProviders(); expect(screen.getByText("Key One")).toBeInTheDocument(); expect(screen.getByText("Key Two")).toBeInTheDocument(); }); + + it("should show skeleton rows while loading", () => { + renderWithProviders(); + expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); + expect(screen.queryByText("No keys found")).not.toBeInTheDocument(); + }); + + it("should show the server-side total in the pagination footer", () => { + renderWithProviders(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-5 of 42"); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 9"); + }); + + it("should request the next page through the pagination footer", async () => { + const user = userEvent.setup(); + const onPaginationChange = vi.fn(); + renderWithProviders( + , + ); + await user.click(screen.getByTestId("pagination-next")); + expect(onPaginationChange).toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx index 8269c843b98..080aad7b26d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx @@ -1,59 +1,59 @@ +"use client"; + +import { OnChangeFn, PaginationState } from "@tanstack/react-table"; +import { KeyRound } from "lucide-react"; +import { useMemo } from "react"; + import { KeyResponse } from "@/components/key_team_helpers/key_list"; -import { Empty, Table, Tooltip } from "antd"; -import type { ColumnsType } from "antd/es/table"; -import type { SpinProps } from "antd"; -import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; -import { DateCell } from "@/components/shared/table_cells"; +import { DataTable } from "@/components/shared/DataTable"; + +import { getProjectKeysTableColumns } from "./ProjectKeysTableColumns"; interface ProjectKeysTableProps { keys: KeyResponse[]; - loading?: boolean | SpinProps; + totalCount: number; + isLoading: boolean; + pagination: PaginationState; + onPaginationChange: OnChangeFn; } -const columns: ColumnsType = [ - { - title: "Key Name", - dataIndex: "key_alias", - key: "key_alias", - render: (alias: string | null) => alias || "—", - }, - { - title: "Owner", - key: "owner", - render: (_: unknown, record: KeyResponse) => { - const email = record.user?.user_email ?? record.user_id ?? null; - if (!email) return "—"; - return ( - - - - ); - }, - }, - { - title: "Created", - dataIndex: "created_at", - key: "created_at", - render: (date: string) => , - }, - { - title: "Last Active", - dataIndex: "last_active", - key: "last_active", - render: (date: string | null) => , - }, -]; - -export function ProjectKeysTable({ keys, loading }: ProjectKeysTableProps) { +const PAGE_SIZE_OPTIONS = [5, 10, 25]; + +function EmptyState() { + return ( +
+
+ +
+
No keys found
+
Keys created in this project will show up here.
+
+ ); +} + +export function ProjectKeysTable({ + keys, + totalCount, + isLoading, + pagination, + onPaginationChange, +}: ProjectKeysTableProps) { + const columns = useMemo(() => getProjectKeysTableColumns(), []); + return ( - }} + getRowId={(key, index) => key.token || String(index)} + paginationMode="server" + pagination={pagination} + onPaginationChange={onPaginationChange} + rowCount={totalCount} + pageSizeOptions={PAGE_SIZE_OPTIONS} + isLoading={isLoading} + loadingMessage="Loading keys…" + noDataMessage={} + size="compact" /> ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTableColumns.tsx new file mode 100644 index 00000000000..b04a32844ea --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTableColumns.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; + +import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; +import { KeyResponse } from "@/components/key_team_helpers/key_list"; +import { CellTooltip, DateCell } from "@/components/shared/table_cells"; + +function OwnerCell({ record }: { record: KeyResponse }) { + const email = record.user?.user_email ?? record.user_id ?? null; + if (!email) return ; + return ( + + + + } + /> + ); +} + +export const getProjectKeysTableColumns = (): ColumnDef[] => [ + { + id: "key_alias", + accessorKey: "key_alias", + meta: { title: "Key Name" }, + header: "Key Name", + enableSorting: false, + cell: ({ row }) => ( + + {row.original.key_alias || "—"} + + ), + }, + { + id: "owner", + meta: { title: "Owner" }, + header: "Owner", + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "created_at", + accessorKey: "created_at", + meta: { title: "Created" }, + header: "Created", + size: 130, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "last_active", + accessorKey: "last_active", + meta: { title: "Last Active" }, + header: "Last Active", + size: 130, + enableSorting: false, + cell: ({ row }) => , + }, +]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx index 73232baa03a..1c06b61698b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; -import { renderWithProviders, screen, waitFor } from "../../../../../tests/test-utils"; +import { renderWithProviders, screen, waitFor, within } from "../../../../../tests/test-utils"; import { ProjectsPage } from "./ProjectsPage"; import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; @@ -141,7 +141,63 @@ describe("ProjectsPage", () => { it("should show the total project count in the pagination", () => { mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false }); renderWithProviders(); - expect(screen.getByText("2 projects")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-2 of 2"); + }); + + it("should show skeleton rows while projects are loading", () => { + mockUseProjects.mockReturnValue({ data: undefined, isLoading: true }); + renderWithProviders(); + expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); + }); + + it("should show the empty state when there are no projects", () => { + mockUseProjects.mockReturnValue({ data: [], isLoading: false }); + renderWithProviders(); + expect(screen.getByText("No projects yet")).toBeInTheDocument(); + }); + + it("should show the filtered empty state when a search matches nothing", async () => { + const user = userEvent.setup(); + mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false }); + renderWithProviders(); + await user.type(screen.getByPlaceholderText(/search projects/i), "zzz-no-match"); + await waitFor(() => { + expect(screen.getByText("No matching projects")).toBeInTheDocument(); + }); + }); + + it("should sort by name when the Name header is clicked", async () => { + const user = userEvent.setup(); + mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false }); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /^name$/i })); + let rows = screen.getAllByRole("row").slice(1); + expect(within(rows[0]).getByText("Alpha Project")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /^name$/i })); + rows = screen.getAllByRole("row").slice(1); + expect(within(rows[0]).getByText("Beta Project")).toBeInTheDocument(); + }); + + it("should reset to the first page when the search text changes", async () => { + const user = userEvent.setup(); + const manyProjects = Array.from({ length: 12 }, (_, i) => ({ + ...mockProjects[0], + project_id: `proj-${i + 1}`, + project_alias: `Project ${String(i + 1).padStart(2, "0")}`, + })); + mockUseProjects.mockReturnValue({ data: manyProjects, isLoading: false }); + renderWithProviders(); + + await user.click(screen.getByTestId("pagination-next")); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2"); + + await user.type(screen.getByPlaceholderText(/search projects/i), "Project 01"); + await waitFor(() => { + expect(screen.getByText("Project 01")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 1"); + }); }); it("should resolve team alias from the teams list in the Team column", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx index be989229022..58c2c4c3ad8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx @@ -1,27 +1,12 @@ -import { useProjects, ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; +import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; -import { DateCell, IdCell } from "@/components/shared/table_cells"; -import { LoadingOutlined, PlusOutlined } from "@ant-design/icons"; -import { - Button, - Card, - Flex, - Input, - Layout, - Pagination, - Space, - Spin, - Table, - Tag, - theme, - Tooltip, - Typography, -} from "antd"; -import type { ColumnsType } from "antd/es/table"; -import { LayersIcon, SearchIcon } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { PlusOutlined } from "@ant-design/icons"; +import { Button, Flex, Input, Layout, Space, theme, Typography } from "antd"; +import { SearchIcon } from "lucide-react"; +import { useMemo, useState } from "react"; import { CreateProjectModal } from "./ProjectModals/CreateProjectModal"; import { ProjectDetail } from "./ProjectDetailsPage"; +import { ProjectsTable } from "./ProjectsTable"; const { Title, Text } = Typography; const { Content } = Layout; @@ -34,14 +19,7 @@ export function ProjectsPage() { const [selectedProjectId, setSelectedProjectId] = useState(null); const [isCreateModalVisible, setIsCreateModalVisible] = useState(false); const [searchText, setSearchText] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const pageSize = 10; - useEffect(() => { - setCurrentPage(1); - }, [searchText]); - - // Build a team_id → team_alias lookup from the teams list const teamAliasMap = useMemo(() => { const map = new Map(); for (const team of teams ?? []) { @@ -50,7 +28,6 @@ export function ProjectsPage() { return map; }, [teams]); - // ---------- filtered data ---------- const filteredProjects = useMemo(() => { const list = projects ?? []; if (!searchText) return list; @@ -66,78 +43,6 @@ export function ProjectsPage() { }); }, [projects, searchText, teamAliasMap]); - // ---------- Ant Design columns ---------- - const columns: ColumnsType = [ - { - title: "ID", - dataIndex: "project_id", - key: "project_id", - width: 170, - render: (id: string) => , - }, - { - title: "Name", - dataIndex: "project_alias", - key: "project_alias", - sorter: (a, b) => (a.project_alias ?? "").localeCompare(b.project_alias ?? ""), - render: (alias: string | null) => alias ?? "—", - }, - { - title: "Team", - key: "team", - sorter: (a, b) => { - const aAlias = teamAliasMap.get(a.team_id ?? "") ?? ""; - const bAlias = teamAliasMap.get(b.team_id ?? "") ?? ""; - return aAlias.localeCompare(bAlias); - }, - render: (_: unknown, record: ProjectResponse) => { - if (!record.team_id) return "—"; - const alias = teamAliasMap.get(record.team_id); - if (alias) return alias; - if (isTeamsLoading) return } size="small" />; - return record.team_id; - }, - }, - { - title: "Models", - key: "models", - render: (_: unknown, record: ProjectResponse) => { - const models = record.models ?? []; - return ( - 0 ? models.join(", ") : "No models"}> - - - - {models.length} - - - - ); - }, - }, - { - title: "Status", - dataIndex: "blocked", - key: "status", - render: (blocked: boolean) => {blocked ? "Blocked" : "Active"}, - }, - { - title: "Created", - dataIndex: "created_at", - key: "created_at", - sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), - responsive: ["lg"], - render: (date: string) => , - }, - { - title: "Updated", - dataIndex: "updated_at", - key: "updated_at", - responsive: ["xl"], - render: (date: string) => , - }, - ]; - if (selectedProjectId) { return setSelectedProjectId(null)} />; } @@ -156,34 +61,25 @@ export function ProjectsPage() { - - - } - placeholder="Search projects by name, ID, description, or team..." - style={{ maxWidth: 400 }} - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear - /> - setCurrentPage(page)} - size="small" - showTotal={(total) => `${total} projects`} - showSizeChanger={false} - /> - -
+ } + placeholder="Search projects by name, ID, description, or team..." + style={{ maxWidth: 400 }} + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear /> - + + + 0} + onProjectClick={setSelectedProjectId} + teamAliasMap={teamAliasMap} + isTeamsLoading={isTeamsLoading} + /> setIsCreateModalVisible(false)} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.tsx new file mode 100644 index 00000000000..ad85019ca6d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { SortingState } from "@tanstack/react-table"; +import { FolderKanban } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; +import { DataTable } from "@/components/shared/DataTable"; + +import { getProjectsTableColumns } from "./ProjectsTableColumns"; + +interface ProjectsTableProps { + projects: ProjectResponse[]; + isLoading: boolean; + isFiltered: boolean; + onProjectClick: (projectId: string) => void; + teamAliasMap: Map; + isTeamsLoading: boolean; +} + +const PAGE_SIZE_OPTIONS = [10, 25, 50]; + +function EmptyState({ isFiltered }: { isFiltered: boolean }) { + return ( +
+
+ +
+
+ {isFiltered ? "No matching projects" : "No projects yet"} +
+
+ {isFiltered ? "Try a different search term." : "Create a project to organize keys within your teams."} +
+
+ ); +} + +export function ProjectsTable({ + projects, + isLoading, + isFiltered, + onProjectClick, + teamAliasMap, + isTeamsLoading, +}: ProjectsTableProps) { + const [sorting, setSorting] = useState([]); + + const columns = useMemo(() => { + const deps = { onProjectClick, teamAliasMap, isTeamsLoading }; + return getProjectsTableColumns(deps); + }, [onProjectClick, teamAliasMap, isTeamsLoading]); + + return ( + project.project_id || String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + paginationMode="client" + pageSizeOptions={PAGE_SIZE_OPTIONS} + isLoading={isLoading} + loadingMessage="Loading projects…" + noDataMessage={} + size="compact" + /> + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTableColumns.tsx new file mode 100644 index 00000000000..46fe259aed2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTableColumns.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { LayersIcon } from "lucide-react"; + +import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { CellTooltip, DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; + +function ProjectTeamCell({ + project, + teamAliasMap, + isTeamsLoading, +}: { + project: ProjectResponse; + teamAliasMap: Map; + isTeamsLoading: boolean; +}) { + if (!project.team_id) return ; + const alias = teamAliasMap.get(project.team_id); + if (alias) { + return ( + + {alias} + + ); + } + if (isTeamsLoading) return ; + return ( + + {project.team_id} + + ); +} + +function ProjectModelsCell({ project }: { project: ProjectResponse }) { + const models = project.models ?? []; + return ( + 0 ? models.join(", ") : "No models"} + trigger={ + + + {models.length} + + } + /> + ); +} + +interface ProjectsTableColumnsDeps { + onProjectClick: (projectId: string) => void; + teamAliasMap: Map; + isTeamsLoading: boolean; +} + +export const getProjectsTableColumns = ({ + onProjectClick, + teamAliasMap, + isTeamsLoading, +}: ProjectsTableColumnsDeps): ColumnDef[] => [ + { + id: "project_id", + accessorKey: "project_id", + meta: { title: "ID" }, + header: "ID", + size: 190, + enableSorting: false, + cell: ({ row }) => ( + onProjectClick(row.original.project_id)} + /> + ), + }, + { + id: "project_alias", + accessorFn: (row) => row.project_alias ?? "", + meta: { title: "Name" }, + header: ({ column }) => , + size: 200, + enableSorting: true, + cell: ({ row }) => ( + + {row.original.project_alias ?? "—"} + + ), + }, + { + id: "team", + accessorFn: (row) => teamAliasMap.get(row.team_id ?? "") ?? "", + meta: { title: "Team" }, + header: ({ column }) => , + size: 180, + enableSorting: true, + cell: ({ row }) => ( + + ), + }, + { + id: "models", + meta: { title: "Models", skeleton: "badge" }, + header: "Models", + size: 110, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "status", + accessorKey: "blocked", + meta: { title: "Status", skeleton: "badge" }, + header: "Status", + size: 110, + enableSorting: false, + cell: ({ row }) => ( + + ), + }, + { + id: "created_at", + accessorKey: "created_at", + sortingFn: "datetime", + meta: { title: "Created" }, + header: ({ column }) => , + size: 140, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "updated_at", + accessorKey: "updated_at", + meta: { title: "Updated" }, + header: "Updated", + size: 140, + enableSorting: false, + cell: ({ row }) => , + }, +]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/DocumentsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/DocumentsTable.test.tsx index c2425c8d651..bbeef5c2216 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/DocumentsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/DocumentsTable.test.tsx @@ -1,18 +1,10 @@ -import { render, screen, fireEvent, act } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import DocumentsTable from "./DocumentsTable"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + import { DocumentUpload } from "@/components/vector_store_management/types"; -// Mock antd message -vi.mock("antd", async () => { - const actual = await vi.importActual("antd"); - return { - ...actual, - message: { - success: vi.fn(), - }, - }; -}); +import DocumentsTable from "./DocumentsTable"; describe("DocumentsTable", () => { const mockDocuments: DocumentUpload[] = [ @@ -39,9 +31,12 @@ describe("DocumentsTable", () => { }, ]; - it("should render the table successfully", () => { - const onRemove = vi.fn(); - render(); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render every document row", () => { + render(); expect(screen.getByText("test1.pdf")).toBeInTheDocument(); expect(screen.getByText("test2.txt")).toBeInTheDocument(); @@ -49,8 +44,7 @@ describe("DocumentsTable", () => { }); it("should display correct status badges", () => { - const onRemove = vi.fn(); - render(); + render(); expect(screen.getByText("Ready")).toBeInTheDocument(); expect(screen.getByText("Uploading")).toBeInTheDocument(); @@ -58,45 +52,46 @@ describe("DocumentsTable", () => { }); it("should display file sizes", () => { - const onRemove = vi.fn(); - render(); + render(); expect(screen.getByText(/1000.00 KB/)).toBeInTheDocument(); expect(screen.getByText(/1.95 MB/)).toBeInTheDocument(); expect(screen.getByText(/500.00 KB/)).toBeInTheDocument(); }); - it("should call onRemove when delete button is clicked", () => { + it("should call onRemove through the actions menu", async () => { + const user = userEvent.setup(); const onRemove = vi.fn(); render(); - const deleteButtons = screen.getAllByLabelText(/delete/i); - - act(() => { - fireEvent.click(deleteButtons[0]); - }); + await user.click(screen.getByTestId("document-actions-1")); + await user.click(await screen.findByTestId("document-action-remove")); expect(onRemove).toHaveBeenCalledWith("1"); }); - it("should show empty state when no documents", () => { - const onRemove = vi.fn(); - render(); + it("should copy the document ID through the actions menu", async () => { + const user = userEvent.setup(); + render(); - expect(screen.getByText(/No documents uploaded yet/)).toBeInTheDocument(); + await user.click(screen.getByTestId("document-actions-2")); + await user.click(await screen.findByTestId("document-action-copy")); + + expect(await window.navigator.clipboard.readText()).toBe("2"); }); - it("should have action buttons for each document", () => { - const onRemove = vi.fn(); - render(); + it("should show the empty state when no documents", () => { + render(); + + expect(screen.getByText("No documents uploaded yet")).toBeInTheDocument(); + expect(screen.getByText("Upload documents above to get started.")).toBeInTheDocument(); + }); - // Each document should have 3 action buttons (view, copy, delete) - const viewButtons = screen.getAllByLabelText(/eye/i); - const copyButtons = screen.getAllByLabelText(/copy/i); - const deleteButtons = screen.getAllByLabelText(/delete/i); + it("should render one actions menu per document", () => { + render(); - expect(viewButtons).toHaveLength(3); - expect(copyButtons).toHaveLength(3); - expect(deleteButtons).toHaveLength(3); + for (const doc of mockDocuments) { + expect(screen.getByTestId(`document-actions-${doc.uid}`)).toBeInTheDocument(); + } }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/DocumentsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/DocumentsTable.tsx index d4c45a6b751..416fcb37a10 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/DocumentsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/DocumentsTable.tsx @@ -1,95 +1,40 @@ -import React from "react"; -import { Table, Tooltip } from "antd"; -import MessageManager from "@/components/molecules/message_manager"; -import { EyeOutlined, CopyOutlined, DeleteOutlined } from "@ant-design/icons"; -import { StatusBadge, type StatusTone } from "@/components/shared/table_cells"; +"use client"; + +import { Inbox } from "lucide-react"; +import React, { useMemo } from "react"; + +import { DataTable } from "@/components/shared/DataTable"; import { DocumentUpload } from "@/components/vector_store_management/types"; +import { getDocumentsTableColumns } from "./DocumentsTableColumns"; + interface DocumentsTableProps { documents: DocumentUpload[]; onRemove: (uid: string) => void; } -const DocumentsTable: React.FC = ({ documents, onRemove }) => { - const handleCopyId = (uid: string) => { - navigator.clipboard.writeText(uid); - MessageManager.success("Document ID copied to clipboard"); - }; - - const getStatusBadge = (status: DocumentUpload["status"]) => { - const statusConfig: Record = { - uploading: { tone: "info", label: "Uploading" }, - done: { tone: "success", label: "Ready" }, - error: { tone: "error", label: "Error" }, - removed: { tone: "neutral", label: "Removed" }, - }; - - const config: { tone: StatusTone; label: string } = statusConfig[status] ?? { tone: "neutral", label: status }; - return ; - }; - - const formatFileSize = (bytes?: number) => { - if (!bytes) return "-"; - const kb = bytes / 1024; - if (kb < 1024) return `${kb.toFixed(2)} KB`; - return `${(kb / 1024).toFixed(2)} MB`; - }; +function EmptyState() { + return ( +
+
+ +
+
No documents uploaded yet
+
Upload documents above to get started.
+
+ ); +} - const columns = [ - { - title: "Name", - dataIndex: "name", - key: "name", - render: (name: string, record: DocumentUpload) => ( -
- {name} - {record.size && ({formatFileSize(record.size)})} -
- ), - }, - { - title: "Status", - dataIndex: "status", - key: "status", - width: 150, - render: (status: DocumentUpload["status"]) => getStatusBadge(status), - }, - { - title: "Actions", - key: "actions", - width: 120, - render: (_: any, record: DocumentUpload) => ( -
- - {}} /> - - - handleCopyId(record.uid)} - /> - - - onRemove(record.uid)} - /> - -
- ), - }, - ]; +const DocumentsTable: React.FC = ({ documents, onRemove }) => { + const columns = useMemo(() => getDocumentsTableColumns({ onRemove }), [onRemove]); return ( -
document.uid || String(index)} + noDataMessage={} + size="compact" /> ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/DocumentsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/DocumentsTableColumns.tsx new file mode 100644 index 00000000000..9be9f806bf9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/DocumentsTableColumns.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, MoreHorizontal, Trash2 } from "lucide-react"; + +import { StatusBadge, type StatusTone } from "@/components/shared/table_cells"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { DocumentUpload } from "@/components/vector_store_management/types"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; + +const STATUS_CONFIG: Record = { + uploading: { tone: "info", label: "Uploading" }, + done: { tone: "success", label: "Ready" }, + error: { tone: "error", label: "Error" }, + removed: { tone: "neutral", label: "Removed" }, +}; + +function formatFileSize(bytes?: number): string { + if (!bytes) return "-"; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(2)} KB`; + return `${(kb / 1024).toFixed(2)} MB`; +} + +function DocumentRowActions({ document, onRemove }: { document: DocumentUpload; onRemove: (uid: string) => void }) { + return ( + + + + + + void copyToClipboard(document.uid, "Document ID copied to clipboard")} + > + + Copy document ID + + onRemove(document.uid)} + > + + Remove + + + + ); +} + +interface DocumentsTableColumnsDeps { + onRemove: (uid: string) => void; +} + +export const getDocumentsTableColumns = ({ onRemove }: DocumentsTableColumnsDeps): ColumnDef[] => [ + { + id: "name", + accessorKey: "name", + meta: { title: "Name" }, + header: "Name", + enableSorting: false, + cell: ({ row }) => ( +
+ + {row.original.name} + + {row.original.size ? ( + ({formatFileSize(row.original.size)}) + ) : null} +
+ ), + }, + { + id: "status", + accessorKey: "status", + meta: { title: "Status", skeleton: "badge" }, + header: "Status", + size: 150, + enableSorting: false, + cell: ({ row }) => { + const config = STATUS_CONFIG[row.original.status] ?? { tone: "neutral", label: row.original.status }; + return ; + }, + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.test.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.test.tsx new file mode 100644 index 00000000000..040fa463f9c --- /dev/null +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.test.tsx @@ -0,0 +1,130 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { PassThroughEndpointsTable } from "./PassThroughEndpointsTable"; +import type { passThroughItem } from "./PassThroughSettings"; + +const endpoints: passThroughItem[] = [ + { + id: "ep-1", + path: "/v1/rerank", + target: "https://api.cohere.com/v1/rerank", + headers: { Authorization: "Bearer secret-value" }, + auth: true, + methods: ["POST"], + }, + { + id: "ep-2", + path: "/bria", + target: "https://engine.prod.bria-api.com", + headers: {}, + auth: false, + }, +]; + +const defaultProps = { + endpoints, + isLoading: false, + onEndpointClick: vi.fn(), + onDeleteClick: vi.fn(), +}; + +describe("PassThroughEndpointsTable", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render a row per endpoint with path and target", () => { + render(); + expect(screen.getByText("/v1/rerank")).toBeInTheDocument(); + expect(screen.getByText("https://api.cohere.com/v1/rerank")).toBeInTheDocument(); + expect(screen.getByText("/bria")).toBeInTheDocument(); + }); + + it("should open the endpoint when its ID is clicked", async () => { + const user = userEvent.setup(); + const onEndpointClick = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "ep-1" })); + expect(onEndpointClick).toHaveBeenCalledWith("ep-1"); + }); + + it("should show method chips, or ALL when no methods are set", () => { + render(); + expect(screen.getByText("POST")).toBeInTheDocument(); + expect(screen.getByText("ALL")).toBeInTheDocument(); + }); + + it("should show authentication as Yes or No", () => { + render(); + expect(screen.getByText("Yes")).toBeInTheDocument(); + expect(screen.getByText("No")).toBeInTheDocument(); + }); + + it("should mask headers until the visibility toggle is clicked", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByText(/secret-value/)).not.toBeInTheDocument(); + const toggles = screen.getAllByRole("button", { name: "Show headers" }); + await user.click(toggles[0]); + expect(screen.getByText(/secret-value/)).toBeInTheDocument(); + }); + + it("should edit and delete an endpoint through the actions menu", async () => { + const user = userEvent.setup(); + const onEndpointClick = vi.fn(); + const onDeleteClick = vi.fn(); + render( + , + ); + + await user.click(screen.getByTestId("endpoint-actions-ep-1")); + await user.click(await screen.findByTestId("endpoint-action-edit")); + expect(onEndpointClick).toHaveBeenCalledWith("ep-1"); + + await user.click(screen.getByTestId("endpoint-actions-ep-1")); + await user.click(await screen.findByTestId("endpoint-action-delete")); + expect(onDeleteClick).toHaveBeenCalledWith("ep-1"); + }); + + it("should disable edit and delete for endpoints without an id", async () => { + const user = userEvent.setup(); + const onEndpointClick = vi.fn(); + const onDeleteClick = vi.fn(); + const endpointWithoutId: passThroughItem = { path: "/legacy", target: "https://legacy.example.com", headers: {} }; + render( + , + ); + + await user.click(screen.getByTestId("endpoint-actions-/legacy")); + const editItem = await screen.findByTestId("endpoint-action-edit"); + const deleteItem = await screen.findByTestId("endpoint-action-delete"); + + expect(editItem).toHaveAttribute("data-disabled"); + expect(deleteItem).toHaveAttribute("data-disabled"); + + await user.click(editItem); + await user.click(deleteItem); + + expect(onEndpointClick).not.toHaveBeenCalled(); + expect(onDeleteClick).not.toHaveBeenCalled(); + }); + + it("should show the empty state when there are no endpoints", () => { + render(); + expect(screen.getByText("No pass-through endpoints configured")).toBeInTheDocument(); + }); + + it("should show skeleton rows while loading", () => { + render(); + expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); + expect(screen.queryByText("No pass-through endpoints configured")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.tsx new file mode 100644 index 00000000000..754e7ff68dd --- /dev/null +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Waypoints } from "lucide-react"; +import { useMemo } from "react"; + +import { DataTable } from "@/components/shared/DataTable"; + +import { getPassThroughEndpointsTableColumns } from "./PassThroughEndpointsTableColumns"; +import type { passThroughItem } from "./PassThroughSettings"; + +interface PassThroughEndpointsTableProps { + endpoints: passThroughItem[]; + isLoading: boolean; + onEndpointClick: (endpointId: string) => void; + onDeleteClick: (endpointId: string) => void; +} + +function EmptyState() { + return ( +
+
+ +
+
No pass-through endpoints configured
+
Add a pass-through endpoint to route custom paths.
+
+ ); +} + +export function PassThroughEndpointsTable({ + endpoints, + isLoading, + onEndpointClick, + onDeleteClick, +}: PassThroughEndpointsTableProps) { + const columns = useMemo( + () => getPassThroughEndpointsTableColumns({ onEndpointClick, onDeleteClick }), + [onEndpointClick, onDeleteClick], + ); + + return ( + endpoint.id || endpoint.path || String(index)} + isLoading={isLoading} + loadingMessage="Loading pass-through endpoints…" + noDataMessage={} + size="compact" + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.tsx new file mode 100644 index 00000000000..d22b274861a --- /dev/null +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Eye, EyeOff, Info, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import React, { useState } from "react"; + +import { CellTooltip, IdentityCell, StatusBadge } from "@/components/shared/table_cells"; +import { Badge } from "@/components/ui/badge"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; + +import type { passThroughItem } from "./PassThroughSettings"; + +function HeaderWithTooltip({ title, tooltip }: { title: string; tooltip: string }) { + return ( +
+ {title} + } /> +
+ ); +} + +function HeadersCell({ value }: { value: object }) { + const [showHeaders, setShowHeaders] = useState(false); + const headerString = JSON.stringify(value); + + return ( +
+ {showHeaders ? headerString : "••••••••"} + +
+ ); +} + +function MethodsCell({ methods }: { methods: string[] | undefined }) { + if (!methods || methods.length === 0) { + return ALL; + } + return ( +
+ {methods.map((method) => ( + + {method} + + ))} +
+ ); +} + +interface EndpointRowActionsProps { + endpoint: passThroughItem; + onEndpointClick: (endpointId: string) => void; + onDeleteClick: (endpointId: string) => void; +} + +function EndpointRowActions({ endpoint, onEndpointClick, onDeleteClick }: EndpointRowActionsProps) { + const endpointId = endpoint.id; + return ( + + + + + + endpointId && onEndpointClick(endpointId)} + > + + Edit + + + endpointId && onDeleteClick(endpointId)} + > + + Delete + + + + ); +} + +interface PassThroughEndpointsTableColumnsDeps { + onEndpointClick: (endpointId: string) => void; + onDeleteClick: (endpointId: string) => void; +} + +export const getPassThroughEndpointsTableColumns = ({ + onEndpointClick, + onDeleteClick, +}: PassThroughEndpointsTableColumnsDeps): ColumnDef[] => [ + { + id: "id", + accessorKey: "id", + meta: { title: "ID" }, + header: "ID", + size: 190, + enableSorting: false, + cell: ({ row }) => { + const endpointId = row.original.id; + if (!endpointId) return ; + return ( + onEndpointClick(endpointId)} + /> + ); + }, + }, + { + id: "path", + accessorKey: "path", + meta: { title: "Path" }, + header: "Path", + size: 200, + enableSorting: false, + cell: ({ row }) => ( + + {row.original.path} + + ), + }, + { + id: "target", + accessorKey: "target", + meta: { title: "Target" }, + header: "Target", + size: 240, + enableSorting: false, + cell: ({ row }) => ( + + {row.original.target} + + ), + }, + { + id: "methods", + meta: { title: "Methods", skeleton: "chips" }, + header: () => , + size: 150, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "auth", + accessorKey: "auth", + meta: { title: "Authentication", skeleton: "badge" }, + header: () => , + size: 140, + enableSorting: false, + cell: ({ row }) => ( + + ), + }, + { + id: "headers", + meta: { title: "Headers" }, + header: "Headers", + size: 180, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.test.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.test.tsx new file mode 100644 index 00000000000..90269a916f7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.test.tsx @@ -0,0 +1,120 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { deletePassThroughEndpointsCall, getPassThroughEndpointsCall } from "../networking"; +import PassThroughSettings from "./PassThroughSettings"; +import type { PassThroughEndpointsTable } from "./PassThroughEndpointsTable"; + +vi.mock("../networking", () => ({ + getPassThroughEndpointsCall: vi.fn(), + deletePassThroughEndpointsCall: vi.fn(), +})); + +vi.mock("../add_pass_through", () => ({ + default: () =>
, +})); + +vi.mock("../pass_through_info", () => ({ + default: ({ endpointData }: { endpointData: { id?: string } }) => ( +
{endpointData.id}
+ ), +})); + +vi.mock("../molecules/notifications_manager", () => ({ + __esModule: true, + default: { success: vi.fn(), fromBackend: vi.fn() }, +})); + +vi.mock("./PassThroughEndpointsTable", () => ({ + PassThroughEndpointsTable: (props: React.ComponentProps) => ( +
+ {props.endpoints.map((endpoint) => ( +
+ + +
+ ))} +
+ ), +})); + +const mockGetEndpoints = vi.mocked(getPassThroughEndpointsCall); +const mockDeleteEndpoint = vi.mocked(deletePassThroughEndpointsCall); + +const defaultProps = { + accessToken: "token", + userRole: "Admin", + userID: "user-1", + premiumUser: false, +}; + +const endpoint = { id: "ep-1", path: "/v1/rerank", target: "https://example.com", headers: {} }; + +describe("PassThroughSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetEndpoints.mockResolvedValue({ endpoints: [endpoint] }); + }); + + it("should render nothing without an access token", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("should hold the table in loading state until the fetch settles", async () => { + let resolveEndpoints: (value: { endpoints: (typeof endpoint)[] }) => void = () => {}; + mockGetEndpoints.mockReturnValue( + new Promise((resolve) => { + resolveEndpoints = resolve; + }), + ); + + render(); + expect(screen.getByTestId("endpoints-table")).toHaveAttribute("data-loading", "true"); + + await act(async () => { + resolveEndpoints({ endpoints: [endpoint] }); + }); + + await waitFor(() => { + expect(screen.getByTestId("endpoints-table")).toHaveAttribute("data-loading", "false"); + }); + }); + + it("should resolve loading without fetching when the user id is missing", async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId("endpoints-table")).toHaveAttribute("data-loading", "false"); + }); + expect(mockGetEndpoints).not.toHaveBeenCalled(); + }); + + it("should swap to the endpoint info view when an endpoint is opened", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByText("open-ep-1")); + expect(screen.getByTestId("endpoint-info")).toHaveTextContent("ep-1"); + }); + + it("should confirm before deleting an endpoint", async () => { + const user = userEvent.setup(); + mockDeleteEndpoint.mockResolvedValue(undefined); + render(); + + await user.click(await screen.findByText("delete-ep-1")); + expect(screen.getByText("Delete Pass-Through Endpoint")).toBeInTheDocument(); + expect(mockDeleteEndpoint).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Delete" })); + await waitFor(() => { + expect(mockDeleteEndpoint).toHaveBeenCalledWith("token", "ep-1"); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx new file mode 100644 index 00000000000..975ba0b70e1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx @@ -0,0 +1,176 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { deletePassThroughEndpointsCall, getPassThroughEndpointsCall } from "../networking"; +import AddPassThroughEndpoint from "../add_pass_through"; +import PassThroughInfoView from "../pass_through_info"; +import NotificationsManager from "../molecules/notifications_manager"; +import { PassThroughEndpointsTable } from "./PassThroughEndpointsTable"; + +interface PassThroughSettingsProps { + accessToken: string | null; + userRole: string | null; + userID: string | null; + premiumUser?: boolean; +} + +export interface passThroughItem { + id?: string; + path: string; + target: string; + headers: object; + include_subpath?: boolean; + cost_per_request?: number; + timeout?: number; + auth?: boolean; + methods?: string[]; + guardrails?: Record; + default_query_params?: Record; +} + +const PassThroughSettings: React.FC = ({ accessToken, userRole, userID, premiumUser }) => { + const [generalSettings, setGeneralSettings] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [selectedEndpointId, setSelectedEndpointId] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [endpointToDelete, setEndpointToDelete] = useState(null); + + useEffect(() => { + const fetchEndpoints = async () => { + if (!accessToken || !userRole || !userID) { + setIsLoading(false); + return; + } + try { + const data = await getPassThroughEndpointsCall(accessToken); + setGeneralSettings(data["endpoints"]); + } finally { + setIsLoading(false); + } + }; + fetchEndpoints(); + }, [accessToken, userRole, userID]); + + const handleEndpointUpdated = () => { + if (accessToken) { + getPassThroughEndpointsCall(accessToken).then((data) => { + setGeneralSettings(data["endpoints"]); + }); + } + }; + + const handleDelete = (endpointId: string) => { + setEndpointToDelete(endpointId); + setIsDeleteModalOpen(true); + }; + + const confirmDelete = async () => { + if (endpointToDelete == null || !accessToken) { + return; + } + + try { + await deletePassThroughEndpointsCall(accessToken, endpointToDelete); + + const updatedSettings = generalSettings.filter((setting) => setting.id !== endpointToDelete); + setGeneralSettings(updatedSettings); + + NotificationsManager.success("Endpoint deleted successfully."); + } catch (error) { + console.error("Error deleting the endpoint:", error); + NotificationsManager.fromBackend("Error deleting the endpoint: " + error); + } + + setIsDeleteModalOpen(false); + setEndpointToDelete(null); + }; + + const cancelDelete = () => { + setIsDeleteModalOpen(false); + setEndpointToDelete(null); + }; + + if (!accessToken) { + return null; + } + + if (selectedEndpointId) { + const selectedEndpoint = generalSettings.find((endpoint) => endpoint.id === selectedEndpointId); + + if (!selectedEndpoint) { + return
Endpoint not found
; + } + + return ( + setSelectedEndpointId(null)} + accessToken={accessToken} + isAdmin={userRole === "Admin" || userRole === "admin"} + premiumUser={premiumUser} + onEndpointUpdated={handleEndpointUpdated} + /> + ); + } + + return ( +
+
+

Pass Through Endpoints

+

Configure and manage your pass-through endpoints

+
+ + + + + + {isDeleteModalOpen && ( +
+
+ + + + +
+
+
+
+

Delete Pass-Through Endpoint

+
+

+ Are you sure you want to delete this pass-through endpoint? This action cannot be undone. +

+
+
+
+
+
+ + +
+
+
+
+ )} +
+ ); +}; + +export default PassThroughSettings; diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx index 0533b98b762..3ef7dee01b0 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx @@ -1,28 +1,43 @@ -import { render } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + import { LoggingCallbacksTable } from "./LoggingCallbacksTable"; +const baseVars = { + SLACK_WEBHOOK_URL: null, + LANGFUSE_PUBLIC_KEY: null, + LANGFUSE_SECRET_KEY: null, + LANGFUSE_HOST: null, + OPENMETER_API_KEY: null, +}; + describe("LoggingCallbacksTable", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("should render", () => { - const { getByText } = render(); - expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); + render(); + expect(screen.getByText("Active Logging Callbacks")).toBeInTheDocument(); + }); + + it("should show the empty state when there are no callbacks", () => { + render(); + expect(screen.getByText("No callbacks configured")).toBeInTheDocument(); + expect(screen.getByText("Add your first callback to start logging data to external services.")).toBeInTheDocument(); + }); + + it("should show skeleton rows while loading", () => { + render(); + expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); + expect(screen.queryByText("No callbacks configured")).not.toBeInTheDocument(); }); it('should map "otel" to "OpenTelemetry" on the table', () => { - const { getByText } = render( + render( { }} />, ); - expect(getByText("OpenTelemetry")).toBeInTheDocument(); + expect(screen.getByText("OpenTelemetry")).toBeInTheDocument(); }); it("should fallback to original callback name when not in availableCallbacks", () => { - const { getByText } = render( + render( , + ); + expect(screen.getByText("custom_callback_x")).toBeInTheDocument(); + }); + + it("should call onAdd when the Add Callback button is clicked", async () => { + const user = userEvent.setup(); + const onAdd = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: /add callback/i })); + expect(onAdd).toHaveBeenCalled(); + }); + + it("should test, edit, and delete a callback through the actions menu", async () => { + const user = userEvent.setup(); + const onTest = vi.fn(); + const onEdit = vi.fn(); + const onDelete = vi.fn(); + const callback = { name: "langfuse", type: "success" as const, variables: baseVars }; + render( + , ); - expect(getByText("custom_callback_x")).toBeInTheDocument(); + + await user.click(screen.getByTestId("callback-actions-langfuse-success")); + await user.click(await screen.findByTestId("callback-action-test")); + expect(onTest).toHaveBeenCalledWith(callback); + + await user.click(screen.getByTestId("callback-actions-langfuse-success")); + await user.click(await screen.findByTestId("callback-action-edit")); + expect(onEdit).toHaveBeenCalledWith(callback); + + await user.click(screen.getByTestId("callback-actions-langfuse-success")); + await user.click(await screen.findByTestId("callback-action-delete")); + expect(onDelete).toHaveBeenCalledWith(callback); }); // Regression: `/get_callbacks` returns the same `name` twice when a @@ -61,16 +102,9 @@ describe("LoggingCallbacksTable", () => { // → POST to spend-log on both 200 and 4xx/5xx). The UI used to ignore // the `type` field and render every row as "Success", masking the // failure registration. Reading `record.type` fixes the badge AND - // composing the rowKey with type avoids React's duplicate-key warning. + // composing the row id with type avoids React's duplicate-key warning. it("renders distinct Success and Failure badges for same-name dual registration", () => { - const baseVars = { - SLACK_WEBHOOK_URL: null, - LANGFUSE_PUBLIC_KEY: null, - LANGFUSE_SECRET_KEY: null, - LANGFUSE_HOST: null, - OPENMETER_API_KEY: null, - }; - const { getAllByText, getByText } = render( + render( { }} />, ); - // Both rows show the same display name, but distinct mode badges. - expect(getAllByText("Custom Callback API")).toHaveLength(2); - expect(getByText("Success")).toBeInTheDocument(); - expect(getByText("Failure")).toBeInTheDocument(); + expect(screen.getAllByText("Custom Callback API")).toHaveLength(2); + expect(screen.getByText("Success")).toBeInTheDocument(); + expect(screen.getByText("Failure")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx index 4f1889cbc99..d27b0cfd340 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx @@ -1,118 +1,75 @@ -import { Button } from "@tremor/react"; -import type { TableProps } from "antd"; -import { Table } from "antd"; -import Title from "antd/es/typography/Title"; -import React from "react"; -import { StatusBadge, type StatusTone } from "@/components/shared/table_cells"; -import TableIconActionButton from "../../../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; +"use client"; + +import { Inbox, Plus } from "lucide-react"; +import React, { useMemo } from "react"; + +import { DataTable } from "@/components/shared/DataTable"; +import { Button } from "@/components/ui/button"; + +import { + AvailableCallbacks, + CallbackRow, + callbackRowMode, + getLoggingCallbacksTableColumns, +} from "./LoggingCallbacksTableColumns"; import { AlertingObject } from "./types"; type LoggingCallbacksProps = { callbacks: AlertingObject[]; - availableCallbacks?: Record< - string, - { - litellm_callback_name: string; - litellm_callback_params: string[]; - ui_callback_name: string; - } - >; + availableCallbacks?: AvailableCallbacks; + isLoading?: boolean; onTest?: (callback: AlertingObject) => void | Promise; onEdit?: (callback: AlertingObject) => void; onDelete?: (callback: AlertingObject) => void; onAdd?: () => void; }; -type CallbackRow = AlertingObject & { - id?: string; - mode?: "success" | "failure" | "info" | string; -}; - -const CALLBACK_MODES: { value: string; label: string }[] = [ - { value: "success", label: "Success" }, - { value: "failure", label: "Failure" }, - { value: "success_and_failure", label: "Success & Failure" }, -]; +function EmptyState() { + return ( +
+
+ +
+
No callbacks configured
+
+ Add your first callback to start logging data to external services. +
+
+ ); +} export const LoggingCallbacksTable: React.FC = ({ callbacks, availableCallbacks = {}, + isLoading = false, onTest = () => {}, onEdit = () => {}, onDelete = () => {}, onAdd = () => {}, }) => { - const columns: TableProps["columns"] = [ - { - title: Callback Name, - dataIndex: "name", - key: "name", - render: (_: string, record: CallbackRow) => { - const id = record.name; - const displayName = availableCallbacks[id]?.ui_callback_name || id; - return
{displayName}
; - }, - }, - { - title: Mode, - key: "mode", - render: (_: unknown, record: CallbackRow) => { - // Backend sends `type` (success | failure); legacy in-memory rows - // from add-callback flow set `mode`. Read both so newly-added rows - // and server-fetched rows both render correctly. - const mode = record.type || record.mode || "success"; - const label = CALLBACK_MODES.find((m) => m.value === mode)?.label || mode; - const tone: StatusTone = mode === "success" ? "success" : mode === "failure" ? "error" : "info"; - return ; - }, - width: 240, - }, - { - title: Actions, - key: "actions", - align: "right", - render: (_: unknown, record: CallbackRow) => ( -
- onTest(record)} /> - onEdit(record)} /> - onDelete(record)} /> -
- ), - width: 240, - }, - ]; + const columns = useMemo(() => { + const deps = { availableCallbacks, onTest, onEdit, onDelete }; + return getLoggingCallbacksTableColumns(deps); + }, [availableCallbacks, onTest, onEdit, onDelete]); + return ( - <> -
- -
- Active Logging Callbacks -
- {/* Empty state */} - {callbacks.length === 0 ? ( -
-
-

No callbacks configured

-

Add your first callback to start logging data to external services.

-
-
- ) : ( -
-
`${record.name}-${record.type || record.mode || "success"}`} - pagination={false} - rowClassName={() => "hover:bg-gray-50"} - /> - - )} - + `${callback.name || index}-${callbackRowMode(callback)}`} + isLoading={isLoading} + loadingMessage="Loading callbacks…" + noDataMessage={} + size="compact" + /> + ); }; diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx new file mode 100644 index 00000000000..2263fe03b3d --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { MoreHorizontal, Pencil, Play, Trash2 } from "lucide-react"; + +import { StatusBadge, type StatusTone } from "@/components/shared/table_cells"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; + +import { AlertingObject } from "./types"; + +export type CallbackRow = AlertingObject & { + mode?: "success" | "failure" | "info" | string; +}; + +export interface AvailableCallbackMeta { + litellm_callback_name: string; + litellm_callback_params: string[]; + ui_callback_name: string; +} + +export type AvailableCallbacks = Record; + +export const callbackRowMode = (record: CallbackRow): string => record.type || record.mode || "success"; + +const CALLBACK_MODE_LABELS: Record = { + success: "Success", + failure: "Failure", + success_and_failure: "Success & Failure", +}; + +function callbackModeTone(mode: string): StatusTone { + if (mode === "success") return "success"; + if (mode === "failure") return "error"; + return "info"; +} + +interface CallbackRowActionsProps { + callback: CallbackRow; + onTest: (callback: AlertingObject) => void | Promise; + onEdit: (callback: AlertingObject) => void; + onDelete: (callback: AlertingObject) => void; +} + +function CallbackRowActions({ callback, onTest, onEdit, onDelete }: CallbackRowActionsProps) { + return ( + + + + + + void onTest(callback)}> + + Test + + onEdit(callback)}> + + Edit + + + onDelete(callback)}> + + Delete + + + + ); +} + +interface LoggingCallbacksTableColumnsDeps { + availableCallbacks: AvailableCallbacks; + onTest: (callback: AlertingObject) => void | Promise; + onEdit: (callback: AlertingObject) => void; + onDelete: (callback: AlertingObject) => void; +} + +export const getLoggingCallbacksTableColumns = ({ + availableCallbacks, + onTest, + onEdit, + onDelete, +}: LoggingCallbacksTableColumnsDeps): ColumnDef[] => [ + { + id: "name", + accessorKey: "name", + meta: { title: "Callback Name" }, + header: "Callback Name", + enableSorting: false, + cell: ({ row }) => { + const id = row.original.name; + const displayName = availableCallbacks[id]?.ui_callback_name || id; + return ( + + {displayName} + + ); + }, + }, + { + id: "mode", + meta: { title: "Mode", skeleton: "badge" }, + header: "Mode", + size: 240, + enableSorting: false, + cell: ({ row }) => { + const mode = callbackRowMode(row.original); + return ; + }, + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/add_pass_through.tsx b/ui/litellm-dashboard/src/components/add_pass_through.tsx index af71d3f97fe..c0343e268a1 100644 --- a/ui/litellm-dashboard/src/components/add_pass_through.tsx +++ b/ui/litellm-dashboard/src/components/add_pass_through.tsx @@ -11,7 +11,7 @@ import NumericalInput from "./shared/numerical_input"; import { InfoCircleOutlined, ApiOutlined } from "@ant-design/icons"; import KeyValueInput from "./key_value_input"; import QueryParamInput from "./query_param_input"; -import { passThroughItem } from "./pass_through_settings"; +import { passThroughItem } from "./PassThroughSettings/PassThroughSettings"; import RoutePreview from "./route_preview"; import NotificationsManager from "./molecules/notifications_manager"; import PassThroughSecuritySection from "./common_components/PassThroughSecuritySection"; diff --git a/ui/litellm-dashboard/src/components/pass_through_settings.tsx b/ui/litellm-dashboard/src/components/pass_through_settings.tsx deleted file mode 100644 index 6c72b8a62ab..00000000000 --- a/ui/litellm-dashboard/src/components/pass_through_settings.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Text, Button, Icon, Title } from "@tremor/react"; -import { deletePassThroughEndpointsCall, getPassThroughEndpointsCall } from "./networking"; -import { Badge, Tooltip } from "antd"; -import { PencilAltIcon, TrashIcon, InformationCircleIcon } from "@heroicons/react/outline"; -import AddPassThroughEndpoint from "./add_pass_through"; -import PassThroughInfoView from "./pass_through_info"; -import { DataTable } from "./view_logs/table"; -import { ColumnDef } from "@tanstack/react-table"; -import { IdCell, StatusBadge } from "@/components/shared/table_cells"; -import { Eye, EyeOff } from "lucide-react"; -import NotificationsManager from "./molecules/notifications_manager"; - -interface GeneralSettingsPageProps { - accessToken: string | null; - userRole: string | null; - userID: string | null; - modelData: any; - premiumUser?: boolean; -} - -interface routingStrategyArgs { - ttl?: number; - lowest_latency_buffer?: number; -} - -interface nestedFieldItem { - field_name: string; - field_type: string; - field_value: any; - field_description: string; - stored_in_db: boolean | null; -} - -export interface passThroughItem { - id?: string; - path: string; - target: string; - headers: object; - include_subpath?: boolean; - cost_per_request?: number; - timeout?: number; - auth?: boolean; - methods?: string[]; - guardrails?: Record; - default_query_params?: Record; -} - -// Password field component for headers -const PasswordField: React.FC<{ value: object }> = ({ value }) => { - const [showPassword, setShowPassword] = useState(false); - const headerString = JSON.stringify(value); - - return ( -
- {showPassword ? headerString : "••••••••"} - -
- ); -}; - -const PassThroughSettings: React.FC = ({ - accessToken, - userRole, - userID, - modelData, - premiumUser, -}) => { - const [generalSettings, setGeneralSettings] = useState([]); - const [selectedEndpointId, setSelectedEndpointId] = useState(null); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [endpointToDelete, setEndpointToDelete] = useState(null); - - useEffect(() => { - if (!accessToken || !userRole || !userID) { - return; - } - getPassThroughEndpointsCall(accessToken).then((data) => { - let general_settings = data["endpoints"]; - setGeneralSettings(general_settings); - }); - }, [accessToken, userRole, userID]); - - const handleEndpointUpdated = () => { - // Refresh the endpoints list when an endpoint is updated - if (accessToken) { - getPassThroughEndpointsCall(accessToken).then((data) => { - let general_settings = data["endpoints"]; - setGeneralSettings(general_settings); - }); - } - }; - - const handleDelete = async (endpointId: string) => { - // Set the endpoint to delete and open the confirmation modal - setEndpointToDelete(endpointId); - setIsDeleteModalOpen(true); - }; - - const confirmDelete = async () => { - if (endpointToDelete == null || !accessToken) { - return; - } - - try { - await deletePassThroughEndpointsCall(accessToken, endpointToDelete); - - const updatedSettings = generalSettings.filter((setting) => setting.id !== endpointToDelete); - setGeneralSettings(updatedSettings); - - NotificationsManager.success("Endpoint deleted successfully."); - } catch (error) { - console.error("Error deleting the endpoint:", error); - NotificationsManager.fromBackend("Error deleting the endpoint: " + error); - } - - // Close the confirmation modal and reset the endpointToDelete - setIsDeleteModalOpen(false); - setEndpointToDelete(null); - }; - - const cancelDelete = () => { - // Close the confirmation modal and reset the endpointToDelete - setIsDeleteModalOpen(false); - setEndpointToDelete(null); - }; - - const handleResetField = (endpointId: string, idx: number) => { - // Use handleDelete instead of direct deletion - handleDelete(endpointId); - }; - - // Define columns for the DataTable - const columns: ColumnDef[] = [ - { - header: "ID", - accessorKey: "id", - cell: (info: any) => , - }, - { - header: "Path", - accessorKey: "path", - }, - { - header: "Target", - accessorKey: "target", - cell: (info: any) => {info.getValue()}, - }, - { - header: () => ( -
- Methods - - - -
- ), - accessorKey: "methods", - cell: (info: any) => { - const methods = info.getValue(); - if (!methods || methods.length === 0) { - return ALL; - } - return ( -
- {methods.map((method: string) => ( - - {method} - - ))} -
- ); - }, - }, - { - header: () => ( -
- Authentication - - - -
- ), - accessorKey: "auth", - cell: (info: any) => ( - - ), - }, - { - header: "Headers", - accessorKey: "headers", - cell: (info: any) => , - }, - { - header: "Actions", - id: "actions", - cell: ({ row }) => ( -
- row.original.id && setSelectedEndpointId(row.original.id)} - title="Edit" - /> - handleResetField(row.original.id!, row.index)} - title="Delete" - /> -
- ), - }, - ]; - - if (!accessToken) { - return null; - } - - // If a specific endpoint is selected, show the info view - if (selectedEndpointId) { - // Find the endpoint by ID to get the endpoint data for the info view - const selectedEndpoint = generalSettings.find((endpoint) => endpoint.id === selectedEndpointId); - - if (!selectedEndpoint) { - return
Endpoint not found
; - } - - return ( - setSelectedEndpointId(null)} - accessToken={accessToken} - isAdmin={userRole === "Admin" || userRole === "admin"} - premiumUser={premiumUser} - onEndpointUpdated={handleEndpointUpdated} - /> - ); - } - - return ( -
-
- Pass Through Endpoints - Configure and manage your pass-through endpoints -
- - - - - - {isDeleteModalOpen && ( -
-
- - - {/* Modal Panel */} - - - {/* Confirmation Modal Content */} -
-
-
-
-

Delete Pass-Through Endpoint

-
-

- Are you sure you want to delete this pass-through endpoint? This action cannot be undone. -

-
-
-
-
-
- - -
-
-
-
- )} -
- ); -}; - -export default PassThroughSettings; diff --git a/ui/litellm-dashboard/src/components/settings.test.tsx b/ui/litellm-dashboard/src/components/settings.test.tsx index 7776d0c7082..ebc1a5a7a6c 100644 --- a/ui/litellm-dashboard/src/components/settings.test.tsx +++ b/ui/litellm-dashboard/src/components/settings.test.tsx @@ -1,4 +1,5 @@ -import { act, fireEvent, render, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall } from "./networking"; import Settings from "./settings"; @@ -160,7 +161,8 @@ describe("Settings", () => { mockGetCallbackConfigsCall.mockResolvedValue([mockCallbackConfig]); - const { getByText, container } = render(); + const user = userEvent.setup(); + const { getByText } = render(); await waitFor(() => { expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); @@ -170,18 +172,8 @@ describe("Settings", () => { expect(getByText("Langfuse")).toBeInTheDocument(); }); - const actionsCell = container.querySelector('[class*="flex justify-end gap-2"]'); - expect(actionsCell).toBeTruthy(); - - const icons = actionsCell?.querySelectorAll("svg"); - expect(icons?.length).toBeGreaterThanOrEqual(2); - - const editIconParent = icons?.[1]?.closest('[class*="cursor-pointer"]'); - expect(editIconParent).toBeTruthy(); - - act(() => { - fireEvent.click(editIconParent!); - }); + await user.click(screen.getByTestId("callback-actions-langfuse-success")); + await user.click(await screen.findByTestId("callback-action-edit")); await waitFor(() => { expect(getByText("Edit Callback Settings")).toBeInTheDocument(); @@ -194,6 +186,42 @@ describe("Settings", () => { }); }); + it("should hold the callbacks table in loading state until the fetch settles", async () => { + let resolveCallbacks: (value: { + callbacks: never[]; + available_callbacks: never[]; + alerts: never[]; + }) => void = () => {}; + mockGetCallbacksCall.mockReturnValue( + new Promise((resolve) => { + resolveCallbacks = resolve; + }), + ); + + render(); + + expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); + + await act(async () => { + resolveCallbacks({ callbacks: [], available_callbacks: [], alerts: [] }); + }); + + await waitFor(() => { + expect(screen.queryByTestId("skeleton-row")).not.toBeInTheDocument(); + }); + expect(screen.getByText("No callbacks configured")).toBeInTheDocument(); + }); + + it("should resolve loading without fetching when the user id is missing", async () => { + render(); + + await waitFor(() => { + expect(screen.queryByTestId("skeleton-row")).not.toBeInTheDocument(); + }); + expect(mockGetCallbacksCall).not.toHaveBeenCalled(); + expect(screen.getByText("No callbacks configured")).toBeInTheDocument(); + }); + it("should display CloudZero Cost Tracking tab", async () => { const { getByText } = render(); diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 46811e0106b..b3f33133a80 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -217,6 +217,7 @@ const buildCallbackPayload = (formValues: Record, callbackName: str const Settings: React.FC = ({ accessToken, userRole, userID, premiumUser }) => { const [callbacks, setCallbacks] = useState([]); + const [isLoadingCallbacks, setIsLoadingCallbacks] = useState(true); const [alerts, setAlerts] = useState([]); const [isModalVisible, setIsModalVisible] = useState(false); const [addForm] = Form.useForm(); @@ -293,29 +294,35 @@ const Settings: React.FC = ({ accessToken, userRole, userID, }; useEffect(() => { - if (!accessToken || !userRole || !userID) { - return; - } - getCallbacksCall(accessToken, userID, userRole).then((data) => { - setCallbacks(data.callbacks); - setAllCallbacks(data.available_callbacks); - // setCallbacks(callbacks_data); - - let alerts_data = data.alerts; - if (alerts_data) { - if (alerts_data.length > 0) { - let _alert_info = alerts_data[0]; - let catch_all_webhook = _alert_info.variables.SLACK_WEBHOOK_URL; - - let active_alerts = _alert_info.active_alerts; - setActiveAlerts(active_alerts); - setCatchAllWebhookURL(catch_all_webhook); - setAlertToWebhooks(_alert_info.alerts_to_webhook); - } + const fetchCallbacks = async () => { + if (!accessToken || !userRole || !userID) { + setIsLoadingCallbacks(false); + return; } + try { + const data = await getCallbacksCall(accessToken, userID, userRole); + setCallbacks(data.callbacks); + setAllCallbacks(data.available_callbacks); + + let alerts_data = data.alerts; + if (alerts_data) { + if (alerts_data.length > 0) { + let _alert_info = alerts_data[0]; + let catch_all_webhook = _alert_info.variables.SLACK_WEBHOOK_URL; + + let active_alerts = _alert_info.active_alerts; + setActiveAlerts(active_alerts); + setCatchAllWebhookURL(catch_all_webhook); + setAlertToWebhooks(_alert_info.alerts_to_webhook); + } + } - setAlerts(alerts_data); - }); + setAlerts(alerts_data); + } finally { + setIsLoadingCallbacks(false); + } + }; + fetchCallbacks(); }, [accessToken, userRole, userID]); const isAlertOn = (alertName: string) => { @@ -581,6 +588,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, setShowAddCallbacksModal(true)} onEdit={(cb) => { setSelectedEditCallback(cb);