mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
revert(ui): return Models + Endpoints tabs to in-memory, keep the ?model drill-in (#34629)
Per-tab path routing for Models made each tab a separate route parsed out of the pathname, which is fragile under a static export mounted at a runtime-variable server-root prefix. Revert the tabs to in-memory state: a single /models-and-endpoints route renders an antd Tabs whose active tab is React state, and each tab body moves from its own page.tsx into a non-routed panel component under panels/. Role-gating (which tabs show), the refresh control and the header are unchanged. The ?model= / ?team= query drill-in stays: it is query-param based (read via useSearchParams, written via history.pushState), so it is unaffected by the server-root prefix and remains shareable. The shared tab-routing helpers (createTabRoutes / useTabRouting) are untouched; the other four pages still use them. Removes the per-tab route dirs, layout.tsx and tabRoutes.ts (+ their path-routing tests) and replaces the layout's coverage with a page test for in-memory tab switching, the drill-in overlays and role-gating.
This commit is contained in:
parent
21de59b32e
commit
57894b5b5e
16 changed files with 322 additions and 364 deletions
|
|
@ -1061,7 +1061,17 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/models-and-endpoints/layout.tsx": {
|
||||
"src/app/(dashboard)/models-and-endpoints/page.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -4340,4 +4350,4 @@
|
|||
"count": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,126 +0,0 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelsAndEndpointsLayout from "./layout";
|
||||
|
||||
const { mockPush, mockReplace, navState } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockReplace: vi.fn(),
|
||||
navState: { pathname: "/models-and-endpoints", search: "" },
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => navState.pathname,
|
||||
useRouter: () => ({ push: mockPush, replace: mockReplace }),
|
||||
useSearchParams: () => new URLSearchParams(navState.search),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
|
||||
|
||||
vi.mock("@/components/molecules/cost_optimization_feedback_banner", () => ({ default: () => null }));
|
||||
vi.mock("@/components/model_info_view", () => ({
|
||||
default: ({ modelId }: { modelId: string }) => <div data-testid="model-info">model:{modelId}</div>,
|
||||
}));
|
||||
vi.mock("@/components/team/TeamInfo", () => ({
|
||||
default: ({ teamId }: { teamId: string }) => <div data-testid="team-info">team:{teamId}</div>,
|
||||
}));
|
||||
|
||||
const mockUseAuthorized = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized() }));
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) }));
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
||||
useUISettings: () => ({ data: { values: {} } }),
|
||||
}));
|
||||
vi.mock("@/app/(dashboard)/models-and-endpoints/useModelDashboardData", () => ({
|
||||
useModelDashboardData: () => ({
|
||||
availableModelGroups: [],
|
||||
availableModelAccessGroups: [],
|
||||
allModelsOnProxy: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderLayout = () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsLayout>
|
||||
<div data-testid="tab-content">CHILD</div>
|
||||
</ModelsAndEndpointsLayout>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("ModelsAndEndpointsLayout", () => {
|
||||
beforeEach(() => {
|
||||
navState.pathname = "/models-and-endpoints";
|
||||
navState.search = "";
|
||||
mockPush.mockClear();
|
||||
mockReplace.mockClear();
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "123",
|
||||
token: "123",
|
||||
userRole: "Admin",
|
||||
userId: "123",
|
||||
premiumUser: false,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(global as any).ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
it("renders the admin tab bar and the active tab's page content", () => {
|
||||
const { getByRole, getByTestId } = renderLayout();
|
||||
expect(getByRole("tab", { name: "LLM Credentials" })).toBeInTheDocument();
|
||||
expect(getByRole("tab", { name: "Health Status" })).toBeInTheDocument();
|
||||
expect(getByTestId("tab-content")).toHaveTextContent("CHILD");
|
||||
});
|
||||
|
||||
it("navigates to a tab's path when its tab is clicked", async () => {
|
||||
const { getByRole } = renderLayout();
|
||||
await act(async () => {
|
||||
getByRole("tab", { name: "Health Status" }).click();
|
||||
});
|
||||
expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/models-and-endpoints\/health\/$/));
|
||||
});
|
||||
|
||||
it("redirects to the base models path when the tab path is not permitted for the role", async () => {
|
||||
const replaceMock = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { replace: replaceMock, assign: vi.fn(), href: "http://localhost/", pathname: "/", search: "" },
|
||||
});
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "123",
|
||||
token: "123",
|
||||
userRole: "Internal User",
|
||||
userId: "123",
|
||||
premiumUser: false,
|
||||
});
|
||||
navState.pathname = "/models-and-endpoints/llm-credentials";
|
||||
await act(async () => {
|
||||
renderLayout();
|
||||
});
|
||||
expect(replaceMock).toHaveBeenCalledWith(expect.stringMatching(/\/models-and-endpoints\/$/));
|
||||
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
|
||||
});
|
||||
|
||||
it("renders the model detail overlay from ?model and hides the tabs and page content", () => {
|
||||
navState.search = "model=abc-123";
|
||||
const { getByTestId, queryByTestId, queryByRole } = renderLayout();
|
||||
expect(getByTestId("model-info")).toHaveTextContent("model:abc-123");
|
||||
expect(queryByTestId("tab-content")).toBeNull();
|
||||
expect(queryByRole("tab", { name: "Health Status" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the team detail overlay from ?team", () => {
|
||||
navState.search = "team=team-9";
|
||||
const { getByTestId, queryByTestId } = renderLayout();
|
||||
expect(getByTestId("team-info")).toHaveTextContent("team:team-9");
|
||||
expect(queryByTestId("tab-content")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Tabs } from "antd";
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { useTabRouting } from "@/app/(dashboard)/hooks/useTabRouting";
|
||||
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
|
||||
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
|
||||
import ModelInfoView from "@/components/model_info_view";
|
||||
import TeamInfoView from "@/components/team/TeamInfo";
|
||||
import { modelsRoutes, type ModelTabSlug } from "@/app/(dashboard)/models-and-endpoints/tabRoutes";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
||||
|
||||
const BASE_TAB_KEY = "all-models";
|
||||
|
||||
const TAB_LABELS: Record<ModelTabSlug, string> = {
|
||||
add: "Add Model",
|
||||
"llm-credentials": "LLM Credentials",
|
||||
"pass-through": "Pass-Through Endpoints",
|
||||
health: "Health Status",
|
||||
"retry-settings": "Model Retry Settings",
|
||||
"model-group-alias": "Model Group Alias",
|
||||
"price-data": "Price Data Reload",
|
||||
};
|
||||
|
||||
export default function ModelsAndEndpointsLayout({ children }: { children: ReactNode }) {
|
||||
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
|
||||
const { data: teams, isLoading: teamsLoading } = useTeams();
|
||||
const { data: uiSettings, isLoading: uiSettingsLoading } = useUISettings();
|
||||
const queryClient = useQueryClient();
|
||||
const { modelId, teamId, close } = useModelDetailRouting();
|
||||
const { availableModelAccessGroups, allModelsOnProxy } = useModelDashboardData();
|
||||
|
||||
const [lastRefreshed, setLastRefreshed] = useState("");
|
||||
|
||||
const isProxyAdmin = userRole && isProxyAdminRole(userRole);
|
||||
const isInternalUser = userRole && internalUserRoles.includes(userRole);
|
||||
const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams ?? null, userID);
|
||||
const addModelDisabledForInternalUsers =
|
||||
isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true;
|
||||
const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin);
|
||||
const isAdmin = all_admin_roles.includes(userRole);
|
||||
|
||||
const visibleSlugs = useMemo<Array<"" | ModelTabSlug>>(
|
||||
() => [
|
||||
"",
|
||||
...(shouldHideAddModelTab ? [] : (["add"] as const)),
|
||||
...(isAdmin
|
||||
? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const)
|
||||
: []),
|
||||
],
|
||||
[shouldHideAddModelTab, isAdmin],
|
||||
);
|
||||
|
||||
const tabRoutingConfig = {
|
||||
routes: modelsRoutes,
|
||||
baseTabKey: BASE_TAB_KEY,
|
||||
visibleKeys: visibleSlugs.filter(Boolean),
|
||||
ready: !teamsLoading && !uiSettingsLoading,
|
||||
};
|
||||
const { activeKey, onTabChange } = useTabRouting(tabRoutingConfig);
|
||||
|
||||
const allModelsLabel = isAdmin ? "All Models" : "Your Models";
|
||||
const tabItems = visibleSlugs.map((slug) => {
|
||||
const key = slug || BASE_TAB_KEY;
|
||||
return {
|
||||
key,
|
||||
label: slug ? TAB_LABELS[slug] : allModelsLabel,
|
||||
children: key === activeKey ? children : null,
|
||||
};
|
||||
});
|
||||
|
||||
const handleRefreshClick = () => {
|
||||
setLastRefreshed(new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
};
|
||||
|
||||
const invalidateModels = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
|
||||
if (teamId) {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<TeamInfoView
|
||||
teamId={teamId}
|
||||
onClose={close}
|
||||
accessToken={accessToken}
|
||||
is_team_admin={userRole === "Admin"}
|
||||
is_proxy_admin={userRole === "Proxy Admin"}
|
||||
userModels={allModelsOnProxy}
|
||||
editTeam={false}
|
||||
onUpdate={invalidateModels}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<div className="flex flex-col gap-2 p-8 w-full mt-2">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Model Management</h2>
|
||||
{isAdmin ? (
|
||||
<p className="text-sm text-gray-600">Add and manage models for the proxy</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">Add models for teams you are an admin for.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CostOptimizationFeedbackBanner />
|
||||
|
||||
{modelId ? (
|
||||
<ModelInfoView
|
||||
modelId={modelId}
|
||||
onClose={close}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
onModelUpdate={invalidateModels}
|
||||
modelAccessGroups={availableModelAccessGroups}
|
||||
/>
|
||||
) : (
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={onTabChange}
|
||||
items={tabItems}
|
||||
tabBarExtraContent={{
|
||||
right: (
|
||||
<div className="flex items-center space-x-2 self-center">
|
||||
{lastRefreshed && <span className="text-xs text-gray-500">Last Refreshed: {lastRefreshed}</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefreshClick}
|
||||
aria-label="Refresh models"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RefreshIcon className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelsAndEndpointsPage from "./page";
|
||||
|
||||
vi.mock("./panels/AllModelsPanel", () => ({ default: () => <div data-testid="panel-all-models" /> }));
|
||||
vi.mock("./panels/AddModelPanel", () => ({ default: () => <div data-testid="panel-add" /> }));
|
||||
vi.mock("./panels/LlmCredentialsPanel", () => ({ default: () => <div data-testid="panel-credentials" /> }));
|
||||
vi.mock("./panels/PassThroughPanel", () => ({ default: () => <div data-testid="panel-pass-through" /> }));
|
||||
vi.mock("./panels/HealthStatusPanel", () => ({ default: () => <div data-testid="panel-health" /> }));
|
||||
vi.mock("./panels/ModelRetrySettingsPanel", () => ({ default: () => <div data-testid="panel-retry" /> }));
|
||||
vi.mock("./panels/ModelGroupAliasPanel", () => ({ default: () => <div data-testid="panel-alias" /> }));
|
||||
vi.mock("./panels/PriceDataPanel", () => ({ default: () => <div data-testid="panel-price" /> }));
|
||||
|
||||
const detailState = { modelId: null as string | null, teamId: null as string | null };
|
||||
vi.mock("./detailNavigation", () => ({
|
||||
useModelDetailRouting: () => ({ ...detailState, close: vi.fn(), openModel: vi.fn(), openTeam: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/molecules/cost_optimization_feedback_banner", () => ({ default: () => null }));
|
||||
vi.mock("@/components/model_info_view", () => ({
|
||||
default: ({ modelId }: { modelId: string }) => <div data-testid="model-info">model:{modelId}</div>,
|
||||
}));
|
||||
vi.mock("@/components/team/TeamInfo", () => ({
|
||||
default: ({ teamId }: { teamId: string }) => <div data-testid="team-info">team:{teamId}</div>,
|
||||
}));
|
||||
|
||||
const mockUseAuthorized = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized() }));
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) }));
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
||||
useUISettings: () => ({ data: { values: {} } }),
|
||||
}));
|
||||
vi.mock("./useModelDashboardData", () => ({
|
||||
useModelDashboardData: () => ({ availableModelAccessGroups: [], allModelsOnProxy: [], availableModelGroups: [] }),
|
||||
}));
|
||||
|
||||
const ADMIN = { accessToken: "at", token: "t", userRole: "Admin", userId: "u1", premiumUser: false };
|
||||
const NON_ADMIN = { accessToken: "at", token: "t", userRole: "Internal User", userId: "u1", premiumUser: false };
|
||||
|
||||
const renderPage = () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("ModelsAndEndpointsPage", () => {
|
||||
beforeEach(() => {
|
||||
detailState.modelId = null;
|
||||
detailState.teamId = null;
|
||||
mockUseAuthorized.mockReturnValue(ADMIN);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(global as any).ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
it("renders the admin tab bar and the All Models panel by default", () => {
|
||||
const { getByRole, getByTestId } = renderPage();
|
||||
expect(getByRole("tab", { name: "All Models" })).toBeInTheDocument();
|
||||
expect(getByRole("tab", { name: "LLM Credentials" })).toBeInTheDocument();
|
||||
expect(getByRole("tab", { name: "Health Status" })).toBeInTheDocument();
|
||||
expect(getByTestId("panel-all-models")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches tabs in-memory, mounting only the active panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole, getByTestId, queryByTestId } = renderPage();
|
||||
await user.click(getByRole("tab", { name: "Health Status" }));
|
||||
expect(getByTestId("panel-health")).toBeInTheDocument();
|
||||
expect(queryByTestId("panel-all-models")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the model detail overlay from the ?model drill-in and hides the tabs", () => {
|
||||
detailState.modelId = "abc-123";
|
||||
const { getByTestId, queryByRole } = renderPage();
|
||||
expect(getByTestId("model-info")).toHaveTextContent("model:abc-123");
|
||||
expect(queryByRole("tab", { name: "All Models" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the team detail overlay from the ?team drill-in", () => {
|
||||
detailState.teamId = "team-9";
|
||||
const { getByTestId } = renderPage();
|
||||
expect(getByTestId("team-info")).toHaveTextContent("team:team-9");
|
||||
});
|
||||
|
||||
it("hides admin-only tabs for a non-admin user", () => {
|
||||
mockUseAuthorized.mockReturnValue(NON_ADMIN);
|
||||
const { queryByRole } = renderPage();
|
||||
expect(queryByRole("tab", { name: "LLM Credentials" })).toBeNull();
|
||||
expect(queryByRole("tab", { name: "Health Status" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,23 +1,185 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab";
|
||||
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Tabs } from "antd";
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
|
||||
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
|
||||
import ModelInfoView from "@/components/model_info_view";
|
||||
import TeamInfoView from "@/components/team/TeamInfo";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
||||
import AllModelsPanel from "@/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel";
|
||||
import AddModelPanel from "@/app/(dashboard)/models-and-endpoints/panels/AddModelPanel";
|
||||
import LlmCredentialsPanel from "@/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel";
|
||||
import PassThroughPanel from "@/app/(dashboard)/models-and-endpoints/panels/PassThroughPanel";
|
||||
import HealthStatusPanel from "@/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel";
|
||||
import ModelRetrySettingsPanel from "@/app/(dashboard)/models-and-endpoints/panels/ModelRetrySettingsPanel";
|
||||
import ModelGroupAliasPanel from "@/app/(dashboard)/models-and-endpoints/panels/ModelGroupAliasPanel";
|
||||
import PriceDataPanel from "@/app/(dashboard)/models-and-endpoints/panels/PriceDataPanel";
|
||||
|
||||
export default function AllModelsPage() {
|
||||
const [selectedModelGroup, setSelectedModelGroup] = useState<string | null>(null);
|
||||
const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData();
|
||||
const { openModel, openTeam } = useModelDetailRouting();
|
||||
type ModelTabSlug =
|
||||
| "add"
|
||||
| "llm-credentials"
|
||||
| "pass-through"
|
||||
| "health"
|
||||
| "retry-settings"
|
||||
| "model-group-alias"
|
||||
| "price-data";
|
||||
|
||||
const BASE_TAB_KEY = "all-models";
|
||||
|
||||
const TAB_LABELS: Record<ModelTabSlug, string> = {
|
||||
add: "Add Model",
|
||||
"llm-credentials": "LLM Credentials",
|
||||
"pass-through": "Pass-Through Endpoints",
|
||||
health: "Health Status",
|
||||
"retry-settings": "Model Retry Settings",
|
||||
"model-group-alias": "Model Group Alias",
|
||||
"price-data": "Price Data Reload",
|
||||
};
|
||||
|
||||
const renderPanel = (key: string) => {
|
||||
switch (key) {
|
||||
case BASE_TAB_KEY:
|
||||
return <AllModelsPanel />;
|
||||
case "add":
|
||||
return <AddModelPanel />;
|
||||
case "llm-credentials":
|
||||
return <LlmCredentialsPanel />;
|
||||
case "pass-through":
|
||||
return <PassThroughPanel />;
|
||||
case "health":
|
||||
return <HealthStatusPanel />;
|
||||
case "retry-settings":
|
||||
return <ModelRetrySettingsPanel />;
|
||||
case "model-group-alias":
|
||||
return <ModelGroupAliasPanel />;
|
||||
case "price-data":
|
||||
return <PriceDataPanel />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default function ModelsAndEndpointsPage() {
|
||||
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
|
||||
const { data: teams } = useTeams();
|
||||
const { data: uiSettings } = useUISettings();
|
||||
const queryClient = useQueryClient();
|
||||
const { modelId, teamId, close } = useModelDetailRouting();
|
||||
const { availableModelAccessGroups, allModelsOnProxy } = useModelDashboardData();
|
||||
|
||||
const [activeKey, setActiveKey] = useState<string>(BASE_TAB_KEY);
|
||||
const [lastRefreshed, setLastRefreshed] = useState("");
|
||||
|
||||
const isProxyAdmin = userRole && isProxyAdminRole(userRole);
|
||||
const isInternalUser = userRole && internalUserRoles.includes(userRole);
|
||||
const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams ?? null, userID);
|
||||
const addModelDisabledForInternalUsers =
|
||||
isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true;
|
||||
const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin);
|
||||
const isAdmin = all_admin_roles.includes(userRole);
|
||||
|
||||
const visibleSlugs = useMemo<Array<"" | ModelTabSlug>>(
|
||||
() => [
|
||||
"",
|
||||
...(shouldHideAddModelTab ? [] : (["add"] as const)),
|
||||
...(isAdmin
|
||||
? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const)
|
||||
: []),
|
||||
],
|
||||
[shouldHideAddModelTab, isAdmin],
|
||||
);
|
||||
|
||||
const allModelsLabel = isAdmin ? "All Models" : "Your Models";
|
||||
const tabItems = visibleSlugs.map((slug) => {
|
||||
const key = slug || BASE_TAB_KEY;
|
||||
return {
|
||||
key,
|
||||
label: slug ? TAB_LABELS[slug] : allModelsLabel,
|
||||
children: key === activeKey ? renderPanel(key) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const handleRefreshClick = () => {
|
||||
setLastRefreshed(new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
};
|
||||
|
||||
const invalidateModels = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
|
||||
if (teamId) {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<TeamInfoView
|
||||
teamId={teamId}
|
||||
onClose={close}
|
||||
accessToken={accessToken}
|
||||
is_team_admin={userRole === "Admin"}
|
||||
is_proxy_admin={userRole === "Proxy Admin"}
|
||||
userModels={allModelsOnProxy}
|
||||
editTeam={false}
|
||||
onUpdate={invalidateModels}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AllModelsTab
|
||||
selectedModelGroup={selectedModelGroup}
|
||||
setSelectedModelGroup={setSelectedModelGroup}
|
||||
availableModelGroups={availableModelGroups}
|
||||
availableModelAccessGroups={availableModelAccessGroups}
|
||||
setSelectedModelId={openModel}
|
||||
setSelectedTeamId={openTeam}
|
||||
/>
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<div className="flex flex-col gap-2 p-8 w-full mt-2">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Model Management</h2>
|
||||
{isAdmin ? (
|
||||
<p className="text-sm text-gray-600">Add and manage models for the proxy</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">Add models for teams you are an admin for.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CostOptimizationFeedbackBanner />
|
||||
|
||||
{modelId ? (
|
||||
<ModelInfoView
|
||||
modelId={modelId}
|
||||
onClose={close}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
onModelUpdate={invalidateModels}
|
||||
modelAccessGroups={availableModelAccessGroups}
|
||||
/>
|
||||
) : (
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
items={tabItems}
|
||||
tabBarExtraContent={{
|
||||
right: (
|
||||
<div className="flex items-center space-x-2 self-center">
|
||||
{lastRefreshed && <span className="text-xs text-gray-500">Last Refreshed: {lastRefreshed}</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefreshClick}
|
||||
aria-label="Refresh models"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RefreshIcon className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
|
||||
|
||||
export default function AddModelPage() {
|
||||
export default function AddModelPanel() {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
const [form] = Form.useForm();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab";
|
||||
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
|
||||
export default function AllModelsPanel() {
|
||||
const [selectedModelGroup, setSelectedModelGroup] = useState<string | null>(null);
|
||||
const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData();
|
||||
const { openModel, openTeam } = useModelDetailRouting();
|
||||
|
||||
return (
|
||||
<AllModelsTab
|
||||
selectedModelGroup={selectedModelGroup}
|
||||
setSelectedModelGroup={setSelectedModelGroup}
|
||||
availableModelGroups={availableModelGroups}
|
||||
availableModelAccessGroups={availableModelAccessGroups}
|
||||
setSelectedModelId={openModel}
|
||||
setSelectedTeamId={openTeam}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import HealthStatusPage from "./page";
|
||||
import HealthStatusPanel from "./HealthStatusPanel";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/models-and-endpoints/health",
|
||||
|
|
@ -27,7 +27,7 @@ vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostM
|
|||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) }));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "123" }) }));
|
||||
|
||||
describe("HealthStatusPage", () => {
|
||||
describe("HealthStatusPanel", () => {
|
||||
beforeEach(() => {
|
||||
mockHealthCheckComponent.mockClear();
|
||||
});
|
||||
|
|
@ -44,7 +44,7 @@ describe("HealthStatusPage", () => {
|
|||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<HealthStatusPage />);
|
||||
render(<HealthStatusPanel />);
|
||||
|
||||
expect(mockHealthCheckComponent).toHaveBeenCalled();
|
||||
const props = mockHealthCheckComponent.mock.calls[0][0];
|
||||
|
|
@ -13,7 +13,7 @@ import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/de
|
|||
|
||||
const HEALTH_PAGE_SIZE = 50;
|
||||
|
||||
export default function HealthStatusPage() {
|
||||
export default function HealthStatusPanel() {
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data: teams } = useTeams();
|
||||
const { data: modelCostMapData } = useModelCostMap();
|
||||
|
|
@ -4,7 +4,7 @@ import { Form } from "antd";
|
|||
import CredentialsPanel from "@/components/model_add/CredentialsPanel";
|
||||
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
|
||||
|
||||
export default function LlmCredentialsPage() {
|
||||
export default function LlmCredentialsPanel() {
|
||||
const [form] = Form.useForm();
|
||||
return <CredentialsPanel uploadProps={vertexCredentialsUploadProps(form)} />;
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import ModelGroupAliasSettings from "@/components/model_group_alias_settings";
|
|||
import { getCallbacksCall } from "@/components/networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
export default function ModelGroupAliasPage() {
|
||||
export default function ModelGroupAliasPanel() {
|
||||
const { accessToken, userId: userID, userRole } = useAuthorized();
|
||||
const [modelGroupAlias, setModelGroupAlias] = useState<{ [key: string]: string }>({});
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ interface RouterSettings {
|
|||
num_retries?: number | null;
|
||||
}
|
||||
|
||||
export default function ModelRetrySettingsPage() {
|
||||
export default function ModelRetrySettingsPanel() {
|
||||
const { accessToken, userId: userID, userRole } = useAuthorized();
|
||||
const { availableModelGroups } = useModelDashboardData();
|
||||
const updateRetryPolicy = useUpdateRetryPolicy(accessToken);
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import PassThroughSettings from "@/components/PassThroughSettings/PassThroughSettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
export default function PassThroughPage() {
|
||||
export default function PassThroughPanel() {
|
||||
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
|
||||
return (
|
||||
<PassThroughSettings accessToken={accessToken} userRole={userRole} userID={userID} premiumUser={premiumUser} />
|
||||
|
|
@ -2,6 +2,6 @@
|
|||
|
||||
import PriceDataManagementTab from "@/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab";
|
||||
|
||||
export default function PriceDataPage() {
|
||||
export default function PriceDataPanel() {
|
||||
return <PriceDataManagementTab />;
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
|
||||
|
||||
import { MODEL_TAB_SLUGS, modelTabHref, slugFromPathname } from "./tabRoutes";
|
||||
|
||||
describe("slugFromPathname", () => {
|
||||
it("returns empty string for the base path with or without a trailing slash", () => {
|
||||
expect(slugFromPathname("/models-and-endpoints")).toBe("");
|
||||
expect(slugFromPathname("/models-and-endpoints/")).toBe("");
|
||||
});
|
||||
|
||||
it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => {
|
||||
expect(slugFromPathname("/models-and-endpoints/add")).toBe("add");
|
||||
expect(slugFromPathname("/ui/models-and-endpoints/llm-credentials/")).toBe("llm-credentials");
|
||||
});
|
||||
|
||||
it("returns the raw segment for an unknown tab so the view can redirect to base", () => {
|
||||
expect(slugFromPathname("/ui/models-and-endpoints/bogus")).toBe("bogus");
|
||||
});
|
||||
|
||||
it("returns empty string when the models base segment is not in the path", () => {
|
||||
expect(slugFromPathname("/teams")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelTabHref", () => {
|
||||
it("builds the trailing-slash base href for the empty slug", () => {
|
||||
expect(modelTabHref("")).toBe("/ui/models-and-endpoints/");
|
||||
});
|
||||
|
||||
it("builds a trailing-slash href for every tab slug (required by static export)", () => {
|
||||
for (const slug of MODEL_TAB_SLUGS) {
|
||||
expect(modelTabHref(slug)).toBe(`/ui/models-and-endpoints/${slug}/`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { createTabRoutes } from "@/utils/tabRoutes";
|
||||
|
||||
export const modelsRoutes = createTabRoutes("models-and-endpoints", [
|
||||
"add",
|
||||
"llm-credentials",
|
||||
"pass-through",
|
||||
"health",
|
||||
"retry-settings",
|
||||
"model-group-alias",
|
||||
"price-data",
|
||||
] as const);
|
||||
|
||||
export type ModelTabSlug = (typeof modelsRoutes.slugs)[number];
|
||||
|
||||
export const MODELS_BASE_SEGMENT = modelsRoutes.baseSegment;
|
||||
export const MODEL_TAB_SLUGS = modelsRoutes.slugs;
|
||||
export const modelTabHref = modelsRoutes.tabHref;
|
||||
export const slugFromPathname = modelsRoutes.slugFromPathname;
|
||||
Loading…
Add table
Reference in a new issue