feat(ui): add pause/resume Switch to the models table (#28151)

Squash-merged by litellm-agent from Cyberfilo's PR.
This commit is contained in:
Filippo Menghi 2026-05-18 12:41:03 +02:00 committed by Sameer Kankute
parent 62f6c06042
commit 56f430ebcc
No known key found for this signature in database
4 changed files with 164 additions and 4 deletions

View file

@ -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<string | null>(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 (
<TabPanel>
<Grid>
@ -536,6 +555,8 @@ const AllModelsTab = ({
expandedRows,
setExpandedRows,
setDeleteModalModelId,
handleTogglePause,
pausingModelId,
)}
data={filteredData}
isLoading={isLoadingModelsInfo}

View file

@ -6,6 +6,7 @@ export interface ModelInfo {
team_id: string;
db_model: boolean;
access_groups: string[] | null;
blocked?: boolean;
}
export interface LiteLLMParams {

View file

@ -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<ReturnType<typeof createMockModel>["model_info"]> = {},
togglePauseHandler?: ReturnType<typeof vi.fn>,
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(<TestTable data={[model]} columns={cols} />);
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(<TestTable data={[model]} columns={cols} />);
const toggle = screen.getByRole("switch", { name: /pause model/i });
expect(toggle).toBeDisabled();
});
});
});

View file

@ -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<string>,
setExpandedRows: (expandedRows: Set<string>) => void,
onDeleteClick?: (modelId: string) => void,
onTogglePauseClick?: (modelId: string, blocked: boolean) => void | Promise<void>,
pausingModelId?: string | null,
): ColumnDef<ModelData>[] => [
{
header: () => <span className="text-sm font-semibold">Model ID</span>,
@ -398,15 +400,47 @@ export const columns = (
{
id: "actions",
header: () => <span className="text-sm font-semibold">Actions</span>,
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 (
<div className="flex items-center justify-end gap-2 pr-4">
<Tooltip title={pauseTooltip}>
<Switch
size="small"
checked={!isBlocked}
disabled={!isPauseToggleable || isPausing}
loading={isPausing}
aria-label={isBlocked ? "Resume model" : "Pause model"}
onClick={(_, e) => {
e.stopPropagation();
}}
onChange={(nextChecked) => {
if (isPauseToggleable && onTogglePauseClick) {
void onTogglePauseClick(model.model_info.id, !nextChecked);
}
}}
/>
</Tooltip>
{isConfigModel ? (
<Tooltip title="Config model cannot be deleted on the dashboard. Please delete it from the config file.">
<Icon icon={TrashIcon} size="sm" className="opacity-50 cursor-not-allowed" />