mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
fix(ui): honor dark and local logos across Connect and Chat (#48532)
This commit is contained in:
parent
ebe0871a28
commit
53a4cee0d8
12 changed files with 267 additions and 67 deletions
|
|
@ -234,6 +234,9 @@ class DefaultTeamSettingsResponse(SettingsResponse):
|
|||
class UIThemeSettingsResponse(SettingsResponse):
|
||||
"""Response model for UI theme settings"""
|
||||
|
||||
logo_url_via_proxy: bool = False
|
||||
logo_url_dark_via_proxy: bool = False
|
||||
|
||||
|
||||
_TEAM_ADMIN_FIELD_ENUM: Final = tuple(sorted(SUPPORTED_TEAM_ADMIN_PERMISSIONS))
|
||||
|
||||
|
|
@ -1377,7 +1380,7 @@ async def update_sso_settings(
|
|||
tags=["UI Theme Settings"],
|
||||
response_model=UIThemeSettingsResponse,
|
||||
)
|
||||
async def get_ui_theme_settings():
|
||||
async def get_ui_theme_settings() -> UIThemeSettingsResponse:
|
||||
"""
|
||||
Get UI theme configuration from the litellm_settings.
|
||||
Returns current logo settings for UI customization.
|
||||
|
|
@ -1390,18 +1393,23 @@ async def get_ui_theme_settings():
|
|||
# Load existing config
|
||||
config: Final = await proxy_config.get_config()
|
||||
|
||||
result: Final = await _get_settings_with_schema(
|
||||
settings_key="ui_theme_config",
|
||||
settings_class=UIThemeConfig,
|
||||
config=config,
|
||||
result: Final = _SettingsWithSchema.model_validate(
|
||||
await _get_settings_with_schema(
|
||||
settings_key="ui_theme_config",
|
||||
settings_class=UIThemeConfig,
|
||||
config=config,
|
||||
)
|
||||
)
|
||||
|
||||
stored_values: Final = result.get("values", {})
|
||||
result["values"] = {
|
||||
**stored_values,
|
||||
**{field: _resolve_ui_theme_field(stored_values, field) for field in _UI_THEME_FIELD_ENV_VARS},
|
||||
values: Final = {
|
||||
**result.values,
|
||||
**{field: _resolve_ui_theme_field(result.values, field) for field in _UI_THEME_FIELD_ENV_VARS},
|
||||
}
|
||||
return result
|
||||
return UIThemeSettingsResponse(
|
||||
values=values,
|
||||
field_schema=result.field_schema,
|
||||
logo_url_via_proxy=values["logo_url"] is None and bool(os.getenv("UI_LOGO_PATH", "").strip()),
|
||||
logo_url_dark_via_proxy=values["logo_url_dark"] is None and bool(os.getenv("UI_LOGO_PATH_DARK", "").strip()),
|
||||
)
|
||||
|
||||
|
||||
def _validate_public_image_url(value: str | None, field_name: str) -> None:
|
||||
|
|
|
|||
|
|
@ -1359,35 +1359,45 @@ class TestProxySettingEndpoints:
|
|||
assert values["logo_url"] == "https://cdn.example.com/logo.png"
|
||||
assert values["favicon_url"] == "https://cdn.example.com/favicon.ico"
|
||||
|
||||
@pytest.mark.parametrize("env_logo", [
|
||||
"https://env.example.com/logo.png", "/mnt/secret/internal/logo.png",
|
||||
])
|
||||
def test_get_ui_theme_settings_stored_value_wins_over_env(
|
||||
self, mock_auth, monkeypatch
|
||||
):
|
||||
self, monkeypatch: pytest.MonkeyPatch, env_logo: str,
|
||||
) -> None:
|
||||
"""A stored ui_theme_config field outranks the env var for that field.
|
||||
|
||||
The env fallback only fills fields the stored config leaves blank, so the
|
||||
UI-driven flow is unchanged while an unstored field still resolves.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import proxy_config
|
||||
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import UIThemeSettingsResponse
|
||||
|
||||
stored_config = {
|
||||
stored_config: Final[dict[str, object]] = {
|
||||
"litellm_settings": {
|
||||
"ui_theme_config": {"logo_url": "https://db.example.com/logo.png"}
|
||||
"ui_theme_config": {
|
||||
"logo_url": "https://db.example.com/logo.png",
|
||||
"logo_url_dark": "https://db.example.com/logo-dark.png",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async def mock_get_config():
|
||||
async def mock_get_config() -> dict[str, object]:
|
||||
return stored_config
|
||||
|
||||
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
|
||||
monkeypatch.setenv("UI_LOGO_PATH", "https://env.example.com/logo.png")
|
||||
monkeypatch.setenv("UI_LOGO_PATH", env_logo)
|
||||
monkeypatch.setenv("UI_LOGO_PATH_DARK", env_logo)
|
||||
monkeypatch.setenv("LITELLM_FAVICON_URL", "https://env.example.com/favicon.ico")
|
||||
|
||||
response = client.get("/get/ui_theme_settings")
|
||||
response: Final = client.get("/get/ui_theme_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
values = response.json()["values"]
|
||||
assert values["logo_url"] == "https://db.example.com/logo.png"
|
||||
assert values["favicon_url"] == "https://env.example.com/favicon.ico"
|
||||
result: Final = UIThemeSettingsResponse.model_validate(response.json())
|
||||
assert result.values["logo_url"] == "https://db.example.com/logo.png"
|
||||
assert result.values["logo_url_dark"] == "https://db.example.com/logo-dark.png"
|
||||
assert result.values["favicon_url"] == "https://env.example.com/favicon.ico"
|
||||
assert (result.logo_url_via_proxy, result.logo_url_dark_via_proxy) == (False, False)
|
||||
|
||||
def test_get_ui_theme_settings_reports_unset_when_absent_everywhere(
|
||||
self, mock_proxy_config, monkeypatch
|
||||
|
|
@ -1403,22 +1413,45 @@ class TestProxySettingEndpoints:
|
|||
assert values["logo_url"] is None
|
||||
assert values["favicon_url"] is None
|
||||
|
||||
def test_get_ui_theme_settings_does_not_disclose_local_path_env_value(
|
||||
self, mock_proxy_config, monkeypatch
|
||||
):
|
||||
"""This endpoint is public, so an env-configured local filesystem branding
|
||||
path must never be surfaced to anonymous callers; only public http(s) URLs.
|
||||
"""
|
||||
monkeypatch.setenv("UI_LOGO_PATH", "/mnt/secret/internal/logo.png")
|
||||
@pytest.mark.usefixtures("mock_proxy_config")
|
||||
@pytest.mark.parametrize("light,public_light,light_via_proxy", [
|
||||
(None, None, False),
|
||||
(" ", None, False),
|
||||
("https://cdn.example.com/light.png", "https://cdn.example.com/light.png", False),
|
||||
("/mnt/secret/internal/light.png", None, True),
|
||||
])
|
||||
@pytest.mark.parametrize("dark,public_dark,dark_via_proxy", [
|
||||
(None, None, False),
|
||||
(" ", None, False),
|
||||
("https://cdn.example.com/dark.png", "https://cdn.example.com/dark.png", False),
|
||||
("/mnt/secret/internal/dark.png", None, True),
|
||||
])
|
||||
def test_get_ui_theme_settings_separates_private_logo_rendering_from_settings(
|
||||
self, monkeypatch: pytest.MonkeyPatch,
|
||||
light: str | None, public_light: str | None, light_via_proxy: bool,
|
||||
dark: str | None, public_dark: str | None, dark_via_proxy: bool,
|
||||
) -> None:
|
||||
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import UIThemeSettingsResponse
|
||||
|
||||
for key, value in (("UI_LOGO_PATH", light), ("UI_LOGO_PATH_DARK", dark)):
|
||||
if value is None:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
else:
|
||||
monkeypatch.setenv(key, value)
|
||||
monkeypatch.setenv("LITELLM_FAVICON_URL", "file:///etc/favicon.ico")
|
||||
|
||||
response = client.get("/get/ui_theme_settings")
|
||||
response: Final = client.get("/get/ui_theme_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
values = response.json()["values"]
|
||||
# the local path / file scheme is withheld rather than disclosed
|
||||
assert values["logo_url"] is None
|
||||
assert values["favicon_url"] is None
|
||||
result: Final = UIThemeSettingsResponse.model_validate(response.json())
|
||||
assert (result.values["logo_url"], result.values["logo_url_dark"]) == (public_light, public_dark)
|
||||
assert (result.logo_url_via_proxy, result.logo_url_dark_via_proxy) == (light_via_proxy, dark_via_proxy)
|
||||
assert result.values["favicon_url"] is None
|
||||
assert "/mnt/secret/internal/" not in response.text
|
||||
assert "file:///etc/favicon.ico" not in response.text
|
||||
for hint in ("logo_url_via_proxy", "logo_url_dark_via_proxy"):
|
||||
assert hint not in result.values
|
||||
assert hint not in json.dumps(result.field_schema)
|
||||
|
||||
def test_get_ui_settings(self, mock_auth, monkeypatch):
|
||||
"""Test retrieving UI settings with allowlist sanitization"""
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ const setLogoUrl = vi.fn();
|
|||
const setLogoUrlDark = vi.fn();
|
||||
const setFaviconUrl = vi.fn();
|
||||
|
||||
vi.mock("@/contexts/ThemeContext", () => ({
|
||||
vi.mock("@/contexts/ThemeContext", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/contexts/ThemeContext")>()),
|
||||
useTheme: () => ({
|
||||
logoUrl: null,
|
||||
setLogoUrl,
|
||||
|
|
@ -22,7 +23,7 @@ vi.mock("@/contexts/ThemeContext", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getProxyBaseUrl: () => "",
|
||||
getProxyBaseUrl: () => "/gateway",
|
||||
getGlobalLitellmHeaderName: () => "Authorization",
|
||||
}));
|
||||
|
||||
|
|
@ -111,6 +112,34 @@ describe("UIThemeSettings", () => {
|
|||
expect(bodyOf(patchCalls()[0]).logo_url_dark).toBe("https://a.test/logo-dark.png");
|
||||
});
|
||||
|
||||
it.each(["Save Changes", "Reset to Default"])(
|
||||
"should keep local logo routes out of settings and clear preview overrides with %s",
|
||||
async (action) => {
|
||||
const user = userEvent.setup();
|
||||
fetchMock.mockImplementation(async () =>
|
||||
Response.json({
|
||||
values: { logo_url: null, logo_url_dark: null },
|
||||
logo_url_via_proxy: true,
|
||||
logo_url_dark_via_proxy: true,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
|
||||
|
||||
await waitFor(() => expect(setLogoUrlDark).toHaveBeenCalledWith("/gateway/get_image?theme=dark"));
|
||||
expect(setLogoUrl).toHaveBeenCalledWith("/gateway/get_image");
|
||||
expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("");
|
||||
expect(screen.getByPlaceholderText(LOGO_DARK_PLACEHOLDER)).toHaveValue("");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: action }));
|
||||
await waitFor(() => expect(patchCalls()).toHaveLength(1));
|
||||
expect(bodyOf(patchCalls()[0])).toEqual({ logo_url: null, logo_url_dark: null, favicon_url: null });
|
||||
|
||||
expect(setLogoUrl).toHaveBeenLastCalledWith(null);
|
||||
expect(setLogoUrlDark).toHaveBeenLastCalledWith(null);
|
||||
},
|
||||
);
|
||||
|
||||
it("should surface a backend failure when saving fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { resolveThemeImages, useTheme } from "@/contexts/ThemeContext";
|
||||
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
|
|
@ -40,12 +40,13 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
|
|||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const images = resolveThemeImages(data, proxyBaseUrl);
|
||||
setLogoUrlInput(data.values?.logo_url || "");
|
||||
setLogoUrlDarkInput(data.values?.logo_url_dark || "");
|
||||
setFaviconUrlInput(data.values?.favicon_url || "");
|
||||
setLogoUrl(data.values?.logo_url || null);
|
||||
setLogoUrlDark(data.values?.logo_url_dark || null);
|
||||
setFaviconUrl(data.values?.favicon_url || null);
|
||||
setLogoUrl(images.logoUrl);
|
||||
setLogoUrlDark(images.logoUrlDark);
|
||||
setFaviconUrl(images.faviconUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching theme settings:", error);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, screen, testQueryClient } from "@/../tests/test-utils";
|
||||
import { renderWithProviders, screen, testQueryClient, within } from "@/../tests/test-utils";
|
||||
import { setServerRootPath } from "@/lib/serverRootPath";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import ChatLayout from "./layout";
|
||||
|
||||
const { router, route } = vi.hoisted(() => ({
|
||||
|
|
@ -33,9 +34,13 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
|||
const plugin = { name: "observability", display_name: "Observability", url: "https://plugin.example.com" };
|
||||
const originalLocation = Object.getOwnPropertyDescriptor(window, "location")!;
|
||||
const assign = vi.fn();
|
||||
const LIGHT_LOGO = "https://cdn.test/light.svg";
|
||||
const DARK_LOGO = "https://cdn.test/dark.svg";
|
||||
let themeResponse: unknown = { values: {} };
|
||||
|
||||
describe("Chat layout product navigation", () => {
|
||||
beforeEach(() => {
|
||||
themeResponse = { values: {} };
|
||||
route.pathname = "/ui/chat";
|
||||
localStorage.clear();
|
||||
localStorage.setItem("litellm_plugin_mode", plugin.name);
|
||||
|
|
@ -55,7 +60,7 @@ describe("Chat layout product navigation", () => {
|
|||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer chat-layout-test-token");
|
||||
return Response.json([plugin]);
|
||||
case "/get/ui_theme_settings":
|
||||
return Response.json({ values: {} });
|
||||
return Response.json(themeResponse);
|
||||
case "/sso/get/ui_settings":
|
||||
return Response.json({});
|
||||
default:
|
||||
|
|
@ -75,6 +80,55 @@ describe("Chat layout product navigation", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["public", [LIGHT_LOGO, DARK_LOGO], [false, false], [LIGHT_LOGO, DARK_LOGO]],
|
||||
["local", [null, null], [true, true], ["/get_image", "/get_image?theme=dark"]],
|
||||
["public light and local dark", [LIGHT_LOGO, null], [false, true], [LIGHT_LOGO, "/get_image?theme=dark"]],
|
||||
["local light and public dark", [null, DARK_LOGO], [true, false], ["/get_image", DARK_LOGO]],
|
||||
["public light only", [LIGHT_LOGO, null], [false, false], [LIGHT_LOGO, LIGHT_LOGO]],
|
||||
["public dark only", [null, DARK_LOGO], [false, false], [DARK_LOGO, DARK_LOGO]],
|
||||
["local light only", [null, null], [true, false], ["/get_image", "/get_image"]],
|
||||
["local dark only", [null, null], [false, true], ["/get_image?theme=dark", "/get_image?theme=dark"]],
|
||||
] as const)(
|
||||
"should render %s organization logos from the theme response",
|
||||
async (
|
||||
_label,
|
||||
[logo_url, logo_url_dark],
|
||||
[logo_url_via_proxy, logo_url_dark_via_proxy],
|
||||
[lightSource, darkSource],
|
||||
) => {
|
||||
themeResponse = { values: { logo_url, logo_url_dark }, logo_url_via_proxy, logo_url_dark_via_proxy };
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ChatLayout>
|
||||
<h1>Chat content</h1>
|
||||
</ChatLayout>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Open chat navigation" }));
|
||||
const logo = await screen.findByRole("img", { name: "Organization logo" });
|
||||
const [light, dark] = within(logo).getAllByAltText("");
|
||||
const source = (path: string) => (path.startsWith("/") ? `${getProxyBaseUrl()}${path}` : path);
|
||||
expect(light).toHaveAttribute("src", source(lightSource));
|
||||
expect(dark).toHaveAttribute("src", source(darkSource));
|
||||
expect(light).toHaveClass("dark:hidden");
|
||||
expect(dark).toHaveClass("hidden", "dark:block");
|
||||
},
|
||||
);
|
||||
|
||||
it("should omit organization branding when no logo is configured", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ChatLayout>
|
||||
<h1>Chat content</h1>
|
||||
</ChatLayout>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Open chat navigation" }));
|
||||
expect(await screen.findByRole("dialog", { name: "LiteLLM Chat" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img", { name: "Organization logo" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ pathname: "/ui/chat", root: "/", gateway: "/ui/" },
|
||||
{ pathname: "/gateway/ui/chat/models/", root: "/gateway", gateway: "/gateway/ui/" },
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders as render } from "@/../tests/test-utils";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import type { ReactElement } from "react";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import ChatShell from "./ChatShell";
|
||||
|
||||
const render = (ui: ReactElement) => renderWithProviders(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
const { mockPush, mockUsePathname, mockUseChatShell, mockUseMediaQuery } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockUsePathname: vi.fn(() => "/ui/chat"),
|
||||
|
|
@ -34,7 +38,15 @@ vi.mock("usehooks-ts", () => ({ useMediaQuery: mockUseMediaQuery }));
|
|||
vi.mock("./ConversationList", () => ({ default: () => <div data-testid="conversation-list" /> }));
|
||||
|
||||
describe("ChatShell", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Response.json({ values: {} })),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
mockPush.mockClear();
|
||||
mockUsePathname.mockReturnValue("/ui/chat");
|
||||
mockUseMediaQuery.mockReturnValue(true);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
Plus,
|
||||
|
|
@ -34,6 +35,7 @@ import ThemeToggle from "@/components/ThemeToggle/ThemeToggle";
|
|||
import { useLogout } from "@/app/(dashboard)/hooks/useLogout";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { useChatShell } from "@/contexts/ChatShellContext";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
|
||||
import ConversationList from "./ConversationList";
|
||||
|
||||
|
|
@ -94,6 +96,9 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
if (isDesktop && sidebarOpen) setSidebarOpen(false);
|
||||
const collapsed = isDesktop && sidebarCollapsed;
|
||||
const { logoUrl, logoUrlDark } = useTheme();
|
||||
const lightLogo = logoUrl || logoUrlDark;
|
||||
const darkLogo = logoUrlDark || logoUrl;
|
||||
const routes = getChatRoutes();
|
||||
const isChatsRoute = pathname === routes.chats;
|
||||
const accountName = userEmail?.split("@")[0] || userId || "Account";
|
||||
|
|
@ -216,6 +221,26 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||
hideSearchButton
|
||||
/>
|
||||
</div>
|
||||
{lightLogo && darkLogo && (
|
||||
<div className="flex h-12 shrink-0 items-center px-1 pb-2" role="img" aria-label="Organization logo">
|
||||
<Image
|
||||
src={lightLogo}
|
||||
alt=""
|
||||
width={160}
|
||||
height={32}
|
||||
unoptimized
|
||||
className="h-8 max-w-full object-contain object-left dark:hidden"
|
||||
/>
|
||||
<Image
|
||||
src={darkLogo}
|
||||
alt=""
|
||||
width={160}
|
||||
height={32}
|
||||
unoptimized
|
||||
className="hidden h-8 max-w-full object-contain object-left dark:block"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<footer className="moyai-chat-account">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px]" title={userEmail || userId}>
|
||||
{accountName}
|
||||
|
|
|
|||
|
|
@ -182,6 +182,8 @@ At 600px and below, a compact header keeps the workspace switcher beside the lab
|
|||
|
||||
The landing page reuses Moyai's left-aligned 720px home content, dot/grid backdrop, 46px heading, 132px editor and compact starter rows. `ChatModelPicker` sits at the right of the composer action row, matching Moyai's model selector position. Selecting a model chooses chat or image generation. Preserve LiteLLM's existing model access and request behavior; do not copy Moyai product or backend logic.
|
||||
|
||||
Configured organization branding appears above the account row in the expanded sidebar and mobile navigation. Light and dark variants fall back to the available image when only one is configured. Private filesystem images use the proxy image endpoint without exposing their paths. With no configured branding, the logo row is absent. The existing collapsed rail hides it along with the account row.
|
||||
|
||||
### ScrollArea
|
||||
|
||||
**Known gotcha — cost real debugging time:** a `ScrollArea` won't actually scroll if its container is sized with `max-height` instead of `height`, even though the box _looks_ correctly capped. Radix's `ScrollArea` viewport is `size-full` (`height: 100%`), and CSS percentage-height resolution requires the ancestor chain to have a genuinely _definite_ height — `max-height` on a flex container doesn't count as definite for this purpose, even though the browser renders the box at the capped size. The result: the viewport silently grows to its full content height instead of 100% of the visible box, `scrollHeight === clientHeight` is reported (no overflow, so no scrollbar), and scroll events fall through to whatever's behind the popover/panel instead of scrolling the list.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, screen, waitFor } from "../../tests/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, screen, waitFor, within } from "../../tests/test-utils";
|
||||
import Navbar from "./navbar";
|
||||
|
||||
// Mock the hooks and utilities
|
||||
|
|
@ -90,7 +90,7 @@ vi.mock("./Navbar/CommunityEngagementButtons/CommunityEngagementButtons", () =>
|
|||
}));
|
||||
|
||||
// Create mock functions that can be controlled in tests
|
||||
let mockUseThemeImpl = () => ({ logoUrl: null as string | null });
|
||||
let mockUseThemeImpl = (): { logoUrl: string | null; logoUrlDark?: string | null } => ({ logoUrl: null });
|
||||
let mockUseHealthReadinessDetailsImpl = () => ({ data: null as any });
|
||||
let mockGetLocalStorageItemImpl = (key: string) => null as string | null;
|
||||
const mockUseAuthorizedImpl = () => ({
|
||||
|
|
@ -136,6 +136,10 @@ Object.defineProperty(window, "location", {
|
|||
});
|
||||
|
||||
describe("Navbar", () => {
|
||||
afterEach(() => {
|
||||
mockUseThemeImpl = () => ({ logoUrl: null });
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
accessToken: "test-token",
|
||||
isPublicPage: false,
|
||||
|
|
@ -250,17 +254,21 @@ describe("Navbar", () => {
|
|||
expect(useHealthReadinessDetailsSpy).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("should use custom logo from theme context", () => {
|
||||
mockUseThemeImpl = () => ({ logoUrl: "https://example.com/custom-logo.png" });
|
||||
it.each(["https://example.com/dark-logo.png", null])(
|
||||
"should use the configured dark logo %s with the light logo as fallback",
|
||||
(logoUrlDark) => {
|
||||
const logoUrl = "https://example.com/custom-logo.png";
|
||||
mockUseThemeImpl = () => ({ logoUrl, logoUrlDark });
|
||||
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
|
||||
const logoImg = screen.getByAltText("LiteLLM Brand");
|
||||
expect(logoImg).toHaveAttribute("src", "https://example.com/custom-logo.png");
|
||||
|
||||
// Reset mock
|
||||
mockUseThemeImpl = () => ({ logoUrl: null });
|
||||
});
|
||||
const brand = within(screen.getByRole("link", { name: /litellm brand/i }));
|
||||
const light = brand.getByAltText("LiteLLM Brand");
|
||||
const dark = brand.getByAltText("");
|
||||
expect(light).toHaveAttribute("src", logoUrl);
|
||||
expect(dark).toHaveAttribute("src", logoUrlDark ?? logoUrl);
|
||||
},
|
||||
);
|
||||
|
||||
it("should hide user dropdown and notifications on public pages", () => {
|
||||
const publicPageProps = { ...defaultProps, isPublicPage: true };
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
deploymentHealth,
|
||||
}) => {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const { logoUrl } = useTheme();
|
||||
const { logoUrl, logoUrlDark } = useTheme();
|
||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
const version = healthData?.litellm_version;
|
||||
const disableBouncingIcon = useDisableBouncingIcon();
|
||||
|
|
@ -48,7 +48,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
|
||||
|
||||
const imageUrl = logoUrl || `${baseUrl}/get_image`;
|
||||
const darkImageUrl = logoUrl || `${baseUrl}/get_image?theme=dark`;
|
||||
const darkImageUrl = logoUrlDark || logoUrl || `${baseUrl}/get_image?theme=dark`;
|
||||
|
||||
const handleLogout = useLogout(accessToken);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { z } from "zod";
|
||||
|
||||
interface ThemeContextType {
|
||||
logoUrl: string | null;
|
||||
|
|
@ -10,6 +11,29 @@ interface ThemeContextType {
|
|||
setFaviconUrl: (url: string | null) => void;
|
||||
}
|
||||
|
||||
const themeImagesSchema = z.object({
|
||||
values: z.object({
|
||||
logo_url: z.string().nullish(),
|
||||
logo_url_dark: z.string().nullish(),
|
||||
favicon_url: z.string().nullish(),
|
||||
}),
|
||||
logo_url_via_proxy: z.boolean().optional(),
|
||||
logo_url_dark_via_proxy: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export function resolveThemeImages(
|
||||
data: unknown,
|
||||
proxyBaseUrl: string | null,
|
||||
): Pick<ThemeContextType, "logoUrl" | "logoUrlDark" | "faviconUrl"> {
|
||||
const settings = themeImagesSchema.parse(data);
|
||||
const imageUrl = `${proxyBaseUrl ?? ""}/get_image`;
|
||||
return {
|
||||
logoUrl: settings.logo_url_via_proxy ? imageUrl : settings.values.logo_url || null,
|
||||
logoUrlDark: settings.logo_url_dark_via_proxy ? `${imageUrl}?theme=dark` : settings.values.logo_url_dark || null,
|
||||
faviconUrl: settings.values.favicon_url || null,
|
||||
};
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const useTheme = () => {
|
||||
|
|
@ -41,16 +65,10 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, accessTo
|
|||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.values?.logo_url) {
|
||||
setLogoUrl(data.values.logo_url);
|
||||
}
|
||||
if (data.values?.logo_url_dark) {
|
||||
setLogoUrlDark(data.values.logo_url_dark);
|
||||
}
|
||||
if (data.values?.favicon_url) {
|
||||
setFaviconUrl(data.values.favicon_url);
|
||||
}
|
||||
const images = resolveThemeImages(await response.json(), proxyBaseUrl);
|
||||
setLogoUrl(images.logoUrl);
|
||||
setLogoUrlDark(images.logoUrlDark);
|
||||
setFaviconUrl(images.faviconUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load theme settings from backend:", error);
|
||||
|
|
|
|||
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -48094,6 +48094,16 @@ export interface components {
|
|||
field_schema: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* Logo Url Dark Via Proxy
|
||||
* @default false
|
||||
*/
|
||||
logo_url_dark_via_proxy: boolean;
|
||||
/**
|
||||
* Logo Url Via Proxy
|
||||
* @default false
|
||||
*/
|
||||
logo_url_via_proxy: boolean;
|
||||
/** Values */
|
||||
values: {
|
||||
[key: string]: unknown;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue