mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
refactor(ui): extract shared tab-routing helpers and adopt them in Models + Endpoints (#34435)
* refactor(ui): extract shared tab-routing helpers
Every per-tab-routed page copy-pastes the same URL<->slug logic and the
same active-tab/redirect engine. Extract two reusable pieces:
- createTabRoutes(baseSegment, slugs) in utils/tabRoutes.ts returns
{ baseSegment, slugs, tabHref, slugFromPathname }, the trailing-slash
href builder (via migratedHref) and the pathname->slug reader.
- useTabRouting({ routes, baseTabKey, visibleKeys, ready }) derives the
active tab from the pathname, redirects an unknown/forbidden slug to
base once ready, and returns an onTabChange navigator.
visibleKeys + ready exist so a role-gated page can pass its filtered tab
set and defer the redirect until permissions resolve, rather than
bouncing a user off a still-loading valid tab. Both are pure/unit-tested.
No page consumes them yet.
* refactor(ui): migrate Models + Endpoints onto the shared tab-routing helpers
Replace the page's hand-rolled tabRoutes.ts (base segment + slug tuple +
href builder + slugFromPathname) with createTabRoutes, keeping the
existing named exports as thin re-exports so callers and tests are
unchanged. The layout drops its local activeSlug/isKnownSlug/activeKey
derivation, its redirect useEffect and its router.push onChange in favor
of useTabRouting, passing the role-filtered visibleKeys and a ready flag
(!teamsLoading && !uiSettingsLoading) so the permission-gated redirect
behavior is preserved exactly. The antd tab bar, role-gated tab set, the
refresh button and the ?model=/?team= drill-in overlay are untouched; the
file's pre-existing antd import is now recorded in the suppressions
baseline since editing it makes it a linted-as-changed file.
The existing models-and-endpoints layout.test.tsx and tabRoutes.test.ts
pass unchanged, which is the regression guarantee.
This commit is contained in:
parent
07726b4f60
commit
e906a7e796
7 changed files with 217 additions and 37 deletions
|
|
@ -1142,6 +1142,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/models-and-endpoints/layout.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts": {
|
||||
"prefer-const": {
|
||||
"count": 6
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockPush, navState } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
navState: { pathname: "/logs" },
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => navState.pathname,
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
|
||||
|
||||
import { createTabRoutes } from "@/utils/tabRoutes";
|
||||
import { useTabRouting } from "./useTabRouting";
|
||||
|
||||
const routes = createTabRoutes("logs", ["audit", "deleted-keys", "deleted-teams"] as const);
|
||||
|
||||
const render = (ready = true) => {
|
||||
const config = {
|
||||
routes,
|
||||
baseTabKey: "request-logs",
|
||||
visibleKeys: ["audit", "deleted-keys", "deleted-teams"],
|
||||
ready,
|
||||
};
|
||||
return renderHook(() => useTabRouting(config));
|
||||
};
|
||||
|
||||
describe("useTabRouting", () => {
|
||||
beforeEach(() => {
|
||||
navState.pathname = "/logs";
|
||||
mockPush.mockClear();
|
||||
});
|
||||
|
||||
it("maps the base path to the base tab key", () => {
|
||||
const { result } = render();
|
||||
expect(result.current.activeSlug).toBe("");
|
||||
expect(result.current.activeKey).toBe("request-logs");
|
||||
});
|
||||
|
||||
it("uses the slug itself as the active key for a known nested tab", () => {
|
||||
navState.pathname = "/ui/logs/audit";
|
||||
const { result } = render();
|
||||
expect(result.current.activeKey).toBe("audit");
|
||||
});
|
||||
|
||||
it("falls back to the base tab key for an unknown slug", () => {
|
||||
navState.pathname = "/ui/logs/bogus";
|
||||
const { result } = render();
|
||||
expect(result.current.activeKey).toBe("request-logs");
|
||||
});
|
||||
|
||||
it("redirects an unknown slug to the base href once ready", () => {
|
||||
const replaceMock = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", { configurable: true, value: { replace: replaceMock } });
|
||||
navState.pathname = "/ui/logs/bogus";
|
||||
render(true);
|
||||
expect(replaceMock).toHaveBeenCalledWith("/ui/logs/");
|
||||
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
|
||||
});
|
||||
|
||||
it("does not redirect while not ready (role/creds still loading)", () => {
|
||||
const replaceMock = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", { configurable: true, value: { replace: replaceMock } });
|
||||
navState.pathname = "/ui/logs/bogus";
|
||||
render(false);
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
|
||||
});
|
||||
|
||||
it("pushes the tab href on change, mapping the base key back to the empty slug", () => {
|
||||
const { result } = render();
|
||||
result.current.onTabChange("audit");
|
||||
expect(mockPush).toHaveBeenCalledWith("/ui/logs/audit/");
|
||||
result.current.onTabChange("request-logs");
|
||||
expect(mockPush).toHaveBeenCalledWith("/ui/logs/");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { TabRoutes } from "@/utils/tabRoutes";
|
||||
|
||||
interface UseTabRoutingArgs {
|
||||
routes: Pick<TabRoutes<string>, "tabHref" | "slugFromPathname">;
|
||||
baseTabKey: string;
|
||||
visibleKeys: readonly string[];
|
||||
ready?: boolean;
|
||||
}
|
||||
|
||||
interface TabRoutingState {
|
||||
activeSlug: string;
|
||||
activeKey: string;
|
||||
onTabChange: (key: string) => void;
|
||||
}
|
||||
|
||||
export function useTabRouting({ routes, baseTabKey, visibleKeys, ready = true }: UseTabRoutingArgs): TabRoutingState {
|
||||
const { tabHref, slugFromPathname } = routes;
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const activeSlug = slugFromPathname(pathname);
|
||||
const isKnownSlug = activeSlug === "" || visibleKeys.includes(activeSlug);
|
||||
const activeKey = isKnownSlug ? activeSlug || baseTabKey : baseTabKey;
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && activeSlug !== "" && !isKnownSlug) {
|
||||
window.location.replace(tabHref(""));
|
||||
}
|
||||
}, [ready, activeSlug, isKnownSlug, tabHref]);
|
||||
|
||||
const onTabChange = (key: string) => {
|
||||
router.push(tabHref(key === baseTabKey ? "" : key));
|
||||
};
|
||||
|
||||
return { activeSlug, activeKey, onTabChange };
|
||||
}
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
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 { modelTabHref, slugFromPathname, type ModelTabSlug } from "@/app/(dashboard)/models-and-endpoints/tabRoutes";
|
||||
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";
|
||||
|
||||
|
|
@ -33,8 +33,6 @@ export default function ModelsAndEndpointsLayout({ children }: { children: React
|
|||
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
|
||||
const { data: teams, isLoading: teamsLoading } = useTeams();
|
||||
const { data: uiSettings, isLoading: uiSettingsLoading } = useUISettings();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { modelId, teamId, close } = useModelDetailRouting();
|
||||
const { availableModelAccessGroups, allModelsOnProxy } = useModelDashboardData();
|
||||
|
|
@ -60,18 +58,13 @@ export default function ModelsAndEndpointsLayout({ children }: { children: React
|
|||
[shouldHideAddModelTab, isAdmin],
|
||||
);
|
||||
|
||||
const activeSlug = slugFromPathname(pathname);
|
||||
const isKnownSlug = visibleSlugs.some((slug) => slug === activeSlug);
|
||||
const activeKey = isKnownSlug ? activeSlug || BASE_TAB_KEY : BASE_TAB_KEY;
|
||||
|
||||
useEffect(() => {
|
||||
if (teamsLoading || uiSettingsLoading) {
|
||||
return;
|
||||
}
|
||||
if (activeSlug !== "" && !isKnownSlug) {
|
||||
window.location.replace(modelTabHref(""));
|
||||
}
|
||||
}, [activeSlug, isKnownSlug, teamsLoading, uiSettingsLoading]);
|
||||
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) => {
|
||||
|
|
@ -137,7 +130,7 @@ export default function ModelsAndEndpointsLayout({ children }: { children: React
|
|||
) : (
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={(key) => router.push(modelTabHref(key === BASE_TAB_KEY ? "" : key))}
|
||||
onChange={onTabChange}
|
||||
items={tabItems}
|
||||
tabBarExtraContent={{
|
||||
right: (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { migratedHref } from "@/utils/migratedPages";
|
||||
import { createTabRoutes } from "@/utils/tabRoutes";
|
||||
|
||||
export const MODELS_BASE_SEGMENT = "models-and-endpoints";
|
||||
|
||||
export const MODEL_TAB_SLUGS = [
|
||||
export const modelsRoutes = createTabRoutes("models-and-endpoints", [
|
||||
"add",
|
||||
"llm-credentials",
|
||||
"pass-through",
|
||||
|
|
@ -10,20 +8,11 @@ export const MODEL_TAB_SLUGS = [
|
|||
"retry-settings",
|
||||
"model-group-alias",
|
||||
"price-data",
|
||||
] as const;
|
||||
] as const);
|
||||
|
||||
export type ModelTabSlug = (typeof MODEL_TAB_SLUGS)[number];
|
||||
export type ModelTabSlug = (typeof modelsRoutes.slugs)[number];
|
||||
|
||||
export function modelTabHref(slug: string): string {
|
||||
const base = migratedHref(MODELS_BASE_SEGMENT);
|
||||
return slug ? `${base}/${slug}/` : `${base}/`;
|
||||
}
|
||||
|
||||
export function slugFromPathname(pathname: string): string {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const idx = parts.indexOf(MODELS_BASE_SEGMENT);
|
||||
if (idx === -1) {
|
||||
return "";
|
||||
}
|
||||
return parts[idx + 1] ?? "";
|
||||
}
|
||||
export const MODELS_BASE_SEGMENT = modelsRoutes.baseSegment;
|
||||
export const MODEL_TAB_SLUGS = modelsRoutes.slugs;
|
||||
export const modelTabHref = modelsRoutes.tabHref;
|
||||
export const slugFromPathname = modelsRoutes.slugFromPathname;
|
||||
|
|
|
|||
47
ui/litellm-dashboard/src/utils/tabRoutes.test.ts
Normal file
47
ui/litellm-dashboard/src/utils/tabRoutes.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
|
||||
|
||||
import { createTabRoutes } from "./tabRoutes";
|
||||
|
||||
const routes = createTabRoutes("logs", ["audit", "deleted-keys", "deleted-teams"] as const);
|
||||
|
||||
describe("createTabRoutes.slugFromPathname", () => {
|
||||
it("returns empty string for the base path with or without a trailing slash", () => {
|
||||
expect(routes.slugFromPathname("/logs")).toBe("");
|
||||
expect(routes.slugFromPathname("/logs/")).toBe("");
|
||||
});
|
||||
|
||||
it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => {
|
||||
expect(routes.slugFromPathname("/logs/audit")).toBe("audit");
|
||||
expect(routes.slugFromPathname("/ui/logs/deleted-teams/")).toBe("deleted-teams");
|
||||
});
|
||||
|
||||
it("returns the raw segment for an unknown tab so the caller can redirect to base", () => {
|
||||
expect(routes.slugFromPathname("/ui/logs/bogus")).toBe("bogus");
|
||||
});
|
||||
|
||||
it("returns empty string when the base segment is not in the path", () => {
|
||||
expect(routes.slugFromPathname("/teams")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTabRoutes.tabHref", () => {
|
||||
it("builds the trailing-slash base href for the empty slug", () => {
|
||||
expect(routes.tabHref("")).toBe("/ui/logs/");
|
||||
});
|
||||
|
||||
it("builds a trailing-slash href for every tab slug (required by static export)", () => {
|
||||
for (const slug of routes.slugs) {
|
||||
expect(routes.tabHref(slug)).toBe(`/ui/logs/${slug}/`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTabRoutes metadata", () => {
|
||||
it("preserves the base segment and slug tuple", () => {
|
||||
expect(routes.baseSegment).toBe("logs");
|
||||
expect(routes.slugs).toEqual(["audit", "deleted-keys", "deleted-teams"]);
|
||||
});
|
||||
});
|
||||
26
ui/litellm-dashboard/src/utils/tabRoutes.ts
Normal file
26
ui/litellm-dashboard/src/utils/tabRoutes.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { migratedHref } from "@/utils/migratedPages";
|
||||
|
||||
export interface TabRoutes<Slug extends string> {
|
||||
baseSegment: string;
|
||||
slugs: readonly Slug[];
|
||||
tabHref: (slug: string) => string;
|
||||
slugFromPathname: (pathname: string) => string;
|
||||
}
|
||||
|
||||
export function createTabRoutes<Slug extends string>(baseSegment: string, slugs: readonly Slug[]): TabRoutes<Slug> {
|
||||
const tabHref = (slug: string): string => {
|
||||
const base = migratedHref(baseSegment);
|
||||
return slug ? `${base}/${slug}/` : `${base}/`;
|
||||
};
|
||||
|
||||
const slugFromPathname = (pathname: string): string => {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const idx = parts.indexOf(baseSegment);
|
||||
if (idx === -1) {
|
||||
return "";
|
||||
}
|
||||
return parts[idx + 1] ?? "";
|
||||
};
|
||||
|
||||
return { baseSegment, slugs, tabHref, slugFromPathname };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue