diff --git a/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts index 8834724f76..c3bd848902 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts @@ -141,7 +141,9 @@ test.describe("Add Model", () => { await page.waitForTimeout(1000); // Verify the model appears in the results count (not "Showing 0 results") - await expect(page.getByText(/Showing \d+ - \d+ of \d+ results/)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("models-results-count")).toHaveText(/Showing \d+ - \d+ of \d+ results/, { + timeout: 15_000, + }); // Verify the model name appears in the table body const tableBody = page.locator("table tbody"); @@ -181,7 +183,9 @@ test.describe("Add Model", () => { await page.waitForTimeout(1000); // Verify the model appears in the results count (not "Showing 0 results") - await expect(page.getByText(/Showing \d+ - \d+ of \d+ results/)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("models-results-count")).toHaveText(/Showing \d+ - \d+ of \d+ results/, { + timeout: 15_000, + }); // Verify the wildcard model appears in the table body (wildcard models show as "cohere/*") const tableBody = page.locator("table tbody"); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index 514ae673d0..7c162e2056 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -46,6 +46,8 @@ interface GlobalRetryPolicyObject { [retryPolicyKey: string]: number; } +const HEALTH_PAGE_SIZE = 50; + const ModelsAndEndpointsView: React.FC = ({ premiumUser, teams }) => { const { accessToken, token, userRole, userId: userID } = useAuthorized(); const [addModelForm] = Form.useForm(); @@ -62,6 +64,7 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te const [selectedModelId, setSelectedModelId] = useState(null); const [selectedTeamId, setSelectedTeamId] = useState(null); const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const [healthCurrentPage, setHealthCurrentPage] = useState(1); const [showMissingProviderBanner, setShowMissingProviderBanner] = useState(() => { if (typeof window !== "undefined") { return localStorage.getItem("hideMissingProviderBanner") !== "true"; @@ -71,6 +74,10 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te const queryClient = useQueryClient(); const { data: modelDataResponse, isLoading: isLoadingModels, refetch: refetchModels } = useModelsInfo(); + const { data: healthModelDataResponse, isLoading: isLoadingHealthModels } = useModelsInfo( + healthCurrentPage, + HEALTH_PAGE_SIZE, + ); const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap(); const { data: credentialsResponse, isLoading: isLoadingCredentials } = useCredentials(); const credentialsList = credentialsResponse?.credentials || []; @@ -104,12 +111,12 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te return modelDataResponse.data.map((model: any) => model.model_name); }, [modelDataResponse?.data]); - const allModelIdsOnProxy = useMemo(() => { - if (!modelDataResponse?.data) return []; - return modelDataResponse.data + const healthModelIdsOnProxy = useMemo(() => { + if (!healthModelDataResponse?.data) return []; + return healthModelDataResponse.data .map((model: any) => model.model_info?.id) .filter((id: string | undefined): id is string => Boolean(id)); - }, [modelDataResponse?.data]); + }, [healthModelDataResponse?.data]); const getProviderFromModel = (model: string) => { if (modelCostMapData !== null && modelCostMapData !== undefined) { @@ -125,6 +132,20 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te return transformModelData(modelDataResponse, getProviderFromModel); }, [modelDataResponse?.data, getProviderFromModel]); + const processedHealthModelData = useMemo(() => { + if (!healthModelDataResponse?.data) return { data: [] }; + return transformModelData(healthModelDataResponse, getProviderFromModel); + }, [healthModelDataResponse?.data, getProviderFromModel]); + + const healthPaginationMeta = useMemo(() => { + return { + total_count: healthModelDataResponse?.total_count ?? 0, + current_page: healthModelDataResponse?.current_page ?? healthCurrentPage, + total_pages: healthModelDataResponse?.total_pages ?? 1, + size: healthModelDataResponse?.size ?? HEALTH_PAGE_SIZE, + }; + }, [healthModelDataResponse, healthCurrentPage]); + const isProxyAdmin = userRole && isProxyAdminRole(userRole); const isInternalUser = userRole && internalUserRoles.includes(userRole); const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams, userID); @@ -166,7 +187,8 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te const handleRefreshClick = () => { const currentDate = new Date(); - setLastRefreshed(currentDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); + setLastRefreshed(currentDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })); + setHealthCurrentPage(1); queryClient.invalidateQueries({ queryKey: ["models", "list"] }); refetchModels(); }; @@ -441,11 +463,16 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te { expect(mockIndividualModelHealthCheckCall).not.toHaveBeenCalledWith("token-123", "gpt-4"); }); + it("should show pagination controls and request the next page", async () => { + const onPageChange = vi.fn(); + const modelData = { + data: [ + { + model_name: "gpt-4", + model_info: { id: "deployment-1" }, + litellm_model_name: "gpt-4", + }, + ], + }; + + await act(async () => { + render( + , + ); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(screen.getByTestId("health-results-count")).toHaveTextContent("Showing 1 - 50 of 75 results"); + + await act(async () => { + screen.getByRole("button", { name: "Next" }).click(); + }); + + expect(onPageChange).toHaveBeenCalledWith(2); + }); + describe("latest_health_checks keyed by model id", () => { it("should show status from latest_health_checks when keys match model ids", async () => { const modelData = { data: [ - { - model_name: "gpt-4", - model_info: { id: "id-alpha" }, - litellm_model_name: "gpt-4", + { + model_name: "gpt-4", + model_info: { id: "id-alpha" }, + litellm_model_name: "gpt-4", }, - { - model_name: "gpt-4", - model_info: { id: "id-beta" }, + { + model_name: "gpt-4", + model_info: { id: "id-beta" }, litellm_model_name: "gpt-4", }, ], diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx index db71733b5b..d9b7fdd149 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx @@ -26,6 +26,16 @@ interface HealthCheckComponentProps { getDisplayModelName: (model: any) => string; setSelectedModelId?: (modelId: string) => void; teams?: Team[] | null; + isLoading?: boolean; + paginationMeta?: { + total_count: number; + current_page: number; + total_pages: number; + size: number; + }; + currentPage?: number; + pageSize?: number; + onPageChange?: (page: number) => void; } const HealthCheckComponent: React.FC = ({ @@ -35,6 +45,11 @@ const HealthCheckComponent: React.FC = ({ getDisplayModelName, setSelectedModelId, teams, + isLoading = false, + paginationMeta, + currentPage = 1, + pageSize = 50, + onPageChange, }) => { const [modelHealthStatuses, setModelHealthStatuses] = useState<{ [key: string]: HealthStatus }>({}); const [selectedModelsForHealth, setSelectedModelsForHealth] = useState([]); @@ -95,19 +110,19 @@ const HealthCheckComponent: React.FC = ({ const fullError = checkData.error_message || undefined; healthStatusMap[modelId] = { - status: checkData.status || "unknown", - lastCheck: checkData.checked_at ? new Date(checkData.checked_at).toLocaleString() : "None", - lastSuccess: - checkData.status === "healthy" - ? checkData.checked_at - ? new Date(checkData.checked_at).toLocaleString() - : "None" - : "None", - loading: false, - error: fullError ? extractMeaningfulError(fullError) : undefined, - fullError: fullError, - successResponse: checkData.status === "healthy" ? checkData : undefined, - }; + status: checkData.status || "unknown", + lastCheck: checkData.checked_at ? new Date(checkData.checked_at).toLocaleString() : "None", + lastSuccess: + checkData.status === "healthy" + ? checkData.checked_at + ? new Date(checkData.checked_at).toLocaleString() + : "None" + : "None", + loading: false, + error: fullError ? extractMeaningfulError(fullError) : undefined, + fullError: fullError, + successResponse: checkData.status === "healthy" ? checkData : undefined, + }; }); } } catch (healthError) { @@ -448,6 +463,13 @@ const HealthCheckComponent: React.FC = ({ } }; + const handlePageChange = (page: number) => { + setSelectedModelsForHealth([]); + setAllModelsSelected(false); + setModelHealthStatuses({}); + onPageChange?.(page); + }; + const getStatusBadge = (status: string) => { switch (status) { case "healthy": @@ -490,6 +512,36 @@ const HealthCheckComponent: React.FC = ({ setSelectedSuccessDetails(null); }; + const healthTableData = (modelData?.data ?? []).map((model: any) => { + const modelId = model.model_info?.id; + const healthStatus = modelId ? modelHealthStatuses[modelId] : null; + const status = healthStatus || { + status: "none", + lastCheck: "None", + loading: false, + }; + return { + model_name: model.model_name, + model_info: model.model_info, + provider: model.provider, + litellm_model_name: model.litellm_model_name, + health_status: status.status, + last_check: status.lastCheck, + last_success: status.lastSuccess || "None", + health_loading: status.loading, + health_error: status.error, + health_full_error: status.fullError, + }; + }); + + const shouldShowPagination = Boolean(paginationMeta && onPageChange); + const totalCount = paginationMeta?.total_count ?? 0; + const totalPages = paginationMeta?.total_pages ?? 1; + const pageForDisplay = paginationMeta?.current_page ?? currentPage; + const pageSizeForDisplay = paginationMeta?.size ?? pageSize; + const resultsStart = shouldShowPagination && totalCount > 0 ? (pageForDisplay - 1) * pageSizeForDisplay + 1 : 0; + const resultsEnd = shouldShowPagination ? Math.min(pageForDisplay * pageSizeForDisplay, totalCount) : 0; + return (
@@ -522,6 +574,38 @@ const HealthCheckComponent: React.FC = ({
+ {shouldShowPagination && ( +
+ + {totalCount > 0 + ? `Showing ${resultsStart} - ${resultsEnd} of ${totalCount} results` + : "Showing 0 results"} + + +
+ + +
+
+ )} = ({ setSelectedModelId, teams, )} - data={modelData.data.map((model: any) => { - const modelId = model.model_info?.id; - const healthStatus = modelId ? modelHealthStatuses[modelId] : null; - const status = healthStatus || { - status: "none", - lastCheck: "None", - loading: false, - }; - return { - model_name: model.model_name, - model_info: model.model_info, - provider: model.provider, - litellm_model_name: model.litellm_model_name, - health_status: status.status, - last_check: status.lastCheck, - last_success: status.lastSuccess || "None", - health_loading: status.loading, - health_error: status.error, - health_full_error: status.fullError, - }; - })} - isLoading={false} + data={healthTableData} + isLoading={isLoading} />