This commit is contained in:
jesus-berri 2026-10-17 08:01:39 -07:00 • committed by GitHub
commit 02bdc19ba4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 828 additions and 112 deletions

View file

@ -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

View file

@ -92,7 +92,7 @@ export async function openKeyDetail(page: PlaywrightPage, alias: string): Promis
await page.getByPlaceholder("Search by key alias or ID").fill(alias);
const row = page.getByRole("row").filter({ hasText: alias });
await expect(row, `key row "${alias}" never appeared on the Virtual Keys page`).toBeVisible({ timeout: 15_000 });
await row.getByRole("button", { name: alias }).click();
await row.getByText(alias, { exact: true }).click();
await expect(page.getByText("Back to Keys"), `key detail for "${alias}" never opened`).toBeVisible({
timeout: 15_000,
});

View file

@ -61,7 +61,7 @@ test.describe("Internal Viewer", () => {
// Open the viewer's own key info page
const keyRow = page.getByRole("row").filter({ hasText: E2E_VIEWER_KEY_ALIAS });
await expect(keyRow).toBeVisible({ timeout: 10_000 });
await keyRow.getByRole("button", { name: E2E_VIEWER_KEY_ALIAS }).click();
await keyRow.getByText(E2E_VIEWER_KEY_ALIAS, { exact: true }).click();
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
// None of the destructive / mutating actions should render

View file

@ -92,7 +92,7 @@ test.describe("Proxy Admin - Keys", () => {
const keyRow = page.getByRole("row").filter({ hasText: E2E_REGENERATE_KEY_ALIAS });
await expect(keyRow).toBeVisible({ timeout: 10_000 });
await keyRow.getByRole("button", { name: E2E_REGENERATE_KEY_ALIAS }).click();
await keyRow.getByText(E2E_REGENERATE_KEY_ALIAS, { exact: true }).click();
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
@ -126,7 +126,7 @@ test.describe("Proxy Admin - Keys", () => {
const keyRow = page.getByRole("row").filter({ hasText: E2E_UPDATE_LIMITS_KEY_ALIAS });
await expect(keyRow).toBeVisible({ timeout: 10_000 });
await keyRow.getByRole("button", { name: E2E_UPDATE_LIMITS_KEY_ALIAS }).click();
await keyRow.getByText(E2E_UPDATE_LIMITS_KEY_ALIAS, { exact: true }).click();
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
@ -168,7 +168,7 @@ test.describe("Proxy Admin - Keys", () => {
const keyRow = page.getByRole("row").filter({ hasText: alias });
await expect(keyRow).toBeVisible({ timeout: 10_000 });
await keyRow.getByRole("button", { name: alias }).click();
await keyRow.getByText(alias, { exact: true }).click();
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });

View file

@ -0,0 +1,277 @@
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 AuditLogListResponse {
audit_logs: Array<{ object_id: string }>;
}
interface SpendLog {
request_id: string;
}
const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const adminHeaders = (): Record<string, string> => ({ 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<T>(request: APIRequestContext, path: string, data: Record<string, unknown>): Promise<T> {
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<GeneratedKey> {
const response = await postOk<GeneratedKeyResponse>(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<Locator> {
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<CreatedTeam> {
return postOk<CreatedTeam>(request, "/team/new", { team_alias: `e2e-row-audit-${uniqueSuffix()}` });
}
async function waitForAuditLog(request: APIRequestContext, teamId: string): Promise<void> {
await expect
.poll(
async () => {
const response = await request.get(
`/audit?page=1&page_size=50&object_id=${encodeURIComponent(teamId)}`,
{ headers: adminHeaders() },
);
expect(response.ok(), `GET /audit for ${teamId} failed (${response.status()})`).toBe(true);
const body = (await response.json()) as AuditLogListResponse;
return body.audit_logs.some((entry) => entry.object_id === teamId);
},
{ timeout: 20_000 },
)
.toBe(true);
}
async function locateAuditRow(page: PlaywrightPage, teamId: string): Promise<Locator> {
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<Locator> {
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<string> {
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 {
await waitForAuditLog(request, team.team_id);
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 {
await waitForAuditLog(request, team.team_id);
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] } });
}
});
});

View file

@ -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}

View file

@ -4,6 +4,11 @@ import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { fireEvent, renderWithProviders, screen, waitFor } from "../../../../../tests/test-utils";
import { ProjectKeysSection } from "./ProjectKeysSection";
vi.mock("next/navigation", async () => ({
...(await vi.importActual("next/navigation")),
useRouter: () => ({ push: vi.fn() }),
}));
const mockUseKeys = vi.fn();
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: (...args: unknown[]) => mockUseKeys(...args),

View file

@ -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";
@ -24,7 +24,6 @@ const defaultProps = {
function makeKey(overrides: Partial<KeyResponse> = {}): KeyResponse {
return {
token: "tok-abc123",
token_id: "tid-abc123",
key_name: "sk-...abc",
key_alias: "Test Key",
spend: 0,
@ -106,38 +105,75 @@ 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(
<ProjectKeysTable {...defaultProps} keys={[makeKey({ token: "tok-abc123", key_alias: "My API Key" })]} />,
);
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("falls back to the key name in the accessible row name when the alias is empty", () => {
renderWithProviders(
<ProjectKeysTable
{...defaultProps}
keys={[makeKey({ token: "tok-empty-alias", key_name: "name-empty-alias", key_alias: "" })]}
/>,
);
expect(screen.getByRole("row", { name: "Open key name-empty-alias" })).toBeInTheDocument();
});
it("falls back to the token when the alias and key name are empty", () => {
renderWithProviders(
<ProjectKeysTable {...defaultProps} keys={[makeKey({ token: "tok-empty-name", key_name: "", key_alias: "" })]} />,
);
expect(screen.getByRole("row", { name: "Open key tok-empty-name" })).toBeInTheDocument();
});
it("navigates to key details when plain row content is clicked", async () => {
const user = userEvent.setup();
push.mockClear();
renderWithProviders(
<ProjectKeysTable {...defaultProps} keys={[makeKey({ token: "tok-abc123", key_alias: "My API Key" })]} />,
);
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", () => {
renderWithProviders(
<ProjectKeysTable
{...defaultProps}
keys={[makeKey({ token: "tok-no-alias", key_alias: "", user_id: "owner-1" })]}
/>,
);
expect(screen.getByRole("link", { name: "—" })).toHaveAttribute("href", "/ui/api-keys?key=tok-no-alias");
it("opens a focused row with Enter and labels an alias-less key with its key name", async () => {
const user = userEvent.setup();
push.mockClear();
const aliasLessKeyInput = {
token: "tok-no-alias",
key_name: "name-no-alias",
key_alias: null as any,
user_id: "owner-1",
};
const aliasLessKey = makeKey(aliasLessKeyInput);
renderWithProviders(<ProjectKeysTable {...defaultProps} keys={[aliasLessKey]} />);
const row = screen.getByRole("row", { name: "Open key name-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(<ProjectKeysTable {...defaultProps} keys={keys} />);
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", () => {

View file

@ -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={<EmptyState />}
size="compact"
onRowClick={(key) => {
if (key.token) router.push(keyDetailHref(key.token));
}}
getRowLabel={(key) => `Open key ${key.key_alias || key.key_name || key.token}`}
/>
);
}

View file

@ -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<KeyResponse>[] => [
meta: { title: "Key Name" },
header: "Key Name",
enableSorting: false,
cell: ({ row }) => (
<IdentityCell
title={<span title={row.original.key_alias ?? undefined}>{row.original.key_alias || "—"}</span>}
href={row.original.token ? keyDetailHref(row.original.token) : undefined}
className="max-w-60"
/>
),
cell: ({ row }) => <span className="text-sm">{row.original.key_alias || "—"}</span>,
},
{
id: "owner",

View file

@ -167,3 +167,47 @@ 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, project = makeProject(1)) =>
renderWithProviders(
<ProjectsTable
projects={[project]}
isLoading={false}
isFiltered={false}
onProjectClick={onProjectClick}
teamAliasMap={new Map()}
isTeamsLoading={false}
/>,
);
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("falls back to the project ID in the accessible row name when the alias is empty", () => {
renderProject(vi.fn(), { ...makeProject(1), project_alias: "" });
expect(screen.getByRole("row", { name: "Open project proj-01" })).toBeInTheDocument();
});
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");
});
});

View file

@ -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={<EmptyState isFiltered={isFiltered} />}
size="compact"
onRowClick={(project) => onProjectClick(project.project_id)}
getRowLabel={(project) => `Open project ${project.project_alias || project.project_id}`}
/>
);
}

View file

@ -51,13 +51,11 @@ function ProjectModelsCell({ project }: { project: ProjectResponse }) {
}
interface ProjectsTableColumnsDeps {
onProjectClick: (projectId: string) => void;
teamAliasMap: Map<string, string>;
isTeamsLoading: boolean;
}
export const getProjectsTableColumns = ({
onProjectClick,
teamAliasMap,
isTeamsLoading,
}: ProjectsTableColumnsDeps): ColumnDef<ProjectResponse>[] => [
@ -68,13 +66,7 @@ export const getProjectsTableColumns = ({
header: "ID",
size: 190,
enableSorting: false,
cell: ({ row }) => (
<IdentityCell
title={row.original.project_id}
titleClassName="font-mono text-xs font-normal"
onClick={() => onProjectClick(row.original.project_id)}
/>
),
cell: ({ row }) => <IdentityCell title={row.original.project_id} titleClassName="font-mono text-xs font-normal" />,
},
{
id: "project_alias",

View file

@ -530,6 +530,7 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
globalFilter={globalFilter}
onGlobalFilterChange={setGlobalFilter}
onRowClick={fetchRunDetail}
getRowLabel={(run) => `Open workflow run ${run.run_id}`}
size="compact"
toolbar={(table) => (
<>

View file

@ -162,11 +162,7 @@ function OverflowChips({ items }: { items: string[] }) {
);
}
interface PublicModelHubColumnsDeps {
onModelClick: (model: ModelGroupInfo) => void;
}
export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumnsDeps): ColumnDef<ModelGroupInfo>[] => {
export const getPublicModelHubColumns = (): ColumnDef<ModelGroupInfo>[] => {
const columns: ColumnDef<ModelGroupInfo>[] = [
{
id: "model_group",
@ -180,7 +176,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)}
/>
),
},
@ -311,11 +306,7 @@ export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumns
}));
};
interface PublicAgentHubColumnsDeps {
onAgentClick: (agent: AgentCard) => void;
}
export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumnsDeps): ColumnDef<AgentCard>[] => [
export const getPublicAgentHubColumns = (): ColumnDef<AgentCard>[] => [
{
id: "name",
accessorKey: "name",
@ -325,12 +316,7 @@ export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumns
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell
title={row.original.name}
titleClassName="font-mono text-xs font-normal"
className="max-w-72"
onClick={() => onAgentClick(row.original)}
/>
<IdentityCell title={row.original.name} titleClassName="font-mono text-xs font-normal" className="max-w-72" />
),
},
{
@ -403,11 +389,7 @@ export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumns
},
];
interface PublicMCPHubColumnsDeps {
onServerClick: (server: MCPServerData) => void;
}
export const getPublicMCPHubColumns = ({ onServerClick }: PublicMCPHubColumnsDeps): ColumnDef<MCPServerData>[] => [
export const getPublicMCPHubColumns = (): ColumnDef<MCPServerData>[] => [
{
id: "server_name",
accessorKey: "server_name",
@ -421,7 +403,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)}
/>
),
},

View file

@ -427,20 +427,41 @@ 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<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, { 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("falls back to the token in the accessible row name when the alias is empty", () => {
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, key_alias: "", key_name: "" }]));
renderWithProviders(<VirtualKeysTable />);
expect(screen.getByRole("row", { name: `Open key ${mockKey.token}` })).toBeInTheDocument();
});
it("opens a focused key row with Enter", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, { 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 +577,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<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, { 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();

View file

@ -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"

View file

@ -91,7 +91,6 @@ interface KeyTableColumnsDeps {
allTeams: Team[];
organizations: Organization[];
teamMemberBudgets: Readonly<Record<string, TeamMemberBudgetSource>>;
onSelectKey: (key: KeyResponse) => void;
applyUserBudgetToTeamKeys: boolean;
}
@ -99,7 +98,6 @@ export const getKeyTableColumns = ({
allTeams,
organizations,
teamMemberBudgets,
onSelectKey,
applyUserBudgetToTeamKeys,
}: KeyTableColumnsDeps): ColumnDef<KeyResponse>[] => [
{
@ -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 }) => <DataTableSortHeader column={column} title="Key ID" variant="header-cycle" />,
size: 120,
enableSorting: true,
cell: (info) => <IdCell value={info.getValue() as string | null} onClick={() => onSelectKey(info.row.original)} />,
cell: (info) => <IdCell value={info.getValue() as string | null} />,
},
{
id: "team_alias",

View file

@ -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]);

View file

@ -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}

View file

@ -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<AuditLogEntry>[] => [
export const getAuditLogsTableColumns = (): ColumnDef<AuditLogEntry>[] => [
{
id: "updated_at",
accessorKey: "updated_at",
@ -82,9 +78,8 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep
cell: ({ row }) => (
<IdentityCell
title={row.original.object_id}
titleClassName="font-mono text-xs font-normal text-primary"
titleClassName="font-mono text-xs font-normal"
className="max-w-72"
onClick={() => onViewLog(row.original)}
/>
),
},

View file

@ -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,36 @@ 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 a multi-request session from its focused row and exposes its accessible name", async () => {
const user = userEvent.setup();
respondWith([
logEntry({ request_id: "req-session-keyboard", session_id: "sess-keyboard", session_total_count: 3 }),
]);
renderPanel();
const sessionRow = await screen.findByRole("row", { name: "Open session sess-keyboard" });
sessionRow.focus();
expect(sessionRow).toHaveFocus();
await user.keyboard("{Enter}");
await waitFor(() => expect(drawer()).toHaveAttribute("data-session-id", "sess-keyboard"));
expect(urlParams().get("log_id")).toBe("req-session-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");

View file

@ -22,7 +22,7 @@ import {
type PaginatedResponse,
useLogFilterLogic,
} from "./useLogFilterLogic";
import { useLogDetailRouting } from "./logDetailRouting";
import { isMultiRequestSession, useLogDetailRouting } from "./logDetailRouting";
import { LogDetailsDrawer } from "../detail";
import {
defaultLogsTimeRange,
@ -253,7 +253,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
const handleRowClick = useCallback(
(log: LogEntry) => {
setSelectedLog(log);
if (log.session_id && (log.session_total_count || 1) > 1) {
if (isMultiRequestSession(log) && log.session_id) {
openSession(log.session_id, log.request_id);
} else {
openLog(log.request_id);

View file

@ -13,6 +13,7 @@ import { CREDENTIAL_LABELS, SPAN_TYPE_LABELS } from "../constants";
import { LOG_FILTER_IDS, LOG_FILTER_LABELS, type LogsWindow } from "./useLogFilterLogic";
import { RequestLogsFilters } from "./RequestLogsFilters";
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
import { requestLogRowLabel } from "./logDetailRouting";
interface RequestLogsTableProps {
data: LogEntry[];
@ -116,6 +117,7 @@ export function RequestLogsTable({
noDataMessage={<RequestLogsEmptyState filtered={isFiltered} />}
size="compact"
onRowClick={onRowClick}
getRowLabel={requestLogRowLabel}
toolbar={(table) => (
<>
<DataTableToolbar

View file

@ -6,6 +6,7 @@ import { DataTable } from "@/components/shared/DataTable";
import type { LogEntry } from "../types";
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
import { requestLogRowLabel } from "./logDetailRouting";
const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() }));
@ -303,6 +304,7 @@ describe("Request ID column", () => {
getRowId={(row) => row.request_id}
size="compact"
onRowClick={onRowClick}
getRowLabel={requestLogRowLabel}
/>,
);

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { LogEntry } from "../types";
import { isMultiRequestSession, requestLogRowLabel } from "./logDetailRouting";
const logEntry = (overrides: Partial<LogEntry> = {}): LogEntry => ({
request_id: "r1",
api_key: "key-1",
team_id: "team-1",
model: "gpt-4o",
model_id: "model-1",
call_type: "acompletion",
spend: 0,
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
startTime: "2026-07-07T09:50:13Z",
endTime: "2026-07-07T09:50:14Z",
cache_hit: "false",
messages: [],
response: {},
...overrides,
});
describe("Request Logs row labels", () => {
it("labels a multi-request session by its session id", () => {
const log = logEntry({ session_id: "s1", session_total_count: 3 });
expect(isMultiRequestSession(log)).toBe(true);
expect(requestLogRowLabel(log)).toBe("Open session s1");
});
it("labels a single-request session by its request id", () => {
const log = logEntry({ session_id: "s1", session_total_count: 1 });
expect(isMultiRequestSession(log)).toBe(false);
expect(requestLogRowLabel(log)).toBe("Open request r1");
});
it("labels a request without a session by its request id", () => {
const log = logEntry({ session_total_count: 3 });
expect(isMultiRequestSession(log)).toBe(false);
expect(requestLogRowLabel(log)).toBe("Open request r1");
});
it("defaults an undefined session count to a request label", () => {
const log = logEntry({ request_id: "", session_id: "s1", session_total_count: undefined });
expect(isMultiRequestSession(log)).toBe(false);
expect(requestLogRowLabel(log)).toBe("Open request");
});
});

View file

@ -1,9 +1,22 @@
import { parseAsString, useQueryStates } from "nuqs";
import { useCallback } from "react";
import type { LogEntry } from "../types";
export const LOG_ID_QUERY_PARAM = "log_id";
export const SESSION_ID_QUERY_PARAM = "session_id";
export function isMultiRequestSession(log: LogEntry): boolean {
return Boolean(log.session_id) && (log.session_total_count || 1) > 1;
}
export function requestLogRowLabel(log: LogEntry): string {
if (isMultiRequestSession(log)) {
return `Open session ${log.session_id}`;
}
return log.request_id ? `Open request ${log.request_id}` : "Open request";
}
export interface LogDetailRouting {
logId: string | null;
sessionId: string | null;

View file

@ -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() }));
@ -63,6 +64,16 @@ const model = (overrides: Partial<ModelGroupInfo> & { 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[],
@ -152,6 +163,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 = () => {};
@ -307,7 +370,7 @@ describe("PublicModelHub", () => {
respondWith([model({ model_group: "jev-latest", providers: ["typesafe"], mode: "evaluation" })]);
renderHub();
fireEvent.click(await screen.findByRole("button", { name: "jev-latest" }));
fireEvent.click(await screen.findByRole("row", { name: "Open model jev-latest" }));
const example = (await screen.findByText(/\/v1\/systemone/)).closest("pre");
expect(example).toHaveTextContent('"model": "jev-latest"');
@ -317,7 +380,7 @@ describe("PublicModelHub", () => {
it("keeps the chat completions usage example for a chat model", async () => {
renderHub();
fireEvent.click(await screen.findByRole("button", { name: "gpt-4" }));
fireEvent.click(await screen.findByRole("row", { name: "Open model gpt-4" }));
const example = (await screen.findByText(/chat\.completions\.create/)).closest("pre");
expect(example).toHaveTextContent('model="gpt-4"');
@ -455,7 +518,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 (
@ -493,7 +556,7 @@ describe("publicMCPHubColumns", () => {
it("does not expose a URL column header", () => {
render(<PublicMcpTestTable data={[mockMcpServer]} />);
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);
});
@ -504,14 +567,32 @@ describe("publicMCPHubColumns", () => {
});
describe("public hub MCP details modal", () => {
it("uses the alias in the accessible label when the server name is empty", async () => {
const networkingModule = await import("./networking");
const serverWithoutName = {
...mockMcpServer,
server_id: "server-fallback-id",
server_name: "",
alias: "server-fallback-alias",
};
vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValueOnce([serverWithoutName]);
renderHub();
await userEvent.click(await screen.findByRole("tab", { name: /MCP Hub/i }));
expect(await screen.findByRole("row", { name: "Open MCP server server-fallback-alias" })).toBeInTheDocument();
});
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.
@ -519,6 +600,22 @@ 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("uses the backend URL in displayed and copied MCP examples", async () => {
const networkingModule = await import("./networking");
vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValue([mockMcpServer]);
@ -528,7 +625,7 @@ describe("public hub MCP details modal", () => {
renderHub();
await user.click(await screen.findByRole("tab", { name: /MCP Hub/i }));
await user.click(await screen.findByRole("button", { name: "exa_test" }));
await user.click(await screen.findByRole("row", { name: "Open MCP server exa_test" }));
await screen.findByText("Server Overview");
expect(screen.getByText(/https:\/\/backend\.example\.com\/exa_test\/mcp/)).toBeInTheDocument();
@ -543,13 +640,15 @@ describe("public hub MCP details modal", () => {
});
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 }));

View file

@ -305,9 +305,9 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
const [agentSorting, setAgentSorting] = useState<SortingState>([{ id: "name", desc: false }]);
const [mcpSorting, setMcpSorting] = useState<SortingState>([{ 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;
@ -518,6 +518,8 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ 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}
@ -586,6 +588,8 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ 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}
@ -653,6 +657,10 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
paginationMode="client"
columns={mcpColumns}
getRowId={(server, index) => server.server_id || String(index)}
onRowClick={showMcpModal}
getRowLabel={(server) =>
`Open MCP server ${server.server_name || server.alias || server.server_id}`
}
sortingMode="client"
sorting={mcpSorting}
onSortingChange={setMcpSorting}

View file

@ -23,6 +23,13 @@ const noop = () => {};
export const uncontrolled = <DataTable data={data} columns={columns} defaultSorting={sorting} />;
// @ts-expect-error clickable rows require a row label
export const rowActivationRequiresAccessibleLabel = <DataTable data={data} columns={columns} onRowClick={noop} />;
export const rowActivationAcceptsAccessibleLabel = (
<DataTable data={data} columns={columns} onRowClick={noop} getRowLabel={(row) => row.name} />
);
export const controlled = (
<DataTable
data={data}

View file

@ -1,5 +1,5 @@
import type { ColumnDef, ExpandedState, OnChangeFn, PaginationState, VisibilityState } from "@tanstack/react-table";
import { render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
@ -118,7 +118,13 @@ const rowClickColumns: ColumnDef<Person, unknown>[] = [
cell: () => (
<div>
<button data-testid="row-button">Act</button>
<input data-testid="row-input" aria-label="row input" />
<a href="#" data-testid="row-link" onClick={(event) => event.preventDefault()}>
Link
</a>
<input type="checkbox" data-testid="row-checkbox" aria-label="row checkbox" />
<div data-row-click-exempt data-testid="row-click-exempt">
Exempt
</div>
</div>
),
},
@ -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(<DataTable data={[person("a", "Alice")]} columns={rowClickColumns} onRowClick={onRowClick} />);
render(
<DataTable
data={[person("a", "Alice")]}
columns={rowClickColumns}
onRowClick={onRowClick}
getRowLabel={(row) => `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(
<DataTable
data={[person("a", "Alice")]}
columns={rowClickColumns}
onRowClick={onRowClick}
getRowLabel={(row) => `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(
<DataTable
data={[person("a", "Alice")]}
columns={rowClickColumns}
onRowClick={onRowClick}
getRowLabel={(row) => `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(<DataTable data={[person("a", "Alice")]} columns={rowClickColumns} />);
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();
});
});

View file

@ -271,6 +271,7 @@ interface BodyRowProps<TData> {
stickyHeader: boolean;
enableColumnResizing: boolean;
onRowClick?: (row: TData) => void;
getRowLabel?: (row: TData) => string;
rowClassName?: (row: Row<TData>) => string;
renderSubComponent?: (props: { row: Row<TData> }) => React.ReactElement;
}
@ -281,6 +282,7 @@ function DataTableBodyRow<TData>({
stickyHeader,
enableColumnResizing,
onRowClick,
getRowLabel,
rowClassName,
renderSubComponent,
}: BodyRowProps<TData>) {
@ -301,12 +303,32 @@ function DataTableBodyRow<TData>({
onRowClick(row.original);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTableRowElement>) => {
if (onRowClick === undefined || event.target !== event.currentTarget) {
return;
}
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
onRowClick(row.original);
};
return (
<Fragment>
<TableRow
data-row-id={row.id}
className={cn(clickable ? "cursor-pointer" : "", size === "compact" ? "h-8" : "", rowClassName?.(row))}
tabIndex={clickable ? 0 : undefined}
aria-label={clickable ? getRowLabel?.(row.original) : undefined}
className={cn(
clickable
? "cursor-pointer focus-visible:outline-2 focus-visible:outline-primary focus-visible:-outline-offset-2"
: "",
size === "compact" ? "h-8" : "",
rowClassName?.(row),
)}
onClick={clickable ? handleClick : undefined}
onKeyDown={clickable ? handleKeyDown : undefined}
>
{cells.map((cell) => (
<DataTableBodyCell
@ -607,6 +629,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
enableColumnResizing = false,
onRowClick,
getRowLabel,
rowClassName,
renderSubComponent,
maxBodyHeight,
@ -673,6 +696,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
stickyHeader={stickyHeader}
enableColumnResizing={enableColumnResizing}
onRowClick={onRowClick}
getRowLabel={getRowLabel}
rowClassName={rowClassName}
renderSubComponent={renderSubComponent}
/>

View file

@ -68,6 +68,7 @@ export interface DataTableResolvedProps<TData extends RowData, TValue> {
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
onRowClick?: (row: TData) => void;
getRowLabel?: (row: TData) => string;
rowClassName?: (row: Row<TData>) => string;
@ -104,8 +105,20 @@ type DataTableBaseProps<TData extends RowData, TValue> = Omit<
| "defaultColumnVisibility"
| "rowSelection"
| "onRowSelectionChange"
| "onRowClick"
| "getRowLabel"
>;
type RowActivationProps<TData> =
| {
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<RowSelectionState> };
export type DataTableProps<TData extends RowData, TValue> = DataTableBaseProps<TData, TValue> &
RowActivationProps<TData> &
SortingProps &
PaginationProps &
FilterProps &