feat(ui): move the upgrade notice into the notifications bell (#46334)

* feat(ui): move the upgrade notice into the notifications bell

The new version notice was a banner in the same stack as deployment
warnings. It now lives in the notifications bell next to Auto Router,
each announcement has its own read state in localStorage keyed per item
and per release, and the bell shows its unread dot while any item is
unread. The upgrade banner is removed from both dashboard shells.

* test(ui): cover singular release wording, legacy dismissals, and the public page bell guard

* fix(proxy-extras): bound telemetry_reports migration lock waits with SET LOCAL lock_timeout

Ports the open fix in #46386 so this branch passes the migration lock_timeout
check that main currently fails.

* Revert "fix(proxy-extras): bound telemetry_reports migration lock waits with SET LOCAL lock_timeout"

This reverts commit b7c1df1a57.
This commit is contained in:
yuneng-jiang 2026-10-13 23:13:40 -07:00 • committed by GitHub
parent 7fa52b2d21
commit ff2718bcfa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 309 additions and 347 deletions

View file

@ -0,0 +1,37 @@
import { useSyncExternalStore } from "react";
import {
emitLocalStorageChange,
getLocalStorageItem,
LOCAL_STORAGE_EVENT,
setLocalStorageItem,
} from "@/utils/localStorageUtils";
function subscribe(callback: () => void) {
window.addEventListener("storage", callback);
window.addEventListener(LOCAL_STORAGE_EVENT, callback);
return () => {
window.removeEventListener("storage", callback);
window.removeEventListener(LOCAL_STORAGE_EVENT, callback);
};
}
export function useAnnouncementRead(readKey: string): boolean {
return useSyncExternalStore(
subscribe,
() => getLocalStorageItem(readKey) === "true",
() => false,
);
}
export function markAnnouncementRead(readKey: string): void {
setLocalStorageItem(readKey, "true");
emitLocalStorageChange(readKey);
}
export function useUnreadAnnouncementCount(readKeys: readonly string[]): number {
return useSyncExternalStore(
subscribe,
() => readKeys.filter((readKey) => getLocalStorageItem(readKey) !== "true").length,
() => readKeys.length,
);
}

View file

@ -1,35 +0,0 @@
import { useSyncExternalStore } from "react";
import { getLocalStorageItem, LOCAL_STORAGE_EVENT } from "@/utils/localStorageUtils";
export const HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY = "litellmHideAutoRouterAnnouncement";
function subscribe(callback: () => void) {
const onStorage = (e: StorageEvent) => {
if (e.key === HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY) {
callback();
}
};
const onCustom = (e: Event) => {
const { key } = (e as CustomEvent).detail;
if (key === HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY) {
callback();
}
};
window.addEventListener("storage", onStorage);
window.addEventListener(LOCAL_STORAGE_EVENT, onCustom);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(LOCAL_STORAGE_EVENT, onCustom);
};
}
function getSnapshot() {
return getLocalStorageItem(HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY) === "true";
}
export function useHideAutoRouterAnnouncement() {
return useSyncExternalStore(subscribe, getSnapshot, () => false);
}

View file

@ -60,10 +60,6 @@ vi.mock("@/components/UserBanner", () => ({
UserBanner: () => null,
}));
vi.mock("@/components/UpgradeBanner", () => ({
UpgradeBanner: () => null,
}));
vi.mock("@/contexts/ThemeContext", () => ({
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

View file

@ -15,7 +15,6 @@ import { EnvCredentialLoginWarningBanner } from "@/components/EnvCredentialLogin
import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner";
import { UserBanner } from "@/components/UserBanner";
import { LiteAdminFrame } from "@/components/liteadmin/LiteAdmin";
import { UpgradeBanner } from "@/components/UpgradeBanner";
import { routeSegmentForPathname, uiHref } from "@/utils/uiHref";
import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext";
import { createApiClient } from "@/lib/http/client";
@ -147,7 +146,6 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
<EnvCredentialLoginWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<UserBanner accessToken={accessToken} />
<UpgradeBanner accessToken={accessToken} />
<main className="flex min-h-0 flex-1 overflow-hidden">
<AgentControlPlaneView />
</main>
@ -184,6 +182,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
<CommandPaletteProvider>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<DashboardHeader
accessToken={accessToken}
navigationTrigger={
<SheetTrigger
handle={mobileNavigationHandle}
@ -200,7 +199,6 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
<EnvCredentialLoginWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<UserBanner accessToken={accessToken} />
<UpgradeBanner accessToken={accessToken} />
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto">
<TelemetryEnvBanner />
{children}

View file

@ -59,7 +59,7 @@ async function openTools() {
<QueryClientProvider client={client}>
<PluginModeProvider accessToken="test-session">
<CommandPaletteProvider>
<DashboardHeader />
<DashboardHeader accessToken={null} />
</CommandPaletteProvider>
</PluginModeProvider>
</QueryClientProvider>,

View file

@ -35,7 +35,7 @@ vi.mock("@/components/liteadmin/LiteAdmin", () => ({ default: () => <button>Lite
const renderDashboardHeader = () =>
render(
<CommandPaletteProvider>
<DashboardHeader />
<DashboardHeader accessToken={null} />
</CommandPaletteProvider>,
);
@ -133,7 +133,7 @@ describe("DashboardHeader breadcrumb", () => {
state.isDesktop = true;
rerender(
<CommandPaletteProvider>
<DashboardHeader />
<DashboardHeader accessToken={null} />
</CommandPaletteProvider>,
);
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
@ -141,7 +141,7 @@ describe("DashboardHeader breadcrumb", () => {
state.isDesktop = false;
rerender(
<CommandPaletteProvider>
<DashboardHeader />
<DashboardHeader accessToken={null} />
</CommandPaletteProvider>,
);
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();

View file

@ -31,7 +31,13 @@ import { CommandPaletteTrigger } from "@/components/CommandPalette/CommandPalett
// 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({ navigationTrigger }: { navigationTrigger?: ReactNode }) {
export function DashboardHeader({
accessToken,
navigationTrigger,
}: {
accessToken: string | null;
navigationTrigger?: ReactNode;
}) {
const { title } = getBreadcrumb(usePathname());
const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false });
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
@ -78,11 +84,11 @@ export function DashboardHeader({ navigationTrigger }: { navigationTrigger?: Rea
{!hideCommunityLinks && <CommunityEngagementButtons />}
<ToolbarSeparator />
<ThemeToggle />
<NotificationsBell />
<NotificationsBell accessToken={accessToken} />
</div>
<div className="flex shrink-0 items-center gap-1 md:hidden">
<CommandPaletteTrigger mobile />
<NotificationsBell />
<NotificationsBell accessToken={accessToken} />
<Popover open={!isDesktop && mobileToolsOpen} onOpenChange={setMobileToolsOpen}>
<PopoverTrigger render={<Button variant="ghost" size="icon" className="size-11" aria-label="More options" />}>
<Ellipsis />

View file

@ -1,69 +1,175 @@
import { renderWithProviders, screen } from "../../../../tests/test-utils";
import { NotificationsBell, AUTO_ROUTER_DOCS_URL } from "./NotificationsBell";
import { AUTO_ROUTER_DOCS_URL, NotificationsBell, NotificationsBellView } from "./NotificationsBell";
import type { LatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
import React from "react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({
useHealthReadinessDetails: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo", () => ({
useLatestReleaseInfo: vi.fn(),
}));
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import { useLatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
const RELEASE: LatestReleaseInfo = {
version: "1.103.0",
new_features: 12,
bug_fixes: 30,
other_updates: 8,
release_url: "https://github.com/BerriAI/litellm/releases/tag/v1.103.0",
};
const bell = (unread: number) =>
screen.getByRole("button", { name: unread === 0 ? /^notifications$/i : `Notifications, ${unread} unread` });
const announcement = (title: string) => screen.getByRole("article", { name: title });
describe("NotificationsBellView", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it("lists the new version notice above Auto Router when the proxy is behind the latest release", async () => {
const user = userEvent.setup();
renderWithProviders(<NotificationsBellView currentVersion="1.102.0" latestRelease={RELEASE} />);
await user.click(bell(2));
const titles = screen.getAllByRole("article").map((item) => item.getAttribute("aria-label"));
expect(titles).toEqual(["LiteLLM v1.103.0 is available", "LiteLLM Auto Router"]);
const upgrade = announcement("LiteLLM v1.103.0 is available");
expect(upgrade).toHaveTextContent("12 new features, 30 fixes, and 8 other updates");
expect(upgrade).toHaveTextContent("Your current version is v1.102.0");
const notes = screen.getByRole("link", { name: /^view release notes$/i });
expect(notes).toHaveAttribute("href", RELEASE.release_url);
expect(notes).toHaveAttribute("rel", "noopener noreferrer");
const docs = screen.getByRole("link", { name: /^read the docs$/i });
expect(docs).toHaveAttribute("href", AUTO_ROUTER_DOCS_URL);
expect(docs).toHaveAttribute("target", "_blank");
});
it("uses singular wording for counts of one", async () => {
const user = userEvent.setup();
renderWithProviders(
<NotificationsBellView
currentVersion="1.102.0"
latestRelease={{ ...RELEASE, new_features: 1, bug_fixes: 1, other_updates: 1 }}
/>,
);
await user.click(bell(2));
expect(announcement("LiteLLM v1.103.0 is available")).toHaveTextContent("1 new feature, 1 fix, and 1 other update");
});
it.each([
["the running version is unknown", undefined, RELEASE],
["the latest release is unavailable", "1.102.0", null],
["the proxy is up to date", "1.103.0", RELEASE],
["the proxy is ahead of the latest release", "1.104.0-dev.1", RELEASE],
])("lists only Auto Router when %s", async (_case, currentVersion, latestRelease) => {
const user = userEvent.setup();
renderWithProviders(<NotificationsBellView currentVersion={currentVersion} latestRelease={latestRelease} />);
await user.click(bell(1));
expect(screen.getAllByRole("article").map((item) => item.getAttribute("aria-label"))).toEqual([
"LiteLLM Auto Router",
]);
});
it("marks each announcement read on its own and clears the unread dot once all are read", async () => {
const user = userEvent.setup();
renderWithProviders(<NotificationsBellView currentVersion="1.102.0" latestRelease={RELEASE} />);
await user.click(bell(2));
await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]);
expect(bell(1).querySelector("[data-slot=badge]")).not.toBeNull();
expect(screen.getAllByRole("button", { name: /^mark as read$/i })).toHaveLength(1);
expect(announcement("LiteLLM Auto Router")).toContainElement(
screen.getByRole("button", { name: /^mark as read$/i }),
);
await user.click(screen.getByRole("button", { name: /^mark as read$/i }));
expect(bell(0).querySelector("[data-slot=badge]")).toBeNull();
expect(screen.getAllByRole("article")).toHaveLength(2);
expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
});
it("keeps the new version notice read across remounts for the same release", async () => {
const user = userEvent.setup();
const first = renderWithProviders(<NotificationsBellView currentVersion="1.102.0" latestRelease={RELEASE} />);
await user.click(bell(2));
await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]);
first.unmount();
renderWithProviders(<NotificationsBellView currentVersion="1.102.0" latestRelease={RELEASE} />);
expect(bell(1)).toBeInTheDocument();
});
it("shows a newer release as unread after an older release was read", async () => {
const user = userEvent.setup();
const { rerender } = renderWithProviders(
<NotificationsBellView currentVersion="1.102.0" latestRelease={RELEASE} />,
);
await user.click(bell(2));
await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]);
expect(bell(1)).toBeInTheDocument();
rerender(<NotificationsBellView currentVersion="1.102.0" latestRelease={{ ...RELEASE, version: "1.104.0" }} />);
expect(bell(2)).toBeInTheDocument();
expect(announcement("LiteLLM v1.104.0 is available")).toContainElement(
screen.getAllByRole("button", { name: /^mark as read$/i })[0],
);
});
it("treats a release whose upgrade banner was dismissed as already read", () => {
localStorage.setItem("litellm:upgradeBannerDismissed:1.103.0", "true");
renderWithProviders(<NotificationsBellView currentVersion="1.102.0" latestRelease={RELEASE} />);
expect(bell(1)).toBeInTheDocument();
});
it("keeps Auto Router read when it was read before", async () => {
localStorage.setItem("litellmHideAutoRouterAnnouncement", "true");
const user = userEvent.setup();
renderWithProviders(<NotificationsBellView currentVersion="1.103.0" latestRelease={RELEASE} />);
await user.click(bell(0));
expect(announcement("LiteLLM Auto Router")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
});
it("syncs sibling bells when one marks an announcement read", async () => {
const user = userEvent.setup();
renderWithProviders(
<>
<NotificationsBellView currentVersion="1.102.0" latestRelease={RELEASE} />
<NotificationsBellView currentVersion="1.102.0" latestRelease={RELEASE} />
</>,
);
const [bellA] = screen.getAllByRole("button", { name: "Notifications, 2 unread" });
await user.click(bellA);
await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]);
expect(screen.getAllByRole("button", { name: "Notifications, 1 unread" })).toHaveLength(2);
});
});
describe("NotificationsBell", () => {
beforeEach(() => {
localStorage.clear();
});
it("should open notifications with Auto Router details and docs link", async () => {
it("builds the new version notice from the proxy version and the latest release", async () => {
vi.mocked(useHealthReadinessDetails).mockReturnValue({
data: { status: "healthy", litellm_version: "1.102.0" },
} as ReturnType<typeof useHealthReadinessDetails>);
vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: RELEASE } as ReturnType<typeof useLatestReleaseInfo>);
const user = userEvent.setup();
renderWithProviders(<NotificationsBell />);
await user.click(screen.getByRole("button", { name: /^notifications$/i }));
expect(screen.getByText(/^LiteLLM Auto Router$/i)).toBeInTheDocument();
const docsBtn = screen.getByRole("link", { name: /^read the docs$/i });
expect(docsBtn).toHaveAttribute("href", AUTO_ROUTER_DOCS_URL);
expect(docsBtn).toHaveAttribute("target", "_blank");
expect(docsBtn).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("litellmHideAutoRouterAnnouncement")).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("litellmHideAutoRouterAnnouncement", "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();
});
it("should sync sibling instances when one is dismissed", async () => {
const user = userEvent.setup();
renderWithProviders(
<>
<div data-testid="bell-a">
<NotificationsBell />
</div>
<div data-testid="bell-b">
<NotificationsBell />
</div>
</>,
);
// Both bells start unread → both render the "Mark as read" affordance once opened.
const [bellA, bellB] = screen.getAllByRole("button", { name: /^notifications$/i });
await user.click(bellA);
await user.click(screen.getByRole("button", { name: /^mark as read$/i }));
// Dismissing in bell A must also clear bell B without a remount.
await user.click(bellB);
expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
renderWithProviders(<NotificationsBell accessToken="token" />);
await user.click(bell(2));
expect(announcement("LiteLLM v1.103.0 is available")).toHaveTextContent("Your current version is v1.102.0");
});
});

View file

@ -1,66 +1,128 @@
"use client";
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import {
HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY,
useHideAutoRouterAnnouncement,
} from "@/app/(dashboard)/hooks/useHideAutoRouterAnnouncement";
import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils";
type LatestReleaseInfo,
useLatestReleaseInfo,
} from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
import {
markAnnouncementRead,
useAnnouncementRead,
useUnreadAnnouncementCount,
} from "@/app/(dashboard)/hooks/useAnnouncementRead";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/cva.config";
import { isNewerVersion } from "@/utils/versionUtils";
import { Bell } from "lucide-react";
import React, { useState } from "react";
import React, { Fragment } from "react";
export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing";
export const NotificationsBell: React.FC = () => {
const hidden = useHideAutoRouterAnnouncement();
const hasUnread = !hidden;
const [open, setOpen] = useState(false);
interface Announcement {
readKey: string;
title: string;
description: string;
linkLabel: string;
href: string;
}
const markDismissed = () => {
setLocalStorageItem(HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY, "true");
emitLocalStorageChange(HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY);
setOpen(false);
const AUTO_ROUTER_ANNOUNCEMENT: Announcement = {
readKey: "litellmHideAutoRouterAnnouncement",
title: "LiteLLM Auto Router",
description: "Route every request to the cheapest model that can handle it, no prompt changes needed.",
linkLabel: "Read the docs",
href: AUTO_ROUTER_DOCS_URL,
};
const plural = (count: number, singular: string, pluralForm: string): string =>
`${count} ${count === 1 ? singular : pluralForm}`;
const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo): string =>
[
plural(new_features, "new feature", "new features"),
plural(bug_fixes, "fix", "fixes"),
`and ${plural(other_updates, "other update", "other updates")}`,
].join(", ");
const upgradeAnnouncement = (
currentVersion: string | null | undefined,
latestRelease: LatestReleaseInfo | null | undefined,
): Announcement | null => {
if (!currentVersion || !latestRelease || !isNewerVersion(currentVersion, latestRelease.version)) {
return null;
}
return {
readKey: `litellm:upgradeBannerDismissed:${latestRelease.version}`,
title: `LiteLLM v${latestRelease.version} is available`,
description: `${describeRelease(latestRelease)}. Your current version is v${currentVersion}`,
linkLabel: "View release notes",
href: latestRelease.release_url,
};
};
const content = (
<div className="max-w-[280px]">
<PopoverTitle className="mt-0! mb-2!">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.
</PopoverDescription>
const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announcement }) => {
const read = useAnnouncementRead(announcement.readKey);
return (
<article aria-label={announcement.title} className="flex flex-col gap-2">
<p className="text-sm font-medium">{announcement.title}</p>
<p className="text-sm leading-snug text-muted-foreground">{announcement.description}</p>
<div className="flex flex-wrap items-center gap-2">
<a
className={cn(buttonVariants({ size: "sm" }))}
href={AUTO_ROUTER_DOCS_URL}
href={announcement.href}
target="_blank"
rel="noopener noreferrer"
>
Read the docs
{announcement.linkLabel}
</a>
{hasUnread ? (
<Button variant="link" size="sm" className="px-1!" onClick={markDismissed}>
{read ? null : (
<Button variant="link" size="sm" className="px-1!" onClick={() => markAnnouncementRead(announcement.readKey)}>
Mark as read
</Button>
) : null}
)}
</div>
</div>
</article>
);
};
interface NotificationsBellViewProps {
currentVersion: string | null | undefined;
latestRelease: LatestReleaseInfo | null | undefined;
}
export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({ currentVersion, latestRelease }) => {
const upgrade = upgradeAnnouncement(currentVersion, latestRelease);
const announcements = upgrade ? [upgrade, AUTO_ROUTER_ANNOUNCEMENT] : [AUTO_ROUTER_ANNOUNCEMENT];
const unreadCount = useUnreadAnnouncementCount(announcements.map((announcement) => announcement.readKey));
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover>
<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={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
>
<span className="relative inline-flex">
<Bell className="size-4" aria-hidden />
{hasUnread ? <Badge className="absolute -top-0.5 -right-1 size-1.5 p-0" aria-hidden /> : null}
{unreadCount > 0 ? <Badge className="absolute -top-0.5 -right-1 size-1.5 p-0" aria-hidden /> : null}
</span>
</PopoverTrigger>
<PopoverContent align="end">{content}</PopoverContent>
<PopoverContent align="end" aria-label="Notifications">
{announcements.map((announcement, index) => (
<Fragment key={announcement.readKey}>
{index > 0 ? <Separator /> : null}
<AnnouncementItem announcement={announcement} />
</Fragment>
))}
</PopoverContent>
</Popover>
);
};
export const NotificationsBell: React.FC<{ accessToken: string | null }> = ({ accessToken }) => {
const { data: healthData } = useHealthReadinessDetails(accessToken);
const { data: latestRelease } = useLatestReleaseInfo(accessToken);
return <NotificationsBellView currentVersion={healthData?.litellm_version} latestRelease={latestRelease} />;
};

View file

@ -1,131 +0,0 @@
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { describeRelease, UpgradeBanner, UpgradeBannerView } from "./UpgradeBanner";
import type { LatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({
useHealthReadinessDetails: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo", () => ({
useLatestReleaseInfo: vi.fn(),
}));
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import { useLatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
const RELEASE: LatestReleaseInfo = {
version: "1.103.0",
new_features: 12,
bug_fixes: 30,
other_updates: 8,
release_url: "https://github.com/BerriAI/litellm/releases/tag/v1.103.0",
};
describe("describeRelease", () => {
it("lists features, fixes, and other updates in the agreed order", () => {
expect(describeRelease(RELEASE)).toBe("12 new features, 30 fixes, and 8 other updates");
});
it("singularises counts of one", () => {
const singularCounts = { ...RELEASE, new_features: 1, bug_fixes: 1, other_updates: 1 };
expect(describeRelease(singularCounts)).toBe("1 new feature, 1 fix, and 1 other update");
});
});
describe("UpgradeBannerView", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it("renders nothing while either version is unknown", () => {
const { container } = render(<UpgradeBannerView currentVersion={undefined} latestRelease={RELEASE} />);
expect(container).toBeEmptyDOMElement();
const { container: noRelease } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={null} />);
expect(noRelease).toBeEmptyDOMElement();
});
it("renders nothing when the running version is up to date or ahead", () => {
const { container } = render(<UpgradeBannerView currentVersion="1.103.0" latestRelease={RELEASE} />);
expect(container).toBeEmptyDOMElement();
const { container: ahead } = render(<UpgradeBannerView currentVersion="1.104.0-dev.1" latestRelease={RELEASE} />);
expect(ahead).toBeEmptyDOMElement();
});
it("shows the latest version, the stat line, and the current version when behind", () => {
render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
const alert = screen.getByRole("status");
expect(alert).toHaveTextContent("The latest version is v1.103.0: 12 new features, 30 fixes, and 8 other updates");
expect(alert).toHaveTextContent("Your current version is v1.102.0");
expect(screen.getByRole("link", { name: "v1.103.0" })).toHaveAttribute("href", RELEASE.release_url);
});
it("dismissing hides the banner and keeps it hidden on remount for the same release", () => {
const { unmount } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
unmount();
const { container } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
expect(container).toBeEmptyDOMElement();
});
it("reappears once a newer release ships after a dismissal", () => {
const { unmount } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
unmount();
render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={{ ...RELEASE, version: "1.104.0" }} />);
expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0");
});
it("shows a newer release after the current one was dismissed without remounting", () => {
const { rerender } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
rerender(<UpgradeBannerView currentVersion="1.102.0" latestRelease={{ ...RELEASE, version: "1.104.0" }} />);
expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0");
});
});
describe("UpgradeBanner", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it("feeds both hooks the access token and renders from their data", () => {
const healthReadinessResult = {
data: { litellm_version: "1.102.0" },
} as Partial<ReturnType<typeof useHealthReadinessDetails>> as ReturnType<typeof useHealthReadinessDetails>;
const latestReleaseResult = { data: RELEASE } as Partial<ReturnType<typeof useLatestReleaseInfo>> as ReturnType<
typeof useLatestReleaseInfo
>;
vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
render(<UpgradeBanner accessToken="token" />);
expect(useHealthReadinessDetails).toHaveBeenCalledWith("token");
expect(useLatestReleaseInfo).toHaveBeenCalledWith("token");
expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.103.0");
});
it("renders nothing when the release endpoint returns null", () => {
const healthReadinessResult = {
data: { litellm_version: "1.102.0" },
} as Partial<ReturnType<typeof useHealthReadinessDetails>> as ReturnType<typeof useHealthReadinessDetails>;
const latestReleaseResult = { data: null } as Partial<ReturnType<typeof useLatestReleaseInfo>> as ReturnType<
typeof useLatestReleaseInfo
>;
vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
const { container } = render(<UpgradeBanner accessToken="token" />);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -1,77 +0,0 @@
"use client";
import React, { useState } from "react";
import { ArrowUpCircle, X } from "lucide-react";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import {
type LatestReleaseInfo,
useLatestReleaseInfo,
} from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
import { isNewerVersion } from "@/utils/versionUtils";
const DISMISS_KEY_PREFIX = "litellm:upgradeBannerDismissed:";
interface UpgradeBannerProps {
accessToken: string | null;
}
interface UpgradeBannerViewProps {
currentVersion: string | null | undefined;
latestRelease: LatestReleaseInfo | null | undefined;
}
const plural = (count: number, singular: string, pluralForm: string): string =>
`${count} ${count === 1 ? singular : pluralForm}`;
export const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo): string =>
[
plural(new_features, "new feature", "new features"),
plural(bug_fixes, "fix", "fixes"),
`and ${plural(other_updates, "other update", "other updates")}`,
].join(", ");
export const UpgradeBannerView: React.FC<UpgradeBannerViewProps> = ({ currentVersion, latestRelease }) => {
const [dismissedVersion, setDismissedVersion] = useState<string | null>(null);
if (!currentVersion || !latestRelease || !isNewerVersion(currentVersion, latestRelease.version)) {
return null;
}
const dismissKey = `${DISMISS_KEY_PREFIX}${latestRelease.version}`;
if (dismissedVersion === latestRelease.version || getLocalStorageItem(dismissKey) === "true") {
return null;
}
const handleClose = () => {
setLocalStorageItem(dismissKey, "true");
setDismissedVersion(latestRelease.version);
};
return (
<Alert role="status" variant="info" className="rounded-none border-x-0 border-t-0">
<ArrowUpCircle className="size-4" aria-hidden />
<AlertTitle>
The latest version is{" "}
<a href={latestRelease.release_url} target="_blank" rel="noopener noreferrer" className="underline">
v{latestRelease.version}
</a>
: {describeRelease(latestRelease)}
</AlertTitle>
<AlertDescription>Your current version is v{currentVersion}</AlertDescription>
<AlertAction>
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={handleClose}>
<X className="size-4" />
</Button>
</AlertAction>
</Alert>
);
};
export const UpgradeBanner: React.FC<UpgradeBannerProps> = ({ accessToken }) => {
const { data: healthData } = useHealthReadinessDetails(accessToken);
const { data: latestRelease } = useLatestReleaseInfo(accessToken);
return <UpgradeBannerView currentVersion={healthData?.litellm_version} latestRelease={latestRelease} />;
};

View file

@ -143,7 +143,7 @@ describe("Navbar", () => {
it("should render without crashing", () => {
renderWithProviders(<Navbar {...defaultProps} />);
expect(screen.getByRole("button", { name: /^notifications$/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^notifications/i })).toBeInTheDocument();
expect(screen.getByText("Docs")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /open account menu/i })).toBeInTheDocument();
});
@ -266,7 +266,7 @@ describe("Navbar", () => {
renderWithProviders(<Navbar {...publicPageProps} />);
expect(screen.queryByRole("button", { name: /open account menu/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^notifications$/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^notifications/i })).not.toBeInTheDocument();
});
it("should handle hide new feature indicators toggle", async () => {

View file

@ -149,7 +149,7 @@ const Navbar: React.FC<NavbarProps> = ({
<div className="flex items-center gap-0.5 rounded-lg bg-muted px-1 py-0 transition-colors hover:bg-accent">
<ThemeToggle />
<span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
<NotificationsBell />
<NotificationsBell accessToken={accessToken} />
<span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
<UserDropdown onLogout={handleLogout} />
</div>