From ff2718bcfa5266fe6da92891d7a88633a04fd5fd Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 13 Oct 2026 23:13:40 -0700 Subject: [PATCH] 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 b7c1df1a579a8d426e55633ed4b665a15dcf3502. --- .../(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 | 220 +++++++++++++----- .../NotificationsBell/NotificationsBell.tsx | 122 +++++++--- .../src/components/UpgradeBanner.test.tsx | 131 ----------- .../src/components/UpgradeBanner.tsx | 77 ------ .../src/components/navbar.test.tsx | 4 +- .../src/components/navbar.tsx | 2 +- 13 files changed, 309 insertions(+), 347 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 489c5a515f3..bd5c6949c6b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -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}, })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 90401235676..b0494f8415a 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"; @@ -147,7 +146,6 @@ function DashboardShell({ children }: { children: React.ReactNode }) { -
@@ -184,6 +182,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: () =>
- + }> diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx index 8894f2a714b..e0c428a94a0 100644 --- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx @@ -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(); + 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( + , + ); + 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(); + 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(); + 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(); + await user.click(bell(2)); + await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]); + first.unmount(); + + renderWithProviders(); + 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( + , + ); + await user.click(bell(2)); + await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]); + expect(bell(1)).toBeInTheDocument(); + + rerender(); + 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(); + expect(bell(1)).toBeInTheDocument(); + }); + + it("keeps Auto Router read when it was read before", async () => { + localStorage.setItem("litellmHideAutoRouterAnnouncement", "true"); + const user = userEvent.setup(); + renderWithProviders(); + 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( + <> + + + , + ); + 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); + vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: RELEASE } as ReturnType); const user = userEvent.setup(); - renderWithProviders(); - 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(); - 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(); - 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(); - 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( - <> -
- -
-
- -
- , - ); - - // 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(); + await user.click(bell(2)); + expect(announcement("LiteLLM v1.103.0 is available")).toHaveTextContent("Your current version is v1.102.0"); }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx index 8b9b7d1d0d2..4cb9f851153 100644 --- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx @@ -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 = ( -
- LiteLLM Auto Router - - Route every request to the cheapest model that can handle it, no prompt changes needed. - +const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announcement }) => { + const read = useAnnouncementRead(announcement.readKey); + return ( +
+ ); +}; + +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..b8c87a3145e 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(); }); @@ -266,7 +266,7 @@ describe("Navbar", () => { renderWithProviders(); 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 () => { 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 = ({
- +