From 49c18d4bf198fa13960ba1228ac9158f6f304249 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 21 Jul 2026 16:52:39 -0700 Subject: [PATCH 1/5] refactor(ui): migrate users and model health checks tables onto the shared DataTable (#34182) Both tables consume the shared DataTable's controlled row-selection API, so they move together. Users runs fully server-side (sorting, pagination, filtering) with the page, sort and filter state lifted to ViewUserDashboard, which now also owns the detail-view swap that used to live inside the table component. Sort controls are restricted to the five keys the backend accepts so a header click can no longer send an invalid sort_by. The hand-rolled checkbox column, select-all and selectedUsers[] are replaced by controlled rowSelection keyed by user id, and the per-row icon strip becomes an overflow menu. Model health checks keep client-side sorting, including the custom status and timestamp orderings, while pagination moves to the shared footer driven by the grandparent's page state. Selection is cleared whenever the page changes, since the rows underneath it are swapped out. ModelDataTable had no consumers left once HealthCheckComponent stopped using it, so it is removed along with dead local state it carried. --- ui/litellm-dashboard/eslint-suppressions.json | 49 -- .../ModelsAndEndpointsView.tsx | 28 +- .../users/_components/view_users.test.tsx | 291 ++++--- .../users/_components/view_users.tsx | 353 +++++---- .../view_users/UsersTable.test.tsx | 272 +++++++ .../_components/view_users/UsersTable.tsx | 216 ++++++ .../view_users/UsersTableColumns.tsx | 272 +++++++ .../users/_components/view_users/columns.tsx | 195 ----- .../_components/view_users/table.test.tsx | 201 ----- .../users/_components/view_users/table.tsx | 442 ----------- .../HealthCheckComponent.test.tsx | 348 ++++----- .../model_dashboard/HealthCheckComponent.tsx | 711 ++++++++---------- .../HealthChecksTable.test.tsx | 175 +++++ .../model_dashboard/HealthChecksTable.tsx | 92 +++ .../HealthChecksTableColumns.tsx | 413 ++++++++++ .../model_dashboard/health_check_columns.tsx | 364 --------- .../src/components/model_dashboard/table.tsx | 208 ----- 17 files changed, 2307 insertions(+), 2323 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.test.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.tsx create mode 100644 ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.test.tsx create mode 100644 ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.tsx create mode 100644 ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTableColumns.tsx delete mode 100644 ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx delete mode 100644 ui/litellm-dashboard/src/components/model_dashboard/table.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index c8a2883e729..a48276cd727 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1197,9 +1197,6 @@ } }, "src/app/(dashboard)/users/_components/view_users.tsx": { - "no-nested-ternary": { - "count": 1 - }, "no-restricted-imports": { "count": 1 }, @@ -1207,22 +1204,6 @@ "count": 1 } }, - "src/app/(dashboard)/users/_components/view_users/columns.tsx": { - "max-params": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/users/_components/view_users/table.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/users/_components/view_users/user_info_view.tsx": { "no-restricted-imports": { "count": 1 @@ -1859,17 +1840,6 @@ "count": 1 } }, - "src/components/model_dashboard/HealthCheckComponent.tsx": { - "no-nested-ternary": { - "count": 3 - }, - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/immutability": { - "count": 1 - } - }, "src/components/model_dashboard/all_models_table.tsx": { "no-nested-ternary": { "count": 1 @@ -1878,25 +1848,6 @@ "count": 1 } }, - "src/components/model_dashboard/health_check_columns.tsx": { - "max-params": { - "count": 1 - }, - "no-nested-ternary": { - "count": 2 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/model_dashboard/table.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/model_filters.tsx": { "no-restricted-imports": { "count": 1 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 672bcc2aa95..72f4fb8caa3 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 @@ -17,6 +17,7 @@ import { transformModelData } from "./utils/modelDataTransformer"; import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles"; import { RefreshIcon } from "@heroicons/react/outline"; import { useQueryClient } from "@tanstack/react-query"; +import type { PaginationState } from "@tanstack/react-table"; import { Col, Grid, Icon, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; import type { UploadProps } from "antd"; import { Form } from "antd"; @@ -69,13 +70,16 @@ 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 [healthPagination, setHealthPagination] = useState({ + pageIndex: 0, + pageSize: HEALTH_PAGE_SIZE, + }); const queryClient = useQueryClient(); const { data: modelDataResponse, isLoading: isLoadingModels, refetch: refetchModels } = useModelsInfo(); const { data: healthModelDataResponse, isLoading: isLoadingHealthModels } = useModelsInfo( - healthCurrentPage, - HEALTH_PAGE_SIZE, + healthPagination.pageIndex + 1, + healthPagination.pageSize, ); const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap(); const { data: credentialsResponse, isLoading: isLoadingCredentials } = useCredentials(); @@ -137,14 +141,7 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te 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 healthRowCount = healthModelDataResponse?.total_count ?? 0; const isProxyAdmin = userRole && isProxyAdminRole(userRole); const isInternalUser = userRole && internalUserRoles.includes(userRole); @@ -188,7 +185,7 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te const handleRefreshClick = () => { const currentDate = new Date(); setLastRefreshed(currentDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })); - setHealthCurrentPage(1); + setHealthPagination((previous) => ({ ...previous, pageIndex: 0 })); queryClient.invalidateQueries({ queryKey: ["models", "list"] }); refetchModels(); }; @@ -413,10 +410,9 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te setSelectedModelId={setSelectedModelId} teams={teams} isLoading={isLoadingHealthModels} - paginationMeta={healthPaginationMeta} - currentPage={healthCurrentPage} - pageSize={HEALTH_PAGE_SIZE} - onPageChange={setHealthCurrentPage} + pagination={healthPagination} + onPaginationChange={setHealthPagination} + rowCount={healthRowCount} /> ), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx index 996fd58efc1..8dc11babd72 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx @@ -1,31 +1,17 @@ -import React from "react"; -import { render, waitFor, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; +/* @vitest-environment jsdom */ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + import ViewUserDashboard from "./view_users"; +const userListCall = vi.fn(); + // Mock the networking module vi.mock("@/components/networking", () => ({ - userListCall: vi.fn().mockResolvedValue({ - users: [ - { - user_id: "user-1", - user_email: "test@example.com", - user_role: "Admin", - spend: 100.5, - max_budget: null, - key_count: 2, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - sso_user_id: null, - budget_duration: null, - }, - ], - total: 1, - page: 1, - page_size: 25, - total_pages: 1, - }), + userListCall: (...args: unknown[]) => userListCall(...args), userDeleteCall: vi.fn().mockResolvedValue({}), getPossibleUserRoles: vi.fn().mockResolvedValue({ Admin: { ui_label: "Admin" }, @@ -44,6 +30,13 @@ vi.mock("@/components/networking", () => ({ getInternalUserSettings: vi.fn().mockResolvedValue({}), })); +// The detail view has its own test; stub it so this file covers the parent's swap. +vi.mock("./view_users/user_info_view", () => ({ + default: function UserInfoViewMock({ userId, startInEditMode }: { userId: string; startInEditMode?: boolean }) { + return
{`detail:${userId}:${String(Boolean(startInEditMode))}`}
; + }, +})); + // Mock NotificationsManager vi.mock("@/components/molecules/notifications_manager", () => ({ default: { @@ -52,6 +45,21 @@ vi.mock("@/components/molecules/notifications_manager", () => ({ }, })); +const makeUser = (userId: string, email: string) => ({ + user_id: userId, + user_email: email, + user_alias: null, + user_role: "Admin", + spend: 100.5, + max_budget: null, + models: [], + key_count: 2, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + sso_user_id: null, + budget_duration: null, +}); + const createQueryClient = () => new QueryClient({ defaultOptions: { @@ -62,105 +70,194 @@ const createQueryClient = () => }, }); -describe("ViewUserDashboard", () => { - const defaultProps = { - accessToken: "test-token", - token: "test-token", - userRole: "Admin", - userID: "admin-user-id", - teams: [], - }; +const defaultProps = { + accessToken: "test-token", + token: "test-token", + userRole: "Admin", + userID: "admin-user-id", + teams: [], +}; +const renderDashboard = () => + render( + + + , + ); + +describe("ViewUserDashboard", () => { beforeEach(() => { vi.clearAllMocks(); + userListCall.mockResolvedValue({ + users: [makeUser("user-1", "test@example.com")], + total: 1, + page: 1, + page_size: 25, + total_pages: 1, + }); }); it("should render the ViewUserDashboard component", async () => { - const queryClient = createQueryClient(); - render( - - - , - ); + renderDashboard(); - // Wait for the component to load (it shows "Loading..." initially) await waitFor(() => { expect(screen.getByText("Users")).toBeInTheDocument(); }); - // Check if main elements are rendered - expect(screen.getByText("Users")).toBeInTheDocument(); - // Use getAllByText since "Default User Settings" appears multiple times - const defaultUserSettingsTabs = screen.getAllByText("Default User Settings"); - expect(defaultUserSettingsTabs.length).toBeGreaterThan(0); + expect(screen.getAllByText("Default User Settings").length).toBeGreaterThan(0); }); - it("should show delete modal after clicking delete user button", async () => { - const queryClient = createQueryClient(); - render( - - - , - ); + it("should show delete modal after choosing delete from the row actions menu", async () => { + const user = userEvent.setup(); + renderDashboard(); - // Wait for the component to load and the table to render await waitFor(() => { - expect(screen.getByText("Users")).toBeInTheDocument(); + expect(screen.getByText("test@example.com")).toBeInTheDocument(); }); - // Wait for the user data to load - await waitFor(() => { - expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0); - }); - - // Initially, the delete modal should not be visible expect(screen.queryByText("Delete User?")).not.toBeInTheDocument(); - // Find the row containing the user email (use the first one which is in the table) - // The email appears in both the table and potentially in modals, so get the first one from the table - const userEmailCells = screen.getAllByText("test@example.com"); - const userEmailCell = userEmailCells[0]; // First occurrence is in the table - const userRow = userEmailCell.closest("tr"); - expect(userRow).toBeInTheDocument(); - - // Find clickable elements in the actions column (the last column) - const actionCells = userRow?.querySelectorAll("td"); - const actionsCell = actionCells?.[actionCells.length - 1]; - expect(actionsCell).toBeInTheDocument(); - - // Find the action container div with flex gap-2 - const actionContainer = - actionsCell?.querySelector("div.flex.gap-2") || - Array.from(actionsCell?.querySelectorAll("div") || []).find( - (div) => div.className.includes("flex") && div.className.includes("gap"), - ); - - expect(actionContainer).toBeInTheDocument(); - - // Get all direct children of the action container - // These should be Tooltip components wrapping Icon components - const tooltipWrappers = Array.from(actionContainer!.children); - expect(tooltipWrappers.length).toBeGreaterThanOrEqual(2); - - // The delete icon is the second tooltip wrapper (index 1) - // Edit=0, Delete=1, Reset=2 - const deleteTooltipWrapper = tooltipWrappers[1] as HTMLElement; - const clickableElement = deleteTooltipWrapper.querySelector("button, [role='button'], svg") as HTMLElement; - - expect(clickableElement).toBeInTheDocument(); - - fireEvent.click(clickableElement); + await user.click(screen.getByTestId("user-actions-user-1")); + await user.click(await screen.findByTestId("user-action-delete")); await waitFor(() => { expect(screen.getByText("Delete User?")).toBeInTheDocument(); }); - expect( screen.getByText("Are you sure you want to delete this user? This action cannot be undone."), ).toBeInTheDocument(); - const userIdInstances = screen.getAllByText("user-1"); - expect(userIdInstances.length).toBeGreaterThan(0); - const emailInstances = screen.getAllByText("test@example.com"); - expect(emailInstances.length).toBeGreaterThan(0); + expect(screen.getAllByText("user-1").length).toBeGreaterThan(0); + }); + + it("should swap to the detail view when the identity cell is clicked", async () => { + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /user-1/ })); + + expect(await screen.findByTestId("user-info-view")).toHaveTextContent("detail:user-1:false"); + expect(screen.queryByText("test@example.com")).not.toBeInTheDocument(); + }); + + it("should open the detail view in edit mode from the row actions menu", async () => { + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("user-actions-user-1")); + await user.click(await screen.findByTestId("user-action-edit")); + + expect(await screen.findByTestId("user-info-view")).toHaveTextContent("detail:user-1:true"); + }); + + describe("bulk edit selection", () => { + beforeEach(() => { + userListCall.mockResolvedValue({ + users: [makeUser("user-1", "ada@example.com"), makeUser("user-2", "grace@example.com")], + total: 2, + page: 1, + page_size: 25, + total_pages: 1, + }); + }); + + it("reveals selection checkboxes only while selection mode is on", async () => { + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("ada@example.com")).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("datatable-select-all")).not.toBeInTheDocument(); + + await user.click(screen.getByTestId("toggle-user-selection")); + expect(screen.getByTestId("datatable-select-all")).toBeInTheDocument(); + + await user.click(screen.getByTestId("toggle-user-selection")); + expect(screen.queryByTestId("datatable-select-all")).not.toBeInTheDocument(); + }); + + it("counts the selected rows in the bulk edit button and enables it once a row is picked", async () => { + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("ada@example.com")).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("toggle-user-selection")); + + const bulkEdit = screen.getByTestId("bulk-edit-users"); + expect(bulkEdit).toHaveTextContent("Bulk Edit (0 selected)"); + expect(bulkEdit).toBeDisabled(); + + await user.click(screen.getByTestId("datatable-select-row-user-2")); + expect(screen.getByTestId("bulk-edit-users")).toHaveTextContent("Bulk Edit (1 selected)"); + expect(screen.getByTestId("bulk-edit-users")).not.toBeDisabled(); + + await user.click(screen.getByTestId("datatable-select-all")); + expect(screen.getByTestId("bulk-edit-users")).toHaveTextContent("Bulk Edit (2 selected)"); + }); + + it("clears the selection when selection mode is cancelled", async () => { + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("ada@example.com")).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("toggle-user-selection")); + await user.click(screen.getByTestId("datatable-select-row-user-1")); + expect(screen.getByTestId("bulk-edit-users")).toHaveTextContent("Bulk Edit (1 selected)"); + + await user.click(screen.getByTestId("toggle-user-selection")); + await user.click(screen.getByTestId("toggle-user-selection")); + + expect(screen.getByTestId("bulk-edit-users")).toHaveTextContent("Bulk Edit (0 selected)"); + }); + }); + + describe("server-side query wiring", () => { + it("requests page 1 with the default created_at desc sort", async () => { + renderDashboard(); + + await waitFor(() => { + expect(userListCall).toHaveBeenCalled(); + }); + + const [, userIds, page, pageSize, , , , , sortBy, sortOrder] = userListCall.mock.calls[0]; + expect(userIds).toBeNull(); + expect(page).toBe(1); + expect(pageSize).toBe(25); + expect(sortBy).toBe("created_at"); + expect(sortOrder).toBe("desc"); + }); + + it("sends the clicked column as sort_by and resets to the first page", async () => { + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("sort-header-user_email")); + + await waitFor(() => { + const latest = userListCall.mock.calls[userListCall.mock.calls.length - 1]; + expect(latest[8]).toBe("user_email"); + expect(latest[9]).toBe("asc"); + expect(latest[2]).toBe(1); + }); + }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx index db3b17d6af3..ce912c09373 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx @@ -1,5 +1,5 @@ import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "antd"; import BulkEditUserModal from "./BulkEditUsers"; @@ -18,20 +18,24 @@ import OnboardingModal, { InvitationLink } from "@/components/onboarding_link"; import { updateExistingKeys } from "@/utils/dataUtils"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; -import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Typography } from "antd"; +import { + ColumnFiltersState, + OnChangeFn, + PaginationState, + RowSelectionState, + SortingState, +} from "@tanstack/react-table"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { modelAvailableCall, userDeleteCall } from "@/components/networking"; import DefaultUserSettings from "./DefaultUserSettings"; -import { columns } from "./view_users/columns"; -import { UserDataTable } from "./view_users/table"; +import { UsersTable } from "./view_users/UsersTable"; +import UserInfoView from "./view_users/user_info_view"; import { UserInfo } from "@/components/networking"; import { Skeleton } from "antd"; -const { Text, Title } = Typography; - interface ViewUserDashboardProps { accessToken: string | null; token: string | null; @@ -41,33 +45,11 @@ interface ViewUserDashboardProps { orgAdminOrgIds?: Array<{ organization_id: string; organization_alias: string }> | null; } -interface FilterState { - email: string; - user_id: string; - user_role: string; - sso_user_id: string; - team: string; - model: string; - min_spend: number | null; - max_spend: number | null; - sort_by: string; - sort_order: "asc" | "desc"; -} - const DEFAULT_PAGE_SIZE = 25; -const initialFilters: FilterState = { - email: "", - user_id: "", - user_role: "", - sso_user_id: "", - team: "", - model: "", - min_spend: null, - max_spend: null, - sort_by: "created_at", - sort_order: "desc", -}; +const DEFAULT_SORT_BY = "created_at"; + +const DEFAULT_SORTING: SortingState = [{ id: DEFAULT_SORT_BY, desc: true }]; const ViewUserDashboard: React.FC = ({ accessToken, @@ -79,34 +61,30 @@ const ViewUserDashboard: React.FC = ({ }) => { const isProxyAdmin = userRole ? isProxyAdminRole(userRole) : false; const queryClient = useQueryClient(); - const [currentPage, setCurrentPage] = useState(1); + + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE }); + const [sorting, setSorting] = useState(DEFAULT_SORTING); + const [columnFilters, setColumnFilters] = useState([]); + const [searchInput, setSearchInput] = useState(""); + const [searchEmail] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS }); + + const [rowSelection, setRowSelection] = useState({}); + const [selectionMode, setSelectionMode] = useState(false); + const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false); + + const [selectedUserId, setSelectedUserId] = useState(null); + const [openInEditMode, setOpenInEditMode] = useState(false); + const [editModalVisible, setEditModalVisible] = useState(false); const [selectedUser, setSelectedUser] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeletingUser, setIsDeletingUser] = useState(false); const [userToDelete, setUserToDelete] = useState(null); - const [activeTab, setActiveTab] = useState("users"); - const [filters, setFilters] = useState(initialFilters); - const [debouncedFilters, setDebouncedFilters, debouncer] = useDebouncedState(filters, { wait: DEBOUNCE_WAIT_MS }); const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false); const [invitationLinkData, setInvitationLinkData] = useState(null); const [baseUrl, setBaseUrl] = useState(null); - const [selectedUsers, setSelectedUsers] = useState([]); - const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false); - const [selectionMode, setSelectionMode] = useState(false); const [userModels, setUserModels] = useState([]); - const handleDelete = (user: UserInfo) => { - setUserToDelete(user); - setIsDeleteModalOpen(true); - }; - - useEffect(() => { - return () => { - debouncer.cancel(); - }; - }, [debouncer]); - useEffect(() => { setBaseUrl(getProxyBaseUrl()); }, []); @@ -130,32 +108,69 @@ const ViewUserDashboard: React.FC = ({ fetchUserModels(); }, [accessToken, userID, userRole]); - const updateFilters = (update: Partial) => { - setFilters((previousFilters) => { - const newFilters = { ...previousFilters, ...update }; - setDebouncedFilters(newFilters); - return newFilters; - }); - }; + const getFilterValue = useCallback( + (columnId: string): string | undefined => { + const entry = columnFilters.find((filter) => filter.id === columnId); + return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined; + }, + [columnFilters], + ); - const handleSortChange = (sortBy: string, sortOrder: "asc" | "desc") => { - updateFilters({ sort_by: sortBy, sort_order: sortOrder }); - }; + const handleSearchChange = useCallback((value: string) => { + setSearchInput(value); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + setRowSelection({}); + }, []); - const handleResetPassword = async (userId: string) => { - if (!accessToken) { - NotificationsManager.fromBackend("Access token not found"); - return; - } - try { - NotificationsManager.success("Generating password reset link..."); - const data = await invitationCreateCall(accessToken, userId); - setInvitationLinkData(data); - setIsInvitationLinkModalVisible(true); - } catch (error) { - NotificationsManager.fromBackend("Failed to generate password reset link"); - } - }; + const handleSortingChange = useCallback>((updaterOrValue) => { + setSorting(updaterOrValue); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + setRowSelection({}); + }, []); + + const handleColumnFiltersChange = useCallback>((updaterOrValue) => { + setColumnFilters(updaterOrValue); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + setRowSelection({}); + }, []); + + const handlePaginationChange = useCallback>((updaterOrValue) => { + setPagination(updaterOrValue); + setRowSelection({}); + }, []); + + const handleUserClick = useCallback((userId: string, openInEdit: boolean = false) => { + setSelectedUserId(userId); + setOpenInEditMode(openInEdit); + }, []); + + const handleCloseUserInfo = useCallback(() => { + setSelectedUserId(null); + setOpenInEditMode(false); + }, []); + + const handleDelete = useCallback((user: UserInfo) => { + setUserToDelete(user); + setIsDeleteModalOpen(true); + }, []); + + const handleResetPassword = useCallback( + async (userId: string) => { + if (!accessToken) { + NotificationsManager.fromBackend("Access token not found"); + return; + } + try { + NotificationsManager.success("Generating password reset link..."); + const data = await invitationCreateCall(accessToken, userId); + setInvitationLinkData(data); + setIsInvitationLinkModalVisible(true); + } catch (error) { + NotificationsManager.fromBackend("Failed to generate password reset link"); + } + }, + [accessToken], + ); const confirmDelete = async () => { if (userToDelete && accessToken) { @@ -220,58 +235,63 @@ const ViewUserDashboard: React.FC = ({ // Close the modal }; - const handlePageChange = async (newPage: number) => { - setCurrentPage(newPage); - }; - const handleToggleSelectionMode = () => { setSelectionMode(!selectionMode); - setSelectedUsers([]); - }; - - const handleSelectionChange = (users: UserInfo[]) => { - setSelectedUsers(users); - }; - - const handleBulkEdit = () => { - if (selectedUsers.length === 0) { - NotificationsManager.fromBackend("Please select users to edit"); - return; - } - - setIsBulkEditModalVisible(true); + setRowSelection({}); }; const handleBulkEditSuccess = () => { // Refresh the user list queryClient.invalidateQueries({ queryKey: ["userList"] }); - setSelectedUsers([]); + setRowSelection({}); setSelectionMode(false); }; + const activeSort = sorting[0]; + const sortBy = activeSort?.id ?? DEFAULT_SORT_BY; + const sortOrder: "asc" | "desc" = activeSort?.desc ?? true ? "desc" : "asc"; + + const userIdFilter = getFilterValue("user_id"); + const ssoUserIdFilter = getFilterValue("sso_user_id"); + const userRoleFilter = getFilterValue("user_role"); + const teamFilter = getFilterValue("team"); + const emailFilter = searchEmail.trim() || null; + + const userListQueryFilters = { + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + email: emailFilter, + userId: userIdFilter, + ssoUserId: ssoUserIdFilter, + role: userRoleFilter, + team: teamFilter, + sortBy, + sortOrder, + orgAdminOrgIds, + }; + const userListQuery = useQuery({ - queryKey: ["userList", { debouncedFilter: debouncedFilters, currentPage, orgAdminOrgIds }], + queryKey: ["userList", userListQueryFilters], queryFn: async () => { if (!accessToken) throw new Error("Access token required"); return await userListCall( accessToken, - debouncedFilters.user_id ? [debouncedFilters.user_id] : null, - currentPage, - DEFAULT_PAGE_SIZE, - debouncedFilters.email || null, - debouncedFilters.user_role || null, - debouncedFilters.team || null, - debouncedFilters.sso_user_id || null, - debouncedFilters.sort_by, - debouncedFilters.sort_order, + userIdFilter ? [userIdFilter] : null, + pagination.pageIndex + 1, + pagination.pageSize, + emailFilter, + userRoleFilter ?? null, + teamFilter ?? null, + ssoUserIdFilter ?? null, + sortBy, + sortOrder, orgAdminOrgIds ? orgAdminOrgIds.map((o) => o.organization_id) : null, ); }, enabled: Boolean(accessToken && token && userRole && userID), placeholderData: (previousData) => previousData, }); - const userListResponse = userListQuery.data; const userRolesQuery = useQuery>>({ queryKey: ["userRoles"], @@ -284,28 +304,61 @@ const ViewUserDashboard: React.FC = ({ }); const possibleUIRoles = userRolesQuery.data; - const tableColumns = columns( - possibleUIRoles, - (user) => { - setSelectedUser(user); - setEditModalVisible(true); - }, - handleDelete, - handleResetPassword, - () => {}, // placeholder function, will be overridden in UserDataTable + const users = useMemo(() => userListQuery.data?.users ?? [], [userListQuery.data]); + const totalUserCount = userListQuery.data?.total ?? 0; + + const selectedUsers = useMemo(() => users.filter((user) => rowSelection[user.user_id]), [users, rowSelection]); + + if (selectedUserId) { + return ( + + ); + } + + const usersTable = ( + ); return (
- {userListQuery.isLoading ? ( + {userListQuery.isLoading && ( <> - ) : userID && accessToken ? ( + )} + {!userListQuery.isLoading && userID && accessToken && ( <> {isProxyAdmin && ( = ({ onClick={handleToggleSelectionMode} type={selectionMode ? "primary" : "default"} className="flex items-center" + data-testid="toggle-user-selection" > {selectionMode ? "Cancel Selection" : "Select Users"} @@ -329,57 +383,28 @@ const ViewUserDashboard: React.FC = ({ {isProxyAdmin && selectionMode && ( )} - ) : null} + )}
{isProxyAdmin ? ( - setActiveTab(index === 0 ? "users" : "settings")}> + Users Default User Settings - - { - setSelectedUser(user); - setEditModalVisible(true); - }} - handleDelete={handleDelete} - handleResetPassword={handleResetPassword} - enableSelection={selectionMode} - selectedUsers={selectedUsers} - onSelectionChange={handleSelectionChange} - filters={filters} - updateFilters={updateFilters} - initialFilters={initialFilters} - teams={teams} - userListResponse={userListResponse} - currentPage={currentPage} - handlePageChange={handlePageChange} - /> - + {usersTable} {!userID || !userRole || !accessToken ? ( @@ -398,35 +423,7 @@ const ViewUserDashboard: React.FC = ({ ) : ( - { - setSelectedUser(user); - setEditModalVisible(true); - }} - handleDelete={handleDelete} - handleResetPassword={handleResetPassword} - enableSelection={false} - selectedUsers={[]} - onSelectionChange={handleSelectionChange} - filters={filters} - updateFilters={updateFilters} - initialFilters={initialFilters} - teams={teams} - userListResponse={userListResponse} - currentPage={currentPage} - handlePageChange={handlePageChange} - /> + usersTable )} {/* Existing Modals */} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx new file mode 100644 index 00000000000..4689ef7cf96 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.test.tsx @@ -0,0 +1,272 @@ +/* @vitest-environment jsdom */ +import type { PaginationState, RowSelectionState, SortingState } from "@tanstack/react-table"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { UserInfo } from "@/components/networking"; + +import { UsersTable } from "./UsersTable"; + +const possibleUIRoles = { + proxy_admin: { ui_label: "Admin" }, + internal_user: { ui_label: "Internal User" }, +}; + +const makeUser = (overrides: Partial = {}): UserInfo => + ({ + user_id: "user-1", + user_email: "ada@example.com", + user_alias: null, + user_role: "proxy_admin", + spend: 12.5, + max_budget: null, + models: [], + key_count: 2, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-02-01T00:00:00Z", + sso_user_id: null, + budget_duration: null, + ...overrides, + }) as UserInfo; + +interface HarnessOverrides { + data?: UserInfo[]; + rowCount?: number; + isLoading?: boolean; + selectionEnabled?: boolean; + onUserClick?: (userId: string, openInEditMode?: boolean) => void; + onDeleteUser?: (user: UserInfo) => void; + onResetPassword?: (userId: string) => void; + onSortingChange?: ReturnType; +} + +/** + * Renders the table with real selection/sorting state so assertions exercise the + * controlled wiring rather than a stubbed callback. + */ +function Harness({ + data = [makeUser()], + rowCount = 1, + isLoading = false, + selectionEnabled = false, + onUserClick = vi.fn(), + onDeleteUser = vi.fn(), + onResetPassword = vi.fn(), + onSortingChange, +}: HarnessOverrides) { + const [sorting, setSorting] = useState([{ id: "created_at", desc: true }]); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); + const [rowSelection, setRowSelection] = useState({}); + + return ( + <> + + {Object.keys(rowSelection) + .filter((key) => rowSelection[key]) + .sort() + .join(",")} + + { + setSorting(updater); + onSortingChange?.(updater); + }} + pagination={pagination} + onPaginationChange={setPagination} + columnFilters={[]} + onColumnFiltersChange={vi.fn()} + searchValue="" + onSearchChange={vi.fn()} + selectionEnabled={selectionEnabled} + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} + onUserClick={onUserClick} + onDeleteUser={onDeleteUser} + onResetPassword={onResetPassword} + /> + + ); +} + +const openRowMenu = async (user: ReturnType, userId: string) => { + await user.click(screen.getByTestId(`user-actions-${userId}`)); +}; + +describe("UsersTable", () => { + it("renders every migrated column header", () => { + render(); + + const headerRow = screen.getAllByRole("row")[0]; + + [ + "User ID", + "Email", + "Status", + "Global Proxy Role", + "User Alias", + "Spend (USD)", + "Budget (USD)", + "SSO ID", + "Virtual Keys", + "Created At", + "Updated At", + ].forEach((header) => { + expect(headerRow.textContent).toContain(header); + }); + }); + + // Sorting is server-side and the backend only accepts these five keys, so a sort + // control on any other column would send an invalid sort_by. Assert the exact set: + // a missing control and an extra one both have to fail. + it("exposes a sort control for exactly the five server-sortable columns", () => { + render(); + + const sortableIds = screen + .getAllByTestId(/^sort-header-/) + .map((node) => (node.getAttribute("data-testid") ?? "").replace("sort-header-", "")) + .sort(); + + expect(sortableIds).toEqual(["created_at", "spend", "user_email", "user_id", "user_role"]); + }); + + it("reports the clicked column to the server sorting handler", async () => { + const user = userEvent.setup(); + const onSortingChange = vi.fn(); + render(); + + await user.click(screen.getByTestId("sort-header-user_email")); + + expect(onSortingChange).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("sort-header-user_email").querySelector("[data-sort-indicator]")).toHaveAttribute( + "data-sort-indicator", + "asc", + ); + }); + + it("opens the detail view from the identity cell without edit mode", async () => { + const user = userEvent.setup(); + const onUserClick = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /user-1/ })); + + expect(onUserClick).toHaveBeenCalledWith("user-1", false); + }); + + it("opens the detail view in edit mode from the row menu", async () => { + const user = userEvent.setup(); + const onUserClick = vi.fn(); + render(); + + await openRowMenu(user, "user-1"); + await user.click(await screen.findByTestId("user-action-edit")); + + expect(onUserClick).toHaveBeenCalledWith("user-1", true); + }); + + it("delegates delete and reset-password from the row menu", async () => { + const user = userEvent.setup(); + const onDeleteUser = vi.fn(); + const onResetPassword = vi.fn(); + render(); + + await openRowMenu(user, "user-1"); + await user.click(await screen.findByTestId("user-action-reset-password")); + expect(onResetPassword).toHaveBeenCalledWith("user-1"); + + await openRowMenu(user, "user-1"); + await user.click(await screen.findByTestId("user-action-delete")); + expect(onDeleteUser).toHaveBeenCalledWith(expect.objectContaining({ user_id: "user-1" })); + }); + + it("renders the SCIM status cell from metadata", () => { + const { rerender } = render(); + expect(screen.getByTestId("user-status-user-1")).toHaveTextContent("Active"); + + rerender()]} />); + expect(screen.getByTestId("user-status-user-1")).toHaveTextContent("Inactive"); + + rerender()]} />); + expect(screen.getByTestId("user-status-user-1")).toHaveTextContent("Active"); + }); + + describe("row selection", () => { + const twoUsers = [ + makeUser({ user_id: "user-1", user_email: "ada@example.com" }), + makeUser({ user_id: "user-2", user_email: "grace@example.com" }), + ]; + + it("hides the selection column until selection mode is on", () => { + const { rerender } = render(); + expect(screen.queryByTestId("datatable-select-all")).not.toBeInTheDocument(); + expect(screen.queryByTestId("datatable-select-row-user-1")).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId("datatable-select-all")).toBeInTheDocument(); + expect(screen.getByTestId("datatable-select-row-user-1")).toBeInTheDocument(); + }); + + it("keys the controlled selection by user id", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("datatable-select-row-user-2")); + expect(screen.getByTestId("selected-ids")).toHaveTextContent("user-2"); + + await user.click(screen.getByTestId("datatable-select-row-user-1")); + expect(screen.getByTestId("selected-ids")).toHaveTextContent("user-1,user-2"); + + await user.click(screen.getByTestId("datatable-select-row-user-2")); + expect(screen.getByTestId("selected-ids")).toHaveTextContent("user-1"); + }); + + it("selects and clears the whole page from the header checkbox", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("datatable-select-all")); + expect(screen.getByTestId("selected-ids")).toHaveTextContent("user-1,user-2"); + + await user.click(screen.getByTestId("datatable-select-all")); + expect(screen.getByTestId("selected-ids")).toBeEmptyDOMElement(); + }); + + it("shows an indeterminate header while only part of the page is selected", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("datatable-select-row-user-1")); + + expect(screen.getByTestId("datatable-select-all")).toHaveAttribute("aria-checked", "mixed"); + }); + }); + + it("renders the empty state when there are no users", () => { + render(); + + expect(screen.getByText("No users found")).toBeInTheDocument(); + }); + + it("shows skeleton rows on the initial load instead of the empty state", () => { + render(); + + expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); + expect(screen.queryByText("No users found")).not.toBeInTheDocument(); + }); + + it("keeps the row menu out of the identity cell so only the name and menu act on a row", () => { + render(); + + const rows = screen.getAllByRole("row"); + const dataRow = rows[rows.length - 1]; + expect(within(dataRow).getByTestId("user-actions-user-1")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx new file mode 100644 index 00000000000..26663f5d9e2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTable.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { + ColumnFiltersState, + OnChangeFn, + PaginationState, + RowSelectionState, + SortingState, +} from "@tanstack/react-table"; +import { Users } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { UserInfo } from "@/components/networking"; +import { + DataTable, + DataTableFilterDrawer, + DataTableFilterField, + DataTableToolbar, +} from "@/components/shared/DataTable"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { Input } from "@/components/ui/input"; + +import { getUsersTableColumns } from "./UsersTableColumns"; + +export interface UsersTableTeamOption { + team_id: string; + team_alias?: string | null; +} + +interface UsersTableProps { + data: UserInfo[]; + rowCount: number; + isLoading: boolean; + possibleUIRoles: Record> | null; + teams: UsersTableTeamOption[] | null; + sorting: SortingState; + onSortingChange: OnChangeFn; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + columnFilters: ColumnFiltersState; + onColumnFiltersChange: OnChangeFn; + searchValue: string; + onSearchChange: (value: string) => void; + selectionEnabled: boolean; + rowSelection: RowSelectionState; + onRowSelectionChange: OnChangeFn; + onUserClick: (userId: string, openInEditMode?: boolean) => void; + onDeleteUser: (user: UserInfo) => void; + onResetPassword: (userId: string) => void; +} + +const FILTER_LABELS: Record = { + user_id: "User ID", + sso_user_id: "SSO ID", + user_role: "Role", + team: "Team", +}; + +function EmptyState() { + return ( +
+
+ +
+
No users found
+
Try adjusting your search or filters.
+
+ ); +} + +export function UsersTable({ + data, + rowCount, + isLoading, + possibleUIRoles, + teams, + sorting, + onSortingChange, + pagination, + onPaginationChange, + columnFilters, + onColumnFiltersChange, + searchValue, + onSearchChange, + selectionEnabled, + rowSelection, + onRowSelectionChange, + onUserClick, + onDeleteUser, + onResetPassword, +}: UsersTableProps) { + const [filtersOpen, setFiltersOpen] = useState(false); + + const columns = useMemo(() => { + const columnDeps = { + possibleUIRoles, + includeSelection: selectionEnabled, + onUserClick, + onDeleteUser, + onResetPassword, + }; + return getUsersTableColumns(columnDeps); + }, [possibleUIRoles, selectionEnabled, onUserClick, onDeleteUser, onResetPassword]); + + const roleOptions = useMemo( + () => + Object.entries(possibleUIRoles ?? {}).map(([role, config]) => ({ + label: config.ui_label || role, + value: role, + })), + [possibleUIRoles], + ); + + const teamOptions = useMemo( + () => + (teams ?? []).map((team) => ({ + label: team.team_alias || team.team_id, + value: team.team_id, + })), + [teams], + ); + + const formatFilterValue = (columnId: string, value: unknown): string => { + const raw = String(value); + if (columnId === "user_role") { + return possibleUIRoles?.[raw]?.ui_label || raw; + } + if (columnId === "team") { + return teams?.find((team) => team.team_id === raw)?.team_alias || raw; + } + return raw; + }; + + return ( + row.user_id} + sortingMode="server" + sorting={sorting} + onSortingChange={onSortingChange} + paginationMode="server" + pagination={pagination} + onPaginationChange={onPaginationChange} + rowCount={rowCount} + filterMode="server" + columnFilters={columnFilters} + onColumnFiltersChange={onColumnFiltersChange} + rowSelection={rowSelection} + onRowSelectionChange={onRowSelectionChange} + isLoading={isLoading} + loadingMessage="Loading users…" + noDataMessage={} + size="compact" + toolbar={(table) => ( + <> + setFiltersOpen(true)} + filterLabels={FILTER_LABELS} + formatFilterValue={formatFilterValue} + /> + + {({ get, set }) => ( + <> + + set("user_id", event.target.value)} + placeholder="Enter user ID…" + data-testid="users-filter-user-id" + /> + + + set("sso_user_id", event.target.value)} + placeholder="Enter SSO ID…" + data-testid="users-filter-sso-id" + /> + + + set("user_role", value)} + placeholder="Select a role…" + emptyText="No roles found" + /> + + + set("team", value)} + placeholder="Select a team…" + emptyText="No teams found" + /> + + + )} + + + )} + /> + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx new file mode 100644 index 00000000000..6c569f205e5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/UsersTableColumns.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, Info, KeyRound, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; + +import { UserInfo } from "@/components/networking"; +import { createSelectionColumn, DataTableSortHeader } from "@/components/shared/DataTable"; +import { CellTooltip, DateCell, IdentityCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells"; +import { Badge } from "@/components/ui/badge"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; + +const SSO_ID_HINT = + "SSO ID is the ID of the user in the SSO provider. If the user is not using SSO, this will be null."; + +const SCIM_INACTIVE_HINT = "Deactivated via SCIM (external identity provider). The user's virtual keys are blocked."; + +function isScimInactive(user: UserInfo): boolean { + return (user.metadata as Record | null | undefined)?.scim_active === false; +} + +interface UserRowActionsProps { + user: UserInfo; + onUserClick: (userId: string, openInEditMode?: boolean) => void; + onDeleteUser: (user: UserInfo) => void; + onResetPassword: (userId: string) => void; +} + +function UserRowActions({ user, onUserClick, onDeleteUser, onResetPassword }: UserRowActionsProps) { + return ( + + + + + + onUserClick(user.user_id, true)} data-testid="user-action-edit"> + + Edit user + + onResetPassword(user.user_id)} data-testid="user-action-reset-password"> + + Reset password + + void copyToClipboard(user.user_id, "User ID copied")} + data-testid="user-action-copy" + > + + Copy user ID + + + onDeleteUser(user)} data-testid="user-action-delete"> + + Delete user + + + + ); +} + +export interface UsersTableColumnsDeps { + possibleUIRoles: Record> | null; + includeSelection: boolean; + onUserClick: (userId: string, openInEditMode?: boolean) => void; + onDeleteUser: (user: UserInfo) => void; + onResetPassword: (userId: string) => void; +} + +export const getUsersTableColumns = ({ + possibleUIRoles, + includeSelection, + onUserClick, + onDeleteUser, + onResetPassword, +}: UsersTableColumnsDeps): ColumnDef[] => { + const baseColumns: ColumnDef[] = [ + { + id: "user_id", + accessorKey: "user_id", + meta: { title: "User ID" }, + header: ({ column }) => , + size: 220, + enableSorting: true, + cell: ({ row }) => ( + onUserClick(row.original.user_id, false)} + /> + ), + }, + { + id: "user_email", + accessorKey: "user_email", + meta: { title: "Email" }, + header: ({ column }) => , + size: 220, + enableSorting: true, + cell: ({ row }) => ( + + {row.original.user_email || "-"} + + ), + }, + { + id: "status", + meta: { title: "Status", skeleton: "badge" }, + header: "Status", + size: 110, + enableSorting: false, + cell: ({ row }) => { + if (isScimInactive(row.original)) { + return ( + + ); + } + return ; + }, + }, + { + id: "user_role", + accessorKey: "user_role", + meta: { title: "Global Proxy Role" }, + header: ({ column }) => , + size: 160, + enableSorting: true, + cell: ({ row }) => {possibleUIRoles?.[row.original.user_role]?.ui_label || "-"}, + }, + { + id: "user_alias", + accessorKey: "user_alias", + meta: { title: "User Alias" }, + header: "User Alias", + size: 150, + enableSorting: false, + cell: ({ row }) => ( + + {row.original.user_alias || "-"} + + ), + }, + { + id: "spend", + accessorKey: "spend", + meta: { title: "Spend (USD)", numeric: true }, + header: ({ column }) => , + size: 130, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "max_budget", + accessorKey: "max_budget", + meta: { title: "Budget (USD)", numeric: true }, + header: "Budget (USD)", + size: 130, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "sso_user_id", + accessorKey: "sso_user_id", + meta: { title: "SSO ID" }, + header: () => ( + + SSO ID + } + /> + + ), + size: 160, + enableSorting: false, + cell: ({ row }) => ( + + {row.original.sso_user_id ?? "-"} + + ), + }, + { + id: "key_count", + accessorKey: "key_count", + meta: { title: "Virtual Keys", skeleton: "badge" }, + header: "Virtual Keys", + size: 120, + enableSorting: false, + cell: ({ row }) => { + const keyCount = row.original.key_count; + if (keyCount > 0) { + return ( + + {keyCount} {keyCount === 1 ? "Key" : "Keys"} + + ); + } + return ( + + No Keys + + ); + }, + }, + { + id: "created_at", + accessorKey: "created_at", + meta: { title: "Created At" }, + header: ({ column }) => , + size: 130, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "updated_at", + accessorKey: "updated_at", + meta: { title: "Updated At" }, + header: "Updated At", + size: 130, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "actions", + meta: { title: "Actions", className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 60, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, + ]; + + if (!includeSelection) { + return baseColumns; + } + + return [ + createSelectionColumn({ + rowAriaLabel: (row) => `Select ${row.original.user_email || row.original.user_id}`, + }), + ...baseColumns, + ]; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx deleted file mode 100644 index fc680cb5b1b..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { ColumnDef } from "@tanstack/react-table"; -import { Badge, Grid, Icon } from "@tremor/react"; -import { Tooltip, Checkbox, Tag } from "antd"; -import { UserInfo } from "@/components/networking"; -import { PencilAltIcon, TrashIcon, InformationCircleIcon, RefreshIcon } from "@heroicons/react/outline"; -import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; - -interface SelectionOptions { - selectedUsers: UserInfo[]; - onSelectUser: (user: UserInfo, isSelected: boolean) => void; - onSelectAll: (isSelected: boolean) => void; - isUserSelected: (user: UserInfo) => boolean; - isAllSelected: boolean; - isIndeterminate: boolean; -} - -export const columns = ( - possibleUIRoles: Record>, - handleEdit: (user: UserInfo) => void, - handleDelete: (user: UserInfo) => void, - handleResetPassword: (userId: string) => void, - handleUserClick: (userId: string, openInEditMode?: boolean) => void, - selectionOptions?: SelectionOptions, -): ColumnDef[] => { - // Backend sortable columns: user_id, user_email, created_at, spend, user_alias, user_role - const baseColumns: ColumnDef[] = [ - { - header: "User ID", - accessorKey: "user_id", - enableSorting: true, - cell: ({ row }) => , - }, - { - header: "Email", - accessorKey: "user_email", - enableSorting: true, - cell: ({ row }) => {row.original.user_email || "-"}, - }, - { - id: "status", - header: "Status", - enableSorting: false, - cell: ({ row }) => { - const isScimInactive = - (row.original.metadata as Record | null | undefined)?.scim_active === false; - if (isScimInactive) { - return ( - - - Inactive - - - ); - } - return ( - - Active - - ); - }, - }, - { - header: "Global Proxy Role", - accessorKey: "user_role", - enableSorting: true, - cell: ({ row }) => {possibleUIRoles?.[row.original.user_role]?.ui_label || "-"}, - }, - { - header: "User Alias", - accessorKey: "user_alias", - enableSorting: false, - cell: ({ row }) => {row.original.user_alias || "-"}, - }, - { - header: "Spend (USD)", - accessorKey: "spend", - enableSorting: true, - cell: ({ row }) => , - }, - { - header: "Budget (USD)", - accessorKey: "max_budget", - enableSorting: false, - cell: ({ row }) => , - }, - { - header: () => ( -
- SSO ID - - - -
- ), - accessorKey: "sso_user_id", - enableSorting: false, - cell: ({ row }) => ( - {row.original.sso_user_id !== null ? row.original.sso_user_id : "-"} - ), - }, - { - header: "Virtual Keys", - accessorKey: "key_count", - enableSorting: false, - cell: ({ row }) => ( - - {row.original.key_count > 0 ? ( - - {row.original.key_count} {row.original.key_count === 1 ? "Key" : "Keys"} - - ) : ( - - No Keys - - )} - - ), - }, - { - header: "Created At", - accessorKey: "created_at", - enableSorting: true, - cell: ({ row }) => , - }, - { - header: "Updated At", - accessorKey: "updated_at", - enableSorting: false, - cell: ({ row }) => , - }, - { - id: "actions", - header: "Actions", - enableSorting: false, - cell: ({ row }) => ( -
- - handleUserClick(row.original.user_id, true)} - className="cursor-pointer hover:text-blue-600" - /> - - - handleDelete(row.original)} - className="cursor-pointer hover:text-red-600" - /> - - - handleResetPassword(row.original.user_id)} - className="cursor-pointer hover:text-green-600" - /> - -
- ), - }, - ]; - - // Add selection column if selection is enabled - if (selectionOptions) { - const { onSelectUser, onSelectAll, isUserSelected, isAllSelected, isIndeterminate } = selectionOptions; - - return [ - { - id: "select", - enableSorting: false, - header: () => ( - onSelectAll(e.target.checked)} - onClick={(e) => e.stopPropagation()} - /> - ), - cell: ({ row }) => ( - onSelectUser(row.original, e.target.checked)} - onClick={(e) => e.stopPropagation()} - /> - ), - }, - ...baseColumns, - ]; - } - - return baseColumns; -}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.test.tsx deleted file mode 100644 index 695aaa30cd7..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.test.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { columns } from "./columns"; -import { UserDataTable } from "./table"; -import { UserInfo } from "@/components/networking"; - -const defaultFilters = { - email: "", - user_id: "", - user_role: "", - sso_user_id: "", - team: "", - model: "", - min_spend: null, - max_spend: null, - sort_by: "", - sort_order: "asc" as const, -}; - -const getDefaultProps = () => ({ - data: [] as any[], - columns: [] as any[], - accessToken: null, - userRole: "Admin", - possibleUIRoles: null as Record> | null, - filters: defaultFilters, - updateFilters: vi.fn(), - initialFilters: defaultFilters, - teams: [] as any[], - handleEdit: vi.fn(), - handleDelete: vi.fn(), - handleResetPassword: vi.fn(), - userListResponse: { users: [], total: 0, page: 1, page_size: 25, total_pages: 1 }, - currentPage: 1, - handlePageChange: vi.fn(), -}); - -describe("UserDataTable", () => { - it("should render the UserDataTable component", () => { - render(); - - expect(screen.getByText("Filters")).toBeInTheDocument(); - }); - - it("should call onSortChange when clicking a sortable header", () => { - const filters = { - ...defaultFilters, - sort_by: "created_at", - sort_order: "desc" as const, - }; - - const onSortChange = vi.fn(); - - const possibleUIRoles = { - admin: { ui_label: "Admin" }, - user: { ui_label: "User" }, - }; - - render( - , - ); - - const emailHeader = screen.getByRole("columnheader", { name: /email/i }); - act(() => { - fireEvent.click(emailHeader); - }); - - expect(onSortChange).toHaveBeenCalledWith("user_email", "desc"); - }); - - it("should show skeleton loaders when isLoading is true", () => { - render(); - - expect(screen.queryByText(/Showing/i)).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /Previous/i })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /Next/i })).not.toBeInTheDocument(); - }); - - it("should show actual content when isLoading is false", () => { - render(); - - expect(screen.getByText(/Showing/i)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Previous/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Next/i })).toBeInTheDocument(); - }); - - it("should render all column headers", () => { - const possibleUIRoles = { - admin: { ui_label: "Admin" }, - user: { ui_label: "User" }, - }; - - render(); - - [ - "User ID", - "Email", - "Status", - "Global Proxy Role", - "User Alias", - "Spend (USD)", - "Budget (USD)", - "SSO ID", - "Virtual Keys", - "Created At", - "Updated At", - "Actions", - ].forEach((header) => { - expect(screen.getByRole("columnheader", { name: header })).toBeInTheDocument(); - }); - }); - - it("should render the user-row Status cell as Active when scim_active is not set to false", () => { - const possibleUIRoles = { admin: { ui_label: "Admin" } }; - const handlers = { edit: vi.fn(), del: vi.fn(), reset: vi.fn(), click: vi.fn() }; - const cols = columns(possibleUIRoles, handlers.edit, handlers.del, handlers.reset, handlers.click); - const statusCol = cols.find((c) => (c as { id?: string }).id === "status"); - expect(statusCol).toBeDefined(); - - const baseUser: UserInfo = { - user_id: "u-active", - user_email: "active@example.com", - user_alias: null, - user_role: "admin", - spend: 0, - max_budget: null, - models: [], - key_count: 0, - created_at: "", - updated_at: "", - sso_user_id: null, - budget_duration: null, - }; - - const cellNoMetadata = (statusCol as any).cell({ row: { original: baseUser } }); - render(<>{cellNoMetadata}); - expect(screen.getByText("Active")).toBeInTheDocument(); - expect(screen.queryByText("Inactive")).not.toBeInTheDocument(); - }); - - it("should render the user-row Status cell as Inactive when scim_active is false", () => { - const possibleUIRoles = { admin: { ui_label: "Admin" } }; - const cols = columns(possibleUIRoles, vi.fn(), vi.fn(), vi.fn(), vi.fn()); - const statusCol = cols.find((c) => (c as { id?: string }).id === "status")!; - - const inactiveUser: UserInfo = { - user_id: "u-inactive", - user_email: "alex@acme.io", - user_alias: null, - user_role: "internal_user", - spend: 0, - max_budget: null, - models: [], - key_count: 1, - created_at: "", - updated_at: "", - sso_user_id: null, - budget_duration: null, - metadata: { scim_active: false }, - }; - - const cell = (statusCol as any).cell({ row: { original: inactiveUser } }); - render(<>{cell}); - expect(screen.getByText("Inactive")).toBeInTheDocument(); - expect(screen.queryByText("Active")).not.toBeInTheDocument(); - }); - - it("should treat scim_active=true as Active (not Inactive)", () => { - const possibleUIRoles = { admin: { ui_label: "Admin" } }; - const cols = columns(possibleUIRoles, vi.fn(), vi.fn(), vi.fn(), vi.fn()); - const statusCol = cols.find((c) => (c as { id?: string }).id === "status")!; - - const reactivated: UserInfo = { - user_id: "u-rehired", - user_email: "alex@acme.io", - user_alias: null, - user_role: "internal_user", - spend: 0, - max_budget: null, - models: [], - key_count: 1, - created_at: "", - updated_at: "", - sso_user_id: null, - budget_duration: null, - metadata: { scim_active: true }, - }; - - const cell = (statusCol as any).cell({ row: { original: reactivated } }); - render(<>{cell}); - expect(screen.getByText("Active")).toBeInTheDocument(); - expect(screen.queryByText("Inactive")).not.toBeInTheDocument(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.tsx deleted file mode 100644 index 1ba09243a08..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/table.tsx +++ /dev/null @@ -1,442 +0,0 @@ -import { ColumnDef, flexRender, getCoreRowModel, SortingState, useReactTable } from "@tanstack/react-table"; -import React from "react"; -import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell, Select, SelectItem } from "@tremor/react"; -import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline"; -import { Skeleton } from "antd"; -import { UserInfo } from "@/components/networking"; -import UserInfoView from "./user_info_view"; -import { columns as createColumns } from "./columns"; -import { FilterInput } from "@/components/common_components/Filters/FilterInput"; -import { FiltersButton } from "@/components/common_components/Filters/FiltersButton"; -import { ResetFiltersButton } from "@/components/common_components/Filters/ResetFiltersButton"; -import { Search, User, CircleUserRound } from "lucide-react"; - -interface FilterState { - email: string; - user_id: string; - user_role: string; - sso_user_id: string; - team: string; - model: string; - min_spend: number | null; - max_spend: number | null; - sort_by: string; - sort_order: "asc" | "desc"; -} - -interface UserDataTableProps { - data: UserInfo[]; - columns: ColumnDef[]; - isLoading?: boolean; - onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void; - currentSort?: { - sortBy: string; - sortOrder: "asc" | "desc"; - }; - accessToken: string | null; - userRole: string | null; - possibleUIRoles: Record> | null; - handleEdit: (user: UserInfo) => void; - handleDelete: (user: UserInfo) => void; - handleResetPassword: (userId: string) => void; - selectedUsers?: UserInfo[]; - onSelectionChange?: (selectedUsers: UserInfo[]) => void; - enableSelection?: boolean; - // Filter-related props - filters: FilterState; - updateFilters: (update: Partial) => void; - initialFilters: FilterState; - teams: any[] | null; - // Pagination props - userListResponse: any; - currentPage: number; - handlePageChange: (newPage: number) => void; -} - -export function UserDataTable({ - data = [], - columns: originalColumns, - isLoading = false, - onSortChange, - currentSort, - accessToken, - userRole, - possibleUIRoles, - handleEdit, - handleDelete, - handleResetPassword, - selectedUsers = [], - onSelectionChange, - enableSelection = false, - filters, - updateFilters, - initialFilters, - teams, - userListResponse, - currentPage, - handlePageChange, -}: UserDataTableProps) { - const [sorting, setSorting] = React.useState([ - { - id: currentSort?.sortBy || "created_at", - desc: currentSort?.sortOrder === "desc", - }, - ]); - const [selectedUserId, setSelectedUserId] = React.useState(null); - const [openInEditMode, setOpenInEditMode] = React.useState(false); - const [showFilters, setShowFilters] = React.useState(false); - - const handleUserClick = (userId: string, openInEditMode: boolean = false) => { - setSelectedUserId(userId); - setOpenInEditMode(openInEditMode); - }; - - const handleCloseUserInfo = () => { - setSelectedUserId(null); - setOpenInEditMode(false); - }; - - // Selection handlers - const handleSelectUser = (user: UserInfo, isSelected: boolean) => { - if (!onSelectionChange) return; - - if (isSelected) { - onSelectionChange([...selectedUsers, user]); - } else { - onSelectionChange(selectedUsers.filter((u) => u.user_id !== user.user_id)); - } - }; - - const handleSelectAll = (isSelected: boolean) => { - if (!onSelectionChange) return; - - if (isSelected) { - onSelectionChange(data); - } else { - onSelectionChange([]); - } - }; - - const isUserSelected = (user: UserInfo) => { - return selectedUsers.some((u) => u.user_id === user.user_id); - }; - - const isAllSelected = data.length > 0 && selectedUsers.length === data.length; - const isIndeterminate = selectedUsers.length > 0 && selectedUsers.length < data.length; - - // Create columns with the handleUserClick function - const columns = React.useMemo(() => { - if (possibleUIRoles) { - return createColumns( - possibleUIRoles, - handleEdit, - handleDelete, - handleResetPassword, - handleUserClick, - enableSelection - ? { - selectedUsers, - onSelectUser: handleSelectUser, - onSelectAll: handleSelectAll, - isUserSelected, - isAllSelected, - isIndeterminate, - } - : undefined, - ); - } - return originalColumns; - }, [ - possibleUIRoles, - handleEdit, - handleDelete, - handleResetPassword, - handleUserClick, - originalColumns, - enableSelection, - selectedUsers, - isAllSelected, - isIndeterminate, - ]); - - const table = useReactTable({ - data, - columns, - state: { - sorting, - }, - onSortingChange: (updaterOrValue: any) => { - const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; - setSorting(newSorting); - if (newSorting && Array.isArray(newSorting) && newSorting.length > 0 && newSorting[0]) { - const sortState = newSorting[0]; - if (sortState.id) { - const sortBy = sortState.id; - const sortOrder = sortState.desc ? "desc" : "asc"; - onSortChange?.(sortBy, sortOrder); - } - } else { - // Reset to default sort when no sorting is selected - onSortChange?.("created_at", "desc"); - } - }, - getCoreRowModel: getCoreRowModel(), - manualSorting: true, - enableSorting: true, - }); - - // Update local sorting state when currentSort prop changes - React.useEffect(() => { - if (currentSort) { - setSorting([ - { - id: currentSort.sortBy, - desc: currentSort.sortOrder === "desc", - }, - ]); - } - }, [currentSort]); - - if (selectedUserId) { - return ( - - ); - } - - return ( -
- {/* Filter Section */} -
-
- {/* Search and Filter Controls */} -
- {/* Email Search */} - updateFilters({ email: value })} - icon={Search} - /> - - {/* Filter Button */} - setShowFilters(!showFilters)} - active={showFilters} - hasActiveFilters={!!(filters.user_id || filters.user_role || filters.team)} - /> - - {/* Reset Filters Button */} - { - updateFilters(initialFilters); - }} - /> -
- - {/* Additional Filters */} - {showFilters && ( -
- {/* User ID Search */} - updateFilters({ user_id: value })} - icon={User} - /> - - updateFilters({ sso_user_id: value })} - icon={CircleUserRound} - /> - - {/* Role Dropdown */} -
- -
- - {/* Team Dropdown */} -
- -
-
- )} - - {/* Results Count and Pagination */} -
- {isLoading ? ( - - ) : ( - - Showing{" "} - {userListResponse && userListResponse.users && userListResponse.users.length > 0 - ? (userListResponse.page - 1) * userListResponse.page_size + 1 - : 0}{" "} - -{" "} - {userListResponse && userListResponse.users - ? Math.min(userListResponse.page * userListResponse.page_size, userListResponse.total) - : 0}{" "} - of {userListResponse ? userListResponse.total : 0} results - - )} - - {/* Pagination Buttons */} -
- {isLoading ? ( - <> - - - - ) : ( - <> - - - - )} -
-
-
-
- - {/* Table Section */} -
-
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
-
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.id !== "actions" && header.column.getCanSort() && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} -
-
- ))} -
- ))} -
- - {isLoading ? ( - - -
-

🚅 Loading users...

-
-
-
- ) : data.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - { - if (cell.column.id === "user_id") { - handleUserClick(cell.getValue() as string, false); - } - }} - style={{ - cursor: cell.column.id === "user_id" ? "pointer" : "default", - color: cell.column.id === "user_id" ? "#3b82f6" : "inherit", - }} - > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No users found

-
-
-
- )} -
-
-
-
-
-
- ); -} diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.test.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.test.tsx index 1a4c0ed9ff5..011d0568afd 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.test.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.test.tsx @@ -1,6 +1,10 @@ /* @vitest-environment jsdom */ +import type { PaginationState } from "@tanstack/react-table"; import { act, render, screen } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + import HealthCheckComponent from "./HealthCheckComponent"; const mockIndividualModelHealthCheckCall = vi.fn(); @@ -11,9 +15,57 @@ vi.mock("../networking", () => ({ latestHealthChecksCall: (...args: unknown[]) => mockLatestHealthChecksCall(...args), })); -describe("HealthCheckComponent", () => { - const getDisplayModelName = (model: { model_name?: string }) => model.model_name ?? ""; +const getDisplayModelName = (model: { model_name?: string }) => model.model_name ?? ""; +const makeModel = (id: string, name = "gpt-4") => ({ + model_name: name, + model_info: { id }, + litellm_model_name: name, +}); + +interface HarnessProps { + modelData: { data: ReturnType[] }; + allModelsOnProxy: string[]; + rowCount?: number; + onPageIndexChange?: (pageIndex: number) => void; +} + +/** Holds pagination state so page changes exercise the real controlled wiring. */ +function Harness({ modelData, allModelsOnProxy, rowCount = 1, onPageIndexChange }: HarnessProps) { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 }); + + return ( + <> + {pagination.pageIndex} + { + setPagination((previous) => { + const next = typeof updater === "function" ? updater(previous) : updater; + onPageIndexChange?.(next.pageIndex); + return next; + }); + }} + rowCount={rowCount} + /> + + ); +} + +const renderHealthCheck = async (props: HarnessProps) => { + await act(async () => { + render(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); +}; + +describe("HealthCheckComponent", () => { beforeEach(() => { vi.clearAllMocks(); mockLatestHealthChecksCall.mockResolvedValue({ latest_health_checks: {} }); @@ -26,29 +78,7 @@ describe("HealthCheckComponent", () => { }); it("should render the health check section", async () => { - 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)); - }); + await renderHealthCheck({ modelData: { data: [makeModel("deployment-1")] }, allModelsOnProxy: ["deployment-1"] }); expect(screen.getByText("Model Health Status")).toBeInTheDocument(); expect( @@ -57,176 +87,149 @@ describe("HealthCheckComponent", () => { }); it("should call individualModelHealthCheckCall with model id when run health check is triggered", async () => { - const modelData = { - data: [ - { - model_name: "gpt-4", - model_info: { id: "deployment-abc-123" }, - litellm_model_name: "gpt-4", - }, - ], - }; - render( - , + , ); const runButtons = screen.getAllByTestId("run-health-check-btn"); expect(runButtons.length).toBeGreaterThanOrEqual(1); - const runButton = runButtons[0]; await act(async () => { - runButton.click(); + runButtons[0].click(); }); - await act(async () => { await new Promise((r) => setTimeout(r, 50)); }); - expect(mockIndividualModelHealthCheckCall).toHaveBeenCalledWith("token-123", "deployment-abc-123"); - expect(mockIndividualModelHealthCheckCall).not.toHaveBeenCalledWith("token-123", "gpt-4"); + expect(mockIndividualModelHealthCheckCall).toHaveBeenCalledWith("token", "deployment-abc-123"); + expect(mockIndividualModelHealthCheckCall).not.toHaveBeenCalledWith("token", "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)); + it("should page through results with the shared pagination footer", async () => { + const onPageIndexChange = vi.fn(); + await renderHealthCheck({ + modelData: { data: [makeModel("deployment-1")] }, + allModelsOnProxy: ["deployment-1"], + rowCount: 75, + onPageIndexChange, }); - expect(screen.getByTestId("health-results-count")).toHaveTextContent("Showing 1 - 50 of 75 results"); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 75"); - await act(async () => { - screen.getByRole("button", { name: "Next" }).click(); + const user = userEvent.setup(); + await user.click(screen.getByTestId("pagination-next")); + + expect(onPageIndexChange).toHaveBeenCalledWith(1); + expect(screen.getByTestId("page-index")).toHaveTextContent("1"); + }); + + describe("row selection drives the bulk run", () => { + const twoModels = { data: [makeModel("id-alpha", "alpha"), makeModel("id-beta", "beta")] }; + const bothIds = ["id-alpha", "id-beta"]; + + it("runs only the selected models and labels the button accordingly", async () => { + await renderHealthCheck({ modelData: twoModels, allModelsOnProxy: bothIds, rowCount: 2 }); + const user = userEvent.setup(); + + expect(screen.getByTestId("run-health-checks")).toHaveTextContent("Run All Checks"); + + await user.click(screen.getByTestId("datatable-select-row-id-beta")); + expect(screen.getByTestId("run-health-checks")).toHaveTextContent("Run Selected Checks"); + + await act(async () => { + screen.getByTestId("run-health-checks").click(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(mockIndividualModelHealthCheckCall).toHaveBeenCalledWith("token", "id-beta"); + expect(mockIndividualModelHealthCheckCall).not.toHaveBeenCalledWith("token", "id-alpha"); }); - expect(onPageChange).toHaveBeenCalledWith(2); + it("falls back to every model on the page when nothing is selected", async () => { + await renderHealthCheck({ modelData: twoModels, allModelsOnProxy: bothIds, rowCount: 2 }); + + await act(async () => { + screen.getByTestId("run-health-checks").click(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(mockIndividualModelHealthCheckCall).toHaveBeenCalledWith("token", "id-alpha"); + expect(mockIndividualModelHealthCheckCall).toHaveBeenCalledWith("token", "id-beta"); + }); + + it("treats a full page selection as running everything", async () => { + await renderHealthCheck({ modelData: twoModels, allModelsOnProxy: bothIds, rowCount: 2 }); + const user = userEvent.setup(); + + await user.click(screen.getByTestId("datatable-select-all")); + + expect(screen.getByTestId("run-health-checks")).toHaveTextContent("Run All Checks"); + }); + + it("clears the selection from the Clear Selection button", async () => { + await renderHealthCheck({ modelData: twoModels, allModelsOnProxy: bothIds, rowCount: 2 }); + const user = userEvent.setup(); + + expect(screen.queryByTestId("clear-health-selection")).not.toBeInTheDocument(); + + await user.click(screen.getByTestId("datatable-select-row-id-alpha")); + await user.click(screen.getByTestId("clear-health-selection")); + + expect(screen.getByTestId("datatable-select-row-id-alpha")).toHaveAttribute("aria-checked", "false"); + expect(screen.getByTestId("run-health-checks")).toHaveTextContent("Run All Checks"); + }); + + // The pager swaps the underlying rows, so a carried-over selection would target + // models that are no longer on screen. + it("wipes the selection when the page changes", async () => { + await renderHealthCheck({ modelData: twoModels, allModelsOnProxy: bothIds, rowCount: 120 }); + const user = userEvent.setup(); + + await user.click(screen.getByTestId("datatable-select-row-id-alpha")); + expect(screen.getByTestId("clear-health-selection")).toBeInTheDocument(); + + await user.click(screen.getByTestId("pagination-next")); + + expect(screen.queryByTestId("clear-health-selection")).not.toBeInTheDocument(); + expect(screen.getByTestId("datatable-select-row-id-alpha")).toHaveAttribute("aria-checked", "false"); + }); }); 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-beta" }, - litellm_model_name: "gpt-4", - }, - ], - }; - mockLatestHealthChecksCall.mockResolvedValue({ latest_health_checks: { - "id-alpha": { - status: "healthy", - checked_at: "2024-01-15T10:00:00Z", - error_message: null, - }, - "id-beta": { - status: "unhealthy", - checked_at: "2024-01-15T10:05:00Z", - error_message: "Connection failed", - }, + "id-alpha": { status: "healthy", checked_at: "2024-01-15T10:00:00Z", error_message: null }, + "id-beta": { status: "unhealthy", checked_at: "2024-01-15T10:05:00Z", error_message: "Connection failed" }, }, }); - await act(async () => { - render( - , - ); - }); - await act(async () => { - await new Promise((r) => setTimeout(r, 0)); + await renderHealthCheck({ + modelData: { data: [makeModel("id-alpha"), makeModel("id-beta")] }, + allModelsOnProxy: ["id-alpha", "id-beta"], + rowCount: 2, }); expect(mockLatestHealthChecksCall).toHaveBeenCalledWith("token"); - const healthyBadges = screen.getAllByText("healthy"); - const unhealthyBadges = screen.getAllByText("unhealthy"); - expect(healthyBadges.length).toBeGreaterThanOrEqual(1); - expect(unhealthyBadges.length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("healthy").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("unhealthy").length).toBeGreaterThanOrEqual(1); }); it("should skip latest_health_checks entries whose key is not a known model id", async () => { - const modelData = { - data: [ - { - model_name: "gpt-4", - model_info: { id: "current-model-id" }, - litellm_model_name: "gpt-4", - }, - ], - }; - mockLatestHealthChecksCall.mockResolvedValue({ latest_health_checks: { - "current-model-id": { - status: "healthy", - checked_at: "2024-01-15T10:00:00Z", - error_message: null, - }, - "deleted-or-unknown-id": { - status: "unhealthy", - checked_at: "2024-01-15T10:05:00Z", - error_message: "Stale entry", - }, + "current-model-id": { status: "healthy", checked_at: "2024-01-15T10:00:00Z", error_message: null }, + "deleted-or-unknown-id": { status: "unhealthy", checked_at: "2024-01-15T10:05:00Z", error_message: "Stale" }, }, }); - await act(async () => { - render( - , - ); - }); - await act(async () => { - await new Promise((r) => setTimeout(r, 0)); + await renderHealthCheck({ + modelData: { data: [makeModel("current-model-id")] }, + allModelsOnProxy: ["current-model-id"], }); expect(screen.getByText("healthy")).toBeInTheDocument(); @@ -234,38 +237,15 @@ describe("HealthCheckComponent", () => { }); it("should not apply status when latest_health_checks key is model name not model id", async () => { - const modelData = { - data: [ - { - model_name: "gpt-4", - model_info: { id: "model-id-123" }, - litellm_model_name: "gpt-4", - }, - ], - }; - mockLatestHealthChecksCall.mockResolvedValue({ latest_health_checks: { - "gpt-4": { - status: "healthy", - checked_at: "2024-01-15T10:00:00Z", - error_message: null, - }, + "gpt-4": { status: "healthy", checked_at: "2024-01-15T10:00:00Z", error_message: null }, }, }); - await act(async () => { - render( - , - ); - }); - await act(async () => { - await new Promise((r) => setTimeout(r, 0)); + await renderHealthCheck({ + modelData: { data: [makeModel("model-id-123")] }, + allModelsOnProxy: ["model-id-123"], }); expect(screen.queryByText("healthy")).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx index 6497f2686cb..46c4a726927 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx @@ -1,24 +1,141 @@ -import React, { useState, useEffect, useRef } from "react"; -import { Title, Text, Button } from "@tremor/react"; +import { OnChangeFn, PaginationState, RowSelectionState } from "@tanstack/react-table"; import { Modal } from "antd"; import { Button as AntdButton } from "antd"; -import { ModelDataTable } from "./table"; -import { healthCheckColumns } from "./health_check_columns"; -import { errorPatterns } from "@/utils/errorPatterns"; -import { individualModelHealthCheckCall, latestHealthChecksCall } from "../networking"; -import { Table as TableInstance } from "@tanstack/react-table"; -import { Team } from "../key_team_helpers/key_list"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; -interface HealthStatus { - status: string; - lastCheck: string; - lastSuccess?: string; - loading: boolean; - error?: string; - fullError?: string; - successResponse?: any; +import { errorPatterns } from "@/utils/errorPatterns"; + +import { Team } from "../key_team_helpers/key_list"; +import { individualModelHealthCheckCall, latestHealthChecksCall } from "../networking"; +import { Button } from "@/components/ui/button"; +import { HealthChecksTable } from "./HealthChecksTable"; +import type { HealthCheckData, HealthStatus } from "./HealthChecksTableColumns"; + +interface LatestHealthCheck { + status?: string; + checked_at?: string | null; + error_message?: string | null; } +const STATUS_TO_ERROR: Record = { + "400": "BadRequestError", + "401": "AuthenticationError", + "403": "ForbiddenError", + "404": "NotFoundError", + "408": "TimeoutError", + "429": "RateLimitError", + "500": "InternalServerError", + "502": "BadGatewayError", + "503": "ServiceUnavailableError", + "504": "GatewayTimeoutError", +}; + +const ERROR_TO_STATUS: Record = { + AuthenticationError: "401", + RateLimitError: "429", + BadRequestError: "400", + InternalServerError: "500", + TimeoutError: "408", + NotFoundError: "404", + ForbiddenError: "403", + ServiceUnavailableError: "503", + BadGatewayError: "502", + GatewayTimeoutError: "504", + ContentPolicyViolationError: "400", +}; + +const KEYWORD_ERRORS: ReadonlyArray<{ pattern: RegExp; label: string }> = [ + { pattern: /missing.*api.*key|invalid.*key|unauthorized/i, label: "AuthenticationError: 401" }, + { pattern: /rate.*limit|too.*many.*requests/i, label: "RateLimitError: 429" }, + { pattern: /timeout|timed.*out/i, label: "TimeoutError: 408" }, + { pattern: /not.*found/i, label: "NotFoundError: 404" }, + { pattern: /forbidden|access.*denied/i, label: "ForbiddenError: 403" }, + { pattern: /internal.*server.*error/i, label: "InternalServerError: 500" }, +]; + +const truncate = (value: string): string => (value.length > 100 ? `${value.substring(0, 97)}...` : value); + +// Helper function to extract meaningful error information +const extractMeaningfulError = (error: unknown): string => { + if (!error) return "Health check failed"; + + const errorStr = typeof error === "string" ? error : JSON.stringify(error); + + // First, look for explicit "ErrorType: StatusCode" patterns + const directPatternMatch = errorStr.match(/(\w+Error):\s*(\d{3})/i); + if (directPatternMatch) { + return `${directPatternMatch[1]}: ${directPatternMatch[2]}`; + } + + // Look for error types and status codes separately, then combine them + const errorTypeMatch = errorStr.match( + /(AuthenticationError|RateLimitError|BadRequestError|InternalServerError|TimeoutError|NotFoundError|ForbiddenError|ServiceUnavailableError|BadGatewayError|ContentPolicyViolationError|\w+Error)/i, + ); + const statusCodeMatch = errorStr.match(/\b(400|401|403|404|408|429|500|502|503|504)\b/); + + if (errorTypeMatch && statusCodeMatch) { + return `${errorTypeMatch[1]}: ${statusCodeMatch[1]}`; + } + + // If we have a status code but no clear error type, map it + if (statusCodeMatch) { + const statusCode = statusCodeMatch[1]; + return `${STATUS_TO_ERROR[statusCode]}: ${statusCode}`; + } + + // If we have an error type but no status code, map error type to expected status code + if (errorTypeMatch) { + const errorType = errorTypeMatch[1]; + const mappedStatus = ERROR_TO_STATUS[errorType]; + if (mappedStatus) { + return `${errorType}: ${mappedStatus}`; + } + return errorType; + } + + // Check for specific error patterns from errorPatterns + for (const { pattern, replacement } of errorPatterns) { + if (pattern.test(errorStr)) { + return replacement; + } + } + + // Look for common error keywords and provide meaningful names with status codes + for (const { pattern, label } of KEYWORD_ERRORS) { + if (pattern.test(errorStr)) { + return label; + } + } + + // Fallback: clean up the error string and return first meaningful part + const cleaned = errorStr + .replace(/[\n\r]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + + // Try to get first meaningful sentence or phrase + const firstSentence = cleaned.split(/[.!?]/)[0]?.trim(); + if (firstSentence && firstSentence.length > 0) { + return truncate(firstSentence); + } + + return truncate(cleaned); +}; + +const toCheckedAtLabel = (checkedAt: string | null | undefined, fallback: string): string => { + if (!checkedAt) { + return fallback; + } + return new Date(checkedAt).toLocaleString(); +}; + +const toLastSuccessLabel = (checkData: LatestHealthCheck, fallback: string): string => { + if (checkData.status !== "healthy") { + return fallback; + } + return toCheckedAtLabel(checkData.checked_at, fallback); +}; + interface HealthCheckComponentProps { accessToken: string | null; modelData: any; @@ -27,15 +144,9 @@ interface HealthCheckComponentProps { 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; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + rowCount: number; } const HealthCheckComponent: React.FC = ({ @@ -46,14 +157,12 @@ const HealthCheckComponent: React.FC = ({ setSelectedModelId, teams, isLoading = false, - paginationMeta, - currentPage = 1, - pageSize = 50, - onPageChange, + pagination, + onPaginationChange, + rowCount, }) => { const [modelHealthStatuses, setModelHealthStatuses] = useState<{ [key: string]: HealthStatus }>({}); - const [selectedModelsForHealth, setSelectedModelsForHealth] = useState([]); - const [allModelsSelected, setAllModelsSelected] = useState(false); + const [rowSelection, setRowSelection] = useState({}); const [errorModalVisible, setErrorModalVisible] = useState(false); const [selectedErrorDetails, setSelectedErrorDetails] = useState<{ modelName: string; @@ -63,11 +172,9 @@ const HealthCheckComponent: React.FC = ({ const [successModalVisible, setSuccessModalVisible] = useState(false); const [selectedSuccessDetails, setSelectedSuccessDetails] = useState<{ modelName: string; - response: any; + response: unknown; } | null>(null); - const healthTableRef = useRef>(null); - // Initialize health statuses on component mount (keyed by model id) useEffect(() => { if (!accessToken || !modelData?.data) return; @@ -100,8 +207,9 @@ const HealthCheckComponent: React.FC = ({ latestHealthChecks.latest_health_checks && typeof latestHealthChecks.latest_health_checks === "object" ) { - Object.entries(latestHealthChecks.latest_health_checks).forEach(([modelId, checkData]: [string, any]) => { - if (!checkData) return; + Object.entries(latestHealthChecks.latest_health_checks).forEach(([modelId, rawCheck]) => { + if (!rawCheck) return; + const checkData = rawCheck as LatestHealthCheck; // Key is model_id from the backend (guaranteed by DB schema) const modelExists = modelData.data.some((m: any) => m.model_info?.id === modelId); @@ -111,13 +219,8 @@ const HealthCheckComponent: React.FC = ({ 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", + lastCheck: toCheckedAtLabel(checkData.checked_at, "None"), + lastSuccess: toLastSuccessLabel(checkData, "None"), loading: false, error: fullError ? extractMeaningfulError(fullError) : undefined, fullError: fullError, @@ -135,132 +238,73 @@ const HealthCheckComponent: React.FC = ({ initializeHealthStatuses(); }, [accessToken, modelData]); - // Helper function to extract meaningful error information - const extractMeaningfulError = (error: any): string => { - if (!error) return "Health check failed"; + const runIndividualHealthCheck = useCallback( + async (modelId: string) => { + if (!accessToken) return; - let errorStr = typeof error === "string" ? error : JSON.stringify(error); + setModelHealthStatuses((prev) => ({ + ...prev, + [modelId]: { + ...prev[modelId], + loading: true, + status: "checking", + }, + })); - // First, look for explicit "ErrorType: StatusCode" patterns - const directPatternMatch = errorStr.match(/(\w+Error):\s*(\d{3})/i); - if (directPatternMatch) { - return `${directPatternMatch[1]}: ${directPatternMatch[2]}`; - } + try { + const response = await individualModelHealthCheckCall(accessToken, modelId); + const currentTime = new Date().toLocaleString(); - // Look for error types and status codes separately, then combine them - const errorTypeMatch = errorStr.match( - /(AuthenticationError|RateLimitError|BadRequestError|InternalServerError|TimeoutError|NotFoundError|ForbiddenError|ServiceUnavailableError|BadGatewayError|ContentPolicyViolationError|\w+Error)/i, - ); - const statusCodeMatch = errorStr.match(/\b(400|401|403|404|408|429|500|502|503|504)\b/); + if (response.unhealthy_count > 0 && response.unhealthy_endpoints && response.unhealthy_endpoints.length > 0) { + const rawError = response.unhealthy_endpoints[0]?.error || "Health check failed"; + const errorMessage = extractMeaningfulError(rawError); + setModelHealthStatuses((prev) => ({ + ...prev, + [modelId]: { + status: "unhealthy", + lastCheck: currentTime, + lastSuccess: prev[modelId]?.lastSuccess || "None", + loading: false, + error: errorMessage, + fullError: rawError, + }, + })); + } else { + setModelHealthStatuses((prev) => ({ + ...prev, + [modelId]: { + status: "healthy", + lastCheck: currentTime, + lastSuccess: currentTime, + loading: false, + successResponse: response, + }, + })); + } - if (errorTypeMatch && statusCodeMatch) { - return `${errorTypeMatch[1]}: ${statusCodeMatch[1]}`; - } + try { + const latestHealthChecks = await latestHealthChecksCall(accessToken); + const checkData = latestHealthChecks.latest_health_checks?.[modelId] as LatestHealthCheck | undefined; - // If we have a status code but no clear error type, map it - if (statusCodeMatch) { - const statusCode = statusCodeMatch[1]; - const statusToError: { [key: string]: string } = { - "400": "BadRequestError", - "401": "AuthenticationError", - "403": "ForbiddenError", - "404": "NotFoundError", - "408": "TimeoutError", - "429": "RateLimitError", - "500": "InternalServerError", - "502": "BadGatewayError", - "503": "ServiceUnavailableError", - "504": "GatewayTimeoutError", - }; - return `${statusToError[statusCode]}: ${statusCode}`; - } - - // If we have an error type but no status code, map error type to expected status code - if (errorTypeMatch) { - const errorType = errorTypeMatch[1]; - const errorToStatus: { [key: string]: string } = { - AuthenticationError: "401", - RateLimitError: "429", - BadRequestError: "400", - InternalServerError: "500", - TimeoutError: "408", - NotFoundError: "404", - ForbiddenError: "403", - ServiceUnavailableError: "503", - BadGatewayError: "502", - GatewayTimeoutError: "504", - ContentPolicyViolationError: "400", - }; - - const mappedStatus = errorToStatus[errorType]; - if (mappedStatus) { - return `${errorType}: ${mappedStatus}`; - } - return errorType; - } - - // Check for specific error patterns from errorPatterns - for (const { pattern, replacement } of errorPatterns) { - if (pattern.test(errorStr)) { - return replacement; - } - } - - // Look for common error keywords and provide meaningful names with status codes - if (/missing.*api.*key|invalid.*key|unauthorized/i.test(errorStr)) { - return "AuthenticationError: 401"; - } - if (/rate.*limit|too.*many.*requests/i.test(errorStr)) { - return "RateLimitError: 429"; - } - if (/timeout|timed.*out/i.test(errorStr)) { - return "TimeoutError: 408"; - } - if (/not.*found/i.test(errorStr)) { - return "NotFoundError: 404"; - } - if (/forbidden|access.*denied/i.test(errorStr)) { - return "ForbiddenError: 403"; - } - if (/internal.*server.*error/i.test(errorStr)) { - return "InternalServerError: 500"; - } - - // Fallback: clean up the error string and return first meaningful part - const cleaned = errorStr - .replace(/[\n\r]+/g, " ") - .replace(/\s+/g, " ") - .trim(); - - // Try to get first meaningful sentence or phrase - const sentences = cleaned.split(/[.!?]/); - const firstSentence = sentences[0]?.trim(); - - if (firstSentence && firstSentence.length > 0) { - return firstSentence.length > 100 ? firstSentence.substring(0, 97) + "..." : firstSentence; - } - - return cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned; - }; - - const runIndividualHealthCheck = async (modelId: string) => { - if (!accessToken) return; - - setModelHealthStatuses((prev) => ({ - ...prev, - [modelId]: { - ...prev[modelId], - loading: true, - status: "checking", - }, - })); - - try { - const response = await individualModelHealthCheckCall(accessToken, modelId); - const currentTime = new Date().toLocaleString(); - - if (response.unhealthy_count > 0 && response.unhealthy_endpoints && response.unhealthy_endpoints.length > 0) { - const rawError = response.unhealthy_endpoints[0]?.error || "Health check failed"; + if (checkData) { + const fullError = checkData.error_message || undefined; + setModelHealthStatuses((prev) => ({ + ...prev, + [modelId]: { + status: checkData.status || prev[modelId]?.status || "unknown", + lastCheck: toCheckedAtLabel(checkData.checked_at, prev[modelId]?.lastCheck || "None"), + lastSuccess: toLastSuccessLabel(checkData, prev[modelId]?.lastSuccess || "None"), + loading: false, + error: fullError ? extractMeaningfulError(fullError) : prev[modelId]?.error, + fullError: fullError || prev[modelId]?.fullError, + successResponse: checkData.status === "healthy" ? checkData : prev[modelId]?.successResponse, + }, + })); + } + } catch (dbError) {} + } catch (error) { + const currentTime = new Date().toLocaleString(); + const rawError = error instanceof Error ? error.message : String(error); const errorMessage = extractMeaningfulError(rawError); setModelHealthStatuses((prev) => ({ ...prev, @@ -273,66 +317,18 @@ const HealthCheckComponent: React.FC = ({ fullError: rawError, }, })); - } else { - setModelHealthStatuses((prev) => ({ - ...prev, - [modelId]: { - status: "healthy", - lastCheck: currentTime, - lastSuccess: currentTime, - loading: false, - successResponse: response, - }, - })); } + }, + [accessToken], + ); - try { - const latestHealthChecks = await latestHealthChecksCall(accessToken); - const checkData = latestHealthChecks.latest_health_checks?.[modelId]; - - if (checkData) { - const fullError = checkData.error_message || undefined; - setModelHealthStatuses((prev) => ({ - ...prev, - [modelId]: { - status: checkData.status || prev[modelId]?.status || "unknown", - lastCheck: checkData.checked_at - ? new Date(checkData.checked_at).toLocaleString() - : prev[modelId]?.lastCheck || "None", - lastSuccess: - checkData.status === "healthy" - ? checkData.checked_at - ? new Date(checkData.checked_at).toLocaleString() - : prev[modelId]?.lastSuccess || "None" - : prev[modelId]?.lastSuccess || "None", - loading: false, - error: fullError ? extractMeaningfulError(fullError) : prev[modelId]?.error, - fullError: fullError || prev[modelId]?.fullError, - successResponse: checkData.status === "healthy" ? checkData : prev[modelId]?.successResponse, - }, - })); - } - } catch (dbError) {} - } catch (error) { - const currentTime = new Date().toLocaleString(); - const rawError = error instanceof Error ? error.message : String(error); - const errorMessage = extractMeaningfulError(rawError); - setModelHealthStatuses((prev) => ({ - ...prev, - [modelId]: { - status: "unhealthy", - lastCheck: currentTime, - lastSuccess: prev[modelId]?.lastSuccess || "None", - loading: false, - error: errorMessage, - fullError: rawError, - }, - })); - } - }; + const selectedModelIds = useMemo( + () => Object.keys(rowSelection).filter((modelId) => rowSelection[modelId]), + [rowSelection], + ); const runAllHealthChecks = async () => { - const modelsToCheck = selectedModelsForHealth.length > 0 ? selectedModelsForHealth : all_models_on_proxy; + const modelsToCheck = selectedModelIds.length > 0 ? selectedModelIds : all_models_on_proxy; const loadingStatuses = modelsToCheck.reduce( (acc, modelId) => { @@ -348,14 +344,11 @@ const HealthCheckComponent: React.FC = ({ setModelHealthStatuses((prev) => ({ ...prev, ...loadingStatuses })); - const healthCheckResults: { [key: string]: any } = {}; - const healthCheckPromises = modelsToCheck.map(async (modelId) => { if (!accessToken) return; try { const response = await individualModelHealthCheckCall(accessToken, modelId); - healthCheckResults[modelId] = response; const currentTime = new Date().toLocaleString(); if (response.unhealthy_count > 0 && response.unhealthy_endpoints && response.unhealthy_endpoints.length > 0) { @@ -410,32 +403,26 @@ const HealthCheckComponent: React.FC = ({ const latestHealthChecks = await latestHealthChecksCall(accessToken); if (latestHealthChecks.latest_health_checks) { - Object.entries(latestHealthChecks.latest_health_checks).forEach(([modelId, checkData]: [string, any]) => { - if (modelsToCheck.includes(modelId) && checkData) { - const fullError = checkData.error_message || undefined; - setModelHealthStatuses((prev) => { - const currentStatus = prev[modelId]; - return { - ...prev, - [modelId]: { - status: checkData.status || currentStatus?.status || "unknown", - lastCheck: checkData.checked_at - ? new Date(checkData.checked_at).toLocaleString() - : currentStatus?.lastCheck || "None", - lastSuccess: - checkData.status === "healthy" - ? checkData.checked_at - ? new Date(checkData.checked_at).toLocaleString() - : currentStatus?.lastSuccess || "None" - : currentStatus?.lastSuccess || "None", - loading: false, - error: fullError ? extractMeaningfulError(fullError) : currentStatus?.error, - fullError: fullError || currentStatus?.fullError, - successResponse: checkData.status === "healthy" ? checkData : currentStatus?.successResponse, - }, - }; - }); - } + Object.entries(latestHealthChecks.latest_health_checks).forEach(([modelId, rawCheck]) => { + if (!modelsToCheck.includes(modelId) || !rawCheck) return; + const checkData = rawCheck as LatestHealthCheck; + const fullError = checkData.error_message || undefined; + + setModelHealthStatuses((prev) => { + const currentStatus = prev[modelId]; + return { + ...prev, + [modelId]: { + status: checkData.status || currentStatus?.status || "unknown", + lastCheck: toCheckedAtLabel(checkData.checked_at, currentStatus?.lastCheck || "None"), + lastSuccess: toLastSuccessLabel(checkData, currentStatus?.lastSuccess || "None"), + loading: false, + error: fullError ? extractMeaningfulError(fullError) : currentStatus?.error, + fullError: fullError || currentStatus?.fullError, + successResponse: checkData.status === "healthy" ? checkData : currentStatus?.successResponse, + }, + }; + }); }); } } catch (dbError) { @@ -443,170 +430,116 @@ const HealthCheckComponent: React.FC = ({ } }; - const handleModelSelection = (modelId: string, checked: boolean) => { - if (checked) { - setSelectedModelsForHealth((prev) => [...prev, modelId]); - } else { - setSelectedModelsForHealth((prev) => prev.filter((id) => id !== modelId)); - setAllModelsSelected(false); - } - }; + // Changing the page swaps the underlying rows, so a carried-over selection would + // point at models that are no longer on screen. + const handlePaginationChange = useCallback>( + (updaterOrValue) => { + setRowSelection({}); + setModelHealthStatuses({}); + onPaginationChange(updaterOrValue); + }, + [onPaginationChange], + ); - const handleSelectAll = (checked: boolean) => { - setAllModelsSelected(checked); - if (checked) { - setSelectedModelsForHealth(all_models_on_proxy); - } else { - setSelectedModelsForHealth([]); - } - }; - - const handlePageChange = (page: number) => { - setSelectedModelsForHealth([]); - setAllModelsSelected(false); - setModelHealthStatuses({}); - onPageChange?.(page); - }; - - const showErrorModal = (modelName: string, cleanedError: string, fullError: string) => { - setSelectedErrorDetails({ - modelName, - cleanedError, - fullError, - }); + const showErrorModal = useCallback((modelName: string, cleanedError: string, fullError: string) => { + setSelectedErrorDetails({ modelName, cleanedError, fullError }); setErrorModalVisible(true); - }; + }, []); const closeErrorModal = () => { setErrorModalVisible(false); setSelectedErrorDetails(null); }; - const showSuccessModal = (modelName: string, response: any) => { - setSelectedSuccessDetails({ - modelName, - response, - }); + const showSuccessModal = useCallback((modelName: string, response: unknown) => { + setSelectedSuccessDetails({ modelName, response }); setSuccessModalVisible(true); - }; + }, []); const closeSuccessModal = () => { setSuccessModalVisible(false); 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 healthTableData = useMemo( + () => + (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, + }; + }), + [modelData, modelHealthStatuses], + ); - 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; + const isPartialSelection = selectedModelIds.length > 0 && selectedModelIds.length < all_models_on_proxy.length; + const anyCheckRunning = Object.values(modelHealthStatuses).some((status) => status.loading); return (
-
+
- Model Health Status - +

Model Health Status

+

Run health checks on individual models to verify they are working correctly - +

- {selectedModelsForHealth.length > 0 && ( - )}
-
- {shouldShowPagination && ( -
- - {totalCount > 0 - ? `Showing ${resultsStart} - ${resultsEnd} of ${totalCount} results` - : "Showing 0 results"} - - -
- - -
-
- )} - -
+ {/* Error Modal */} = ({ {selectedErrorDetails && (
- Error: -
- {selectedErrorDetails.cleanedError} + Error: +
+ {selectedErrorDetails.cleanedError}
- Full Error Details: -
-
{selectedErrorDetails.fullError}
+ Full Error Details: +
+
{selectedErrorDetails.fullError}
@@ -656,16 +589,16 @@ const HealthCheckComponent: React.FC = ({ {selectedSuccessDetails && (
- Status: -
- Health check passed successfully + Status: +
+ Health check passed successfully
- Response Details: -
-
+              Response Details:
+              
+
                   {JSON.stringify(selectedSuccessDetails.response, null, 2)}
                 
diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.test.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.test.tsx new file mode 100644 index 00000000000..fc5606d46b4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.test.tsx @@ -0,0 +1,175 @@ +/* @vitest-environment jsdom */ +import type { PaginationState, RowSelectionState } from "@tanstack/react-table"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { HealthChecksTable } from "./HealthChecksTable"; +import type { HealthCheckData, HealthStatus } from "./HealthChecksTableColumns"; + +const makeRow = (overrides: Partial & { id: string }): HealthCheckData => { + const { id, ...rest } = overrides; + return { + model_name: `model-${id}`, + model_info: { id }, + health_status: "none", + last_check: "None", + last_success: "None", + health_loading: false, + ...rest, + }; +}; + +interface HarnessProps { + data: HealthCheckData[]; + modelHealthStatuses?: Record; + onRunHealthCheck?: (modelId: string) => void; + onSelectModel?: (modelId: string) => void; +} + +function Harness({ data, modelHealthStatuses = {}, onRunHealthCheck = vi.fn(), onSelectModel }: HarnessProps) { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 }); + const [rowSelection, setRowSelection] = useState({}); + + return ( + model.model_name} + onRunHealthCheck={onRunHealthCheck} + onShowError={vi.fn()} + onShowSuccess={vi.fn()} + onSelectModel={onSelectModel} + /> + ); +} + +/** Row order by model id, read off the per-row selection checkbox (keyed by getRowId). */ +const rowIds = (): string[] => + screen + .getAllByRole("row") + .slice(1) + .map((row) => row.querySelector('[data-testid^="datatable-select-row-"]')) + .filter((node): node is Element => node !== null) + .map((node) => (node.getAttribute("data-testid") ?? "").replace("datatable-select-row-", "")); + +describe("HealthChecksTable client sorting", () => { + it("orders health status healthy > checking > unknown > unhealthy", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByTestId("sort-header-health_status")); + + expect(rowIds()).toEqual(["healthy-row", "checking-row", "unhealthy-row", "weird-row"]); + }); + + it("floats in-progress checks to the top, sinks never-checked, and sorts real checks most-recent-first", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByTestId("sort-header-last_check")); + + expect(rowIds()).toEqual(["in-progress", "newer", "older", "never"]); + }); + + // "Never succeeded" is ranked below "None" -- both sink, but not to the same slot. + it("sinks None below real successes and Never succeeded below None", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByTestId("sort-header-last_success")); + + expect(rowIds()).toEqual(["newer", "older", "none", "never"]); + }); +}); + +describe("HealthChecksTable rows", () => { + it("renders the live checking cell while a row is loading and disables its run button", () => { + render(); + + expect(screen.getByText("Checking...")).toBeInTheDocument(); + expect(screen.getByTestId("run-health-check-btn")).toBeDisabled(); + }); + + it("runs a health check for the row's model id", async () => { + const user = userEvent.setup(); + const onRunHealthCheck = vi.fn(); + render(); + + await user.click(screen.getByTestId("run-health-check-btn")); + + expect(onRunHealthCheck).toHaveBeenCalledWith("deployment-9"); + }); + + it("opens the model detail from the identity cell", async () => { + const user = userEvent.setup(); + const onSelectModel = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /deployment-9/ })); + + expect(onSelectModel).toHaveBeenCalledWith("deployment-9"); + }); + + it("surfaces the error detail button only when a fuller error exists", () => { + const { rerender } = render( + , + ); + expect(screen.queryByTestId("view-health-error-btn")).not.toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByTestId("view-health-error-btn")).toBeInTheDocument(); + }); + + it("renders the empty state when the page has no models", () => { + render(); + + expect(screen.getByText("No models found")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.tsx new file mode 100644 index 00000000000..5c0470315bb --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTable.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { OnChangeFn, PaginationState, RowSelectionState, SortingState } from "@tanstack/react-table"; +import { HeartPulse } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { Team } from "@/components/key_team_helpers/key_list"; +import { DataTable } from "@/components/shared/DataTable"; + +import { getHealthChecksTableColumns, type HealthCheckData, type HealthStatus } from "./HealthChecksTableColumns"; + +interface HealthChecksTableProps { + data: HealthCheckData[]; + rowCount: number; + isLoading: boolean; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + rowSelection: RowSelectionState; + onRowSelectionChange: OnChangeFn; + modelHealthStatuses: Record; + getDisplayModelName: (model: HealthCheckData) => string; + onRunHealthCheck: (modelId: string) => void; + onShowError: (modelName: string, cleanedError: string, fullError: string) => void; + onShowSuccess: (modelName: string, response: unknown) => void; + onSelectModel?: (modelId: string) => void; + teams?: Team[] | null; +} + +function EmptyState() { + return ( +
+
+ +
+
No models found
+
Models added to this proxy will show their health here.
+
+ ); +} + +export function HealthChecksTable({ + data, + rowCount, + isLoading, + pagination, + onPaginationChange, + rowSelection, + onRowSelectionChange, + modelHealthStatuses, + getDisplayModelName, + onRunHealthCheck, + onShowError, + onShowSuccess, + onSelectModel, + teams, +}: HealthChecksTableProps) { + const [sorting, setSorting] = useState([]); + + const columns = useMemo(() => { + const columnDeps = { + modelHealthStatuses, + getDisplayModelName, + onRunHealthCheck, + onShowError, + onShowSuccess, + onSelectModel, + teams, + }; + return getHealthChecksTableColumns(columnDeps); + }, [modelHealthStatuses, getDisplayModelName, onRunHealthCheck, onShowError, onShowSuccess, onSelectModel, teams]); + + return ( + row.model_info?.id ?? String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + paginationMode="server" + pagination={pagination} + onPaginationChange={onPaginationChange} + rowCount={rowCount} + rowSelection={rowSelection} + onRowSelectionChange={onRowSelectionChange} + isLoading={isLoading} + loadingMessage="Loading models…" + noDataMessage={} + size="compact" + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTableColumns.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTableColumns.tsx new file mode 100644 index 00000000000..95857488b20 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthChecksTableColumns.tsx @@ -0,0 +1,413 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Info, Play, RefreshCw } from "lucide-react"; + +import { Team } from "@/components/key_team_helpers/key_list"; +import { createSelectionColumn, DataTableSortHeader } from "@/components/shared/DataTable"; +import { IdentityCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells"; +import { cn } from "@/lib/cva.config"; + +export interface HealthStatus { + status: string; + lastCheck: string; + lastSuccess?: string; + loading: boolean; + error?: string; + fullError?: string; + successResponse?: unknown; +} + +export interface HealthCheckData { + model_name: string; + model_info: { + id: string; + created_at?: string; + team_id?: string; + }; + provider?: string; + litellm_model_name?: string; + health_status: string; + last_check: string; + last_success: string; + health_loading: boolean; + health_error?: string; + health_full_error?: string; +} + +const HEALTH_STATUS_TONES: Record = { + healthy: "success", + unhealthy: "error", + checking: "info", + none: "neutral", +}; + +// healthy > checking > unknown > unhealthy, matching the legacy health table ordering. +const HEALTH_STATUS_ORDER: Record = { healthy: 0, checking: 1, unknown: 2, unhealthy: 3 }; + +const NEVER_CHECKED = "Never checked"; +const CHECK_IN_PROGRESS = "Check in progress..."; +const NEVER_SUCCEEDED = "Never succeeded"; +const NONE = "None"; + +function HealthStatusBadge({ status }: { status: string }) { + const tone = HEALTH_STATUS_TONES[status]; + if (!tone) { + return ; + } + return ; +} + +function DotPulse({ className }: { className: string }) { + return ( +
+
+
+
+
+ ); +} + +function DetailButton({ + label, + onClick, + className, + testId, +}: { + label: string; + onClick: () => void; + className: string; + testId: string; +}) { + return ( + + ); +} + +function runButtonLabel(isLoading: boolean, hasExistingStatus: boolean): string { + if (isLoading) { + return "Checking..."; + } + if (hasExistingStatus) { + return "Re-run Health Check"; + } + return "Run Health Check"; +} + +function RunButtonIcon({ isLoading, hasExistingStatus }: { isLoading: boolean; hasExistingStatus: boolean }) { + if (isLoading) { + return ; + } + if (hasExistingStatus) { + return ; + } + return ; +} + +function RunHealthCheckButton({ + model, + onRunHealthCheck, +}: { + model: HealthCheckData; + onRunHealthCheck: (modelId: string) => void; +}) { + const isLoading = model.health_loading; + const hasExistingStatus = Boolean(model.health_status) && model.health_status !== "none"; + const label = runButtonLabel(isLoading, hasExistingStatus); + + return ( + + ); +} + +function compareDatesDesc(rawA: string, rawB: string): number { + const dateA = new Date(rawA).getTime(); + const dateB = new Date(rawB).getTime(); + if (isNaN(dateA) && isNaN(dateB)) { + return 0; + } + if (isNaN(dateA)) { + return 1; + } + if (isNaN(dateB)) { + return -1; + } + return dateB - dateA; +} + +/** + * Ranks the sentinel strings the health table renders in place of a real timestamp. + * `bottom` and `top` are checked in order, so an earlier sentinel outranks a later one + * (e.g. "Never succeeded" sorts below "None"). + */ +function compareSentinels( + rawA: string, + rawB: string, + bottom: readonly string[], + top: readonly string[], +): number | null { + for (const sentinel of bottom) { + if (rawA === sentinel && rawB === sentinel) { + return 0; + } + if (rawA === sentinel) { + return 1; + } + if (rawB === sentinel) { + return -1; + } + } + + for (const sentinel of top) { + if (rawA === sentinel && rawB === sentinel) { + return 0; + } + if (rawA === sentinel) { + return -1; + } + if (rawB === sentinel) { + return 1; + } + } + + return null; +} + +export interface HealthChecksTableColumnsDeps { + modelHealthStatuses: Record; + getDisplayModelName: (model: HealthCheckData) => string; + onRunHealthCheck: (modelId: string) => void; + onShowError: (modelName: string, cleanedError: string, fullError: string) => void; + onShowSuccess: (modelName: string, response: unknown) => void; + onSelectModel?: (modelId: string) => void; + teams?: Team[] | null; +} + +export const getHealthChecksTableColumns = ({ + modelHealthStatuses, + getDisplayModelName, + onRunHealthCheck, + onShowError, + onShowSuccess, + onSelectModel, + teams, +}: HealthChecksTableColumnsDeps): ColumnDef[] => [ + createSelectionColumn({ + rowAriaLabel: (row) => `Select ${row.original.model_info?.id ?? row.original.model_name}`, + }), + { + id: "model_id", + accessorFn: (row) => row.model_info?.id ?? "", + meta: { title: "Model ID" }, + header: ({ column }) => , + size: 220, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const modelId = row.original.model_info?.id ?? ""; + return ( + onSelectModel(modelId) : undefined} + /> + ); + }, + }, + { + id: "model_name", + accessorKey: "model_name", + meta: { title: "Model Name" }, + header: ({ column }) => , + size: 200, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const displayName = getDisplayModelName(row.original) || row.original.model_name; + return ( + + {displayName} + + ); + }, + }, + { + id: "team_id", + accessorFn: (row) => row.model_info?.team_id ?? "", + meta: { title: "Team Alias" }, + header: ({ column }) => , + size: 160, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const teamId = row.original.model_info?.team_id; + if (!teamId) { + return -; + } + const teamAlias = teams?.find((team) => team.team_id === teamId)?.team_alias || teamId; + return ( + + {teamAlias} + + ); + }, + }, + { + id: "health_status", + accessorKey: "health_status", + meta: { title: "Health Status", skeleton: "badge" }, + header: ({ column }) => , + size: 170, + enableSorting: true, + sortingFn: (rowA, rowB) => { + const statusA = (rowA.getValue("health_status") as string) || "unknown"; + const statusB = (rowB.getValue("health_status") as string) || "unknown"; + const orderA = HEALTH_STATUS_ORDER[statusA] ?? 4; + const orderB = HEALTH_STATUS_ORDER[statusB] ?? 4; + return orderA - orderB; + }, + cell: ({ row }) => { + const model = row.original; + + if (model.health_loading) { + return ( +
+ + Checking... +
+ ); + } + + const modelId = model.model_info?.id ?? ""; + const displayName = getDisplayModelName(model) || model.model_name; + const successResponse = modelHealthStatuses[modelId]?.successResponse; + const hasSuccessResponse = model.health_status === "healthy" && successResponse !== undefined; + + return ( +
+ + {hasSuccessResponse && ( + onShowSuccess(displayName, successResponse)} + /> + )} +
+ ); + }, + }, + { + id: "health_error", + accessorKey: "health_error", + meta: { title: "Error Details" }, + header: "Error Details", + size: 240, + enableSorting: false, + cell: ({ row }) => { + const model = row.original; + const modelId = model.model_info?.id ?? ""; + const healthStatus = modelHealthStatuses[modelId]; + + if (!healthStatus?.error) { + return No errors; + } + + const cleanedError = healthStatus.error; + const fullError = healthStatus.fullError || healthStatus.error; + const displayName = getDisplayModelName(model) || model.model_name; + + return ( +
+ + {cleanedError} + + {fullError !== cleanedError && ( + onShowError(displayName, cleanedError, fullError)} + /> + )} +
+ ); + }, + }, + { + id: "last_check", + accessorKey: "last_check", + meta: { title: "Last Check" }, + header: ({ column }) => , + size: 170, + enableSorting: true, + sortingFn: (rowA, rowB) => { + const rawA = (rowA.getValue("last_check") as string) || NEVER_CHECKED; + const rawB = (rowB.getValue("last_check") as string) || NEVER_CHECKED; + const sentinel = compareSentinels(rawA, rawB, [NEVER_CHECKED], [CHECK_IN_PROGRESS]); + return sentinel ?? compareDatesDesc(rawA, rawB); + }, + cell: ({ row }) => ( + + {row.original.health_loading ? CHECK_IN_PROGRESS : row.original.last_check} + + ), + }, + { + id: "last_success", + accessorKey: "last_success", + meta: { title: "Last Success" }, + header: ({ column }) => , + size: 170, + enableSorting: true, + sortingFn: (rowA, rowB) => { + const rawA = (rowA.getValue("last_success") as string) || NEVER_SUCCEEDED; + const rawB = (rowB.getValue("last_success") as string) || NEVER_SUCCEEDED; + const sentinel = compareSentinels(rawA, rawB, [NEVER_SUCCEEDED, NONE], []); + return sentinel ?? compareDatesDesc(rawA, rawB); + }, + cell: ({ row }) => { + const modelId = row.original.model_info?.id ?? ""; + const lastSuccess = modelHealthStatuses[modelId]?.lastSuccess || NONE; + return {lastSuccess}; + }, + }, + { + id: "actions", + meta: { title: "Actions", className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 80, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx b/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx deleted file mode 100644 index 33c97236f97..00000000000 --- a/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import { ColumnDef } from "@tanstack/react-table"; -import { Tooltip, Checkbox } from "antd"; -import { Text } from "@tremor/react"; -import { InformationCircleIcon, PlayIcon, RefreshIcon } from "@heroicons/react/outline"; -import { Team } from "@/components/key_team_helpers/key_list"; -import { IdCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells"; - -interface HealthCheckData { - model_name: string; - model_info: { - id: string; - created_at?: string; - team_id?: string; - }; - provider?: string; - litellm_model_name?: string; - health_status: string; - last_check: string; - last_success: string; - health_loading: boolean; - health_error?: string; - health_full_error?: string; -} - -const HEALTH_STATUS_TONES: Record = { - healthy: "success", - unhealthy: "error", - checking: "info", - none: "neutral", -}; - -const healthStatusBadge = (status: string): JSX.Element => { - const tone = HEALTH_STATUS_TONES[status]; - return tone ? : ; -}; - -interface HealthStatus { - status: string; - lastCheck: string; - lastSuccess?: string; - loading: boolean; - error?: string; - fullError?: string; - successResponse?: any; -} - -export const healthCheckColumns = ( - modelHealthStatuses: { [key: string]: HealthStatus }, - selectedModelsForHealth: string[], - allModelsSelected: boolean, - handleModelSelection: (modelId: string, checked: boolean) => void, - handleSelectAll: (checked: boolean) => void, - runIndividualHealthCheck: (modelId: string) => void, - getDisplayModelName: (model: any) => string, - showErrorModal?: (modelName: string, cleanedError: string, fullError: string) => void, - showSuccessModal?: (modelName: string, response: any) => void, - setSelectedModelId?: (modelId: string) => void, - teams?: Team[] | null, -): ColumnDef[] => [ - { - header: () => ( -
- 0 && !allModelsSelected} - onChange={(e) => handleSelectAll(e.target.checked)} - onClick={(e) => e.stopPropagation()} - /> - Model ID -
- ), - accessorKey: "model_info.id", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const model = row.original; - const modelId = model.model_info?.id ?? ""; - const isSelected = selectedModelsForHealth.includes(modelId); - - return ( -
- handleModelSelection(modelId, e.target.checked)} - onClick={(e) => e.stopPropagation()} - /> - -
- ); - }, - }, - { - header: "Model Name", - accessorKey: "model_name", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const model = row.original; - const displayName = getDisplayModelName(model) || model.model_name; - - return ( -
- -
{displayName}
-
-
- ); - }, - }, - { - header: "Team Alias", - accessorKey: "model_info.team_id", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const model = row.original; - const teamId = model.model_info?.team_id; - - if (!teamId) { - return -; - } - - const team = teams?.find((t) => t.team_id === teamId); - const teamAlias = team?.team_alias || teamId; - - return ( -
- -
{teamAlias}
-
-
- ); - }, - }, - { - header: "Health Status", - accessorKey: "health_status", - enableSorting: true, - sortingFn: (rowA, rowB, columnId) => { - const statusA = (rowA.getValue("health_status") as string) || "unknown"; - const statusB = (rowB.getValue("health_status") as string) || "unknown"; - - // Define sorting order: healthy > checking > unknown > unhealthy - const statusOrder = { healthy: 0, checking: 1, unknown: 2, unhealthy: 3 }; - const orderA = statusOrder[statusA as keyof typeof statusOrder] ?? 4; - const orderB = statusOrder[statusB as keyof typeof statusOrder] ?? 4; - - return orderA - orderB; - }, - cell: ({ row }) => { - const model = row.original; - const healthStatus = { - status: model.health_status, - loading: model.health_loading, - error: model.health_error, - }; - - if (healthStatus.loading) { - return ( -
-
-
-
-
-
- Checking... -
- ); - } - - const modelId = model.model_info?.id ?? ""; - const displayName = getDisplayModelName(model) || model.model_name; - const hasSuccessResponse = healthStatus.status === "healthy" && modelHealthStatuses[modelId]?.successResponse; - - return ( -
- {healthStatusBadge(healthStatus.status)} - {hasSuccessResponse && showSuccessModal && ( - - - - )} -
- ); - }, - }, - { - header: "Error Details", - accessorKey: "health_error", - enableSorting: false, - cell: ({ row }) => { - const model = row.original; - const modelId = model.model_info?.id ?? ""; - const displayName = getDisplayModelName(model) || model.model_name; - const healthStatus = modelHealthStatuses[modelId]; - - if (!healthStatus?.error) { - return No errors; - } - - const cleanedError = healthStatus.error; - const fullError = healthStatus.fullError || healthStatus.error; - - return ( -
-
- - {cleanedError} - -
- {showErrorModal && fullError !== cleanedError && ( - - - - )} -
- ); - }, - }, - { - header: "Last Check", - accessorKey: "last_check", - enableSorting: true, - sortingFn: (rowA, rowB, columnId) => { - const lastCheckA = (rowA.getValue("last_check") as string) || "Never checked"; - const lastCheckB = (rowB.getValue("last_check") as string) || "Never checked"; - - // Handle special cases - if (lastCheckA === "Never checked" && lastCheckB === "Never checked") return 0; - if (lastCheckA === "Never checked") return 1; // Never checked goes to bottom - if (lastCheckB === "Never checked") return -1; - if (lastCheckA === "Check in progress..." && lastCheckB === "Check in progress...") return 0; - if (lastCheckA === "Check in progress...") return -1; // In progress goes to top - if (lastCheckB === "Check in progress...") return 1; - - // Parse dates for comparison - const dateA = new Date(lastCheckA); - const dateB = new Date(lastCheckB); - - // If dates are invalid, treat as never checked - if (isNaN(dateA.getTime()) && isNaN(dateB.getTime())) return 0; - if (isNaN(dateA.getTime())) return 1; - if (isNaN(dateB.getTime())) return -1; - - // Sort by date (most recent first) - return dateB.getTime() - dateA.getTime(); - }, - cell: ({ row }) => { - const model = row.original; - - return ( - - {model.health_loading ? "Check in progress..." : model.last_check} - - ); - }, - }, - { - header: "Last Success", - accessorKey: "last_success", - enableSorting: true, - sortingFn: (rowA, rowB, columnId) => { - const lastSuccessA = (rowA.getValue("last_success") as string) || "Never succeeded"; - const lastSuccessB = (rowB.getValue("last_success") as string) || "Never succeeded"; - - // Handle special cases - if (lastSuccessA === "Never succeeded" && lastSuccessB === "Never succeeded") return 0; - if (lastSuccessA === "Never succeeded") return 1; // Never succeeded goes to bottom - if (lastSuccessB === "Never succeeded") return -1; - if (lastSuccessA === "None" && lastSuccessB === "None") return 0; - if (lastSuccessA === "None") return 1; // None goes to bottom - if (lastSuccessB === "None") return -1; - - // Parse dates for comparison - const dateA = new Date(lastSuccessA); - const dateB = new Date(lastSuccessB); - - // If dates are invalid, treat as never succeeded - if (isNaN(dateA.getTime()) && isNaN(dateB.getTime())) return 0; - if (isNaN(dateA.getTime())) return 1; - if (isNaN(dateB.getTime())) return -1; - - // Sort by date (most recent first) - return dateB.getTime() - dateA.getTime(); - }, - cell: ({ row }) => { - const model = row.original; - const modelId = model.model_info?.id ?? ""; - const healthStatus = modelHealthStatuses[modelId]; - const lastSuccess = healthStatus?.lastSuccess || "None"; - - return {lastSuccess}; - }, - }, - { - header: "Actions", - id: "actions", - cell: ({ row }) => { - const model = row.original; - const modelId = model.model_info?.id ?? ""; - - const hasExistingStatus = model.health_status && model.health_status !== "none"; - const tooltipText = model.health_loading - ? "Checking..." - : hasExistingStatus - ? "Re-run Health Check" - : "Run Health Check"; - - return ( - - - - ); - }, - enableSorting: false, - }, -]; diff --git a/ui/litellm-dashboard/src/components/model_dashboard/table.tsx b/ui/litellm-dashboard/src/components/model_dashboard/table.tsx deleted file mode 100644 index cd451af31e4..00000000000 --- a/ui/litellm-dashboard/src/components/model_dashboard/table.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { - ColumnDef, - flexRender, - getCoreRowModel, - getSortedRowModel, - 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 { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline"; - -// Extend the column meta type to include className -declare module "@tanstack/react-table" { - interface ColumnMeta { - className?: string; - } -} - -interface ModelDataTableProps { - data: TData[]; - columns: ColumnDef[]; - isLoading?: boolean; - defaultSorting?: SortingState; - pagination?: PaginationState; - onPaginationChange?: OnChangeFn; - enablePagination?: boolean; - onRowClick?: (row: TData) => void; -} - -export function ModelDataTable({ - data = [], - columns, - isLoading = false, - defaultSorting = [], - pagination, - onPaginationChange, - enablePagination = false, - onRowClick, -}: ModelDataTableProps) { - const [sorting, setSorting] = React.useState(defaultSorting); - 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: setSorting, - onColumnSizingChange: setColumnSizing, - onColumnVisibilityChange: setColumnVisibility, - ...(enablePagination && onPaginationChange ? { onPaginationChange } : {}), - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - ...(enablePagination ? { getPaginationRowModel: getPaginationRowModel() } : {}), - enableSorting: true, - enableColumnResizing: true, - 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() && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} -
- {header.column.getCanResize() && ( -
- )} - - ))} - - ))} - - - {isLoading ? ( - - -
-

🚅 Loading models...

-
-
-
- ) : tableInstance.getRowModel().rows.length > 0 ? ( - tableInstance.getRowModel().rows.map((row) => ( - onRowClick?.(row.original)} - className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No models found

-
-
-
- )} -
-
-
-
-
- ); -} From a3248c6be80e83d8c6e218edbed2e14d3f2608ff Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 21 Jul 2026 16:56:40 -0700 Subject: [PATCH 2/5] fix(ui): restore guardrail_info_helpers exports in GuardrailsPanel test mock (#34197) The test replaced the whole ./guardrail_info_helpers module with a factory returning only getGuardrailLogoAndName, so guardrailLogoMap became undefined. guardrail_garden_data.ts indexes that map at module scope and is reachable from the panel via guardrail_garden.tsx, so the file failed to collect and the suite never ran. Spread the real module and override only the stubbed function. Also cover the delete flow, which is the only consumer of the stubbed helper in this component; the mocked table already rendered a delete button that no test clicked. --- .../_components/GuardrailsPanel.test.tsx | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx index 2ad332ae9ed..29655e5910a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx @@ -1,7 +1,7 @@ -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import GuardrailsPanel from "./GuardrailsPanel"; -import { getGuardrailsList } from "@/components/networking"; +import { getGuardrailsList, deleteGuardrailCall } from "@/components/networking"; vi.mock("@/components/networking", () => ({ getGuardrailsList: vi.fn(), @@ -48,7 +48,8 @@ vi.mock("@/utils/roles", () => ({ isAdminRole: vi.fn((role: string) => role === "admin"), })); -vi.mock("./guardrail_info_helpers", () => ({ +vi.mock("./guardrail_info_helpers", async (importOriginal) => ({ + ...(await importOriginal()), getGuardrailLogoAndName: vi.fn(() => ({ logo: null, displayName: "Test Provider", @@ -78,6 +79,7 @@ describe("GuardrailsPanel", () => { }; const mockGetGuardrailsList = vi.mocked(getGuardrailsList); + const mockDeleteGuardrailCall = vi.mocked(deleteGuardrailCall); beforeEach(() => { vi.clearAllMocks(); @@ -107,4 +109,35 @@ describe("GuardrailsPanel", () => { fireEvent.click(screen.getByText("Guardrails")); expect(screen.getByText("Add New Guardrail")).toBeInTheDocument(); }); + + it("should delete the clicked guardrail after confirming in the modal", async () => { + render(); + fireEvent.click(screen.getByText("Guardrails")); + + fireEvent.click(await screen.findByTestId("delete-button")); + + const modal = within(await screen.findByRole("dialog")); + expect(modal.getByText("Delete Guardrail")).toBeInTheDocument(); + expect(modal.getByText("test-guardrail-1")).toBeInTheDocument(); + expect(modal.getByText("Test Provider")).toBeInTheDocument(); + + fireEvent.click(modal.getByRole("button", { name: "Delete" })); + + await waitFor(() => { + expect(mockDeleteGuardrailCall).toHaveBeenCalledWith("test-token", "test-guardrail-1"); + }); + expect(mockGetGuardrailsList).toHaveBeenCalledTimes(2); + }); + + it("should not delete anything when the modal is cancelled", async () => { + render(); + fireEvent.click(screen.getByText("Guardrails")); + + fireEvent.click(await screen.findByTestId("delete-button")); + const modal = within(await screen.findByRole("dialog")); + + fireEvent.click(modal.getByRole("button", { name: "Cancel" })); + + expect(mockDeleteGuardrailCall).not.toHaveBeenCalled(); + }); }); From 20a4666ec67f3882771cffbfb2f3a9d3af10e2f2 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 21 Jul 2026 17:03:31 -0700 Subject: [PATCH 3/5] chore(ui): bump sharp to 0.35.x via npm override (#34193) sharp reaches the dashboard only as an optional dependency of next, which pins it to ^0.34.5. A caret range on a 0.x version cannot resolve past 0.34.x, and every stable next through 16.2.11 still declares that same range, so there is no transitive path to the 0.35 line. Add an overrides entry, matching how the other pinned transitives in this package are already handled. The dashboard builds with output: "export" and images.unoptimized, so sharp is never loaded; this keeps the lockfile current rather than changing runtime behaviour. --- ui/litellm-dashboard/package-lock.json | 308 ++++++++++++++----------- ui/litellm-dashboard/package.json | 3 +- 2 files changed, 176 insertions(+), 135 deletions(-) diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 49d289879d3..742c1e4a63f 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -802,9 +802,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "license": "MIT", "optional": true, "dependencies": { @@ -1648,9 +1648,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -1660,19 +1660,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -1682,19 +1682,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -1708,9 +1727,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -1724,9 +1743,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -1740,9 +1759,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -1756,9 +1775,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -1772,9 +1791,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -1788,9 +1807,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -1804,9 +1823,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -1820,9 +1839,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -1836,9 +1855,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -1852,9 +1871,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -1864,19 +1883,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -1886,19 +1905,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -1908,19 +1927,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -1930,19 +1949,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -1952,19 +1971,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -1974,19 +1993,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -1996,19 +2015,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -2018,38 +2037,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.11.1" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -2059,16 +2094,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -2078,16 +2113,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -2097,7 +2132,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -12534,48 +12569,53 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index c29b53cd818..0f54c536297 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -103,7 +103,8 @@ "axios": "1.13.6", "postcss": "8.5.13", "esbuild": "0.28.1", - "date-fns": "^4.4.0" + "date-fns": "^4.4.0", + "sharp": "^0.35.0" }, "engines": { "node": ">=20.9.0", From dfbd098d65bee5ba996f4735d37d6d51f9fa8ad1 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 21 Jul 2026 17:11:16 -0700 Subject: [PATCH 4/5] refactor(ui): migrate Tool Policies table onto the shared DataTable (#34176) * refactor(ui): migrate Tool Policies table onto the shared DataTable Splits the old components/ToolPolicies.tsx into a data-owning panel, a thin DataTable consumer and a getToolPoliciesTableColumns module, all under components/ToolPolicies/. The hand-rolled tremor table, sort dropdowns and Prev/Next pager are replaced by the shared DataTable in client mode, so sorting, pagination and filtering now come from TanStack rather than local state. Search moves to the toolbar global filter and the four facets (input policy, output policy, team, key) move into a filter drawer; the facets match exactly instead of by substring, so filtering on "trusted" no longer also matches "untrusted" Inline policy editing is preserved. The two policy columns still render a PolicySelect directly in the row, with the per-row-per-column saving state and the in-place row update kept in the panel that owns the data The 15s live-tail poll is removed in favour of the toolbar refresh action, which takes the auto-refresh out of the write path of the inline edits. The green live-tail banner goes with it. The panel now reads through React Query with window-focus and reconnect refetching disabled, so refresh stays manual; that also removes the effect that previously needed a set-state-in-effect suppression The metric cards, the Needs Review banner and the detail swap are unchanged. Review still scrolls to the row when it is on screen, but no longer jumps across pages, since the paginated order now lives inside the table Drops the unused userRole prop threaded from the route through the view into the table, and prunes the suppressions stranded by the file move * fix(ui): make Tool Policies inline saves safe against concurrent edits and refresh Two races in the inline policy editing path, both found by review. Saving state was a single tool name per column, so starting a second row's save re-enabled the first row while its PATCH was still in flight, and whichever save finished first cleared the indicator for whichever row was in the slot. Track the set of tool names currently saving per column instead, so each cell disables and re-enables on its own request A list fetch already in flight when a save landed would resolve afterwards and overwrite the row with its pre-save snapshot, silently reverting a policy the user had just changed and the server had already accepted. Cancel in-flight queries before writing the row, which is the documented React Query ordering for this; the stale response is then discarded and the refresh can be retried Tightens the test helpers that hid the second bug: policy values are now compared exactly rather than with toHaveTextContent, which substring-matches and so let "untrusted" satisfy an assertion for "trusted" --- ui/litellm-dashboard/eslint-suppressions.json | 17 - .../app/(dashboard)/tool-policies/page.tsx | 4 +- .../src/components/ToolPolicies.tsx | 553 ------------------ .../ToolPolicies/ToolPoliciesPanel.test.tsx | 321 ++++++++++ .../ToolPolicies/ToolPoliciesPanel.tsx | 212 +++++++ .../ToolPolicies/ToolPoliciesTable.test.tsx | 189 ++++++ .../ToolPolicies/ToolPoliciesTable.tsx | 199 +++++++ .../ToolPolicies/ToolPoliciesTableColumns.tsx | 141 +++++ .../src/components/ToolPoliciesView.test.tsx | 22 +- .../src/components/ToolPoliciesView.tsx | 7 +- 10 files changed, 1079 insertions(+), 586 deletions(-) delete mode 100644 ui/litellm-dashboard/src/components/ToolPolicies.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index a48276cd727..6072c8c725b 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1497,23 +1497,6 @@ "count": 2 } }, - "src/components/ToolPolicies.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - }, - "react-hooks/static-components": { - "count": 7 - }, - "unused-imports/no-unused-imports": { - "count": 1 - } - }, "src/components/UIAccessControlForm.tsx": { "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tool-policies/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tool-policies/page.tsx index 6aaebaab959..08fded8dca6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tool-policies/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tool-policies/page.tsx @@ -4,6 +4,6 @@ import ToolPoliciesView from "@/components/ToolPoliciesView"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function ToolPolicies() { - const { accessToken, userRole } = useAuthorized(); - return ; + const { accessToken } = useAuthorized(); + return ; } diff --git a/ui/litellm-dashboard/src/components/ToolPolicies.tsx b/ui/litellm-dashboard/src/components/ToolPolicies.tsx deleted file mode 100644 index 4468334f813..00000000000 --- a/ui/litellm-dashboard/src/components/ToolPolicies.tsx +++ /dev/null @@ -1,553 +0,0 @@ -"use client"; - -import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; -import { Button, Switch, Tooltip } from "antd"; -import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react"; -import { DateCell, IdCell } from "@/components/shared/table_cells"; -import type { SortState } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown"; -import { TableHeaderSortDropdown } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown"; -import FilterComponent, { FilterOption } from "./molecules/filter"; -import { MetricCard } from "./GuardrailsMonitor/MetricCard"; -import { PolicySelect, INPUT_POLICY_OPTIONS, OUTPUT_POLICY_OPTIONS } from "./ToolPolicies/PolicySelect"; -import { fetchToolsList, updateToolPolicy, ToolRow } from "./networking"; - -function getUTCDateKey(date: Date): string { - return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`; -} - -function isCreatedInUTCDay(createdAt: string | undefined, utcDateKey: string): boolean { - if (!createdAt) return false; - try { - const d = new Date(createdAt); - return getUTCDateKey(d) === utcDateKey; - } catch { - return false; - } -} - -function countToolsInUTCDay(tools: ToolRow[], utcDateKey: string): number { - return tools.filter((t) => isCreatedInUTCDay(t.created_at, utcDateKey)).length; -} - -function getTrendSubtitle(newToday: number, newYesterday: number): string | undefined { - const diff = newToday - newYesterday; - if (diff === 0) return undefined; - if (diff > 0) return `+${diff} since yesterday`; - return `${diff} since yesterday`; -} - -type SortField = "tool_name" | "input_policy" | "output_policy" | "team_id" | "key_alias" | "created_at" | "call_count"; - -interface FilterValues { - [key: string]: string; -} - -interface ToolPoliciesProps { - accessToken: string | null; - userRole?: string; - onSelectTool?: (toolName: string) => void; -} - -export const ToolPolicies: React.FC = ({ accessToken, onSelectTool }) => { - const [tools, setTools] = useState([]); - const [loading, setLoading] = useState(true); - const [isFetching, setIsFetching] = useState(false); - const [error, setError] = useState(null); - const [savingInput, setSavingInput] = useState(null); - const [savingOutput, setSavingOutput] = useState(null); - - const [searchTerm, setSearchTerm] = useState(""); - const [sortField, setSortField] = useState("created_at"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); - const [currentPage, setCurrentPage] = useState(1); - const [isLiveTail, setIsLiveTail] = useState(true); - const [activeFilters, setActiveFilters] = useState({}); - const pageSize = 50; - - const isFetchingDeferred = useDeferredValue(isFetching); - const isButtonLoading = isFetching || isFetchingDeferred; - - const load = useCallback(async () => { - if (!accessToken) return; - setIsFetching(true); - setError(null); - try { - const rows = await fetchToolsList(accessToken); - setTools(rows); - } catch (e: any) { - setError(e.message ?? "Failed to load tools"); - } finally { - setIsFetching(false); - setLoading(false); - } - }, [accessToken]); - - useEffect(() => { - load(); - }, [load]); - - useEffect(() => { - if (!isLiveTail) return; - const id = setInterval(load, 15000); - return () => clearInterval(id); - }, [isLiveTail, load]); - - const handleInputPolicyChange = async (toolName: string, newPolicy: string) => { - if (!accessToken) return; - setSavingInput(toolName); - try { - await updateToolPolicy(accessToken, toolName, { input_policy: newPolicy }); - setTools((prev) => prev.map((t) => (t.tool_name === toolName ? { ...t, input_policy: newPolicy } : t))); - } catch (e: any) { - alert(`Failed to update input policy: ${e.message}`); - } finally { - setSavingInput(null); - } - }; - - const handleOutputPolicyChange = async (toolName: string, newPolicy: string) => { - if (!accessToken) return; - setSavingOutput(toolName); - try { - await updateToolPolicy(accessToken, toolName, { output_policy: newPolicy }); - setTools((prev) => prev.map((t) => (t.tool_name === toolName ? { ...t, output_policy: newPolicy } : t))); - } catch (e: any) { - alert(`Failed to update output policy: ${e.message}`); - } finally { - setSavingOutput(null); - } - }; - - const handleSortChange = (field: SortField, newState: SortState) => { - if (newState === false) { - setSortField("created_at"); - setSortOrder("desc"); - } else { - setSortField(field); - setSortOrder(newState); - } - setCurrentPage(1); - }; - - const handleApplyFilters = (filters: FilterValues) => { - setActiveFilters(filters); - setCurrentPage(1); - }; - - const handleResetFilters = () => { - setActiveFilters({}); - setCurrentPage(1); - }; - - const teamOptions = Array.from(new Set(tools.map((t) => t.team_id).filter(Boolean))).map((v) => ({ - label: v as string, - value: v as string, - })); - const keyAliasOptions = Array.from(new Set(tools.map((t) => t.key_alias).filter(Boolean))).map((v) => ({ - label: v as string, - value: v as string, - })); - - const filterOptions: FilterOption[] = [ - { - name: "Input Policy", - label: "Input Policy", - options: INPUT_POLICY_OPTIONS.map((o) => ({ label: o.label, value: o.value })), - }, - { - name: "Output Policy", - label: "Output Policy", - options: OUTPUT_POLICY_OPTIONS.map((o) => ({ label: o.label, value: o.value })), - }, - { - name: "Team Name", - label: "Team Name", - options: teamOptions, - }, - { - name: "Key Name", - label: "Key Name", - options: keyAliasOptions, - }, - ]; - - const { newToday, newYesterday, trendSubtitle, totalTools, blockedCount, activeTeamsCount, needsReviewTools } = - useMemo(() => { - const now = new Date(); - const todayKey = getUTCDateKey(now); - const yesterday = new Date(now); - yesterday.setUTCDate(yesterday.getUTCDate() - 1); - const yesterdayKey = getUTCDateKey(yesterday); - - const newToday = countToolsInUTCDay(tools, todayKey); - const newYesterday = countToolsInUTCDay(tools, yesterdayKey); - const trendSubtitle = getTrendSubtitle(newToday, newYesterday); - - const totalTools = tools.length; - const blockedCount = tools.filter((t) => t.input_policy === "blocked").length; - const activeTeamsCount = new Set(tools.map((t) => t.team_id).filter(Boolean)).size; - - const needsReviewTools = tools.filter( - (t) => isCreatedInUTCDay(t.created_at, todayKey) && t.input_policy === "untrusted", - ); - - return { - newToday, - newYesterday, - trendSubtitle, - totalTools, - blockedCount, - activeTeamsCount, - needsReviewTools, - }; - }, [tools]); - - const SortHeader = ({ label, field }: { label: string; field: SortField }) => ( -
- {label} - handleSortChange(field, s)} - /> -
- ); - - const filtered = tools.filter((t) => { - if (searchTerm) { - const q = searchTerm.toLowerCase(); - const matchesSearch = - t.tool_name.toLowerCase().includes(q) || - (t.team_id ?? "").toLowerCase().includes(q) || - (t.key_alias ?? "").toLowerCase().includes(q) || - (t.key_hash ?? "").toLowerCase().includes(q) || - t.input_policy.toLowerCase().includes(q) || - t.output_policy.toLowerCase().includes(q); - if (!matchesSearch) return false; - } - if (activeFilters["Input Policy"] && t.input_policy !== activeFilters["Input Policy"]) return false; - if (activeFilters["Output Policy"] && t.output_policy !== activeFilters["Output Policy"]) return false; - if (activeFilters["Team Name"] && t.team_id !== activeFilters["Team Name"]) return false; - if (activeFilters["Key Name"] && t.key_alias !== activeFilters["Key Name"]) return false; - return true; - }); - - const sorted = [...filtered].sort((a, b) => { - const av = (a as any)[sortField] ?? ""; - const bv = (b as any)[sortField] ?? ""; - if (av < bv) return sortOrder === "desc" ? 1 : -1; - if (av > bv) return sortOrder === "desc" ? -1 : 1; - return 0; - }); - - const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize)); - const paginated = sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize); - - const scrollToToolRow = (toolId: string) => { - const idx = sorted.findIndex((t) => t.tool_id === toolId); - if (idx >= 0) { - const page = Math.floor(idx / pageSize) + 1; - if (page !== currentPage) setCurrentPage(page); - requestAnimationFrame(() => { - setTimeout(() => { - document.getElementById(`tool-row-${toolId}`)?.scrollIntoView({ behavior: "smooth", block: "center" }); - }, 100); - }); - } - }; - - return ( -
-

Tool Policies

- -
- - - - } - /> - - 0 ? "text-red-600" : undefined} - /> - 0 ? activeTeamsCount : "—"} /> -
- - {needsReviewTools.length > 0 && ( -
-

Needs Review

-

- {needsReviewTools.length} new tool{needsReviewTools.length !== 1 ? "s" : ""} discovered that require policy - decisions. -

-
- {needsReviewTools.map((t) => ( - - - {t.tool_name} - - - - ))} -
-
- )} - -
-
-
-
-
- { - setSearchTerm(e.target.value); - setCurrentPage(1); - }} - /> - - - -
- -
- Live Tail - -
- - -
- -
- - Showing {filtered.length === 0 ? 0 : (currentPage - 1) * pageSize + 1} -{" "} - {Math.min(currentPage * pageSize, filtered.length)} of {filtered.length} results - - - Page {currentPage} of {totalPages} - -
- - -
-
-
- -
- -
-
- - {isLiveTail && ( -
- Auto-refreshing every 15 seconds - -
- )} - - {error && ( -
{error}
- )} - - - - - - - - - - - - - - - - - - - - - - - Key Hash - - - - User Agent - - - - {loading ? ( - - - Loading tools… - - - ) : paginated.length === 0 ? ( - - - No tools discovered yet. Make a chat completion that returns tool_calls to start auto-discovery. - - - ) : ( - paginated.map((tool) => ( - - - - - - - - - - - - - - -
- {(tool.call_count ?? 0).toLocaleString()} -
-
- - - - - - - - - {tool.key_alias ?? "-"} - - - - - - {tool.user_agent ?? "-"} - - - -
- )) - )} -
-
- - {totalPages > 1 && ( -
- - Showing {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, sorted.length)} of{" "} - {sorted.length} - -
- - -
-
- )} -
-
- ); -}; - -export default ToolPolicies; diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx new file mode 100644 index 00000000000..721c215cfb1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx @@ -0,0 +1,321 @@ +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { focusManager, QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; +import type { ToolRow } from "@/components/networking"; +import { ToolPoliciesPanel } from "./ToolPoliciesPanel"; + +const fetchToolsList = vi.fn(); +const updateToolPolicy = vi.fn(); + +vi.mock("@/components/networking", () => ({ + fetchToolsList: (...args: unknown[]) => fetchToolsList(...args), + updateToolPolicy: (...args: unknown[]) => updateToolPolicy(...args), +})); + +const fromBackend = vi.fn(); +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { fromBackend: (...args: unknown[]) => fromBackend(...args) }, +})); + +const NOW = new Date("2026-07-21T12:00:00Z"); + +const TOOLS: ToolRow[] = [ + { + tool_id: "tool-1", + tool_name: "get_weather", + input_policy: "untrusted", + output_policy: "untrusted", + call_count: 12, + team_id: "team-alpha", + key_alias: "prod-key", + key_hash: "hash-aaa", + user_agent: "curl/8.7.1", + created_at: "2026-07-21T10:00:00Z", + }, + { + tool_id: "tool-2", + tool_name: "search_web", + input_policy: "trusted", + output_policy: "trusted", + call_count: 5, + team_id: "team-beta", + key_alias: "dev-key", + key_hash: "hash-bbb", + created_at: "2026-07-20T10:00:00Z", + }, + { + tool_id: "tool-3", + tool_name: "delete_file", + input_policy: "blocked", + output_policy: "untrusted", + call_count: 100, + key_hash: "hash-ccc", + created_at: "2026-07-19T10:00:00Z", + }, +]; + +const row = (toolId: string): HTMLElement => { + const element = document.querySelector(`[data-row-id="${toolId}"]`); + if (element === null) throw new Error(`row ${toolId} is not rendered`); + return element as HTMLElement; +}; + +const policySelect = (toolId: string, kind: "input" | "output"): HTMLElement => + within(row(toolId)).getAllByRole("combobox")[kind === "input" ? 0 : 1]; + +/** Exact selected-value text. Never assert with toHaveTextContent here: it substring-matches, so "untrusted" satisfies "trusted". */ +const policyValue = (toolId: string, kind: "input" | "output"): string => + policySelect(toolId, kind).closest(".ant-select")?.querySelector(".ant-select-selection-item")?.textContent ?? ""; + +const isSaving = (toolId: string, kind: "input" | "output"): boolean => + policySelect(toolId, kind).closest(".ant-select")?.classList.contains("ant-select-disabled") ?? false; + +const chooseOption = async (user: ReturnType, trigger: HTMLElement, label: string) => { + await user.click(trigger); + const option = await waitFor(() => { + const match = Array.from(document.querySelectorAll(".ant-select-item-option")).find( + (element) => element.textContent === label, + ); + if (match === undefined) throw new Error(`option ${label} not open`); + return match as HTMLElement; + }); + await user.click(option); +}; + +const renderPanel = (onSelectTool = vi.fn()) => + renderWithProviders(); + +const waitForRows = () => waitFor(() => expect(document.querySelector('[data-row-id="tool-1"]')).not.toBeNull()); + +beforeEach(() => { + testQueryClient.clear(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(NOW); + fetchToolsList.mockReset().mockResolvedValue(TOOLS); + updateToolPolicy.mockReset().mockResolvedValue({}); + fromBackend.mockReset(); + Element.prototype.scrollIntoView = vi.fn(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ToolPoliciesPanel data loading", () => { + it("should load tools once and never auto-refresh on a timer", async () => { + renderPanel(); + await waitForRows(); + + await act(async () => { + vi.advanceTimersByTime(60_000); + }); + + expect(fetchToolsList).toHaveBeenCalledTimes(1); + }); + + it("should not refetch when the window regains focus", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + await waitForRows(); + + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + + expect(fetchToolsList).toHaveBeenCalledTimes(1); + focusManager.setFocused(undefined); + }); + + it("should refetch when the toolbar refresh action is used", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderPanel(); + await waitForRows(); + + await user.click(screen.getByTestId("datatable-refresh")); + + await waitFor(() => expect(fetchToolsList).toHaveBeenCalledTimes(2)); + }); + + it("should keep rows visible during a refresh instead of falling back to skeletons", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderPanel(); + await waitForRows(); + + fetchToolsList.mockReturnValue(new Promise(() => {})); + await user.click(screen.getByTestId("datatable-refresh")); + + expect(row("tool-1")).toBeInTheDocument(); + expect(screen.queryAllByTestId("skeleton-row")).toHaveLength(0); + }); + + it("should resolve the loading skeleton when there is no access token", async () => { + renderWithProviders(); + + await waitFor(() => expect(screen.queryAllByTestId("skeleton-row")).toHaveLength(0)); + expect(fetchToolsList).not.toHaveBeenCalled(); + expect(screen.getByText("No tools discovered")).toBeInTheDocument(); + }); + + it("should surface a load failure without wedging the skeleton", async () => { + fetchToolsList.mockRejectedValue(new Error("boom")); + renderPanel(); + + expect(await screen.findByRole("alert")).toHaveTextContent("boom"); + expect(screen.queryAllByTestId("skeleton-row")).toHaveLength(0); + }); +}); + +describe("ToolPoliciesPanel inline policy editing", () => { + it("should patch the input policy and update that row in place", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderPanel(); + await waitForRows(); + + await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + + expect(updateToolPolicy).toHaveBeenCalledWith("sk-token", "get_weather", { input_policy: "trusted" }); + await waitFor(() => expect(policyValue("tool-1", "input")).toBe("trusted")); + expect(fetchToolsList).toHaveBeenCalledTimes(1); + }); + + it("should patch the output policy from the output column", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderPanel(); + await waitForRows(); + + await chooseOption(user, policySelect("tool-1", "output"), "trusted"); + + expect(updateToolPolicy).toHaveBeenCalledWith("sk-token", "get_weather", { output_policy: "trusted" }); + }); + + it("should keep every in-flight row disabled when two rows are saved at once", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + updateToolPolicy.mockReturnValue(new Promise(() => {})); + renderPanel(); + await waitForRows(); + + await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + await chooseOption(user, policySelect("tool-2", "input"), "blocked"); + + expect(isSaving("tool-2", "input")).toBe(true); + expect(isSaving("tool-1", "input")).toBe(true); + }); + + it("should re-enable only the row whose save finished", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + let finishFirst = () => {}; + updateToolPolicy + .mockImplementationOnce(() => new Promise((resolve) => (finishFirst = () => resolve()))) + .mockImplementationOnce(() => new Promise(() => {})); + renderPanel(); + await waitForRows(); + + await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + await chooseOption(user, policySelect("tool-2", "input"), "blocked"); + await act(async () => { + finishFirst(); + }); + + expect(isSaving("tool-1", "input")).toBe(false); + expect(isSaving("tool-2", "input")).toBe(true); + }); + + it("should not let an in-flight refresh clobber a policy that just saved", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + let landStaleRefresh = () => {}; + renderPanel(); + await waitForRows(); + + fetchToolsList.mockImplementationOnce( + // resolves with the PRE-save snapshot, i.e. tool-1 still "untrusted" + () => new Promise((resolve) => (landStaleRefresh = () => resolve(TOOLS))), + ); + await user.click(screen.getByTestId("datatable-refresh")); + await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + await waitFor(() => expect(policyValue("tool-1", "input")).toBe("trusted")); + + await act(async () => { + landStaleRefresh(); + }); + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(policyValue("tool-1", "input")).toBe("trusted"); + }); + + it("should leave the row untouched and report the failure when the patch is rejected", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + updateToolPolicy.mockRejectedValue(new Error("nope")); + renderPanel(); + await waitForRows(); + + await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + + await waitFor(() => expect(fromBackend).toHaveBeenCalledWith("Failed to update input policy: nope")); + expect(policyValue("tool-1", "input")).toBe("untrusted"); + }); + + it("should disable only the one cell that is saving", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + updateToolPolicy.mockReturnValue(new Promise(() => {})); + renderPanel(); + await waitForRows(); + + await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + + await waitFor(() => expect(isSaving("tool-1", "input")).toBe(true)); + expect(isSaving("tool-1", "output")).toBe(false); + expect(isSaving("tool-2", "input")).toBe(false); + }); +}); + +describe("ToolPoliciesPanel header chrome", () => { + it("should summarise the loaded tools in the metric cards", async () => { + renderPanel(); + await waitForRows(); + + const metric = (label: string): HTMLElement => { + const card = screen.getByText(label).closest("div.h-full"); + if (card === null) throw new Error(`metric ${label} missing`); + return card as HTMLElement; + }; + + expect(metric("Total Tools Discovered")).toHaveTextContent("3"); + expect(metric("Blocked Tools")).toHaveTextContent("1"); + expect(metric("Active Teams")).toHaveTextContent("2"); + expect(metric("New Today")).toHaveTextContent("1"); + }); + + it("should list only today's untrusted tools for review and scroll to the row", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderPanel(); + await waitForRows(); + + const banner = screen.getByText("Needs Review").closest("div"); + if (banner === null) throw new Error("needs review banner missing"); + expect(banner).toHaveTextContent("1 new tool discovered"); + expect(within(banner as HTMLElement).queryByText("delete_file")).not.toBeInTheDocument(); + + await user.click(within(banner as HTMLElement).getByRole("button", { name: "Review" })); + + expect(row("tool-1").scrollIntoView).toHaveBeenCalled(); + }); + + it("should hide the review banner when nothing needs a decision", async () => { + fetchToolsList.mockResolvedValue([{ ...TOOLS[0], input_policy: "trusted" }]); + renderPanel(); + await waitForRows(); + + expect(screen.queryByText("Needs Review")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx new file mode 100644 index 00000000000..1b559352469 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useQuery, useQueryClient, type UseQueryOptions } from "@tanstack/react-query"; +import React, { useCallback, useMemo, useState } from "react"; + +import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { fetchToolsList, ToolRow, updateToolPolicy } from "@/components/networking"; + +import { ToolPoliciesTable } from "./ToolPoliciesTable"; + +function getUTCDateKey(date: Date): string { + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`; +} + +function isCreatedInUTCDay(createdAt: string | undefined, utcDateKey: string): boolean { + if (!createdAt) return false; + try { + return getUTCDateKey(new Date(createdAt)) === utcDateKey; + } catch { + return false; + } +} + +function countToolsInUTCDay(tools: ToolRow[], utcDateKey: string): number { + return tools.filter((tool) => isCreatedInUTCDay(tool.created_at, utcDateKey)).length; +} + +function getTrendSubtitle(newToday: number, newYesterday: number): string | undefined { + const diff = newToday - newYesterday; + if (diff === 0) return undefined; + return diff > 0 ? `+${diff} since yesterday` : `${diff} since yesterday`; +} + +function toMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +const withTool = (names: ReadonlySet, toolName: string): ReadonlySet => new Set([...names, toolName]); + +const withoutTool = (names: ReadonlySet, toolName: string): ReadonlySet => + new Set([...names].filter((name) => name !== toolName)); + +const TOOLS_QUERY_KEY = "tool-policies"; + +interface ToolPoliciesPanelProps { + accessToken: string | null; + onSelectTool: (toolName: string) => void; +} + +export const ToolPoliciesPanel: React.FC = ({ accessToken, onSelectTool }) => { + const queryClient = useQueryClient(); + const [savingInput, setSavingInput] = useState>(() => new Set()); + const [savingOutput, setSavingOutput] = useState>(() => new Set()); + + const queryKey = useMemo(() => [TOOLS_QUERY_KEY, accessToken], [accessToken]); + + const queryOptions: UseQueryOptions = { + queryKey, + queryFn: async () => (accessToken === null ? [] : fetchToolsList(accessToken)), + enabled: accessToken !== null, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }; + const query = useQuery(queryOptions); + + const tools = useMemo(() => query.data ?? [], [query.data]); + + // Cancel first: a list fetch that started before this save would otherwise resolve afterwards + // and overwrite the row we just wrote with its pre-save snapshot. + const patchTool = useCallback( + async (toolName: string, patch: Partial) => { + await queryClient.cancelQueries({ queryKey }); + queryClient.setQueryData(queryKey, (previous) => + (previous ?? []).map((tool) => (tool.tool_name === toolName ? { ...tool, ...patch } : tool)), + ); + }, + [queryClient, queryKey], + ); + + const handleInputPolicyChange = useCallback( + async (toolName: string, newPolicy: string) => { + if (accessToken === null) return; + setSavingInput((previous) => withTool(previous, toolName)); + try { + await updateToolPolicy(accessToken, toolName, { input_policy: newPolicy }); + await patchTool(toolName, { input_policy: newPolicy }); + } catch (e) { + NotificationsManager.fromBackend(`Failed to update input policy: ${toMessage(e, "unknown error")}`); + } finally { + setSavingInput((previous) => withoutTool(previous, toolName)); + } + }, + [accessToken, patchTool], + ); + + const handleOutputPolicyChange = useCallback( + async (toolName: string, newPolicy: string) => { + if (accessToken === null) return; + setSavingOutput((previous) => withTool(previous, toolName)); + try { + await updateToolPolicy(accessToken, toolName, { output_policy: newPolicy }); + await patchTool(toolName, { output_policy: newPolicy }); + } catch (e) { + NotificationsManager.fromBackend(`Failed to update output policy: ${toMessage(e, "unknown error")}`); + } finally { + setSavingOutput((previous) => withoutTool(previous, toolName)); + } + }, + [accessToken, patchTool], + ); + + const { newToday, trendSubtitle, totalTools, blockedCount, activeTeamsCount, needsReviewTools } = useMemo(() => { + const now = new Date(); + const todayKey = getUTCDateKey(now); + const yesterday = new Date(now); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + const today = countToolsInUTCDay(tools, todayKey); + + return { + newToday: today, + trendSubtitle: getTrendSubtitle(today, countToolsInUTCDay(tools, getUTCDateKey(yesterday))), + totalTools: tools.length, + blockedCount: tools.filter((tool) => tool.input_policy === "blocked").length, + activeTeamsCount: new Set(tools.map((tool) => tool.team_id).filter(Boolean)).size, + needsReviewTools: tools.filter( + (tool) => isCreatedInUTCDay(tool.created_at, todayKey) && tool.input_policy === "untrusted", + ), + }; + }, [tools]); + + const scrollToToolRow = (toolId: string) => { + document.querySelector(`[data-row-id="${CSS.escape(toolId)}"]`)?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }; + + return ( +
+

Tool Policies

+ +
+ + + + } + /> + + 0 ? "text-red-600" : undefined} + /> + 0 ? activeTeamsCount : "—"} /> +
+ + {needsReviewTools.length > 0 && ( +
+

Needs Review

+

+ {needsReviewTools.length} new tool{needsReviewTools.length !== 1 ? "s" : ""} discovered that require policy + decisions. +

+
+ {needsReviewTools.map((tool) => ( + + + {tool.tool_name} + + + + ))} +
+
+ )} + + {query.isError && ( +
+ {toMessage(query.error, "Failed to load tools")} +
+ )} + + void query.refetch()} + onSelectTool={onSelectTool} + savingInput={savingInput} + savingOutput={savingOutput} + onInputPolicyChange={handleInputPolicyChange} + onOutputPolicyChange={handleOutputPolicyChange} + /> +
+ ); +}; diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx new file mode 100644 index 00000000000..9d32ad667bd --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx @@ -0,0 +1,189 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { renderWithProviders } from "../../../tests/test-utils"; +import type { ToolRow } from "@/components/networking"; +import { ToolPoliciesTable } from "./ToolPoliciesTable"; + +const TOOLS: ToolRow[] = [ + { + tool_id: "tool-1", + tool_name: "get_weather", + input_policy: "untrusted", + output_policy: "untrusted", + call_count: 12, + team_id: "team-alpha", + key_alias: "prod-key", + key_hash: "hash-aaa", + user_agent: "curl/8.7.1", + created_at: "2026-07-21T10:00:00Z", + }, + { + tool_id: "tool-2", + tool_name: "search_web", + input_policy: "trusted", + output_policy: "trusted", + call_count: 5, + team_id: "team-beta", + key_alias: "dev-key", + key_hash: "hash-bbb", + created_at: "2026-07-20T10:00:00Z", + }, + { + tool_id: "tool-3", + tool_name: "delete_file", + input_policy: "blocked", + output_policy: "untrusted", + call_count: 100, + key_hash: "hash-ccc", + created_at: "2026-07-19T10:00:00Z", + }, +]; + +const renderTable = (overrides: Partial> = {}) => { + const props = { + data: TOOLS, + isLoading: false, + isRefreshing: false, + onRefresh: vi.fn(), + onSelectTool: vi.fn(), + savingInput: new Set(), + savingOutput: new Set(), + onInputPolicyChange: vi.fn(), + onOutputPolicyChange: vi.fn(), + ...overrides, + }; + renderWithProviders(); + return props; +}; + +const rowIds = (): (string | null)[] => + Array.from(document.querySelectorAll("tbody tr[data-row-id]")).map((row) => row.getAttribute("data-row-id")); + +const pickFilter = async ( + user: ReturnType, + triggerTestId: string, + optionLabel: string, +): Promise => { + await user.click(screen.getByTestId(triggerTestId)); + await user.click(await screen.findByRole("option", { name: optionLabel })); +}; + +describe("ToolPoliciesTable sorting", () => { + it("should default to newest discovered first", () => { + renderTable(); + + expect(rowIds()).toEqual(["tool-1", "tool-2", "tool-3"]); + }); + + it("should sort by tool name when its header is used", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByTestId("sort-header-tool_name")); + + expect(rowIds()).toEqual(["tool-3", "tool-1", "tool-2"]); + }); +}); + +describe("ToolPoliciesTable search", () => { + it("should match on tool name", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.type(screen.getByTestId("datatable-search"), "weather"); + + await waitFor(() => expect(rowIds()).toEqual(["tool-1"])); + }); + + it("should match on key hash", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.type(screen.getByTestId("datatable-search"), "hash-bbb"); + + await waitFor(() => expect(rowIds()).toEqual(["tool-2"])); + }); + + it("should not match on user agent", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.type(screen.getByTestId("datatable-search"), "curl"); + + await waitFor(() => expect(rowIds()).toEqual([])); + expect(screen.getByText("No matching tools")).toBeInTheDocument(); + }); +}); + +describe("ToolPoliciesTable filters", () => { + it("should match an input policy exactly rather than as a substring", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await pickFilter(user, "filter-input-policy", "trusted"); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(rowIds()).toEqual(["tool-2"])); + }); + + it("should filter by team", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await pickFilter(user, "filter-team", "team-alpha"); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(rowIds()).toEqual(["tool-1"])); + expect(screen.getByTestId("filter-chip-team_id")).toHaveTextContent("Team Name:"); + }); + + it("should offer only the teams and keys present in the loaded rows", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(screen.getByTestId("filter-team")); + + const teams = (await screen.findAllByRole("option")).map((option) => option.textContent); + expect(teams).toEqual(["All Teams", "team-alpha", "team-beta"]); + }); +}); + +describe("ToolPoliciesTable chrome", () => { + it("should open the detail view from the tool name cell", async () => { + const user = userEvent.setup(); + const { onSelectTool } = renderTable(); + + await user.click(screen.getByRole("button", { name: /get_weather/ })); + + expect(onSelectTool).toHaveBeenCalledWith("get_weather"); + }); + + it("should refresh on demand", async () => { + const user = userEvent.setup(); + const { onRefresh } = renderTable(); + + await user.click(screen.getByTestId("datatable-refresh")); + + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it("should explain how discovery works when there are no tools at all", () => { + renderTable({ data: [] }); + + expect(screen.getByText("No tools discovered")).toBeInTheDocument(); + expect(screen.getByText(/tool_calls to start auto-discovery/)).toBeInTheDocument(); + }); + + it("should show skeleton rows while the first load is in flight", () => { + renderTable({ data: [], isLoading: true }); + + expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); + expect(screen.queryByText("No tools discovered")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx new file mode 100644 index 00000000000..bbffb0d5ff5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { ColumnFiltersState } from "@tanstack/react-table"; +import { Wrench } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { ToolRow } from "@/components/networking"; +import { + DataTable, + DataTableFilterDrawer, + DataTableFilterField, + DataTableToolbar, +} from "@/components/shared/DataTable"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +import { INPUT_POLICY_OPTIONS, OUTPUT_POLICY_OPTIONS } from "./PolicySelect"; +import { getToolPoliciesTableColumns } from "./ToolPoliciesTableColumns"; + +const ALL_VALUE = "all"; + +const toFilterValue = (value: string | null): string | undefined => + value === null || value === ALL_VALUE ? undefined : value; + +interface ToolPoliciesTableProps { + data: ToolRow[]; + isLoading: boolean; + isRefreshing: boolean; + onRefresh: () => void; + onSelectTool: (toolName: string) => void; + savingInput: ReadonlySet; + savingOutput: ReadonlySet; + onInputPolicyChange: (toolName: string, policy: string) => void; + onOutputPolicyChange: (toolName: string, policy: string) => void; +} + +function ToolPoliciesEmptyState({ filtered }: { filtered: boolean }) { + return ( +
+
+ +
+
+ {filtered ? "No matching tools" : "No tools discovered"} +
+
+ {filtered + ? "No tools match your search or filters." + : "Make a chat completion that returns tool_calls to start auto-discovery."} +
+
+ ); +} + +function uniqueValues(rows: ToolRow[], pick: (row: ToolRow) => string | undefined): string[] { + return Array.from(new Set(rows.map(pick).filter((value): value is string => Boolean(value)))); +} + +export function ToolPoliciesTable({ + data, + isLoading, + isRefreshing, + onRefresh, + onSelectTool, + savingInput, + savingOutput, + onInputPolicyChange, + onOutputPolicyChange, +}: ToolPoliciesTableProps) { + const [globalFilter, setGlobalFilter] = useState(""); + const [columnFilters, setColumnFilters] = useState([]); + const [filtersOpen, setFiltersOpen] = useState(false); + + const columns = useMemo(() => { + const deps = { onSelectTool, savingInput, savingOutput, onInputPolicyChange, onOutputPolicyChange }; + return getToolPoliciesTableColumns(deps); + }, [onSelectTool, savingInput, savingOutput, onInputPolicyChange, onOutputPolicyChange]); + + const teamOptions = useMemo(() => uniqueValues(data, (row) => row.team_id), [data]); + const keyAliasOptions = useMemo(() => uniqueValues(data, (row) => row.key_alias), [data]); + + return ( + row.tool_id} + sortingMode="client" + defaultSorting={[{ id: "created_at", desc: true }]} + paginationMode="client" + pageSizeOptions={[50, 100]} + filterMode="client" + columnFilters={columnFilters} + onColumnFiltersChange={setColumnFilters} + globalFilter={globalFilter} + onGlobalFilterChange={setGlobalFilter} + isLoading={isLoading} + loadingMessage="Loading tools…" + noDataMessage={ 0 || globalFilter !== ""} />} + size="compact" + toolbar={(table) => ( + <> + setFiltersOpen(true)} + showViewOptions={false} + /> + + {({ get, set }) => ( + <> + + + + + + + + + + + + + + )} + + + )} + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.tsx new file mode 100644 index 00000000000..29a4708a470 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Tooltip } from "antd"; + +import { ToolRow } from "@/components/networking"; +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DateCell, IdCell, IdentityCell } from "@/components/shared/table_cells"; + +import { PolicySelect } from "./PolicySelect"; + +interface ToolPoliciesTableColumnsDeps { + onSelectTool: (toolName: string) => void; + savingInput: ReadonlySet; + savingOutput: ReadonlySet; + onInputPolicyChange: (toolName: string, policy: string) => void; + onOutputPolicyChange: (toolName: string, policy: string) => void; +} + +function TruncatedText({ value, className }: { value: string | undefined; className?: string }) { + const text = value ?? "-"; + return ( + + {text} + + ); +} + +export const getToolPoliciesTableColumns = ({ + onSelectTool, + savingInput, + savingOutput, + onInputPolicyChange, + onOutputPolicyChange, +}: ToolPoliciesTableColumnsDeps): ColumnDef[] => [ + { + id: "created_at", + accessorFn: (row) => row.created_at ?? "", + header: ({ column }) => , + size: 170, + enableGlobalFilter: false, + cell: ({ row }) => , + }, + { + id: "tool_name", + accessorFn: (row) => row.tool_name, + header: ({ column }) => , + minSize: 200, + cell: ({ row }) => ( + onSelectTool(row.original.tool_name)} + /> + ), + }, + { + id: "input_policy", + accessorFn: (row) => row.input_policy, + header: ({ column }) => , + size: 140, + filterFn: "equalsString", + meta: { title: "Input Policy", skeleton: "badge" }, + cell: ({ row }) => ( + + ), + }, + { + id: "output_policy", + accessorFn: (row) => row.output_policy, + header: ({ column }) => , + size: 140, + filterFn: "equalsString", + meta: { title: "Output Policy", skeleton: "badge" }, + cell: ({ row }) => ( + + ), + }, + { + id: "call_count", + accessorFn: (row) => row.call_count ?? 0, + header: ({ column }) => , + size: 100, + enableGlobalFilter: false, + meta: { numeric: true }, + cell: ({ row }) => {(row.original.call_count ?? 0).toLocaleString()}, + }, + { + id: "team_id", + accessorFn: (row) => row.team_id ?? "", + header: ({ column }) => , + size: 160, + filterFn: "equalsString", + meta: { title: "Team Name" }, + cell: ({ row }) => , + }, + { + id: "key_hash", + accessorFn: (row) => row.key_hash ?? "", + header: "Key Hash", + size: 150, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "key_alias", + accessorFn: (row) => row.key_alias ?? "", + header: ({ column }) => , + size: 150, + filterFn: "equalsString", + meta: { title: "Key Name" }, + cell: ({ row }) => , + }, + { + id: "user_agent", + accessorFn: (row) => row.user_agent ?? "", + header: "User Agent", + size: 180, + enableSorting: false, + enableGlobalFilter: false, + cell: ({ row }) => ( + + ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx index 8b2b1d0e4b7..34c697a98d1 100644 --- a/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx +++ b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx @@ -14,25 +14,27 @@ vi.mock("@/components/ToolDetail", () => ({ ), })); -vi.mock("@/components/ToolPolicies", () => ({ - ToolPolicies: ({ onSelectTool }: { onSelectTool: (name: string) => void }) => ( -
- Tool Policies Overview - -
- ), +vi.mock("@/components/ToolPolicies/ToolPoliciesPanel", () => ({ + ToolPoliciesPanel: function ToolPoliciesPanelMock({ onSelectTool }: { onSelectTool: (name: string) => void }) { + return ( +
+ Tool Policies Overview + +
+ ); + }, })); describe("ToolPoliciesView", () => { it("should render the overview by default", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument(); }); it("should navigate to tool detail when a tool is selected", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderWithProviders(); await user.click(screen.getByRole("button", { name: /select tool/i })); @@ -42,7 +44,7 @@ describe("ToolPoliciesView", () => { it("should navigate back to overview when back is clicked", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderWithProviders(); await user.click(screen.getByRole("button", { name: /select tool/i })); await user.click(screen.getByRole("button", { name: /back/i })); diff --git a/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx b/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx index 31ea7c7f956..bdff40153b9 100644 --- a/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx +++ b/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx @@ -2,16 +2,15 @@ import React, { useState } from "react"; import { ToolDetail } from "@/components/ToolDetail"; -import { ToolPolicies } from "@/components/ToolPolicies"; +import { ToolPoliciesPanel } from "@/components/ToolPolicies/ToolPoliciesPanel"; type View = { type: "overview" } | { type: "detail"; toolName: string }; interface ToolPoliciesViewProps { accessToken: string | null; - userRole?: string; } -export default function ToolPoliciesView({ accessToken, userRole }: ToolPoliciesViewProps) { +export default function ToolPoliciesView({ accessToken }: ToolPoliciesViewProps) { const [view, setView] = useState({ type: "overview" }); const handleSelectTool = (toolName: string) => { @@ -27,7 +26,7 @@ export default function ToolPoliciesView({ accessToken, userRole }: ToolPolicies {view.type === "detail" ? ( ) : ( - + )}
); From 7dd0541126a9944b611add8a8f76787d63881838 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 21 Jul 2026 17:12:55 -0700 Subject: [PATCH 5/5] bump: litellm-proxy-extras 0.4.79 -> 0.4.80, litellm 1.94.0 -> 1.95.0 (#34199) --- litellm-proxy-extras/pyproject.toml | 4 ++-- pyproject.toml | 6 +++--- uv.lock | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 3288f7fd584..ccca88c9996 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-proxy-extras" -version = "0.4.79" +version = "0.4.80" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.4.79" +version = "0.4.80" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-proxy-extras==", diff --git a/pyproject.toml b/pyproject.toml index 9e2f5c4e3ac..080d06258ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm" -version = "1.94.0" +version = "1.95.0" description = "Library to easily interface with LLM API providers" readme = "README.md" requires-python = ">=3.10, <3.15" @@ -62,7 +62,7 @@ proxy = [ "azure-identity>=1.25.2,<2.0", "azure-storage-blob>=12.28.0,<13.0", "mcp>=1.28.1,<2.0", - "litellm-proxy-extras==0.4.79", + "litellm-proxy-extras==0.4.80", "litellm-enterprise==0.1.51", "RestrictedPython>=8.1,<9.0", "rich>=13.9.4,<14.0", @@ -289,7 +289,7 @@ members = ["enterprise", "litellm-proxy-extras"] profile = "black" [tool.commitizen] -version = "1.94.0" +version = "1.95.0" version_files = [ "pyproject.toml:^version", ] diff --git a/uv.lock b/uv.lock index 0a90682a187..6de7bf20bd6 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-18T21:57:43.13625Z" +exclude-newer = "2026-07-19T00:00:06.091071Z" exclude-newer-span = "P3D" [manifest] @@ -3966,7 +3966,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.94.0" +version = "1.95.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -4350,7 +4350,7 @@ source = { editable = "enterprise" } [[package]] name = "litellm-proxy-extras" -version = "0.4.79" +version = "0.4.80" source = { editable = "litellm-proxy-extras" } [[package]]