From fd494d2fb236783ff7f004de177f4f7adff6dc41 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 23 Jul 2026 10:20:16 -0700 Subject: [PATCH] refactor(ui): migrate models and endpoints table onto the shared DataTable (#34363) * refactor(ui): migrate models and endpoints table onto the shared DataTable Rebuilds the All Models table on the shared DataTable, following the 2a treatment from the Models + Endpoints design: one card holding search, the Team and View selectors, refresh, columns and filters, with the active filters on a chip row and the pagination footer at the bottom. Retires the last hand-rolled tremor renderer (all_models_table.tsx) and the antd/tremor column defs in molecules/models/columns.tsx, replacing them with a thin AllModelsTable consumer plus AllModelsTableColumns built from the shared cell library. Behavior is preserved end to end. The server sort field mapping now lives next to the column ids so the two cannot drift. Status keeps its column and its sort, hidden by default behind the Columns menu because the design shows nine columns. Access groups collapse into a "+N more" tooltip instead of a per-row expand toggle, and the full reset moves into the filter drawer footer where the design puts it. Adds the shadcn hover-card primitive (Base UI PreviewCard in the base-vega style) for the model information hover, which needs an interactive surface a tooltip cannot provide. * fix(ui): stop the models tab re-querying on mount The mount-time effect fires the debounced search with the initial empty value, and its callback rebuilt the pagination object unconditionally. That produced a second render (and a second query) roughly 300ms after mount with no user input, which on a slow CI machine swapped the table's row nodes mid-interaction and made a click land on a detached node. resetToFirstPage now returns the existing state when already on the first page, so React bails out instead of re-rendering. Pinned with a test that asserts no additional query after the debounce settles; it fails without the fix. --- ui/litellm-dashboard/eslint-suppressions.json | 57 +- .../components/AllModelsTab.test.tsx | 826 ++++--------- .../components/AllModelsTab.tsx | 631 +++------- .../components/AllModelsTable.test.tsx | 403 ++++++ .../components/AllModelsTable.tsx | 298 +++++ .../components/ModelsTableColumns.tsx | 488 ++++++++ .../model_dashboard/all_models_table.tsx | 221 ---- .../src/components/model_dashboard/types.ts | 1 + .../molecules/models/columns.test.tsx | 1099 ----------------- .../components/molecules/models/columns.tsx | 423 ------- .../DataTable/DataTableFilterDrawer.tsx | 7 + .../src/components/ui/hover-card.tsx | 46 + 12 files changed, 1704 insertions(+), 2796 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx delete mode 100644 ui/litellm-dashboard/src/components/model_dashboard/all_models_table.tsx delete mode 100644 ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/molecules/models/columns.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/hover-card.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 1bd3fceb6ff..6a50f4aa99e 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1143,25 +1143,6 @@ "count": 1 } }, - "src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx": { - "max-params": { - "count": 1 - }, - "unused-imports/no-unused-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx": { - "local/no-complex-jsx-arrow": { - "count": 2 - }, - "no-restricted-imports": { - "count": 2 - }, - "react-hooks/set-state-in-effect": { - "count": 3 - } - }, "src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.test.tsx": { "react/display-name": { "count": 1 @@ -3483,17 +3464,6 @@ "count": 1 } }, - "src/components/model_dashboard/all_models_table.tsx": { - "local/filename-pascal-case": { - "count": 1 - }, - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/model_filters.tsx": { "local/filename-pascal-case": { "count": 1 @@ -3560,28 +3530,6 @@ "count": 2 } }, - "src/components/molecules/models/columns.test.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/display-name": { - "count": 1 - } - }, - "src/components/molecules/models/columns.tsx": { - "local/filename-pascal-case": { - "count": 1 - }, - "max-params": { - "count": 1 - }, - "no-nested-ternary": { - "count": 2 - }, - "no-restricted-imports": { - "count": 2 - } - }, "src/components/molecules/notifications_manager.test.tsx": { "no-restricted-imports": { "count": 1 @@ -4179,6 +4127,11 @@ "count": 1 } }, + "src/components/ui/hover-card.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/ui/input-group.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx index 045bf0a5f44..659a618c8f6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx @@ -1,662 +1,364 @@ import * as useAuthorizedModule from "@/app/(dashboard)/hooks/useAuthorized"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { renderWithProviders } from "../../../../../tests/test-utils"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import AllModelsTab from "./AllModelsTab"; -// Mock modelDeleteCall +import AllModelsTab from "./AllModelsTab"; +import { STATUS_COLUMN_ID, toServerSortField } from "./ModelsTableColumns"; + const mockModelDeleteCall = vi.fn().mockResolvedValue({}); +const mockModelPatchUpdateCall = vi.fn().mockResolvedValue({}); vi.mock("@/components/networking", () => ({ - modelDeleteCall: (...args: any[]) => mockModelDeleteCall(...args), + modelDeleteCall: (...args: unknown[]) => mockModelDeleteCall(...args), + modelPatchUpdateCall: (...args: unknown[]) => mockModelPatchUpdateCall(...args), })); -// Mock NotificationsManager vi.mock("@/components/molecules/notifications_manager", () => ({ - default: { - success: vi.fn(), - fromBackend: vi.fn(), + default: { success: vi.fn(), fromBackend: vi.fn() }, +})); + +vi.mock("@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal", () => ({ + default: function ModelSettingsModalMock({ isVisible }: { isVisible: boolean }) { + return isVisible ?
: null; }, })); -// Mock react-query const mockInvalidateQueries = vi.fn(); vi.mock("@tanstack/react-query", async (importOriginal) => { - const actual = (await importOriginal()) as any; - return { - ...actual, - useQueryClient: () => ({ - invalidateQueries: mockInvalidateQueries, - }), - }; + const actual = await importOriginal(); + return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) }; }); -// Mock the useModelsInfo hook -const mockUseModelsInfo = vi.fn(() => ({ - data: { data: [], total_count: 0, current_page: 1, total_pages: 1, size: 50 }, - isLoading: false, - error: null, -})) as any; +interface ModelsInfoArgs { + page?: number; + size?: number; + search?: string; + teamId?: string; + sortBy?: string; + sortOrder?: string; +} + +const modelsInfoCalls: ModelsInfoArgs[] = []; +const mockRefetch = vi.fn(); +let modelsInfoResult: Record = {}; + +type UseModelsInfoArgs = [ + page?: number, + size?: number, + search?: string, + modelId?: string, + teamId?: string, + sortBy?: string, + sortOrder?: string, +]; vi.mock("../../hooks/models/useModels", () => ({ - useModelsInfo: (page?: number, size?: number, search?: string) => mockUseModelsInfo(page, size, search), -})); - -// Mock the useModelCostMap hook -const mockUseModelCostMap = vi.fn(() => ({ - data: { - "gpt-4": { litellm_provider: "openai" }, - "gpt-3.5-turbo": { litellm_provider: "openai" }, - "gpt-4-accessible": { litellm_provider: "openai" }, - "gpt-3.5-turbo-blocked": { litellm_provider: "openai" }, - "gpt-4-sales": { litellm_provider: "openai" }, - "gpt-4-engineering": { litellm_provider: "openai" }, - "gpt-4-personal": { litellm_provider: "openai" }, - "gpt-4-team-only": { litellm_provider: "openai" }, - "gpt-4-config": { litellm_provider: "openai" }, - "gpt-4-db": { litellm_provider: "openai" }, + useModelsInfo: (...args: UseModelsInfoArgs) => { + const [page, size, search, , teamId, sortBy, sortOrder] = args; + const call: ModelsInfoArgs = { page, size, search, teamId, sortBy, sortOrder }; + modelsInfoCalls.push(call); + return { ...modelsInfoResult, refetch: mockRefetch }; }, - isLoading: false, - error: null, -})) as any; +})); vi.mock("../../hooks/models/useModelCostMap", () => ({ - useModelCostMap: () => mockUseModelCostMap(), + useModelCostMap: () => ({ data: { "gpt-4": { litellm_provider: "openai" } }, isLoading: false, error: null }), })); -// Mock the useTeams hook (react-query implementation) -const mockUseTeams = vi.fn(() => ({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), -})) as any; - +const mockTeams = [{ team_id: "team-1", team_alias: "Engineering" }]; vi.mock("../../hooks/teams/useTeams", () => ({ - useTeams: () => mockUseTeams(), + useTeams: () => ({ data: mockTeams, isLoading: false, error: null, refetch: vi.fn() }), })); -// Helper function to create model cost map mock return value -const createModelCostMapMock = (data: Record) => ({ - data, - isLoading: false, - error: null, +const BASE_MODEL_INFO = { + id: "model-1", + db_model: true, + created_by: "user-123", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", + team_id: "team-1", + access_groups: [], +}; + +const makeRow = (overrides: Record = {}) => ({ + model_name: "gpt-4", + litellm_params: { model: "openai/gpt-4", custom_llm_provider: "openai" }, + model_info: { ...BASE_MODEL_INFO, ...((overrides.model_info as Record) ?? {}) }, }); -// Helper function to create paginated model data mock -const createPaginatedModelData = ( - models: any[], - totalCount: number = models.length, - currentPage: number = 1, - totalPages: number = 1, - size: number = 50, -) => ({ - data: models, - total_count: totalCount, - current_page: currentPage, - total_pages: totalPages, - size: size, -}); +const setModelsInfo = (rows: Record[], totalCount = rows.length, isLoading = false) => { + modelsInfoResult = { + data: { data: rows, total_count: totalCount, current_page: 1, total_pages: 1, size: 50 }, + isLoading, + isFetching: false, + error: null, + }; +}; + +const lastModelsInfoCall = (): ModelsInfoArgs => modelsInfoCalls[modelsInfoCalls.length - 1]; + +const SEARCH_SETTLE_MS = 400; + +const MOCK_AUTHORIZED = { + isLoading: false, + isAuthorized: true, + token: "mock-token", + accessToken: "mock-access-token", + userId: "user-123", + userEmail: "test@example.com", + userRole: "Admin", + premiumUser: true, + disabledPersonalKeyCreation: false, + showSSOBanner: false, +}; + +const mockSetSelectedModelGroup = vi.fn(); +const mockSetSelectedModelId = vi.fn(); +const mockSetSelectedTeamId = vi.fn(); + +const defaultProps = { + selectedModelGroup: "all", + setSelectedModelGroup: mockSetSelectedModelGroup, + availableModelGroups: ["gpt-4", "gpt-3.5-turbo"], + availableModelAccessGroups: ["sales-team"], + setSelectedModelId: mockSetSelectedModelId, + setSelectedTeamId: mockSetSelectedTeamId, +}; describe("AllModelsTab", () => { - const mockSetSelectedModelGroup = vi.fn(); - const mockSetSelectedModelId = vi.fn(); - const mockSetSelectedTeamId = vi.fn(); - - const defaultProps = { - selectedModelGroup: "all", - setSelectedModelGroup: mockSetSelectedModelGroup, - availableModelGroups: ["gpt-4", "gpt-3.5-turbo"], - availableModelAccessGroups: ["sales-team", "engineering-team"], - setSelectedModelId: mockSetSelectedModelId, - setSelectedTeamId: mockSetSelectedTeamId, - }; - - const mockUseAuthorized = { - token: "mock-token", - accessToken: "mock-access-token", - userId: "user-123", - userEmail: "test@example.com", - userRole: "Admin", - premiumUser: true, - disabledPersonalKeyCreation: false, - showSSOBanner: false, - }; - beforeEach(() => { vi.clearAllMocks(); - vi.spyOn(useAuthorizedModule, "default").mockReturnValue(mockUseAuthorized); + modelsInfoCalls.length = 0; + setModelsInfo([makeRow()]); + vi.spyOn(useAuthorizedModule, "default").mockReturnValue(MOCK_AUTHORIZED); }); - it("should render with empty data", () => { - mockUseModelsInfo.mockReturnValueOnce({ - data: createPaginatedModelData([], 0, 1, 1, 50), - isLoading: false, - error: null, - }); + it("renders the fetched models and the server row count", async () => { + setModelsInfo([makeRow()], 137); + render(); - mockUseTeams.mockReturnValueOnce({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - }); - - mockUseModelCostMap.mockReturnValueOnce(createModelCostMapMock({})); - - renderWithProviders(); - expect(screen.getByText("Current Team:")).toBeInTheDocument(); + expect(await screen.findByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 137"); }); - it("should filter models by direct team access when current team is selected", async () => { - const mockTeams = [ - { - team_id: "team-456", - team_alias: "Engineering Team", - models: ["gpt-4"], - max_budget: null, - budget_duration: null, - tpm_limit: null, - rpm_limit: null, - organization_id: "org-123", - created_at: "2024-01-01", - keys: [], - members_with_roles: [], - }, + it("does not re-query after the mount-time debounced search settles unchanged", async () => { + render(); + const callsAfterMount = modelsInfoCalls.length; + + await new Promise((resolve) => setTimeout(resolve, SEARCH_SETTLE_MS)); + + expect(modelsInfoCalls.length).toBe(callsAfterMount); + }); + + it("shows the empty state when the proxy returns no models", () => { + setModelsInfo([], 0); + render(); + + expect(screen.getByText("No models found")).toBeInTheDocument(); + }); + + it("shows the loading skeleton while the first page is in flight", () => { + setModelsInfo([], 0, true); + render(); + + expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); + expect(screen.queryByText("No models found")).not.toBeInTheDocument(); + }); + + describe("server sort contract", () => { + const sortHeader = (columnId: string): HTMLElement => screen.getByTestId(`sort-header-${columnId}`); + + const expectIndicator = async (columnId: string, state: "asc" | "desc" | "none") => { + await waitFor(() => { + expect(sortHeader(columnId).querySelector(`[data-sort-indicator="${state}"]`)).not.toBeNull(); + }); + }; + + const cases: [string, string, string, "asc" | "desc"][] = [ + ["Model Information", "model_name", "model_name", "asc"], + ["Created By", "model_info_created_by", "created_at", "asc"], + ["Updated At", "model_info_updated_at", "updated_at", "asc"], + ["Costs", "input_cost", "costs", "desc"], ]; - mockUseTeams.mockReturnValueOnce({ - data: mockTeams, - isLoading: false, - error: null, - refetch: vi.fn(), + it.each(cases)("sorts %s using the server field %s", async (_label, columnId, serverField, firstDirection) => { + const user = userEvent.setup(); + render(); + + await user.click(sortHeader(columnId)); + await expectIndicator(columnId, firstDirection); + + expect(lastModelsInfoCall().sortBy).toBe(serverField); + expect(lastModelsInfoCall().sortOrder).toBe(firstDirection); }); - mockUseModelCostMap.mockReturnValueOnce( - createModelCostMapMock({ - "gpt-4-accessible": { litellm_provider: "openai" }, - "gpt-3.5-turbo-blocked": { litellm_provider: "openai" }, - }), - ); + it("maps the hidden Status column to the server field status", () => { + expect(toServerSortField(STATUS_COLUMN_ID)).toBe("status"); + }); - const modelData = createPaginatedModelData( - [ - { - model_name: "gpt-4-accessible", - model_info: { - id: "model-1", - access_via_team_ids: ["team-456"], - access_groups: [], - }, - }, - { - model_name: "gpt-3.5-turbo-blocked", - model_info: { - id: "model-2", - access_via_team_ids: ["team-789"], - access_groups: [], - }, - }, - ], - 2, - 1, - 1, - 50, - ); + it("cycles a sorted column back to unsorted", async () => { + const user = userEvent.setup(); + render(); - mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null }); + await user.click(sortHeader("model_info_updated_at")); + await expectIndicator("model_info_updated_at", "asc"); + expect(lastModelsInfoCall().sortOrder).toBe("asc"); - renderWithProviders(); + await user.click(sortHeader("model_info_updated_at")); + await expectIndicator("model_info_updated_at", "desc"); + expect(lastModelsInfoCall().sortOrder).toBe("desc"); - // Component shows API total_count (2), not filtered count - // Since default is "personal" team and models don't have direct_access, they're filtered out - await waitFor(() => { - expect(screen.getByText("Showing 1 - 2 of 2 results")).toBeInTheDocument(); + await user.click(sortHeader("model_info_updated_at")); + await expectIndicator("model_info_updated_at", "none"); + expect(lastModelsInfoCall().sortBy).toBeUndefined(); }); }); - it("should filter models by access group matching when team models match model access groups", async () => { - const mockTeams = [ - { - team_id: "team-sales", - team_alias: "Sales Team", - models: ["sales-model-group"], - max_budget: null, - budget_duration: null, - tpm_limit: null, - rpm_limit: null, - organization_id: "org-123", - created_at: "2024-01-01", - keys: [], - members_with_roles: [], - }, - ]; + it("queries the selected team and resets to the first page", async () => { + const user = userEvent.setup(); + render(); - mockUseTeams.mockReturnValue({ - data: mockTeams, - isLoading: false, - error: null, - refetch: vi.fn(), - }); + expect(lastModelsInfoCall().teamId).toBeUndefined(); - mockUseModelCostMap.mockReturnValueOnce( - createModelCostMapMock({ - "gpt-4-sales": { litellm_provider: "openai" }, - "gpt-4-engineering": { litellm_provider: "openai" }, - }), - ); + await user.click(screen.getByTestId("models-team-select")); + await user.click(await screen.findByRole("option", { name: "Engineering" })); - const modelData = createPaginatedModelData( - [ - { - model_name: "gpt-4-sales", - model_info: { - id: "model-sales-1", - access_via_team_ids: [], - access_groups: ["sales-model-group"], - }, - }, - { - model_name: "gpt-4-engineering", - model_info: { - id: "model-eng-1", - access_via_team_ids: [], - access_groups: ["engineering-model-group"], - }, - }, - ], - 2, - 1, - 1, - 50, - ); - - mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null }); - - renderWithProviders(); - - // Component shows API total_count (2), not filtered count - // Since default is "personal" team and models don't have direct_access, they're filtered out await waitFor(() => { - expect(screen.getByText("Showing 1 - 2 of 2 results")).toBeInTheDocument(); + expect(lastModelsInfoCall().teamId).toBe("team-1"); + }); + expect(lastModelsInfoCall().page).toBe(1); + }); + + it("debounces the model name search into the server query", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByTestId("datatable-search"), "claude"); + + await waitFor(() => { + expect(lastModelsInfoCall().search).toBe("claude"); }); }); - it("should filter models by direct_access for personal team", async () => { - mockUseTeams.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - }); + it("applies a public model name filter through the drawer", async () => { + const user = userEvent.setup(); + render(); - mockUseModelCostMap.mockReturnValueOnce( - createModelCostMapMock({ - "gpt-4-personal": { litellm_provider: "openai" }, - "gpt-4-team-only": { litellm_provider: "openai" }, - }), - ); + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(await screen.findByPlaceholderText("Filter by Public Model Name")); + await user.click(await screen.findByRole("option", { name: "gpt-3.5-turbo" })); + await user.click(screen.getByTestId("filter-drawer-apply")); - const modelData = createPaginatedModelData( - [ - { - model_name: "gpt-4-personal", - model_info: { - id: "model-personal-1", - direct_access: true, - access_via_team_ids: [], - access_groups: [], - }, - }, - { - model_name: "gpt-4-team-only", - model_info: { - id: "model-team-1", - direct_access: false, - access_via_team_ids: ["team-123"], - access_groups: [], - }, - }, - ], - 2, - 1, - 1, - 50, - ); - - mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null }); - - renderWithProviders(); - - // Component shows API total_count (2), but only 1 model has direct_access await waitFor(() => { - expect(screen.getByText("Showing 1 - 2 of 2 results")).toBeInTheDocument(); + expect(mockSetSelectedModelGroup).toHaveBeenCalledWith("gpt-3.5-turbo"); }); }); - it("should show config model status for models defined in configs", async () => { - mockUseTeams.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - }); + it("filters the fetched page down to the selected model group", () => { + setModelsInfo([makeRow(), { ...makeRow(), model_name: "claude-opus" }], 2); + render(); - mockUseModelCostMap.mockReturnValueOnce( - createModelCostMapMock({ - "gpt-4-config": { litellm_provider: "openai" }, - "gpt-4-db": { litellm_provider: "openai" }, - }), - ); + const table = screen.getByRole("table"); + expect(within(table).getByText("claude-opus")).toBeInTheDocument(); + expect(within(table).queryByText("gpt-4")).not.toBeInTheDocument(); + }); - const modelData = createPaginatedModelData( - [ - { - model_name: "gpt-4-config", - litellm_model_name: "gpt-4-config", - provider: "openai", - model_info: { - id: "model-config-1", - db_model: false, - direct_access: true, - access_via_team_ids: [], - access_groups: [], - created_by: "user-123", - created_at: "2024-01-01", - updated_at: "2024-01-01", - }, - }, - { - model_name: "gpt-4-db", - litellm_model_name: "gpt-4-db", - provider: "openai", - model_info: { - id: "model-db-1", - db_model: true, - direct_access: true, - access_via_team_ids: [], - access_groups: [], - created_by: "user-123", - created_at: "2024-01-01", - updated_at: "2024-01-01", - }, - }, - ], - 2, - 1, - 1, - 50, - ); + it("resets search, filters, team and sorting from the drawer reset button", async () => { + const user = userEvent.setup(); + render(); - mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null }); + await user.click(screen.getByTestId("models-team-select")); + await user.click(await screen.findByRole("option", { name: "Engineering" })); + await waitFor(() => expect(lastModelsInfoCall().teamId).toBe("team-1")); - renderWithProviders(); + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(await screen.findByTestId("filter-drawer-reset")); + expect(mockSetSelectedModelGroup).toHaveBeenCalledWith("all"); await waitFor(() => { - expect(screen.getByText("Config Model")).toBeInTheDocument(); - expect(screen.getByText("DB Model")).toBeInTheDocument(); + expect(lastModelsInfoCall().teamId).toBeUndefined(); }); }); - it("should show 'Defined in config' for models defined in configs", async () => { - mockUseTeams.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - }); + it("opens the delete modal from the row and deletes the model", async () => { + const user = userEvent.setup(); + render(); - mockUseModelCostMap.mockReturnValueOnce( - createModelCostMapMock({ - "gpt-4-config": { litellm_provider: "openai" }, - }), - ); + await user.click(await screen.findByTestId("model-delete-model-1")); + expect(await screen.findByText("Delete Model")).toBeInTheDocument(); - const modelData = createPaginatedModelData( - [ - { - model_name: "gpt-4-config", - litellm_model_name: "gpt-4-config", - provider: "openai", - model_info: { - id: "model-config-1", - db_model: false, - direct_access: true, - access_via_team_ids: [], - access_groups: [], - created_by: "user-123", - created_at: "2024-01-01", - updated_at: "2024-01-01", - }, - }, - ], - 1, - 1, - 1, - 50, - ); - - mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null }); - - renderWithProviders(); + await user.click(screen.getByRole("button", { name: /^delete$/i })); await waitFor(() => { - expect(screen.getByText("Defined in config")).toBeInTheDocument(); + expect(mockModelDeleteCall).toHaveBeenCalledWith("mock-access-token", "model-1"); }); }); - it("should handle pagination: Previous button is disabled on first page and Next button works", async () => { - mockUseTeams.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - }); + it("pauses a model through the row toggle", async () => { + const user = userEvent.setup(); + render(); - mockUseModelCostMap.mockReturnValue( - createModelCostMapMock({ - "gpt-4-page1": { litellm_provider: "openai" }, - "gpt-4-page2": { litellm_provider: "openai" }, - }), - ); - - // Mock first page response (page 1 of 2) - const page1Data = createPaginatedModelData( - [ - { - model_name: "gpt-4-page1", - model_info: { - id: "model-page1-1", - direct_access: true, - access_via_team_ids: [], - access_groups: [], - }, - }, - ], - 2, // total_count - 1, // current_page - 2, // total_pages - 50, // size - ); - - // Set up mock to return page1Data for page 1 - mockUseModelsInfo.mockImplementation((page: number = 1, size?: number, search?: string) => { - return { data: page1Data, isLoading: false, error: null }; - }); - - renderWithProviders(); + await user.click(await screen.findByTestId("model-pause-toggle-model-1")); await waitFor(() => { - // Component calculates: ((1-1)*50)+1 = 1, Math.min(1*50, 2) = 2 - expect(screen.getByText("Showing 1 - 2 of 2 results")).toBeInTheDocument(); + expect(mockModelPatchUpdateCall).toHaveBeenCalledWith("mock-access-token", { blocked: true }, "model-1"); }); - - // Check that Previous button is disabled on first page - const previousButton = screen.getByRole("button", { name: /previous/i }); - expect(previousButton).toBeDisabled(); - - // Check that Next button is enabled (since we're on page 1 of 2) - const nextButton = screen.getByRole("button", { name: /next/i }); - expect(nextButton).not.toBeDisabled(); }); - it("should handle pagination: Next button is disabled on last page", async () => { - mockUseTeams.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - }); + it("opens the model settings modal from the toolbar", async () => { + const user = userEvent.setup(); + render(); - mockUseModelCostMap.mockReturnValue( - createModelCostMapMock({ - "gpt-4-page2": { litellm_provider: "openai" }, - }), - ); - - // Mock single page response (page 1 of 1 - last page) - const singlePageData = createPaginatedModelData( - [ - { - model_name: "gpt-4-page2", - model_info: { - id: "model-page2-1", - direct_access: true, - access_via_team_ids: [], - access_groups: [], - }, - }, - ], - 1, // total_count - 1, // current_page - 1, // total_pages (only 1 page, so this is the last page) - 50, // size - ); - - mockUseModelsInfo.mockImplementation((page?: number, size?: number, search?: string) => { - return { data: singlePageData, isLoading: false, error: null }; - }); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Showing 1 - 1 of 1 results")).toBeInTheDocument(); - }); - - // When there's only 1 page (last page), Next should be disabled - const nextButton = screen.getByRole("button", { name: /next/i }); - expect(nextButton).toBeDisabled(); - - // Previous should also be disabled on the first (and only) page - const previousButton = screen.getByRole("button", { name: /previous/i }); - expect(previousButton).toBeDisabled(); + expect(screen.queryByTestId("model-settings-modal")).not.toBeInTheDocument(); + await user.click(screen.getByTestId("models-settings-trigger")); + expect(screen.getByTestId("model-settings-modal")).toBeInTheDocument(); }); - it("should pass setDeleteModalModelId to columns for delete functionality", async () => { - // This test verifies that the delete modal setter is passed to columns - // The actual modal rendering is handled by DeleteResourceModal component - mockUseTeams.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), - }); + it("opens the model detail view from the model ID cell", async () => { + const user = userEvent.setup(); + render(); - mockUseModelCostMap.mockReturnValue( - createModelCostMapMock({ - "gpt-4-delete-test": { litellm_provider: "openai" }, - }), - ); + await user.click(await screen.findByTestId("model-id-model-1")); - const modelData = createPaginatedModelData( - [ - { - model_name: "gpt-4-delete-test", - litellm_model_name: "gpt-4-delete-test", - provider: "openai", - model_info: { - id: "model-to-delete", - db_model: true, - direct_access: true, - access_via_team_ids: [], - access_groups: [], - created_by: "user-123", - created_at: "2024-01-01", - updated_at: "2024-01-01", - }, - }, - ], - 1, - 1, - 1, - 50, - ); - - mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null, refetch: vi.fn() }); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("gpt-4-delete-test")).toBeInTheDocument(); - }); - - // Verify the DB Model badge is shown (indicating it can be deleted) - expect(screen.getByText("DB Model")).toBeInTheDocument(); + expect(mockSetSelectedModelId).toHaveBeenCalledWith("model-1"); }); - it("should render clickable model ID that calls setSelectedModelId", async () => { - mockUseTeams.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: vi.fn(), + it("opens the team detail view from the team ID cell", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByTestId("model-team-id-model-1")); + + expect(mockSetSelectedTeamId).toHaveBeenCalledWith("team-1"); + }); + + describe("virtual key hint", () => { + it("explains personal key creation while viewing current team models", () => { + render(); + + expect(screen.getByText(/create a Virtual Key without selecting a team/i)).toBeInTheDocument(); }); - mockUseModelCostMap.mockReturnValue( - createModelCostMapMock({ - "gpt-4-clickable": { litellm_provider: "openai" }, - }), - ); + it("names the selected team in the hint", async () => { + const user = userEvent.setup(); + render(); - const modelData = createPaginatedModelData( - [ - { - model_name: "gpt-4-clickable", - litellm_model_name: "gpt-4-clickable", - provider: "openai", - model_info: { - id: "clickable-model-id", - db_model: true, - direct_access: true, - access_via_team_ids: [], - access_groups: [], - created_by: "user-123", - created_at: "2024-01-01", - updated_at: "2024-01-01", - }, - }, - ], - 1, - 1, - 1, - 50, - ); + await user.click(screen.getByTestId("models-team-select")); + await user.click(await screen.findByRole("option", { name: "Engineering" })); - mockUseModelsInfo.mockReturnValue({ data: modelData, isLoading: false, error: null, refetch: vi.fn() }); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("gpt-4-clickable")).toBeInTheDocument(); + expect(await screen.findByText(/select Team as "Engineering"/i)).toBeInTheDocument(); }); - // Click on the Model ID cell which should call setSelectedModelId - const modelIdCell = screen.getByText("clickable-model-id"); - expect(modelIdCell).toBeInTheDocument(); + it("hides the hint when viewing all available models", async () => { + const user = userEvent.setup(); + render(); - fireEvent.click(modelIdCell); + await user.click(screen.getByTestId("models-view-select")); + await user.click(await screen.findByRole("option", { name: "All Available Models" })); - await waitFor(() => { - expect(mockSetSelectedModelId).toHaveBeenCalledWith("clickable-model-id"); + await waitFor(() => { + expect(screen.queryByText(/create a Virtual Key/i)).not.toBeInTheDocument(); + }); }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index 6efc85c019b..1dc7736d5ac 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -1,27 +1,33 @@ +"use client"; + import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { Team } from "@/components/key_team_helpers/key_list"; -import { AllModelsDataTable } from "@/components/model_dashboard/all_models_table"; -import { columns } from "@/components/molecules/models/columns"; -import { getDisplayModelName } from "@/components/view_model/model_name_display"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; +import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal"; +import { ModelData } from "@/components/model_dashboard/types"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { modelDeleteCall, modelPatchUpdateCall } from "@/components/networking"; -import { InfoCircleOutlined, SettingOutlined } from "@ant-design/icons"; -import { PaginationState, SortingState } from "@tanstack/react-table"; import { useQueryClient } from "@tanstack/react-query"; -import { Grid } from "@tremor/react"; -import { Badge, Button, Select, Skeleton, Space, Typography } from "antd"; -import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; -import { useEffect, useMemo, useState } from "react"; +import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; +import { Info } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + import { useModelsInfo } from "../../hooks/models/useModels"; import { transformModelData } from "../utils/modelDataTransformer"; -type ModelViewMode = "all" | "current_team"; +import { + ALL_MODEL_GROUPS_VALUE, + AllModelsTable, + ModelViewMode, + PERSONAL_TEAM_VALUE, + WILDCARD_MODEL_GROUP_VALUE, +} from "./AllModelsTable"; +import { ACCESS_GROUPS_COLUMN_ID, MODEL_NAME_COLUMN_ID, toServerSortField } from "./ModelsTableColumns"; const SEARCH_DEBOUNCE_WAIT_MS = 200; -const { Text } = Typography; +const DEFAULT_PAGE_SIZE = 50; +const DEFAULT_PAGINATION: PaginationState = { pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE }; interface AllModelsTabProps { selectedModelGroup: string | null; @@ -41,31 +47,30 @@ const AllModelsTab = ({ setSelectedTeamId, }: AllModelsTabProps) => { const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap(); - const { accessToken, userId, userRole, premiumUser } = useAuthorized(); + const { accessToken, userId, userRole } = useAuthorized(); const { data: teams, isLoading: isLoadingTeams } = useTeams(); const queryClient = useQueryClient(); const [modelNameSearch, setModelNameSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [modelViewMode, setModelViewMode] = useState("current_team"); - const [currentTeam, setCurrentTeam] = useState("personal"); - const [showFilters, setShowFilters] = useState(false); + const [selectedTeamValue, setSelectedTeamValue] = useState(PERSONAL_TEAM_VALUE); const [selectedModelAccessGroupFilter, setSelectedModelAccessGroupFilter] = useState(null); - const [expandedRows, setExpandedRows] = useState>(new Set()); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize] = useState(50); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 50, - }); + const [pagination, setPagination] = useState(DEFAULT_PAGINATION); const [sorting, setSorting] = useState([]); const [isModelSettingsModalVisible, setIsModelSettingsModalVisible] = useState(false); + const [deleteModalModelId, setDeleteModalModelId] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + const [pausingModelId, setPausingModelId] = useState(null); + + const resetToFirstPage = useCallback(() => { + setPagination((previous) => (previous.pageIndex === 0 ? previous : { ...previous, pageIndex: 0 })); + }, []); const debouncedUpdateSearch = useDebouncedCallback( (value: string) => { setDebouncedSearch(value); - setCurrentPage(1); - setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); + resetToFirstPage(); }, { wait: SEARCH_DEBOUNCE_WAIT_MS }, ); @@ -74,125 +79,130 @@ const AllModelsTab = ({ debouncedUpdateSearch(modelNameSearch); }, [modelNameSearch, debouncedUpdateSearch]); - // Determine teamId to pass to the query - only pass if not "personal" - const teamIdForQuery = currentTeam === "personal" ? undefined : currentTeam.team_id; + const teamIdForQuery = selectedTeamValue === PERSONAL_TEAM_VALUE ? undefined : selectedTeamValue; - // Convert sorting state to sortBy and sortOrder for API const sortBy = useMemo(() => { if (sorting.length === 0) return undefined; - const sort = sorting[0]; - const columnIdToServerField: Record = { - input_cost: "costs", // Map input_cost column to "costs" for server-side sorting - model_info_db_model: "status", // Map model_info.db_model column to "status" for server-side sorting - model_info_created_by: "created_at", // Map model_info.created_by column to "created_at" for server-side sorting - model_info_updated_at: "updated_at", // Map model_info.updated_at column to "updated_at" for server-side sorting - }; - return columnIdToServerField[sort.id] || sort.id; + return toServerSortField(sorting[0].id); }, [sorting]); const sortOrder = useMemo(() => { if (sorting.length === 0) return undefined; - const sort = sorting[0]; - return sort.desc ? "desc" : "asc"; + return sorting[0].desc ? "desc" : "asc"; }, [sorting]); const { data: rawModelData, isLoading: isLoadingModelsInfo, + isFetching: isFetchingModelsInfo, refetch: refetchModels, - } = useModelsInfo(currentPage, pageSize, debouncedSearch || undefined, undefined, teamIdForQuery, sortBy, sortOrder); + } = useModelsInfo( + pagination.pageIndex + 1, + pagination.pageSize, + debouncedSearch || undefined, + undefined, + teamIdForQuery, + sortBy, + sortOrder, + ); const isLoading = isLoadingModelsInfo || isLoadingModelCostMap; - const getProviderFromModel = (model: string) => { - if (modelCostMapData !== null && modelCostMapData !== undefined) { - if (typeof modelCostMapData == "object" && model in modelCostMapData) { - return modelCostMapData[model]["litellm_provider"]; + const getProviderFromModel = useCallback( + (model: string) => { + if (modelCostMapData !== null && modelCostMapData !== undefined) { + if (typeof modelCostMapData == "object" && model in modelCostMapData) { + return modelCostMapData[model]["litellm_provider"]; + } } - } - return "openai"; - }; + return "openai"; + }, + [modelCostMapData], + ); const modelData = useMemo(() => { if (!rawModelData) return { data: [] }; return transformModelData(rawModelData, getProviderFromModel); - }, [rawModelData, modelCostMapData]); + }, [rawModelData, getProviderFromModel]); - const [deleteModalModelId, setDeleteModalModelId] = useState(null); - const [deleteLoading, setDeleteLoading] = useState(false); - - // Get pagination metadata from the response - const paginationMeta = useMemo(() => { - if (!rawModelData) { - return { - total_count: 0, - current_page: 1, - total_pages: 1, - size: pageSize, - }; - } - return { - total_count: rawModelData.total_count ?? 0, - current_page: rawModelData.current_page ?? 1, - total_pages: rawModelData.total_pages ?? 1, - size: rawModelData.size ?? pageSize, - }; - }, [rawModelData, pageSize]); - - const filteredData = useMemo(() => { + const filteredData = useMemo(() => { if (!modelData || !modelData.data || modelData.data.length === 0) { return []; } - // Server-side search is now handled by the API, so we only filter by other criteria - return modelData.data.filter((model: any) => { + return modelData.data.filter((model: ModelData) => { const modelNameMatch = - selectedModelGroup === "all" || + selectedModelGroup === ALL_MODEL_GROUPS_VALUE || model.model_name === selectedModelGroup || !selectedModelGroup || - (selectedModelGroup === "wildcard" && model.model_name?.includes("*")); + (selectedModelGroup === WILDCARD_MODEL_GROUP_VALUE && model.model_name?.includes("*")); const accessGroupMatch = - selectedModelAccessGroupFilter === "all" || - model.model_info["access_groups"]?.includes(selectedModelAccessGroupFilter) || + selectedModelAccessGroupFilter === ALL_MODEL_GROUPS_VALUE || + model.model_info["access_groups"]?.includes(selectedModelAccessGroupFilter ?? "") || !selectedModelAccessGroupFilter; - // Team filtering is now handled server-side via teamId query parameter - // Only apply client-side filtering for model groups and access groups return modelNameMatch && accessGroupMatch; }); }, [modelData, selectedModelGroup, selectedModelAccessGroupFilter]); - useEffect(() => { - setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); - setCurrentPage(1); - }, [selectedModelGroup, selectedModelAccessGroupFilter]); + const columnFilters = useMemo( + () => + [ + selectedModelGroup && selectedModelGroup !== ALL_MODEL_GROUPS_VALUE + ? { id: MODEL_NAME_COLUMN_ID, value: selectedModelGroup } + : null, + selectedModelAccessGroupFilter ? { id: ACCESS_GROUPS_COLUMN_ID, value: selectedModelAccessGroupFilter } : null, + ].filter((entry) => entry !== null), + [selectedModelGroup, selectedModelAccessGroupFilter], + ); - // Reset pagination when team changes - useEffect(() => { - setCurrentPage(1); - setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); - }, [teamIdForQuery]); + const handleColumnFiltersChange: OnChangeFn = (updater) => { + const next = typeof updater === "function" ? updater(columnFilters) : updater; + const modelGroup = next.find((entry) => entry.id === MODEL_NAME_COLUMN_ID)?.value; + const accessGroup = next.find((entry) => entry.id === ACCESS_GROUPS_COLUMN_ID)?.value; + setSelectedModelGroup(typeof modelGroup === "string" ? modelGroup : ALL_MODEL_GROUPS_VALUE); + setSelectedModelAccessGroupFilter(typeof accessGroup === "string" ? accessGroup : null); + resetToFirstPage(); + }; - // Reset pagination when sorting changes - useEffect(() => { - setCurrentPage(1); - setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); - }, [sorting]); + const handleSortingChange: OnChangeFn = (updater) => { + setSorting(typeof updater === "function" ? updater(sorting) : updater); + resetToFirstPage(); + }; + + const handleTeamChange = (value: string) => { + setSelectedTeamValue(value); + resetToFirstPage(); + }; const resetFilters = () => { setModelNameSearch(""); - setSelectedModelGroup("all"); + setSelectedModelGroup(ALL_MODEL_GROUPS_VALUE); setSelectedModelAccessGroupFilter(null); - setCurrentTeam("personal"); + setSelectedTeamValue(PERSONAL_TEAM_VALUE); setModelViewMode("current_team"); - setCurrentPage(1); - setPagination({ pageIndex: 0, pageSize: 50 }); + setPagination(DEFAULT_PAGINATION); setSorting([]); }; + const teamOptions = useMemo( + () => [ + { value: PERSONAL_TEAM_VALUE, label: "Personal" }, + ...(teams ?? []) + .filter((team) => team.team_id) + .map((team) => ({ value: team.team_id, label: team.team_alias ? team.team_alias : team.team_id })), + ], + [teams], + ); + + const selectedTeam = useMemo( + () => (teams ?? []).find((team) => team.team_id === selectedTeamValue) ?? null, + [teams, selectedTeamValue], + ); + const modelToDelete = useMemo(() => { if (!deleteModalModelId || !modelData?.data) return null; - return modelData.data.find((model: any) => model.model_info.id === deleteModalModelId); + return modelData.data.find((model: ModelData) => model.model_info.id === deleteModalModelId); }, [deleteModalModelId, modelData]); const handleDeleteModel = async () => { @@ -212,356 +222,99 @@ const AllModelsTab = ({ } }; - const [pausingModelId, setPausingModelId] = useState(null); + const handleTogglePause = useCallback( + async (modelId: string, blocked: boolean) => { + if (!accessToken) return; + try { + setPausingModelId(modelId); + await modelPatchUpdateCall(accessToken, { blocked }, modelId); + NotificationsManager.success(blocked ? "Model paused" : "Model resumed"); + // invalidateQueries already schedules a refetch for active observers + // on this key — no need to also call refetchModels() (would double-fetch). + queryClient.invalidateQueries({ queryKey: ["models", "list"] }); + } catch (error) { + console.error("Error toggling model pause state:", error); + NotificationsManager.fromBackend(error); + } finally { + setPausingModelId(null); + } + }, + [accessToken, queryClient], + ); - const handleTogglePause = async (modelId: string, blocked: boolean) => { - if (!accessToken) return; - try { - setPausingModelId(modelId); - await modelPatchUpdateCall(accessToken, { blocked }, modelId); - NotificationsManager.success(blocked ? "Model paused" : "Model resumed"); - // invalidateQueries already schedules a refetch for active observers - // on this key — no need to also call refetchModels() (would double-fetch). - queryClient.invalidateQueries({ queryKey: ["models", "list"] }); - } catch (error) { - console.error("Error toggling model pause state:", error); - NotificationsManager.fromBackend(error); - } finally { - setPausingModelId(null); - } - }; + const handleRefresh = useCallback(() => { + void refetchModels(); + }, [refetchModels]); + + const handleDeleteClick = useCallback((modelId: string) => { + setDeleteModalModelId(modelId); + }, []); + + const handleOpenModelSettings = useCallback(() => { + setIsModelSettingsModalVisible(true); + }, []); + + const teamAccessLabel = selectedTeam?.team_alias || selectedTeam?.team_id || ""; return (
- -
-
- {/* Current Team and View Mode Selector - Prominent Section */} -
-
-
- Current Team: -
- {isLoading ? ( - - ) : ( - setModelViewMode(value as "current_team" | "all")} - options={[ - { - value: "current_team", - label: ( - - - Current Team Models - - ), - }, - { - value: "all", - label: ( - - - All Available Models - - ), - }, - ]} - /> - )} -
-
-
+
+ - {modelViewMode === "current_team" && ( -
- -
- {currentTeam === "personal" ? ( - - To access these models: Create a Virtual Key without selecting a team on the{" "} - - Virtual Keys page - - - ) : ( - - To access these models: Create a Virtual Key and select Team as " - {typeof currentTeam !== "string" ? currentTeam.team_alias || currentTeam.team_id : ""}" on - the{" "} - - Virtual Keys page - - - )} -
-
- )} -
- - {/* Search and Filter Controls */} -
-
- {/* Search and Filter Controls */} -
-
- {/* Model Name Search */} -
- setModelNameSearch(e.target.value)} - /> - - - -
- - {/* Filter Button */} - - - {/* Reset Filters Button */} - -
- - {/* Model Settings Button */} -
- - {/* Additional Filters */} - {showFilters && ( -
- {/* Model Name Filter */} -
- setSelectedModelAccessGroupFilter(value === "all" ? null : value)} - placeholder="Filter by Model Access Group" - showSearch - options={[ - { value: "all", label: "All Model Access Groups" }, - ...availableModelAccessGroups.map((accessGroup, idx) => ({ - value: accessGroup, - label: accessGroup, - })), - ]} - /> -
-
- )} - - {/* Results Count and Pagination Controls */} -
- {isLoading ? ( - - ) : ( - - {paginationMeta.total_count > 0 - ? `Showing ${(currentPage - 1) * pageSize + 1} - ${Math.min(currentPage * pageSize, paginationMeta.total_count)} of ${paginationMeta.total_count} results` - : "Showing 0 results"} - - )} - -
- {isLoading ? ( - - ) : ( - - )} - - {isLoading ? ( - - ) : ( - - )} -
-
-
-
- - {}, - () => {}, - expandedRows, - setExpandedRows, - setDeleteModalModelId, - handleTogglePause, - pausingModelId, - )} - data={filteredData} - isLoading={isLoadingModelsInfo} - sorting={sorting} - onSortingChange={setSorting} - pagination={pagination} - onPaginationChange={setPagination} - enablePagination={true} - onRowClick={(model: any) => setSelectedModelId(model.model_info.id)} - /> + {modelViewMode === "current_team" && ( +
+ + {selectedTeamValue === PERSONAL_TEAM_VALUE ? ( + + To access these models, create a Virtual Key without selecting a team on the{" "} + + Virtual Keys page + + . + + ) : ( + + To access these models, create a Virtual Key and select Team as "{teamAccessLabel}" on the{" "} + + Virtual Keys page + + . + + )}
-
- + )} +
({ + default: { success: vi.fn(), fromBackend: vi.fn() }, +})); + +const makeModel = (overrides: Partial = {}): ModelData => + ({ + model_name: "gpt-4-public", + litellm_model_name: "openai/gpt-4", + provider: "openai", + input_cost: 30 as unknown as number, + output_cost: 60 as unknown as number, + max_tokens: 8192, + max_input_tokens: 8192, + litellm_params: { model: "openai/gpt-4" }, + cleanedLitellmParams: {}, + ...overrides, + model_info: { + id: "model-1", + created_at: "2024-01-02T00:00:00Z", + updated_at: "2024-03-04T00:00:00Z", + created_by: "alice", + team_id: "team-1", + db_model: true, + access_groups: null, + ...(overrides.model_info ?? {}), + }, + }) as ModelData; + +const baseProps = { + data: [makeModel()], + rowCount: 1, + isLoading: false, + isRefreshing: false, + onRefresh: vi.fn(), + sorting: [], + onSortingChange: vi.fn(), + pagination: { pageIndex: 0, pageSize: 50 }, + onPaginationChange: vi.fn(), + columnFilters: [], + onColumnFiltersChange: vi.fn(), + onResetFilters: vi.fn(), + searchValue: "", + onSearchChange: vi.fn(), + teamOptions: [ + { value: "personal", label: "Personal" }, + { value: "team-1", label: "Engineering" }, + ], + selectedTeamValue: "personal", + onTeamChange: vi.fn(), + isLoadingTeams: false, + viewMode: "current_team" as const, + onViewModeChange: vi.fn(), + onOpenModelSettings: vi.fn(), + availableModelGroups: ["gpt-4", "gpt-3.5-turbo"], + availableModelAccessGroups: ["sales-team"], + userRole: "Admin", + userID: "alice", + onModelIdClick: vi.fn(), + onTeamIdClick: vi.fn(), + onDeleteClick: vi.fn(), + onTogglePauseClick: vi.fn(), + pausingModelId: null, +}; + +const row = (modelId: string): HTMLElement => { + const element = document.querySelector(`[data-row-id="${modelId}"]`); + if (!(element instanceof HTMLElement)) { + throw new Error(`row ${modelId} not rendered`); + } + return element; +}; + +describe("AllModelsTable", () => { + it("renders the nine design columns and hides Status behind the Columns menu", async () => { + const user = userEvent.setup(); + render(); + + for (const header of [ + "Model ID", + "Model Information", + "Credentials", + "Created By", + "Updated At", + "Costs", + "Team ID", + "Model Access Group", + "Actions", + ]) { + expect(screen.getByRole("columnheader", { name: new RegExp(header, "i") })).toBeInTheDocument(); + } + + expect(screen.queryByRole("columnheader", { name: /^status$/i })).not.toBeInTheDocument(); + expect(screen.queryByText("DB Model")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /columns/i })); + await user.click(await screen.findByRole("menuitemcheckbox", { name: /status/i })); + + expect(await screen.findByText("DB Model")).toBeInTheDocument(); + }); + + it("opens the model detail from the model ID cell", async () => { + const user = userEvent.setup(); + const onModelIdClick = vi.fn(); + render(); + + await user.click(screen.getByTestId("model-id-model-1")); + + expect(onModelIdClick).toHaveBeenCalledWith("model-1"); + }); + + it("opens the team detail from the team ID cell", async () => { + const user = userEvent.setup(); + const onTeamIdClick = vi.fn(); + render(); + + await user.click(screen.getByTestId("model-team-id-model-1")); + + expect(onTeamIdClick).toHaveBeenCalledWith("team-1"); + }); + + it("shows a dash when the model has no team", () => { + render( + , + ); + + expect(within(row("model-1")).getAllByText("-").length).toBeGreaterThan(0); + expect(screen.queryByTestId("model-team-id-model-1")).not.toBeInTheDocument(); + }); + + it("renders the model name over the litellm model name", () => { + render(); + + const cell = screen.getByTestId("model-information-model-1"); + expect(within(cell).getByText("gpt-4-public")).toBeInTheDocument(); + expect(within(cell).getByText("openai/gpt-4")).toBeInTheDocument(); + }); + + it("renders a reusable credential by name and falls back to Manual", () => { + const { rerender } = render( + , + ); + expect(screen.getByText("openai-prod")).toBeInTheDocument(); + expect(screen.queryByText("Manual")).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Manual")).toBeInTheDocument(); + }); + + it("shows 'Defined in config' for a config model and the creator for a DB model", () => { + const { rerender } = render(); + expect(screen.getByText("alice")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("Defined in config")).toBeInTheDocument(); + }); + + it("renders input and output costs and a dash when both are missing", () => { + const { rerender } = render(); + expect(screen.getByText("$30")).toBeInTheDocument(); + expect(screen.getByText("$60")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByText(/^\$/)).not.toBeInTheDocument(); + }); + + it("collapses extra access groups behind a +N more badge", () => { + render( + , + ); + + expect(screen.getByText("sales-team")).toBeInTheDocument(); + expect(screen.getByText("+2 more")).toBeInTheDocument(); + }); + + describe("pause / resume", () => { + it("renders the toggle on for an active DB model and off for a blocked one", () => { + const { rerender } = render(); + expect(screen.getByTestId("model-pause-toggle-model-1")).toBeChecked(); + + rerender( + , + ); + expect(screen.getByTestId("model-pause-toggle-model-1")).not.toBeChecked(); + }); + + it("pauses an active model and resumes a blocked one", async () => { + const user = userEvent.setup(); + const onTogglePauseClick = vi.fn(); + const { rerender } = render(); + + await user.click(screen.getByTestId("model-pause-toggle-model-1")); + expect(onTogglePauseClick).toHaveBeenCalledWith("model-1", true); + + onTogglePauseClick.mockClear(); + rerender( + , + ); + + await user.click(screen.getByTestId("model-pause-toggle-model-1")); + expect(onTogglePauseClick).toHaveBeenCalledWith("model-1", false); + }); + + it("does not let a non-admin toggle a model", async () => { + const user = userEvent.setup(); + const onTogglePauseClick = vi.fn(); + render(); + + const toggle = screen.getByTestId("model-pause-toggle-model-1"); + expect(toggle).toHaveAttribute("data-disabled"); + await user.click(toggle); + expect(onTogglePauseClick).not.toHaveBeenCalled(); + }); + + it("does not let anyone toggle a config model", async () => { + const user = userEvent.setup(); + const onTogglePauseClick = vi.fn(); + render( + , + ); + + const toggle = screen.getByTestId("model-pause-toggle-model-1"); + expect(toggle).toHaveAttribute("data-disabled"); + await user.click(toggle); + expect(onTogglePauseClick).not.toHaveBeenCalled(); + }); + + it("replaces the toggle with a pending indicator while a PATCH is in flight", () => { + render(); + + expect(screen.getByTestId("model-pause-pending-model-1")).toBeInTheDocument(); + expect(screen.queryByTestId("model-pause-toggle-model-1")).not.toBeInTheDocument(); + }); + }); + + describe("delete", () => { + it("lets an admin delete a DB model", async () => { + const user = userEvent.setup(); + const onDeleteClick = vi.fn(); + render(); + + await user.click(screen.getByTestId("model-delete-model-1")); + expect(onDeleteClick).toHaveBeenCalledWith("model-1"); + }); + + it("lets the creator delete their own DB model", async () => { + const user = userEvent.setup(); + const onDeleteClick = vi.fn(); + render(); + + await user.click(screen.getByTestId("model-delete-model-1")); + expect(onDeleteClick).toHaveBeenCalledWith("model-1"); + }); + + it("blocks deleting a model the user did not create", async () => { + const user = userEvent.setup(); + const onDeleteClick = vi.fn(); + render(); + + const deleteButton = screen.getByTestId("model-delete-model-1"); + expect(deleteButton).toBeDisabled(); + await user.click(deleteButton); + expect(onDeleteClick).not.toHaveBeenCalled(); + }); + + it("blocks deleting a config model", async () => { + const user = userEvent.setup(); + const onDeleteClick = vi.fn(); + render( + , + ); + + const deleteButton = screen.getByTestId("model-delete-model-1"); + expect(deleteButton).toBeDisabled(); + await user.click(deleteButton); + expect(onDeleteClick).not.toHaveBeenCalled(); + }); + }); + + describe("toolbar", () => { + it("wires search, refresh, team, view and model settings", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + const onRefresh = vi.fn(); + const onOpenModelSettings = vi.fn(); + render( + , + ); + + await user.type(screen.getByTestId("datatable-search"), "gpt"); + expect(onSearchChange).toHaveBeenCalled(); + + await user.click(screen.getByTestId("datatable-refresh")); + expect(onRefresh).toHaveBeenCalled(); + + await user.click(screen.getByTestId("models-settings-trigger")); + expect(onOpenModelSettings).toHaveBeenCalled(); + + expect(screen.getByTestId("models-team-select")).toHaveTextContent("Personal"); + expect(screen.getByTestId("models-view-select")).toHaveTextContent("Current Team Models"); + }); + + it("switches the current team", async () => { + const user = userEvent.setup(); + const onTeamChange = vi.fn(); + render(); + + await user.click(screen.getByTestId("models-team-select")); + await user.click(await screen.findByRole("option", { name: "Engineering" })); + + expect(onTeamChange).toHaveBeenCalledWith("team-1"); + }); + + it("runs the full reset from the filter drawer", async () => { + const user = userEvent.setup(); + const onResetFilters = vi.fn(); + render(); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(await screen.findByTestId("filter-drawer-reset")); + + expect(onResetFilters).toHaveBeenCalled(); + }); + + it("renders active filters as removable chips", async () => { + const user = userEvent.setup(); + const onColumnFiltersChange = vi.fn(); + render( + , + ); + + const chip = screen.getByTestId("filter-chip-model_name"); + expect(chip).toHaveTextContent("Public Model Name"); + expect(chip).toHaveTextContent("Wildcard Models (*)"); + + await user.click(screen.getByTestId("filter-chip-remove-model_name")); + expect(onColumnFiltersChange).toHaveBeenCalled(); + }); + }); + + it("shows the server row count in the pagination footer", () => { + render(); + + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 137"); + }); + + it("shows the empty state when there are no models", () => { + render(); + + expect(screen.getByText("No models found")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx new file mode 100644 index 00000000000..d073519d162 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; +import { Search, Settings } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { ModelData } from "@/components/model_dashboard/types"; +import { + DataTable, + DataTableFilterDrawer, + DataTableFilterField, + DataTableToolbar, +} from "@/components/shared/DataTable"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/cva.config"; + +import { + ACCESS_GROUPS_COLUMN_ID, + getModelsTableColumns, + MODEL_NAME_COLUMN_ID, + STATUS_COLUMN_ID, +} from "./ModelsTableColumns"; + +export type ModelViewMode = "all" | "current_team"; + +export const PERSONAL_TEAM_VALUE = "personal"; +export const ALL_MODEL_GROUPS_VALUE = "all"; +export const WILDCARD_MODEL_GROUP_VALUE = "wildcard"; + +const MODEL_TABLE_BODY_HEIGHT = 600; + +const FILTER_LABELS: Record = { + [MODEL_NAME_COLUMN_ID]: "Public Model Name", + [ACCESS_GROUPS_COLUMN_ID]: "Model Access Group", +}; + +const VIEW_MODE_LABELS: Record = { + current_team: "Current Team Models", + all: "All Available Models", +}; + +export interface ModelsTableTeamOption { + value: string; + label: string; +} + +interface AllModelsTableProps { + data: ModelData[]; + rowCount: number; + isLoading: boolean; + isRefreshing: boolean; + onRefresh: () => void; + sorting: SortingState; + onSortingChange: OnChangeFn; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + columnFilters: ColumnFiltersState; + onColumnFiltersChange: OnChangeFn; + onResetFilters: () => void; + searchValue: string; + onSearchChange: (value: string) => void; + teamOptions: ModelsTableTeamOption[]; + selectedTeamValue: string; + onTeamChange: (value: string) => void; + isLoadingTeams: boolean; + viewMode: ModelViewMode; + onViewModeChange: (viewMode: ModelViewMode) => void; + onOpenModelSettings: () => void; + availableModelGroups: string[]; + availableModelAccessGroups: string[]; + userRole: string; + userID: string; + onModelIdClick: (modelId: string) => void; + onTeamIdClick: (teamId: string) => void; + onDeleteClick: (modelId: string) => void; + onTogglePauseClick: (modelId: string, blocked: boolean) => void | Promise; + pausingModelId: string | null; +} + +function EmptyState() { + return ( +
+
+ +
+
No models found
+
+ No models match your search or filters. Try resetting them. +
+
+ ); +} + +export function AllModelsTable({ + data, + rowCount, + isLoading, + isRefreshing, + onRefresh, + sorting, + onSortingChange, + pagination, + onPaginationChange, + columnFilters, + onColumnFiltersChange, + onResetFilters, + searchValue, + onSearchChange, + teamOptions, + selectedTeamValue, + onTeamChange, + isLoadingTeams, + viewMode, + onViewModeChange, + onOpenModelSettings, + availableModelGroups, + availableModelAccessGroups, + userRole, + userID, + onModelIdClick, + onTeamIdClick, + onDeleteClick, + onTogglePauseClick, + pausingModelId, +}: AllModelsTableProps) { + const [filtersOpen, setFiltersOpen] = useState(false); + + const columns = useMemo(() => { + const columnDeps = { + userRole, + userID, + onModelIdClick, + onTeamIdClick, + onDeleteClick, + onTogglePauseClick, + pausingModelId, + }; + return getModelsTableColumns(columnDeps); + }, [userRole, userID, onModelIdClick, onTeamIdClick, onDeleteClick, onTogglePauseClick, pausingModelId]); + + const modelGroupOptions = useMemo( + () => [ + { label: "All Models", value: ALL_MODEL_GROUPS_VALUE }, + { label: "Wildcard Models (*)", value: WILDCARD_MODEL_GROUP_VALUE }, + ...availableModelGroups.map((group) => ({ label: group, value: group })), + ], + [availableModelGroups], + ); + + const accessGroupOptions = useMemo( + () => [ + { label: "All Model Access Groups", value: ALL_MODEL_GROUPS_VALUE }, + ...availableModelAccessGroups.map((accessGroup) => ({ label: accessGroup, value: accessGroup })), + ], + [availableModelAccessGroups], + ); + + const formatFilterValue = (columnId: string, value: unknown): string => { + const raw = String(value); + if (columnId === MODEL_NAME_COLUMN_ID && raw === WILDCARD_MODEL_GROUP_VALUE) { + return "Wildcard Models (*)"; + } + return raw; + }; + + const selectedTeamLabel = + teamOptions.find((option) => option.value === selectedTeamValue)?.label ?? teamOptions[0]?.label ?? ""; + + return ( + row.model_info?.id ?? String(index)} + sortingMode="server" + sorting={sorting} + onSortingChange={onSortingChange} + enableSortingRemoval + paginationMode="server" + pagination={pagination} + onPaginationChange={onPaginationChange} + rowCount={rowCount} + pageSizeOptions={[10, 25, 50]} + filterMode="server" + columnFilters={columnFilters} + onColumnFiltersChange={onColumnFiltersChange} + defaultColumnVisibility={{ [STATUS_COLUMN_ID]: false }} + enableColumnResizing + maxBodyHeight={MODEL_TABLE_BODY_HEIGHT} + isLoading={isLoading} + loadingMessage="Loading models…" + noDataMessage={} + size="compact" + toolbar={(table) => ( + <> + setFiltersOpen(true)} + onRefresh={onRefresh} + isRefreshing={isRefreshing} + filterLabels={FILTER_LABELS} + formatFilterValue={formatFilterValue} + > + + + + + + + + + + {({ get, set }) => ( + <> + + + set(MODEL_NAME_COLUMN_ID, value === ALL_MODEL_GROUPS_VALUE ? undefined : value) + } + placeholder="Filter by Public Model Name" + emptyText="No models found" + /> + + + + set(ACCESS_GROUPS_COLUMN_ID, value === ALL_MODEL_GROUPS_VALUE ? undefined : value) + } + placeholder="Filter by Model Access Group" + emptyText="No model access groups found" + /> + + + )} + + + )} + /> + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx new file mode 100644 index 00000000000..93ee6d9f0ab --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx @@ -0,0 +1,488 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, Info, Loader2, Pencil, RefreshCw, Trash2 } from "lucide-react"; + +import { ProviderLogo } from "@/components/molecules/models/ProviderLogo"; +import { ModelData } from "@/components/model_dashboard/types"; +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { CellTooltip, DateCell, formatCellDate, IdCell, StatusBadge } from "@/components/shared/table_cells"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; +import { Switch } from "@/components/ui/switch"; +import { getDisplayModelName } from "@/components/view_model/model_name_display"; +import { copyToClipboard } from "@/utils/dataUtils"; + +export const MODEL_ID_COLUMN_ID = "model_info_id"; +export const MODEL_NAME_COLUMN_ID = "model_name"; +export const CREDENTIALS_COLUMN_ID = "litellm_credential_name"; +export const CREATED_BY_COLUMN_ID = "model_info_created_by"; +export const UPDATED_AT_COLUMN_ID = "model_info_updated_at"; +export const COSTS_COLUMN_ID = "input_cost"; +export const TEAM_ID_COLUMN_ID = "model_info_team_id"; +export const ACCESS_GROUPS_COLUMN_ID = "model_info_access_groups"; +export const STATUS_COLUMN_ID = "model_info_db_model"; + +const COLUMN_ID_TO_SERVER_SORT_FIELD: Record = { + [COSTS_COLUMN_ID]: "costs", + [STATUS_COLUMN_ID]: "status", + [CREATED_BY_COLUMN_ID]: "created_at", + [UPDATED_AT_COLUMN_ID]: "updated_at", +}; + +export const toServerSortField = (columnId: string): string => COLUMN_ID_TO_SERVER_SORT_FIELD[columnId] ?? columnId; + +const formatShortDate = (value: string | null | undefined): string | null => { + if (!value) { + return null; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : formatCellDate(date, "date"); +}; + +function ModelInformationCell({ model, displayName }: { model: ModelData; displayName: string }) { + const litellmModelName = model.litellm_model_name || "-"; + + return ( + + + } + > + {model.provider ? ( + + ) : ( + + - + + )} + + + {displayName} + + + {litellmModelName} + + + + +
+
+ {model.provider ? : null} + {model.provider || "Unknown provider"} +
+
+ Public Model Name + + {displayName} + +
+
+ LiteLLM Model Name + + + {litellmModelName} + + + +
+
+
+
+ ); +} + +function CredentialsHeader() { + return ( + + Credentials + + + } + > + + + +
+ Credential types +
+ + + Reusable + + + Credentials saved in LiteLLM that can be added to models repeatedly. + +
+
+ + + Manual + + + Credentials added directly during model creation or defined in the config file. + +
+
+
+
+
+ ); +} + +function CredentialsCell({ credentialName }: { credentialName: string | undefined }) { + if (!credentialName) { + return ( + + + Manual + + ); + } + + return ( + + + {credentialName} + + ); +} + +function CreatedByCell({ model }: { model: ModelData }) { + const isConfigModel = !model.model_info?.db_model; + const createdAt = formatShortDate(model.model_info.created_at); + const primary = isConfigModel ? "Defined in config" : model.model_info.created_by || "Unknown"; + const secondaryForDbModel = createdAt ?? "Unknown date"; + + return ( +
+ + {primary} + + {isConfigModel ? "-" : secondaryForDbModel} +
+ ); +} + +function CostsCell({ model }: { model: ModelData }) { + const { input_cost: inputCost, output_cost: outputCost } = model; + + if (inputCost == null && outputCost == null) { + return -; + } + + return ( + + {inputCost != null && ( + + IN + ${inputCost} + + )} + {outputCost != null && ( + + OUT + ${outputCost} + + )} +
+ } + /> + ); +} + +function AccessGroupsCell({ accessGroups }: { accessGroups: string[] | null }) { + if (!accessGroups || accessGroups.length === 0) { + return -; + } + + const [first, ...overflow] = accessGroups; + + return ( +
+ + {first} + + {overflow.length > 0 && ( + + {overflow.map((group) => ( + {group} + ))} +
+ } + trigger={ + + +{overflow.length} more + + } + /> + )} +
+ ); +} + +interface ModelRowActionsProps { + model: ModelData; + userRole: string; + userID: string; + isPausing: boolean; + onDeleteClick?: (modelId: string) => void; + onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise; +} + +function ModelRowActions({ + model, + userRole, + userID, + isPausing, + onDeleteClick, + onTogglePauseClick, +}: ModelRowActionsProps) { + const modelId = model.model_info?.id; + const isConfigModel = !model.model_info?.db_model; + const isAdmin = userRole === "Admin"; + const canEditModel = isAdmin || model.model_info?.created_by === userID; + const isBlocked = model.model_info?.blocked === true; + const isPauseToggleable = !isConfigModel && isAdmin && Boolean(onTogglePauseClick); + + const resolvePauseTooltip = (): string => { + if (isConfigModel) { + return "Config models cannot be paused from the dashboard. Pause is DB-backed."; + } + if (!isAdmin) { + return "Only proxy admins can pause or resume a model."; + } + return isBlocked ? "Resume model — restore normal routing." : "Pause model — stop routing requests until resumed."; + }; + + const deleteTooltip = isConfigModel + ? "Config model cannot be deleted on the dashboard. Please delete it from the config file." + : "Delete model"; + + return ( +
+ + {isPausing ? ( + + ) : ( + + { + if (isPauseToggleable && onTogglePauseClick && modelId) { + void onTogglePauseClick(modelId, !nextChecked); + } + }} + /> + + } + /> + )} + + + + + } + /> +
+ ); +} + +export interface ModelsTableColumnDeps { + userRole: string; + userID: string; + onModelIdClick: (modelId: string) => void; + onTeamIdClick: (teamId: string) => void; + onDeleteClick?: (modelId: string) => void; + onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise; + pausingModelId?: string | null; +} + +export const getModelsTableColumns = ({ + userRole, + userID, + onModelIdClick, + onTeamIdClick, + onDeleteClick, + onTogglePauseClick, + pausingModelId, +}: ModelsTableColumnDeps): ColumnDef[] => [ + { + id: MODEL_ID_COLUMN_ID, + accessorFn: (row) => row.model_info.id, + meta: { title: "Model ID" }, + header: "Model ID", + enableSorting: false, + size: 140, + minSize: 90, + cell: ({ row }) => ( + + ), + }, + { + id: MODEL_NAME_COLUMN_ID, + accessorFn: (row) => row.model_name ?? "", + meta: { title: "Model Information", skeleton: "twoLine" }, + header: ({ column }) => , + enableSorting: true, + size: 280, + minSize: 160, + cell: ({ row }) => ( + + ), + }, + { + id: CREDENTIALS_COLUMN_ID, + accessorFn: (row) => row.litellm_params?.litellm_credential_name ?? "", + meta: { title: "Credentials" }, + header: () => , + enableSorting: false, + size: 180, + minSize: 110, + cell: ({ row }) => , + }, + { + id: CREATED_BY_COLUMN_ID, + accessorFn: (row) => row.model_info.created_by ?? "", + meta: { title: "Created By", skeleton: "twoLine" }, + header: ({ column }) => , + enableSorting: true, + size: 180, + minSize: 110, + cell: ({ row }) => , + }, + { + id: UPDATED_AT_COLUMN_ID, + accessorFn: (row) => row.model_info.updated_at ?? "", + meta: { title: "Updated At" }, + header: ({ column }) => , + enableSorting: true, + size: 140, + minSize: 100, + cell: ({ row }) => , + }, + { + id: COSTS_COLUMN_ID, + accessorFn: (row) => row.input_cost, + meta: { title: "Costs" }, + header: ({ column }) => , + enableSorting: true, + size: 130, + minSize: 90, + cell: ({ row }) => , + }, + { + id: TEAM_ID_COLUMN_ID, + accessorFn: (row) => row.model_info.team_id ?? "", + meta: { title: "Team ID" }, + header: "Team ID", + enableSorting: false, + size: 140, + minSize: 90, + cell: ({ row }) => ( + + ), + }, + { + id: ACCESS_GROUPS_COLUMN_ID, + accessorFn: (row) => row.model_info.access_groups ?? [], + meta: { title: "Model Access Group", skeleton: "chips" }, + header: "Model Access Group", + enableSorting: false, + size: 200, + minSize: 120, + cell: ({ row }) => , + }, + { + id: STATUS_COLUMN_ID, + accessorFn: (row) => row.model_info.db_model, + meta: { title: "Status", skeleton: "badge" }, + header: ({ column }) => , + enableSorting: true, + size: 140, + minSize: 100, + cell: ({ row }) => + row.original.model_info.db_model ? ( + + ) : ( + + ), + }, + { + id: "actions", + meta: { title: "Actions", className: "text-right", headerClassName: "text-right" }, + header: "Actions", + enableSorting: false, + enableHiding: false, + enableResizing: false, + size: 110, + minSize: 110, + cell: ({ row }) => ( + + ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/model_dashboard/all_models_table.tsx b/ui/litellm-dashboard/src/components/model_dashboard/all_models_table.tsx deleted file mode 100644 index 4372c6e0efc..00000000000 --- a/ui/litellm-dashboard/src/components/model_dashboard/all_models_table.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { - ColumnDef, - flexRender, - getCoreRowModel, - getPaginationRowModel, - SortingState, - useReactTable, - ColumnResizeMode, - VisibilityState, - PaginationState, - OnChangeFn, -} from "@tanstack/react-table"; -import React from "react"; -import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react"; -import { - TableHeaderSortDropdown, - SortState, -} from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown"; - -// Extend the column meta type to include className -declare module "@tanstack/react-table" { - interface ColumnMeta { - className?: string; - } -} - -interface AllModelsDataTableProps { - data: TData[]; - columns: ColumnDef[]; - isLoading?: boolean; - sorting?: SortingState; - onSortingChange?: OnChangeFn; - pagination?: PaginationState; - onPaginationChange?: OnChangeFn; - enablePagination?: boolean; - onRowClick?: (row: TData) => void; -} - -export function AllModelsDataTable({ - data = [], - columns, - isLoading = false, - sorting = [], - onSortingChange, - pagination, - onPaginationChange, - enablePagination = false, - onRowClick, -}: AllModelsDataTableProps) { - const [columnResizeMode] = React.useState("onChange"); - const [columnSizing, setColumnSizing] = React.useState({}); - const [columnVisibility, setColumnVisibility] = React.useState({}); - - const tableInstance = useReactTable({ - data, - columns, - state: { - sorting, - columnSizing, - columnVisibility, - ...(enablePagination && pagination ? { pagination } : {}), - }, - columnResizeMode, - onSortingChange: onSortingChange, - onColumnSizingChange: setColumnSizing, - onColumnVisibilityChange: setColumnVisibility, - ...(enablePagination && onPaginationChange ? { onPaginationChange } : {}), - getCoreRowModel: getCoreRowModel(), - // NO getSortedRowModel - sorting is handled server-side - ...(enablePagination ? { getPaginationRowModel: getPaginationRowModel() } : {}), - enableSorting: true, - enableColumnResizing: true, - manualSorting: true, // Enable manual sorting for server-side sorting - defaultColumn: { - minSize: 40, - maxSize: 500, - }, - }); - - const getHeaderText = (header: any): string => { - if (typeof header === "string") { - return header; - } - if (typeof header === "function") { - const headerElement = header(); - if (headerElement && headerElement.props && headerElement.props.children) { - const children = headerElement.props.children; - if (typeof children === "string") { - return children; - } - if (children.props && children.props.children) { - return children.props.children; - } - } - } - return ""; - }; - - return ( -
-
-
- - - {tableInstance.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
-
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.id !== "actions" && header.column.getCanSort() && onSortingChange && ( - { - // Convert SortState to TanStack SortingState - // Only allow one column to be sorted at a time - if (newState === false) { - onSortingChange([]); - } else { - onSortingChange([ - { - id: header.column.id, - desc: newState === "desc", - }, - ]); - } - }} - columnId={header.column.id} - /> - )} -
- {header.column.getCanResize() && ( -
- )} - - ))} - - ))} - - - {isLoading ? ( - - -
-

🚅 Loading models...

-
-
-
- ) : tableInstance.getRowModel().rows.length > 0 ? ( - tableInstance.getRowModel().rows.map((row) => ( - onRowClick?.(row.original)} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No models found

-
-
-
- )} -
-
-
-
-
- ); -} diff --git a/ui/litellm-dashboard/src/components/model_dashboard/types.ts b/ui/litellm-dashboard/src/components/model_dashboard/types.ts index 77a03d2c039..e58204995dd 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/types.ts +++ b/ui/litellm-dashboard/src/components/model_dashboard/types.ts @@ -7,6 +7,7 @@ export interface ModelInfo { db_model: boolean; access_groups: string[] | null; blocked?: boolean; + team_public_model_name?: string; } export interface LiteLLMParams { diff --git a/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx b/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx deleted file mode 100644 index 9628c706c83..00000000000 --- a/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx +++ /dev/null @@ -1,1099 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { useReactTable, getCoreRowModel, flexRender } from "@tanstack/react-table"; -import { columns } from "./columns"; -import { ModelData } from "../../model_dashboard/types"; -import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react"; -import * as providerInfoHelpers from "../../provider_info_helpers"; - -vi.mock("../../provider_info_helpers"); - -vi.mock("@tremor/react", async (importOriginal) => { - const React = await import("react"); - const actual = await importOriginal(); - const IconComponent = React.forwardRef( - ({ icon: IconComp, onClick, className, ...props }, ref) => { - const ariaLabel = className?.includes("cursor-not-allowed") - ? "Config model cannot be deleted on the dashboard. Please delete it from the config file." - : "Delete model"; - return React.createElement( - "button", - { ...props, onClick, className, ref, "aria-label": ariaLabel }, - IconComp && React.createElement(IconComp, { className: "w-4 h-4" }), - ); - }, - ); - IconComponent.displayName = "Icon"; - // Re-apply the global Button/Tooltip overrides from tests/setupTests.ts. A file-level - // vi.mock fully replaces the setup-level mock, so without this the real Tremor Button - // leaks through and its useTooltip(300) schedules a native setTimeout that can fire - // post-teardown -> "window is not defined". - const Button = React.forwardRef(({ children, ...props }, ref) => - React.createElement("button", { ...props, ref }, children), - ); - const Tooltip = ({ children }: any) => React.createElement(React.Fragment, null, children); - return { - ...actual, - Icon: IconComponent, - Button, - Tooltip, - }; -}); - -const createMockModel = (overrides: Partial = {}): ModelData => ({ - model_info: { - id: "test-model-id", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-02T00:00:00Z", - created_by: "test-user", - team_id: "test-team-id", - db_model: true, - access_groups: ["group1"], - }, - model_name: "test-model", - provider: "openai", - litellm_model_name: "gpt-4", - input_cost: 0.01, - output_cost: 0.03, - max_tokens: 4096, - max_input_tokens: 8192, - litellm_params: { - model: "gpt-4", - litellm_credential_name: "test-credential", - }, - cleanedLitellmParams: {}, - ...overrides, -}); - -const TestTable = ({ data, columns: cols }: { data: ModelData[]; columns: ReturnType }) => { - const table = useReactTable({ - data, - columns: cols, - getCoreRowModel: getCoreRowModel(), - }); - - return ( - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} - ))} - - ))} - -
- ); -}; - -describe("columns", () => { - beforeEach(() => { - vi.mocked(providerInfoHelpers.getProviderLogoAndName).mockImplementation((provider: string) => { - const providerMap: Record = { - openai: { displayName: "OpenAI", logo: "/openai-logo.png" }, - anthropic: { displayName: "Anthropic", logo: "/anthropic-logo.png" }, - }; - return providerMap[provider] || { displayName: provider || "Unknown provider", logo: "" }; - }); - }); - - const defaultProps = { - userRole: "Admin", - userID: "test-user", - premiumUser: false, - setSelectedModelId: vi.fn(), - setSelectedTeamId: vi.fn(), - getDisplayModelName: vi.fn((model: ModelData) => model.model_name || "-"), - handleEditClick: vi.fn(), - handleRefreshClick: vi.fn(), - expandedRows: new Set(), - setExpandedRows: vi.fn(), - }; - - it("should render columns with table structure", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel(); - render(); - - expect(screen.getByText("Model ID")).toBeInTheDocument(); - expect(screen.getByText("Model Information")).toBeInTheDocument(); - expect(screen.getByText("Credentials")).toBeInTheDocument(); - expect(screen.getByText("Created By")).toBeInTheDocument(); - expect(screen.getByText("Updated At")).toBeInTheDocument(); - expect(screen.getByText("Costs")).toBeInTheDocument(); - expect(screen.getByText("Team ID")).toBeInTheDocument(); - expect(screen.getByText("Model Access Group")).toBeInTheDocument(); - expect(screen.getByText("Status")).toBeInTheDocument(); - expect(screen.getByText("Actions")).toBeInTheDocument(); - }); - - it("should display model information with provider logo", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_name: "GPT-4", - provider: "openai", - litellm_model_name: "gpt-4", - }); - render(); - - expect(screen.getByText("GPT-4")).toBeInTheDocument(); - expect(screen.getByText("gpt-4")).toBeInTheDocument(); - }); - - it("should display credential name when available", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - litellm_params: { - model: "gpt-4", - litellm_credential_name: "my-credential", - }, - }); - render(); - - expect(screen.getByText("my-credential")).toBeInTheDocument(); - }); - - it("should display 'Manual' when credential name is missing", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - litellm_params: { - model: "gpt-4", - }, - }); - render(); - - expect(screen.getByText("Manual")).toBeInTheDocument(); - }); - - describe("credentials column", () => { - it("should display Credentials header with info icon", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel(); - render(); - - expect(screen.getByText("Credentials")).toBeInTheDocument(); - // Info icon is in a flex container with Credentials - ant icons render as span with role="img" - const credentialsHeader = screen.getByText("Credentials").closest("span"); - expect(credentialsHeader?.parentElement?.querySelector('[role="img"]')).toBeInTheDocument(); - }); - - it("should display reusable credential with SyncOutlined icon and credential name", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - litellm_params: { - model: "gpt-4", - litellm_credential_name: "my-reusable-credential", - }, - }); - render(); - - expect(screen.getByText("my-reusable-credential")).toBeInTheDocument(); - const credentialCell = screen.getByText("my-reusable-credential").closest("div"); - expect(credentialCell).toHaveClass("flex"); - expect(screen.getByText("my-reusable-credential")).toHaveClass("text-blue-600"); - }); - - it("should display Manual with EditOutlined when no credential name", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - litellm_params: { - model: "gpt-4", - }, - }); - render(); - - expect(screen.getByText("Manual")).toBeInTheDocument(); - expect(screen.getByText("Manual")).toHaveClass("text-gray-500"); - }); - - it("should display Manual when litellm_params is undefined", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - litellm_params: undefined as any, - }); - render(); - - expect(screen.getByText("Manual")).toBeInTheDocument(); - }); - - it("should display Manual when litellm_credential_name is empty string", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - litellm_params: { - model: "gpt-4", - litellm_credential_name: "", - }, - }); - render(); - - expect(screen.getByText("Manual")).toBeInTheDocument(); - }); - }); - - it("should display created by information for DB models", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: true, - created_by: "admin-user", - created_at: "2024-01-15T10:30:00Z", - }, - }); - render(); - - expect(screen.getByText("admin-user")).toBeInTheDocument(); - }); - - it("should display 'Defined in config' for config models", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: false, - }, - }); - render(); - - expect(screen.getByText("Defined in config")).toBeInTheDocument(); - }); - - it("should display costs when available", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - input_cost: 0.01, - output_cost: 0.03, - }); - render(); - - expect(screen.getByText("In: $0.01")).toBeInTheDocument(); - expect(screen.getByText("Out: $0.03")).toBeInTheDocument(); - }); - - it("should display '-' when costs are missing", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - input_cost: undefined as any, - output_cost: undefined as any, - }); - render(); - - const costCells = screen.getAllByText("-"); - expect(costCells.length).toBeGreaterThan(0); - }); - - it("should call setSelectedModelId without triggering the row click when the model ID pill is clicked", async () => { - const user = userEvent.setup(); - const setSelectedModelId = vi.fn(); - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel(); - const rowClick = vi.fn(); - render( -
- -
, - ); - - await user.click(screen.getByText("test-model-id")); - expect(setSelectedModelId).toHaveBeenCalledWith("test-model-id"); - expect(rowClick).not.toHaveBeenCalled(); - }); - - it("should call setSelectedTeamId without triggering the row click when the team ID pill is clicked", async () => { - const user = userEvent.setup(); - const setSelectedTeamId = vi.fn(); - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel(); - const rowClick = vi.fn(); - render( -
- -
, - ); - - await user.click(screen.getByText("test-team-id")); - expect(setSelectedTeamId).toHaveBeenCalledWith("test-team-id"); - expect(rowClick).not.toHaveBeenCalled(); - }); - - it("should display '-' when team ID is missing", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - team_id: "", - }, - }); - render(); - - const teamIdCells = screen.getAllByText("-"); - expect(teamIdCells.length).toBeGreaterThan(0); - }); - - it("should display access groups", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - access_groups: ["group1", "group2"], - }, - }); - render(); - - expect(screen.getByText("group1")).toBeInTheDocument(); - expect(screen.getByText("+1")).toBeInTheDocument(); - }); - - it("should expand access groups when expand button is clicked", async () => { - const user = userEvent.setup(); - const setExpandedRows = vi.fn(); - const expandedRows = new Set(); - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - expandedRows, - setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - id: "model-with-groups", - access_groups: ["group1", "group2", "group3"], - }, - }); - render(); - - const expandButton = screen.getByText("+2"); - expect(expandButton).toBeInTheDocument(); - - await user.click(expandButton); - expect(setExpandedRows).toHaveBeenCalled(); - }); - - it("should display '-' when access groups are empty", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - access_groups: null, - }, - }); - render(); - - const emptyCells = screen.getAllByText("-"); - expect(emptyCells.length).toBeGreaterThan(0); - }); - - it("should display 'DB Model' status for DB models", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: true, - }, - }); - render(); - - expect(screen.getByText("DB Model")).toBeInTheDocument(); - }); - - it("should display 'Config Model' status for config models", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: false, - }, - }); - render(); - - expect(screen.getByText("Config Model")).toBeInTheDocument(); - }); - - it("should allow Admin to delete DB models", async () => { - const user = userEvent.setup(); - const onDeleteClick = vi.fn(); - const cols = columns( - "Admin", - "admin-user", - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - onDeleteClick, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: true, - id: "deletable-model", - }, - }); - render(); - - const deleteButton = screen.getByRole("button", { name: "Delete model" }); - expect(deleteButton).toBeInTheDocument(); - - await user.click(deleteButton); - expect(onDeleteClick).toHaveBeenCalledWith("deletable-model"); - }); - - it("should allow model creator to delete their own DB models", async () => { - const user = userEvent.setup(); - const onDeleteClick = vi.fn(); - const cols = columns( - "User", - "model-creator", - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - onDeleteClick, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: true, - created_by: "model-creator", - id: "user-model", - }, - }); - render(); - - const deleteButton = screen.getByRole("button", { name: "Delete model" }); - expect(deleteButton).toBeInTheDocument(); - - await user.click(deleteButton); - expect(onDeleteClick).toHaveBeenCalledWith("user-model"); - }); - - it("should disable delete for config models", () => { - const cols = columns( - "Admin", - "admin-user", - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: false, - }, - }); - render(); - - const deleteButton = screen.getByRole("button", { name: /config model cannot be deleted/i }); - expect(deleteButton).toBeInTheDocument(); - expect(deleteButton).toHaveClass("cursor-not-allowed"); - }); - - it("should display collapsed access groups with expand button", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - new Set(), - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - access_groups: ["group1", "group2", "group3"], - }, - }); - render(); - - expect(screen.getByText("group1")).toBeInTheDocument(); - expect(screen.getByText("+2")).toBeInTheDocument(); - expect(screen.queryByText("group2")).not.toBeInTheDocument(); - expect(screen.queryByText("group3")).not.toBeInTheDocument(); - }); - - it("should display expanded access groups when expanded", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - new Set(["test-model-id"]), - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - id: "test-model-id", - access_groups: ["group1", "group2", "group3"], - }, - }); - render(); - - expect(screen.getByText("group1")).toBeInTheDocument(); - expect(screen.getByText("group2")).toBeInTheDocument(); - expect(screen.getByText("group3")).toBeInTheDocument(); - expect(screen.getByText("−")).toBeInTheDocument(); - }); - - it("should display single access group without expand button", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - access_groups: ["group1"], - }, - }); - render(); - - expect(screen.getByText("group1")).toBeInTheDocument(); - expect(screen.queryByText(/\+/)).not.toBeInTheDocument(); - }); - - it("should handle missing display name gracefully", () => { - const getDisplayModelName = vi.fn(() => ""); - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel(); - render(); - - expect(screen.getByText("-")).toBeInTheDocument(); - }); - - it("should handle missing created_at date", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - created_at: "", - }, - }); - render(); - - expect(screen.getByText("Unknown date")).toBeInTheDocument(); - }); - - it("should handle missing updated_at date", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - updated_at: "", - }, - }); - render(); - - const updatedAtCells = screen.getAllByText("-"); - expect(updatedAtCells.length).toBeGreaterThan(0); - }); - - it("should handle missing created_by for DB models", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: true, - created_by: "", - }, - }); - render(); - - expect(screen.getByText("Unknown")).toBeInTheDocument(); - }); - - it("should display only input cost when output cost is missing", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - input_cost: 0.01, - output_cost: undefined as any, - }); - render(); - - expect(screen.getByText("In: $0.01")).toBeInTheDocument(); - expect(screen.queryByText(/Out:/)).not.toBeInTheDocument(); - }); - - it("should display only output cost when input cost is missing", () => { - const cols = columns( - defaultProps.userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - ); - - const model = createMockModel({ - input_cost: undefined as any, - output_cost: 0.03, - }); - render(); - - expect(screen.getByText("Out: $0.03")).toBeInTheDocument(); - expect(screen.queryByText(/In:/)).not.toBeInTheDocument(); - }); - - describe("pause/resume toggle", () => { - const renderWithToggle = ( - overrides: Partial["model_info"]> = {}, - togglePauseHandler?: ReturnType, - userRole: string = "Admin", - ) => { - const handler = togglePauseHandler ?? vi.fn(); - const cols = columns( - userRole, - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - vi.fn(), - handler, - ); - const model = createMockModel({ - model_info: { ...createMockModel().model_info, ...overrides }, - }); - render(); - return { handler }; - }; - - it("renders the toggle ON for a db_model that is not blocked", () => { - renderWithToggle({ db_model: true, blocked: false }); - const toggle = screen.getByRole("switch", { name: /pause model/i }); - expect(toggle).toBeEnabled(); - expect(toggle).toHaveAttribute("aria-checked", "true"); - }); - - it("renders the toggle OFF for a db_model that is blocked", () => { - renderWithToggle({ db_model: true, blocked: true }); - const toggle = screen.getByRole("switch", { name: /resume model/i }); - expect(toggle).toBeEnabled(); - expect(toggle).toHaveAttribute("aria-checked", "false"); - }); - - it("calls the handler with blocked=true when an admin flips an active toggle off", async () => { - const handler = vi.fn(); - renderWithToggle({ db_model: true, blocked: false }, handler); - await userEvent.click(screen.getByRole("switch", { name: /pause model/i })); - expect(handler).toHaveBeenCalledWith("test-model-id", true); - }); - - it("calls the handler with blocked=false when an admin flips a paused toggle on", async () => { - const handler = vi.fn(); - renderWithToggle({ db_model: true, blocked: true }, handler); - await userEvent.click(screen.getByRole("switch", { name: /resume model/i })); - expect(handler).toHaveBeenCalledWith("test-model-id", false); - }); - - it("disables the toggle for non-admin users", () => { - const handler = vi.fn(); - renderWithToggle({ db_model: true, blocked: false }, handler, "User"); - const toggle = screen.getByRole("switch", { name: /pause model/i }); - expect(toggle).toBeDisabled(); - }); - - it("disables the toggle for config models", () => { - const handler = vi.fn(); - renderWithToggle({ db_model: false, blocked: false }, handler, "Admin"); - const toggle = screen.getByRole("switch", { name: /pause model/i }); - expect(toggle).toBeDisabled(); - }); - - it("disables the toggle while a PATCH for the same row is in-flight", () => { - // Regression for Greptile P1 on PR #28151 — antd's `loading` prop is - // visual only and does not prevent click events, so the row needs to - // be explicitly disabled while its PATCH is pending to avoid - // racing/conflicting PATCH calls on double-click. - const handler = vi.fn(); - const model = createMockModel({ - model_info: { - ...createMockModel().model_info, - db_model: true, - blocked: false, - }, - }); - const cols = columns( - "Admin", - defaultProps.userID, - defaultProps.premiumUser, - defaultProps.setSelectedModelId, - defaultProps.setSelectedTeamId, - defaultProps.getDisplayModelName, - defaultProps.handleEditClick, - defaultProps.handleRefreshClick, - defaultProps.expandedRows, - defaultProps.setExpandedRows, - vi.fn(), - handler, - model.model_info.id, // pausingModelId matches this row - ); - render(); - const toggle = screen.getByRole("switch", { name: /pause model/i }); - expect(toggle).toBeDisabled(); - }); - }); -}); diff --git a/ui/litellm-dashboard/src/components/molecules/models/columns.tsx b/ui/litellm-dashboard/src/components/molecules/models/columns.tsx deleted file mode 100644 index d043d0820f8..00000000000 --- a/ui/litellm-dashboard/src/components/molecules/models/columns.tsx +++ /dev/null @@ -1,423 +0,0 @@ -import { EditOutlined, InfoCircleOutlined, SyncOutlined } from "@ant-design/icons"; -import { TrashIcon } from "@heroicons/react/outline"; -import { ColumnDef } from "@tanstack/react-table"; -import { Badge, Icon } from "@tremor/react"; -import { Divider, Flex, Popover, Space, Switch, Tooltip, Typography } from "antd"; -import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells"; -import { ModelData } from "../../model_dashboard/types"; -import { ProviderLogo } from "./ProviderLogo"; - -const { Text, Title } = Typography; - -const credentialsInfoPopoverContent = ( - - - Credential types - - - - - - - - Reusable - - - Credentials saved in LiteLLM that can be added to models repeatedly. - - - - - - - - - Manual - - - Credentials added directly during model creation or defined in the config file. - - - - -); - -export const columns = ( - userRole: string, - userID: string, - premiumUser: boolean, - setSelectedModelId: (id: string) => void, - setSelectedTeamId: (id: string) => void, - getDisplayModelName: (model: any) => string, - handleEditClick: (model: any) => void, - handleRefreshClick: () => void, - expandedRows: Set, - setExpandedRows: (expandedRows: Set) => void, - onDeleteClick?: (modelId: string) => void, - onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise, - pausingModelId?: string | null, -): ColumnDef[] => [ - { - header: () => Model ID, - accessorKey: "model_info.id", - enableSorting: false, - size: 130, - minSize: 80, - cell: ({ row }) => { - const model = row.original; - return ( -
e.stopPropagation()}> - -
- ); - }, - }, - { - header: () => Model Information, - accessorKey: "model_name", - size: 250, - minSize: 120, - cell: ({ row }) => { - const model = row.original; - const displayName = getDisplayModelName(row.original) || "-"; - const popoverContent = ( - - - - - {model.provider || "Unknown provider"} - - - - - - - Public Model Name - - - {displayName} - - - - - - LiteLLM Model Name - - - {model.litellm_model_name || "-"} - - - - - ); - - return ( - -
-
- {model.provider ? ( - - ) : ( -
-
- )} -
- -
- - {displayName} - - - {model.litellm_model_name || "-"} - -
-
-
- ); - }, - }, - { - header: () => ( - - Credentials - - - - - ), - accessorKey: "litellm_credential_name", - enableSorting: false, - size: 180, - minSize: 100, - cell: ({ row }) => { - const model = row.original; - const credentialName = model.litellm_params?.litellm_credential_name; - const isReusable = !!credentialName; - - return ( -
- {isReusable ? ( - <> - - - {credentialName} - - - ) : ( - <> - - Manual - - )} -
- ); - }, - }, - { - header: () => Created By, - accessorKey: "model_info.created_by", - sortingFn: "datetime", - size: 160, - minSize: 100, - cell: ({ row }) => { - const model = row.original; - const isConfigModel = !model.model_info?.db_model; - const createdBy = model.model_info.created_by; - const createdAt = model.model_info.created_at ? new Date(model.model_info.created_at).toLocaleDateString() : null; - - return ( -
- {/* Created By - Primary */} -
- {isConfigModel ? "Defined in config" : createdBy || "Unknown"} -
- {/* Created At - Secondary */} -
- {isConfigModel ? "-" : createdAt || "Unknown date"} -
-
- ); - }, - }, - { - header: () => Updated At, - accessorKey: "model_info.updated_at", - sortingFn: "datetime", - size: 120, - minSize: 80, - cell: ({ row }) => { - const model = row.original; - return ; - }, - }, - { - header: () => Costs, - accessorKey: "input_cost", - size: 120, - minSize: 80, - cell: ({ row }) => { - const model = row.original; - const inputCost = model.input_cost; - const outputCost = model.output_cost; - - // If both costs are missing or undefined, show "-" - if (inputCost == null && outputCost == null) { - return ( -
- - -
- ); - } - - return ( - -
- {/* Input Cost - Primary */} - {inputCost != null &&
In: ${inputCost}
} - {/* Output Cost - Secondary */} - {outputCost != null &&
Out: ${outputCost}
} -
-
- ); - }, - }, - { - header: () => Team ID, - accessorKey: "model_info.team_id", - enableSorting: false, - size: 130, - minSize: 80, - cell: ({ row }) => { - const model = row.original; - return model.model_info.team_id ? ( -
e.stopPropagation()}> - -
- ) : ( - "-" - ); - }, - }, - { - header: () => Model Access Group, - accessorKey: "model_info.model_access_group", - enableSorting: false, - size: 180, - minSize: 100, - cell: ({ row }) => { - const model = row.original; - const accessGroups = model.model_info.access_groups; - - if (!accessGroups || accessGroups.length === 0) { - return "-"; - } - - const modelId = model.model_info.id; - const isExpanded = expandedRows.has(modelId); - const shouldShowExpandButton = accessGroups.length > 1; - - const toggleExpanded = () => { - const newExpanded = new Set(expandedRows); - if (isExpanded) { - newExpanded.delete(modelId); - } else { - newExpanded.add(modelId); - } - setExpandedRows(newExpanded); - }; - - return ( -
- - {accessGroups[0]} - - - {(isExpanded || (!shouldShowExpandButton && accessGroups.length === 2)) && - accessGroups.slice(1).map((group: string, index: number) => ( - - {group} - - ))} - - {shouldShowExpandButton && ( - - )} -
- ); - }, - }, - { - header: () => Status, - accessorKey: "model_info.db_model", - size: 120, - minSize: 80, - cell: ({ row }) => { - const model = row.original; - return model.model_info.db_model ? ( - - ) : ( - - ); - }, - }, - { - id: "actions", - header: () => Actions, - size: 100, - minSize: 80, - enableResizing: false, - cell: ({ row }) => { - const model = row.original; - const canEditModel = userRole === "Admin" || model.model_info?.created_by === userID; - const isConfigModel = !model.model_info?.db_model; - const isAdmin = userRole === "Admin"; - const isBlocked = model.model_info?.blocked === true; - const isPauseToggleable = !isConfigModel && isAdmin && Boolean(onTogglePauseClick); - const pauseTooltip = isConfigModel - ? "Config models cannot be paused from the dashboard. Pause is DB-backed." - : !isAdmin - ? "Only proxy admins can pause or resume a model." - : isBlocked - ? "Resume model — restore normal routing." - : "Pause model — stop routing requests until resumed."; - // antd's `loading` prop on Switch is purely cosmetic — it does not block - // clicks. Pair `loading` with `disabled` derived from the same condition - // so a double-click during a pending PATCH cannot send a second, - // conflicting `blocked` value. - const isPausing = pausingModelId === model.model_info?.id; - return ( -
- - { - e.stopPropagation(); - }} - onChange={(nextChecked) => { - const modelId = model.model_info?.id; - if (isPauseToggleable && onTogglePauseClick && modelId) { - void onTogglePauseClick(modelId, !nextChecked); - } - }} - /> - - {isConfigModel ? ( - - - - ) : ( - - { - e.stopPropagation(); - if (canEditModel && onDeleteClick) { - onDeleteClick(model.model_info.id); - } - }} - className={!canEditModel ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:text-red-600"} - /> - - )} -
- ); - }, - }, -]; diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx index 8aaec3f13a0..ea986a84cc3 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx @@ -20,6 +20,8 @@ interface DataTableFilterDrawerProps { description?: React.ReactNode; applyLabel?: string; resetLabel?: string; + /** Runs instead of the default "clear this table's column filters" when the reset button is pressed. */ + onReset?: () => void; children: (draft: FilterDraft) => React.ReactNode; } @@ -48,6 +50,7 @@ export function DataTableFilterDrawer({ description, applyLabel = "Apply Filters", resetLabel = "Reset", + onReset, children, }: DataTableFilterDrawerProps) { const [draft, setDraft] = React.useState>(() => toDraft(table.getState().columnFilters)); @@ -72,6 +75,10 @@ export function DataTableFilterDrawer({ const reset = () => { setDraft({}); + if (onReset !== undefined) { + onReset(); + return; + } table.setColumnFilters([]); }; diff --git a/ui/litellm-dashboard/src/components/ui/hover-card.tsx b/ui/litellm-dashboard/src/components/ui/hover-card.tsx new file mode 100644 index 00000000000..586a77b7cb6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/hover-card.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"; + +import { cn } from "@/lib/cva.config"; + +function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) { + return ; +} + +function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) { + return ; +} + +function HoverCardContent({ + className, + side = "bottom", + sideOffset = 4, + align = "center", + alignOffset = 4, + ...props +}: PreviewCardPrimitive.Popup.Props & + Pick) { + return ( + + + + + + ); +} + +export { HoverCard, HoverCardTrigger, HoverCardContent };