diff --git a/tests/e2e/ui/fixtures/config.yml b/tests/e2e/ui/fixtures/config.yml index 3d250984bca..f3355076666 100644 --- a/tests/e2e/ui/fixtures/config.yml +++ b/tests/e2e/ui/fixtures/config.yml @@ -10,6 +10,9 @@ model_list: api_base: os.environ/MOCK_LLM_URL api_key: fake-key +litellm_settings: + store_audit_logs: true + general_settings: master_key: os.environ/LITELLM_MASTER_KEY database_url: os.environ/DATABASE_URL diff --git a/tests/e2e/ui/tests/tables/rowActivation.spec.ts b/tests/e2e/ui/tests/tables/rowActivation.spec.ts new file mode 100644 index 00000000000..aae7a3e4930 --- /dev/null +++ b/tests/e2e/ui/tests/tables/rowActivation.spec.ts @@ -0,0 +1,254 @@ +import { test, expect, type APIRequestContext, type Locator, type Page as PlaywrightPage } from "@playwright/test"; +import { ADMIN_STORAGE_PATH } from "../../constants"; +import { Page } from "../../fixtures/pages"; +import { navigateToPage } from "../../helpers/navigation"; +import { proxyIsPremium } from "../../helpers/premium"; +import { CHAT_MODEL_A, deleteVirtualKey, masterKey, sendChatCompletion, waitForSpendLog } from "../../helpers/traffic"; + +interface GeneratedKeyResponse { + key: string; + token?: string; + token_id?: string; +} + +interface GeneratedKey { + key: string; + token: string; +} + +interface CreatedTeam { + team_id: string; +} + +interface SpendLog { + request_id: string; +} + +const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +const adminHeaders = (): Record => ({ Authorization: `Bearer ${masterKey()}` }); + +const visibleTestId = (page: PlaywrightPage, id: string): Locator => page.getByTestId(id).filter({ visible: true }); + +const visibleDataTable = (page: PlaywrightPage): Locator => visibleTestId(page, "data-table-root").first(); + +const visibleRows = (page: PlaywrightPage): Locator => visibleDataTable(page).locator("tbody tr"); + +const rowWithText = (page: PlaywrightPage, text: string): Locator => visibleRows(page).filter({ hasText: text }).first(); + +async function postOk(request: APIRequestContext, path: string, data: Record): Promise { + const response = await request.post(path, { headers: adminHeaders(), data }); + expect(response.ok(), `POST ${path} failed (${response.status()}): ${await response.text()}`).toBe(true); + return (await response.json()) as T; +} + +async function createKey( + request: APIRequestContext, + alias: string, + teamId?: string, +): Promise { + const response = await postOk(request, "/key/generate", { + key_alias: alias, + ...(teamId ? { team_id: teamId } : {}), + }); + const token = response.token ?? response.token_id; + if (!token) { + throw new Error("POST /key/generate did not return a key token"); + } + return { key: response.key, token }; +} + +async function cellForHeader(page: PlaywrightPage, row: Locator, title: string): Promise { + const headers = await visibleDataTable(page).getByRole("columnheader").allTextContents(); + const index = headers.findIndex((header) => header.includes(title)); + if (index < 0) { + throw new Error(`Could not find the ${title} table column`); + } + return row.getByRole("cell").nth(index); +} + +async function createAuditTeam(request: APIRequestContext): Promise { + return postOk(request, "/team/new", { team_alias: `e2e-row-audit-${uniqueSuffix()}` }); +} + +async function locateAuditRow(page: PlaywrightPage, teamId: string): Promise { + await navigateToPage(page, Page.Logs); + await page.getByRole("tab", { name: "Audit Logs" }).click(); + await page.getByPlaceholder("Search audit logs by ID…").fill(teamId); + const row = rowWithText(page, teamId); + await expect(row, `audit row for team ${teamId} should be visible before activation checks`).toBeVisible(); + return row; +} + +async function locateRequestRow(page: PlaywrightPage, requestId: string): Promise { + await navigateToPage(page, Page.Logs); + await page.getByPlaceholder("Search logs by ID…").fill(requestId); + const row = rowWithText(page, requestId); + await expect(row, `request row ${requestId} should be visible before activation checks`).toBeVisible(); + return row; +} + +async function readSpendLogRequestId(request: APIRequestContext, requestId: string): Promise { + const response = await request.get(`/spend/logs?request_id=${encodeURIComponent(requestId)}`, { + headers: adminHeaders(), + }); + expect(response.ok(), `GET /spend/logs for ${requestId} failed (${response.status()})`).toBe(true); + const body = (await response.json()) as SpendLog[] | { data: SpendLog[] }; + const rows = Array.isArray(body) ? body : body.data; + const spendLog = rows.find((row) => row.request_id === requestId); + if (!spendLog) { + throw new Error(`Spend log for request ${requestId} was not returned after waitForSpendLog`); + } + return spendLog.request_id; +} + +test.describe("Virtual Keys row activation", () => { + test.use({ storageState: ADMIN_STORAGE_PATH }); + + test("a non-interactive cell opens that key's details", async ({ page, request }) => { + const alias = `e2e-row-key-cell-${uniqueSuffix()}`; + const key = await createKey(request, alias); + try { + await navigateToPage(page, Page.ApiKeys); + const row = rowWithText(page, alias); + await expect(row, `key row ${alias} should be visible before clicking Created At`).toBeVisible(); + + await (await cellForHeader(page, row, "Created At")).click(); + + await expect(page).toHaveURL((url) => url.searchParams.get("key") === key.token); + await expect(page.getByText("Back to Keys", { exact: true })).toBeVisible(); + } finally { + await deleteVirtualKey(request, key.token); + } + }); + + test("the row is focusable, named, and opens details with Enter and Space", async ({ page, request }) => { + const alias = `e2e-row-key-keyboard-${uniqueSuffix()}`; + const key = await createKey(request, alias); + try { + await navigateToPage(page, Page.ApiKeys); + const row = rowWithText(page, alias); + await expect(row, `key row ${alias} should be visible before keyboard checks`).toBeVisible(); + await expect.soft(row).toHaveAccessibleName(`Open key ${alias}`); + + await row.focus(); + await expect.soft(row).toBeFocused(); + await page.keyboard.press("Enter"); + await expect.soft(page).toHaveURL((url) => url.searchParams.get("key") === key.token); + await expect.soft(page.getByText("Back to Keys", { exact: true })).toBeVisible(); + + const backToKeys = page.getByText("Back to Keys", { exact: true }); + if (await backToKeys.isVisible()) { + await backToKeys.click(); + } + await expect(row).toBeVisible(); + await row.focus(); + await page.keyboard.press("Space"); + await expect.soft(page).toHaveURL((url) => url.searchParams.get("key") === key.token); + await expect.soft(page.getByText("Back to Keys", { exact: true })).toBeVisible(); + } finally { + await deleteVirtualKey(request, key.token); + } + }); + + test("the team link does not open key details", async ({ page, request }) => { + const alias = `e2e-row-key-link-${uniqueSuffix()}`; + const key = await createKey(request, alias, "e2e-team-crud"); + try { + await navigateToPage(page, Page.ApiKeys); + const row = rowWithText(page, alias); + await expect(row, `key row ${alias} should be visible before checking its team link`).toBeVisible(); + const teamLink = row.getByRole("link", { name: "E2E Team CRUD" }); + await expect(teamLink).toBeVisible(); + await teamLink.click(); + + await expect(page).not.toHaveURL((url) => url.searchParams.has("key")); + await expect(page.getByText("Back to Keys", { exact: true })).not.toBeVisible(); + } finally { + await deleteVirtualKey(request, key.token); + } + }); +}); + +test.describe("Request Logs row activation", () => { + test.use({ storageState: ADMIN_STORAGE_PATH }); + + test("clicking a non-interactive cell opens the single-request drawer", async ({ page, request }) => { + const suffix = uniqueSuffix(); + const key = await createKey(request, `e2e-row-log-mouse-${suffix}`); + try { + const requestId = await sendChatCompletion(request, { + model: CHAT_MODEL_A, + prompt: `row activation mouse ${suffix}`, + apiKey: key.key, + }); + await waitForSpendLog(request, requestId); + const spendLogRequestId = await readSpendLogRequestId(request, requestId); + const row = await locateRequestRow(page, spendLogRequestId); + await (await cellForHeader(page, row, "Time")).click(); + + await expect(page.getByRole("dialog").filter({ hasText: spendLogRequestId })).toBeVisible(); + } finally { + await deleteVirtualKey(request, key.token); + } + }); + + test("the row is focusable, named, and opens the drawer with Enter", async ({ page, request }) => { + const suffix = uniqueSuffix(); + const key = await createKey(request, `e2e-row-log-keyboard-${suffix}`); + try { + const requestId = await sendChatCompletion(request, { + model: CHAT_MODEL_A, + prompt: `row activation keyboard ${suffix}`, + apiKey: key.key, + }); + await waitForSpendLog(request, requestId); + const spendLogRequestId = await readSpendLogRequestId(request, requestId); + const row = await locateRequestRow(page, spendLogRequestId); + const drawer = page.getByRole("dialog").filter({ hasText: spendLogRequestId }); + + await expect.soft(row).toHaveAccessibleName(`Open request ${spendLogRequestId}`); + await row.focus(); + await expect.soft(row).toBeFocused(); + await page.keyboard.press("Enter"); + await expect.soft(drawer).toBeVisible(); + } finally { + await deleteVirtualKey(request, key.token); + } + }); +}); + +test.describe("Audit Logs row activation", () => { + test.use({ storageState: ADMIN_STORAGE_PATH }); + + test("clicking Timestamp opens that audit log", async ({ page, request }) => { + test.skip(!proxyIsPremium(), "Audit Logs require a premium proxy license"); + const team = await createAuditTeam(request); + try { + const row = await locateAuditRow(page, team.team_id); + await (await cellForHeader(page, row, "Timestamp")).click(); + + await expect(page.getByRole("dialog").filter({ hasText: team.team_id })).toBeVisible(); + } finally { + await request.post("/team/delete", { headers: adminHeaders(), data: { team_ids: [team.team_id] } }); + } + }); + + test("the row is focusable, named, and opens the drawer with Enter", async ({ page, request }) => { + test.skip(!proxyIsPremium(), "Audit Logs require a premium proxy license"); + const team = await createAuditTeam(request); + try { + const row = await locateAuditRow(page, team.team_id); + const action = (await row.getByRole("cell").nth(1).innerText()).trim(); + const drawer = page.getByRole("dialog").filter({ hasText: team.team_id }); + + await expect.soft(row).toHaveAccessibleName(`Open audit log ${action} ${team.team_id}`); + await row.focus(); + await expect.soft(row).toBeFocused(); + await page.keyboard.press("Enter"); + await expect.soft(drawer).toBeVisible(); + } finally { + await request.post("/team/delete", { headers: adminHeaders(), data: { team_ids: [team.team_id] } }); + } + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx index 5627e7fc3cb..5d1ad857b35 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx @@ -351,6 +351,7 @@ export function GuardrailsOverview({ isLoading={isLoading} noDataMessage="No data for this period" onRowClick={(row) => onSelectGuardrail(row.id)} + getRowLabel={(row) => `Open guardrail ${row.name}`} rowClassName={() => "cursor-pointer"} sortingMode="server" sorting={sorting} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.integration.test.tsx similarity index 81% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.integration.test.tsx index 506de6ad43e..25673468b5f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.integration.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; -import { renderWithProviders, screen } from "../../../../../tests/test-utils"; +import { renderWithProviders, screen, within } from "../../../../../tests/test-utils"; import { ProjectKeysTable } from "./ProjectKeysTable"; import { KeyResponse } from "@/components/key_team_helpers/key_list"; @@ -106,38 +106,56 @@ describe("ProjectKeysTable", () => { expect(screen.getByText("—")).toBeInTheDocument(); }); - it("should link the key name to that key's detail on the Virtual Keys page", () => { + it("renders the key alias as plain text and gives its row an accessible name", () => { renderWithProviders( , ); - expect(screen.getByRole("link", { name: "My API Key" })).toHaveAttribute("href", "/ui/api-keys?key=tok-abc123"); + const row = screen.getByRole("row", { name: "Open key My API Key" }); + expect(within(row).getByText("My API Key")).toBeInTheDocument(); + expect(within(row).queryByRole("link", { name: "My API Key" })).not.toBeInTheDocument(); }); - it("should navigate to the key detail without a full page load when the key name is clicked", async () => { + it("navigates to key details when plain row content is clicked", async () => { const user = userEvent.setup(); push.mockClear(); renderWithProviders( , ); - await user.click(screen.getByRole("link", { name: "My API Key" })); + await user.click(screen.getByText("My API Key")); expect(push).toHaveBeenCalledWith("/ui/api-keys?key=tok-abc123"); }); - it("should still link a key that has no alias", () => { + it("opens a focused row with Enter and labels an alias-less key with its ID", async () => { + const user = userEvent.setup(); + push.mockClear(); renderWithProviders( , ); - expect(screen.getByRole("link", { name: "—" })).toHaveAttribute("href", "/ui/api-keys?key=tok-no-alias"); + const row = screen.getByRole("row", { name: "Open key tid-no-alias" }); + row.focus(); + expect(row).toHaveFocus(); + await user.keyboard("{Enter}"); + + expect(push).toHaveBeenCalledWith("/ui/api-keys?key=tok-no-alias"); }); - it("should give each row a link to its own key", () => { + it("routes each row to its own key", async () => { + const user = userEvent.setup(); + push.mockClear(); const keys = [makeKey({ token: "tok-1", key_alias: "Key One" }), makeKey({ token: "tok-2", key_alias: "Key Two" })]; renderWithProviders(); - expect(screen.getByRole("link", { name: "Key One" })).toHaveAttribute("href", "/ui/api-keys?key=tok-1"); - expect(screen.getByRole("link", { name: "Key Two" })).toHaveAttribute("href", "/ui/api-keys?key=tok-2"); + + await user.click(screen.getByText("Key One")); + expect(push).toHaveBeenLastCalledWith("/ui/api-keys?key=tok-1"); + const secondRow = screen.getByRole("row", { name: "Open key Key Two" }); + secondRow.focus(); + await user.keyboard("{Enter}"); + expect(push).toHaveBeenLastCalledWith("/ui/api-keys?key=tok-2"); }); it("should display the owner using user.user_email when available", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx index 50f40057ec2..d9303c57b8a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx @@ -2,10 +2,12 @@ import { OnChangeFn, PaginationState } from "@tanstack/react-table"; import { KeyRound } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useMemo } from "react"; import { KeyResponse } from "@/components/key_team_helpers/key_list"; import { DataTable } from "@/components/shared/DataTable"; +import { keyDetailHref } from "@/utils/entityLinks"; import { getProjectKeysTableColumns } from "./ProjectKeysTableColumns"; import { PROJECT_KEYS_PAGE_SIZE_OPTIONS } from "./useProjectsUrlState"; @@ -39,6 +41,7 @@ export function ProjectKeysTable({ pagination, onPaginationChange, }: ProjectKeysTableProps) { + const router = useRouter(); const columns = useMemo(() => getProjectKeysTableColumns(), []); return ( @@ -56,6 +59,10 @@ export function ProjectKeysTable({ loadingMessage="Loading keys…" noDataMessage={} size="compact" + onRowClick={(key) => { + if (key.token) router.push(keyDetailHref(key.token)); + }} + getRowLabel={(key) => `Open key ${key.key_alias ?? key.token_id}`} /> ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTableColumns.tsx index 591de71ce10..34a5958e915 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTableColumns.tsx @@ -4,8 +4,7 @@ import { ColumnDef } from "@tanstack/react-table"; import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; import { KeyResponse } from "@/components/key_team_helpers/key_list"; -import { CellTooltip, DateCell, IdentityCell } from "@/components/shared/table_cells"; -import { keyDetailHref } from "@/utils/entityLinks"; +import { CellTooltip, DateCell } from "@/components/shared/table_cells"; function OwnerCell({ record }: { record: KeyResponse }) { const email = record.user?.user_email ?? record.user_id ?? null; @@ -29,13 +28,7 @@ export const getProjectKeysTableColumns = (): ColumnDef[] => [ meta: { title: "Key Name" }, header: "Key Name", enableSorting: false, - cell: ({ row }) => ( - {row.original.key_alias || "—"}} - href={row.original.token ? keyDetailHref(row.original.token) : undefined} - className="max-w-60" - /> - ), + cell: ({ row }) => {row.original.key_alias || "—"}, }, { id: "owner", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.integration.test.tsx similarity index 85% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.integration.test.tsx index aaecf98d4ab..1e959b3d4d7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.integration.test.tsx @@ -167,3 +167,41 @@ describe("ProjectsTable pagination URL state", () => { expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 2"); }); }); + +describe("ProjectsTable row activation", () => { + const renderProject = (onProjectClick: (projectId: string) => void) => + renderWithProviders( + , + ); + + it("opens project details from a plain cell and exposes the accessible row name", async () => { + const user = userEvent.setup(); + const onProjectClick = vi.fn(); + renderProject(onProjectClick); + const row = screen.getByRole("row", { name: "Open project Project 01" }); + + await user.click(within(row).getByText("proj-01")); + + expect(onProjectClick).toHaveBeenCalledWith("proj-01"); + }); + + it("opens project details when Enter is pressed on the focused row", async () => { + const user = userEvent.setup(); + const onProjectClick = vi.fn(); + renderProject(onProjectClick); + const row = screen.getByRole("row", { name: "Open project Project 01" }); + + row.focus(); + expect(row).toHaveFocus(); + await user.keyboard("{Enter}"); + + expect(onProjectClick).toHaveBeenCalledWith("proj-01"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.tsx index 1d63958faed..5230784947e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTable.tsx @@ -50,9 +50,9 @@ export function ProjectsTable({ const pageSize = PAGE_SIZE_OPTIONS.includes(pagination.pageSize) ? pagination.pageSize : PROJECTS_DEFAULT_PAGE_SIZE; const columns = useMemo(() => { - const deps = { onProjectClick, teamAliasMap, isTeamsLoading }; + const deps = { teamAliasMap, isTeamsLoading }; return getProjectsTableColumns(deps); - }, [onProjectClick, teamAliasMap, isTeamsLoading]); + }, [teamAliasMap, isTeamsLoading]); const pageCount = Math.max(Math.ceil(projects.length / pageSize), 1); const pageIndex = pagination.pageIndex < pageCount ? pagination.pageIndex : 0; @@ -83,6 +83,8 @@ export function ProjectsTable({ loadingMessage="Loading projects…" noDataMessage={} size="compact" + onRowClick={(project) => onProjectClick(project.project_id)} + getRowLabel={(project) => `Open project ${project.project_alias ?? project.project_id}`} /> ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTableColumns.tsx index 46fe259aed2..e08f235664e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsTableColumns.tsx @@ -51,13 +51,11 @@ function ProjectModelsCell({ project }: { project: ProjectResponse }) { } interface ProjectsTableColumnsDeps { - onProjectClick: (projectId: string) => void; teamAliasMap: Map; isTeamsLoading: boolean; } export const getProjectsTableColumns = ({ - onProjectClick, teamAliasMap, isTeamsLoading, }: ProjectsTableColumnsDeps): ColumnDef[] => [ @@ -68,13 +66,7 @@ export const getProjectsTableColumns = ({ header: "ID", size: 190, enableSorting: false, - cell: ({ row }) => ( - onProjectClick(row.original.project_id)} - /> - ), + cell: ({ row }) => , }, { id: "project_alias", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx index ba4c23fcc9c..d2a9fae1fbc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx @@ -530,6 +530,7 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { globalFilter={globalFilter} onGlobalFilterChange={setGlobalFilter} onRowClick={fetchRunDetail} + getRowLabel={(run) => `Open workflow run ${run.run_id}`} size="compact" toolbar={(table) => ( <> diff --git a/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx b/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx index de987360791..b7758c2f03b 100644 --- a/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx @@ -160,11 +160,7 @@ function OverflowChips({ items }: { items: string[] }) { ); } -interface PublicModelHubColumnsDeps { - onModelClick: (model: ModelGroupInfo) => void; -} - -export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumnsDeps): ColumnDef[] => { +export const getPublicModelHubColumns = (): ColumnDef[] => { const columns: ColumnDef[] = [ { id: "model_group", @@ -178,7 +174,6 @@ export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumns title={row.original.model_group} titleClassName="font-mono text-xs font-normal" className="max-w-72" - onClick={() => onModelClick(row.original)} /> ), }, @@ -309,11 +304,7 @@ export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumns })); }; -interface PublicAgentHubColumnsDeps { - onAgentClick: (agent: AgentCard) => void; -} - -export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumnsDeps): ColumnDef[] => [ +export const getPublicAgentHubColumns = (): ColumnDef[] => [ { id: "name", accessorKey: "name", @@ -323,12 +314,7 @@ export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumns enableSorting: true, sortingFn: "alphanumeric", cell: ({ row }) => ( - onAgentClick(row.original)} - /> + ), }, { @@ -401,11 +387,7 @@ export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumns }, ]; -interface PublicMCPHubColumnsDeps { - onServerClick: (server: MCPServerData) => void; -} - -export const getPublicMCPHubColumns = ({ onServerClick }: PublicMCPHubColumnsDeps): ColumnDef[] => [ +export const getPublicMCPHubColumns = (): ColumnDef[] => [ { id: "server_name", accessorKey: "server_name", @@ -419,7 +401,6 @@ export const getPublicMCPHubColumns = ({ onServerClick }: PublicMCPHubColumnsDep title={row.original.server_name} titleClassName="font-mono text-xs font-normal" className="max-w-72" - onClick={() => onServerClick(row.original)} /> ), }, diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.integration.test.tsx similarity index 97% rename from ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx rename to ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.integration.test.tsx index ccf7f453132..bf201c1ee84 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.integration.test.tsx @@ -427,20 +427,33 @@ it("sorts by spend ascending when 'Spend ascending' is chosen from the Spend / B }); }); -it("clicking the key cell deep-links via ?key=", async () => { +it("clicking a plain key cell opens the key details", async () => { + const user = userEvent.setup(); const onUrlUpdate = vi.fn(); renderWithProviders(, { onUrlUpdate }); - await waitFor(() => { - expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText("Test Key Alias")); + const row = await screen.findByRole("row", { name: "Open key Test Key Alias" }); + await user.click(within(row).getByText("Test Key Alias")); await waitFor(() => { expect(lastKeyParam(onUrlUpdate)).toBe(mockKey.token); }); expect(lastHistoryMode(onUrlUpdate)).toBe("push"); + expect(screen.getByText("Back to Keys")).toBeInTheDocument(); +}); + +it("opens a focused key row with Enter", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); + + const row = await screen.findByRole("row", { name: "Open key Test Key Alias" }); + row.focus(); + expect(row).toHaveFocus(); + await user.keyboard("{Enter}"); + + await waitFor(() => expect(lastKeyParam(onUrlUpdate)).toBe(mockKey.token)); + expect(screen.getByText("Back to Keys")).toBeInTheDocument(); }); it("renders KeyInfoView when the URL has ?key= for a key on the current page, without refetching it", async () => { @@ -556,6 +569,18 @@ describe("entity links out of the key rows", () => { expect(within(row).getByRole("link", { name: "Test Team" })).toHaveAttribute("href", "/ui/teams?team=team-1"); }); + it("does not open key details when the Team link is clicked", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); + + const row = await keyRow(); + await user.click(within(row).getByRole("link", { name: "Test Team" })); + + expect(onUrlUpdate).not.toHaveBeenCalled(); + expect(screen.queryByText("Back to Keys")).not.toBeInTheDocument(); + }); + it("points the Organization cell at the org's detail page", async () => { mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, org_id: "org-1" }])); const user = userEvent.setup(); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index 32801b693d4..b6e54e6e452 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -157,10 +157,9 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { allTeams, organizations, teamMemberBudgets, - onSelectKey: (key: KeyResponse) => void setSelectedKeyId(key.token), applyUserBudgetToTeamKeys, }), - [allTeams, organizations, teamMemberBudgets, setSelectedKeyId, applyUserBudgetToTeamKeys], + [allTeams, organizations, teamMemberBudgets, applyUserBudgetToTeamKeys], ); const columns = useMemo(() => getKeyTableColumns(columnDeps), [columnDeps]); @@ -253,6 +252,10 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { data={keyList} columns={columns} getRowId={(row) => row.token} + onRowClick={(key) => { + void setSelectedKeyId(key.token); + }} + getRowLabel={(key) => `Open key ${key.key_alias ?? key.key_name ?? key.token}`} columnVisibility={columnVisibility} onColumnVisibilityChange={onColumnVisibilityChange} sortingMode="server" diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx index 9751b9ebf2c..22541042715 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx @@ -91,7 +91,6 @@ interface KeyTableColumnsDeps { allTeams: Team[]; organizations: Organization[]; teamMemberBudgets: Readonly>; - onSelectKey: (key: KeyResponse) => void; applyUserBudgetToTeamKeys: boolean; } @@ -99,7 +98,6 @@ export const getKeyTableColumns = ({ allTeams, organizations, teamMemberBudgets, - onSelectKey, applyUserBudgetToTeamKeys, }: KeyTableColumnsDeps): ColumnDef[] => [ { @@ -134,7 +132,6 @@ export const getKeyTableColumns = ({ dataTestId={`key-status-${row.original.token_id}`} /> } - onClick={() => onSelectKey(row.original)} /> ); }, @@ -146,7 +143,7 @@ export const getKeyTableColumns = ({ header: ({ column }) => , size: 120, enableSorting: true, - cell: (info) => onSelectKey(info.row.original)} />, + cell: (info) => , }, { id: "team_alias", diff --git a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.test.tsx b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.integration.test.tsx similarity index 89% rename from ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.test.tsx rename to ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.integration.test.tsx index d8e549715af..8f7bc18dfb2 100644 --- a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.integration.test.tsx @@ -1,5 +1,5 @@ import type { ColumnFiltersState, PaginationState } from "@tanstack/react-table"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; @@ -69,11 +69,25 @@ describe("AuditLogsTable", () => { expect(screen.getByText("sk-hash-abc")).toBeInTheDocument(); }); - it("opens the detail drawer from the Object ID identity cell with the full row", async () => { + it("opens the detail drawer from a plain cell and exposes the accessible row name", async () => { const user = userEvent.setup(); const props = renderTable(); + const row = screen.getByRole("row", { name: "Open audit log Created team-obj-123" }); - await user.click(screen.getByText("team-obj-123")); + await user.click(within(row).getAllByRole("cell")[0]); + + expect(props.onViewLog).toHaveBeenCalledTimes(1); + expect(props.onViewLog).toHaveBeenCalledWith(ROWS[0]); + }); + + it("opens the audit detail drawer when Enter is pressed on the focused row", async () => { + const user = userEvent.setup(); + const props = renderTable(); + const row = screen.getByRole("row", { name: "Open audit log Created team-obj-123" }); + + row.focus(); + expect(row).toHaveFocus(); + await user.keyboard("{Enter}"); expect(props.onViewLog).toHaveBeenCalledTimes(1); expect(props.onViewLog).toHaveBeenCalledWith(ROWS[0]); diff --git a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.tsx b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.tsx index 1f972df0f59..5c785a971e1 100644 --- a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.tsx +++ b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTable.tsx @@ -13,7 +13,12 @@ import { import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { AUDIT_TABLE_NAME_DISPLAY, AuditLogEntry, getAuditLogsTableColumns } from "./AuditLogsTableColumns"; +import { + AUDIT_TABLE_NAME_DISPLAY, + AuditLogEntry, + auditActionLabel, + getAuditLogsTableColumns, +} from "./AuditLogsTableColumns"; interface AuditLogsTableProps { data: AuditLogEntry[]; @@ -112,7 +117,7 @@ export function AuditLogsTable({ onViewLog, }: AuditLogsTableProps) { const [filtersOpen, setFiltersOpen] = useState(false); - const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog }), [onViewLog]); + const columns = useMemo(() => getAuditLogsTableColumns(), []); const hasActiveSearch = Boolean(searchValue?.trim()); return ( @@ -121,6 +126,8 @@ export function AuditLogsTable({ data={data} columns={columns} getRowId={(row) => row.id} + onRowClick={onViewLog} + getRowLabel={(log) => `Open audit log ${auditActionLabel(log.action)} ${log.object_id}`} paginationMode="server" pagination={pagination} onPaginationChange={onPaginationChange} diff --git a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTableColumns.tsx b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTableColumns.tsx index 17e057a1343..c19c72b8bd2 100644 --- a/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/logs/audit/AuditLogsTableColumns.tsx @@ -40,11 +40,7 @@ export const auditActionLabel = (action: string): string => { return words ? words.charAt(0).toUpperCase() + words.slice(1) : words; }; -interface AuditLogsTableColumnsDeps { - onViewLog: (log: AuditLogEntry) => void; -} - -export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDeps): ColumnDef[] => [ +export const getAuditLogsTableColumns = (): ColumnDef[] => [ { id: "updated_at", accessorKey: "updated_at", @@ -82,9 +78,8 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep cell: ({ row }) => ( onViewLog(row.original)} /> ), }, diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.integration.test.tsx similarity index 97% rename from ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx rename to ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.integration.test.tsx index d950859f22e..c78a89e2993 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.integration.test.tsx @@ -534,7 +534,7 @@ describe("RequestLogsPanel", () => { renderPanel(); await waitFor(() => expect(row("req-1")).not.toBeNull()); - await user.click(row("req-1") as HTMLElement); + await user.click(within(row("req-1") as HTMLElement).getByText("gpt-4o")); await waitFor(() => expect(urlParams().get("log_id")).toBe("req-1")); expect(historyModes()).toEqual(["push"]); @@ -544,6 +544,20 @@ describe("RequestLogsPanel", () => { expect(drawer()).toHaveAttribute("data-log-id", "req-1"); }); + it("opens the drawer from a focused row and exposes its accessible name", async () => { + const user = userEvent.setup(); + respondWith([logEntry({ request_id: "req-keyboard" })]); + renderPanel(); + + const requestRow = await screen.findByRole("row", { name: "Open request req-keyboard" }); + requestRow.focus(); + expect(requestRow).toHaveFocus(); + await user.keyboard("{Enter}"); + + await waitFor(() => expect(drawer()).toHaveAttribute("data-log-id", "req-keyboard")); + expect(urlParams().get("log_id")).toBe("req-keyboard"); + }); + it("opens the drawer on load when ?log_id= matches a log in the loaded page", async () => { respondWith([logEntry({ request_id: "req-1" }), logEntry({ request_id: "req-2" })]); renderPanel("?log_id=req-2"); diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsTable.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsTable.tsx index 822bf73302b..0a88b4de97e 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsTable.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsTable.tsx @@ -116,6 +116,7 @@ export function RequestLogsTable({ noDataMessage={} size="compact" onRowClick={onRowClick} + getRowLabel={(row) => `Open request ${row.request_id}`} toolbar={(table) => ( <> { getRowId={(row) => row.request_id} size="compact" onRowClick={onRowClick} + getRowLabel={(row) => `Open request ${row.request_id}`} />, ); diff --git a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx b/ui/litellm-dashboard/src/components/public_model_hub.integration.test.tsx similarity index 81% rename from ui/litellm-dashboard/src/components/public_model_hub.test.tsx rename to ui/litellm-dashboard/src/components/public_model_hub.integration.test.tsx index cb23dfd9bb6..c4c7fe8e4d8 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.integration.test.tsx @@ -4,7 +4,8 @@ import { render, screen, waitFor, within, fireEvent } from "@testing-library/rea import userEvent from "@testing-library/user-event"; import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import PublicModelHub from "./public_model_hub"; -import { getPublicMCPHubColumns, MCPServerData, ModelGroupInfo } from "./PublicModelHubTableColumns"; +import { getPublicMCPHubColumns } from "./PublicModelHubTableColumns"; +import type { AgentCard, MCPServerData, ModelGroupInfo } from "./PublicModelHubTableColumns"; const { apiGetMock } = vi.hoisted(() => ({ apiGetMock: vi.fn() })); @@ -61,6 +62,16 @@ const model = (overrides: Partial & { model_group: string }): Mo }); const DEFAULT_MODELS = [model({ model_group: "gpt-4" }), model({ model_group: "claude-3", providers: ["anthropic"] })]; +const mockAgent: AgentCard = { + protocolVersion: "1", + name: "row-activation-agent", + description: "Agent used for row activation", + url: "https://example.com/agent", + version: "1.0", + defaultInputModes: [], + defaultOutputModes: [], + skills: [], +}; const respondWith = (rows: ModelGroupInfo[], totalCount: number = rows.length, pageSize: number = 50) => apiGetMock.mockImplementation((path: string) => { @@ -145,6 +156,58 @@ describe("PublicModelHub", () => { expect(modelQueries()[0]).toEqual({ page: 1, page_size: 50, sort: "model_group" }); }); + it("opens model details by clicking a plain cell and exposes the accessible row name", async () => { + const user = userEvent.setup(); + respondWith([model({ model_group: "gpt-4" })]); + renderHub(); + + const row = await screen.findByRole("row", { name: "Open model gpt-4" }); + await user.click(within(row).getByText("gpt-4")); + + expect(await screen.findByRole("heading", { name: "gpt-4" })).toBeInTheDocument(); + }); + + it("opens model details when Enter is pressed on the focused row", async () => { + const user = userEvent.setup(); + respondWith([model({ model_group: "gpt-4" })]); + renderHub(); + + const row = await screen.findByRole("row", { name: "Open model gpt-4" }); + row.focus(); + expect(row).toHaveFocus(); + await user.keyboard("{Enter}"); + + expect(await screen.findByRole("heading", { name: "gpt-4" })).toBeInTheDocument(); + }); + + it("opens agent details from a plain cell and exposes the accessible row name", async () => { + const user = userEvent.setup(); + const networkingModule = await import("./networking"); + vi.mocked(networkingModule.agentHubPublicModelsCall).mockResolvedValueOnce([mockAgent]); + renderHub(); + + await user.click(await screen.findByRole("tab", { name: "Agent Hub" })); + const row = await screen.findByRole("row", { name: "Open agent row-activation-agent" }); + await user.click(within(row).getByText("row-activation-agent")); + + expect(await screen.findByRole("heading", { name: "row-activation-agent" })).toBeInTheDocument(); + }); + + it("opens agent details when Enter is pressed on the focused row", async () => { + const user = userEvent.setup(); + const networkingModule = await import("./networking"); + vi.mocked(networkingModule.agentHubPublicModelsCall).mockResolvedValueOnce([mockAgent]); + renderHub(); + + await user.click(await screen.findByRole("tab", { name: "Agent Hub" })); + const row = await screen.findByRole("row", { name: "Open agent row-activation-agent" }); + row.focus(); + expect(row).toHaveFocus(); + await user.keyboard("{Enter}"); + + expect(await screen.findByRole("heading", { name: "row-activation-agent" })).toBeInTheDocument(); + }); + it("waits for the resolved proxy base url before asking for a page", async () => { const networkingModule = await import("./networking"); let publishConfig: () => void = () => {}; @@ -409,7 +472,7 @@ const mockMcpServer: MCPServerData = { }; function PublicMcpTestTable({ data }: { data: MCPServerData[] }) { - const columns = getPublicMCPHubColumns({ onServerClick: vi.fn() }); + const columns = getPublicMCPHubColumns(); const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }); return ( @@ -447,7 +510,7 @@ describe("publicMCPHubColumns", () => { it("does not expose a URL column header", () => { render(); expect(screen.queryByText("URL")).not.toBeInTheDocument(); - const columns = getPublicMCPHubColumns({ onServerClick: vi.fn() }); + const columns = getPublicMCPHubColumns(); expect(columns.some((c) => c.header === "URL" || c.meta?.title === "URL")).toBe(false); }); @@ -459,13 +522,15 @@ describe("publicMCPHubColumns", () => { describe("public hub MCP details modal", () => { it("does not show the upstream url when a server is opened", async () => { + const user = userEvent.setup(); const networkingModule = await import("./networking"); vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValue([mockMcpServer]); renderHub(); - fireEvent.click(await screen.findByRole("tab", { name: /MCP Hub/i })); - fireEvent.click(await screen.findByRole("button", { name: "exa_test" })); + await user.click(await screen.findByRole("tab", { name: /MCP Hub/i })); + const row = await screen.findByRole("row", { name: "Open MCP server exa_test" }); + await user.click(within(row).getByText("exa_test")); // "Server Overview" only exists inside the opened MCP details modal, // so finding it proves the modal rendered and the url assertion is not vacuous. @@ -473,14 +538,32 @@ describe("public hub MCP details modal", () => { expect(screen.queryByText(PUBLIC_SERVER_URL)).not.toBeInTheDocument(); }); + it("opens MCP server details when Enter is pressed on the focused row", async () => { + const user = userEvent.setup(); + const networkingModule = await import("./networking"); + vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValueOnce([mockMcpServer]); + + renderHub(); + + await user.click(await screen.findByRole("tab", { name: /MCP Hub/i })); + const row = await screen.findByRole("row", { name: "Open MCP server exa_test" }); + row.focus(); + expect(row).toHaveFocus(); + await user.keyboard("{Enter}"); + + expect(await screen.findByText("Server Overview")).toBeInTheDocument(); + }); + it("closes the server details modal from its close control", async () => { + const user = userEvent.setup(); const networkingModule = await import("./networking"); vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValue([mockMcpServer]); renderHub(); - fireEvent.click(await screen.findByRole("tab", { name: /MCP Hub/i })); - fireEvent.click(await screen.findByRole("button", { name: "exa_test" })); + await user.click(await screen.findByRole("tab", { name: /MCP Hub/i })); + const row = await screen.findByRole("row", { name: "Open MCP server exa_test" }); + await user.click(within(row).getByText("exa_test")); await screen.findByText("Server Overview"); fireEvent.click(screen.getByRole("button", { name: /close/i })); diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index 6102ed9556a..f8d431ce808 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -276,9 +276,9 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded const [agentSorting, setAgentSorting] = useState([{ id: "name", desc: false }]); const [mcpSorting, setMcpSorting] = useState([{ id: "server_name", desc: false }]); - const modelColumns = useMemo(() => getPublicModelHubColumns({ onModelClick: showModal }), [showModal]); - const agentColumns = useMemo(() => getPublicAgentHubColumns({ onAgentClick: showAgentModal }), [showAgentModal]); - const mcpColumns = useMemo(() => getPublicMCPHubColumns({ onServerClick: showMcpModal }), [showMcpModal]); + const modelColumns = useMemo(() => getPublicModelHubColumns(), []); + const agentColumns = useMemo(() => getPublicAgentHubColumns(), []); + const mcpColumns = useMemo(() => getPublicMCPHubColumns(), []); const hasAgents = Array.isArray(agentHubData) && agentHubData.length > 0; const hasMcpServers = Array.isArray(mcpHubData) && mcpHubData.length > 0; @@ -489,6 +489,8 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded data={models.rows} columns={modelColumns} getRowId={(model, index) => model.model_group || String(index)} + onRowClick={showModal} + getRowLabel={(model) => `Open model ${model.model_group}`} sortingMode="server" sorting={models.sorting} onSortingChange={models.onSortingChange} @@ -557,6 +559,8 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded paginationMode="client" columns={agentColumns} getRowId={(agent, index) => agent.name || String(index)} + onRowClick={showAgentModal} + getRowLabel={(agent) => `Open agent ${agent.name}`} sortingMode="client" sorting={agentSorting} onSortingChange={setAgentSorting} @@ -624,6 +628,8 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded paginationMode="client" columns={mcpColumns} getRowId={(server, index) => server.server_id || String(index)} + onRowClick={showMcpModal} + getRowLabel={(server) => `Open MCP server ${server.server_name}`} sortingMode="client" sorting={mcpSorting} onSortingChange={setMcpSorting} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test-d.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test-d.tsx index c6c6a392aec..9a3e3a5719d 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test-d.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test-d.tsx @@ -23,6 +23,13 @@ const noop = () => {}; export const uncontrolled = ; +// @ts-expect-error clickable rows require a row label +export const rowActivationRequiresAccessibleLabel = ; + +export const rowActivationAcceptsAccessibleLabel = ( + row.name} /> +); + export const controlled = ( [] = [ cell: () => ( ), }, @@ -758,11 +764,18 @@ describe("DataTable pinned columns", () => { }); }); -describe("DataTable row click guard", () => { - it("fires onRowClick from a plain cell but not from interactive elements", async () => { +describe("DataTable row activation", () => { + it("fires onRowClick exactly once from a plain cell but not from interactive elements", async () => { const user = userEvent.setup(); const onRowClick = vi.fn(); - render(); + render( + `Open person ${row.name}`} + />, + ); await user.click(screen.getByTestId("name-cell")); expect(onRowClick).toHaveBeenCalledTimes(1); @@ -771,8 +784,68 @@ describe("DataTable row click guard", () => { await user.click(screen.getByTestId("row-button")); expect(onRowClick).toHaveBeenCalledTimes(1); - await user.click(screen.getByTestId("row-input")); + await user.click(screen.getByTestId("row-link")); expect(onRowClick).toHaveBeenCalledTimes(1); + + await user.click(screen.getByTestId("row-checkbox")); + expect(onRowClick).toHaveBeenCalledTimes(1); + + await user.click(screen.getByTestId("row-click-exempt")); + expect(onRowClick).toHaveBeenCalledTimes(1); + }); + + it("activates a focused row with Enter and Space and exposes its accessible label", async () => { + const user = userEvent.setup(); + const onRowClick = vi.fn(); + render( + `Open person ${row.name}`} + />, + ); + + const row = screen.getByRole("row", { name: "Open person Alice" }); + expect(row).toHaveAttribute("tabindex", "0"); + row.focus(); + expect(row).toHaveFocus(); + + await user.keyboard("{Enter}"); + expect(onRowClick).toHaveBeenCalledTimes(1); + expect(onRowClick).toHaveBeenCalledWith(expect.objectContaining({ id: "a" })); + + await user.keyboard(" "); + expect(onRowClick).toHaveBeenCalledTimes(2); + }); + + it("does not activate the row when Enter starts on an inner button", () => { + const onRowClick = vi.fn(); + render( + `Open person ${row.name}`} + />, + ); + + fireEvent.keyDown(screen.getByTestId("row-button"), { key: "Enter", code: "Enter" }); + + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it("keeps rows without row activation unfocusable, unlabeled, and inactive", async () => { + const user = userEvent.setup(); + render(); + + const row = screen.getByText("Alice").closest("tr"); + expect(row).not.toHaveAttribute("tabindex"); + expect(row).not.toHaveAttribute("aria-label"); + + await user.click(screen.getByText("Alice")); + fireEvent.keyDown(row as HTMLTableRowElement, { key: "Enter", code: "Enter" }); + expect(row).not.toHaveFocus(); }); }); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx index a94c4e34611..5219a07f966 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -271,6 +271,7 @@ interface BodyRowProps { stickyHeader: boolean; enableColumnResizing: boolean; onRowClick?: (row: TData) => void; + getRowLabel?: (row: TData) => string; rowClassName?: (row: Row) => string; renderSubComponent?: (props: { row: Row }) => React.ReactElement; } @@ -281,6 +282,7 @@ function DataTableBodyRow({ stickyHeader, enableColumnResizing, onRowClick, + getRowLabel, rowClassName, renderSubComponent, }: BodyRowProps) { @@ -301,12 +303,32 @@ function DataTableBodyRow({ onRowClick(row.original); }; + const handleKeyDown = (event: React.KeyboardEvent) => { + if (onRowClick === undefined || event.target !== event.currentTarget) { + return; + } + if (event.key !== "Enter" && event.key !== " ") { + return; + } + event.preventDefault(); + onRowClick(row.original); + }; + return ( {cells.map((cell) => ( (props: DataTableProps(props: DataTableProps diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts index 4529e3df164..59e994a4007 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts +++ b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts @@ -68,6 +68,7 @@ export interface DataTableResolvedProps { onRowSelectionChange?: OnChangeFn; onRowClick?: (row: TData) => void; + getRowLabel?: (row: TData) => string; rowClassName?: (row: Row) => string; @@ -104,8 +105,20 @@ type DataTableBaseProps = Omit< | "defaultColumnVisibility" | "rowSelection" | "onRowSelectionChange" + | "onRowClick" + | "getRowLabel" >; +type RowActivationProps = + | { + onRowClick: (row: TData) => void; + getRowLabel: (row: TData) => string; + } + | { + onRowClick?: undefined; + getRowLabel?: undefined; + }; + type SortingProps = | { sorting: SortingState; @@ -165,6 +178,7 @@ type RowSelectionProps = | { rowSelection?: never; onRowSelectionChange?: OnChangeFn }; export type DataTableProps = DataTableBaseProps & + RowActivationProps & SortingProps & PaginationProps & FilterProps &