mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-14 04:09:09 +00:00
Merge f56c13c5ab into d8c0e2c715
This commit is contained in:
commit
5bd963187d
27 changed files with 1181 additions and 116 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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…"
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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"]} />,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
24
ui/litellm-dashboard/src/components/useModelInfoTab.ts
Normal file
24
ui/litellm-dashboard/src/components/useModelInfoTab.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue