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";