mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-06 22:22:23 +00:00
Merge pull request #26826 from BerriAI/litellm_health_status_pagination
Add pagination controls to model health status
This commit is contained in:
@@ -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");
|
||||
|
||||
+34
-7
@@ -46,6 +46,8 @@ interface GlobalRetryPolicyObject {
|
||||
[retryPolicyKey: string]: number;
|
||||
}
|
||||
|
||||
const HEALTH_PAGE_SIZE = 50;
|
||||
|
||||
const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, teams }) => {
|
||||
const { accessToken, token, userRole, userId: userID } = useAuthorized();
|
||||
const [addModelForm] = Form.useForm();
|
||||
@@ -62,6 +64,7 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(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<ModelDashboardProps> = ({ 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<ModelDashboardProps> = ({ premiumUser, te
|
||||
return modelDataResponse.data.map((model: any) => model.model_name);
|
||||
}, [modelDataResponse?.data]);
|
||||
|
||||
const allModelIdsOnProxy = useMemo<string[]>(() => {
|
||||
if (!modelDataResponse?.data) return [];
|
||||
return modelDataResponse.data
|
||||
const healthModelIdsOnProxy = useMemo<string[]>(() => {
|
||||
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<ModelDashboardProps> = ({ 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<ModelDashboardProps> = ({ 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<ModelDashboardProps> = ({ premiumUser, te
|
||||
<TabPanel>
|
||||
<HealthCheckComponent
|
||||
accessToken={accessToken}
|
||||
modelData={processedModelData}
|
||||
all_models_on_proxy={allModelIdsOnProxy}
|
||||
modelData={processedHealthModelData}
|
||||
all_models_on_proxy={healthModelIdsOnProxy}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
setSelectedModelId={setSelectedModelId}
|
||||
teams={teams}
|
||||
isLoading={isLoadingHealthModels}
|
||||
paginationMeta={healthPaginationMeta}
|
||||
currentPage={healthCurrentPage}
|
||||
pageSize={HEALTH_PAGE_SIZE}
|
||||
onPageChange={setHealthCurrentPage}
|
||||
/>
|
||||
</TabPanel>
|
||||
<ModelRetrySettingsTab
|
||||
|
||||
@@ -92,18 +92,62 @@ describe("HealthCheckComponent", () => {
|
||||
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(
|
||||
<HealthCheckComponent
|
||||
accessToken="token"
|
||||
modelData={modelData}
|
||||
all_models_on_proxy={["deployment-1"]}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
paginationMeta={{
|
||||
total_count: 75,
|
||||
current_page: 1,
|
||||
total_pages: 2,
|
||||
size: 50,
|
||||
}}
|
||||
currentPage={1}
|
||||
pageSize={50}
|
||||
onPageChange={onPageChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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<HealthCheckComponentProps> = ({
|
||||
@@ -35,6 +45,11 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
getDisplayModelName,
|
||||
setSelectedModelId,
|
||||
teams,
|
||||
isLoading = false,
|
||||
paginationMeta,
|
||||
currentPage = 1,
|
||||
pageSize = 50,
|
||||
onPageChange,
|
||||
}) => {
|
||||
const [modelHealthStatuses, setModelHealthStatuses] = useState<{ [key: string]: HealthStatus }>({});
|
||||
const [selectedModelsForHealth, setSelectedModelsForHealth] = useState<string[]>([]);
|
||||
@@ -95,19 +110,19 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
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<HealthCheckComponentProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
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<HealthCheckComponentProps> = ({
|
||||
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 (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
@@ -522,6 +574,38 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{shouldShowPagination && (
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span data-testid="health-results-count" className="text-sm text-gray-700">
|
||||
{totalCount > 0
|
||||
? `Showing ${resultsStart} - ${resultsEnd} of ${totalCount} results`
|
||||
: "Showing 0 results"}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={isLoading || currentPage === 1}
|
||||
className={`px-3 py-1 text-sm border rounded-md ${
|
||||
isLoading || currentPage === 1 ? "bg-gray-100 text-gray-400 cursor-not-allowed" : "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={isLoading || currentPage >= totalPages}
|
||||
className={`px-3 py-1 text-sm border rounded-md ${
|
||||
isLoading || currentPage >= totalPages
|
||||
? "bg-gray-100 text-gray-400 cursor-not-allowed"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ModelDataTable
|
||||
columns={healthCheckColumns(
|
||||
modelHealthStatuses,
|
||||
@@ -537,28 +621,8 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user