Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ui/litellm-dashboard/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,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
Expand Down
Original file line number Diff line number Diff line change
@@ -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/");
});
});
38 changes: 38 additions & 0 deletions ui/litellm-dashboard/src/app/(dashboard)/hooks/useTabRouting.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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();
Expand All @@ -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) => {
Expand Down Expand Up @@ -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: (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,18 @@
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",
"health",
"retry-settings",
"model-group-alias",
"price-data",
] as const;

export type ModelTabSlug = (typeof MODEL_TAB_SLUGS)[number];
] as const);

export function modelTabHref(slug: string): string {
const base = migratedHref(MODELS_BASE_SEGMENT);
return slug ? `${base}/${slug}/` : `${base}/`;
}
export type ModelTabSlug = (typeof modelsRoutes.slugs)[number];

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 changes: 47 additions & 0 deletions ui/litellm-dashboard/src/utils/tabRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 26 additions & 0 deletions ui/litellm-dashboard/src/utils/tabRoutes.ts
Original file line number Diff line number Diff line change
@@ -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