From bfaaea89984b76273407460fb72932b446efe24b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B9=E9=9B=AA?= <2b@yorha.xyz> Date: Fri, 5 Jun 2026 18:43:34 +0900 Subject: [PATCH] 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. --- ui/litellm-dashboard/package-lock.json | 75 +++++++++++++ ui/litellm-dashboard/package.json | 2 + .../src/app/login/LoginPage.tsx | 63 +++++------ .../Navbar/BlogDropdown/BlogDropdown.tsx | 18 ++-- .../CommunityEngagementButtons.tsx | 12 ++- .../LanguageSelector.test.tsx | 33 ++++++ .../LanguageSelector/LanguageSelector.tsx | 42 ++++++++ .../Navbar/UserDropdown/UserDropdown.tsx | 24 +++-- .../UISettings/PageVisibilitySettings.tsx | 5 +- .../src/components/leftnav.tsx | 101 +++++++++--------- .../src/components/navbar.tsx | 13 ++- .../src/components/page_utils.test.ts | 22 ++-- .../src/components/page_utils.ts | 7 +- .../src/contexts/AntdGlobalProvider.tsx | 14 ++- ui/litellm-dashboard/src/lib/i18n.test.ts | 87 +++++++++++++++ ui/litellm-dashboard/src/lib/i18n.ts | 57 ++++++++++ ui/litellm-dashboard/src/locales/en.json | 101 ++++++++++++++++++ ui/litellm-dashboard/src/locales/zh-CN.json | 101 ++++++++++++++++++ ui/litellm-dashboard/tests/setupTests.ts | 1 + 19 files changed, 656 insertions(+), 122 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/Navbar/LanguageSelector/LanguageSelector.test.tsx create mode 100644 ui/litellm-dashboard/src/components/Navbar/LanguageSelector/LanguageSelector.tsx create mode 100644 ui/litellm-dashboard/src/lib/i18n.test.ts create mode 100644 ui/litellm-dashboard/src/lib/i18n.ts create mode 100644 ui/litellm-dashboard/src/locales/en.json create mode 100644 ui/litellm-dashboard/src/locales/zh-CN.json diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 858844d7e5c..e5e67ed2929 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -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", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 77731bbd693..1e28103d37e 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -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", diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.tsx index db3a069902a..98dcdb710b8 100644 --- a/ui/litellm-dashboard/src/app/login/LoginPage.tsx +++ b/ui/litellm-dashboard/src/app/login/LoginPage.tsx @@ -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() { - - The Admin UI has been disabled by the administrator. To re-enable it, please update the following - environment variable: - + {t("login.adminUiDisabledBody")} DISABLE_ADMIN_UI=False @@ -184,25 +183,29 @@ function LoginPageContent() {
- Login - Access your LiteLLM Admin UI. + {t("login.title")} + {t("login.subtitle")}
- By default, Username is admin and - Password is your set LiteLLM Proxy - MASTER_KEY. + }} + /> - Need to set UI credentials or SSO?{" "} - - Check the documentation - - . + + ), + }} + /> } @@ -215,11 +218,11 @@ function LoginPageContent() {
{uiConfig?.is_control_plane && workers.length > 0 && ( - + setUsername(e.target.value)} @@ -247,12 +250,12 @@ function LoginPageContent() { setPassword(e.target.value)} @@ -270,14 +273,14 @@ function LoginPageContent() { block size="large" > - {isLoginLoading ? "Logging in..." : "Login"} + {isLoginLoading ? t("login.loggingIn") : t("login.loginButton")} {!uiConfig?.sso_configured ? ( - + ) : ( @@ -299,7 +302,7 @@ function LoginPageContent() { block size="large" > - Login with SSO + {t("login.loginWithSso")} )} @@ -312,9 +315,7 @@ function LoginPageContent() { closable message={ - 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{" "} - AUTO_REDIRECT_UI_LOGIN_TO_SSO=true in your environment configuration. + }} /> } /> diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx index 657e5a39bab..10b9dd2dc30 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx @@ -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: ( - Failed to load posts + {t("navbar.failedToLoadPosts")} ), @@ -46,7 +48,7 @@ export const BlogDropdown: React.FC = () => { }, ]; } else if (!data || data.posts.length === 0) { - items = [{ key: "empty", label: No posts available, disabled: true }]; + items = [{ key: "empty", label: {t("navbar.noPostsAvailable")}, disabled: true }]; } else { items = [ ...data.posts.slice(0, 5).map((post: BlogPost) => ({ @@ -57,7 +59,7 @@ export const BlogDropdown: React.FC = () => { {post.title} - {formatDate(post.date)} + {formatDate(post.date, i18n.language)} {post.description} @@ -68,7 +70,7 @@ export const BlogDropdown: React.FC = () => { key: "view-all", label: ( - View all posts + {t("navbar.viewAllPosts")} ), }, @@ -79,7 +81,7 @@ export const BlogDropdown: React.FC = () => { return ( diff --git a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx index f6a43196a32..342b634c88c 100644 --- a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx @@ -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 (
- + - + diff --git a/ui/litellm-dashboard/src/components/Navbar/LanguageSelector/LanguageSelector.test.tsx b/ui/litellm-dashboard/src/components/Navbar/LanguageSelector/LanguageSelector.test.tsx new file mode 100644 index 00000000000..8bda621b529 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/LanguageSelector/LanguageSelector.test.tsx @@ -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(); + + 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(); + + 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Navbar/LanguageSelector/LanguageSelector.tsx b/ui/litellm-dashboard/src/components/Navbar/LanguageSelector/LanguageSelector.tsx new file mode 100644 index 00000000000..95c17ede931 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/LanguageSelector/LanguageSelector.tsx @@ -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 ( + + + + ); +}; + +export default LanguageSelector; diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 03312faaa39..b52756e327f 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -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 = ({ onLogout }) => { + const { t } = useTranslation(); const { userId, userEmail, userRole, premiumUser } = useAuthorized(); const disableShowPrompts = useDisableShowPrompts(); const disableUsageIndicator = useDisableUsageIndicator(); @@ -80,7 +82,7 @@ const UserDropdown: React.FC = ({ onLogout }) => { label: ( - Logout + {t("user.logout")} ), onClick: onLogout, @@ -96,11 +98,11 @@ const UserDropdown: React.FC = ({ onLogout }) => { {premiumUser ? ( } color="gold"> - Premium + {t("user.premium")} ) : ( - - }>Standard + + }>{t("user.standard")} )} @@ -108,7 +110,7 @@ const UserDropdown: React.FC = ({ onLogout }) => { - User ID + {t("user.userId")} {userId || "-"} @@ -117,13 +119,13 @@ const UserDropdown: React.FC = ({ onLogout }) => { - Role + {t("user.role")} {userRole} - Hide New Feature Indicators + {t("user.hideNewFeatureIndicators")} = ({ onLogout }) => { /> - Hide All Prompts + {t("user.hideAllPrompts")} = ({ onLogout }) => { /> - Hide Usage Indicator + {t("user.hideUsageIndicator")} = ({ onLogout }) => { /> - Hide Blog Posts + {t("user.hideBlogPosts")} = ({ onLogout }) => { /> - Hide Bouncing Icon + {t("user.hideBouncingIcon")} getAvailablePages(), []); + const availablePages = useMemo(() => getAvailablePages(t), [t]); // Group pages by their group for better UI const pagesByGroup = useMemo(() => { diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 43a28d94046..2ad51e8409c 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -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: , }, { key: "llm-playground", page: "llm-playground", - label: "Playground", + label: t("nav.playground"), icon: , roles: rolesWithWriteAccess, }, { key: "models", page: "models", - label: "Models + Endpoints", + label: t("nav.modelsEndpoints"), icon: , // 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: , children: [ { key: "agents", page: "agents", - label: "Agents", + label: t("nav.agents"), icon: , // 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: , }, { key: "memory", page: "memory", - label: "Memory", + label: t("nav.memory"), icon: , }, ], @@ -158,51 +161,51 @@ const menuGroups: MenuGroup[] = [ { key: "mcp-servers", page: "mcp-servers", - label: "MCP Servers", + label: t("nav.mcpServers"), icon: , }, { key: "skills", page: "skills", - label: "Skills", + label: t("nav.skills"), icon: , roles: all_admin_roles, }, { key: "guardrails", page: "guardrails", - label: "Guardrails", + label: t("nav.guardrails"), icon: , }, { key: "policies", page: "policies", - label: Policies, + label: {t("nav.policies")}, icon: , roles: all_admin_roles, }, { key: "tools", page: "tools", - label: "Tools", + label: t("nav.tools"), icon: , children: [ { key: "search-tools", page: "search-tools", - label: "Search Tools", + label: t("nav.searchTools"), icon: , }, { key: "vector-stores", page: "vector-stores", - label: "Vector Stores", + label: t("nav.vectorStores"), icon: , }, { key: "tool-policies", page: "tool-policies", - label: "Tool Policies", + label: t("nav.toolPolicies"), icon: , }, ], @@ -210,37 +213,37 @@ const menuGroups: MenuGroup[] = [ ], }, { - groupLabel: "OBSERVABILITY", + groupLabel: t("nav.groups.observability"), items: [ { key: "new_usage", page: "new_usage", icon: , roles: [...all_admin_roles, ...internalUserRoles], - label: "Usage", + label: t("nav.usage"), }, { key: "logs", page: "logs", - label: "Logs", + label: t("nav.logs"), icon: , }, { key: "guardrails-monitor", page: "guardrails-monitor", - label: "Guardrails Monitor", + label: t("nav.guardrailsMonitor"), icon: , 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: , }, { @@ -248,7 +251,7 @@ const menuGroups: MenuGroup[] = [ page: "projects", label: ( - Projects + {t("nav.projects")} ), icon: , @@ -257,94 +260,94 @@ const menuGroups: MenuGroup[] = [ { key: "users", page: "users", - label: "Internal Users", + label: t("nav.internalUsers"), icon: , roles: all_admin_roles, }, { key: "organizations", page: "organizations", - label: "Organizations", + label: t("nav.organizations"), icon: , roles: all_admin_roles, }, { key: "access-groups", page: "access-groups", - label: "Access Groups", + label: t("nav.accessGroups"), icon: , roles: all_admin_roles, }, { key: "budgets", page: "budgets", - label: "Budgets", + label: t("nav.budgets"), icon: , 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: , }, { key: "model-hub-table", page: "model-hub-table", - label: "AI Hub", + label: t("nav.aiHub"), icon: , }, { key: "learning-resources", page: "learning-resources", - label: "Learning Resources", + label: t("nav.learningResources"), icon: , external_url: "https://models.litellm.ai/cookbook", }, { key: "experimental", page: "experimental", - label: "Experimental", + label: t("nav.experimental"), icon: , children: [ { key: "caching", page: "caching", - label: "Caching", + label: t("nav.caching"), icon: , roles: all_admin_roles, }, { key: "prompts", page: "prompts", - label: "Prompts", + label: t("nav.prompts"), icon: , roles: all_admin_roles, }, { key: "transform-request", page: "transform-request", - label: "API Playground", + label: t("nav.apiPlayground"), icon: , roles: [...all_admin_roles, ...internalUserRoles], }, { key: "tag-management", page: "tag-management", - label: "Tag Management", + label: t("nav.tagManagement"), icon: , roles: all_admin_roles, }, { key: "4", page: "usage", - label: "Old Usage", + label: t("nav.oldUsage"), icon: , }, ], @@ -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: ( - Settings + {t("nav.settings")} ), icon: , @@ -369,14 +372,14 @@ const menuGroups: MenuGroup[] = [ { key: "router-settings", page: "router-settings", - label: "Router Settings", + label: t("nav.routerSettings"), icon: , roles: all_admin_roles, }, { key: "logging-and-alerts", page: "logging-and-alerts", - label: "Logging & Alerts", + label: t("nav.loggingAlerts"), icon: , roles: all_admin_roles, }, @@ -385,7 +388,7 @@ const menuGroups: MenuGroup[] = [ page: "admin-panel", label: ( - Admin Settings{" "} + {t("nav.adminSettings")}{" "} @@ -397,14 +400,14 @@ const menuGroups: MenuGroup[] = [ { key: "cost-tracking", page: "cost-tracking", - label: "Cost Tracking", + label: t("nav.costTracking"), icon: , roles: all_admin_roles, }, { key: "ui-theme", page: "ui-theme", - label: "UI Theme", + label: t("nav.uiTheme"), icon: , roles: all_admin_roles, }, @@ -428,6 +431,9 @@ const Sidebar: React.FC = ({ 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 = ({ export default Sidebar; -// Also export menuGroups for advanced use cases -export { menuGroups }; +export { getMenuGroups }; diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index e5a1490788c..b2c03f9d9a4 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -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 = ({ sidebarCollapsed = false, onToggleSidebar, }) => { + const { t } = useTranslation(); const baseUrl = getProxyBaseUrl(); const [logoutUrl, setLogoutUrl] = useState(""); const { logoUrl } = useTheme(); @@ -89,7 +92,7 @@ const Navbar: React.FC = ({ @@ -113,7 +116,7 @@ const Navbar: React.FC = ({ 🌑 @@ -150,13 +153,17 @@ const Navbar: React.FC = ({ rel="noopener noreferrer" className={NAV_PRODUCT_LINK_CLASS} > - Docs + {t("navbar.docs")} {/* Layout parity with Blog chevron — intentional single-level link */} +
+ +
+ {!hideCommunityLinks && (
diff --git a/ui/litellm-dashboard/src/components/page_utils.test.ts b/ui/litellm-dashboard/src/components/page_utils.test.ts index a38c56d8681..949eb1d581f 100644 --- a/ui/litellm-dashboard/src/components/page_utils.test.ts +++ b/ui/litellm-dashboard/src/components/page_utils.test.ts @@ -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 = {}; diff --git a/ui/litellm-dashboard/src/components/page_utils.ts b/ui/litellm-dashboard/src/components/page_utils.ts index c682b2db089..54ed8c63e4b 100644 --- a/ui/litellm-dashboard/src/components/page_utils.ts +++ b/ui/litellm-dashboard/src/components/page_utils.ts @@ -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 diff --git a/ui/litellm-dashboard/src/contexts/AntdGlobalProvider.tsx b/ui/litellm-dashboard/src/contexts/AntdGlobalProvider.tsx index cc182ee36da..1a02bde81c7 100644 --- a/ui/litellm-dashboard/src/contexts/AntdGlobalProvider.tsx +++ b/ui/litellm-dashboard/src/contexts/AntdGlobalProvider.tsx @@ -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 = { "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 ( - <> + {notificationContextHolder} {messageContextHolder} {children} - + ); } diff --git a/ui/litellm-dashboard/src/lib/i18n.test.ts b/ui/litellm-dashboard/src/lib/i18n.test.ts new file mode 100644 index 00000000000..0b513c26fe5 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/i18n.test.ts @@ -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, prefix = ""): string[] => + Object.entries(obj).flatMap(([key, value]) => + typeof value === "object" && value !== null + ? flattenKeys(value as Record, `${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()); + }); +}); diff --git a/ui/litellm-dashboard/src/lib/i18n.ts b/ui/litellm-dashboard/src/lib/i18n.ts new file mode 100644 index 00000000000..ecf848f492d --- /dev/null +++ b/ui/litellm-dashboard/src/lib/i18n.ts @@ -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 = { "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; diff --git a/ui/litellm-dashboard/src/locales/en.json b/ui/litellm-dashboard/src/locales/en.json new file mode 100644 index 00000000000..5c12f525adc --- /dev/null +++ b/ui/litellm-dashboard/src/locales/en.json @@ -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 admin and Password is your set LiteLLM Proxy MASTER_KEY.", + "docsHint": "Need to set UI credentials or SSO? Check the documentation.", + "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 AUTO_REDIRECT_UI_LOGIN_TO_SSO=true in your environment configuration." + } +} diff --git a/ui/litellm-dashboard/src/locales/zh-CN.json b/ui/litellm-dashboard/src/locales/zh-CN.json new file mode 100644 index 00000000000..6c8dbd87541 --- /dev/null +++ b/ui/litellm-dashboard/src/locales/zh-CN.json @@ -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": "默认情况下,用户名为 admin,密码为您设置的 LiteLLM Proxy MASTER_KEY。", + "docsHint": "需要配置 UI 凭据或 SSO?查看文档。", + "worker": "Worker", + "workerPlaceholder": "选择要连接的 worker", + "username": "用户名", + "usernamePlaceholder": "请输入用户名", + "usernameRequired": "请输入用户名", + "password": "密码", + "passwordPlaceholder": "请输入密码", + "passwordRequired": "请输入密码", + "loginButton": "登录", + "loggingIn": "登录中...", + "loginWithSso": "使用 SSO 登录", + "ssoNotConfigured": "请先配置 SSO 后再使用 SSO 登录。", + "adminUiDisabledTitle": "管理界面已禁用", + "adminUiDisabledBody": "管理员已禁用管理界面。如需重新启用,请更新以下环境变量:", + "ssoEnabledNotice": "单点登录(SSO)已启用。LiteLLM 不再在加载此页面时自动跳转到 SSO 登录流程。如需重新启用自动跳转,请在环境配置中设置 AUTO_REDIRECT_UI_LOGIN_TO_SSO=true。" + } +} diff --git a/ui/litellm-dashboard/tests/setupTests.ts b/ui/litellm-dashboard/tests/setupTests.ts index 6bc2e7775a1..0161466ce03 100644 --- a/ui/litellm-dashboard/tests/setupTests.ts +++ b/ui/litellm-dashboard/tests/setupTests.ts @@ -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";