mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-23 00:41:40 +00:00
feat(dashboard): refine navbar zones and Agent Platform notice
Restructure the admin navbar for production users: clear product vs community vs personal columns with vertical dividers, icon-only Slack/GitHub in a shared chip, and Docs/Blog typography aligned on an 8px rhythm. Add a notifications bell with popover linking to the LiteLLM Agent Platform repo and optional mark-as-read persistence. Promote the account control with initials avatar, single-line display name, and navDisplayName mapping for placeholder user ids (e.g. default_user_id). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8c9830eef9
commit
37645756c7
11 changed files with 360 additions and 120 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
|
||||
import { DownOutlined, LoadingOutlined } from "@ant-design/icons";
|
||||
import { Button, Dropdown, Space, Typography } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import React from "react";
|
||||
|
|
@ -74,9 +75,13 @@ export const BlogDropdown: React.FC = () => {
|
|||
];
|
||||
}
|
||||
|
||||
// Blog opens a post list; Docs is a single outbound link — navbar adds a layout-only chevron there for alignment.
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={["hover"]} placement="bottomRight">
|
||||
<Button type="text">Blog</Button>
|
||||
<button type="button" className={`${NAV_PRODUCT_LINK_CLASS} cursor-pointer border-0 bg-transparent`}>
|
||||
Blog
|
||||
<DownOutlined className="text-[10px] text-gray-500" aria-hidden />
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,36 +1,45 @@
|
|||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import { GithubOutlined, SlackOutlined } from "@ant-design/icons";
|
||||
import { Button } from "antd";
|
||||
import { Tooltip } from "antd";
|
||||
import React from "react";
|
||||
|
||||
const iconBtnClass =
|
||||
"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border-0 bg-transparent text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 cursor-pointer";
|
||||
|
||||
export const CommunityEngagementButtons: React.FC = () => {
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
|
||||
// Hide buttons if prompts are disabled
|
||||
if (disableShowPrompts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
href="https://www.litellm.ai/support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon={<SlackOutlined />}
|
||||
className="shadow-md shadow-indigo-500/20 hover:shadow-indigo-500/50 transition-shadow"
|
||||
>
|
||||
Join Slack
|
||||
</Button>
|
||||
<Button
|
||||
href="https://github.com/BerriAI/litellm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shadow-md shadow-indigo-500/20 hover:shadow-indigo-500/50 transition-shadow"
|
||||
icon={<GithubOutlined />}
|
||||
>
|
||||
Star us on GitHub
|
||||
</Button>
|
||||
</>
|
||||
<div
|
||||
className="flex items-center gap-0.5 rounded-md border border-gray-200/80 bg-gray-50 px-0.5 py-0"
|
||||
aria-label="Community links"
|
||||
>
|
||||
<Tooltip title="LiteLLM Slack community">
|
||||
<a
|
||||
href="https://www.litellm.ai/support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={iconBtnClass}
|
||||
aria-label="Join Slack"
|
||||
>
|
||||
<SlackOutlined className="text-lg" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Tooltip title="LiteLLM on GitHub">
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={iconBtnClass}
|
||||
aria-label="LiteLLM on GitHub"
|
||||
>
|
||||
<GithubOutlined className="text-lg" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import { renderWithProviders, screen } from "../../../../tests/test-utils";
|
||||
import { NotificationsBell, AGENT_PLATFORM_URL } from "./NotificationsBell";
|
||||
import React from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
describe("NotificationsBell", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("should open notifications with Agent Platform details and GitHub link", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<NotificationsBell />);
|
||||
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
|
||||
expect(screen.getByText(/LiteLLM Agent Platform/i)).toBeInTheDocument();
|
||||
const githubBtn = screen.getByRole("link", { name: /^GitHub$/i });
|
||||
expect(githubBtn).toHaveAttribute("href", AGENT_PLATFORM_URL);
|
||||
expect(githubBtn).toHaveAttribute("target", "_blank");
|
||||
expect(githubBtn).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should offer mark as read when announcement is unread", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<NotificationsBell />);
|
||||
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
|
||||
expect(screen.getByRole("button", { name: /^mark as read$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide mark as read and persist after marking read", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<NotificationsBell />);
|
||||
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
|
||||
await user.click(screen.getByRole("button", { name: /^mark as read$/i }));
|
||||
expect(localStorage.getItem("litellmHideAgentPlatformBanner")).toBe("true");
|
||||
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
|
||||
expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show mark as read when previously dismissed", async () => {
|
||||
localStorage.setItem("litellmHideAgentPlatformBanner", "true");
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<NotificationsBell />);
|
||||
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
|
||||
expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import { BellOutlined } from "@ant-design/icons";
|
||||
import { Badge, Button, Popover, Typography } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "litellmHideAgentPlatformBanner";
|
||||
export const AGENT_PLATFORM_URL = "https://github.com/BerriAI/litellm-agent-platform";
|
||||
|
||||
export const NotificationsBell: React.FC = () => {
|
||||
const [hasUnread, setHasUnread] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setHasUnread(localStorage.getItem(STORAGE_KEY) !== "true");
|
||||
} catch {
|
||||
setHasUnread(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markDismissed = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setHasUnread(false);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className="max-w-[280px]">
|
||||
<Typography.Title level={5} className="!mt-0 !mb-2">
|
||||
LiteLLM Agent Platform
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" className="!mb-3 text-sm leading-snug">
|
||||
Open-source agent infra — sandboxes, durable sessions, and workers on AWS Fargate.
|
||||
</Typography.Paragraph>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="primary" size="small" href={AGENT_PLATFORM_URL} target="_blank" rel="noopener noreferrer">
|
||||
GitHub
|
||||
</Button>
|
||||
{hasUnread ? (
|
||||
<Button type="link" size="small" className="!px-1" onClick={markDismissed}>
|
||||
Mark as read
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} trigger="click" open={open} onOpenChange={setOpen} placement="bottomRight">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Badge dot={hasUnread} color="#1677ff" size="small" offset={[8, 2]}>
|
||||
<BellOutlined className="text-base" aria-hidden />
|
||||
</Badge>
|
||||
</button>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
@ -37,6 +37,8 @@ vi.mock("@/utils/localStorageUtils", () => ({
|
|||
describe("UserDropdown", () => {
|
||||
const mockOnLogout = vi.fn();
|
||||
|
||||
const getAccountTrigger = () => screen.getByRole("button", { name: /account menu/i });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseAuthorizedImpl = () => ({
|
||||
|
|
@ -55,22 +57,23 @@ describe("UserDropdown", () => {
|
|||
|
||||
it("should render", () => {
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
expect(getAccountTrigger()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display user button with User text", () => {
|
||||
it("should surface initials and account menu affordance", () => {
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(getAccountTrigger()).toBeInTheDocument();
|
||||
expect(screen.getByText("TE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show user email when dropdown is opened", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -78,7 +81,7 @@ describe("UserDropdown", () => {
|
|||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test-user-id")).toBeInTheDocument();
|
||||
|
|
@ -89,10 +92,10 @@ describe("UserDropdown", () => {
|
|||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Admin")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Admin").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -100,7 +103,7 @@ describe("UserDropdown", () => {
|
|||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Standard")).toBeInTheDocument();
|
||||
|
|
@ -118,7 +121,7 @@ describe("UserDropdown", () => {
|
|||
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Premium")).toBeInTheDocument();
|
||||
|
|
@ -129,10 +132,10 @@ describe("UserDropdown", () => {
|
|||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Logout"));
|
||||
|
|
@ -144,10 +147,10 @@ describe("UserDropdown", () => {
|
|||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const toggle = screen.getByLabelText("Toggle hide new feature indicators");
|
||||
|
|
@ -169,10 +172,10 @@ describe("UserDropdown", () => {
|
|||
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const toggle = screen.getByLabelText("Toggle hide new feature indicators");
|
||||
|
|
@ -189,10 +192,10 @@ describe("UserDropdown", () => {
|
|||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const toggle = screen.getByLabelText("Toggle hide all prompts");
|
||||
|
|
@ -215,10 +218,10 @@ describe("UserDropdown", () => {
|
|||
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const toggle = screen.getByLabelText("Toggle hide all prompts");
|
||||
|
|
@ -231,6 +234,17 @@ describe("UserDropdown", () => {
|
|||
expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowPrompts");
|
||||
});
|
||||
|
||||
it("should show Account in the trigger when user id is the default placeholder", () => {
|
||||
mockUseAuthorizedImpl = () => ({
|
||||
userId: "default_user_id",
|
||||
userEmail: null as any,
|
||||
userRole: "Admin",
|
||||
premiumUser: false,
|
||||
});
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
expect(screen.getByText("Account")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display dash when user email is not available", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseAuthorizedImpl = () => ({
|
||||
|
|
@ -242,7 +256,7 @@ describe("UserDropdown", () => {
|
|||
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
|
|
@ -260,7 +274,7 @@ describe("UserDropdown", () => {
|
|||
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
const dashElements = screen.getAllByText("-");
|
||||
|
|
@ -277,10 +291,10 @@ describe("UserDropdown", () => {
|
|||
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const toggle = screen.getByLabelText("Toggle hide new feature indicators");
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
removeLocalStorageItem,
|
||||
setLocalStorageItem,
|
||||
} from "@/utils/localStorageUtils";
|
||||
import { navAccountDisplayName } from "@/components/Navbar/navDisplayName";
|
||||
import {
|
||||
CrownOutlined,
|
||||
DownOutlined,
|
||||
|
|
@ -18,11 +19,44 @@ import {
|
|||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd";
|
||||
import { Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function hueFromString(seed: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i += 1) {
|
||||
h = seed.charCodeAt(i) + ((h << 5) - h);
|
||||
}
|
||||
return Math.abs(h) % 360;
|
||||
}
|
||||
|
||||
function initialsFromIdentity(email: string | null, userId: string | null): string {
|
||||
const local = email?.split("@")[0]?.trim();
|
||||
if (local) {
|
||||
const parts = local
|
||||
.replace(/[^a-zA-Z0-9]+/g, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]!.charAt(0)}${parts[1]!.charAt(0)}`.toUpperCase();
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
const p = parts[0]!;
|
||||
return p.length >= 2 ? p.slice(0, 2).toUpperCase() : `${p.charAt(0)}`.toUpperCase();
|
||||
}
|
||||
}
|
||||
if (userId && userId.length >= 2) {
|
||||
return userId.slice(0, 2).toUpperCase();
|
||||
}
|
||||
if (userId && userId.length === 1) {
|
||||
return `${userId.toUpperCase()}•`;
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
interface UserDropdownProps {
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
|
@ -61,19 +95,12 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
<Text type="secondary">{userEmail || "-"}</Text>
|
||||
</Space>
|
||||
{premiumUser ? (
|
||||
<Tag
|
||||
icon={<CrownOutlined />}
|
||||
color="gold"
|
||||
>
|
||||
<Tag icon={<CrownOutlined />} color="gold">
|
||||
Premium
|
||||
</Tag>
|
||||
) : (
|
||||
<Tooltip title="Upgrade to Premium for advanced features" placement="left">
|
||||
<Tag
|
||||
icon={<CrownOutlined />}
|
||||
>
|
||||
Standard
|
||||
</Tag>
|
||||
<Tag icon={<CrownOutlined />}>Standard</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
|
|
@ -83,12 +110,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
<UserOutlined />
|
||||
<Text type="secondary">User ID</Text>
|
||||
</Space>
|
||||
<Text
|
||||
copyable
|
||||
ellipsis
|
||||
style={{ maxWidth: "150px" }}
|
||||
title={userId || "-"}
|
||||
>
|
||||
<Text copyable ellipsis style={{ maxWidth: "150px" }} title={userId || "-"}>
|
||||
{userId || "-"}
|
||||
</Text>
|
||||
</Space>
|
||||
|
|
@ -189,13 +211,17 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
</Space>
|
||||
);
|
||||
|
||||
const seed = userEmail || userId || "user";
|
||||
const initials = initialsFromIdentity(userEmail, userId);
|
||||
const hue = hueFromString(seed);
|
||||
const displayName = navAccountDisplayName(userEmail, userId);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{ items: userItems }}
|
||||
popupRender={(menu) => (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-lg"
|
||||
>
|
||||
<div className="rounded-lg bg-white shadow-lg">
|
||||
{renderUserInfoSection()}
|
||||
<Divider style={{ margin: 0 }} />
|
||||
{React.cloneElement(menu as React.ReactElement, {
|
||||
|
|
@ -204,13 +230,24 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout }) => {
|
|||
</div>
|
||||
)}
|
||||
>
|
||||
<Button type="text" >
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<Text>User</Text>
|
||||
<DownOutlined />
|
||||
</Space>
|
||||
</Button>
|
||||
<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-gray-100"
|
||||
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white shadow-inner ring-1 ring-black/5"
|
||||
style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}
|
||||
aria-hidden
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<span className="hidden min-w-0 truncate text-left text-sm font-medium leading-none text-gray-900 md:inline">
|
||||
{displayName}
|
||||
</span>
|
||||
<DownOutlined className="hidden shrink-0 text-[10px] text-gray-400 md:inline" aria-hidden />
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { navAccountDisplayName } from "./navDisplayName";
|
||||
|
||||
describe("navAccountDisplayName", () => {
|
||||
it("should prefer email when present", () => {
|
||||
expect(navAccountDisplayName("x@y.com", "ignored")).toBe("x@y.com");
|
||||
});
|
||||
|
||||
it("should map default_user_id placeholder to Account", () => {
|
||||
expect(navAccountDisplayName(null, "default_user_id")).toBe("Account");
|
||||
expect(navAccountDisplayName(null, "DEFAULT_USER_ID")).toBe("Account");
|
||||
});
|
||||
|
||||
it("should show a sensible token when user id is non-placeholder", () => {
|
||||
expect(navAccountDisplayName(null, "user-uuid-123")).toBe("user-uuid-123");
|
||||
});
|
||||
});
|
||||
20
ui/litellm-dashboard/src/components/Navbar/navDisplayName.ts
Normal file
20
ui/litellm-dashboard/src/components/Navbar/navDisplayName.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/** 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 {
|
||||
const email = userEmail?.trim();
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
const id = userId?.trim();
|
||||
if (!id) {
|
||||
return "Account";
|
||||
}
|
||||
const lower = id.toLowerCase();
|
||||
if (
|
||||
lower === "default_user_id" ||
|
||||
lower === "default-user-id" ||
|
||||
/^default[_\s-]?user[_\s-]?id$/i.test(id)
|
||||
) {
|
||||
return "Account";
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
/** Shared styling for Docs / Blog in the top nav (product navigation zone). */
|
||||
export const NAV_PRODUCT_LINK_CLASS =
|
||||
"inline-flex h-9 shrink-0 items-center justify-center gap-1 rounded-md px-2 text-sm font-medium leading-none text-gray-800 transition-colors hover:bg-gray-100 hover:text-gray-950";
|
||||
|
|
@ -37,8 +37,8 @@ vi.mock("./Navbar/UserDropdown/UserDropdown", async (importOriginal) => {
|
|||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => setOpen(!open)}>
|
||||
User
|
||||
<button type="button" aria-label="Open account menu" onClick={() => setOpen(!open)}>
|
||||
Account
|
||||
</button>
|
||||
{open && (
|
||||
<div data-testid="user-dropdown-content">
|
||||
|
|
@ -146,15 +146,16 @@ describe("Navbar", () => {
|
|||
it("should render without crashing", () => {
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /^notifications$/i })).toBeInTheDocument();
|
||||
expect(screen.getByText("Docs")).toBeInTheDocument();
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /open account menu/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display user information in dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(screen.getByRole("button", { name: /open account menu/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test-user")).toBeInTheDocument();
|
||||
|
|
@ -193,7 +194,7 @@ describe("Navbar", () => {
|
|||
});
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(screen.getByRole("button", { name: /open account menu/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Premium")).toBeInTheDocument();
|
||||
|
|
@ -230,7 +231,7 @@ describe("Navbar", () => {
|
|||
const publicPageProps = { ...defaultProps, isPublicPage: true };
|
||||
renderWithProviders(<Navbar {...publicPageProps} />);
|
||||
|
||||
expect(screen.queryByText("User")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /open account menu/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle hide new features toggle", async () => {
|
||||
|
|
@ -244,7 +245,7 @@ describe("Navbar", () => {
|
|||
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(screen.getByRole("button", { name: /open account menu/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test-user")).toBeInTheDocument();
|
||||
|
|
@ -269,7 +270,7 @@ describe("Navbar", () => {
|
|||
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText("User"));
|
||||
await user.click(screen.getByRole("button", { name: /open account menu/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test-user")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness";
|
||||
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness";
|
||||
import { useWorker } from "@/hooks/useWorker";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { clearTokenCookies } from "@/utils/cookieUtils";
|
||||
import { clearStoredReturnUrl } from "@/utils/returnUrlUtils";
|
||||
import { fetchProxySettings } from "@/utils/proxyUtils";
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, SunOutlined } from "@ant-design/icons";
|
||||
import { Button, Switch, Tag } from "antd";
|
||||
import { DownOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
||||
import { Tag } from "antd";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
|
||||
import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
|
||||
import { NAV_PRODUCT_LINK_CLASS } from "./Navbar/navProductLinkClass";
|
||||
import { NotificationsBell } from "./Navbar/NotificationsBell/NotificationsBell";
|
||||
import UserDropdown from "./Navbar/UserDropdown/UserDropdown";
|
||||
import WorkerDropdown from "./Navbar/WorkerDropdown/WorkerDropdown";
|
||||
|
||||
|
|
@ -30,18 +34,12 @@ interface NavbarProps {
|
|||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({
|
||||
userID,
|
||||
userEmail,
|
||||
userRole,
|
||||
premiumUser,
|
||||
proxySettings,
|
||||
setProxySettings,
|
||||
accessToken,
|
||||
isPublicPage = false,
|
||||
sidebarCollapsed = false,
|
||||
onToggleSidebar,
|
||||
isDarkMode,
|
||||
toggleDarkMode,
|
||||
}) => {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const [logoutUrl, setLogoutUrl] = useState("");
|
||||
|
|
@ -49,8 +47,10 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
const { data: healthData } = useHealthReadiness();
|
||||
const version = healthData?.litellm_version;
|
||||
const disableBouncingIcon = useDisableBouncingIcon();
|
||||
const hideCommunityLinks = useDisableShowPrompts();
|
||||
const { isControlPlane, selectedWorker } = useWorker();
|
||||
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
|
||||
|
||||
// Simple logo URL: use custom logo if available, otherwise default
|
||||
const imageUrl = logoUrl || `${baseUrl}/get_image`;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -87,14 +87,14 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<nav className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<nav className="sticky top-0 z-10 border-b border-gray-200 bg-white">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<div className="flex items-center flex-shrink-0">
|
||||
<div className="flex h-14 items-center px-4">
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
{onToggleSidebar && (
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="flex items-center justify-center w-10 h-10 mr-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors"
|
||||
className="mr-2 flex h-9 w-9 items-center justify-center rounded-md text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<span className="text-lg">{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}</span>
|
||||
|
|
@ -104,11 +104,11 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
<div className="flex items-center gap-2">
|
||||
<Link href={baseUrl ? baseUrl : "/"} className="flex items-center">
|
||||
<div className="relative">
|
||||
<div className="h-10 max-w-48 flex items-center justify-center overflow-hidden">
|
||||
<div className="flex h-10 max-w-48 items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="LiteLLM Brand"
|
||||
className="max-w-full max-h-full w-auto h-auto object-contain"
|
||||
className="h-auto max-h-full w-auto max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -117,14 +117,14 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
<div className="relative">
|
||||
{!disableBouncingIcon && (
|
||||
<span
|
||||
className="absolute -top-1 -left-2 text-lg animate-bounce"
|
||||
className="absolute -left-2 -top-1 animate-bounce text-lg"
|
||||
style={{ animationDuration: "2s" }}
|
||||
title="Thanks for using LiteLLM!"
|
||||
>
|
||||
🌑
|
||||
</span>
|
||||
)}
|
||||
<Tag className="relative text-xs font-medium cursor-pointer z-10">
|
||||
<Tag className="relative z-10 cursor-pointer text-xs font-medium">
|
||||
<a
|
||||
href="https://docs.litellm.ai/release_notes"
|
||||
target="_blank"
|
||||
|
|
@ -138,28 +138,50 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right side nav items */}
|
||||
<div className="flex items-center space-x-5 ml-auto">
|
||||
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
|
||||
<CommunityEngagementButtons />
|
||||
{/* Dark mode is currently a work in progress. To test, you can change 'false' to 'true' below.
|
||||
Do not set this to true by default until all components are confirmed to support dark mode styles. */}
|
||||
{false && (
|
||||
<Switch
|
||||
data-testid="dark-mode-toggle"
|
||||
checked={isDarkMode}
|
||||
onChange={toggleDarkMode}
|
||||
checkedChildren={<MoonOutlined />}
|
||||
unCheckedChildren={<SunOutlined />}
|
||||
/>
|
||||
)}
|
||||
<Button type="text" href="https://docs.litellm.ai/docs/" target="_blank" rel="noopener noreferrer">
|
||||
Docs
|
||||
</Button>
|
||||
<BlogDropdown />
|
||||
|
||||
{!isPublicPage && <UserDropdown onLogout={handleLogout} />}
|
||||
<div className="ml-auto flex min-w-0 flex-1 items-center justify-end gap-4">
|
||||
{showWorkerSwitch && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav
|
||||
aria-label="Product documentation"
|
||||
className={`flex min-w-0 items-center gap-2 ${showWorkerSwitch ? "border-l border-gray-200 pl-4" : ""}`}
|
||||
>
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={NAV_PRODUCT_LINK_CLASS}
|
||||
>
|
||||
Docs
|
||||
{/* Layout parity with Blog chevron — intentional single-level link */}
|
||||
<DownOutlined className="pointer-events-none text-[10px] opacity-0" aria-hidden />
|
||||
</a>
|
||||
<BlogDropdown />
|
||||
</nav>
|
||||
|
||||
{!hideCommunityLinks && (
|
||||
<div className="flex shrink-0 items-center border-l border-gray-200 pl-4">
|
||||
<CommunityEngagementButtons />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex shrink-0 items-center border-l border-gray-200 pl-4">
|
||||
<div className="flex items-center gap-0.5 rounded-lg bg-gray-50 px-1 py-0 transition-colors hover:bg-gray-100">
|
||||
<NotificationsBell />
|
||||
{!isPublicPage && (
|
||||
<>
|
||||
<span className="mx-0.5 h-6 w-px shrink-0 bg-gray-200" aria-hidden />
|
||||
<UserDropdown onLogout={handleLogout} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Dark mode toggle: keep disabled until the dashboard supports dark styles end-to-end. */}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue