This commit is contained in:
ryan-crabbe-berri 2026-10-08 16:47:54 +03:00 • committed by GitHub
commit 5bd963187d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1181 additions and 116 deletions

View file

@ -1,5 +1,6 @@
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils";
@ -95,7 +96,7 @@ const DEPLOYMENTS = [
},
];
const pageOf = (data: typeof DEPLOYMENTS) => ({
const pageOf = (data: readonly unknown[]) => ({
data,
total_count: data.length,
current_page: 1,
@ -127,6 +128,12 @@ const A_FULL_PAGE_AND_TWO_MORE = Array.from({ length: 12 }, (_, index) => ({
},
}));
const JUST_CREATED_ROUTER = {
...A_FULL_PAGE_AND_TWO_MORE[0],
model_name: "router-13-just-created",
model_info: { id: "bulk-13", db_model: true, created_at: "2026-08-13T00:00:00.000000+00:00" },
};
/** Row order as rendered, header row dropped. */
const routerNamesInOrder = () =>
screen
@ -134,7 +141,7 @@ const routerNamesInOrder = () =>
.slice(1)
.map((row) => row.querySelector("span.text-sm.font-medium")?.textContent ?? "");
const renderPanel = (canModify = true) =>
const renderPanel = (canModify = true, url: { searchParams?: string; onUrlUpdate?: OnUrlUpdateFunction } = {}) =>
renderWithProviders(
<AutoRoutersPanel
accessToken="token"
@ -144,8 +151,15 @@ const renderPanel = (canModify = true) =>
teams={null}
createScope={canModify ? "unscoped-ok" : "forbidden"}
/>,
url,
);
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn<OnUrlUpdateFunction>>) => {
const lastCall = onUrlUpdate.mock.calls.at(-1);
if (!lastCall) throw new Error("expected a URL update");
return lastCall[0].searchParams;
};
describe("AutoRoutersPanel", () => {
beforeEach(() => {
// The shared test client caches with staleTime: Infinity and refetchOnMount: false, so
@ -301,6 +315,16 @@ describe("AutoRoutersPanel", () => {
]);
});
it("breaks created-at ties by name whatever order the proxy returns them in", async () => {
modelInfoCall.mockResolvedValue(pageOf([...DEPLOYMENTS].reverse()));
renderPanel();
await screen.findByText("tri-tier-router");
expect(routerNamesInOrder()).toEqual(["tri-tier-router", "support-router", "adaptive-router", "config-router"]);
});
// The reported bug: the newest router was rendered last, so it landed on page 2 and read
// as never created.
it("puts a just-created router on the first page of a list longer than one page", async () => {
@ -313,4 +337,61 @@ describe("AutoRoutersPanel", () => {
expect(screen.queryByRole("button", { name: "router-01-oldest" })).not.toBeInTheDocument();
expect(routerNamesInOrder()[0]).toBe("router-12-newest");
});
describe("URL state", () => {
it("opens on the page named in the URL once the routers load", async () => {
modelInfoCall.mockResolvedValue(pageOf(A_FULL_PAGE_AND_TWO_MORE));
renderPanel(true, { searchParams: "?auto_routers_page=2" });
expect(await screen.findByRole("button", { name: "router-01-oldest" })).toBeInTheDocument();
expect(routerNamesInOrder()).toEqual(["router-2", "router-01-oldest"]);
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2");
});
it("returns to the first page after a create so the new router is in view", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
modelInfoCall.mockResolvedValue(pageOf(A_FULL_PAGE_AND_TWO_MORE));
renderPanel(true, { searchParams: "?auto_routers_page=2", onUrlUpdate });
await screen.findByRole("button", { name: "router-01-oldest" });
modelInfoCall.mockResolvedValue(pageOf([...A_FULL_PAGE_AND_TWO_MORE, JUST_CREATED_ROUTER]));
await user.click(screen.getByRole("button", { name: "Add Auto Router" }));
await user.click(await screen.findByRole("button", { name: "Submit auto router" }));
expect(await screen.findByRole("button", { name: "router-13-just-created" })).toBeInTheDocument();
await waitFor(() => expect(lastUrl(onUrlUpdate).has("auto_routers_page")).toBe(false));
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 2");
});
it("sorts by the column and direction named in the URL", async () => {
renderPanel(true, { searchParams: "?auto_routers_sort_by=name&auto_routers_sort_order=asc" });
await screen.findByText("tri-tier-router");
expect(routerNamesInOrder()).toEqual(["adaptive-router", "config-router", "support-router", "tri-tier-router"]);
});
it("writes the page and the sort column to the URL", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
modelInfoCall.mockResolvedValue(pageOf(A_FULL_PAGE_AND_TWO_MORE));
renderPanel(true, { onUrlUpdate });
await screen.findByRole("button", { name: "router-12-newest" });
await user.click(screen.getByTestId("pagination-next"));
await waitFor(() => expect(lastUrl(onUrlUpdate).get("auto_routers_page")).toBe("2"));
expect(routerNamesInOrder()).toEqual(["router-2", "router-01-oldest"]);
await user.click(screen.getByTestId("sort-header-name"));
await waitFor(() => expect(lastUrl(onUrlUpdate).get("auto_routers_sort_by")).toBe("name"));
expect(lastUrl(onUrlUpdate).get("auto_routers_sort_order")).toBe("asc");
expect(lastUrl(onUrlUpdate).get("auto_routers_page")).toBeNull();
expect(routerNamesInOrder()[0]).toBe("router-01-oldest");
});
});
});

View file

@ -14,7 +14,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
import { type ModelWriteScope } from "@/utils/modelPermissions";
import { Team } from "@/components/networking";
import { AutoRoutersTable } from "./AutoRoutersTable";
import { AutoRoutersTable, useAutoRoutersTableState } from "./AutoRoutersTable";
import { AutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
interface AutoRoutersPanelProps {
@ -42,6 +42,7 @@ export function AutoRoutersPanel({
// router gets the full ModelInfoView: Model Settings, Edit Settings, Edit Auto Router and
// Delete. A separate detail view here would be a worse copy of it.
const { openModel } = useModelDetailRouting();
const { onPaginationChange } = useAutoRoutersTableState();
const [isCreating, setIsCreating] = useState(false);
const [deletingRouter, setDeletingRouter] = useState<AutoRouterRow | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
@ -53,6 +54,7 @@ export function AutoRoutersPanel({
const handleCreated = () => {
setIsCreating(false);
onPaginationChange((page) => ({ ...page, pageIndex: 0 }));
void invalidateAutoRouters();
};

View file

@ -1,9 +1,13 @@
"use client";
import { SortingState } from "@tanstack/react-table";
import { useMemo } from "react";
import { DataTable } from "@/components/shared/DataTable";
import {
DataTable,
useUrlTableState,
type UrlTableState,
type UrlTableStateOptions,
} from "@/components/shared/DataTable";
import { AutoRouterIcon } from "@/components/shared/table_cells";
import { getAutoRoutersTableColumns } from "./AutoRoutersTableColumns";
@ -19,10 +23,20 @@ interface AutoRoutersTableProps {
const PAGE_SIZE_OPTIONS = [10, 25, 50];
const DEFAULT_SORTING: SortingState = [
{ id: "createdAt", desc: true },
{ id: "name", desc: false },
];
const TABLE_STATE_OPTIONS: UrlTableStateOptions<never> = {
sortFields: ["name", "createdAt"],
defaultSort: { id: "createdAt", desc: true },
defaultPageSize: PAGE_SIZE_OPTIONS[0],
maxPageSize: Math.max(...PAGE_SIZE_OPTIONS),
filterColumns: [],
keyPrefix: "auto_routers_",
};
const byName = (a: AutoRouterRow, b: AutoRouterRow) => a.name.localeCompare(b.name);
export function useAutoRoutersTableState(): UrlTableState {
return useUrlTableState(TABLE_STATE_OPTIONS);
}
function EmptyState({ canModify }: { canModify: boolean }) {
return (
@ -47,19 +61,24 @@ export function AutoRoutersTable({
onRouterClick,
onDeleteClick,
}: AutoRoutersTableProps) {
const { sorting, onSortingChange, pagination, onPaginationChange } = useAutoRoutersTableState();
const columns = useMemo(
() => getAutoRoutersTableColumns({ canModify, onRouterClick, onDeleteClick }),
[canModify, onRouterClick, onDeleteClick],
);
const routersByName = useMemo(() => [...routers].sort(byName), [routers]);
return (
<DataTable
data={routers}
data={routersByName}
columns={columns}
getRowId={(router) => router.id}
sortingMode="client"
defaultSorting={DEFAULT_SORTING}
sorting={sorting}
onSortingChange={onSortingChange}
paginationMode="client"
pagination={pagination}
onPaginationChange={onPaginationChange}
pageSizeOptions={PAGE_SIZE_OPTIONS}
isLoading={isLoading}
loadingMessage="Loading auto routers…"

View file

@ -1,9 +1,11 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, renderWithProviders, screen, waitFor, within } from "@/../tests/test-utils";
const { GET, PUT, DELETE, userRole } = vi.hoisted(() => ({
GET: vi.fn(),
PUT: vi.fn(),
@ -40,15 +42,22 @@ const FREE_GROUP = {
budget: null,
};
const renderPanel = () => {
const renderPanel = (url: { searchParams?: string; onUrlUpdate?: OnUrlUpdateFunction } = {}) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
return renderWithProviders(
<QueryClientProvider client={queryClient}>
<AccessGroupBudgetsPanel />
</QueryClientProvider>,
url,
);
};
const groupNamesInOrder = () =>
screen
.getAllByRole("row")
.slice(1)
.map((row) => within(row).getAllByRole("cell")[0].textContent);
const openActions = async (accessGroup: string) => {
await userEvent.click(await screen.findByTestId(`access-group-actions-${accessGroup}`));
};
@ -162,4 +171,68 @@ describe("AccessGroupBudgetsPanel", () => {
}),
);
});
describe("URL table state", () => {
const manyGroups = Array.from({ length: 27 }, (_, index) => ({
...FREE_GROUP,
access_group: `group-${String(index + 1).padStart(2, "0")}`,
}));
it("sorts by the column and direction named in the URL", async () => {
renderPanel({ searchParams: "?ag_budgets_sort_by=spend&ag_budgets_sort_order=asc" });
await screen.findByText("premium");
expect(groupNamesInOrder()).toEqual(["shared", "premium"]);
});
it("sorts by deployment count when the URL names it", async () => {
GET.mockResolvedValue({
data: {
access_groups: [BUDGETED_GROUP, FREE_GROUP, { ...FREE_GROUP, access_group: "a-many", deployment_count: 3 }],
},
});
renderPanel({ searchParams: "?ag_budgets_sort_by=deployment_count&ag_budgets_sort_order=asc" });
await screen.findByText("a-many");
expect(groupNamesInOrder()).toEqual(["premium", "shared", "a-many"]);
});
it("writes a header sort to the URL", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderPanel({ onUrlUpdate });
await screen.findByText("premium");
await userEvent.click(screen.getByTestId("sort-header-access_group"));
await waitFor(() =>
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("ag_budgets_sort_order")).toBe("desc"),
);
expect(groupNamesInOrder()).toEqual(["shared", "premium"]);
});
it("opens on the page named in the URL once the groups load", async () => {
GET.mockResolvedValue({ data: { access_groups: manyGroups } });
renderPanel({ searchParams: "?ag_budgets_page=2" });
expect(await screen.findByText("group-27")).toBeInTheDocument();
expect(groupNamesInOrder()).toEqual(["group-26", "group-27"]);
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2");
});
it("writes a page change to the URL", async () => {
GET.mockResolvedValue({ data: { access_groups: manyGroups } });
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderPanel({ onUrlUpdate });
await screen.findByText("group-01");
await userEvent.click(screen.getByTestId("pagination-next"));
await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("ag_budgets_page")).toBe("2"));
expect(groupNamesInOrder()).toEqual(["group-26", "group-27"]);
});
});
});

View file

@ -1,11 +1,15 @@
"use client";
import { SortingState } from "@tanstack/react-table";
import { Inbox } from "lucide-react";
import React, { useMemo, useState } from "react";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import { DataTable } from "@/components/shared/DataTable";
import {
DataTable,
DEFAULT_PAGE_SIZE_OPTIONS,
useUrlTableState,
type UrlTableStateOptions,
} from "@/components/shared/DataTable";
import { toast } from "@/lib/toast";
import { isProxyAdminRole } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
@ -18,7 +22,13 @@ import {
import AccessGroupBudgetModal from "@/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal";
import { getAccessGroupBudgetColumns } from "@/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns";
const DEFAULT_SORTING: SortingState = [{ id: "access_group", desc: false }];
const TABLE_STATE_OPTIONS: UrlTableStateOptions<never> = {
sortFields: ["access_group", "deployment_count", "spend"],
defaultSort: { id: "access_group", desc: false },
defaultPageSize: DEFAULT_PAGE_SIZE_OPTIONS[0],
filterColumns: [],
keyPrefix: "ag_budgets_",
};
function EmptyState() {
return (
@ -40,7 +50,7 @@ export default function AccessGroupBudgetsPanel() {
const setBudget = useSetModelAccessGroupBudget();
const clearBudget = useDeleteModelAccessGroupBudget();
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const { sorting, onSortingChange, pagination, onPaginationChange } = useUrlTableState(TABLE_STATE_OPTIONS);
const [editing, setEditing] = useState<ModelAccessGroup | null>(null);
const [clearing, setClearing] = useState<ModelAccessGroup | null>(null);
@ -85,11 +95,13 @@ export default function AccessGroupBudgetsPanel() {
<DataTable
data={accessGroups ?? []}
paginationMode="client"
pagination={pagination}
onPaginationChange={onPaginationChange}
columns={columns}
getRowId={(group) => group.access_group}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
onSortingChange={onSortingChange}
isLoading={isLoading}
loadingMessage="Loading model access groups…"
noDataMessage={<EmptyState />}

View file

@ -0,0 +1,82 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@/../tests/test-utils";
import HealthStatusPanel from "./HealthStatusPanel";
const { modelInfoCall, latestHealthChecksCall } = vi.hoisted(() => ({
modelInfoCall: vi.fn(),
latestHealthChecksCall: vi.fn(),
}));
vi.mock("@/components/networking", () => ({
modelInfoCall,
latestHealthChecksCall,
individualModelHealthCheckCall: vi.fn(),
modelHubCall: vi.fn(),
modelAvailableCall: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "sk-test", userId: "u-admin", userRole: "Admin" }),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) }));
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostMap: () => ({ data: {} }) }));
const PAGE_ARG = 3;
const TEN_MODELS_FIRST_PAGE = { data: [], total_count: 10, current_page: 1, total_pages: 1, size: 50 };
const renderPanel = (searchParams: string, onUrlUpdate: OnUrlUpdateFunction) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<HealthStatusPanel />, {
wrapper: ({ children }: { children: ReactNode }) => (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onUrlUpdate}
hasMemory
resetUrlUpdateQueueOnMount={false}
>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</NuqsTestingAdapter>
),
});
return queryClient;
};
const flushUrlUpdates = () => act(() => new Promise((resolve) => setTimeout(resolve, 50)));
describe("HealthStatusPanel health_page", () => {
beforeEach(() => {
modelInfoCall.mockReset();
latestHealthChecksCall.mockReset();
latestHealthChecksCall.mockResolvedValue({ latest_health_checks: {} });
});
it("snaps a page past the end back to the first page once the models load", async () => {
modelInfoCall.mockResolvedValue(TEN_MODELS_FIRST_PAGE);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderPanel("?health_page=3", onUrlUpdate);
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(modelInfoCall.mock.calls[0][PAGE_ARG]).toBe(3);
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has("health_page")).toBe(false);
});
it("keeps the page in the URL when fetching it fails", async () => {
modelInfoCall.mockRejectedValue(new Error("proxy unavailable"));
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const queryClient = renderPanel("?health_page=3", onUrlUpdate);
await waitFor(() => expect(queryClient.getQueryCache().getAll()[0]?.state.status).toBe("error"));
await flushUrlUpdates();
expect(modelInfoCall.mock.calls[0][PAGE_ARG]).toBe(3);
expect(onUrlUpdate).not.toHaveBeenCalled();
expect(screen.queryByText("No models found")).not.toBeInTheDocument();
});
});

View file

@ -1,23 +1,38 @@
/* @vitest-environment jsdom */
import { render } from "@testing-library/react";
import { withNuqsTestingAdapter } from "nuqs/adapters/testing";
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
import { act, render, waitFor } from "@testing-library/react";
import { type OnUrlUpdateFunction, withNuqsTestingAdapter } from "nuqs/adapters/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import HealthStatusPanel from "./HealthStatusPanel";
const mockHealthCheckComponent = vi.fn((_props: { all_models_on_proxy?: string[] }) => null);
interface CapturedHealthCheckProps {
all_models_on_proxy?: string[];
pagination?: PaginationState;
onPaginationChange?: OnChangeFn<PaginationState>;
}
const mockHealthCheckComponent = vi.fn((_props: CapturedHealthCheckProps) => null);
vi.mock("@/components/model_dashboard/HealthCheckComponent", () => ({
default: (props: { all_models_on_proxy?: string[] }) => {
default: (props: CapturedHealthCheckProps) => {
mockHealthCheckComponent(props);
return null;
},
}));
const lastHealthCheckProps = (): CapturedHealthCheckProps => {
const lastCall = mockHealthCheckComponent.mock.calls.at(-1);
if (!lastCall) throw new Error("HealthCheckComponent was not rendered");
return lastCall[0];
};
vi.mock("@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer", () => ({
transformModelData: () => ({ data: [] }),
}));
const mockUseModelsInfo = vi.fn();
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ useModelsInfo: () => mockUseModelsInfo() }));
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useModelsInfo: (...args: unknown[]) => mockUseModelsInfo(...args),
}));
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostMap: () => ({ data: {} }) }));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "123" }) }));
@ -25,6 +40,31 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ acce
describe("HealthStatusPanel", () => {
beforeEach(() => {
mockHealthCheckComponent.mockClear();
mockUseModelsInfo.mockReset();
mockUseModelsInfo.mockReturnValue({ data: undefined, isLoading: true });
});
it("fetches and shows the page and page size named in the URL", () => {
render(<HealthStatusPanel />, {
wrapper: withNuqsTestingAdapter({ searchParams: "?health_page=3&health_page_size=25" }),
});
expect(mockUseModelsInfo).toHaveBeenLastCalledWith(3, 25);
expect(lastHealthCheckProps().pagination).toEqual({ pageIndex: 2, pageSize: 25 });
});
it("writes table page changes to the URL and refetches that page", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
render(<HealthStatusPanel />, { wrapper: withNuqsTestingAdapter({ onUrlUpdate, hasMemory: true }) });
expect(mockUseModelsInfo).toHaveBeenLastCalledWith(1, 50);
act(() => lastHealthCheckProps().onPaginationChange?.({ pageIndex: 1, pageSize: 50 }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const params = onUrlUpdate.mock.calls.at(-1)?.[0].searchParams;
expect(params?.get("health_page")).toBe("2");
expect(params?.get("health_page_size")).toBeNull();
await waitFor(() => expect(mockUseModelsInfo).toHaveBeenLastCalledWith(2, 50));
});
it("passes deployment ids (not model names) to HealthCheckComponent as all_models_on_proxy", () => {

View file

@ -1,8 +1,8 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import { useCallback, useMemo } from "react";
import HealthCheckComponent from "@/components/model_dashboard/HealthCheckComponent";
import { useHealthTableUrlState } from "@/components/model_dashboard/useHealthTableUrlState";
import { getDisplayModelName } from "@/components/view_model/model_name_display";
import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
@ -11,15 +11,18 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { transformModelData } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer";
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
const HEALTH_PAGE_SIZE = 50;
export default function HealthStatusPanel() {
const { accessToken } = useAuthorized();
const { data: teams } = useTeams();
const { data: modelCostMapData } = useModelCostMap();
const { openModel } = useModelDetailRouting();
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: HEALTH_PAGE_SIZE });
const { data: healthModelDataResponse, isLoading } = useModelsInfo(pagination.pageIndex + 1, pagination.pageSize);
const { pagination, onPaginationChange } = useHealthTableUrlState();
const {
data: healthModelDataResponse,
isLoading,
isError,
} = useModelsInfo(pagination.pageIndex + 1, pagination.pageSize);
const pageUnavailable = isLoading || (isError && !healthModelDataResponse);
const getProviderFromModel = useCallback(
(model: string) => {
@ -54,9 +57,9 @@ export default function HealthStatusPanel() {
getDisplayModelName={getDisplayModelName}
setSelectedModelId={openModel}
teams={teams ?? null}
isLoading={isLoading}
isLoading={pageUnavailable}
pagination={pagination}
onPaginationChange={setPagination}
onPaginationChange={onPaginationChange}
rowCount={healthModelDataResponse?.total_count ?? 0}
/>
);

View file

@ -0,0 +1,126 @@
import userEvent from "@testing-library/user-event";
import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, chooseSelectOption, render, screen, waitFor } from "@/../tests/test-utils";
import ModelRetrySettingsPanel from "./ModelRetrySettingsPanel";
const { getCallbacksCall, dashboardData } = vi.hoisted(() => ({
getCallbacksCall: vi.fn(),
dashboardData: vi.fn(),
}));
vi.mock("@/components/networking", () => ({ getCallbacksCall }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "sk-test", userId: "u-admin", userRole: "Admin" }),
}));
vi.mock("@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy", () => ({
useUpdateRetryPolicy: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/app/(dashboard)/models-and-endpoints/useModelDashboardData", () => ({
useModelDashboardData: () => dashboardData(),
}));
const MODEL_GROUPS = ["claude-opus", "gpt-5"];
const loadedGroups = () => ({ availableModelGroups: MODEL_GROUPS, isLoading: false });
const loadingGroups = () => ({ availableModelGroups: [], isLoading: true });
const failedGroups = () => ({ availableModelGroups: [], isLoading: false });
const renderPanel = (searchParams = "", onUrlUpdate?: OnUrlUpdateFunction) =>
render(<ModelRetrySettingsPanel />, {
wrapper: ({ children }: { children: ReactNode }) => (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onUrlUpdate}
hasMemory
resetUrlUpdateQueueOnMount={false}
>
{children}
</NuqsTestingAdapter>
),
});
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn<OnUrlUpdateFunction>>) => {
const lastCall = onUrlUpdate.mock.calls.at(-1);
if (!lastCall) throw new Error("expected a URL update");
return lastCall[0].searchParams;
};
describe("ModelRetrySettingsPanel retry_scope", () => {
beforeEach(() => {
getCallbacksCall.mockReset();
getCallbacksCall.mockResolvedValue({ router_settings: { num_retries: 2 } });
dashboardData.mockReset();
dashboardData.mockImplementation(loadedGroups);
});
it("shows the global policy when the URL names no scope", () => {
renderPanel();
expect(screen.getByRole("heading", { name: "Global Retry Policy" })).toBeInTheDocument();
});
it("shows the model group named in the URL", () => {
renderPanel("?retry_scope=gpt-5");
expect(screen.getByRole("heading", { name: "Retry Policy for gpt-5" })).toBeInTheDocument();
});
it("keeps a model group scope in the URL while the model groups are still loading", async () => {
dashboardData.mockImplementation(loadingGroups);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const { rerender } = renderPanel("?retry_scope=gpt-5", onUrlUpdate);
expect(screen.getByRole("heading", { name: "Retry Policy for gpt-5" })).toBeInTheDocument();
dashboardData.mockImplementation(loadedGroups);
rerender(<ModelRetrySettingsPanel />);
expect(screen.getByRole("heading", { name: "Retry Policy for gpt-5" })).toBeInTheDocument();
await waitFor(() => expect(getCallbacksCall).toHaveBeenCalled());
expect(onUrlUpdate).not.toHaveBeenCalled();
});
it("keeps a model group scope in the URL when the model groups fail to load", async () => {
dashboardData.mockImplementation(failedGroups);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderPanel("?retry_scope=gpt-5", onUrlUpdate);
expect(screen.getByRole("heading", { name: "Retry Policy for gpt-5" })).toBeInTheDocument();
await waitFor(() => expect(getCallbacksCall).toHaveBeenCalled());
await act(() => new Promise((resolve) => setTimeout(resolve, 50)));
expect(onUrlUpdate).not.toHaveBeenCalled();
});
it("falls back to the global policy and drops a model group that no longer exists", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderPanel("?retry_scope=retired-model&tab=retry-settings", onUrlUpdate);
expect(screen.getByRole("heading", { name: "Global Retry Policy" })).toBeInTheDocument();
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(lastUrl(onUrlUpdate).has("retry_scope")).toBe(false);
expect(lastUrl(onUrlUpdate).get("tab")).toBe("retry-settings");
});
it("writes the selected scope to the URL and clears it for the global default", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderPanel("", onUrlUpdate);
await chooseSelectOption(user, screen.getByRole("combobox", { name: /retry policy scope/i }), "gpt-5");
await waitFor(() => expect(lastUrl(onUrlUpdate).get("retry_scope")).toBe("gpt-5"));
expect(screen.getByRole("heading", { name: "Retry Policy for gpt-5" })).toBeInTheDocument();
await chooseSelectOption(user, screen.getByRole("combobox", { name: /retry policy scope/i }), "Global Default");
await waitFor(() => expect(lastUrl(onUrlUpdate).has("retry_scope")).toBe(false));
expect(screen.getByRole("heading", { name: "Global Retry Policy" })).toBeInTheDocument();
});
});

View file

@ -1,12 +1,17 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { parseAsString, useQueryState } from "nuqs";
import { useCallback, useEffect, useMemo, useState } from "react";
import ModelRetrySettingsTab from "@/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab";
import { getCallbacksCall } from "@/components/networking";
import { useUpdateRetryPolicy } from "@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy";
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { toast } from "@/lib/toast";
import { useUrlTab } from "@/hooks/useUrlTab";
const GLOBAL_SCOPE = "global";
const RETRY_SCOPE_KEY = "retry_scope";
interface RetryPolicyObject {
[key: string]: { [retryPolicyKey: string]: number } | undefined;
@ -27,7 +32,13 @@ export default function ModelRetrySettingsPanel() {
const { availableModelGroups } = useModelDashboardData();
const updateRetryPolicy = useUpdateRetryPolicy(accessToken);
const [retryScope, setRetryScope] = useState<string | null>("global");
const [requestedScope] = useQueryState(RETRY_SCOPE_KEY, parseAsString.withDefault(GLOBAL_SCOPE));
const allowedScopes = useMemo(
() => [GLOBAL_SCOPE, ...(availableModelGroups.length > 0 ? availableModelGroups : [requestedScope])],
[requestedScope, availableModelGroups],
);
const [retryScope, setRetryScope] = useUrlTab(allowedScopes, GLOBAL_SCOPE, RETRY_SCOPE_KEY);
const selectRetryScope = useCallback((scope: string | null) => setRetryScope(scope ?? GLOBAL_SCOPE), [setRetryScope]);
const [modelGroupRetryPolicy, setModelGroupRetryPolicy] = useState<RetryPolicyObject | null>(null);
const [globalRetryPolicy, setGlobalRetryPolicy] = useState<GlobalRetryPolicyObject | null>(null);
const [defaultRetry, setDefaultRetry] = useState<number>(0);
@ -86,7 +97,7 @@ export default function ModelRetrySettingsPanel() {
return (
<ModelRetrySettingsTab
selectedModelGroup={retryScope}
setSelectedModelGroup={setRetryScope}
setSelectedModelGroup={selectRetryScope}
availableModelGroups={availableModelGroups}
globalRetryPolicy={globalRetryPolicy}
setGlobalRetryPolicy={setGlobalRetryPolicy}

View file

@ -1,7 +1,9 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import { PassThroughEndpointsTable } from "./PassThroughEndpointsTable";
import type { passThroughItem } from "./PassThroughSettings";
@ -36,7 +38,7 @@ describe("PassThroughEndpointsTable", () => {
});
it("should render a row per endpoint with path and target", () => {
render(<PassThroughEndpointsTable {...defaultProps} />);
renderWithProviders(<PassThroughEndpointsTable {...defaultProps} />);
expect(screen.getByText("/v1/rerank")).toBeInTheDocument();
expect(screen.getByText("https://api.cohere.com/v1/rerank")).toBeInTheDocument();
expect(screen.getByText("/bria")).toBeInTheDocument();
@ -45,26 +47,26 @@ describe("PassThroughEndpointsTable", () => {
it("should open the endpoint when its ID is clicked", async () => {
const user = userEvent.setup();
const onEndpointClick = vi.fn();
render(<PassThroughEndpointsTable {...defaultProps} onEndpointClick={onEndpointClick} />);
renderWithProviders(<PassThroughEndpointsTable {...defaultProps} onEndpointClick={onEndpointClick} />);
await user.click(screen.getByRole("button", { name: "ep-1" }));
expect(onEndpointClick).toHaveBeenCalledWith("ep-1");
});
it("should show method chips, or ALL when no methods are set", () => {
render(<PassThroughEndpointsTable {...defaultProps} />);
renderWithProviders(<PassThroughEndpointsTable {...defaultProps} />);
expect(screen.getByText("POST")).toBeInTheDocument();
expect(screen.getByText("ALL")).toBeInTheDocument();
});
it("should show authentication as Yes or No", () => {
render(<PassThroughEndpointsTable {...defaultProps} />);
renderWithProviders(<PassThroughEndpointsTable {...defaultProps} />);
expect(screen.getByText("Yes")).toBeInTheDocument();
expect(screen.getByText("No")).toBeInTheDocument();
});
it("should mask headers until the visibility toggle is clicked", async () => {
const user = userEvent.setup();
render(<PassThroughEndpointsTable {...defaultProps} />);
renderWithProviders(<PassThroughEndpointsTable {...defaultProps} />);
expect(screen.queryByText(/secret-value/)).not.toBeInTheDocument();
const toggles = screen.getAllByRole("button", { name: "Show headers" });
@ -76,7 +78,7 @@ describe("PassThroughEndpointsTable", () => {
const user = userEvent.setup();
const onEndpointClick = vi.fn();
const onDeleteClick = vi.fn();
render(
renderWithProviders(
<PassThroughEndpointsTable {...defaultProps} onEndpointClick={onEndpointClick} onDeleteClick={onDeleteClick} />,
);
@ -100,7 +102,7 @@ describe("PassThroughEndpointsTable", () => {
headers: {},
is_from_config: true,
};
render(
renderWithProviders(
<PassThroughEndpointsTable
{...defaultProps}
endpoints={[configEndpoint]}
@ -128,7 +130,7 @@ describe("PassThroughEndpointsTable", () => {
it("should not show the config hint for DB endpoints", async () => {
const user = userEvent.setup();
render(<PassThroughEndpointsTable {...defaultProps} />);
renderWithProviders(<PassThroughEndpointsTable {...defaultProps} />);
await user.click(screen.getByTestId("endpoint-actions-ep-1"));
await screen.findByTestId("endpoint-action-delete");
@ -143,7 +145,7 @@ describe("PassThroughEndpointsTable", () => {
headers: {},
is_from_config: true,
};
render(<PassThroughEndpointsTable {...defaultProps} endpoints={[...endpoints, configEndpoint]} />);
renderWithProviders(<PassThroughEndpointsTable {...defaultProps} endpoints={[...endpoints, configEndpoint]} />);
expect(screen.getByText("Config")).toBeInTheDocument();
expect(screen.getAllByText("DB")).toHaveLength(2);
});
@ -153,7 +155,7 @@ describe("PassThroughEndpointsTable", () => {
const onEndpointClick = vi.fn();
const onDeleteClick = vi.fn();
const endpointWithoutId: passThroughItem = { path: "/legacy", target: "https://legacy.example.com", headers: {} };
render(
renderWithProviders(
<PassThroughEndpointsTable
{...defaultProps}
endpoints={[endpointWithoutId]}
@ -177,13 +179,47 @@ describe("PassThroughEndpointsTable", () => {
});
it("should show the empty state when there are no endpoints", () => {
render(<PassThroughEndpointsTable {...defaultProps} endpoints={[]} />);
renderWithProviders(<PassThroughEndpointsTable {...defaultProps} endpoints={[]} />);
expect(screen.getByText("No pass-through endpoints configured")).toBeInTheDocument();
});
it("should show skeleton rows while loading", () => {
render(<PassThroughEndpointsTable {...defaultProps} endpoints={[]} isLoading />);
renderWithProviders(<PassThroughEndpointsTable {...defaultProps} endpoints={[]} isLoading />);
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
expect(screen.queryByText("No pass-through endpoints configured")).not.toBeInTheDocument();
});
describe("URL page state", () => {
const manyEndpoints: passThroughItem[] = Array.from({ length: 27 }, (_, index) => ({
id: `ep-${index + 1}`,
path: `/route-${index + 1}`,
target: `https://upstream.example.com/${index + 1}`,
headers: {},
}));
it("keeps the page named in the URL once the endpoints finish loading", () => {
const { rerender } = renderWithProviders(
<PassThroughEndpointsTable {...defaultProps} endpoints={[]} isLoading />,
{ searchParams: "?pass_through_page=2" },
);
rerender(<PassThroughEndpointsTable {...defaultProps} endpoints={manyEndpoints} isLoading={false} />);
expect(screen.getByText("/route-26")).toBeInTheDocument();
expect(screen.getByText("/route-27")).toBeInTheDocument();
expect(screen.queryByText("/route-1")).not.toBeInTheDocument();
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2");
});
it("writes a page change to the URL", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<PassThroughEndpointsTable {...defaultProps} endpoints={manyEndpoints} />, { onUrlUpdate });
await user.click(screen.getByTestId("pagination-next"));
await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("pass_through_page")).toBe("2"));
expect(screen.getByText("/route-26")).toBeInTheDocument();
});
});
});

View file

@ -3,7 +3,12 @@
import { Waypoints } from "lucide-react";
import { useMemo } from "react";
import { DataTable } from "@/components/shared/DataTable";
import {
DataTable,
DEFAULT_PAGE_SIZE_OPTIONS,
useUrlTableState,
type UrlTableStateOptions,
} from "@/components/shared/DataTable";
import { getPassThroughEndpointsTableColumns } from "./PassThroughEndpointsTableColumns";
import type { passThroughItem } from "./PassThroughSettings";
@ -15,6 +20,14 @@ interface PassThroughEndpointsTableProps {
onDeleteClick: (endpointId: string) => void;
}
const TABLE_STATE_OPTIONS: UrlTableStateOptions<never> = {
sortFields: [],
defaultSort: { id: "", desc: false },
defaultPageSize: DEFAULT_PAGE_SIZE_OPTIONS[0],
filterColumns: [],
keyPrefix: "pass_through_",
};
function EmptyState() {
return (
<div className="flex flex-col items-center gap-1 py-6">
@ -33,6 +46,7 @@ export function PassThroughEndpointsTable({
onEndpointClick,
onDeleteClick,
}: PassThroughEndpointsTableProps) {
const { pagination, onPaginationChange } = useUrlTableState(TABLE_STATE_OPTIONS);
const columns = useMemo(
() => getPassThroughEndpointsTableColumns({ onEndpointClick, onDeleteClick }),
[onEndpointClick, onDeleteClick],
@ -42,6 +56,8 @@ export function PassThroughEndpointsTable({
<DataTable
data={endpoints}
paginationMode="client"
pagination={pagination}
onPaginationChange={onPaginationChange}
columns={columns}
getRowId={(endpoint, index) => endpoint.id || endpoint.path || String(index)}
isLoading={isLoading}

View file

@ -1,7 +1,8 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import { deletePassThroughEndpointsCall, getPassThroughEndpointsCall } from "../networking";
import PassThroughSettings from "./PassThroughSettings";
import type { PassThroughEndpointsTable } from "./PassThroughEndpointsTable";
@ -16,8 +17,21 @@ vi.mock("../add_pass_through", () => ({
}));
vi.mock("../pass_through_info", () => ({
default: ({ endpointData }: { endpointData: { id?: string } }) => (
<div data-testid="endpoint-info">{endpointData.id}</div>
default: ({
endpointData,
onClose,
isAdmin,
}: {
endpointData: { id?: string; path: string };
onClose: () => void;
isAdmin: boolean;
}) => (
<div data-testid="endpoint-info" data-path={endpointData.path} data-admin={String(isAdmin)}>
{endpointData.id}
<button type="button" onClick={onClose}>
close-endpoint
</button>
</div>
),
}));
@ -57,7 +71,7 @@ describe("PassThroughSettings", () => {
});
it("should render nothing without an access token", () => {
const { container } = render(<PassThroughSettings {...defaultProps} accessToken={null} />);
const { container } = renderWithProviders(<PassThroughSettings {...defaultProps} accessToken={null} />);
expect(container).toBeEmptyDOMElement();
});
@ -69,7 +83,7 @@ describe("PassThroughSettings", () => {
}),
);
render(<PassThroughSettings {...defaultProps} />);
renderWithProviders(<PassThroughSettings {...defaultProps} />);
expect(screen.getByTestId("endpoints-table")).toHaveAttribute("data-loading", "true");
await act(async () => {
@ -82,7 +96,7 @@ describe("PassThroughSettings", () => {
});
it("should resolve loading without fetching when the user id is missing", async () => {
render(<PassThroughSettings {...defaultProps} userID={null} />);
renderWithProviders(<PassThroughSettings {...defaultProps} userID={null} />);
await waitFor(() => {
expect(screen.getByTestId("endpoints-table")).toHaveAttribute("data-loading", "false");
@ -92,7 +106,7 @@ describe("PassThroughSettings", () => {
it("should swap to the endpoint info view when an endpoint is opened", async () => {
const user = userEvent.setup();
render(<PassThroughSettings {...defaultProps} />);
renderWithProviders(<PassThroughSettings {...defaultProps} />);
await user.click(await screen.findByText("open-ep-1"));
expect(screen.getByTestId("endpoint-info")).toHaveTextContent("ep-1");
@ -101,7 +115,7 @@ describe("PassThroughSettings", () => {
it("should confirm before deleting an endpoint", async () => {
const user = userEvent.setup();
mockDeleteEndpoint.mockResolvedValue(undefined);
render(<PassThroughSettings {...defaultProps} />);
renderWithProviders(<PassThroughSettings {...defaultProps} />);
await user.click(await screen.findByText("delete-ep-1"));
expect(screen.getByText("Delete Pass-Through Endpoint")).toBeInTheDocument();
@ -112,4 +126,112 @@ describe("PassThroughSettings", () => {
expect(mockDeleteEndpoint).toHaveBeenCalledWith("token", "ep-1");
});
});
describe("endpoint in the URL", () => {
const lastUrlUpdate = (onUrlUpdate: ReturnType<typeof vi.fn<OnUrlUpdateFunction>>) => {
const lastCall = onUrlUpdate.mock.calls.at(-1);
if (!lastCall) throw new Error("expected a URL update");
return lastCall[0];
};
it("opens the endpoint named in the URL once loaded, without flashing not found first", async () => {
let resolveEndpoints: (value: { endpoints: (typeof endpoint)[] }) => void = () => {};
mockGetEndpoints.mockReturnValue(
new Promise((resolve) => {
resolveEndpoints = resolve;
}),
);
renderWithProviders(<PassThroughSettings {...defaultProps} />, { searchParams: "?endpoint=ep-1" });
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(screen.queryByText("Endpoint not found")).not.toBeInTheDocument();
expect(screen.queryByTestId("endpoints-table")).not.toBeInTheDocument();
await act(async () => {
resolveEndpoints({ endpoints: [endpoint] });
});
expect(await screen.findByTestId("endpoint-info")).toHaveTextContent("ep-1");
});
it("resolves a config endpoint without an id by its path", async () => {
const configEndpoint = { path: "/from-config", target: "https://config.example.com", headers: {} };
mockGetEndpoints.mockResolvedValue({ endpoints: [endpoint, configEndpoint] });
renderWithProviders(<PassThroughSettings {...defaultProps} />, { searchParams: "?endpoint=%2Ffrom-config" });
expect(await screen.findByTestId("endpoint-info")).toHaveAttribute("data-path", "/from-config");
});
it("says the endpoint is not found when the URL names one that does not exist, with a way back", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<PassThroughSettings {...defaultProps} />, {
searchParams: "?endpoint=ep-gone&endpoint_tab=settings",
onUrlUpdate,
});
expect(await screen.findByText("Endpoint not found")).toBeInTheDocument();
expect(screen.queryByTestId("endpoint-info")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /back/i }));
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate).searchParams.has("endpoint")).toBe(false));
expect(lastUrlUpdate(onUrlUpdate).searchParams.has("endpoint_tab")).toBe(false);
expect(await screen.findByTestId("endpoints-table")).toBeInTheDocument();
});
it("offers admins the edit view for a dashboard endpoint", async () => {
renderWithProviders(<PassThroughSettings {...defaultProps} />, { searchParams: "?endpoint=ep-1" });
expect(await screen.findByTestId("endpoint-info")).toHaveAttribute("data-admin", "true");
});
it.each([
["a config endpoint without an id", "%2Ffrom-config", { path: "/from-config" }],
["a config endpoint with an id", "cfg-1", { id: "cfg-1", path: "/cfg", is_from_config: true }],
])("keeps admins read-only on %s, which the dashboard cannot edit", async (_label, param, configFields) => {
const configEndpoint = { target: "https://config.example.com", headers: {}, ...configFields };
mockGetEndpoints.mockResolvedValue({ endpoints: [endpoint, configEndpoint] });
renderWithProviders(<PassThroughSettings {...defaultProps} />, { searchParams: `?endpoint=${param}` });
const info = await screen.findByTestId("endpoint-info");
expect(info).toHaveAttribute("data-path", configEndpoint.path);
expect(info).toHaveAttribute("data-admin", "false");
});
it("pushes the opened endpoint to the URL and drops a stale detail tab", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<PassThroughSettings {...defaultProps} />, {
searchParams: "?endpoint_tab=settings",
onUrlUpdate,
});
await user.click(await screen.findByText("open-ep-1"));
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate).searchParams.get("endpoint")).toBe("ep-1"));
expect(lastUrlUpdate(onUrlUpdate).searchParams.has("endpoint_tab")).toBe(false);
expect(lastUrlUpdate(onUrlUpdate).options.history).toBe("push");
expect(screen.getByTestId("endpoint-info")).toHaveTextContent("ep-1");
});
it("clears the endpoint and its tab from the URL when the detail closes", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<PassThroughSettings {...defaultProps} />, {
searchParams: "?endpoint=ep-1&endpoint_tab=settings",
onUrlUpdate,
});
await user.click(await screen.findByRole("button", { name: "close-endpoint" }));
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate).searchParams.has("endpoint")).toBe(false));
expect(lastUrlUpdate(onUrlUpdate).searchParams.has("endpoint_tab")).toBe(false);
expect(lastUrlUpdate(onUrlUpdate).options.history).toBe("push");
expect(screen.getByTestId("endpoints-table")).toBeInTheDocument();
});
});
});

View file

@ -14,6 +14,7 @@ import AddPassThroughEndpoint from "../add_pass_through";
import PassThroughInfoView from "../pass_through_info";
import { toast } from "@/lib/toast";
import { PassThroughEndpointsTable } from "./PassThroughEndpointsTable";
import { findPassThroughEndpoint, usePassThroughDetailRouting } from "./passThroughDetailRouting";
interface PassThroughSettingsProps {
accessToken: string | null;
@ -40,7 +41,7 @@ export interface passThroughItem {
const PassThroughSettings: React.FC<PassThroughSettingsProps> = ({ accessToken, userRole, userID, premiumUser }) => {
const [generalSettings, setGeneralSettings] = useState<passThroughItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedEndpointId, setSelectedEndpointId] = useState<string | null>(null);
const { endpointKey, openEndpoint, closeEndpoint } = usePassThroughDetailRouting();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [endpointToDelete, setEndpointToDelete] = useState<string | null>(null);
@ -103,19 +104,33 @@ const PassThroughSettings: React.FC<PassThroughSettingsProps> = ({ accessToken,
return null;
}
if (selectedEndpointId) {
const selectedEndpoint = generalSettings.find((endpoint) => endpoint.id === selectedEndpointId);
if (endpointKey && isLoading) {
return <div className="p-4">Loading...</div>;
}
if (endpointKey) {
const selectedEndpoint = findPassThroughEndpoint(generalSettings, endpointKey);
if (!selectedEndpoint) {
return <div>Endpoint not found</div>;
return (
<div className="p-4">
<Button onClick={closeEndpoint} className="mb-4">
← Back
</Button>
<div>Endpoint not found</div>
</div>
);
}
const editableOnDashboard = Boolean(selectedEndpoint.id) && !selectedEndpoint.is_from_config;
return (
<PassThroughInfoView
key={selectedEndpoint.id ?? selectedEndpoint.path}
endpointData={selectedEndpoint}
onClose={() => setSelectedEndpointId(null)}
onClose={closeEndpoint}
accessToken={accessToken}
isAdmin={userRole === "Admin" || userRole === "admin"}
isAdmin={(userRole === "Admin" || userRole === "admin") && editableOnDashboard}
premiumUser={premiumUser}
onEndpointUpdated={handleEndpointUpdated}
/>
@ -139,7 +154,7 @@ const PassThroughSettings: React.FC<PassThroughSettingsProps> = ({ accessToken,
<PassThroughEndpointsTable
endpoints={generalSettings}
isLoading={isLoading}
onEndpointClick={setSelectedEndpointId}
onEndpointClick={openEndpoint}
onDeleteClick={handleDelete}
/>

View file

@ -0,0 +1,49 @@
import { parseAsString, useQueryStates } from "nuqs";
import { useCallback } from "react";
import { useUrlTab } from "@/hooks/useUrlTab";
const ENDPOINT_TAB_KEY = "endpoint_tab";
const DETAIL_PARSERS = { endpoint: parseAsString, detailTab: parseAsString };
const DETAIL_OPTIONS = { history: "push", urlKeys: { detailTab: ENDPOINT_TAB_KEY } } as const;
const DETAIL_TABS = ["overview", "settings"] as const;
type PassThroughDetailTab = (typeof DETAIL_TABS)[number];
const OVERVIEW_ONLY: readonly PassThroughDetailTab[] = ["overview"];
export interface PassThroughDetailRouting {
endpointKey: string | null;
openEndpoint: (endpointKey: string) => void;
closeEndpoint: () => void;
}
export function usePassThroughDetailRouting(): PassThroughDetailRouting {
const [{ endpoint }, setParams] = useQueryStates(DETAIL_PARSERS, DETAIL_OPTIONS);
const openEndpoint = useCallback(
(endpointKey: string) => {
void setParams({ endpoint: endpointKey, detailTab: null });
},
[setParams],
);
const closeEndpoint = useCallback(() => {
void setParams({ endpoint: null, detailTab: null });
}, [setParams]);
return { endpointKey: endpoint, openEndpoint, closeEndpoint };
}
export function usePassThroughDetailTab(
canViewSettings: boolean,
): [PassThroughDetailTab, (tab: PassThroughDetailTab) => void] {
return useUrlTab(canViewSettings ? DETAIL_TABS : OVERVIEW_ONLY, "overview", ENDPOINT_TAB_KEY);
}
export function findPassThroughEndpoint<T extends { id?: string; path: string }>(
endpoints: readonly T[],
endpointKey: string,
): T | undefined {
const byId = endpoints.find((endpoint) => endpoint.id === endpointKey);
return byId ?? endpoints.find((endpoint) => endpoint.path === endpointKey);
}

View file

@ -1,11 +1,12 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CredentialItem, credentialCreateCall, credentialUpdateCall } from "@/components/networking";
import { toast } from "@/lib/toast";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import CredentialsPanel from "./CredentialsPanel";
const mockUseAuthorized = vi.fn();
@ -89,12 +90,13 @@ const createQueryClient = () =>
},
});
const renderPanel = () =>
render(
<QueryClientProvider client={createQueryClient()}>
<CredentialsPanel />
</QueryClientProvider>,
);
const panel = (queryClient: QueryClient) => (
<QueryClientProvider client={queryClient}>
<CredentialsPanel />
</QueryClientProvider>
);
const renderPanel = (searchParams?: string) => renderWithProviders(panel(createQueryClient()), { searchParams });
describe("CredentialsPanel", () => {
beforeEach(() => {
@ -138,6 +140,26 @@ describe("CredentialsPanel", () => {
expect(screen.queryByText("No credentials configured")).not.toBeInTheDocument();
});
it("keeps the page named in the URL once credentials finish loading", () => {
const manyCredentials: CredentialItem[] = Array.from({ length: 27 }, (_, index) => ({
credential_name: `cred-${String(index + 1).padStart(2, "0")}`,
credential_values: {},
credential_info: { custom_llm_provider: "openai" },
}));
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" });
mockUseCredentials.mockReturnValue({ data: undefined, isLoading: true, refetch: vi.fn() });
const queryClient = createQueryClient();
const { rerender } = renderWithProviders(panel(queryClient), { searchParams: "?credentials_page=2" });
mockUseCredentials.mockReturnValue({ data: { credentials: manyCredentials }, isLoading: false, refetch: vi.fn() });
rerender(panel(queryClient));
expect(screen.getByText("cred-26")).toBeInTheDocument();
expect(screen.getByText("cred-27")).toBeInTheDocument();
expect(screen.queryByText("cred-01")).not.toBeInTheDocument();
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2");
});
it("opens the add modal when the add button is clicked", async () => {
const user = userEvent.setup();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" });

View file

@ -1,9 +1,11 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CredentialItem } from "@/components/networking";
import { renderWithProviders, screen, waitFor, within } from "../../../tests/test-utils";
import CredentialsTable from "./CredentialsTable";
vi.mock("@/components/provider_info_helpers", () => ({
@ -45,20 +47,20 @@ describe("CredentialsTable", () => {
});
it("should render the data column headers", () => {
render(<CredentialsTable {...defaultProps} />);
renderWithProviders(<CredentialsTable {...defaultProps} />);
for (const header of ["Credential Name", "Provider"]) {
expect(screen.getByText(header)).toBeInTheDocument();
}
});
it("should display each credential name", () => {
render(<CredentialsTable {...defaultProps} />);
renderWithProviders(<CredentialsTable {...defaultProps} />);
expect(screen.getByText("b-openai-key")).toBeInTheDocument();
expect(screen.getByText("a-azure-key")).toBeInTheDocument();
});
it("should render provider display names from the logo helper", () => {
render(<CredentialsTable {...defaultProps} />);
renderWithProviders(<CredentialsTable {...defaultProps} />);
expect(screen.getByText("OpenAI")).toBeInTheDocument();
expect(screen.getByText("Azure")).toBeInTheDocument();
});
@ -87,26 +89,26 @@ describe("CredentialsTable", () => {
const credentials: CredentialItem[] = [
{ credential_name: "no-provider", credential_values: {}, credential_info: {} },
];
render(<CredentialsTable {...defaultProps} credentials={credentials} />);
renderWithProviders(<CredentialsTable {...defaultProps} credentials={credentials} />);
const row = screen.getAllByRole("row").slice(1)[0];
expect(within(row).getByText("-")).toBeInTheDocument();
});
it("should sort by credential name ascending by default", () => {
render(<CredentialsTable {...defaultProps} />);
renderWithProviders(<CredentialsTable {...defaultProps} />);
const rows = screen.getAllByRole("row").slice(1);
expect(within(rows[0]).getByText("a-azure-key")).toBeInTheDocument();
expect(within(rows[1]).getByText("b-openai-key")).toBeInTheDocument();
});
it("should display the empty state when there are no credentials", () => {
render(<CredentialsTable {...defaultProps} credentials={[]} />);
renderWithProviders(<CredentialsTable {...defaultProps} credentials={[]} />);
expect(screen.getByText("No credentials configured")).toBeInTheDocument();
});
it("should edit a credential through the actions menu", async () => {
const user = userEvent.setup();
render(<CredentialsTable {...defaultProps} />);
renderWithProviders(<CredentialsTable {...defaultProps} />);
await user.click(screen.getByTestId("credential-actions-b-openai-key"));
await user.click(await screen.findByTestId("credential-action-edit"));
expect(mockOnEdit).toHaveBeenCalledWith(mockCredentials[0]);
@ -114,7 +116,7 @@ describe("CredentialsTable", () => {
it("should delete a credential through the actions menu", async () => {
const user = userEvent.setup();
render(<CredentialsTable {...defaultProps} />);
renderWithProviders(<CredentialsTable {...defaultProps} />);
await user.click(screen.getByTestId("credential-actions-b-openai-key"));
await user.click(await screen.findByTestId("credential-action-delete"));
expect(mockOnDelete).toHaveBeenCalledWith(mockCredentials[0]);
@ -122,18 +124,77 @@ describe("CredentialsTable", () => {
it("should copy the credential name through the actions menu", async () => {
const user = userEvent.setup();
render(<CredentialsTable {...defaultProps} />);
renderWithProviders(<CredentialsTable {...defaultProps} />);
await user.click(screen.getByTestId("credential-actions-b-openai-key"));
await user.click(await screen.findByTestId("credential-action-copy"));
expect(await window.navigator.clipboard.readText()).toBe("b-openai-key");
});
it("should not render the actions menu when the user cannot modify credentials", () => {
render(<CredentialsTable {...defaultProps} canModifyCredentials={false} />);
renderWithProviders(<CredentialsTable {...defaultProps} canModifyCredentials={false} />);
// Read parity: names still render...
expect(screen.getByText("b-openai-key")).toBeInTheDocument();
// ...but there is no per-row actions trigger.
expect(screen.queryByTestId("credential-actions-b-openai-key")).not.toBeInTheDocument();
expect(screen.queryByTestId("credential-actions-a-azure-key")).not.toBeInTheDocument();
});
describe("URL state", () => {
const manyCredentials: CredentialItem[] = Array.from({ length: 30 }, (_, index) => ({
credential_name: `cred-${String(index + 1).padStart(2, "0")}`,
credential_values: {},
credential_info: { custom_llm_provider: "openai" },
}));
const credentialNamesInOrder = () =>
screen
.getAllByRole("row")
.slice(1)
.map((row) => within(row).getAllByRole("cell")[0].textContent);
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn<OnUrlUpdateFunction>>) => {
const lastCall = onUrlUpdate.mock.calls.at(-1);
if (!lastCall) throw new Error("expected a URL update");
return lastCall[0].searchParams;
};
it("sorts in the direction named in the URL", () => {
renderWithProviders(<CredentialsTable {...defaultProps} />, {
searchParams: "?credentials_sort_order=desc",
});
expect(credentialNamesInOrder()).toEqual(["b-openai-key", "a-azure-key"]);
});
it("writes a sort toggle to the URL", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<CredentialsTable {...defaultProps} />, { onUrlUpdate });
await user.click(screen.getByTestId("sort-header-credential_name"));
await waitFor(() => expect(lastUrl(onUrlUpdate).get("credentials_sort_order")).toBe("desc"));
expect(lastUrl(onUrlUpdate).has("credentials_sort_by")).toBe(false);
expect(credentialNamesInOrder()[0]).toBe("b-openai-key");
});
it("shows the page named in the URL", () => {
renderWithProviders(<CredentialsTable {...defaultProps} credentials={manyCredentials} />, {
searchParams: "?credentials_page=2",
});
expect(credentialNamesInOrder()).toEqual(["cred-26", "cred-27", "cred-28", "cred-29", "cred-30"]);
});
it("writes a page change to the URL", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<CredentialsTable {...defaultProps} credentials={manyCredentials} />, { onUrlUpdate });
await user.click(screen.getByTestId("pagination-next"));
await waitFor(() => expect(lastUrl(onUrlUpdate).get("credentials_page")).toBe("2"));
expect(credentialNamesInOrder()[0]).toBe("cred-26");
});
});
});

View file

@ -1,11 +1,15 @@
"use client";
import { SortingState } from "@tanstack/react-table";
import { KeyRound } from "lucide-react";
import React, { useMemo, useState } from "react";
import React, { useMemo } from "react";
import { CredentialItem } from "@/components/networking";
import { DataTable } from "@/components/shared/DataTable";
import {
DataTable,
DEFAULT_PAGE_SIZE_OPTIONS,
useUrlTableState,
type UrlTableStateOptions,
} from "@/components/shared/DataTable";
import { getCredentialsTableColumns } from "./CredentialsTableColumns";
@ -17,7 +21,13 @@ interface CredentialsTableProps {
isLoading?: boolean;
}
const DEFAULT_SORTING: SortingState = [{ id: "credential_name", desc: false }];
const TABLE_STATE_OPTIONS: UrlTableStateOptions<never> = {
sortFields: ["credential_name"],
defaultSort: { id: "credential_name", desc: false },
defaultPageSize: DEFAULT_PAGE_SIZE_OPTIONS[0],
filterColumns: [],
keyPrefix: "credentials_",
};
function EmptyState() {
return (
@ -38,7 +48,7 @@ const CredentialsTable: React.FC<CredentialsTableProps> = ({
onDelete,
isLoading = false,
}) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const { sorting, onSortingChange, pagination, onPaginationChange } = useUrlTableState(TABLE_STATE_OPTIONS);
const columns = useMemo(
() => getCredentialsTableColumns({ canModifyCredentials, onEdit, onDelete }),
@ -49,11 +59,13 @@ const CredentialsTable: React.FC<CredentialsTableProps> = ({
<DataTable
data={credentials}
paginationMode="client"
pagination={pagination}
onPaginationChange={onPaginationChange}
columns={columns}
getRowId={(credential, index) => credential.credential_name || String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
onSortingChange={onSortingChange}
isLoading={isLoading}
loadingMessage="Loading credentials…"
noDataMessage={<EmptyState />}

View file

@ -1,10 +1,10 @@
/* @vitest-environment jsdom */
import type { PaginationState } from "@tanstack/react-table";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderWithProviders, screen } from "../../../tests/test-utils";
import HealthCheckComponent from "./HealthCheckComponent";
const mockIndividualModelHealthCheckCall = vi.fn();
@ -58,7 +58,7 @@ function Harness({ modelData, allModelsOnProxy, rowCount = 1, onPageIndexChange
const renderHealthCheck = async (props: HarnessProps) => {
await act(async () => {
render(<Harness {...props} />);
renderWithProviders(<Harness {...props} />);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
@ -87,7 +87,7 @@ describe("HealthCheckComponent", () => {
});
it("should call individualModelHealthCheckCall with model id when run health check is triggered", async () => {
render(
renderWithProviders(
<Harness modelData={{ data: [makeModel("deployment-abc-123")] }} allModelsOnProxy={["deployment-abc-123"]} />,
);

View file

@ -1,9 +1,11 @@
/* @vitest-environment jsdom */
import type { PaginationState, RowSelectionState } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, type Mock, vi } from "vitest";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { HealthChecksTable } from "./HealthChecksTable";
import type { HealthCheckData, HealthStatus } from "./HealthChecksTableColumns";
@ -63,7 +65,7 @@ const rowIds = (): string[] =>
describe("HealthChecksTable client sorting", () => {
it("orders health status healthy > checking > unknown > unhealthy", async () => {
const user = userEvent.setup();
render(
renderWithProviders(
<Harness
data={[
makeRow({ id: "unhealthy-row", health_status: "unhealthy" }),
@ -81,7 +83,7 @@ describe("HealthChecksTable client sorting", () => {
it("floats in-progress checks to the top, sinks never-checked, and sorts real checks most-recent-first", async () => {
const user = userEvent.setup();
render(
renderWithProviders(
<Harness
data={[
makeRow({ id: "never", last_check: "Never checked" }),
@ -100,7 +102,7 @@ describe("HealthChecksTable client sorting", () => {
// "Never succeeded" is ranked below "None" -- both sink, but not to the same slot.
it("sinks None below real successes and Never succeeded below None", async () => {
const user = userEvent.setup();
render(
renderWithProviders(
<Harness
data={[
makeRow({ id: "never", last_success: "Never succeeded" }),
@ -117,9 +119,95 @@ describe("HealthChecksTable client sorting", () => {
});
});
const statusRows = [
makeRow({ id: "unhealthy-row", health_status: "unhealthy" }),
makeRow({ id: "healthy-row", health_status: "healthy" }),
makeRow({ id: "weird-row", health_status: "some-other-status" }),
makeRow({ id: "checking-row", health_status: "checking" }),
];
const lastUrl = (onUrlUpdate: Mock<OnUrlUpdateFunction>): URLSearchParams => {
const lastCall = onUrlUpdate.mock.calls.at(-1);
if (!lastCall) throw new Error("expected a URL update");
return lastCall[0].searchParams;
};
describe("HealthChecksTable URL sort state", () => {
it("keeps the fetched row order when the URL names no sort column", () => {
renderWithProviders(<Harness data={statusRows} />);
expect(rowIds()).toEqual(["unhealthy-row", "healthy-row", "weird-row", "checking-row"]);
});
it("sorts by health_sort_by and health_sort_order from the URL", () => {
renderWithProviders(<Harness data={statusRows} />, {
searchParams: "?health_sort_by=health_status&health_sort_order=desc",
});
expect(rowIds()).toEqual(["weird-row", "unhealthy-row", "checking-row", "healthy-row"]);
});
it("writes header clicks to the URL and toggles direction from the URL value", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<Harness data={statusRows} />, { onUrlUpdate });
await user.click(screen.getByTestId("sort-header-health_status"));
expect(lastUrl(onUrlUpdate).get("health_sort_by")).toBe("health_status");
expect(lastUrl(onUrlUpdate).get("health_sort_order")).toBeNull();
expect(rowIds()).toEqual(["healthy-row", "checking-row", "unhealthy-row", "weird-row"]);
await user.click(screen.getByTestId("sort-header-health_status"));
expect(lastUrl(onUrlUpdate).get("health_sort_by")).toBe("health_status");
expect(lastUrl(onUrlUpdate).get("health_sort_order")).toBe("desc");
expect(rowIds()).toEqual(["weird-row", "unhealthy-row", "checking-row", "healthy-row"]);
});
it("keeps the fetched page when sorting, since the sort only reorders that page", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<Harness data={statusRows} />, {
searchParams: "?health_page=3&health_page_size=25",
onUrlUpdate,
});
await user.click(screen.getByTestId("sort-header-model_name"));
expect(lastUrl(onUrlUpdate).get("health_sort_by")).toBe("model_name");
expect(lastUrl(onUrlUpdate).get("health_page")).toBe("3");
expect(lastUrl(onUrlUpdate).get("health_page_size")).toBe("25");
expect(rowIds()).toEqual(["checking-row", "healthy-row", "unhealthy-row", "weird-row"]);
});
const sortableRow = (suffix: string, healthStatus: string, checkedAt: string): HealthCheckData => {
const fields = {
id: `row-${suffix}`,
model_name: `model-${suffix}`,
model_info: { id: `row-${suffix}`, team_id: `team-${suffix}` },
health_status: healthStatus,
last_check: checkedAt,
last_success: checkedAt,
};
return makeRow(fields);
};
const newerRow = sortableRow("a", "healthy", "2024-06-01T10:00:00Z");
const olderRow = sortableRow("b", "unhealthy", "2024-01-01T10:00:00Z");
it.each(["model_id", "model_name", "team_id", "health_status", "last_check", "last_success"])(
"sorts by %s when the URL names it",
(column) => {
renderWithProviders(<Harness data={[olderRow, newerRow]} />, { searchParams: `?health_sort_by=${column}` });
expect(rowIds()).toEqual(["row-a", "row-b"]);
},
);
});
describe("HealthChecksTable rows", () => {
it("renders the live checking cell while a row is loading and disables its run button", () => {
render(<Harness data={[makeRow({ id: "busy", health_loading: true, health_status: "checking" })]} />);
renderWithProviders(<Harness data={[makeRow({ id: "busy", health_loading: true, health_status: "checking" })]} />);
expect(screen.getByText("Checking...")).toBeInTheDocument();
expect(screen.getByTestId("run-health-check-btn")).toBeDisabled();
@ -128,7 +216,7 @@ describe("HealthChecksTable rows", () => {
it("runs a health check for the row's model id", async () => {
const user = userEvent.setup();
const onRunHealthCheck = vi.fn();
render(<Harness data={[makeRow({ id: "deployment-9" })]} onRunHealthCheck={onRunHealthCheck} />);
renderWithProviders(<Harness data={[makeRow({ id: "deployment-9" })]} onRunHealthCheck={onRunHealthCheck} />);
await user.click(screen.getByTestId("run-health-check-btn"));
@ -138,7 +226,7 @@ describe("HealthChecksTable rows", () => {
it("opens the model detail from the identity cell", async () => {
const user = userEvent.setup();
const onSelectModel = vi.fn();
render(<Harness data={[makeRow({ id: "deployment-9" })]} onSelectModel={onSelectModel} />);
renderWithProviders(<Harness data={[makeRow({ id: "deployment-9" })]} onSelectModel={onSelectModel} />);
await user.click(screen.getByRole("button", { name: /deployment-9/ }));
@ -146,7 +234,7 @@ describe("HealthChecksTable rows", () => {
});
it("surfaces the error detail button only when a fuller error exists", () => {
const { rerender } = render(
const { rerender } = renderWithProviders(
<Harness
data={[makeRow({ id: "m1", health_status: "unhealthy" })]}
modelHealthStatuses={{
@ -168,7 +256,7 @@ describe("HealthChecksTable rows", () => {
});
it("renders the empty state when the page has no models", () => {
render(<Harness data={[]} />);
renderWithProviders(<Harness data={[]} />);
expect(screen.getByText("No models found")).toBeInTheDocument();
});

View file

@ -1,13 +1,14 @@
"use client";
import { OnChangeFn, PaginationState, RowSelectionState, SortingState } from "@tanstack/react-table";
import { OnChangeFn, PaginationState, RowSelectionState } from "@tanstack/react-table";
import { HeartPulse } from "lucide-react";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { Team } from "@/components/key_team_helpers/key_list";
import { DataTable } from "@/components/shared/DataTable";
import { getHealthChecksTableColumns, type HealthCheckData, type HealthStatus } from "./HealthChecksTableColumns";
import { useHealthTableUrlState } from "./useHealthTableUrlState";
interface HealthChecksTableProps {
data: HealthCheckData[];
@ -54,7 +55,7 @@ export function HealthChecksTable({
onSelectModel,
teams,
}: HealthChecksTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const { sorting, onSortingChange } = useHealthTableUrlState();
const columns = useMemo(() => {
const columnDeps = {
@ -76,7 +77,7 @@ export function HealthChecksTable({
getRowId={(row, index) => row.model_info?.id ?? String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
onSortingChange={onSortingChange}
paginationMode="server"
pagination={pagination}
onPaginationChange={onPaginationChange}

View file

@ -0,0 +1,35 @@
import { functionalUpdate, type OnChangeFn, type SortingState } from "@tanstack/react-table";
import { useCallback, useMemo } from "react";
import { useUrlTableState, type UrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable";
const UNSORTED_COLUMN_ID = "";
const HEALTH_TABLE_STATE_OPTIONS: UrlTableStateOptions<never> = {
sortFields: ["model_id", "model_name", "team_id", "health_status", "last_check", "last_success"],
defaultSort: { id: UNSORTED_COLUMN_ID, desc: false },
defaultPageSize: 50,
filterColumns: [],
keyPrefix: "health_",
};
export function useHealthTableUrlState(): UrlTableState {
const state = useUrlTableState(HEALTH_TABLE_STATE_OPTIONS);
const { sorting: urlSorting, onSortingChange: setUrlSorting, pagination, onPaginationChange } = state;
const sorting = useMemo<SortingState>(
() => urlSorting.filter((entry) => entry.id !== UNSORTED_COLUMN_ID),
[urlSorting],
);
const onSortingChange = useCallback<OnChangeFn<SortingState>>(
(updater) => {
const pageBeforeSort = pagination;
setUrlSorting(functionalUpdate(updater, sorting));
onPaginationChange(pageBeforeSort);
},
[setUrlSorting, sorting, onPaginationChange, pagination],
);
return useMemo(() => ({ ...state, sorting, onSortingChange }), [state, sorting, onSortingChange]);
}

View file

@ -1,7 +1,8 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React, { ReactNode } from "react";
import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ModelInfoView from "./model_info_view";
import { toast } from "@/lib/toast";
@ -186,8 +187,20 @@ describe("ModelInfoView", () => {
mockCredentialCreateCall.mockResolvedValue({});
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const wrapperWithUrl = (searchParams = "", onUrlUpdate?: OnUrlUpdateFunction, keepMountUpdates = false) =>
function UrlWrapper({ children }: { children: ReactNode }) {
return (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onUrlUpdate}
hasMemory
resetUrlUpdateQueueOnMount={!keepMountUpdates}
>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</NuqsTestingAdapter>
);
};
const wrapper = wrapperWithUrl();
it("should render", async () => {
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
@ -430,6 +443,73 @@ describe("ModelInfoView", () => {
});
});
describe("model_tab in the URL", () => {
const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn<OnUrlUpdateFunction>>) => {
const lastCall = onUrlUpdate.mock.calls.at(-1);
if (!lastCall) throw new Error("expected a URL update");
return lastCall[0].searchParams;
};
it("opens on the Raw JSON tab named in the URL", async () => {
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper: wrapperWithUrl("?model=123&model_tab=raw") });
expect(await screen.findByRole("tab", { name: /raw json/i })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText(/"model_name": "GPT-4"/, { selector: "pre" })).toBeVisible();
});
it("writes the selected tab to the URL", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper: wrapperWithUrl("?model=123", onUrlUpdate) });
await user.click(await screen.findByRole("tab", { name: /raw json/i }));
await waitFor(() => expect(lastUrl(onUrlUpdate).get("model_tab")).toBe("raw"));
expect(lastUrl(onUrlUpdate).get("model")).toBe("123");
});
it("drops the tab from the URL when going back to the models list", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const onClose = vi.fn();
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} onClose={onClose} />, {
wrapper: wrapperWithUrl("?model=123&model_tab=raw", onUrlUpdate),
});
await user.click(await screen.findByRole("button", { name: /back to models/i }));
expect(onClose).toHaveBeenCalledTimes(1);
await waitFor(() => expect(lastUrl(onUrlUpdate).has("model_tab")).toBe(false));
});
it("drops the tab from the URL after deleting the model", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const onClose = vi.fn();
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} onClose={onClose} />, {
wrapper: wrapperWithUrl("?model=123&model_tab=raw", onUrlUpdate),
});
await user.click(await screen.findByTestId("delete-model-button"));
await user.click(await screen.findByRole("button", { name: /^delete$/i }));
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
expect(mockModelDeleteCall).toHaveBeenCalledWith("test-token", "123");
await waitFor(() => expect(lastUrl(onUrlUpdate).has("model_tab")).toBe(false));
});
it("opens on Overview and clears a tab value it does not know", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, {
wrapper: wrapperWithUrl("?model=123&model_tab=bogus", onUrlUpdate, true),
});
expect(await screen.findByRole("tab", { name: /overview/i })).toHaveAttribute("aria-selected", "true");
await waitFor(() => expect(lastUrl(onUrlUpdate).has("model_tab")).toBe(false));
expect(lastUrl(onUrlUpdate).get("model")).toBe("123");
});
});
it("keeps the edit form and its touched fields alive across a tab switch", async () => {
const user = userEvent.setup();
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });

View file

@ -47,6 +47,7 @@ import UpdateModelCredentialsModal from "./update_model_credentials_modal";
import ModelInfoEditForm, { type ModelEditFormValues, type TouchedPricingField } from "./ModelInfoEditForm";
import { Tag } from "./tag_management/types";
import { getDisplayModelName } from "./view_model/model_name_display";
import { useModelInfoTab } from "./useModelInfoTab";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
interface ModelInfoViewProps {
@ -71,6 +72,7 @@ export default function ModelInfoView({
modelAccessGroups,
}: ModelInfoViewProps) {
const queryClient = useQueryClient();
const { tab: detailTab, setTab: setDetailTab, close } = useModelInfoTab(onClose);
const [localModelData, setLocalModelData] = useState<any>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
@ -440,7 +442,7 @@ export default function ModelInfoView({
if (isLoadingModel) {
return (
<div className="p-4">
<Button variant="ghost" onClick={onClose} className="mb-4">
<Button variant="ghost" onClick={close} className="mb-4">
<ArrowLeft className="size-4" />
Back to Models
</Button>
@ -453,7 +455,7 @@ export default function ModelInfoView({
if (!modelData) {
return (
<div className="p-4">
<Button variant="ghost" onClick={onClose} className="mb-4">
<Button variant="ghost" onClick={close} className="mb-4">
<ArrowLeft className="size-4" />
Back to Models
</Button>
@ -522,7 +524,7 @@ export default function ModelInfoView({
});
}
onClose();
close();
} catch (error) {
console.error("Error deleting the model:", error);
toast.fromError("Failed to delete model");
@ -562,7 +564,7 @@ export default function ModelInfoView({
<div className="p-4">
<div className="flex justify-between items-center mb-6">
<div>
<Button variant="ghost" onClick={onClose} className="mb-4">
<Button variant="ghost" onClick={close} className="mb-4">
<ArrowLeft className="size-4" />
Back to Models
</Button>
@ -635,7 +637,7 @@ export default function ModelInfoView({
</div>
</div>
<Tabs defaultValue="overview">
<Tabs value={detailTab} onValueChange={setDetailTab}>
<TabsList variant="line" className="mb-6 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
Overview

View file

@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { renderWithProviders, screen, waitFor } from "../../tests/test-utils";
import { QueryClientProvider } from "@tanstack/react-query";
import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { render, renderWithProviders, screen, testQueryClient, waitFor } from "../../tests/test-utils";
import PassThroughInfoView from "./pass_through_info";
const updatePassThroughEndpoint = vi.fn();
@ -180,3 +182,52 @@ describe("pass_through_info update payload", () => {
expect(updatePassThroughEndpoint).toHaveBeenCalledTimes(1);
});
});
describe("pass_through_info detail tab in the URL", () => {
const renderWithUrl = (isAdmin: boolean, searchParams: string, onUrlUpdate?: OnUrlUpdateFunction) =>
render(
<PassThroughInfoView endpointData={endpoint} onClose={vi.fn()} accessToken="test-token" isAdmin={isAdmin} />,
{
wrapper: ({ children }) => (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onUrlUpdate}
hasMemory
resetUrlUpdateQueueOnMount={false}
>
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
</NuqsTestingAdapter>
),
},
);
it("opens on the Settings tab named in the URL", () => {
renderWithUrl(true, "?endpoint=ep-1&endpoint_tab=settings");
expect(screen.getByRole("tab", { name: "Settings" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeVisible();
});
it("writes the selected tab to the URL and clears it again on Overview", async () => {
const user = setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithUrl(true, "?endpoint=ep-1", onUrlUpdate);
await user.click(screen.getByRole("tab", { name: "Settings" }));
await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("endpoint_tab")).toBe("settings"));
await user.click(screen.getByRole("tab", { name: "Overview" }));
await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has("endpoint_tab")).toBe(false));
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("endpoint")).toBe("ep-1");
});
it("falls back to Overview and drops a Settings tab a non-admin cannot see", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithUrl(false, "?endpoint=ep-1&endpoint_tab=settings", onUrlUpdate);
expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has("endpoint_tab")).toBe(false));
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("endpoint")).toBe("ep-1");
});
});

View file

@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useZodForm } from "@/lib/forms/useZodForm";
import { usePassThroughDetailTab } from "./PassThroughSettings/passThroughDetailRouting";
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
const HTTP_METHOD_OPTIONS = HTTP_METHODS.map((method) => ({ label: method, value: method }));
@ -143,6 +144,7 @@ const PassThroughInfoView: React.FC<PassThroughInfoProps> = ({
onEndpointUpdated,
}) => {
const [endpointData, setEndpointData] = useState<PassThroughEndpoint | null>(initialEndpointData);
const [detailTab, setDetailTab] = usePassThroughDetailTab(isAdmin);
const [loading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [guardrails, setGuardrails] = useState<
@ -247,7 +249,7 @@ const PassThroughInfoView: React.FC<PassThroughInfoProps> = ({
</div>
</div>
<Tabs defaultValue="overview">
<Tabs value={detailTab} onValueChange={setDetailTab}>
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
Overview

View file

@ -0,0 +1,24 @@
import { useCallback } from "react";
import { useUrlTab } from "@/hooks/useUrlTab";
const MODEL_INFO_TABS = ["overview", "raw"] as const;
type ModelInfoTab = (typeof MODEL_INFO_TABS)[number];
const DEFAULT_MODEL_INFO_TAB: ModelInfoTab = "overview";
export interface ModelInfoTabState {
tab: ModelInfoTab;
setTab: (tab: ModelInfoTab) => void;
close: () => void;
}
export function useModelInfoTab(onClose: () => void): ModelInfoTabState {
const [tab, setTab] = useUrlTab(MODEL_INFO_TABS, DEFAULT_MODEL_INFO_TAB, "model_tab");
const close = useCallback(() => {
setTab(DEFAULT_MODEL_INFO_TAB);
onClose();
}, [setTab, onClose]);
return { tab, setTab, close };
}