diff --git a/ui/litellm-dashboard/src/app/layout.tsx b/ui/litellm-dashboard/src/app/layout.tsx index a4ed17cde39..58b4785b883 100644 --- a/ui/litellm-dashboard/src/app/layout.tsx +++ b/ui/litellm-dashboard/src/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import AntdGlobalProvider from "@/contexts/AntdGlobalProvider"; import ReactQueryProvider from "@/contexts/ReactQueryProvider"; +import { Toaster } from "@/components/ui/sonner"; const inter = Inter({ subsets: ["latin"] }); @@ -24,6 +25,7 @@ export default function RootLayout({ {children} + ); diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.test.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.test.tsx index 0e05c141570..6f7e3164b9a 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.test.tsx @@ -8,15 +8,22 @@ vi.mock("../../../playground/llm_calls/fetch_models", () => ({ fetchAvailableModels: vi.fn(), })); -vi.mock("antd", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - message: { - error: vi.fn(), - }, - }; -}); +// MessageManager is the project's global message wrapper (delegates to sonner +// post phase-1 migration). Mock it as a first-class object so assertions don't +// care which underlying toast library is in use. +const mockMessageManager = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + destroy: vi.fn(), +})); + +vi.mock("@/components/molecules/message_manager", () => ({ + default: mockMessageManager, + setMessageInstance: vi.fn(), +})); vi.mock("./FallbackSelectionForm", () => ({ FallbackSelectionForm: ({ groups, onGroupsChange }: any) => { @@ -122,7 +129,6 @@ describe("AddFallbacks", () => { it("should show error when saving incomplete groups", async () => { const user = userEvent.setup(); - const antd = await import("antd"); render(); const addButton = screen.getByRole("button", { name: /add fallbacks/i }); @@ -141,13 +147,12 @@ describe("AddFallbacks", () => { await user.click(saveButton); await waitFor(() => { - expect(antd.message.error).toHaveBeenCalled(); + expect(mockMessageManager.error).toHaveBeenCalled(); }); }); it("should show error message when saving incomplete groups", async () => { const user = userEvent.setup(); - const antd = await import("antd"); render(); const addButton = screen.getByRole("button", { name: /add fallbacks/i }); @@ -161,7 +166,7 @@ describe("AddFallbacks", () => { await user.click(saveButton); await waitFor(() => { - expect(antd.message.error).toHaveBeenCalled(); + expect(mockMessageManager.error).toHaveBeenCalled(); }); }); diff --git a/ui/litellm-dashboard/src/components/molecules/message_manager.test.ts b/ui/litellm-dashboard/src/components/molecules/message_manager.test.ts index 541efcbc4cb..1457f875072 100644 --- a/ui/litellm-dashboard/src/components/molecules/message_manager.test.ts +++ b/ui/litellm-dashboard/src/components/molecules/message_manager.test.ts @@ -1,17 +1,17 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -// Use vi.hoisted so the mock object is available when vi.mock is hoisted -const mockStaticMessage = vi.hoisted(() => ({ +// Sonner is a module-level singleton; mock it before importing the manager. +const mockToast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), - destroy: vi.fn(), + dismiss: vi.fn(), })); -vi.mock("antd", () => ({ - message: mockStaticMessage, +vi.mock("sonner", () => ({ + toast: mockToast, })); import MessageManager, { setMessageInstance } from "./message_manager"; @@ -21,87 +21,43 @@ describe("MessageManager", () => { vi.clearAllMocks(); }); - describe("when no instance is set (falls back to static message)", () => { - it("delegates success to static message", () => { + describe("delegates to sonner", () => { + it("delegates success", () => { MessageManager.success("done!"); - expect(mockStaticMessage.success).toHaveBeenCalledWith("done!", undefined); + expect(mockToast.success).toHaveBeenCalledWith("done!", { duration: undefined }); }); - it("delegates error to static message", () => { + it("delegates error with duration (converts seconds → ms)", () => { MessageManager.error("failed!", 5); - expect(mockStaticMessage.error).toHaveBeenCalledWith("failed!", 5); + expect(mockToast.error).toHaveBeenCalledWith("failed!", { duration: 5000 }); }); - it("delegates warning to static message", () => { + it("delegates warning", () => { MessageManager.warning("watch out"); - expect(mockStaticMessage.warning).toHaveBeenCalledWith("watch out", undefined); + expect(mockToast.warning).toHaveBeenCalledWith("watch out", { duration: undefined }); }); - it("delegates info to static message", () => { - MessageManager.info("fyi"); - expect(mockStaticMessage.info).toHaveBeenCalledWith("fyi", undefined); + it("delegates info with duration", () => { + MessageManager.info("fyi", 2); + expect(mockToast.info).toHaveBeenCalledWith("fyi", { duration: 2000 }); }); - it("delegates loading to static message", () => { - MessageManager.loading("loading...", 3); - expect(mockStaticMessage.loading).toHaveBeenCalledWith("loading...", 3); + it("delegates loading and returns the toast id", () => { + mockToast.loading.mockReturnValue("toast-id-42"); + const result = MessageManager.loading("loading...", 3); + expect(mockToast.loading).toHaveBeenCalledWith("loading...", { duration: 3000 }); + expect(result).toBe("toast-id-42"); }); - it("delegates destroy to static message", () => { + it("delegates destroy to toast.dismiss()", () => { MessageManager.destroy(); - expect(mockStaticMessage.destroy).toHaveBeenCalled(); + expect(mockToast.dismiss).toHaveBeenCalled(); }); }); - describe("when a custom instance is set", () => { - const mockInstance = { - success: vi.fn(), - error: vi.fn(), - warning: vi.fn(), - info: vi.fn(), - loading: vi.fn(), - destroy: vi.fn(), - open: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - setMessageInstance(mockInstance as any); - }); - - it("delegates success to custom instance", () => { - MessageManager.success("done!"); - expect(mockInstance.success).toHaveBeenCalledWith("done!", undefined); - expect(mockStaticMessage.success).not.toHaveBeenCalled(); - }); - - it("delegates error with duration to custom instance", () => { - MessageManager.error("failed!", 5); - expect(mockInstance.error).toHaveBeenCalledWith("failed!", 5); - expect(mockStaticMessage.error).not.toHaveBeenCalled(); - }); - - it("delegates warning to custom instance", () => { - MessageManager.warning("watch out"); - expect(mockInstance.warning).toHaveBeenCalledWith("watch out", undefined); - }); - - it("delegates info to custom instance", () => { - MessageManager.info("fyi", 2); - expect(mockInstance.info).toHaveBeenCalledWith("fyi", 2); - }); - - it("delegates loading to custom instance and returns result", () => { - const mockReturn = { then: vi.fn() }; - mockInstance.loading.mockReturnValue(mockReturn); - const result = MessageManager.loading("loading...", 3); - expect(mockInstance.loading).toHaveBeenCalledWith("loading...", 3); - expect(result).toBe(mockReturn); - }); - - it("delegates destroy to custom instance", () => { - MessageManager.destroy(); - expect(mockInstance.destroy).toHaveBeenCalled(); + describe("setMessageInstance is a no-op (back-compat shim)", () => { + it("does not throw when called", () => { + expect(() => setMessageInstance({} as unknown)).not.toThrow(); }); }); }); diff --git a/ui/litellm-dashboard/src/components/molecules/message_manager.tsx b/ui/litellm-dashboard/src/components/molecules/message_manager.tsx index e4c1552d5ee..ba408b606f2 100644 --- a/ui/litellm-dashboard/src/components/molecules/message_manager.tsx +++ b/ui/litellm-dashboard/src/components/molecules/message_manager.tsx @@ -1,37 +1,60 @@ -import { message as staticMessage } from "antd"; -import type { MessageInstance } from "antd/es/message/interface"; +/** + * Global message manager — thin wrapper around sonner's `toast.*` API. + * + * Phase-1 shadcn migration: this file previously delegated to antd's + * `message.*` API. It now delegates to sonner. The public API (including + * `setMessageInstance`) is preserved so existing call sites don't need to + * change; the `setMessageInstance` function is now a no-op kept for + * backwards-compatibility (sonner is singleton-rendered at the root layout + * and does not need per-tree instance registration). + */ +import { toast } from "sonner"; -let messageInstance: MessageInstance | null = null; - -export const setMessageInstance = (instance: MessageInstance) => { - messageInstance = instance; +/** + * No-op retained for API compatibility with call sites that import + * `setMessageInstance` from this module. Sonner is rendered globally via + * `` in the root layout; no per-tree registration is required. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const setMessageInstance = (_instance: unknown) => { + // no-op — sonner is singleton }; -const getMessageApi = () => messageInstance || staticMessage; +/** antd durations are seconds; sonner durations are milliseconds. */ +function toSonnerDuration(duration?: number): number | undefined { + if (duration == null) return undefined; + return duration * 1000; +} const MessageManager = { success(content: string, duration?: number) { - getMessageApi().success(content, duration); + return toast.success(content, { duration: toSonnerDuration(duration) }); }, error(content: string, duration?: number) { - getMessageApi().error(content, duration); + return toast.error(content, { duration: toSonnerDuration(duration) }); }, warning(content: string, duration?: number) { - getMessageApi().warning(content, duration); + return toast.warning(content, { duration: toSonnerDuration(duration) }); }, info(content: string, duration?: number) { - getMessageApi().info(content, duration); + return toast.info(content, { duration: toSonnerDuration(duration) }); }, + /** + * Show a loading toast. Returns the sonner toast id, which can be passed + * to `toast.dismiss(id)` (or `MessageManager.destroy()` to clear all). + * Behaves like antd's `message.loading` — duration in seconds; omit to + * keep the toast open until dismissed. + */ loading(content: string, duration?: number) { - return getMessageApi().loading(content, duration); + return toast.loading(content, { duration: toSonnerDuration(duration) }); }, destroy() { - getMessageApi().destroy(); + toast.dismiss(); }, }; diff --git a/ui/litellm-dashboard/src/components/molecules/notifications_manager.test.tsx b/ui/litellm-dashboard/src/components/molecules/notifications_manager.test.tsx index c886f78b4b1..a77762cc376 100644 --- a/ui/litellm-dashboard/src/components/molecules/notifications_manager.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/notifications_manager.test.tsx @@ -1,33 +1,35 @@ -import { notification } from "antd"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import NotificationManager, { COMMON_NOTIFICATION_PROPS } from "./notifications_manager"; -vi.mock("@/components/molecules/notifications_manager", async () => { - const actual = await vi.importActual( - "@/components/molecules/notifications_manager", - ); +// The global `setupTests.ts` mocks `@/components/molecules/notifications_manager` +// as a safety rail for component tests. Unmock it here so we can exercise the +// real manager against sonner. +vi.unmock("@/components/molecules/notifications_manager"); +vi.unmock("./notifications_manager"); - return actual; -}); - -// Mock the antd notification module -vi.mock("antd", () => ({ - notification: { - error: vi.fn(), - warning: vi.fn(), - info: vi.fn(), - success: vi.fn(), - destroy: vi.fn(), - }, +// Sonner is a module-level singleton; mock it before importing the manager. +const mockToast = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), })); -describe("NotificationManager", () => { +vi.mock("sonner", () => ({ + toast: mockToast, +})); + +// Dynamic import AFTER vi.unmock so the global mock doesn't take effect. +const { default: NotificationManager } = await import("./notifications_manager"); + +describe("NotificationManager (sonner-backed)", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("Already Exists case", () => { - it("should show error notification for 'already exists' message", () => { + it("should show error toast for 'already exists' backend message", () => { const error = { message: "Key with alias 'test10' already exists.", type: "bad_request_error", @@ -36,35 +38,36 @@ describe("NotificationManager", () => { NotificationManager.fromBackend(error); - expect(notification.error).toHaveBeenCalledWith( + expect(mockToast.error).toHaveBeenCalledWith( + "Already Exists", expect.objectContaining({ - message: "Already Exists", description: "Key with alias 'test10' already exists.", - duration: 6, - placement: "topRight", + duration: 6000, }), ); }); }); - describe("COMMON_NOTIFICATION_PROPS", () => { - const notificationTypes = [ - { type: "error", method: NotificationManager.error, mockFn: notification.error }, - { type: "warning", method: NotificationManager.warning, mockFn: notification.warning }, - { type: "info", method: NotificationManager.info, mockFn: notification.info }, - { type: "success", method: NotificationManager.success, mockFn: notification.success }, + describe("direct call routing", () => { + const cases = [ + { label: "error", call: () => NotificationManager.error("Test error"), mockFn: mockToast.error }, + { label: "warning", call: () => NotificationManager.warning("Test warning"), mockFn: mockToast.warning }, + { label: "info", call: () => NotificationManager.info("Test info"), mockFn: mockToast.info }, + { label: "success", call: () => NotificationManager.success("Test success"), mockFn: mockToast.success }, ]; - notificationTypes.forEach(({ type, method, mockFn }) => { - it(`should pass COMMON_NOTIFICATION_PROPS to ${type} notifications`, () => { - method(`Test ${type}`); - - expect(mockFn).toHaveBeenCalledWith( - expect.objectContaining({ - ...COMMON_NOTIFICATION_PROPS, - }), - ); + cases.forEach(({ label, call, mockFn }) => { + it(`should route ${label} to toast.${label}`, () => { + call(); + expect(mockFn).toHaveBeenCalled(); }); }); }); + + describe("clear()", () => { + it("dismisses all toasts", () => { + NotificationManager.clear(); + expect(mockToast.dismiss).toHaveBeenCalled(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/molecules/notifications_manager.tsx b/ui/litellm-dashboard/src/components/molecules/notifications_manager.tsx index 59b048b412c..79a6bf20690 100644 --- a/ui/litellm-dashboard/src/components/molecules/notifications_manager.tsx +++ b/ui/litellm-dashboard/src/components/molecules/notifications_manager.tsx @@ -1,17 +1,32 @@ import React from "react"; -import { notification as staticNotification } from "antd"; -import type { NotificationInstance } from "antd/es/notification/interface"; +import { toast } from "sonner"; import { parseErrorMessage } from "../shared/errorUtils"; -import { ArgsProps } from "antd/es/notification"; -let notificationInstance: NotificationInstance | null = null; +/** + * Global notification manager — thin wrapper around sonner's `toast.*` API. + * + * Phase-1 shadcn migration: this file previously delegated to antd's + * `notification.*` API. It now delegates to sonner. The public shape of + * the module is preserved (same functions + `setNotificationInstance`) + * so existing call sites don't need to change; `setNotificationInstance` + * is now a no-op kept for backwards-compatibility. + */ -export const setNotificationInstance = (instance: NotificationInstance) => { - notificationInstance = instance; +/** + * No-op retained for API compatibility with call sites that import + * `setNotificationInstance` from this module. Sonner is rendered globally via + * `` in the root layout; no per-tree registration is required. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const setNotificationInstance = (_instance: unknown) => { + // no-op — sonner is singleton }; -// Helper to get the best available notification instance -const getNotification = () => notificationInstance || staticNotification; +/** Kept public for callers that used to spread it into an antd notification config. */ +export const COMMON_NOTIFICATION_PROPS = { + // sonner's equivalent toggles are `richColors` (global on ) and + // `closeButton`; individual toasts don't need these fields. +}; type Placement = "top" | "topLeft" | "topRight" | "bottom" | "bottomLeft" | "bottomRight"; @@ -25,16 +40,12 @@ type NotificationConfig = { type NotificationConfigResolved = Omit & { message: string | React.ReactNode }; -function defaultPlacement(): Placement { - return "topRight"; -} - function normalize(input: string | NotificationConfig, fallbackTitle: string): NotificationConfigResolved { if (typeof input === "string") return { message: fallbackTitle, description: input }; return { message: input.message ?? fallbackTitle, ...input }; } -function toIntMaybe(val: any): number | undefined { +function toIntMaybe(val: unknown): number | undefined { if (typeof val === "number") return val; if (typeof val === "string" && /^\d+$/.test(val)) return parseInt(val, 10); return undefined; @@ -114,7 +125,7 @@ const NOT_FOUND_MATCH = [ "team not found", "organization not found", "mcp server with id", - "tool '", // will combine with “not found” in message + "tool '", ]; const EXISTS_MATCH = ["already exists", "team member is already in team", "user already exists"]; @@ -151,10 +162,10 @@ function titleFor(status?: number, desc?: string): string { if (AUTH_MATCH.some((s) => d.includes(s))) return "Authentication Error"; if (FORBIDDEN_MATCH.some((s) => d.includes(s))) return "Access Denied"; - if (DB_MATCH?.some?.((s: string) => d.includes(s)) || status === 503) return "Service Unavailable"; - if (BUDGET_MATCH?.some?.((s: string) => d.includes(s))) return "Budget Exceeded"; - if (ENTERPRISE_MATCH?.some?.((s: string) => d.includes(s))) return "Feature Unavailable"; - if (ROUTER_MATCH?.some?.((s: string) => d.includes(s))) return "Routing Error"; + if (DB_MATCH.some((s) => d.includes(s)) || status === 503) return "Service Unavailable"; + if (BUDGET_MATCH.some((s) => d.includes(s))) return "Budget Exceeded"; + if (ENTERPRISE_MATCH.some((s) => d.includes(s))) return "Feature Unavailable"; + if (ROUTER_MATCH.some((s) => d.includes(s))) return "Routing Error"; if (EXISTS_MATCH.some((s) => d.includes(s))) return "Already Exists"; if (GUARDRAIL_MATCH.some((s) => d.includes(s))) return "Content Blocked"; @@ -169,7 +180,7 @@ function titleFor(status?: number, desc?: string): string { d.includes("rate limit") || d.includes("tpm") || d.includes("rpm") || - RATE_LIMIT_EXTRA?.some?.((s: string) => d.includes(s)) + RATE_LIMIT_EXTRA.some((s) => d.includes(s)) ) return "Rate Limit Exceeded"; if (status && status >= 500) return "Server Error"; @@ -224,99 +235,115 @@ function classifyGeneralMessage(desc?: string): { kind: "success" | "info" | "wa if (SUCCESS_MATCH.some((s) => d.includes(s))) return { kind: "success", title: "Success" }; if (DEPRECATION_FEATURE_WARN_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Feature Notice" }; if (CONFIG_WARN_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Configuration Warning" }; - if (INFO_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Rate Limit" }; // show as warning for visibility + if (INFO_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Rate Limit" }; return null; } -function extractStatus(input: any): number | undefined { - return toIntMaybe(input?.response?.status) ?? toIntMaybe(input?.status_code) ?? toIntMaybe(input?.code); +function extractStatus(input: unknown): number | undefined { + const obj = (input ?? {}) as Record; + const response = obj?.response as Record | undefined; + return toIntMaybe(response?.status) ?? toIntMaybe(obj?.status_code) ?? toIntMaybe(obj?.code); } -function extractDescription(input: any): string { - if (typeof input === "string") return input; // raw error string +function extractDescription(input: unknown): string { + if (typeof input === "string") return input; + const obj = (input ?? {}) as Record; + const response = obj?.response as Record | undefined; + const data = response?.data as Record | undefined; + const dataError = data?.error as Record | string | undefined; const backendMsg = - input?.response?.data?.error?.message ?? - input?.response?.data?.message ?? - input?.response?.data?.error ?? - input?.detail ?? - input?.message ?? + (typeof dataError === "object" ? dataError?.message : dataError) ?? + data?.message ?? + obj?.detail ?? + obj?.message ?? input; return parseErrorMessage(backendMsg); } -export const COMMON_NOTIFICATION_PROPS: Partial = { - showProgress: true, - pauseOnHover: true, -}; - -function looksErrorPayload(input: any, status?: number): boolean { +function looksErrorPayload(input: unknown, status?: number): boolean { if (status !== undefined) return true; if (input instanceof Error) return true; - if (typeof input === "string") return true; // treat raw strings passed to fromBackend as errors + if (typeof input === "string") return true; if (input && typeof input === "object" && ("error" in input || "detail" in input)) return true; return false; } +/** antd durations are seconds; sonner durations are milliseconds. */ +function toMs(d?: number): number | undefined { + if (d == null) return undefined; + return d * 1000; +} + +/** + * Sonner accepts a `description` field on every level, so we bundle + * the (message, description) pair by using message as the main text and + * description as the subtitle — same visual shape as the old antd stack. + */ +function callToast( + level: "error" | "warning" | "info" | "success", + cfg: NotificationConfigResolved, + defaultDurationSec: number, +) { + const title = + typeof cfg.message === "string" ? cfg.message : String(cfg.message ?? ""); + const description = + typeof cfg.description === "string" || typeof cfg.description === "number" + ? String(cfg.description) + : cfg.description + ? (cfg.description as React.ReactNode) + : undefined; + const duration = toMs(cfg.duration ?? defaultDurationSec); + const opts: Parameters[1] = { description, duration }; + switch (level) { + case "error": + return toast.error(title, opts); + case "warning": + return toast.warning(title, opts); + case "info": + return toast.info(title, opts); + case "success": + return toast.success(title, opts); + } +} + const NotificationManager = { error(input: string | NotificationConfig) { const cfg = normalize(input, "Error"); - getNotification().error({ - ...COMMON_NOTIFICATION_PROPS, - ...cfg, - placement: cfg.placement ?? defaultPlacement(), - duration: cfg.duration ?? 6, - }); + callToast("error", cfg, 6); }, warning(input: string | NotificationConfig) { const cfg = normalize(input, "Warning"); - getNotification().warning({ - ...COMMON_NOTIFICATION_PROPS, - ...cfg, - placement: cfg.placement ?? defaultPlacement(), - duration: cfg.duration ?? 5, - }); + callToast("warning", cfg, 5); }, info(input: string | NotificationConfig) { const cfg = normalize(input, "Info"); - getNotification().info({ - ...COMMON_NOTIFICATION_PROPS, - ...cfg, - placement: cfg.placement ?? defaultPlacement(), - duration: cfg.duration ?? 4, - }); + callToast("info", cfg, 4); }, success(input: string | React.ReactNode | NotificationConfig) { if (React.isValidElement(input)) { - getNotification().success({ - ...COMMON_NOTIFICATION_PROPS, - message: "Success", - description: input, - placement: defaultPlacement(), - duration: 3.5, - }); + toast.success("Success", { description: input, duration: toMs(3.5) }); return; } const cfg = normalize(input as string | NotificationConfig, "Success"); - getNotification().success({ - ...COMMON_NOTIFICATION_PROPS, - ...cfg, - placement: cfg.placement ?? defaultPlacement(), - duration: cfg.duration ?? 3.5, - }); + callToast("success", cfg, 3.5); }, - fromBackend(input: any, extra?: Omit) { + fromBackend(input: unknown, extra?: Omit) { const status = extractStatus(input); const description = extractDescription(input); - const base = { ...(extra ?? {}), description, placement: extra?.placement ?? defaultPlacement() }; + const base: NotificationConfigResolved = { + ...(extra ?? {}), + message: "Info", + description, + }; if (looksErrorPayload(input, status)) { const title = titleFor(status, description); - const payload = { ...base, message: title }; + const payload: NotificationConfigResolved = { ...base, message: title }; if ( title === "Rate Limit Exceeded" || @@ -326,11 +353,11 @@ const NotificationManager = { title === "Content Blocked" || title === "Integration Error" ) { - getNotification().warning({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 7 }); + callToast("warning", payload, extra?.duration ?? 7); return; } if (title === "Server Error") { - getNotification().error({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 8 }); + callToast("error", payload, extra?.duration ?? 8); return; } if ( @@ -341,30 +368,29 @@ const NotificationManager = { title === "Error" || title === "Already Exists" ) { - getNotification().error({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 6 }); + callToast("error", payload, extra?.duration ?? 6); return; } - getNotification().info({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 4 }); + callToast("info", payload, extra?.duration ?? 4); return; } - // Non-error: success/info/warning classifier const cls = classifyGeneralMessage(description); - const payload = { ...base, message: cls?.title ?? "Info" }; + const payload: NotificationConfigResolved = { ...base, message: cls?.title ?? "Info" }; if (cls?.kind === "success") { - getNotification().success({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 3.5 }); + callToast("success", payload, extra?.duration ?? 3.5); return; } if (cls?.kind === "warning") { - getNotification().warning({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 6 }); + callToast("warning", payload, extra?.duration ?? 6); return; } - getNotification().info({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 4 }); + callToast("info", payload, extra?.duration ?? 4); }, clear() { - getNotification().destroy(); + toast.dismiss(); }, }; diff --git a/ui/litellm-dashboard/src/contexts/AntdGlobalProvider.tsx b/ui/litellm-dashboard/src/contexts/AntdGlobalProvider.tsx index cc182ee36da..b311c8246be 100644 --- a/ui/litellm-dashboard/src/contexts/AntdGlobalProvider.tsx +++ b/ui/litellm-dashboard/src/contexts/AntdGlobalProvider.tsx @@ -1,28 +1,17 @@ "use client"; -import React, { useEffect, useRef } from "react"; -import { notification, message } from "antd"; -import { setNotificationInstance } from "@/components/molecules/notifications_manager"; -import { setMessageInstance } from "@/components/molecules/message_manager"; +import React from "react"; +/** + * Legacy wrapper kept for backwards-compatibility during the phase-1 + * shadcn migration. Previously registered antd notification/message + * instances with the global managers. The managers now delegate to + * sonner (rendered globally via `` in the root layout), so + * this component is a passthrough. + * + * Will be deleted entirely in the "drop AntdGlobalProvider" cleanup + * task after antd has been uninstalled. + */ export default function AntdGlobalProvider({ children }: { children: React.ReactNode }) { - const [notificationApi, notificationContextHolder] = notification.useNotification(); - const [messageApi, messageContextHolder] = message.useMessage(); - const initialized = useRef(false); - - useEffect(() => { - if (!initialized.current) { - setNotificationInstance(notificationApi); - setMessageInstance(messageApi); - initialized.current = true; - } - }, [notificationApi, messageApi]); - - return ( - <> - {notificationContextHolder} - {messageContextHolder} - {children} - - ); + return <>{children}; }