mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
Merge 2e5b434596 into 22b36cbcf6
This commit is contained in:
commit
6109202431
30 changed files with 916 additions and 140 deletions
74
ui/litellm-dashboard/package-lock.json
generated
74
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"dayjs": "1.11.19",
|
||||
"i18next": "26.4.2",
|
||||
"jwt-decode": "4.0.0",
|
||||
"lucide-react": "0.513.0",
|
||||
"moment": "2.30.1",
|
||||
|
|
@ -36,6 +37,7 @@
|
|||
"react-copy-to-clipboard": "5.1.1",
|
||||
"react-dom": "19.2.8",
|
||||
"react-hook-form": "7.82.0",
|
||||
"react-i18next": "17.0.13",
|
||||
"react-json-view-lite": "2.5.0",
|
||||
"react-markdown": "9.1.0",
|
||||
"react-syntax-highlighter": "15.6.6",
|
||||
|
|
@ -402,9 +404,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
|
|
@ -7287,6 +7289,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://locize.com"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
|
|
@ -7334,6 +7345,34 @@
|
|||
"ms": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"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 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
|
@ -10602,6 +10641,33 @@
|
|||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.13",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.13.tgz",
|
||||
"integrity": "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"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",
|
||||
|
|
@ -12071,7 +12137,7 @@
|
|||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"dayjs": "1.11.19",
|
||||
"i18next": "26.4.2",
|
||||
"jwt-decode": "4.0.0",
|
||||
"lucide-react": "0.513.0",
|
||||
"moment": "2.30.1",
|
||||
|
|
@ -52,6 +53,7 @@
|
|||
"react-copy-to-clipboard": "5.1.1",
|
||||
"react-dom": "19.2.8",
|
||||
"react-hook-form": "7.82.0",
|
||||
"react-i18next": "17.0.13",
|
||||
"react-json-view-lite": "2.5.0",
|
||||
"react-markdown": "9.1.0",
|
||||
"react-syntax-highlighter": "15.6.6",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import LanguageProvider from "@/i18n/LanguageProvider";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
|
@ -28,12 +29,14 @@ export default function RootLayout({
|
|||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
|
||||
<NuqsAdapter>
|
||||
<ReactQueryProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<Toaster />
|
||||
</ReactQueryProvider>
|
||||
</NuqsAdapter>
|
||||
<LanguageProvider>
|
||||
<NuqsAdapter>
|
||||
<ReactQueryProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<Toaster />
|
||||
</ReactQueryProvider>
|
||||
</NuqsAdapter>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LoginPage from "./LoginPage";
|
||||
import LanguageProvider from "@/i18n/LanguageProvider";
|
||||
|
||||
const mockPush = vi.fn();
|
||||
const mockReplace = vi.fn();
|
||||
|
|
@ -60,7 +61,9 @@ const renderLoginPage = () => {
|
|||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LoginPage />
|
||||
<LanguageProvider>
|
||||
<LoginPage />
|
||||
</LanguageProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
|
@ -77,6 +80,7 @@ const originalLocation = window.location;
|
|||
describe("LoginPage submit payload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.removeItem("litellm_ui_language");
|
||||
WORKERS = [];
|
||||
setSearch("");
|
||||
(useUIConfig as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
|
|
@ -91,6 +95,7 @@ describe("LoginPage submit payload", () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem("litellm_ui_language");
|
||||
Object.defineProperty(window, "location", { value: originalLocation, writable: true });
|
||||
});
|
||||
|
||||
|
|
@ -190,3 +195,36 @@ describe("LoginPage submit payload", () => {
|
|||
expect(switchToWorkerUrl).toHaveBeenCalledWith("http://worker-a:4000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("localized login", () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem("litellm_ui_language");
|
||||
});
|
||||
|
||||
it("updates existing validation errors when switching language and keeps the submitted payload unchanged", async () => {
|
||||
vi.clearAllMocks();
|
||||
WORKERS = [];
|
||||
setSearch("");
|
||||
window.localStorage.removeItem("litellm_ui_language");
|
||||
(useUIConfig as ReturnType<typeof vi.fn>).mockReturnValue({ data: { sso_configured: false }, isLoading: false });
|
||||
const user = userEvent.setup();
|
||||
renderLoginPage();
|
||||
await user.click(await screen.findByRole("button", { name: "Login" }));
|
||||
expect(await screen.findByText("Please enter your username")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Language" }));
|
||||
await user.click(await screen.findByRole("menuitemradio", { name: "简体中文" }));
|
||||
expect(await screen.findByText("请输入用户名")).toBeInTheDocument();
|
||||
expect(screen.getByText("请输入密码")).toBeInTheDocument();
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByLabelText("用户名"), { target: { value: "admin" } });
|
||||
fireEvent.change(screen.getByLabelText("密码"), { target: { value: "opaque-password" } });
|
||||
await user.click(screen.getByRole("button", { name: "登录" }));
|
||||
await waitFor(() => expect(mockMutate).toHaveBeenCalledTimes(1));
|
||||
expect(mockMutate.mock.calls[0][0]).toStrictEqual({ username: "admin", password: "opaque-password", useV3: false });
|
||||
expect(screen.getByText("MASTER_KEY")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "查看文档" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.litellm.ai/docs/proxy/ui",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import { Trans } from "react-i18next";
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { useLogin } from "@/app/(dashboard)/hooks/login/useLogin";
|
||||
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
|
||||
import LoadingScreen from "@/components/common_components/LoadingScreen";
|
||||
|
|
@ -24,14 +27,10 @@ import { useEffect, useId, useState } from "react";
|
|||
import { z } from "zod/v4";
|
||||
import { useWorker } from "@/hooks/useWorker";
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, "Please enter your username"),
|
||||
password: z.string().min(1, "Please enter your password"),
|
||||
});
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||
type LoginFormValues = { username: string; password: string };
|
||||
|
||||
function SsoEnabledNotice() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed) {
|
||||
|
|
@ -42,13 +41,15 @@ function SsoEnabledNotice() {
|
|||
<Alert variant="info" className="mt-4">
|
||||
<Info />
|
||||
<AlertTitle>
|
||||
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 className="bg-muted px-1 py-0.5 rounded-sm text-xs">AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</code> in your
|
||||
environment configuration.
|
||||
<Trans
|
||||
i18n={i18n}
|
||||
t={t}
|
||||
i18nKey="login.ssoNotice"
|
||||
components={{ code: <code className="bg-muted px-1 py-0.5 rounded-sm text-xs" /> }}
|
||||
/>
|
||||
</AlertTitle>
|
||||
<AlertAction>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={() => setDismissed(true)}>
|
||||
<Button variant="ghost" size="icon-sm" aria-label={t("Close")} onClick={() => setDismissed(true)}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</AlertAction>
|
||||
|
|
@ -57,6 +58,11 @@ function SsoEnabledNotice() {
|
|||
}
|
||||
|
||||
function LoginPageContent() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, t("Please enter your username")),
|
||||
password: z.string().min(1, t("Please enter your password")),
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { data: uiConfig, isLoading: isConfigLoading } = useUIConfig();
|
||||
const loginMutation = useLogin();
|
||||
|
|
@ -66,6 +72,12 @@ function LoginPageContent() {
|
|||
const workerFieldId = useId();
|
||||
const form = useZodForm(loginSchema, { defaultValues: { username: "", password: "" } });
|
||||
|
||||
useEffect(() => {
|
||||
if (form.getFieldState("username").error || form.getFieldState("password").error) {
|
||||
void form.trigger();
|
||||
}
|
||||
}, [form, t]);
|
||||
|
||||
// Pre-select worker from URL param (e.g. /ui/login?worker=team-b)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
|
@ -190,6 +202,9 @@ function LoginPageContent() {
|
|||
<div className="min-h-screen flex items-center justify-center bg-muted">
|
||||
<Card className="w-full max-w-lg shadow-md">
|
||||
<CardContent>
|
||||
<div className="flex justify-end">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-semibold text-foreground">🚅 LiteLLM</h2>
|
||||
|
|
@ -197,11 +212,12 @@ function LoginPageContent() {
|
|||
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Admin UI Disabled</AlertTitle>
|
||||
<AlertTitle>{t("Admin UI Disabled")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="text-sm">
|
||||
The Admin UI has been disabled by the administrator. To re-enable it, please update the following
|
||||
environment variable:
|
||||
{t(
|
||||
"The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:",
|
||||
)}{" "}
|
||||
</p>
|
||||
<p className="mt-2 text-sm">
|
||||
<code className="bg-muted px-1 py-0.5 rounded-sm text-xs">DISABLE_ADMIN_UI=False</code>
|
||||
|
|
@ -219,6 +235,9 @@ function LoginPageContent() {
|
|||
<div className="min-h-screen flex items-center justify-center bg-muted">
|
||||
<Card className="w-full max-w-lg shadow-md">
|
||||
<CardContent>
|
||||
<div className="flex justify-end">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="text-center">
|
||||
|
|
@ -226,26 +245,34 @@ function LoginPageContent() {
|
|||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-2xl font-semibold text-foreground">Login</h3>
|
||||
<p className="text-sm text-muted-foreground">Access your LiteLLM Admin UI.</p>
|
||||
<h3 className="text-2xl font-semibold text-foreground">{t("Login")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("Access your LiteLLM Admin UI.")}</p>
|
||||
</div>
|
||||
|
||||
{!uiConfig?.hide_default_credentials_hint && (
|
||||
<Alert variant="info">
|
||||
<Info />
|
||||
<AlertTitle>Default Credentials</AlertTitle>
|
||||
<AlertTitle>{t("Default Credentials")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="text-sm">
|
||||
By default, Username is <code className="bg-muted px-1 py-0.5 rounded-sm text-xs">admin</code> and
|
||||
Password is your set LiteLLM Proxy
|
||||
<code className="bg-muted px-1 py-0.5 rounded-sm text-xs">MASTER_KEY</code>.
|
||||
<Trans
|
||||
i18n={i18n}
|
||||
t={t}
|
||||
i18nKey="login.defaultCredentials"
|
||||
components={{ code: <code className="bg-muted px-1 py-0.5 rounded-sm text-xs" /> }}
|
||||
/>
|
||||
</p>
|
||||
<p className="mt-2 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
|
||||
i18n={i18n}
|
||||
t={t}
|
||||
i18nKey="login.credentialsHelp"
|
||||
components={{
|
||||
docs: (
|
||||
<a href="https://docs.litellm.ai/docs/proxy/ui" target="_blank" rel="noopener noreferrer" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
@ -262,14 +289,14 @@ function LoginPageContent() {
|
|||
<FieldGroup>
|
||||
{uiConfig?.is_control_plane && workers.length > 0 && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={workerFieldId}>Worker</FieldLabel>
|
||||
<FieldLabel htmlFor={workerFieldId}>{t("Worker")}</FieldLabel>
|
||||
<Select
|
||||
items={workers.map((worker) => ({ label: worker.name, value: worker.worker_id }))}
|
||||
value={selectedWorkerId}
|
||||
onValueChange={(value: string | null) => setSelectedWorkerId(value)}
|
||||
>
|
||||
<SelectTrigger id={workerFieldId} className="h-10 w-full">
|
||||
<SelectValue placeholder="Choose a worker to connect to" />
|
||||
<SelectValue placeholder={t("Choose a worker to connect to")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{workers.map((worker) => (
|
||||
|
|
@ -282,12 +309,12 @@ function LoginPageContent() {
|
|||
</Field>
|
||||
)}
|
||||
|
||||
<FormField control={form.control} name="username" label="Username">
|
||||
<FormField control={form.control} name="username" label={t("Username")}>
|
||||
{({ ref, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="Enter your username"
|
||||
placeholder={t("Enter your username")}
|
||||
autoComplete="username"
|
||||
disabled={isLoginLoading}
|
||||
className="h-10 rounded-md"
|
||||
|
|
@ -295,12 +322,12 @@ function LoginPageContent() {
|
|||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="password" label="Password">
|
||||
<FormField control={form.control} name="password" label={t("Password")}>
|
||||
{({ ref, ...field }) => (
|
||||
<PasswordInput
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="Enter your password"
|
||||
placeholder={t("Enter your password")}
|
||||
autoComplete="current-password"
|
||||
disabled={isLoginLoading}
|
||||
groupClassName="h-10"
|
||||
|
|
@ -309,18 +336,18 @@ function LoginPageContent() {
|
|||
</FormField>
|
||||
|
||||
<Button type="submit" size="lg" disabled={isLoginLoading} className="w-full">
|
||||
{isLoginLoading && <UiLoadingSpinner className="size-4" role="img" aria-label="loading" />}
|
||||
{isLoginLoading ? "Logging in..." : "Login"}
|
||||
{isLoginLoading && <UiLoadingSpinner className="size-4" role="img" aria-label={t("loading")} />}
|
||||
{isLoginLoading ? t("Logging in...") : t("Login")}
|
||||
</Button>
|
||||
|
||||
{!uiConfig?.sso_configured ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className="block w-full" />}>
|
||||
<Button type="button" variant="outline" size="lg" disabled className="w-full">
|
||||
Login with SSO
|
||||
{t("Login with SSO")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Please configure SSO to log in with SSO.</TooltipContent>
|
||||
<TooltipContent>{t("Please configure SSO to log in with SSO.")}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -343,7 +370,7 @@ function LoginPageContent() {
|
|||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Login with SSO
|
||||
{t("Login with SSO")}
|
||||
</Button>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
|
|
@ -25,6 +27,7 @@ import { usePathname } from "next/navigation";
|
|||
// Top bar for the dashboard shell. Sits only over the content column (the brand
|
||||
// lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout.
|
||||
export function DashboardHeader() {
|
||||
const { t } = useTranslation();
|
||||
const { title } = getBreadcrumb(usePathname());
|
||||
const { isControlPlane, selectedWorker } = useWorker();
|
||||
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
|
||||
|
|
@ -47,7 +50,7 @@ export function DashboardHeader() {
|
|||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">{title}</BreadcrumbPage>
|
||||
<BreadcrumbPage className="truncate">{t(title)}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
|
@ -63,6 +66,7 @@ export function DashboardHeader() {
|
|||
<BlogDropdown />
|
||||
{!hideCommunityLinks && <CommunityEngagementButtons />}
|
||||
<ToolbarSeparator />
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
<NotificationsBell />
|
||||
</div>
|
||||
|
|
|
|||
45
ui/litellm-dashboard/src/components/LanguageSwitcher.tsx
Normal file
45
ui/litellm-dashboard/src/components/LanguageSwitcher.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { Languages } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { isLanguage, LANGUAGE_STORAGE_KEY } from "@/i18n";
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const selectLanguage = (language: string) => {
|
||||
if (!isLanguage(language)) return;
|
||||
void i18n.changeLanguage(language);
|
||||
try {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" size="icon-sm" aria-label={t("Language")} title={t("Language")} />}
|
||||
>
|
||||
<Languages />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={i18n.language} onValueChange={selectLanguage}>
|
||||
<DropdownMenuRadioItem value="en">
|
||||
<span lang="en">English</span>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="zh-CN">
|
||||
<span lang="zh-CN">简体中文</span>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDashboardI18n } from "@/i18n";
|
||||
import { renderWithProviders, screen } from "../../../../tests/test-utils";
|
||||
import { BlogDropdown } from "./BlogDropdown";
|
||||
|
||||
const mockRefetch = vi.fn();
|
||||
let mockIsError = false;
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableBlogPosts", () => ({
|
||||
useDisableBlogPosts: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/blogPosts/useBlogPosts", () => ({
|
||||
useBlogPosts: () => ({
|
||||
data: {
|
||||
posts: [{ title: "Post One", date: "2026-02-01", description: "Description one", url: "https://example.com/1" }],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: mockIsError,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}));
|
||||
|
||||
async function openDropdown() {
|
||||
await userEvent.setup().hover(screen.getByRole("button", { name: "Blog" }));
|
||||
}
|
||||
|
||||
describe("blog dropdown language changes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsError = false;
|
||||
});
|
||||
|
||||
it("should translate Retry after switching languages and still refetch posts", async () => {
|
||||
mockIsError = true;
|
||||
const user = userEvent.setup();
|
||||
const i18n = createDashboardI18n();
|
||||
renderWithProviders(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<BlogDropdown />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
await openDropdown();
|
||||
expect(await screen.findByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
});
|
||||
expect(screen.getByText("加载文章失败")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "重试" }));
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should update visible dates when switching between English and Chinese", async () => {
|
||||
const i18n = createDashboardI18n();
|
||||
renderWithProviders(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<BlogDropdown />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
await openDropdown();
|
||||
expect(await screen.findByText("Feb 1, 2026")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
});
|
||||
expect(screen.getByText("2026年2月1日")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Feb 1, 2026")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Post One")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
expect(screen.getByText("Feb 1, 2026")).toBeInTheDocument();
|
||||
expect(screen.queryByText("2026年2月1日")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
|
||||
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
|
||||
|
|
@ -12,9 +13,9 @@ import {
|
|||
import { ChevronDown, LoaderCircle } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
function formatDate(dateStr: string, language: string): string {
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", {
|
||||
return date.toLocaleDateString(language === "zh-CN" ? "zh-CN" : "en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
|
|
@ -22,6 +23,7 @@ function formatDate(dateStr: string): string {
|
|||
}
|
||||
|
||||
export const BlogDropdown: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const disableBlogPosts = useDisableBlogPosts();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useBlogPosts();
|
||||
|
|
@ -34,7 +36,7 @@ export const BlogDropdown: React.FC = () => {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center px-2 py-1.5 text-sm">
|
||||
<LoaderCircle role="img" aria-label="loading" className="size-4 animate-spin" />
|
||||
<LoaderCircle role="img" aria-label={t("loading")} className="size-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -42,16 +44,16 @@ export const BlogDropdown: React.FC = () => {
|
|||
if (isError) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-sm">
|
||||
<span className="text-destructive">Failed to load posts</span>
|
||||
<span className="text-destructive">{t("Failed to load posts")}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
Retry
|
||||
{t("Retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.posts.length === 0) {
|
||||
return <div className="px-2 py-1.5 text-sm text-muted-foreground">No posts available</div>;
|
||||
return <div className="px-2 py-1.5 text-sm text-muted-foreground">{t("No posts available")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -63,7 +65,7 @@ export const BlogDropdown: React.FC = () => {
|
|||
{post.title}
|
||||
</h5>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 11 }}>
|
||||
{formatDate(post.date)}
|
||||
{formatDate(post.date, i18n.language)}
|
||||
</span>
|
||||
<p className="line-clamp-2">{post.description}</p>
|
||||
</a>
|
||||
|
|
@ -72,7 +74,7 @@ export const BlogDropdown: React.FC = () => {
|
|||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<a href="https://docs.litellm.ai/blog" target="_blank" rel="noopener noreferrer">
|
||||
View all posts
|
||||
{t("View all posts")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
|
|
@ -87,8 +89,7 @@ export const BlogDropdown: React.FC = () => {
|
|||
closeDelay={100}
|
||||
render={<Button variant="ghost" className={`${NAV_PRODUCT_LINK_CLASS} border-0!`} />}
|
||||
>
|
||||
Blog
|
||||
<ChevronDown className="size-2.5 text-muted-foreground" aria-hidden />
|
||||
{t("Blog")} <ChevronDown className="size-2.5 text-muted-foreground" aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-auto">
|
||||
{renderMenuContent()}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import React from "react";
|
||||
|
|
@ -8,11 +9,13 @@ const ChevronWidthSpacer: React.FC = () => (
|
|||
<ChevronDown className="pointer-events-none size-2.5 opacity-0" aria-hidden />
|
||||
);
|
||||
|
||||
export const DocsLink: React.FC = () => (
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={NAV_PRODUCT_LINK_CLASS}>
|
||||
Docs
|
||||
<ChevronWidthSpacer />
|
||||
</a>
|
||||
);
|
||||
export const DocsLink: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={NAV_PRODUCT_LINK_CLASS}>
|
||||
{t("Docs")} <ChevronWidthSpacer />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocsLink;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import {
|
||||
HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY,
|
||||
useHideAutoRouterAnnouncement,
|
||||
|
|
@ -15,6 +16,7 @@ import React, { useState } from "react";
|
|||
export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing";
|
||||
|
||||
export const NotificationsBell: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const hidden = useHideAutoRouterAnnouncement();
|
||||
const hasUnread = !hidden;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -27,9 +29,9 @@ export const NotificationsBell: React.FC = () => {
|
|||
|
||||
const content = (
|
||||
<div className="max-w-[280px]">
|
||||
<PopoverTitle className="mt-0! mb-2!">LiteLLM Auto Router</PopoverTitle>
|
||||
<PopoverTitle className="mt-0! mb-2!">{t("LiteLLM Auto Router")}</PopoverTitle>
|
||||
<PopoverDescription className="mb-3! text-sm leading-snug">
|
||||
Route every request to the cheapest model that can handle it, no prompt changes needed.
|
||||
{t("Route every request to the cheapest model that can handle it, no prompt changes needed.")}
|
||||
</PopoverDescription>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
|
|
@ -38,11 +40,11 @@ export const NotificationsBell: React.FC = () => {
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read the docs
|
||||
{t("Read the docs")}
|
||||
</a>
|
||||
{hasUnread ? (
|
||||
<Button variant="link" size="sm" className="px-1!" onClick={markDismissed}>
|
||||
Mark as read
|
||||
{t("Mark as read")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -53,7 +55,7 @@ export const NotificationsBell: React.FC = () => {
|
|||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className="flex! h-9! w-9! items-center justify-center rounded-md! text-muted-foreground transition-colors hover:bg-accent! hover:text-foreground!"
|
||||
aria-label="Notifications"
|
||||
aria-label={t("Notifications")}
|
||||
>
|
||||
<span className="relative inline-flex">
|
||||
<Bell className="size-4" aria-hidden />
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
|
||||
|
|
@ -78,6 +79,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
} = useAuthorized();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
const disableBlogPosts = useDisableBlogPosts();
|
||||
const disableBouncingIcon = useDisableBouncingIcon();
|
||||
|
|
@ -100,16 +102,16 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
{premiumUser ? (
|
||||
<Badge>
|
||||
<Crown className="size-3" />
|
||||
Premium
|
||||
{t("Premium")}
|
||||
</Badge>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Badge variant="outline" />}>
|
||||
<Crown className="size-3" />
|
||||
Standard
|
||||
{t("Standard")}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Upgrade to Premium for advanced features</TooltipContent>
|
||||
<TooltipContent side="left">{t("Upgrade to Premium for advanced features")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
|
@ -118,25 +120,25 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="size-4" />
|
||||
<span className="text-muted-foreground">User ID</span>
|
||||
<span className="text-muted-foreground">{t("User ID")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="max-w-[150px] truncate" title={userId || "-"}>
|
||||
{userId || "-"}
|
||||
</span>
|
||||
<CopyButton value={userId} label="Copy User ID" />
|
||||
<CopyButton value={userId} label={t("Copy User ID")} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-4" />
|
||||
<span className="text-muted-foreground">Role</span>
|
||||
<span className="text-muted-foreground">{t("Role")}</span>
|
||||
</div>
|
||||
<span>{userRole}</span>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Hide New Feature Indicators</span>
|
||||
<span className="text-muted-foreground">{t("Hide New Feature Indicators")}</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={disableShowNewBadge}
|
||||
|
|
@ -150,11 +152,11 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
emitLocalStorageChange("disableShowNewBadge");
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle hide new feature indicators"
|
||||
aria-label={t("Toggle hide new feature indicators")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Hide All Prompts</span>
|
||||
<span className="text-muted-foreground">{t("Hide All Prompts")}</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={disableShowPrompts}
|
||||
|
|
@ -167,11 +169,11 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
emitLocalStorageChange("disableShowPrompts");
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle hide all prompts"
|
||||
aria-label={t("Toggle hide all prompts")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Hide Blog Posts</span>
|
||||
<span className="text-muted-foreground">{t("Hide Blog Posts")}</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={disableBlogPosts}
|
||||
|
|
@ -184,11 +186,11 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
emitLocalStorageChange("disableBlogPosts");
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle hide blog posts"
|
||||
aria-label={t("Toggle hide blog posts")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Hide Bouncing Icon</span>
|
||||
<span className="text-muted-foreground">{t("Hide Bouncing Icon")}</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={disableBouncingIcon}
|
||||
|
|
@ -201,7 +203,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
emitLocalStorageChange("disableBouncingIcon");
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle hide bouncing icon"
|
||||
aria-label={t("Toggle hide bouncing icon")}
|
||||
/>
|
||||
</div>
|
||||
{canUseLiteAdmin && (
|
||||
|
|
@ -221,7 +223,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
const seed = userEmail || userId || "user";
|
||||
const initials = initialsFromIdentity(userEmail, userId);
|
||||
const hue = hueFromString(seed);
|
||||
const displayName = navAccountDisplayName(userEmail, userId);
|
||||
const displayName = navAccountDisplayName(userEmail, userId, t("Account"));
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
|
|
@ -234,7 +236,10 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
"flex w-full items-center rounded-lg border border-transparent transition-colors hover:bg-sidebar-accent",
|
||||
collapsed ? "justify-center px-0 py-1" : "gap-2.5 px-2 py-1.5 text-left",
|
||||
)}
|
||||
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
|
||||
aria-label={t("accountMenu", {
|
||||
role: userRole ?? t("Unknown role"),
|
||||
identity: userEmail || userId || t("unknown"),
|
||||
})}
|
||||
aria-haspopup="dialog"
|
||||
title={collapsed ? displayName : undefined}
|
||||
/>
|
||||
|
|
@ -261,7 +266,10 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
<button
|
||||
type="button"
|
||||
className="flex! max-w-[min(200px,34vw)] items-center gap-2 rounded-md! py-0.5! pl-1! pr-2! transition-colors hover:bg-accent!"
|
||||
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
|
||||
aria-label={t("accountMenu", {
|
||||
role: userRole ?? t("Unknown role"),
|
||||
identity: userEmail || userId || t("unknown"),
|
||||
})}
|
||||
aria-haspopup="dialog"
|
||||
/>
|
||||
}
|
||||
|
|
@ -304,7 +312,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
Logout
|
||||
{t("Logout")}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import React from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
|
|
@ -22,6 +23,7 @@ interface ViewSwitcherItem {
|
|||
}
|
||||
|
||||
export default function ViewSwitcher() {
|
||||
const { t } = useTranslation();
|
||||
const { mode, setMode, plugins } = usePluginMode();
|
||||
const { data: uiSettings } = useUISettings();
|
||||
const pathname = usePathname();
|
||||
|
|
@ -32,10 +34,10 @@ export default function ViewSwitcher() {
|
|||
const normalizedPathname = (pathname ?? "").replace(/\/+$/, "");
|
||||
const isChatRoute = chatEnabled && (normalizedPathname === chatHref || normalizedPathname.startsWith(`${chatHref}/`));
|
||||
|
||||
const activeLabel = isChatRoute ? "Chat" : plugins.find((p) => p.name === mode)?.display_name ?? "AI Gateway";
|
||||
const activeLabel = isChatRoute ? t("Chat") : plugins.find((p) => p.name === mode)?.display_name ?? t("AI Gateway");
|
||||
|
||||
const modeEntries = [
|
||||
{ key: GATEWAY, label: "AI Gateway" },
|
||||
{ key: GATEWAY, label: t("AI Gateway") },
|
||||
...plugins.map((p) => ({ key: p.name, label: p.display_name })),
|
||||
];
|
||||
|
||||
|
|
@ -53,7 +55,7 @@ export default function ViewSwitcher() {
|
|||
key: CHAT,
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-6 py-0.5">
|
||||
<span className="font-medium">Chat</span>
|
||||
<span className="font-medium">{t("Chat")}</span>
|
||||
{isChatRoute && <Check className="size-4 text-info" />}
|
||||
</div>
|
||||
),
|
||||
|
|
@ -64,9 +66,9 @@ export default function ViewSwitcher() {
|
|||
disabled: true,
|
||||
label: (
|
||||
<div className="flex max-w-[220px] flex-col py-0.5">
|
||||
<span className="font-medium">Chat</span>
|
||||
<span className="font-medium">{t("Chat")}</span>
|
||||
<span className="whitespace-normal text-xs leading-snug text-muted-foreground">
|
||||
Admins can enable in Settings
|
||||
{t("Admins can enable in Settings")}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -15,3 +15,9 @@ describe("navAccountDisplayName", () => {
|
|||
expect(navAccountDisplayName(null, "user-uuid-123")).toBe("user-uuid-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("localizes only the placeholder account label, preserving real account identities", () => {
|
||||
expect(navAccountDisplayName(null, "default_user_id", "账户")).toBe("账户");
|
||||
expect(navAccountDisplayName(null, "Account", "账户")).toBe("Account");
|
||||
expect(navAccountDisplayName("admin@example.com", null, "账户")).toBe("admin@example.com");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
/** Primary label for the navbar account control — avoids raw placeholder JWT/user IDs in the UI. */
|
||||
export function navAccountDisplayName(userEmail: string | null, userId: string | null): string {
|
||||
export function navAccountDisplayName(userEmail: string | null, userId: string | null, fallback = "Account"): string {
|
||||
const email = userEmail?.trim();
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
const id = userId?.trim();
|
||||
if (!id) {
|
||||
return "Account";
|
||||
return fallback;
|
||||
}
|
||||
if (/^default[_\s-]?user[_\s-]?id$/i.test(id)) {
|
||||
return "Account";
|
||||
return fallback;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
|
||||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
|
|
@ -97,6 +98,7 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
} = useAuthorized();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
const version = healthData?.litellm_version;
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
|
|
@ -118,29 +120,29 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
const toggles = [
|
||||
{
|
||||
key: "disableShowNewBadge",
|
||||
label: "Hide New Feature Indicators",
|
||||
ariaLabel: "Toggle hide new feature indicators",
|
||||
label: t("Hide New Feature Indicators"),
|
||||
ariaLabel: t("Toggle hide new feature indicators"),
|
||||
checked: disableShowNewBadge,
|
||||
onCheckedChange: (checked: boolean) => setFlag("disableShowNewBadge", checked),
|
||||
},
|
||||
{
|
||||
key: "disableShowPrompts",
|
||||
label: "Hide All Prompts",
|
||||
ariaLabel: "Toggle hide all prompts",
|
||||
label: t("Hide All Prompts"),
|
||||
ariaLabel: t("Toggle hide all prompts"),
|
||||
checked: disableShowPrompts,
|
||||
onCheckedChange: (checked: boolean) => setFlag("disableShowPrompts", checked),
|
||||
},
|
||||
{
|
||||
key: "disableBlogPosts",
|
||||
label: "Hide Blog Posts",
|
||||
ariaLabel: "Toggle hide blog posts",
|
||||
label: t("Hide Blog Posts"),
|
||||
ariaLabel: t("Toggle hide blog posts"),
|
||||
checked: disableBlogPosts,
|
||||
onCheckedChange: (checked: boolean) => setFlag("disableBlogPosts", checked),
|
||||
},
|
||||
{
|
||||
key: "disableBouncingIcon",
|
||||
label: "Hide Bouncing Icon",
|
||||
ariaLabel: "Toggle hide bouncing icon",
|
||||
label: t("Hide Bouncing Icon"),
|
||||
ariaLabel: t("Toggle hide bouncing icon"),
|
||||
checked: disableBouncingIcon,
|
||||
onCheckedChange: (checked: boolean) => setFlag("disableBouncingIcon", checked),
|
||||
},
|
||||
|
|
@ -149,8 +151,11 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
const seed = userEmail || userId || "user";
|
||||
const initials = initialsFromIdentity(userEmail, userId);
|
||||
const hue = hueFromString(seed);
|
||||
const displayName = navAccountDisplayName(userEmail, userId);
|
||||
const triggerLabel = `Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`;
|
||||
const displayName = navAccountDisplayName(userEmail, userId, t("Account"));
|
||||
const triggerLabel = t("accountMenu", {
|
||||
role: userRole ?? t("Unknown role"),
|
||||
identity: userEmail || userId || t("unknown"),
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
|
|
@ -191,7 +196,7 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
<span
|
||||
className="animate-bounce text-lg leading-none"
|
||||
style={{ animationDuration: "2s" }}
|
||||
title="Thanks for using LiteLLM!"
|
||||
title={t("Thanks for using LiteLLM!")}
|
||||
aria-hidden
|
||||
>
|
||||
🌴
|
||||
|
|
@ -210,27 +215,27 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
</div>
|
||||
|
||||
<div className="flex flex-col px-3 py-2">
|
||||
<InfoRow icon={<Crown className="size-[17px]" />} label="Tier">
|
||||
<InfoRow icon={<Crown className="size-[17px]" />} label={t("Tier")}>
|
||||
{premiumUser ? (
|
||||
<Badge variant="outline" className="gap-1 border-warning/30 bg-warning/10 text-warning">
|
||||
<Crown />
|
||||
Premium
|
||||
{t("Premium")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="gap-1" title="Upgrade to Premium for advanced features">
|
||||
<Badge variant="secondary" className="gap-1" title={t("Upgrade to Premium for advanced features")}>
|
||||
<Crown />
|
||||
Standard
|
||||
{t("Standard")}
|
||||
</Badge>
|
||||
)}
|
||||
</InfoRow>
|
||||
<InfoRow icon={<ShieldCheck className="size-[17px]" />} label="Role">
|
||||
<InfoRow icon={<ShieldCheck className="size-[17px]" />} label={t("Role")}>
|
||||
<Badge variant="secondary">{userRole}</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow icon={<Mail className="size-[17px]" />} label="Email">
|
||||
<MonoValue value={userEmail} copyLabel="Copy email" />
|
||||
<InfoRow icon={<Mail className="size-[17px]" />} label={t("Email")}>
|
||||
<MonoValue value={userEmail} copyLabel={t("Copy email")} />
|
||||
</InfoRow>
|
||||
<InfoRow icon={<IdCard className="size-[17px]" />} label="User ID">
|
||||
<MonoValue value={userId} copyLabel="Copy user ID" />
|
||||
<InfoRow icon={<IdCard className="size-[17px]" />} label={t("User ID")}>
|
||||
<MonoValue value={userId} copyLabel={t("Copy user ID")} />
|
||||
</InfoRow>
|
||||
</div>
|
||||
|
||||
|
|
@ -283,7 +288,7 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
className="h-[42px] w-full justify-start gap-2.5 rounded-none px-3 text-sm font-medium text-foreground"
|
||||
>
|
||||
<LogOut className="size-[19px] text-muted-foreground" />
|
||||
Logout
|
||||
{t("Logout")}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import React from "react";
|
||||
|
|
@ -7,9 +8,10 @@ import React from "react";
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const ThemeToggle: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
const label = isDark ? "Switch to light mode" : "Switch to dark mode (beta)";
|
||||
const label = isDark ? t("Switch to light mode") : t("Switch to dark mode (beta)");
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { cx } from "@/lib/cva.config";
|
||||
import { UiLoadingSpinner } from "../ui/ui-loading-spinner";
|
||||
|
||||
export default function LoadingScreen() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cx("h-screen", "flex items-center justify-center gap-4")}>
|
||||
<div className="text-lg font-medium py-2 pr-4 border-r border-r-gray-200">🚅 LiteLLM</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<UiLoadingSpinner className="size-4" />
|
||||
<span className="text-muted-foreground text-sm">Loading...</span>
|
||||
<span className="text-muted-foreground text-sm">{t("Loading...")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { createDashboardI18n } from "@/i18n";
|
||||
import { renderWithProviders } from "../../tests/test-utils";
|
||||
import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav";
|
||||
|
||||
|
|
@ -623,3 +625,37 @@ describe("getBreadcrumb", () => {
|
|||
expect(getBreadcrumb("/ui/some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("localized navigation", () => {
|
||||
it("changes visible and collapsed labels while retaining routes and permission filtering", async () => {
|
||||
const internalUser = {
|
||||
userId: "test-user-id",
|
||||
accessToken: "test-access-token",
|
||||
userRole: "internal",
|
||||
isViewOnly: false,
|
||||
token: "test-token",
|
||||
userEmail: "test@example.com",
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: false,
|
||||
showSSOBanner: false,
|
||||
};
|
||||
mockUseAuthorized.mockReturnValue(internalUser);
|
||||
const instance = createDashboardI18n("zh-CN");
|
||||
renderWithProviders(
|
||||
<I18nextProvider i18n={instance}>
|
||||
<Sidebar collapsed />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "虚拟密钥" })).toHaveAttribute("href", "/ui/api-keys");
|
||||
expect(screen.getByRole("link", { name: "虚拟密钥" })).toHaveAttribute("title", "虚拟密钥");
|
||||
expect(screen.getByRole("link", { name: "模型与端点" })).toHaveAttribute("href", "/ui/models-and-endpoints");
|
||||
expect(screen.queryByRole("link", { name: "内部用户" })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await instance.changeLanguage("en");
|
||||
});
|
||||
expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("href", "/ui/api-keys");
|
||||
expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("title", "Virtual Keys");
|
||||
expect(screen.queryByRole("link", { name: "Internal Users" })).not.toBeInTheDocument();
|
||||
expect(menuGroups[0].items[0].label).toBe("Virtual Keys");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
|
||||
|
|
@ -102,6 +103,7 @@ interface MenuItem {
|
|||
roles?: string[];
|
||||
children?: MenuItem[];
|
||||
icon?: React.ReactNode;
|
||||
badge?: React.ReactNode;
|
||||
external_url?: string;
|
||||
}
|
||||
|
||||
|
|
@ -209,11 +211,8 @@ const menuGroups: MenuGroup[] = [
|
|||
page: "cost-optimization",
|
||||
icon: <PiggyBank {...ICON} />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Cost Optimization <BetaBadge />
|
||||
</span>
|
||||
),
|
||||
label: "Cost Optimization",
|
||||
badge: <BetaBadge />,
|
||||
},
|
||||
{ key: "logs", page: "logs", label: "Logs", icon: <Activity {...ICON} /> },
|
||||
{
|
||||
|
|
@ -232,11 +231,8 @@ const menuGroups: MenuGroup[] = [
|
|||
{
|
||||
key: "projects",
|
||||
page: "projects",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Projects <BetaBadge />
|
||||
</span>
|
||||
),
|
||||
label: "Projects",
|
||||
badge: <BetaBadge />,
|
||||
icon: <Folder {...ICON} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
|
|
@ -428,6 +424,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
disableVectorStoresForInternalUsers,
|
||||
allowVectorStoresForTeamAdmins,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { userId, accessToken, userRole, isViewOnly } = useAuthorized();
|
||||
const isOrgAdmin = useIsOrgAdmin();
|
||||
const { data: teams } = useTeams();
|
||||
|
|
@ -521,10 +518,24 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
const translatedLabel = (item: MenuItem) => {
|
||||
const label = typeof item.label === "string" ? t(item.label) : item.label;
|
||||
return item.badge ? (
|
||||
<span className="flex items-center gap-2">
|
||||
{label}
|
||||
{item.badge}
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
);
|
||||
};
|
||||
|
||||
const renderLeaf = (item: MenuItem, isChild: boolean) => {
|
||||
const active = selectedKey === item.key;
|
||||
const size = isChild ? "sub" : "default";
|
||||
const label = <span className="flex-1 truncate group-data-[collapsed=true]/sidebar:hidden">{item.label}</span>;
|
||||
const label = (
|
||||
<span className="flex-1 truncate group-data-[collapsed=true]/sidebar:hidden">{translatedLabel(item)}</span>
|
||||
);
|
||||
|
||||
if (item.external_url) {
|
||||
return (
|
||||
|
|
@ -533,7 +544,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
href={item.external_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={collapsed ? labelText(item) : undefined}
|
||||
title={collapsed ? t(labelText(item)) : undefined}
|
||||
data-active={active || undefined}
|
||||
className={cn(sidebarMenuButtonVariants({ isActive: active, size }))}
|
||||
>
|
||||
|
|
@ -548,7 +559,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
<Link
|
||||
key={item.key}
|
||||
href={uiHref(routeOf(item))}
|
||||
title={collapsed ? labelText(item) : undefined}
|
||||
title={collapsed ? t(labelText(item)) : undefined}
|
||||
data-active={active || undefined}
|
||||
className={cn(sidebarMenuButtonVariants({ isActive: active, size }))}
|
||||
>
|
||||
|
|
@ -572,10 +583,10 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
isActive={active}
|
||||
aria-expanded={open}
|
||||
onClick={() => toggleGroup(item.key)}
|
||||
title={collapsed ? labelText(item) : undefined}
|
||||
title={collapsed ? t(labelText(item)) : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="flex-1 truncate group-data-[collapsed=true]/sidebar:hidden">{item.label}</span>
|
||||
<span className="flex-1 truncate group-data-[collapsed=true]/sidebar:hidden">{translatedLabel(item)}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-transform group-data-[collapsed=true]/sidebar:hidden",
|
||||
|
|
@ -603,7 +614,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
<SidebarHeader className="h-14 border-b border-border group-data-[collapsed=true]/sidebar:h-auto">
|
||||
<div className="flex items-center justify-between gap-2 group-data-[collapsed=true]/sidebar:flex-col">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Link href={uiHref("")} className="flex min-w-0 items-center" aria-label="LiteLLM home">
|
||||
<Link href={uiHref("")} className="flex min-w-0 items-center" aria-label={t("LiteLLM home")}>
|
||||
<img src={logoSrc} alt="LiteLLM" className={cn(LOGO_CLASS_NAME, "dark:hidden")} />
|
||||
<img
|
||||
src={darkLogoSrc}
|
||||
|
|
@ -628,7 +639,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
aria-label={collapsed ? t("Expand sidebar") : t("Collapse sidebar")}
|
||||
className="flex-none text-muted-foreground"
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen /> : <PanelLeftClose />}
|
||||
|
|
@ -642,7 +653,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
{visibleGroups.map((group, gi) => (
|
||||
<SidebarGroup key={group.groupLabel}>
|
||||
{gi > 0 && <SidebarSeparator className="hidden group-data-[collapsed=true]/sidebar:block" />}
|
||||
<SidebarGroupLabel>{group.groupLabel}</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{t(group.groupLabel)}</SidebarGroupLabel>
|
||||
<SidebarMenu>{group.items.map((item) => renderItem(item))}</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
|
||||
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
|
|
@ -37,6 +39,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
onToggleSidebar,
|
||||
}) => {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const { t } = useTranslation();
|
||||
const { logoUrl } = useTheme();
|
||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
const version = healthData?.litellm_version;
|
||||
|
|
@ -65,7 +68,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="mr-2 flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
title={sidebarCollapsed ? t("Expand sidebar") : t("Collapse sidebar")}
|
||||
>
|
||||
<span className="text-lg">
|
||||
{sidebarCollapsed ? (
|
||||
|
|
@ -97,7 +100,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("Thanks for using LiteLLM!")}
|
||||
>
|
||||
🌑
|
||||
</span>
|
||||
|
|
@ -131,7 +134,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
)}
|
||||
|
||||
<nav
|
||||
aria-label="Product documentation"
|
||||
aria-label={t("Product documentation")}
|
||||
className={`flex min-w-0 items-center gap-2 ${showWorkerSwitch ? "border-l border-border pl-4" : ""}`}
|
||||
>
|
||||
<DocsLink />
|
||||
|
|
@ -147,6 +150,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
{!isPublicPage && (
|
||||
<div className="flex shrink-0 items-center border-l border-border pl-4">
|
||||
<div className="flex items-center gap-0.5 rounded-lg bg-muted px-1 py-0 transition-colors hover:bg-accent">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
<span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
|
||||
<NotificationsBell />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/useTranslation";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ export type PasswordInputProps = Omit<React.ComponentPropsWithoutRef<"input">, "
|
|||
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
({ className, groupClassName, disabled, ...props }, ref) => {
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InputGroup className={groupClassName}>
|
||||
|
|
@ -26,7 +28,7 @@ export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputPro
|
|||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
disabled={disabled}
|
||||
aria-label={revealed ? "Hide password" : "Show password"}
|
||||
aria-label={revealed ? t("Hide password") : t("Show password")}
|
||||
onClick={() => setRevealed((current) => !current)}
|
||||
>
|
||||
{revealed ? <EyeOff /> : <Eye />}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import LoadingScreen from "@/components/common_components/LoadingScreen";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import LanguageProvider from "./LanguageProvider";
|
||||
import { LANGUAGE_STORAGE_KEY } from ".";
|
||||
|
||||
function LanguageExample() {
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<LanguageSwitcher />
|
||||
<LoadingScreen />
|
||||
<PasswordInput aria-label="API key" />
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function selectChinese() {
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "Language" }));
|
||||
await user.click(await screen.findByRole("menuitemradio", { name: "简体中文" }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe("dashboard language preference", () => {
|
||||
it("switches all consumers, preserves password fields, and restores the explicit preference after remount", async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = render(<LanguageExample />);
|
||||
await user.type(screen.getByLabelText("API key"), "opaque-value");
|
||||
await selectChinese();
|
||||
expect(screen.getByText("加载中…")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "显示密码" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("API key")).toHaveValue("opaque-value");
|
||||
expect(document.documentElement).toHaveAttribute("lang", "zh-CN");
|
||||
expect(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("zh-CN");
|
||||
first.unmount();
|
||||
render(<LanguageExample />);
|
||||
expect(await screen.findByRole("button", { name: "语言" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "语言" }));
|
||||
expect(await screen.findByRole("menuitemradio", { name: "简体中文" })).toBeChecked();
|
||||
await user.click(await screen.findByRole("menuitemradio", { name: "English" }));
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
expect(document.documentElement).toHaveAttribute("lang", "en");
|
||||
expect(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("en");
|
||||
});
|
||||
|
||||
it("renders English on the server even when the browser has a Chinese preference", () => {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
expect(renderToString(<LanguageExample />)).toContain("Loading...");
|
||||
});
|
||||
|
||||
it("ignores invalid stored values without changing storage or the default UI", () => {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "fr");
|
||||
render(<LanguageExample />);
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
expect(document.documentElement).toHaveAttribute("lang", "en");
|
||||
expect(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("fr");
|
||||
});
|
||||
|
||||
it("remains usable when browser storage throws on both reads and writes", async () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new DOMException("Blocked", "SecurityError");
|
||||
});
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new DOMException("Blocked", "SecurityError");
|
||||
});
|
||||
render(<LanguageExample />);
|
||||
await selectChinese();
|
||||
expect(screen.getByText("加载中…")).toBeInTheDocument();
|
||||
expect(document.documentElement).toHaveAttribute("lang", "zh-CN");
|
||||
});
|
||||
});
|
||||
26
ui/litellm-dashboard/src/i18n/LanguageProvider.tsx
Normal file
26
ui/litellm-dashboard/src/i18n/LanguageProvider.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { createDashboardI18n, isLanguage, LANGUAGE_STORAGE_KEY } from ".";
|
||||
|
||||
export default function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [instance] = useState(createDashboardI18n);
|
||||
|
||||
useEffect(() => {
|
||||
const updateDocumentLanguage = (language: string) => {
|
||||
document.documentElement.lang = language;
|
||||
};
|
||||
instance.on("languageChanged", updateDocumentLanguage);
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||
if (isLanguage(stored)) void instance.changeLanguage(stored);
|
||||
} catch {}
|
||||
updateDocumentLanguage(instance.language);
|
||||
return () => {
|
||||
instance.off("languageChanged", updateDocumentLanguage);
|
||||
};
|
||||
}, [instance]);
|
||||
|
||||
return <I18nextProvider i18n={instance}>{children}</I18nextProvider>;
|
||||
}
|
||||
13
ui/litellm-dashboard/src/i18n/README.md
Normal file
13
ui/litellm-dashboard/src/i18n/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Dashboard translations
|
||||
|
||||
The dashboard defaults to English. Select 简体中文 from the language menu on the login page or dashboard header to switch to Simplified Chinese. The explicit choice is saved in `localStorage.litellm_ui_language`. Unsupported stored values fall back to English. When browser storage is unavailable, the selection works until the page reloads
|
||||
|
||||
This initial coverage includes login and validation, the sidebar and collapsed navigation labels, breadcrumbs, view switching, account controls, and header controls. Dashboard page bodies, server error messages, worker and plugin names, and role labels are outside this change
|
||||
|
||||
Translations are bundled with the static export using i18next and react-i18next. Each mounted `LanguageProvider` owns its instance, starts in English for server rendering and hydration, then restores the stored preference after mounting. Changing language updates the HTML `lang` attribute without changing URLs or remounting the form. No translation service or language-specific backend is required
|
||||
|
||||
Use `useTranslation` from `@/i18n/useTranslation` for React components. It uses the root provider, with an isolated English fallback for standalone components. Add the same key to `locales/en.json` and `locales/zh-CN.json`. Plain display text serves as its own key; longer sentences with markup use descriptive keys and `Trans`. Key and namespace separators are disabled, so punctuation in display text stays literal
|
||||
|
||||
Translate labels at render time. Keep identifiers, URLs, form field names, permission checks, plugin names, and API payloads unchanged. Use interpolation for user values and `Trans` for inline links or code, rather than joining translated fragments. React escapes interpolated display values
|
||||
|
||||
Chinese terminology adapts the MIT-licensed locale contributed by [2099383411 in PR #30499](https://github.com/BerriAI/litellm/pull/30499) at `e787b318a932c9c6b897830f6d01045f05bbd0a8`, with additions for the current Base UI dashboard. That PR's Ant Design integration is not included here
|
||||
35
ui/litellm-dashboard/src/i18n/index.test.ts
Normal file
35
ui/litellm-dashboard/src/i18n/index.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createDashboardI18n, isLanguage } from ".";
|
||||
import en from "./locales/en.json";
|
||||
import zhCN from "./locales/zh-CN.json";
|
||||
|
||||
describe("dashboard translations", () => {
|
||||
it("keeps complete English and Chinese catalogs with matching interpolation fields and markup", () => {
|
||||
expect(Object.keys(zhCN).sort()).toEqual(Object.keys(en).sort());
|
||||
for (const key of Object.keys(en) as (keyof typeof en)[]) {
|
||||
expect(zhCN[key].trim(), key).not.toBe("");
|
||||
expect(zhCN[key].match(/{{\w+}}|<\/?\w+>/g) ?? [], key).toEqual(en[key].match(/{{\w+}}|<\/?\w+>/g) ?? []);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves punctuation in natural-language keys and falls back to English", () => {
|
||||
const instance = createDashboardI18n("zh-CN");
|
||||
expect(instance.t("Access your LiteLLM Admin UI.")).toBe("登录 LiteLLM 管理界面。");
|
||||
instance.addResource("en", "translation", "English fallback.", "English fallback.");
|
||||
expect(instance.t("English fallback.")).toBe("English fallback.");
|
||||
expect(instance.t("An untranslated label")).toBe("An untranslated label");
|
||||
});
|
||||
|
||||
it("keeps server-rendered and standalone instances independent of another user's selection", async () => {
|
||||
const first = createDashboardI18n();
|
||||
const second = createDashboardI18n();
|
||||
await first.changeLanguage("zh-CN");
|
||||
expect(first.t("Login")).toBe("登录");
|
||||
expect(second.t("Login")).toBe("Login");
|
||||
});
|
||||
|
||||
it("only accepts the supported explicit preferences", () => {
|
||||
expect(["en", "zh-CN"].every(isLanguage)).toBe(true);
|
||||
expect([null, undefined, "", "fr", "zh-TW", "__proto__"].some(isLanguage)).toBe(false);
|
||||
});
|
||||
});
|
||||
27
ui/litellm-dashboard/src/i18n/index.ts
Normal file
27
ui/litellm-dashboard/src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { createInstance, type InitOptions } from "i18next";
|
||||
import en from "./locales/en.json";
|
||||
import zhCN from "./locales/zh-CN.json";
|
||||
|
||||
export type Language = "en" | "zh-CN";
|
||||
export const LANGUAGE_STORAGE_KEY = "litellm_ui_language";
|
||||
|
||||
export function isLanguage(value: unknown): value is Language {
|
||||
return value === "en" || value === "zh-CN";
|
||||
}
|
||||
|
||||
export function createDashboardI18n(language: Language = "en") {
|
||||
const instance = createInstance();
|
||||
const options: InitOptions = {
|
||||
lng: language,
|
||||
fallbackLng: "en",
|
||||
supportedLngs: ["en", "zh-CN"],
|
||||
resources: { en: { translation: { ...en } }, "zh-CN": { translation: { ...zhCN } } },
|
||||
initAsync: false,
|
||||
keySeparator: false,
|
||||
nsSeparator: false,
|
||||
interpolation: { escapeValue: false },
|
||||
react: { useSuspense: false },
|
||||
};
|
||||
void instance.init(options);
|
||||
return instance;
|
||||
}
|
||||
116
ui/litellm-dashboard/src/i18n/locales/en.json
Normal file
116
ui/litellm-dashboard/src/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
{
|
||||
"ACCESS CONTROL": "ACCESS CONTROL",
|
||||
"AI GATEWAY": "AI GATEWAY",
|
||||
"AI Gateway": "AI Gateway",
|
||||
"AI Hub": "AI Hub",
|
||||
"API Playground": "API Playground",
|
||||
"API Reference": "API Reference",
|
||||
"Access Groups": "Access Groups",
|
||||
"Access your LiteLLM Admin UI.": "Access your LiteLLM Admin UI.",
|
||||
"Account": "Account",
|
||||
"Admin Settings": "Admin Settings",
|
||||
"Admin UI Disabled": "Admin UI Disabled",
|
||||
"Admins can enable in Settings": "Admins can enable in Settings",
|
||||
"Agentic": "Agentic",
|
||||
"Agents": "Agents",
|
||||
"Blog": "Blog",
|
||||
"Budgets": "Budgets",
|
||||
"Chat": "Chat",
|
||||
"Choose a worker to connect to": "Choose a worker to connect to",
|
||||
"Close": "Close",
|
||||
"Collapse sidebar": "Collapse sidebar",
|
||||
"Copy User ID": "Copy User ID",
|
||||
"Copy email": "Copy email",
|
||||
"Copy user ID": "Copy user ID",
|
||||
"Cost Optimization": "Cost Optimization",
|
||||
"Cost Tracking": "Cost Tracking",
|
||||
"DEVELOPER TOOLS": "DEVELOPER TOOLS",
|
||||
"Default Credentials": "Default Credentials",
|
||||
"Docs": "Docs",
|
||||
"Email": "Email",
|
||||
"Enter your password": "Enter your password",
|
||||
"Enter your username": "Enter your username",
|
||||
"Expand sidebar": "Expand sidebar",
|
||||
"Experimental": "Experimental",
|
||||
"Failed to load posts": "Failed to load posts",
|
||||
"Guardrails": "Guardrails",
|
||||
"Guardrails Monitor": "Guardrails Monitor",
|
||||
"Hide All Prompts": "Hide All Prompts",
|
||||
"Hide Blog Posts": "Hide Blog Posts",
|
||||
"Hide Bouncing Icon": "Hide Bouncing Icon",
|
||||
"Hide New Feature Indicators": "Hide New Feature Indicators",
|
||||
"Hide password": "Hide password",
|
||||
"Internal Users": "Internal Users",
|
||||
"Language": "Language",
|
||||
"Learning Resources": "Learning Resources",
|
||||
"LiteLLM Auto Router": "LiteLLM Auto Router",
|
||||
"LiteLLM home": "LiteLLM home",
|
||||
"Loading...": "Loading...",
|
||||
"Logging & Alerts": "Logging & Alerts",
|
||||
"Logging in...": "Logging in...",
|
||||
"Login": "Login",
|
||||
"Login with SSO": "Login with SSO",
|
||||
"Logout": "Logout",
|
||||
"Logs": "Logs",
|
||||
"MCP Servers": "MCP Servers",
|
||||
"Mark as read": "Mark as read",
|
||||
"Memory": "Memory",
|
||||
"Models + Endpoints": "Models + Endpoints",
|
||||
"No posts available": "No posts available",
|
||||
"Notifications": "Notifications",
|
||||
"OBSERVABILITY": "OBSERVABILITY",
|
||||
"Old Usage": "Old Usage",
|
||||
"Organizations": "Organizations",
|
||||
"Password": "Password",
|
||||
"Playground": "Playground",
|
||||
"Please configure SSO to log in with SSO.": "Please configure SSO to log in with SSO.",
|
||||
"Please enter your password": "Please enter your password",
|
||||
"Please enter your username": "Please enter your username",
|
||||
"Policies": "Policies",
|
||||
"Premium": "Premium",
|
||||
"Product documentation": "Product documentation",
|
||||
"Projects": "Projects",
|
||||
"Prompts": "Prompts",
|
||||
"Read the docs": "Read the docs",
|
||||
"Response Cache": "Response Cache",
|
||||
"Retry": "Retry",
|
||||
"Role": "Role",
|
||||
"Route every request to the cheapest model that can handle it, no prompt changes needed.": "Route every request to the cheapest model that can handle it, no prompt changes needed.",
|
||||
"Router Settings": "Router Settings",
|
||||
"SETTINGS": "SETTINGS",
|
||||
"Search Tools": "Search Tools",
|
||||
"Settings": "Settings",
|
||||
"Show password": "Show password",
|
||||
"Skills": "Skills",
|
||||
"Standard": "Standard",
|
||||
"Switch to dark mode (beta)": "Switch to dark mode (beta)",
|
||||
"Switch to light mode": "Switch to light mode",
|
||||
"Tag Management": "Tag Management",
|
||||
"Teams": "Teams",
|
||||
"Thanks for using LiteLLM!": "Thanks for using LiteLLM!",
|
||||
"The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:": "The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:",
|
||||
"Tier": "Tier",
|
||||
"Toggle hide all prompts": "Toggle hide all prompts",
|
||||
"Toggle hide blog posts": "Toggle hide blog posts",
|
||||
"Toggle hide bouncing icon": "Toggle hide bouncing icon",
|
||||
"Toggle hide new feature indicators": "Toggle hide new feature indicators",
|
||||
"Tool Policies": "Tool Policies",
|
||||
"Tools": "Tools",
|
||||
"UI Theme": "UI Theme",
|
||||
"Unknown role": "Unknown role",
|
||||
"Upgrade to Premium for advanced features": "Upgrade to Premium for advanced features",
|
||||
"Usage": "Usage",
|
||||
"User ID": "User ID",
|
||||
"Username": "Username",
|
||||
"Vector Stores": "Vector Stores",
|
||||
"View all posts": "View all posts",
|
||||
"Virtual Keys": "Virtual Keys",
|
||||
"Worker": "Worker",
|
||||
"Workflow Runs": "Workflow Runs",
|
||||
"accountMenu": "Account menu — {{role}} — signed in as {{identity}}",
|
||||
"loading": "loading",
|
||||
"login.credentialsHelp": "Need to set UI credentials or SSO? <docs>Check the documentation</docs>.",
|
||||
"login.defaultCredentials": "By default, Username is <code>admin</code> and Password is your set LiteLLM Proxy <code>MASTER_KEY</code>.",
|
||||
"login.ssoNotice": "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.",
|
||||
"unknown": "unknown"
|
||||
}
|
||||
116
ui/litellm-dashboard/src/i18n/locales/zh-CN.json
Normal file
116
ui/litellm-dashboard/src/i18n/locales/zh-CN.json
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
{
|
||||
"ACCESS CONTROL": "访问控制",
|
||||
"AI GATEWAY": "AI 网关",
|
||||
"AI Gateway": "AI 网关",
|
||||
"AI Hub": "AI 资源中心",
|
||||
"API Playground": "API 试用",
|
||||
"API Reference": "API 参考",
|
||||
"Access Groups": "访问组",
|
||||
"Access your LiteLLM Admin UI.": "登录 LiteLLM 管理界面。",
|
||||
"Account": "账户",
|
||||
"Admin Settings": "管理设置",
|
||||
"Admin UI Disabled": "管理界面已禁用",
|
||||
"Admins can enable in Settings": "管理员可在设置中启用",
|
||||
"Agentic": "智能体应用",
|
||||
"Agents": "智能体",
|
||||
"Blog": "博客",
|
||||
"Budgets": "预算",
|
||||
"Chat": "聊天",
|
||||
"Choose a worker to connect to": "选择要连接的工作节点",
|
||||
"Close": "关闭",
|
||||
"Collapse sidebar": "收起侧栏",
|
||||
"Copy User ID": "复制用户 ID",
|
||||
"Copy email": "复制邮箱",
|
||||
"Copy user ID": "复制用户 ID",
|
||||
"Cost Optimization": "成本优化",
|
||||
"Cost Tracking": "成本追踪",
|
||||
"DEVELOPER TOOLS": "开发工具",
|
||||
"Default Credentials": "默认登录信息",
|
||||
"Docs": "文档",
|
||||
"Email": "邮箱",
|
||||
"Enter your password": "请输入密码",
|
||||
"Enter your username": "请输入用户名",
|
||||
"Expand sidebar": "展开侧栏",
|
||||
"Experimental": "实验功能",
|
||||
"Failed to load posts": "加载文章失败",
|
||||
"Guardrails": "安全护栏",
|
||||
"Guardrails Monitor": "护栏监控",
|
||||
"Hide All Prompts": "隐藏所有提示",
|
||||
"Hide Blog Posts": "隐藏博客文章",
|
||||
"Hide Bouncing Icon": "隐藏跳动图标",
|
||||
"Hide New Feature Indicators": "隐藏新功能提示",
|
||||
"Hide password": "隐藏密码",
|
||||
"Internal Users": "内部用户",
|
||||
"Language": "语言",
|
||||
"Learning Resources": "学习资料",
|
||||
"LiteLLM Auto Router": "LiteLLM 自动路由",
|
||||
"LiteLLM home": "LiteLLM 首页",
|
||||
"Loading...": "加载中…",
|
||||
"Logging & Alerts": "日志与告警",
|
||||
"Logging in...": "正在登录…",
|
||||
"Login": "登录",
|
||||
"Login with SSO": "使用单点登录",
|
||||
"Logout": "退出登录",
|
||||
"Logs": "请求日志",
|
||||
"MCP Servers": "MCP 服务",
|
||||
"Mark as read": "标记为已读",
|
||||
"Memory": "记忆",
|
||||
"Models + Endpoints": "模型与端点",
|
||||
"No posts available": "暂无文章",
|
||||
"Notifications": "通知",
|
||||
"OBSERVABILITY": "运行监控",
|
||||
"Old Usage": "旧版用量",
|
||||
"Organizations": "组织",
|
||||
"Password": "密码",
|
||||
"Playground": "模型试用",
|
||||
"Please configure SSO to log in with SSO.": "请先配置单点登录。",
|
||||
"Please enter your password": "请输入密码",
|
||||
"Please enter your username": "请输入用户名",
|
||||
"Policies": "策略",
|
||||
"Premium": "高级版",
|
||||
"Product documentation": "产品文档",
|
||||
"Projects": "项目",
|
||||
"Prompts": "提示词",
|
||||
"Read the docs": "查看文档",
|
||||
"Response Cache": "响应缓存",
|
||||
"Retry": "重试",
|
||||
"Role": "角色",
|
||||
"Route every request to the cheapest model that can handle it, no prompt changes needed.": "自动将每个请求分配给能够处理它的最低成本模型,无需修改提示词。",
|
||||
"Router Settings": "路由设置",
|
||||
"SETTINGS": "设置",
|
||||
"Search Tools": "搜索工具",
|
||||
"Settings": "设置",
|
||||
"Show password": "显示密码",
|
||||
"Skills": "技能",
|
||||
"Standard": "标准版",
|
||||
"Switch to dark mode (beta)": "切换到深色模式(测试版)",
|
||||
"Switch to light mode": "切换到浅色模式",
|
||||
"Tag Management": "标签管理",
|
||||
"Teams": "团队",
|
||||
"Thanks for using LiteLLM!": "感谢使用 LiteLLM!",
|
||||
"The Admin UI has been disabled by the administrator. To re-enable it, please update the following environment variable:": "管理员已禁用管理界面。如需重新启用,请修改以下环境变量:",
|
||||
"Tier": "套餐",
|
||||
"Toggle hide all prompts": "切换所有提示显示",
|
||||
"Toggle hide blog posts": "切换博客文章显示",
|
||||
"Toggle hide bouncing icon": "切换跳动图标显示",
|
||||
"Toggle hide new feature indicators": "切换新功能提示显示",
|
||||
"Tool Policies": "工具策略",
|
||||
"Tools": "工具",
|
||||
"UI Theme": "界面主题",
|
||||
"Unknown role": "未知角色",
|
||||
"Upgrade to Premium for advanced features": "升级到高级版以使用更多功能",
|
||||
"Usage": "用量统计",
|
||||
"User ID": "用户 ID",
|
||||
"Username": "用户名",
|
||||
"Vector Stores": "向量存储",
|
||||
"View all posts": "查看全部文章",
|
||||
"Virtual Keys": "虚拟密钥",
|
||||
"Worker": "工作节点",
|
||||
"Workflow Runs": "工作流运行",
|
||||
"accountMenu": "账户菜单:{{role}},当前登录为 {{identity}}",
|
||||
"loading": "加载中",
|
||||
"login.credentialsHelp": "需要设置管理界面登录凭据或单点登录?<docs>查看文档</docs>。",
|
||||
"login.defaultCredentials": "默认用户名为 <code>admin</code>,密码为你设置的 LiteLLM 网关 <code>MASTER_KEY</code>。",
|
||||
"login.ssoNotice": "已启用单点登录(SSO)。打开此页面时,LiteLLM 不再自动跳转到单点登录流程。若要恢复自动跳转,请在环境配置中设置 <code>AUTO_REDIRECT_UI_LOGIN_TO_SSO=true</code>。",
|
||||
"unknown": "未知"
|
||||
}
|
||||
12
ui/litellm-dashboard/src/i18n/useTranslation.ts
Normal file
12
ui/litellm-dashboard/src/i18n/useTranslation.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { I18nContext, useTranslation as useReactTranslation } from "react-i18next";
|
||||
import { createDashboardI18n } from ".";
|
||||
|
||||
const englishFallback = createDashboardI18n();
|
||||
|
||||
export function useTranslation() {
|
||||
const context = useContext(I18nContext);
|
||||
return useReactTranslation(undefined, { i18n: context?.i18n ?? englishFallback });
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue