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 = ({
- +