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 5431c196883..2626ace86d5 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 @@ -7,7 +7,7 @@ import { columns } from "@/components/molecules/models/columns"; import { getDisplayModelName } from "@/components/view_model/model_name_display"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import NotificationsManager from "@/components/molecules/notifications_manager"; -import { modelDeleteCall } from "@/components/networking"; +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"; @@ -220,6 +220,25 @@ const AllModelsTab = ({ } }; + const [pausingModelId, setPausingModelId] = useState(null); + + 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); + } + }; + return ( @@ -536,6 +555,8 @@ const AllModelsTab = ({ expandedRows, setExpandedRows, setDeleteModalModelId, + handleTogglePause, + pausingModelId, )} data={filteredData} isLoading={isLoadingModelsInfo} diff --git a/ui/litellm-dashboard/src/components/model_dashboard/types.ts b/ui/litellm-dashboard/src/components/model_dashboard/types.ts index b1447a0634b..77a03d2c039 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/types.ts +++ b/ui/litellm-dashboard/src/components/model_dashboard/types.ts @@ -6,6 +6,7 @@ export interface ModelInfo { team_id: string; db_model: boolean; access_groups: string[] | null; + blocked?: boolean; } 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 index a3dbbb2783f..c08dca1b8ce 100644 --- a/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/models/columns.test.tsx @@ -944,4 +944,108 @@ describe("columns", () => { 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 index a303e1b1a44..edf9f7e0867 100644 --- a/ui/litellm-dashboard/src/components/molecules/models/columns.tsx +++ b/ui/litellm-dashboard/src/components/molecules/models/columns.tsx @@ -2,7 +2,7 @@ import { EditOutlined, InfoCircleOutlined, SyncOutlined } from "@ant-design/icon import { TrashIcon } from "@heroicons/react/outline"; import { ColumnDef } from "@tanstack/react-table"; import { Badge, Button, Icon } from "@tremor/react"; -import { Divider, Flex, Popover, Space, Tooltip, Typography } from "antd"; +import { Divider, Flex, Popover, Space, Switch, Tooltip, Typography } from "antd"; import { ModelData } from "../../model_dashboard/types"; import { ProviderLogo } from "./ProviderLogo"; @@ -53,6 +53,8 @@ export const columns = ( expandedRows: Set, setExpandedRows: (expandedRows: Set) => void, onDeleteClick?: (modelId: string) => void, + onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise, + pausingModelId?: string | null, ): ColumnDef[] => [ { header: () => Model ID, @@ -398,15 +400,47 @@ export const columns = ( { id: "actions", header: () => Actions, - size: 60, - minSize: 40, + 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) => { + if (isPauseToggleable && onTogglePauseClick) { + void onTogglePauseClick(model.model_info.id, !nextChecked); + } + }} + /> + {isConfigModel ? (