mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
feat(ui): add i18n support with language selector and zh-CN translations
Adds react-i18next infrastructure to the dashboard as a minimal first step for issue 19730. Covers the sidebar, navbar, user dropdown and login page with English and Simplified Chinese translations. Language resolution order is localStorage (litellm_ui_language) first, then browser language (exact match, then primary subtag), then English. Only an explicit pick in the new navbar language selector is persisted, so users who never choose keep following their browser language. Switching language also syncs the antd ConfigProvider locale, the dayjs global locale and the html lang attribute; the languageChanged listener is registered before init() because i18next emits the initial event synchronously during init. Sidebar menu labels move from a module-level menuGroups constant to a getMenuGroups(t) factory so labels resolve against the active language; getAvailablePages takes the t function for the same reason.
This commit is contained in:
parent
8259d6cd85
commit
bfaaea8998
19 changed files with 656 additions and 122 deletions
75
ui/litellm-dashboard/package-lock.json
generated
75
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -20,6 +20,7 @@
|
|||
"antd": "5.29.3",
|
||||
"cva": "1.0.0-beta.4",
|
||||
"dayjs": "1.11.19",
|
||||
"i18next": "26.3.0",
|
||||
"jwt-decode": "4.0.0",
|
||||
"lucide-react": "0.513.0",
|
||||
"moment": "2.30.1",
|
||||
|
|
@ -29,6 +30,7 @@
|
|||
"react": "18.3.1",
|
||||
"react-copy-to-clipboard": "5.1.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-json-view-lite": "2.5.0",
|
||||
"react-markdown": "9.1.0",
|
||||
"react-syntax-highlighter": "15.6.6",
|
||||
|
|
@ -7392,6 +7394,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
|
|
@ -7439,6 +7450,34 @@
|
|||
"ms": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
|
||||
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
|
@ -11215,6 +11254,33 @@
|
|||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
|
|
@ -13388,6 +13454,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"antd": "5.29.3",
|
||||
"cva": "1.0.0-beta.4",
|
||||
"dayjs": "1.11.19",
|
||||
"i18next": "26.3.0",
|
||||
"jwt-decode": "4.0.0",
|
||||
"lucide-react": "0.513.0",
|
||||
"moment": "2.30.1",
|
||||
|
|
@ -41,6 +42,7 @@
|
|||
"react": "18.3.1",
|
||||
"react-copy-to-clipboard": "5.1.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-json-view-lite": "2.5.0",
|
||||
"react-markdown": "9.1.0",
|
||||
"react-syntax-highlighter": "15.6.6",
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ import { InfoCircleOutlined, CloudServerOutlined } from "@ant-design/icons";
|
|||
import { Alert, Button, Card, Form, Input, Popover, Select, Space, Typography } from "antd";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useWorker } from "@/hooks/useWorker";
|
||||
|
||||
function LoginPageContent() {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -154,13 +156,10 @@ function LoginPageContent() {
|
|||
</div>
|
||||
|
||||
<Alert
|
||||
message="Admin UI Disabled"
|
||||
message={t("login.adminUiDisabledTitle")}
|
||||
description={
|
||||
<>
|
||||
<Paragraph className="text-sm">
|
||||
The Admin UI has been disabled by the administrator. To re-enable it, please update the following
|
||||
environment variable:
|
||||
</Paragraph>
|
||||
<Paragraph className="text-sm">{t("login.adminUiDisabledBody")}</Paragraph>
|
||||
<Paragraph className="text-sm">
|
||||
<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">DISABLE_ADMIN_UI=False</code>
|
||||
</Paragraph>
|
||||
|
|
@ -184,25 +183,29 @@ function LoginPageContent() {
|
|||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Title level={3}>Login</Title>
|
||||
<Text type="secondary">Access your LiteLLM Admin UI.</Text>
|
||||
<Title level={3}>{t("login.title")}</Title>
|
||||
<Text type="secondary">{t("login.subtitle")}</Text>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
message="Default Credentials"
|
||||
message={t("login.defaultCredentialsTitle")}
|
||||
description={
|
||||
<>
|
||||
<Paragraph className="text-sm">
|
||||
By default, Username is <code className="bg-gray-100 px-1 py-0.5 rounded text-xs">admin</code> and
|
||||
Password is your set LiteLLM Proxy
|
||||
<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">MASTER_KEY</code>.
|
||||
<Trans
|
||||
i18nKey="login.defaultCredentialsBody"
|
||||
components={{ code: <code className="bg-gray-100 px-1 py-0.5 rounded text-xs" /> }}
|
||||
/>
|
||||
</Paragraph>
|
||||
<Paragraph className="text-sm">
|
||||
Need to set UI credentials or SSO?{" "}
|
||||
<a href="https://docs.litellm.ai/docs/proxy/ui" target="_blank" rel="noopener noreferrer">
|
||||
Check the documentation
|
||||
</a>
|
||||
.
|
||||
<Trans
|
||||
i18nKey="login.docsHint"
|
||||
components={{
|
||||
docsLink: (
|
||||
<a href="https://docs.litellm.ai/docs/proxy/ui" target="_blank" rel="noopener noreferrer" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Paragraph>
|
||||
</>
|
||||
}
|
||||
|
|
@ -215,11 +218,11 @@ function LoginPageContent() {
|
|||
|
||||
<Form onFinish={handleSubmit} layout="vertical" requiredMark={false}>
|
||||
{uiConfig?.is_control_plane && workers.length > 0 && (
|
||||
<Form.Item label="Worker" style={{ marginBottom: 16 }}>
|
||||
<Form.Item label={t("login.worker")} style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
value={selectedWorkerId || undefined}
|
||||
onChange={(value) => setSelectedWorkerId(value)}
|
||||
placeholder="Choose a worker to connect to"
|
||||
placeholder={t("login.workerPlaceholder")}
|
||||
size="large"
|
||||
suffixIcon={<CloudServerOutlined />}
|
||||
options={workers.map((w) => ({
|
||||
|
|
@ -231,12 +234,12 @@ function LoginPageContent() {
|
|||
)}
|
||||
|
||||
<Form.Item
|
||||
label="Username"
|
||||
label={t("login.username")}
|
||||
name="username"
|
||||
rules={[{ required: true, message: "Please enter your username" }]}
|
||||
rules={[{ required: true, message: t("login.usernameRequired") }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="Enter your username"
|
||||
placeholder={t("login.usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
|
|
@ -247,12 +250,12 @@ function LoginPageContent() {
|
|||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Password"
|
||||
label={t("login.password")}
|
||||
name="password"
|
||||
rules={[{ required: true, message: "Please enter your password" }]}
|
||||
rules={[{ required: true, message: t("login.passwordRequired") }]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="Enter your password"
|
||||
placeholder={t("login.passwordPlaceholder")}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
|
|
@ -270,14 +273,14 @@ function LoginPageContent() {
|
|||
block
|
||||
size="large"
|
||||
>
|
||||
{isLoginLoading ? "Logging in..." : "Login"}
|
||||
{isLoginLoading ? t("login.loggingIn") : t("login.loginButton")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
{!uiConfig?.sso_configured ? (
|
||||
<Popover content="Please configure SSO to log in with SSO." trigger="hover">
|
||||
<Popover content={t("login.ssoNotConfigured")} trigger="hover">
|
||||
<Button disabled block size="large">
|
||||
Login with SSO
|
||||
{t("login.loginWithSso")}
|
||||
</Button>
|
||||
</Popover>
|
||||
) : (
|
||||
|
|
@ -299,7 +302,7 @@ function LoginPageContent() {
|
|||
block
|
||||
size="large"
|
||||
>
|
||||
Login with SSO
|
||||
{t("login.loginWithSso")}
|
||||
</Button>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
|
@ -312,9 +315,7 @@ function LoginPageContent() {
|
|||
closable
|
||||
message={
|
||||
<Text>
|
||||
Single Sign-On (SSO) is enabled. LiteLLM no longer automatically redirects to the SSO login flow upon
|
||||
loading this page. To re-enable auto-redirect-to-SSO, set{" "}
|
||||
<Text code>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</Text> in your environment configuration.
|
||||
<Trans i18nKey="login.ssoEnabledNotice" components={{ code: <Text code /> }} />
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ import { DownOutlined, LoadingOutlined } from "@ant-design/icons";
|
|||
import { Button, Dropdown, Space, Typography } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
function formatDate(dateStr: string, locale: string): string {
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", {
|
||||
return date.toLocaleDateString(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
|
|
@ -18,6 +19,7 @@ function formatDate(dateStr: string): string {
|
|||
}
|
||||
|
||||
export const BlogDropdown: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const disableBlogPosts = useDisableBlogPosts();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useBlogPosts();
|
||||
|
|
@ -36,9 +38,9 @@ export const BlogDropdown: React.FC = () => {
|
|||
key: "error",
|
||||
label: (
|
||||
<Space>
|
||||
<Text type="danger">Failed to load posts</Text>
|
||||
<Text type="danger">{t("navbar.failedToLoadPosts")}</Text>
|
||||
<Button size="small" onClick={() => refetch()}>
|
||||
Retry
|
||||
{t("navbar.retry")}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
|
|
@ -46,7 +48,7 @@ export const BlogDropdown: React.FC = () => {
|
|||
},
|
||||
];
|
||||
} else if (!data || data.posts.length === 0) {
|
||||
items = [{ key: "empty", label: <Text type="secondary">No posts available</Text>, disabled: true }];
|
||||
items = [{ key: "empty", label: <Text type="secondary">{t("navbar.noPostsAvailable")}</Text>, disabled: true }];
|
||||
} else {
|
||||
items = [
|
||||
...data.posts.slice(0, 5).map((post: BlogPost) => ({
|
||||
|
|
@ -57,7 +59,7 @@ export const BlogDropdown: React.FC = () => {
|
|||
{post.title}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{formatDate(post.date)}
|
||||
{formatDate(post.date, i18n.language)}
|
||||
</Text>
|
||||
<Paragraph ellipsis={{ rows: 2 }}>{post.description}</Paragraph>
|
||||
</a>
|
||||
|
|
@ -68,7 +70,7 @@ export const BlogDropdown: React.FC = () => {
|
|||
key: "view-all",
|
||||
label: (
|
||||
<a href="https://docs.litellm.ai/blog" target="_blank" rel="noopener noreferrer">
|
||||
View all posts
|
||||
{t("navbar.viewAllPosts")}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
|
|
@ -79,7 +81,7 @@ export const BlogDropdown: React.FC = () => {
|
|||
return (
|
||||
<Dropdown menu={{ items }} trigger={["hover"]} placement="bottomRight">
|
||||
<Button type="text" className={`${NAV_PRODUCT_LINK_CLASS} !border-0 !bg-transparent`}>
|
||||
Blog
|
||||
{t("navbar.blog")}
|
||||
<DownOutlined className="text-[10px] text-gray-500" aria-hidden />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPro
|
|||
import { GithubOutlined, SlackOutlined } from "@ant-design/icons";
|
||||
import { Tooltip } from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const iconBtnClass =
|
||||
"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border-0 bg-transparent text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 cursor-pointer";
|
||||
|
||||
export const CommunityEngagementButtons: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
|
||||
if (disableShowPrompts) {
|
||||
|
|
@ -16,26 +18,26 @@ export const CommunityEngagementButtons: React.FC = () => {
|
|||
return (
|
||||
<div
|
||||
className="flex items-center gap-0.5 rounded-md border border-gray-200/80 bg-gray-50 px-0.5 py-0"
|
||||
aria-label="Community links"
|
||||
aria-label={t("navbar.communityLinks")}
|
||||
>
|
||||
<Tooltip title="LiteLLM Slack community">
|
||||
<Tooltip title={t("navbar.slackCommunity")}>
|
||||
<a
|
||||
href="https://www.litellm.ai/support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={iconBtnClass}
|
||||
aria-label="Join Slack"
|
||||
aria-label={t("navbar.joinSlack")}
|
||||
>
|
||||
<SlackOutlined className="text-lg" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Tooltip title="LiteLLM on GitHub">
|
||||
<Tooltip title={t("navbar.githubRepo")}>
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={iconBtnClass}
|
||||
aria-label="LiteLLM on GitHub"
|
||||
aria-label={t("navbar.githubRepo")}
|
||||
>
|
||||
<GithubOutlined className="text-lg" />
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import i18n, { LANGUAGE_STORAGE_KEY } from "@/lib/i18n";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
|
||||
describe("LanguageSelector", () => {
|
||||
afterEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("lists all supported languages in the dropdown", async () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Language" }));
|
||||
|
||||
expect(await screen.findByText("English")).toBeInTheDocument();
|
||||
expect(screen.getByText("简体中文")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches the language and persists the choice", async () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Language" }));
|
||||
await userEvent.click(await screen.findByText("简体中文"));
|
||||
|
||||
await waitFor(() => expect(i18n.language).toBe("zh-CN"));
|
||||
expect(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("zh-CN");
|
||||
// The selector's own label re-renders in the new language
|
||||
expect(screen.getByRole("button", { name: "语言" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { LANGUAGE_STORAGE_KEY, SUPPORTED_LANGUAGES } from "@/lib/i18n";
|
||||
import { setLocalStorageItem } from "@/utils/localStorageUtils";
|
||||
import { GlobalOutlined } from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Button, Dropdown } from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const LanguageSelector: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const items: MenuProps["items"] = SUPPORTED_LANGUAGES.map((lang) => ({
|
||||
key: lang.code,
|
||||
label: lang.label,
|
||||
}));
|
||||
|
||||
const handleSelect: MenuProps["onClick"] = ({ key }) => {
|
||||
i18n.changeLanguage(key);
|
||||
// Only persist explicit choices, so browser-language auto-detection
|
||||
// keeps working for users who never picked a language
|
||||
setLocalStorageItem(LANGUAGE_STORAGE_KEY, key);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{ items, selectedKeys: [i18n.language], onClick: handleSelect }}
|
||||
trigger={["click"]}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
className="!flex h-9 w-9 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
aria-label={t("navbar.language")}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<GlobalOutlined className="text-lg" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
import type { MenuProps } from "antd";
|
||||
import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
@ -62,6 +63,7 @@ interface UserDropdownProps {
|
|||
}
|
||||
|
||||
const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
||||
const { t } = useTranslation();
|
||||
const { userId, userEmail, userRole, premiumUser } = useAuthorized();
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
const disableUsageIndicator = useDisableUsageIndicator();
|
||||
|
|
@ -80,7 +82,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
label: (
|
||||
<Space>
|
||||
<LogoutOutlined />
|
||||
Logout
|
||||
{t("user.logout")}
|
||||
</Space>
|
||||
),
|
||||
onClick: onLogout,
|
||||
|
|
@ -96,11 +98,11 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
</Space>
|
||||
{premiumUser ? (
|
||||
<Tag icon={<CrownOutlined />} color="gold">
|
||||
Premium
|
||||
{t("user.premium")}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tooltip title="Upgrade to Premium for advanced features" placement="left">
|
||||
<Tag icon={<CrownOutlined />}>Standard</Tag>
|
||||
<Tooltip title={t("user.upgradeTooltip")} placement="left">
|
||||
<Tag icon={<CrownOutlined />}>{t("user.standard")}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
|
|
@ -108,7 +110,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<Text type="secondary">User ID</Text>
|
||||
<Text type="secondary">{t("user.userId")}</Text>
|
||||
</Space>
|
||||
<Text copyable ellipsis style={{ maxWidth: "150px" }} title={userId || "-"}>
|
||||
{userId || "-"}
|
||||
|
|
@ -117,13 +119,13 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Space>
|
||||
<SafetyOutlined />
|
||||
<Text type="secondary">Role</Text>
|
||||
<Text type="secondary">{t("user.role")}</Text>
|
||||
</Space>
|
||||
<Text>{userRole}</Text>
|
||||
</Space>
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide New Feature Indicators</Text>
|
||||
<Text type="secondary">{t("user.hideNewFeatureIndicators")}</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={disableShowNewBadge}
|
||||
|
|
@ -141,7 +143,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
/>
|
||||
</Space>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide All Prompts</Text>
|
||||
<Text type="secondary">{t("user.hideAllPrompts")}</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={disableShowPrompts}
|
||||
|
|
@ -158,7 +160,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
/>
|
||||
</Space>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide Usage Indicator</Text>
|
||||
<Text type="secondary">{t("user.hideUsageIndicator")}</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={disableUsageIndicator}
|
||||
|
|
@ -175,7 +177,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
/>
|
||||
</Space>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide Blog Posts</Text>
|
||||
<Text type="secondary">{t("user.hideBlogPosts")}</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={disableBlogPosts}
|
||||
|
|
@ -192,7 +194,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
/>
|
||||
</Space>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide Bouncing Icon</Text>
|
||||
<Text type="secondary">{t("user.hideBouncingIcon")}</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={disableBouncingIcon}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { getAvailablePages } from "@/components/page_utils";
|
||||
import { Button, Checkbox, Collapse, Space, Tag, Typography } from "antd";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PageVisibilitySettingsProps {
|
||||
enabledPagesInternalUsers: string[] | null | undefined;
|
||||
|
|
@ -17,11 +18,13 @@ export default function PageVisibilitySettings({
|
|||
isUpdating,
|
||||
onUpdate,
|
||||
}: PageVisibilitySettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Check if page visibility is set (null/undefined means "not set" = all pages visible)
|
||||
const isPageVisibilitySet = enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined;
|
||||
|
||||
// Get available pages from leftnav configuration
|
||||
const availablePages = useMemo(() => getAvailablePages(), []);
|
||||
const availablePages = useMemo(() => getAvailablePages(t), [t]);
|
||||
|
||||
// Group pages by their group for better UI
|
||||
const pagesByGroup = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ import {
|
|||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { ConfigProvider, Layout, Menu } from "antd";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
all_admin_roles,
|
||||
internalUserRoles,
|
||||
|
|
@ -99,28 +101,29 @@ interface MenuGroup {
|
|||
roles?: string[];
|
||||
}
|
||||
|
||||
// Menu groups organized by category - defined outside component for export
|
||||
const menuGroups: MenuGroup[] = [
|
||||
// Menu groups organized by category; built through a factory so labels resolve
|
||||
// against the active language
|
||||
const getMenuGroups = (t: TFunction): MenuGroup[] => [
|
||||
{
|
||||
groupLabel: "AI GATEWAY",
|
||||
groupLabel: t("nav.groups.aiGateway"),
|
||||
items: [
|
||||
{
|
||||
key: "api-keys",
|
||||
page: "api-keys",
|
||||
label: "Virtual Keys",
|
||||
label: t("nav.virtualKeys"),
|
||||
icon: <KeyOutlined />,
|
||||
},
|
||||
{
|
||||
key: "llm-playground",
|
||||
page: "llm-playground",
|
||||
label: "Playground",
|
||||
label: t("nav.playground"),
|
||||
icon: <PlayCircleOutlined />,
|
||||
roles: rolesWithWriteAccess,
|
||||
},
|
||||
{
|
||||
key: "models",
|
||||
page: "models",
|
||||
label: "Models + Endpoints",
|
||||
label: t("nav.modelsEndpoints"),
|
||||
icon: <BlockOutlined />,
|
||||
// Admin Viewer can view models read-only (write actions are
|
||||
// hidden inside the page); Playground above stays write-only.
|
||||
|
|
@ -129,13 +132,13 @@ const menuGroups: MenuGroup[] = [
|
|||
{
|
||||
key: "agentic",
|
||||
page: "agentic",
|
||||
label: "Agentic",
|
||||
label: t("nav.agentic"),
|
||||
icon: <RobotOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: "agents",
|
||||
page: "agents",
|
||||
label: "Agents",
|
||||
label: t("nav.agents"),
|
||||
icon: <RobotOutlined />,
|
||||
// Admin Viewer can view agents read-only (write actions are
|
||||
// hidden inside the page); Playground above stays write-only.
|
||||
|
|
@ -144,13 +147,13 @@ const menuGroups: MenuGroup[] = [
|
|||
{
|
||||
key: "workflows",
|
||||
page: "workflows",
|
||||
label: "Workflow Runs",
|
||||
label: t("nav.workflowRuns"),
|
||||
icon: <ApartmentOutlined />,
|
||||
},
|
||||
{
|
||||
key: "memory",
|
||||
page: "memory",
|
||||
label: "Memory",
|
||||
label: t("nav.memory"),
|
||||
icon: <BookOutlined />,
|
||||
},
|
||||
],
|
||||
|
|
@ -158,51 +161,51 @@ const menuGroups: MenuGroup[] = [
|
|||
{
|
||||
key: "mcp-servers",
|
||||
page: "mcp-servers",
|
||||
label: "MCP Servers",
|
||||
label: t("nav.mcpServers"),
|
||||
icon: <ToolOutlined />,
|
||||
},
|
||||
{
|
||||
key: "skills",
|
||||
page: "skills",
|
||||
label: "Skills",
|
||||
label: t("nav.skills"),
|
||||
icon: <ApiOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "guardrails",
|
||||
page: "guardrails",
|
||||
label: "Guardrails",
|
||||
label: t("nav.guardrails"),
|
||||
icon: <SafetyOutlined />,
|
||||
},
|
||||
{
|
||||
key: "policies",
|
||||
page: "policies",
|
||||
label: <span className="flex items-center gap-4">Policies</span>,
|
||||
label: <span className="flex items-center gap-4">{t("nav.policies")}</span>,
|
||||
icon: <AuditOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
page: "tools",
|
||||
label: "Tools",
|
||||
label: t("nav.tools"),
|
||||
icon: <ToolOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: "search-tools",
|
||||
page: "search-tools",
|
||||
label: "Search Tools",
|
||||
label: t("nav.searchTools"),
|
||||
icon: <SearchOutlined />,
|
||||
},
|
||||
{
|
||||
key: "vector-stores",
|
||||
page: "vector-stores",
|
||||
label: "Vector Stores",
|
||||
label: t("nav.vectorStores"),
|
||||
icon: <DatabaseOutlined />,
|
||||
},
|
||||
{
|
||||
key: "tool-policies",
|
||||
page: "tool-policies",
|
||||
label: "Tool Policies",
|
||||
label: t("nav.toolPolicies"),
|
||||
icon: <SafetyOutlined />,
|
||||
},
|
||||
],
|
||||
|
|
@ -210,37 +213,37 @@ const menuGroups: MenuGroup[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "OBSERVABILITY",
|
||||
groupLabel: t("nav.groups.observability"),
|
||||
items: [
|
||||
{
|
||||
key: "new_usage",
|
||||
page: "new_usage",
|
||||
icon: <BarChartOutlined />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
label: "Usage",
|
||||
label: t("nav.usage"),
|
||||
},
|
||||
{
|
||||
key: "logs",
|
||||
page: "logs",
|
||||
label: "Logs",
|
||||
label: t("nav.logs"),
|
||||
icon: <LineChartOutlined />,
|
||||
},
|
||||
{
|
||||
key: "guardrails-monitor",
|
||||
page: "guardrails-monitor",
|
||||
label: "Guardrails Monitor",
|
||||
label: t("nav.guardrailsMonitor"),
|
||||
icon: <SafetyOutlined />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "ACCESS CONTROL",
|
||||
groupLabel: t("nav.groups.accessControl"),
|
||||
items: [
|
||||
{
|
||||
key: "teams",
|
||||
page: "teams",
|
||||
label: "Teams",
|
||||
label: t("nav.teams"),
|
||||
icon: <TeamOutlined />,
|
||||
},
|
||||
{
|
||||
|
|
@ -248,7 +251,7 @@ const menuGroups: MenuGroup[] = [
|
|||
page: "projects",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Projects <NewBadge />
|
||||
{t("nav.projects")} <NewBadge />
|
||||
</span>
|
||||
),
|
||||
icon: <FolderOutlined />,
|
||||
|
|
@ -257,94 +260,94 @@ const menuGroups: MenuGroup[] = [
|
|||
{
|
||||
key: "users",
|
||||
page: "users",
|
||||
label: "Internal Users",
|
||||
label: t("nav.internalUsers"),
|
||||
icon: <UserOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "organizations",
|
||||
page: "organizations",
|
||||
label: "Organizations",
|
||||
label: t("nav.organizations"),
|
||||
icon: <BankOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "access-groups",
|
||||
page: "access-groups",
|
||||
label: "Access Groups",
|
||||
label: t("nav.accessGroups"),
|
||||
icon: <BlockOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "budgets",
|
||||
page: "budgets",
|
||||
label: "Budgets",
|
||||
label: t("nav.budgets"),
|
||||
icon: <CreditCardOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "DEVELOPER TOOLS",
|
||||
groupLabel: t("nav.groups.developerTools"),
|
||||
items: [
|
||||
{
|
||||
key: "api_ref",
|
||||
page: "api_ref",
|
||||
label: "API Reference",
|
||||
label: t("nav.apiReference"),
|
||||
icon: <ApiOutlined />,
|
||||
},
|
||||
{
|
||||
key: "model-hub-table",
|
||||
page: "model-hub-table",
|
||||
label: "AI Hub",
|
||||
label: t("nav.aiHub"),
|
||||
icon: <AppstoreOutlined />,
|
||||
},
|
||||
|
||||
{
|
||||
key: "learning-resources",
|
||||
page: "learning-resources",
|
||||
label: "Learning Resources",
|
||||
label: t("nav.learningResources"),
|
||||
icon: <BookOutlined />,
|
||||
external_url: "https://models.litellm.ai/cookbook",
|
||||
},
|
||||
{
|
||||
key: "experimental",
|
||||
page: "experimental",
|
||||
label: "Experimental",
|
||||
label: t("nav.experimental"),
|
||||
icon: <ExperimentOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: "caching",
|
||||
page: "caching",
|
||||
label: "Caching",
|
||||
label: t("nav.caching"),
|
||||
icon: <DatabaseOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "prompts",
|
||||
page: "prompts",
|
||||
label: "Prompts",
|
||||
label: t("nav.prompts"),
|
||||
icon: <FileTextOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "transform-request",
|
||||
page: "transform-request",
|
||||
label: "API Playground",
|
||||
label: t("nav.apiPlayground"),
|
||||
icon: <ApiOutlined />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
},
|
||||
{
|
||||
key: "tag-management",
|
||||
page: "tag-management",
|
||||
label: "Tag Management",
|
||||
label: t("nav.tagManagement"),
|
||||
icon: <TagsOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "4",
|
||||
page: "usage",
|
||||
label: "Old Usage",
|
||||
label: t("nav.oldUsage"),
|
||||
icon: <BarChartOutlined />,
|
||||
},
|
||||
],
|
||||
|
|
@ -352,7 +355,7 @@ const menuGroups: MenuGroup[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "SETTINGS",
|
||||
groupLabel: t("nav.groups.settings"),
|
||||
roles: all_admin_roles,
|
||||
items: [
|
||||
{
|
||||
|
|
@ -360,7 +363,7 @@ const menuGroups: MenuGroup[] = [
|
|||
page: "settings",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Settings <NewBadge />
|
||||
{t("nav.settings")} <NewBadge />
|
||||
</span>
|
||||
),
|
||||
icon: <SettingOutlined />,
|
||||
|
|
@ -369,14 +372,14 @@ const menuGroups: MenuGroup[] = [
|
|||
{
|
||||
key: "router-settings",
|
||||
page: "router-settings",
|
||||
label: "Router Settings",
|
||||
label: t("nav.routerSettings"),
|
||||
icon: <SettingOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "logging-and-alerts",
|
||||
page: "logging-and-alerts",
|
||||
label: "Logging & Alerts",
|
||||
label: t("nav.loggingAlerts"),
|
||||
icon: <SettingOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
|
|
@ -385,7 +388,7 @@ const menuGroups: MenuGroup[] = [
|
|||
page: "admin-panel",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Admin Settings{" "}
|
||||
{t("nav.adminSettings")}{" "}
|
||||
<NewBadge dot>
|
||||
<span />
|
||||
</NewBadge>
|
||||
|
|
@ -397,14 +400,14 @@ const menuGroups: MenuGroup[] = [
|
|||
{
|
||||
key: "cost-tracking",
|
||||
page: "cost-tracking",
|
||||
label: "Cost Tracking",
|
||||
label: t("nav.costTracking"),
|
||||
icon: <BarChartOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "ui-theme",
|
||||
page: "ui-theme",
|
||||
label: "UI Theme",
|
||||
label: t("nav.uiTheme"),
|
||||
icon: <BgColorsOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
|
|
@ -428,6 +431,9 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||
const { userId, accessToken, userRole } = useAuthorized();
|
||||
const { data: organizations } = useOrganizations();
|
||||
const { data: teams } = useTeams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const menuGroups = useMemo(() => getMenuGroups(t), [t]);
|
||||
|
||||
// Check if user is an org_admin
|
||||
const isOrgAdmin = useMemo(() => {
|
||||
|
|
@ -704,5 +710,4 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||
|
||||
export default Sidebar;
|
||||
|
||||
// Also export menuGroups for advanced use cases
|
||||
export { menuGroups };
|
||||
export { getMenuGroups };
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ import { DownOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/
|
|||
import { Tag } from "antd";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
|
||||
import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
|
||||
import LanguageSelector from "./Navbar/LanguageSelector/LanguageSelector";
|
||||
import { NAV_PRODUCT_LINK_CLASS } from "./Navbar/navProductLinkClass";
|
||||
import { NotificationsBell } from "./Navbar/NotificationsBell/NotificationsBell";
|
||||
import UserDropdown from "./Navbar/UserDropdown/UserDropdown";
|
||||
|
|
@ -35,6 +37,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
sidebarCollapsed = false,
|
||||
onToggleSidebar,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const [logoutUrl, setLogoutUrl] = useState("");
|
||||
const { logoUrl } = useTheme();
|
||||
|
|
@ -89,7 +92,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="mr-2 flex h-9 w-9 items-center justify-center rounded-md text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
title={sidebarCollapsed ? t("navbar.expandSidebar") : t("navbar.collapseSidebar")}
|
||||
>
|
||||
<span className="text-lg">{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}</span>
|
||||
</button>
|
||||
|
|
@ -113,7 +116,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
<span
|
||||
className="absolute -left-2 -top-1 animate-bounce text-lg"
|
||||
style={{ animationDuration: "2s" }}
|
||||
title="Thanks for using LiteLLM!"
|
||||
title={t("navbar.thanksForUsing")}
|
||||
>
|
||||
🌑
|
||||
</span>
|
||||
|
|
@ -150,13 +153,17 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
rel="noopener noreferrer"
|
||||
className={NAV_PRODUCT_LINK_CLASS}
|
||||
>
|
||||
Docs
|
||||
{t("navbar.docs")}
|
||||
{/* Layout parity with Blog chevron — intentional single-level link */}
|
||||
<DownOutlined className="pointer-events-none text-[10px] opacity-0" aria-hidden />
|
||||
</a>
|
||||
<BlogDropdown />
|
||||
</nav>
|
||||
|
||||
<div className="flex shrink-0 items-center border-l border-gray-200 pl-4">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
|
||||
{!hideCommunityLinks && (
|
||||
<div className="flex shrink-0 items-center border-l border-gray-200 pl-4">
|
||||
<CommunityEngagementButtons />
|
||||
|
|
|
|||
|
|
@ -4,11 +4,15 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import i18n from "@/lib/i18n";
|
||||
import { getAvailablePages } from "./page_utils";
|
||||
import { menuGroups } from "./leftnav";
|
||||
import { getMenuGroups } from "./leftnav";
|
||||
import { pageDescriptions } from "./page_metadata";
|
||||
import { internalUserRoles } from "@/utils/roles";
|
||||
|
||||
const t = i18n.t;
|
||||
const menuGroups = getMenuGroups(t);
|
||||
|
||||
/**
|
||||
* Check if a page is accessible to internal users
|
||||
* A page is accessible if:
|
||||
|
|
@ -26,7 +30,7 @@ const isPageAccessibleToInternalUsers = (pageRoles?: string[]): boolean => {
|
|||
|
||||
describe("Page Utils - LeftNav Sync", () => {
|
||||
it("should return all pages from leftnav configuration", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
const availablePages = getAvailablePages(t);
|
||||
|
||||
// Should have pages
|
||||
expect(availablePages.length).toBeGreaterThan(0);
|
||||
|
|
@ -45,7 +49,7 @@ describe("Page Utils - LeftNav Sync", () => {
|
|||
});
|
||||
|
||||
it("should include all navigable pages from menuGroups", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
const availablePages = getAvailablePages(t);
|
||||
const availablePageKeys = availablePages.map((p) => p.page);
|
||||
|
||||
// Collect all page keys from menuGroups (excluding parent containers and pages not accessible to internal users)
|
||||
|
|
@ -71,14 +75,14 @@ describe("Page Utils - LeftNav Sync", () => {
|
|||
|
||||
// Every menu page accessible to internal users should be in available pages
|
||||
menuPageKeys.forEach((pageKey) => {
|
||||
expect(availablePageKeys, `Page "${pageKey}" from menuGroups should be in getAvailablePages() output`).toContain(
|
||||
expect(availablePageKeys, `Page "${pageKey}" from menuGroups should be in getAvailablePages(t) output`).toContain(
|
||||
pageKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not include parent container pages (tools, experimental, settings)", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
const availablePages = getAvailablePages(t);
|
||||
const availablePageKeys = availablePages.map((p) => p.page);
|
||||
|
||||
const excludedParents = ["tools", "experimental", "settings"];
|
||||
|
|
@ -89,7 +93,7 @@ describe("Page Utils - LeftNav Sync", () => {
|
|||
});
|
||||
|
||||
it("should have descriptions for all pages", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
const availablePages = getAvailablePages(t);
|
||||
|
||||
availablePages.forEach((page) => {
|
||||
expect(page.description, `Page "${page.page}" should have a description`).toBeTruthy();
|
||||
|
|
@ -167,7 +171,7 @@ describe("Page Utils - LeftNav Sync", () => {
|
|||
});
|
||||
|
||||
it("should have proper group hierarchy for nested pages", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
const availablePages = getAvailablePages(t);
|
||||
|
||||
// Find pages that should be nested (children of Tools, Experimental, Settings)
|
||||
const nestedPages = availablePages.filter((page) => page.group.includes(" > "));
|
||||
|
|
@ -188,7 +192,7 @@ describe("Page Utils - LeftNav Sync", () => {
|
|||
});
|
||||
|
||||
it("should have unique page keys", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
const availablePages = getAvailablePages(t);
|
||||
const pageKeys = availablePages.map((p) => p.page);
|
||||
const uniquePageKeys = new Set(pageKeys);
|
||||
|
||||
|
|
@ -196,7 +200,7 @@ describe("Page Utils - LeftNav Sync", () => {
|
|||
});
|
||||
|
||||
it("should match the structure expected by PageVisibilitySettings component", () => {
|
||||
const availablePages = getAvailablePages();
|
||||
const availablePages = getAvailablePages(t);
|
||||
|
||||
// Group pages by their group (same logic as in PageVisibilitySettings)
|
||||
const grouped: Record<string, typeof availablePages> = {};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
* Utility functions for working with navigation pages
|
||||
*/
|
||||
|
||||
import { menuGroups } from "./leftnav";
|
||||
import type { TFunction } from "i18next";
|
||||
import { getMenuGroups } from "./leftnav";
|
||||
import { pageDescriptions, PageMetadata } from "./page_metadata";
|
||||
import { internalUserRoles } from "@/utils/roles";
|
||||
|
||||
|
|
@ -29,10 +30,10 @@ const isPageAccessibleToInternalUsers = (pageRoles?: string[]): boolean => {
|
|||
* Pages restricted to admin-only roles are excluded because internal users
|
||||
* cannot see them regardless of the UI visibility setting.
|
||||
*/
|
||||
export const getAvailablePages = (): PageMetadata[] => {
|
||||
export const getAvailablePages = (t: TFunction): PageMetadata[] => {
|
||||
const pages: PageMetadata[] = [];
|
||||
|
||||
menuGroups.forEach((group) => {
|
||||
getMenuGroups(t).forEach((group) => {
|
||||
group.items.forEach((item) => {
|
||||
// Add top-level items (skip parent containers like 'tools', 'experimental', 'settings')
|
||||
// Also skip items that internal users cannot access
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import "@/lib/i18n";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { notification, message } from "antd";
|
||||
import { notification, message, ConfigProvider } from "antd";
|
||||
import type { Locale } from "antd/es/locale";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { setNotificationInstance } from "@/components/molecules/notifications_manager";
|
||||
import { setMessageInstance } from "@/components/molecules/message_manager";
|
||||
|
||||
// antd falls back to its built-in English locale when undefined
|
||||
const ANTD_LOCALES: Record<string, Locale> = { "zh-CN": zhCN };
|
||||
|
||||
export default function AntdGlobalProvider({ children }: { children: React.ReactNode }) {
|
||||
const [notificationApi, notificationContextHolder] = notification.useNotification();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const initialized = useRef(false);
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized.current) {
|
||||
|
|
@ -19,10 +27,10 @@ export default function AntdGlobalProvider({ children }: { children: React.React
|
|||
}, [notificationApi, messageApi]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigProvider locale={ANTD_LOCALES[i18n.language]}>
|
||||
{notificationContextHolder}
|
||||
{messageContextHolder}
|
||||
{children}
|
||||
</>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
87
ui/litellm-dashboard/src/lib/i18n.test.ts
Normal file
87
ui/litellm-dashboard/src/lib/i18n.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import dayjs from "dayjs";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import en from "@/locales/en.json";
|
||||
import zhCN from "@/locales/zh-CN.json";
|
||||
import i18n, { detectLanguage, LANGUAGE_STORAGE_KEY } from "./i18n";
|
||||
|
||||
const mockBrowserLanguage = (language: string) => {
|
||||
vi.spyOn(window.navigator, "language", "get").mockReturnValue(language);
|
||||
};
|
||||
|
||||
// Must run before any test changes the language: this fresh test environment
|
||||
// imported @/lib/i18n via setupTests, so the initial language's side effects
|
||||
// must already be applied. Catches listeners registered after init(), which
|
||||
// miss the languageChanged event that init() emits synchronously.
|
||||
it("applies the detected language's side effects at module load", () => {
|
||||
expect(i18n.language).not.toBe("");
|
||||
expect(document.documentElement.lang).toBe(i18n.language);
|
||||
});
|
||||
|
||||
describe("detectLanguage", () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("prefers a supported language stored in localStorage", () => {
|
||||
mockBrowserLanguage("en-US");
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
expect(detectLanguage()).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("ignores an unsupported stored value and falls back to the browser language", () => {
|
||||
mockBrowserLanguage("zh-CN");
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "fr");
|
||||
expect(detectLanguage()).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("matches the browser language exactly", () => {
|
||||
mockBrowserLanguage("zh-CN");
|
||||
expect(detectLanguage()).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("matches by primary subtag when there is no exact match", () => {
|
||||
mockBrowserLanguage("zh-TW");
|
||||
expect(detectLanguage()).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("defaults to English for unsupported browser languages", () => {
|
||||
mockBrowserLanguage("fr-FR");
|
||||
expect(detectLanguage()).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("languageChanged side effects", () => {
|
||||
afterEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
it("syncs the html lang attribute and dayjs locale", async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
expect(document.documentElement.lang).toBe("zh-CN");
|
||||
expect(dayjs.locale()).toBe("zh-cn");
|
||||
|
||||
await i18n.changeLanguage("en");
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
expect(dayjs.locale()).toBe("en");
|
||||
});
|
||||
|
||||
it("translates keys after switching language", async () => {
|
||||
expect(i18n.t("nav.virtualKeys")).toBe("Virtual Keys");
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
expect(i18n.t("nav.virtualKeys")).toBe("虚拟密钥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("locale files", () => {
|
||||
const flattenKeys = (obj: Record<string, unknown>, prefix = ""): string[] =>
|
||||
Object.entries(obj).flatMap(([key, value]) =>
|
||||
typeof value === "object" && value !== null
|
||||
? flattenKeys(value as Record<string, unknown>, `${prefix}${key}.`)
|
||||
: [`${prefix}${key}`],
|
||||
);
|
||||
|
||||
it("keeps en and zh-CN key sets identical, so fallbackLng never papers over a missing translation", () => {
|
||||
expect(flattenKeys(zhCN).sort()).toEqual(flattenKeys(en).sort());
|
||||
});
|
||||
});
|
||||
57
ui/litellm-dashboard/src/lib/i18n.ts
Normal file
57
ui/litellm-dashboard/src/lib/i18n.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import en from "@/locales/en.json";
|
||||
import zhCN from "@/locales/zh-CN.json";
|
||||
import { getLocalStorageItem } from "@/utils/localStorageUtils";
|
||||
|
||||
export const LANGUAGE_STORAGE_KEY = "litellm_ui_language";
|
||||
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "zh-CN", label: "简体中文" },
|
||||
] as const;
|
||||
|
||||
type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]["code"];
|
||||
|
||||
const DAYJS_LOCALES: Record<string, string> = { "zh-CN": "zh-cn" };
|
||||
|
||||
export function detectLanguage(): LanguageCode {
|
||||
if (typeof window === "undefined") return "en";
|
||||
|
||||
const stored = getLocalStorageItem(LANGUAGE_STORAGE_KEY);
|
||||
const fromStorage = SUPPORTED_LANGUAGES.find((lang) => lang.code === stored);
|
||||
if (fromStorage) return fromStorage.code;
|
||||
|
||||
const browser = window.navigator.language;
|
||||
const exact = SUPPORTED_LANGUAGES.find((lang) => lang.code === browser);
|
||||
if (exact) return exact.code;
|
||||
|
||||
const primarySubtag = browser?.split("-")[0];
|
||||
const byPrimarySubtag = SUPPORTED_LANGUAGES.find((lang) => lang.code.split("-")[0] === primarySubtag);
|
||||
return byPrimarySubtag?.code ?? "en";
|
||||
}
|
||||
|
||||
// Registered before init so the initial language also syncs these side effects
|
||||
i18n.on("languageChanged", (lng) => {
|
||||
dayjs.locale(DAYJS_LOCALES[lng] ?? "en");
|
||||
if (typeof window !== "undefined") {
|
||||
document.documentElement.lang = lng;
|
||||
}
|
||||
});
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
"zh-CN": { translation: zhCN },
|
||||
},
|
||||
lng: detectLanguage(),
|
||||
fallbackLng: "en",
|
||||
// React already escapes interpolated values
|
||||
interpolation: { escapeValue: false },
|
||||
// Initialize synchronously so SSG prerendering and tests never render untranslated keys
|
||||
initAsync: false,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
101
ui/litellm-dashboard/src/locales/en.json
Normal file
101
ui/litellm-dashboard/src/locales/en.json
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
{
|
||||
"nav": {
|
||||
"groups": {
|
||||
"aiGateway": "AI GATEWAY",
|
||||
"observability": "OBSERVABILITY",
|
||||
"accessControl": "ACCESS CONTROL",
|
||||
"developerTools": "DEVELOPER TOOLS",
|
||||
"settings": "SETTINGS"
|
||||
},
|
||||
"virtualKeys": "Virtual Keys",
|
||||
"playground": "Playground",
|
||||
"modelsEndpoints": "Models + Endpoints",
|
||||
"agentic": "Agentic",
|
||||
"agents": "Agents",
|
||||
"workflowRuns": "Workflow Runs",
|
||||
"memory": "Memory",
|
||||
"mcpServers": "MCP Servers",
|
||||
"skills": "Skills",
|
||||
"guardrails": "Guardrails",
|
||||
"policies": "Policies",
|
||||
"tools": "Tools",
|
||||
"searchTools": "Search Tools",
|
||||
"vectorStores": "Vector Stores",
|
||||
"toolPolicies": "Tool Policies",
|
||||
"usage": "Usage",
|
||||
"logs": "Logs",
|
||||
"guardrailsMonitor": "Guardrails Monitor",
|
||||
"teams": "Teams",
|
||||
"projects": "Projects",
|
||||
"internalUsers": "Internal Users",
|
||||
"organizations": "Organizations",
|
||||
"accessGroups": "Access Groups",
|
||||
"budgets": "Budgets",
|
||||
"apiReference": "API Reference",
|
||||
"aiHub": "AI Hub",
|
||||
"learningResources": "Learning Resources",
|
||||
"experimental": "Experimental",
|
||||
"caching": "Caching",
|
||||
"prompts": "Prompts",
|
||||
"apiPlayground": "API Playground",
|
||||
"tagManagement": "Tag Management",
|
||||
"oldUsage": "Old Usage",
|
||||
"settings": "Settings",
|
||||
"routerSettings": "Router Settings",
|
||||
"loggingAlerts": "Logging & Alerts",
|
||||
"adminSettings": "Admin Settings",
|
||||
"costTracking": "Cost Tracking",
|
||||
"uiTheme": "UI Theme"
|
||||
},
|
||||
"navbar": {
|
||||
"expandSidebar": "Expand sidebar",
|
||||
"collapseSidebar": "Collapse sidebar",
|
||||
"docs": "Docs",
|
||||
"blog": "Blog",
|
||||
"thanksForUsing": "Thanks for using LiteLLM!",
|
||||
"failedToLoadPosts": "Failed to load posts",
|
||||
"retry": "Retry",
|
||||
"noPostsAvailable": "No posts available",
|
||||
"viewAllPosts": "View all posts",
|
||||
"communityLinks": "Community links",
|
||||
"slackCommunity": "LiteLLM Slack community",
|
||||
"joinSlack": "Join Slack",
|
||||
"githubRepo": "LiteLLM on GitHub",
|
||||
"language": "Language"
|
||||
},
|
||||
"user": {
|
||||
"logout": "Logout",
|
||||
"premium": "Premium",
|
||||
"standard": "Standard",
|
||||
"upgradeTooltip": "Upgrade to Premium for advanced features",
|
||||
"userId": "User ID",
|
||||
"role": "Role",
|
||||
"hideNewFeatureIndicators": "Hide New Feature Indicators",
|
||||
"hideAllPrompts": "Hide All Prompts",
|
||||
"hideUsageIndicator": "Hide Usage Indicator",
|
||||
"hideBlogPosts": "Hide Blog Posts",
|
||||
"hideBouncingIcon": "Hide Bouncing Icon"
|
||||
},
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"subtitle": "Access your LiteLLM Admin UI.",
|
||||
"defaultCredentialsTitle": "Default Credentials",
|
||||
"defaultCredentialsBody": "By default, Username is <code>admin</code> and Password is your set LiteLLM Proxy <code>MASTER_KEY</code>.",
|
||||
"docsHint": "Need to set UI credentials or SSO? <docsLink>Check the documentation</docsLink>.",
|
||||
"worker": "Worker",
|
||||
"workerPlaceholder": "Choose a worker to connect to",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "Enter your username",
|
||||
"usernameRequired": "Please enter your username",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"passwordRequired": "Please enter your password",
|
||||
"loginButton": "Login",
|
||||
"loggingIn": "Logging in...",
|
||||
"loginWithSso": "Login with SSO",
|
||||
"ssoNotConfigured": "Please configure SSO to log in with SSO.",
|
||||
"adminUiDisabledTitle": "Admin UI Disabled",
|
||||
"adminUiDisabledBody": "The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:",
|
||||
"ssoEnabledNotice": "Single Sign-On (SSO) is enabled. LiteLLM no longer automatically redirects to the SSO login flow upon loading this page. To re-enable auto-redirect-to-SSO, set <code>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</code> in your environment configuration."
|
||||
}
|
||||
}
|
||||
101
ui/litellm-dashboard/src/locales/zh-CN.json
Normal file
101
ui/litellm-dashboard/src/locales/zh-CN.json
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
{
|
||||
"nav": {
|
||||
"groups": {
|
||||
"aiGateway": "AI 网关",
|
||||
"observability": "可观测性",
|
||||
"accessControl": "访问控制",
|
||||
"developerTools": "开发者工具",
|
||||
"settings": "设置"
|
||||
},
|
||||
"virtualKeys": "虚拟密钥",
|
||||
"playground": "Playground",
|
||||
"modelsEndpoints": "模型与端点",
|
||||
"agentic": "智能体",
|
||||
"agents": "Agents",
|
||||
"workflowRuns": "工作流运行",
|
||||
"memory": "记忆",
|
||||
"mcpServers": "MCP 服务器",
|
||||
"skills": "技能",
|
||||
"guardrails": "安全护栏",
|
||||
"policies": "策略",
|
||||
"tools": "工具",
|
||||
"searchTools": "搜索工具",
|
||||
"vectorStores": "向量存储",
|
||||
"toolPolicies": "工具策略",
|
||||
"usage": "用量",
|
||||
"logs": "日志",
|
||||
"guardrailsMonitor": "护栏监控",
|
||||
"teams": "团队",
|
||||
"projects": "项目",
|
||||
"internalUsers": "内部用户",
|
||||
"organizations": "组织",
|
||||
"accessGroups": "访问组",
|
||||
"budgets": "预算",
|
||||
"apiReference": "API 参考",
|
||||
"aiHub": "AI Hub",
|
||||
"learningResources": "学习资源",
|
||||
"experimental": "实验性功能",
|
||||
"caching": "缓存",
|
||||
"prompts": "提示词",
|
||||
"apiPlayground": "API Playground",
|
||||
"tagManagement": "标签管理",
|
||||
"oldUsage": "旧版用量",
|
||||
"settings": "设置",
|
||||
"routerSettings": "路由设置",
|
||||
"loggingAlerts": "日志与告警",
|
||||
"adminSettings": "管理员设置",
|
||||
"costTracking": "成本追踪",
|
||||
"uiTheme": "界面主题"
|
||||
},
|
||||
"navbar": {
|
||||
"expandSidebar": "展开侧边栏",
|
||||
"collapseSidebar": "收起侧边栏",
|
||||
"docs": "文档",
|
||||
"blog": "博客",
|
||||
"thanksForUsing": "感谢使用 LiteLLM!",
|
||||
"failedToLoadPosts": "加载文章失败",
|
||||
"retry": "重试",
|
||||
"noPostsAvailable": "暂无文章",
|
||||
"viewAllPosts": "查看全部文章",
|
||||
"communityLinks": "社区链接",
|
||||
"slackCommunity": "LiteLLM Slack 社区",
|
||||
"joinSlack": "加入 Slack",
|
||||
"githubRepo": "LiteLLM 的 GitHub 仓库",
|
||||
"language": "语言"
|
||||
},
|
||||
"user": {
|
||||
"logout": "退出登录",
|
||||
"premium": "高级版",
|
||||
"standard": "标准版",
|
||||
"upgradeTooltip": "升级到高级版以使用高级功能",
|
||||
"userId": "用户 ID",
|
||||
"role": "角色",
|
||||
"hideNewFeatureIndicators": "隐藏新功能标记",
|
||||
"hideAllPrompts": "隐藏所有提示",
|
||||
"hideUsageIndicator": "隐藏用量指示器",
|
||||
"hideBlogPosts": "隐藏博客文章",
|
||||
"hideBouncingIcon": "隐藏跳动图标"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录",
|
||||
"subtitle": "访问您的 LiteLLM 管理界面。",
|
||||
"defaultCredentialsTitle": "默认凭据",
|
||||
"defaultCredentialsBody": "默认情况下,用户名为 <code>admin</code>,密码为您设置的 LiteLLM Proxy <code>MASTER_KEY</code>。",
|
||||
"docsHint": "需要配置 UI 凭据或 SSO?<docsLink>查看文档</docsLink>。",
|
||||
"worker": "Worker",
|
||||
"workerPlaceholder": "选择要连接的 worker",
|
||||
"username": "用户名",
|
||||
"usernamePlaceholder": "请输入用户名",
|
||||
"usernameRequired": "请输入用户名",
|
||||
"password": "密码",
|
||||
"passwordPlaceholder": "请输入密码",
|
||||
"passwordRequired": "请输入密码",
|
||||
"loginButton": "登录",
|
||||
"loggingIn": "登录中...",
|
||||
"loginWithSso": "使用 SSO 登录",
|
||||
"ssoNotConfigured": "请先配置 SSO 后再使用 SSO 登录。",
|
||||
"adminUiDisabledTitle": "管理界面已禁用",
|
||||
"adminUiDisabledBody": "管理员已禁用管理界面。如需重新启用,请更新以下环境变量:",
|
||||
"ssoEnabledNotice": "单点登录(SSO)已启用。LiteLLM 不再在加载此页面时自动跳转到 SSO 登录流程。如需重新启用自动跳转,请在环境配置中设置 <code>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</code>。"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import "@testing-library/jest-dom";
|
||||
import "@/lib/i18n";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue