From f0a72fccd1c4989e1d12c363bedb060e6aadf50d Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 14 Oct 2026 01:06:24 +0000 Subject: [PATCH] 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. --- .../(dashboard)/hooks/useAnnouncementRead.ts | 37 ++++ .../hooks/useHideAutoRouterAnnouncement.ts | 35 --- .../src/app/(dashboard)/layout.test.tsx | 4 - .../src/app/(dashboard)/layout.tsx | 4 +- .../DashboardHeader.integration.test.tsx | 2 +- .../src/components/DashboardHeader.test.tsx | 6 +- .../src/components/DashboardHeader.tsx | 12 +- .../NotificationsBell.test.tsx | 202 +++++++++++++----- .../NotificationsBell/NotificationsBell.tsx | 122 ++++++++--- .../src/components/UpgradeBanner.test.tsx | 131 ------------ .../src/components/UpgradeBanner.tsx | 77 ------- .../src/components/navbar.test.tsx | 2 +- .../src/components/navbar.tsx | 2 +- 13 files changed, 290 insertions(+), 346 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useAnnouncementRead.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useHideAutoRouterAnnouncement.ts delete mode 100644 ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/UpgradeBanner.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAnnouncementRead.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAnnouncementRead.ts new file mode 100644 index 00000000000..916ccc43017 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAnnouncementRead.ts @@ -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, + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useHideAutoRouterAnnouncement.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useHideAutoRouterAnnouncement.ts deleted file mode 100644 index 61b9b3eea7a..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useHideAutoRouterAnnouncement.ts +++ /dev/null @@ -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); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index 552bffb6cd8..612aaea5bdd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -53,10 +53,6 @@ vi.mock("@/components/UserBanner", () => ({ UserBanner: () => null, })); -vi.mock("@/components/UpgradeBanner", () => ({ - UpgradeBanner: () => null, -})); - vi.mock("@/contexts/ThemeContext", () => ({ ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 9233a56781e..4e600fff286 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -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"; @@ -142,7 +141,6 @@ function DashboardShell({ children }: { children: React.ReactNode }) { -
@@ -179,6 +177,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
-
{children}
diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx index cb58670c20a..d899d032395 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx @@ -59,7 +59,7 @@ async function openTools() { - + , diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx index 6c64d09009c..41d35bd40b7 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx @@ -35,7 +35,7 @@ vi.mock("@/components/liteadmin/LiteAdmin", () => ({ default: () => - ) : null} + )} - + ); +}; + +interface NotificationsBellViewProps { + currentVersion: string | null | undefined; + latestRelease: LatestReleaseInfo | null | undefined; +} + +export const NotificationsBellView: React.FC = ({ 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 ( - + 0 ? `Notifications, ${unreadCount} unread` : "Notifications"} > - {hasUnread ? : null} + {unreadCount > 0 ? : null} - {content} + + {announcements.map((announcement, index) => ( + + {index > 0 ? : null} + + + ))} + ); }; + +export const NotificationsBell: React.FC<{ accessToken: string | null }> = ({ accessToken }) => { + const { data: healthData } = useHealthReadinessDetails(accessToken); + const { data: latestRelease } = useLatestReleaseInfo(accessToken); + return ; +}; diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx deleted file mode 100644 index 4466597394c..00000000000 --- a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx +++ /dev/null @@ -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(); - expect(container).toBeEmptyDOMElement(); - const { container: noRelease } = render(); - expect(noRelease).toBeEmptyDOMElement(); - }); - - it("renders nothing when the running version is up to date or ahead", () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - const { container: ahead } = render(); - expect(ahead).toBeEmptyDOMElement(); - }); - - it("shows the latest version, the stat line, and the current version when behind", () => { - render(); - 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(); - fireEvent.click(screen.getByRole("button", { name: "Close" })); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - unmount(); - - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it("reappears once a newer release ships after a dismissal", () => { - const { unmount } = render(); - fireEvent.click(screen.getByRole("button", { name: "Close" })); - unmount(); - - render(); - 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(); - fireEvent.click(screen.getByRole("button", { name: "Close" })); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - rerender(); - 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> as ReturnType; - const latestReleaseResult = { data: RELEASE } as Partial> as ReturnType< - typeof useLatestReleaseInfo - >; - vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult); - vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult); - render(); - 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> as ReturnType; - const latestReleaseResult = { data: null } as Partial> as ReturnType< - typeof useLatestReleaseInfo - >; - vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult); - vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx deleted file mode 100644 index 101a6e3410d..00000000000 --- a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx +++ /dev/null @@ -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 = ({ currentVersion, latestRelease }) => { - const [dismissedVersion, setDismissedVersion] = useState(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 ( - - - - The latest version is{" "} - - v{latestRelease.version} - - : {describeRelease(latestRelease)} - - Your current version is v{currentVersion} - - - - - ); -}; - -export const UpgradeBanner: React.FC = ({ accessToken }) => { - const { data: healthData } = useHealthReadinessDetails(accessToken); - const { data: latestRelease } = useLatestReleaseInfo(accessToken); - return ; -}; diff --git a/ui/litellm-dashboard/src/components/navbar.test.tsx b/ui/litellm-dashboard/src/components/navbar.test.tsx index 9d80a89f83e..39b7ebd3521 100644 --- a/ui/litellm-dashboard/src/components/navbar.test.tsx +++ b/ui/litellm-dashboard/src/components/navbar.test.tsx @@ -143,7 +143,7 @@ describe("Navbar", () => { it("should render without crashing", () => { renderWithProviders(); - 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(); }); diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 8bc9b06969e..4e99d8e016f 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -149,7 +149,7 @@ const Navbar: React.FC = ({
- +