diff --git a/tests/e2e/ui/tests/internal-user/internalUser.spec.ts b/tests/e2e/ui/tests/internal-user/internalUser.spec.ts index 07a75dc007d..b8424b06115 100644 --- a/tests/e2e/ui/tests/internal-user/internalUser.spec.ts +++ b/tests/e2e/ui/tests/internal-user/internalUser.spec.ts @@ -19,12 +19,11 @@ test.describe("Internal User", () => { // Open the team dropdown — seeded internal user is a member of // e2e-team-crud and e2e-team-org, so we expect at least the CRUD alias. - const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" }); + const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox"); await teamSelect.click(); await page.keyboard.type(E2E_TEAM_CRUD_ALIAS); - await expect(page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first()).toBeVisible({ - timeout: 5_000, - }); + const dropdown = page.locator('[data-slot="combobox-content"]:visible'); + await expect(dropdown.getByText(E2E_TEAM_CRUD_ALIAS).first()).toBeVisible({ timeout: 5_000 }); }); test("Team info page omits the Settings tab for non-admin members", async ({ page }) => { diff --git a/tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts b/tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts index 1b048198456..c44305187f1 100644 --- a/tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts +++ b/tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts @@ -27,18 +27,18 @@ test.describe("Internal User with no team memberships", () => { await page.getByRole("button", { name: /Create New Key/i }).click(); await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 }); - const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" }); + const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox"); await teamSelect.click(); - const dropdown = page.locator(".ant-select-dropdown:visible").first(); + const dropdown = page.locator('[data-slot="combobox-content"]:visible').first(); await expect(dropdown).toBeVisible({ timeout: 5_000 }); // Wait for the settled-empty state, not a transient one. The dropdown shows - // a spinner while teams load and only swaps in "No teams found" once the - // request resolves with nothing (team_dropdown.tsx renders the spinner when - // isLoading and this copy otherwise). Asserting on it means a regression - // where teams DO load for this user fails here instead of racing a one-shot - // count() against an in-flight request. + // "Loading teams…" while teams load and only swaps in "No teams found" once + // the request resolves with nothing (team_dropdown.tsx passes both copies to + // PaginatedSearchSelect). Asserting on it means a regression where teams DO + // load for this user fails here instead of racing a one-shot count() against + // an in-flight request. await expect(dropdown.getByText("No teams found")).toBeVisible({ timeout: 10_000 }); await expect(dropdown.getByRole("option")).toHaveCount(0); }); diff --git a/tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts b/tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts index 7d5058a8140..68319154554 100644 --- a/tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts +++ b/tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts @@ -18,10 +18,10 @@ test.describe("Internal User with team memberships", () => { await page.getByRole("button", { name: /Create New Key/i }).click(); await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 }); - const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" }); + const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox"); await teamSelect.click(); - const dropdown = page.locator(".ant-select-dropdown:visible").first(); + const dropdown = page.locator('[data-slot="combobox-content"]:visible').first(); await expect(dropdown).toBeVisible({ timeout: 5_000 }); // Both seeded memberships render, and nothing else does — proving the diff --git a/tests/e2e/ui/tests/modelsPage/addModel.spec.ts b/tests/e2e/ui/tests/modelsPage/addModel.spec.ts index 461d9dfd9f8..1b11ea69f97 100644 --- a/tests/e2e/ui/tests/modelsPage/addModel.spec.ts +++ b/tests/e2e/ui/tests/modelsPage/addModel.spec.ts @@ -328,11 +328,11 @@ test.describe("Add Model", () => { const teamByokRow = page.locator(".ant-form-item", { hasText: "Team-BYOK Model" }); await teamByokRow.getByRole("switch").click(); - // TeamDropdown's options carry custom markup and no role="option", so match by text. - const teamDropdown = page.getByTestId("team-dropdown"); + // TeamDropdown options show the alias above the team id, so match on the id line by text. + const teamDropdown = page.getByTestId("team-dropdown").getByRole("combobox"); await expect(teamDropdown).toBeVisible({ timeout: 5_000 }); await teamDropdown.click(); - const teamOption = page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ID).first(); + const teamOption = page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ID).first(); await expect(teamOption).toBeVisible({ timeout: 5_000 }); await teamOption.click(); diff --git a/tests/e2e/ui/tests/proxy-admin/keys.spec.ts b/tests/e2e/ui/tests/proxy-admin/keys.spec.ts index 1ff3ef274b6..d9b0f959c9f 100644 --- a/tests/e2e/ui/tests/proxy-admin/keys.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/keys.spec.ts @@ -40,11 +40,11 @@ test.describe("Proxy Admin - Keys", () => { const keyName = `e2e-admin-key-${Date.now()}`; await page.getByTestId("base-input").fill(keyName); - // Select team — the team dropdown has placeholder "Search or select a team" - const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" }); + // Select team + const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox"); await teamSelect.click(); await page.keyboard.type(E2E_TEAM_CRUD_ALIAS); - await page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first().click(); + await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click(); // Select models await page.locator(".ant-select-selection-overflow").click(); @@ -157,7 +157,7 @@ test.describe("Proxy Admin - Keys", () => { await page.getByRole("button", { name: "More key actions" }).click(); await page.getByRole("menuitem", { name: "Delete Key" }).click(); - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Delete Key" }); await expect(modal).toBeVisible({ timeout: 5_000 }); await modal.locator("input").fill(E2E_DELETE_KEY_ALIAS); diff --git a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts index 92d22f11f4d..37693c5d49d 100644 --- a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts @@ -129,7 +129,7 @@ test.describe("Proxy Admin - Teams", () => { await teamRow.locator('[data-testid^="team-actions-"]').click(); await page.getByTestId("team-action-delete").click(); - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Delete Team?" }); await expect(modal).toBeVisible({ timeout: 5_000 }); await modal.locator("input").fill(E2E_TEAM_DELETE_ALIAS); await modal.getByRole("button", { name: /Force Delete|Delete/i }).click(); diff --git a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts index be4526f7089..d71d5e6c0fe 100644 --- a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts +++ b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts @@ -105,7 +105,7 @@ test.describe("Team Admin", () => { await expect(row).toBeVisible({ timeout: 10_000 }); await row.getByTestId("delete-member").click(); - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Delete Team Member" }); await expect(modal).toBeVisible({ timeout: 5_000 }); const remove = await captureRequestBody(page, { method: "POST", urlIncludes: "/team/member_delete" }, async () => { @@ -139,10 +139,10 @@ test.describe("Team Admin", () => { await page.getByTestId("base-input").fill(keyName); // Team selector — same locator pattern as the proxy-admin keys test. - const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" }); + const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox"); await teamSelect.click(); await page.keyboard.type(E2E_TEAM_CRUD_ALIAS); - await page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first().click(); + await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click(); // Models — pick "All Team Models" await page.locator(".ant-select-selection-overflow").click(); diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 85bc950af97..e0cdaf9d388 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -228,7 +228,7 @@ "count": 2 }, "no-restricted-imports": { - "count": 2 + "count": 1 } }, "src/app/(dashboard)/cost-tracking/_components/how_it_works.tsx": { @@ -239,9 +239,6 @@ "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx": { @@ -252,9 +249,6 @@ "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.tsx": { @@ -275,17 +269,11 @@ "src/app/(dashboard)/cost-tracking/_components/provider_discount_table.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/cost-tracking/_components/use_discount_config.ts": { @@ -1709,54 +1697,26 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "prefer-const": { "count": 4 } }, - "src/components/AIHub/SkillHubDashboard.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/AIHub/UsefulLinksManagement.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/components/AIHub/forms/MakeAgentPublicForm.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/components/AIHub/forms/MakeMCPPublicForm.test.tsx": { - "react/display-name": { - "count": 1 - } - }, "src/components/AIHub/forms/MakeMCPPublicForm.tsx": { - "no-nested-ternary": { - "count": 2 - }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/components/AIHub/forms/MakeModelPublicForm.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1784,11 +1744,6 @@ "count": 1 } }, - "src/components/DebugWarningBanner.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/DeprecationBanner.tsx": { "no-restricted-imports": { "count": 1 @@ -1840,11 +1795,6 @@ "count": 1 } }, - "src/components/LicenseExpiryBanner.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/ModelSelect/ModelSelect.tsx": { "no-restricted-imports": { "count": 1 @@ -1855,39 +1805,11 @@ "count": 12 } }, - "src/components/Navbar/BlogDropdown/BlogDropdown.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/Navbar/NotificationsBell/NotificationsBell.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Navbar/UserDropdown/UserDropdown.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/components/Navbar/ViewSwitcher.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/SCIM.tsx": { "no-restricted-imports": { "count": 2 @@ -2295,9 +2217,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2372,15 +2291,7 @@ "count": 2 } }, - "src/components/common_components/DefaultProxyAdminTag.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/DeleteResourceModal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2409,29 +2320,15 @@ } }, "src/components/common_components/ModelAliasManager.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/components/common_components/ModelSelector.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/components/common_components/OrganizationDropdown.tsx": { - "local/no-complex-jsx-arrow": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/PassThroughGuardrailsSection.tsx": { "no-restricted-imports": { "count": 2 @@ -2440,29 +2337,11 @@ "count": 1 } }, - "src/components/common_components/PassThroughRoutesSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/PassThroughSecuritySection.tsx": { "no-restricted-imports": { "count": 2 } }, - "src/components/common_components/PremiumLoggingSettings.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/common_components/ProjectDropdown.tsx": { - "local/no-complex-jsx-arrow": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/RateLimitTypeFormItem.test.tsx": { "no-restricted-imports": { "count": 1 @@ -2473,22 +2352,9 @@ "count": 1 } }, - "src/components/common_components/RouterSettingsAccordion.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/budget_duration_dropdown.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/common_components/chartUtils.test.tsx": { - "no-restricted-imports": { - "count": 1 } }, "src/components/common_components/chartUtils.tsx": { @@ -2497,9 +2363,6 @@ }, "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/common_components/check_openapi_schema.tsx": { @@ -2524,17 +2387,11 @@ }, "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/common_components/team_dropdown.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/common_components/team_multi_select.tsx": { @@ -2666,9 +2523,6 @@ "src/components/logging_settings_view.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/mcp_server_management/MCPServerSelector.tsx": { @@ -2734,18 +2588,12 @@ "src/components/model_filters.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/model_group_alias_settings.tsx": { "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2807,9 +2655,6 @@ "src/components/navbar.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/networking.tsx": { @@ -2835,9 +2680,6 @@ "src/components/object_permissions_view.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/onboarding_link.tsx": { @@ -3028,15 +2870,9 @@ "local/filename-pascal-case": { "count": 1 }, - "local/no-complex-jsx-arrow": { - "count": 4 - }, "no-nested-ternary": { "count": 2 }, - "no-restricted-imports": { - "count": 3 - }, "prefer-const": { "count": 4 } @@ -3453,9 +3289,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "prefer-const": { "count": 1 }, @@ -3473,29 +3306,15 @@ "count": 1 } }, - "src/components/view_logs/CostBreakdownViewer.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/view_logs/EvalViewer/EvalViewer.tsx": { - "local/no-complex-jsx-arrow": { - "count": 1 - }, "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/view_logs/GuardrailViewer/CompliancePanel.tsx": { "no-nested-ternary": { "count": 2 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -3508,31 +3327,17 @@ "src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx": { "no-nested-ternary": { "count": 4 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx": { - "no-restricted-imports": { - "count": 1 } }, "src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx": { "no-nested-ternary": { "count": 3 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx": { "no-nested-ternary": { "count": 2 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -3542,36 +3347,11 @@ "count": 2 } }, - "src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts": { "react-hooks/immutability": { "count": 2 } }, - "src/components/view_logs/ToolsSection/FormattedToolView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/view_logs/ToolsSection/ToolExpandedContent.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/view_logs/ToolsSection/ToolItem.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/view_logs/VectorStoreViewer.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/view_logs/columns.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx index a1484ffb5c5..5e212901bd1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx @@ -1,4 +1,5 @@ import { renderWithProviders, screen, within } from "@/../tests/test-utils"; +import { waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AccessGroupsPage } from "./AccessGroupsPage"; @@ -215,7 +216,9 @@ describe("AccessGroupsPage", () => { await user.click(await openRowMenu(user, "ag-1")); const dialog = screen.getByRole("dialog", { name: "Delete Access Group" }); await user.click(within(dialog).getByRole("button", { name: "Cancel" })); - expect(screen.queryByRole("dialog", { name: "Delete Access Group" })).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "Delete Access Group" })).not.toBeInTheDocument(); + }); expect(mockMutate).not.toHaveBeenCalled(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx index 0dae83ba808..c53b7b618b2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx @@ -8,25 +8,29 @@ import CostTrackingSettings from "./cost_tracking_settings"; // Mock sub-hooks so we can control their state without network calls const mockDiscountConfig = vi.fn(() => ({})); const mockMarginConfig = vi.fn(() => ({})); +const mockRemoveDiscount = vi.fn(); +const mockRemoveMargin = vi.fn(); + +const stableDiscountCallbacks = { + fetchDiscountConfig: vi.fn().mockResolvedValue(undefined), + handleAddProvider: vi.fn().mockResolvedValue(true), + handleRemoveProvider: mockRemoveDiscount, + handleDiscountChange: vi.fn().mockResolvedValue(undefined), +}; + +const stableMarginCallbacks = { + fetchMarginConfig: vi.fn().mockResolvedValue(undefined), + handleAddMargin: vi.fn().mockResolvedValue(true), + handleRemoveMargin: mockRemoveMargin, + handleMarginChange: vi.fn().mockResolvedValue(undefined), +}; vi.mock("./use_discount_config", () => ({ - useDiscountConfig: () => ({ - discountConfig: mockDiscountConfig(), - fetchDiscountConfig: vi.fn().mockResolvedValue(undefined), - handleAddProvider: vi.fn().mockResolvedValue(true), - handleRemoveProvider: vi.fn().mockResolvedValue(undefined), - handleDiscountChange: vi.fn().mockResolvedValue(undefined), - }), + useDiscountConfig: () => ({ discountConfig: mockDiscountConfig(), ...stableDiscountCallbacks }), })); vi.mock("./use_margin_config", () => ({ - useMarginConfig: () => ({ - marginConfig: mockMarginConfig(), - fetchMarginConfig: vi.fn().mockResolvedValue(undefined), - handleAddMargin: vi.fn().mockResolvedValue(true), - handleRemoveMargin: vi.fn().mockResolvedValue(undefined), - handleMarginChange: vi.fn().mockResolvedValue(undefined), - }), + useMarginConfig: () => ({ marginConfig: mockMarginConfig(), ...stableMarginCallbacks }), })); vi.mock("./pricing_calculator/index", () => ({ @@ -153,6 +157,57 @@ describe("CostTrackingSettings", () => { }); }); + describe("removing a configured provider", () => { + const expandAndRemove = async (section: string, actionName: string) => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText(section).closest("button")!); + await user.click(await screen.findByRole("button", { name: actionName })); + + return user; + }; + + it("should ask to confirm before removing a discount", async () => { + mockDiscountConfig.mockReturnValue({ openai: 0.05 }); + + await expandAndRemove("Provider Discounts", "Remove discount for openai"); + + expect(await screen.findByRole("button", { name: "Remove" })).toBeInTheDocument(); + expect(screen.getByText(/are you sure you want to remove the discount for openai\?/i)).toBeInTheDocument(); + expect(mockRemoveDiscount).not.toHaveBeenCalled(); + }); + + it("should remove the discount once removal is confirmed", async () => { + mockDiscountConfig.mockReturnValue({ openai: 0.05 }); + + const user = await expandAndRemove("Provider Discounts", "Remove discount for openai"); + await user.click(await screen.findByRole("button", { name: "Remove" })); + + expect(mockRemoveDiscount).toHaveBeenCalledWith("openai"); + }); + + it("should leave the discount in place when the confirmation is cancelled", async () => { + mockDiscountConfig.mockReturnValue({ openai: 0.05 }); + + const user = await expandAndRemove("Provider Discounts", "Remove discount for openai"); + await user.click(await screen.findByRole("button", { name: "Cancel" })); + + expect(mockRemoveDiscount).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Remove" })).not.toBeInTheDocument(); + }); + + it("should remove the margin once removal is confirmed", async () => { + mockMarginConfig.mockReturnValue({ openai: 0.1 }); + + const user = await expandAndRemove("Fee/Price Margin", "Remove margin for openai"); + expect(screen.getByText(/are you sure you want to remove the margin for openai\?/i)).toBeInTheDocument(); + await user.click(await screen.findByRole("button", { name: "Remove" })); + + expect(mockRemoveMargin).toHaveBeenCalledWith("openai"); + }); + }); + describe("empty state messages", () => { it("should show the empty state message when no discount config is loaded", async () => { mockDiscountConfig.mockReturnValue({}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx index b32e7afd756..ba2d830ae7b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx @@ -1,25 +1,25 @@ import React, { useState, useEffect } from "react"; -import { - Title, - Text, - Button, - Accordion, - AccordionHeader, - AccordionBody, - TabGroup, - TabList, - Tab, - TabPanels, - TabPanel, -} from "@tremor/react"; +import { ChevronDown } from "lucide-react"; import { Modal, Form } from "antd"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { CostTrackingSettingsProps } from "./types"; import ProviderDiscountTable from "./provider_discount_table"; import AddProviderForm from "./add_provider_form"; import ProviderMarginTable from "./provider_margin_table"; import AddMarginForm from "./add_margin_form"; import PricingCalculator from "./pricing_calculator/index"; -import { ExclamationCircleOutlined } from "@ant-design/icons"; import { DocsMenu } from "@/components/HelpLink"; import HowItWorks from "./how_it_works"; import { useDiscountConfig } from "./use_discount_config"; @@ -31,6 +31,29 @@ const DOCS_LINKS = [ { label: "Spend tracking", href: "https://docs.litellm.ai/docs/proxy/cost_tracking" }, ]; +const REMOVAL_COPY = { + discount: { title: "Remove Provider Discount", noun: "discount" }, + margin: { title: "Remove Provider Margin", noun: "margin" }, +} as const; + +interface PendingRemoval { + kind: keyof typeof REMOVAL_COPY; + provider: string; + displayName: string; +} + +const SECTION_HEADER_CLASS = "group/section flex w-full items-center justify-between px-6 py-4 text-left"; + +const SectionHeader: React.FC<{ title: string; description: string }> = ({ title, description }) => ( + +
+ {title} + {description} +
+ +
+); + const CostTrackingSettings: React.FC = ({ userID, userRole, accessToken }) => { const [selectedProvider, setSelectedProvider] = useState(undefined); const [newDiscount, setNewDiscount] = useState(""); @@ -42,9 +65,9 @@ const CostTrackingSettings: React.FC = ({ userID, use const [percentageValue, setPercentageValue] = useState(""); const [fixedAmountValue, setFixedAmountValue] = useState(""); const [models, setModels] = useState([]); + const [pendingRemoval, setPendingRemoval] = useState(null); const [form] = Form.useForm(); const [marginForm] = Form.useForm(); - const [modal, contextHolder] = Modal.useModal(); const isProxyAdmin = userRole === "proxy_admin" || userRole === "Admin"; @@ -104,16 +127,18 @@ const CostTrackingSettings: React.FC = ({ userID, use handleAddProvider(); }; - const handleRemoveProvider = async (provider: string, providerDisplayName: string) => { - modal.confirm({ - title: "Remove Provider Discount", - icon: , - content: `Are you sure you want to remove the discount for ${providerDisplayName}?`, - okText: "Remove", - okType: "danger", - cancelText: "Cancel", - onOk: () => removeProvider(provider), - }); + const handleRemoveProvider = (provider: string, providerDisplayName: string) => { + setPendingRemoval({ kind: "discount", provider, displayName: providerDisplayName }); + }; + + const handleConfirmRemoval = () => { + if (!pendingRemoval) return; + if (pendingRemoval.kind === "discount") { + removeProvider(pendingRemoval.provider); + } else { + removeMargin(pendingRemoval.provider); + } + setPendingRemoval(null); }; const handleAddMargin = async () => { @@ -141,16 +166,8 @@ const CostTrackingSettings: React.FC = ({ userID, use setMarginType("percentage"); }; - const handleRemoveMargin = async (provider: string, providerDisplayName: string) => { - modal.confirm({ - title: "Remove Provider Margin", - icon: , - content: `Are you sure you want to remove the margin for ${providerDisplayName}?`, - okText: "Remove", - okType: "danger", - cancelText: "Cancel", - onOk: () => removeMargin(provider), - }); + const handleRemoveMargin = (provider: string, providerDisplayName: string) => { + setPendingRemoval({ kind: "margin", provider, displayName: providerDisplayName }); }; if (!accessToken) { @@ -159,18 +176,16 @@ const CostTrackingSettings: React.FC = ({ userID, use return (
- {contextHolder} - {/* Header Section - Outside the card */}
- Cost Tracking Settings +

Cost Tracking Settings

- +

Configure cost discounts and margins for different LLM providers. Changes are saved automatically. - +

@@ -178,90 +193,78 @@ const CostTrackingSettings: React.FC = ({ userID, use
{/* Accordion 1: Provider Discounts - Only for proxy admins */} {isProxyAdmin && ( - - -
- Provider Discounts - - Apply percentage-based discounts to reduce costs for specific providers - -
-
- - - - Discounts - Test It - - - -
-
- + + + + + + Discounts + Test It + + +
+
+ +
+ {isFetching ? ( +
+

Loading configuration...

- {isFetching ? ( -
- Loading configuration... -
- ) : Object.keys(discountConfig).length > 0 ? ( - - ) : ( -
- - - - No provider discounts configured - - Click "Add Provider Discount" to get started - -
- )} -
- - -
- -
-
- - - - + ) : Object.keys(discountConfig).length > 0 ? ( + + ) : ( +
+ + + +

No provider discounts configured

+

Click "Add Provider Discount" to get started

+
+ )} +
+ + +
+ +
+
+ + + )} {/* Accordion 2: Fee/Price Margin - Only for proxy admins */} {isProxyAdmin && ( - - -
- Fee/Price Margin - - Add fees or margins to LLM costs for internal billing and cost recovery - -
-
- + + +
{isFetching ? (
- Loading configuration... +

Loading configuration...

) : Object.keys(marginConfig).length > 0 ? ( = ({ userID, use d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> - No provider margins configured - Click "Add Provider Margin" to get started +

No provider margins configured

+

Click "Add Provider Margin" to get started

)}
-
-
+ + )} {/* Accordion 3: Pricing Calculator - Available to all roles */} - - -
- Pricing Calculator - - Estimate LLM costs based on expected token usage and request volume - -
-
- + + +
-
-
+ +
+ {pendingRemoval && ( + !open && setPendingRemoval(null)}> + + + {REMOVAL_COPY[pendingRemoval.kind].title} + + Are you sure you want to remove the {REMOVAL_COPY[pendingRemoval.kind].noun} for{" "} + {pendingRemoval.displayName}? + + + + Cancel + + Remove + + + + + )} + @@ -328,10 +347,10 @@ const CostTrackingSettings: React.FC = ({ userID, use }} >
- +

Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5% discount). - +

= ({ userID, use }} >
- +

Select a provider (or "Global" for all providers) and configure the margin. You can use percentage-based or fixed amount. - +

+ within(screen.getByRole("table")) + .getAllByRole("row") + .filter((row) => within(row).queryAllByRole("combobox").length > 0); + +const deleteButtonIn = (row: HTMLElement): HTMLElement => { + const cells = within(row).getAllByRole("cell"); + return within(cells[cells.length - 1]).getByRole("button"); +}; + describe("PricingCalculator", () => { beforeEach(() => { vi.clearAllMocks(); @@ -124,8 +134,31 @@ describe("PricingCalculator", () => { it("should render column headers for Model, Input Tokens, and Output Tokens", () => { renderWithProviders(); - expect(screen.getByText("Model")).toBeInTheDocument(); - expect(screen.getByText("Input Tokens")).toBeInTheDocument(); - expect(screen.getByText("Output Tokens")).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Input Tokens" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Output Tokens" })).toBeInTheDocument(); + }); + + it("should render a numeric field for input tokens, output tokens and requests", () => { + renderWithProviders(); + expect(screen.getAllByRole("spinbutton")).toHaveLength(3); + }); + + it("should offer a model picker per row", () => { + renderWithProviders(); + expect(screen.getAllByRole("combobox")).toHaveLength(1); + }); + + it("should remove a row when its delete button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /add another model/i })); + const withTwoRows = dataRows(); + expect(withTwoRows).toHaveLength(2); + + await user.click(deleteButtonIn(withTwoRows[1])); + + expect(dataRows()).toHaveLength(1); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx index 9b355e55c1c..f3bd74260ad 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx @@ -1,6 +1,10 @@ import React, { useState, useCallback } from "react"; -import { Table, Select, InputNumber, Button, Radio } from "antd"; -import { DeleteOutlined, PlusOutlined } from "@ant-design/icons"; +import { Plus, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { SearchSelect } from "@/components/shared/SearchSelect"; import { PricingCalculatorProps, ModelEntry } from "./types"; import MultiCostResults from "./multi_cost_results"; import { useMultiCostEstimate } from "./use_multi_cost_estimate"; @@ -63,132 +67,115 @@ const PricingCalculator: React.FC = ({ accessToken, mode const multiModelResult = getMultiModelResult(entries); - const columns = [ - { - title: "Model", - dataIndex: "model", - key: "model", - width: "35%", - render: (_: string, record: ModelEntry) => ( - + handleEntryChange(record.id, "input_tokens", e.target.value === "" ? 0 : Number(e.target.value)) + } + /> + + + + handleEntryChange(record.id, "output_tokens", e.target.value === "" ? 0 : Number(e.target.value)) + } + /> + + + + handleEntryChange( + record.id, + requestsField, + e.target.value === "" ? undefined : Number(e.target.value), + ) + } + /> + + + + + + ))} + + + + + + + + +
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx index 04ef60469f0..b17dd2cb859 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx @@ -85,6 +85,14 @@ function emptyMultiResult(): MultiModelResult { }; } +const expandToggle = (): HTMLElement => screen.getByRole("button", { name: /cost breakdown for / }); + +const shownBreakdown = (): HTMLElement | null => { + const label = screen.queryByText("Total/Request"); + if (label === null) return null; + return label.closest("[style*='display: none']") === null ? label : null; +}; + describe("MultiCostResults", () => { beforeEach(() => { vi.clearAllMocks(); @@ -200,40 +208,78 @@ describe("MultiCostResults", () => { expect(screen.getByRole("button", { name: /export/i })).toBeInTheDocument(); }); + it("should render a column header for each summary column", () => { + renderWithProviders(); + + expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Per Request" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Margin Fee" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Daily" })).toBeInTheDocument(); + }); + + it("should not show the model breakdown before the row is expanded", () => { + renderWithProviders(); + expect(shownBreakdown()).toBeNull(); + }); + it("should expand the model breakdown row when the expand button is clicked", async () => { const user = userEvent.setup(); renderWithProviders(); - // The expand column renders a button (RightOutlined icon) for rows without errors - const expandButtons = screen.getAllByRole("button"); - // Find the small expand button (not the Export button) - const expandButton = expandButtons.find((btn) => !btn.textContent?.toLowerCase().includes("export")); - expect(expandButton).toBeDefined(); + await user.click(expandToggle()); - await user.click(expandButton!); - - // After expanding, the SingleModelBreakdown should be visible - expect(screen.getByText("Total/Request")).toBeInTheDocument(); + expect(shownBreakdown()).toBeVisible(); + expect(screen.getByText("Daily Total (100 req)")).toBeInTheDocument(); }); - it("should show the collapse icon after expanding a row", async () => { + it("should collapse the model breakdown again on a second click", async () => { const user = userEvent.setup(); renderWithProviders(); - const getExpandButton = () => { - const allButtons = screen.getAllByRole("button"); - return allButtons.find((btn) => !btn.textContent?.toLowerCase().includes("export")); - }; + await user.click(expandToggle()); + expect(shownBreakdown()).toBeVisible(); - // Before expand: button has the "down" aria-label (RightOutlined renders as down in ant icons) - // Just verify clicking works and the breakdown content appears - await user.click(getExpandButton()!); - expect(screen.getByText("Total/Request")).toBeInTheDocument(); + await user.click(expandToggle()); + expect(shownBreakdown()).toBeNull(); + }); - // After a second click, the row collapses — content may be hidden or removed - await user.click(getExpandButton()!); - // The expanded content should no longer be visible - expect(screen.queryByText("Total/Request")).not.toBeVisible(); + it("should name the breakdown toggle and report its expanded state", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const toggle = screen.getByRole("button", { name: "Show cost breakdown for gpt-4" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + + await user.click(toggle); + + const collapseToggle = screen.getByRole("button", { name: "Hide cost breakdown for gpt-4" }); + expect(collapseToggle).toHaveAttribute("aria-expanded", "true"); + }); + + it("should not offer an expand toggle for a row that failed", () => { + renderWithProviders( + , + ); + + expect(screen.getAllByRole("button", { name: /cost breakdown for / })).toHaveLength(1); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx index 3ea7ea58127..b8375b930c9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx @@ -1,7 +1,11 @@ import React, { useState } from "react"; -import { Text, Button } from "@tremor/react"; -import { Card, Statistic, Row, Col, Divider, Spin, Table, Tag } from "antd"; -import { LoadingOutlined, DownOutlined, RightOutlined } from "@ant-design/icons"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { CostEstimateResponse } from "../types"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { MultiModelResult } from "./types"; @@ -41,55 +45,57 @@ const SingleModelBreakdown: React.FC<{
{loading && (
- } size="small" /> + Updating...
)}
-
- Total/Request - {formatCost(result.cost_per_request)} +
+

Total/Request

+

{formatCost(result.cost_per_request)}

-
- Input Cost - {formatCost(result.input_cost_per_request)} +
+

Input Cost

+

{formatCost(result.input_cost_per_request)}

-
- Output Cost - {formatCost(result.output_cost_per_request)} +
+

Output Cost

+

{formatCost(result.output_cost_per_request)}

-
- Margin Fee - 0 ? "text-amber-600" : ""}`}> +
+

Margin Fee

+

0 ? "text-amber-600" : ""}`}> {formatCost(result.margin_cost_per_request)} - +

{periodCost !== null && (
-
- +
+

{periodLabel} Total ({formatRequests(periodRequests)} req) - - +

+

{formatCost(periodCost)} - +

-
- {periodLabel} Input - {formatCost(periodInputCost)} +
+

{periodLabel} Input

+

{formatCost(periodInputCost)}

-
- {periodLabel} Output - {formatCost(periodOutputCost)} +
+

{periodLabel} Output

+

{formatCost(periodOutputCost)}

-
- {periodLabel} Margin Fee - 0 ? "text-amber-600" : ""}`}> +
+

{periodLabel} Margin Fee

+

0 ? "text-amber-600" : ""}`}> {formatCost(periodMarginCost)} - +

)} @@ -124,7 +130,7 @@ const MultiCostResults: React.FC = ({ multiResult, timePe if (!hasAnyResult && !isAnyLoading && !hasAnyError) { return (
- Select models above to see cost estimates +

Select models above to see cost estimates

); } @@ -133,8 +139,8 @@ const MultiCostResults: React.FC = ({ multiResult, timePe if (!hasAnyResult && isAnyLoading && !hasAnyError) { return (
- } /> - Calculating costs... + +

Calculating costs...

); } @@ -143,10 +149,10 @@ const MultiCostResults: React.FC = ({ multiResult, timePe if (!hasAnyResult && hasAnyError) { return (
- +
- Cost Estimates - {isAnyLoading && } size="small" />} +

Cost Estimates

+ {isAnyLoading && }
{/* Error Messages */} {errorEntries.map((e) => ( @@ -174,102 +180,10 @@ const MultiCostResults: React.FC = ({ multiResult, timePe const hasMargin = multiResult.totals.margin_per_request > 0; const periodLabel = timePeriod === "day" ? "Daily" : "Monthly"; - const periodCostKey = timePeriod === "day" ? "daily_cost" : "monthly_cost"; - - const summaryColumns = [ - { - title: "Model", - dataIndex: "model", - key: "model", - render: ( - text: string, - record: { - id: string; - provider?: string | null; - error?: string | null; - loading?: boolean; - hasZeroCost?: boolean | null; - }, - ) => ( -
-
- {text} - {record.provider && ( - - {record.provider} - - )} - {record.loading && } size="small" />} -
- {record.error &&
⚠️ {record.error}
} - {record.hasZeroCost && !record.error && ( -
- ⚠️ No pricing data found for this model. Set base_model in config. -
- )} -
- ), - }, - { - title: "Per Request", - dataIndex: "cost_per_request", - key: "cost_per_request", - align: "right" as const, - render: (value: number | null, record: { error?: string | null }) => - record.error ? ( - - - ) : ( - {formatCost(value)} - ), - }, - { - title: "Margin Fee", - dataIndex: "margin_cost_per_request", - key: "margin_cost_per_request", - align: "right" as const, - render: (value: number | null, record: { error?: string | null }) => - record.error ? ( - - - ) : ( - 0 ? "text-amber-600" : "text-gray-400"}`}> - {formatCost(value)} - - ), - }, - { - title: periodLabel, - dataIndex: periodCostKey, - key: "period_cost", - align: "right" as const, - render: (value: number | null, record: { error?: string | null }) => - record.error ? ( - - - ) : ( - {formatCost(value)} - ), - }, - { - title: "", - key: "expand", - width: 40, - render: (_: unknown, record: { id: string; error?: string | null }) => - record.error ? null : ( - - ), - }, - ]; // Include both valid results and errors in the table data const allEntriesWithModels = multiResult.entries.filter((e) => e.entry.model); const summaryData = allEntriesWithModels.map((e) => ({ - key: e.entry.id, id: e.entry.id, model: e.result?.model || e.entry.model, provider: e.result?.provider, @@ -284,78 +198,153 @@ const MultiCostResults: React.FC = ({ multiResult, timePe return (
- +
- Cost Estimates +

Cost Estimates

- {isAnyLoading && } size="small" />} + {isAnyLoading && }
{/* Combined Totals - Always show when there are results */} - - - - Total Per Request} - value={formatCost(multiResult.totals.cost_per_request)} - valueStyle={{ color: "#1890ff", fontSize: "18px", fontFamily: "monospace" }} - /> - - - Total {periodLabel}} - value={formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)} - valueStyle={{ - color: timePeriod === "day" ? "#52c41a" : "#722ed1", - fontSize: "18px", - fontFamily: "monospace", - }} - /> - - + +
+
+ Total Per Request +
+ {formatCost(multiResult.totals.cost_per_request)} +
+
+
+ Total {periodLabel} +
+ {formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)} +
+
+
{hasMargin && ( - - +
+
Margin Fee/Request
-
+
{formatCost(multiResult.totals.margin_per_request)}
- - +
+
{periodLabel} Margin Fee
-
+
{formatCost(timePeriod === "day" ? multiResult.totals.daily_margin : multiResult.totals.monthly_margin)}
- - +
+
)} {/* Per-Model Table */} {summaryData.length > 0 && ( - { - const entry = validEntries.find((e) => e.entry.id === record.id); - if (!entry?.result) return null; +
+ + + Model + Per Request + Margin Fee + {periodLabel} + + Cost breakdown + + + + + {summaryData.map((record) => { + const isExpanded = expandedModels.has(record.id); + const periodCost = timePeriod === "day" ? record.daily_cost : record.monthly_cost; + const breakdownEntry = validEntries.find((e) => e.entry.id === record.id); return ( -
- -
+ + + +
+
+ {record.model} + {record.provider && ( + + {record.provider} + + )} + {record.loading && } +
+ {record.error && ( +
⚠️ {record.error}
+ )} + {record.hasZeroCost && !record.error && ( +
+ ⚠️ No pricing data found for this model. Set base_model in config. +
+ )} +
+
+ + {record.error ? ( + - + ) : ( + {formatCost(record.cost_per_request)} + )} + + + {record.error ? ( + - + ) : ( + 0 ? "text-amber-600" : "text-gray-400"}`} + > + {formatCost(record.margin_cost_per_request)} + + )} + + + {record.error ? ( + - + ) : ( + {formatCost(periodCost)} + )} + + + {!record.error && ( + + )} + +
+ {isExpanded && breakdownEntry?.result && ( + + +
+ +
+
+
+ )} +
); - }, - showExpandColumn: false, - }} - /> + })} +
+
)}
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx index f9a0a40f07d..24280873cf0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx @@ -5,49 +5,21 @@ import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../../../../tests/test-utils"; import ProviderDiscountTable from "./provider_discount_table"; -vi.mock("@heroicons/react/outline", () => ({ - TrashIcon: function TrashIcon() { - return null; - }, - PencilAltIcon: function PencilAltIcon() { - return null; - }, - CheckIcon: function CheckIcon() { - return null; - }, - XIcon: function XIcon() { - return null; - }, -})); - -vi.mock("@tremor/react", () => ({ - Table: ({ children }: any) => {children}
, - TableHead: ({ children }: any) => {children}, - TableRow: ({ children }: any) => {children}, - TableHeaderCell: ({ children }: any) => {children}, - TableBody: ({ children }: any) => {children}, - TableCell: ({ children }: any) => {children}, - Text: ({ children }: any) => {children}, - TextInput: ({ value, onValueChange, onKeyDown, placeholder, ...rest }: any) => ( - onValueChange?.(e.target.value)} - onKeyDown={onKeyDown} - placeholder={placeholder} - {...rest} - /> - ), - Icon: ({ icon: IconComponent, onClick }: any) => { - const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon"; - return + + + ) : ( + <> +

{(row.discount * 100).toFixed(1)}%

+ + + )} +
+ ); + }, width: "250px", }, { @@ -125,12 +138,15 @@ const ProviderDiscountTable: React.FC = ({ cell: (row) => { const { displayName } = getProviderLogoAndName(row.provider); return ( - onRemoveProvider(row.provider, displayName)} className="cursor-pointer hover:text-red-600" - /> + > + + ); }, width: "80px", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx index 170e61141b6..dd478571568 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx @@ -6,43 +6,15 @@ import { renderWithProviders } from "../../../../../tests/test-utils"; import ProviderMarginTable from "./provider_margin_table"; import { Providers, providerLogoMap } from "@/components/provider_info_helpers"; -vi.mock("@heroicons/react/outline", () => ({ - TrashIcon: function TrashIcon() { - return null; - }, - PencilAltIcon: function PencilAltIcon() { - return null; - }, - CheckIcon: function CheckIcon() { - return null; - }, - XIcon: function XIcon() { - return null; - }, -})); +const ROW_ACTION_NAME = { + edit: /^Edit margin for /, + save: /^Save margin for /, + cancel: /^Cancel editing margin for /, + remove: /^Remove margin for /, +} as const; -vi.mock("@tremor/react", () => ({ - Table: ({ children }: any) => {children}
, - TableHead: ({ children }: any) => {children}, - TableRow: ({ children }: any) => {children}, - TableHeaderCell: ({ children }: any) => {children}, - TableBody: ({ children }: any) => {children}, - TableCell: ({ children }: any) => {children}, - Text: ({ children }: any) => {children}, - TextInput: ({ value, onValueChange, placeholder, autoFocus, className }: any) => ( - onValueChange?.(e.target.value)} - placeholder={placeholder} - autoFocus={autoFocus} - className={className} - /> - ), - Icon: ({ icon: IconComponent, onClick }: any) => { - const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon"; - return + + + ) : ( + <> +

{formatMargin(row.margin)}

+ + + )} +
+ ); + }, width: "350px", }, { header: "Actions", cell: (row) => { - const displayName = row.provider === "global" ? "Global" : getProviderLogoAndName(row.provider).displayName; + const displayName = marginRowDisplayName(row.provider); return ( - onRemoveProvider(row.provider, displayName)} className="cursor-pointer hover:text-red-600" - /> + > + + ); }, width: "80px", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx index 237e3b2c842..29c9b17cb21 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx @@ -244,7 +244,7 @@ describe("UserInfoView", () => { }); // The DeleteResourceModal's OK button has text "Delete" - find it within the modal - const modal = screen.getByText("Remove from Team").closest(".ant-modal") as HTMLElement; + const modal = screen.getByRole("dialog", { name: "Remove from Team" }); const deleteConfirmButton = within(modal).getByRole("button", { name: /delete/i }); await user.click(deleteConfirmButton); diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 0e4de3f244c..28dac9badcd 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -23,10 +23,12 @@ import { import PublicModelHub from "@/components/public_model_hub"; import { copyToClipboard } from "@/utils/dataUtils"; import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; -import { CopyOutlined } from "@ant-design/icons"; import { SortingState } from "@tanstack/react-table"; -import { Badge, Button, Card, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react"; -import { Modal } from "antd"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Copy, Inbox } from "lucide-react"; import { useRouter } from "next/navigation"; import React, { useCallback, useEffect, useMemo, useState } from "react"; @@ -393,7 +395,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, {/* Header with Title, Description and URL */}
- AI Hub +

AI Hub

{isAdminRole(userRole || "") ? (

Make models, agents, and MCP servers public for developers to know what's available. @@ -403,9 +405,9 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, )}

- Model Hub URL: +

Model Hub URL:

- {`${getProxyBaseUrl()}/ui/model_hub_table`} +

{`${getProxyBaseUrl()}/ui/model_hub_table`}

@@ -568,167 +570,162 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, setSkillHubData(response.plugins); }} /> - - - + +
+
) : ( - - Public Model Hub not enabled. + +

Public Model Hub not enabled.

Ask your proxy admin to enable this on their Admin UI.

)} {/* Public Page Modal */} - -
-
- Shareable Link: - - {`${getProxyBaseUrl()}/ui/model_hub_table`} - + !open && handleCancel()}> + + + {"Public Model Hub"} + +
+
+

Shareable Link:

+

+ {`${getProxyBaseUrl()}/ui/model_hub_table`} +

+
+
+ +
-
- -
-
- + + {/* Model Details Modal */} - - {selectedModel && ( -
- {/* Model Overview */} -
- Model Overview -
-
- Model Group: - {selectedModel.model_group} + !open && handleCancel()}> + + + {selectedModel?.model_group || "Model Details"} + + {selectedModel && ( +
+ {/* Model Overview */} +
+

Model Overview

+
+
+

Model Group:

+

{selectedModel.model_group}

+
+
+

Mode:

+

{selectedModel.mode || "Not specified"}

+
+
+

Providers:

+
+ {selectedModel.providers.map((provider) => ( + + {provider} + + ))} +
+
-
- Mode: - {selectedModel.mode || "Not specified"} +
+ + {/* Token and Cost Information */} +
+

Token & Cost Information

+
+
+

Max Input Tokens:

+

{selectedModel.max_input_tokens?.toLocaleString() || "Not specified"}

+
+
+

Max Output Tokens:

+

{selectedModel.max_output_tokens?.toLocaleString() || "Not specified"}

+
+
+

Input Cost per 1M Tokens:

+

+ {selectedModel.input_cost_per_token + ? formatCost(selectedModel.input_cost_per_token) + : "Not specified"} +

+
+
+

Output Cost per 1M Tokens:

+

+ {selectedModel.output_cost_per_token + ? formatCost(selectedModel.output_cost_per_token) + : "Not specified"} +

+
+
+ + {/* Capabilities */} +
+

Capabilities

+
+ {(() => { + const capabilities = getModelCapabilities(selectedModel); + const colors = ["green", "blue", "purple", "orange", "red", "yellow"]; + + if (capabilities.length === 0) { + return

No special capabilities listed

; + } + + return capabilities.map((capability, index) => ( + + {formatCapabilityName(capability)} + + )); + })()} +
+
+ + {/* Rate Limits */} + {(selectedModel.tpm || selectedModel.rpm) && (
- Providers: -
- {selectedModel.providers.map((provider) => ( - - {provider} +

Rate Limits

+
+ {selectedModel.tpm && ( +
+

Tokens per Minute:

+

{selectedModel.tpm.toLocaleString()}

+
+ )} + {selectedModel.rpm && ( +
+

Requests per Minute:

+

{selectedModel.rpm.toLocaleString()}

+
+ )} +
+
+ )} + + {/* Supported OpenAI Parameters */} + {selectedModel.supported_openai_params && ( +
+

Supported OpenAI Parameters

+
+ {selectedModel.supported_openai_params.map((param) => ( + + {param} ))}
-
-
+ )} - {/* Token and Cost Information */} -
- Token & Cost Information -
-
- Max Input Tokens: - {selectedModel.max_input_tokens?.toLocaleString() || "Not specified"} -
-
- Max Output Tokens: - {selectedModel.max_output_tokens?.toLocaleString() || "Not specified"} -
-
- Input Cost per 1M Tokens: - - {selectedModel.input_cost_per_token - ? formatCost(selectedModel.input_cost_per_token) - : "Not specified"} - -
-
- Output Cost per 1M Tokens: - - {selectedModel.output_cost_per_token - ? formatCost(selectedModel.output_cost_per_token) - : "Not specified"} - -
-
-
- - {/* Capabilities */} -
- Capabilities -
- {(() => { - const capabilities = getModelCapabilities(selectedModel); - const colors = ["green", "blue", "purple", "orange", "red", "yellow"]; - - if (capabilities.length === 0) { - return No special capabilities listed; - } - - return capabilities.map((capability, index) => ( - - {formatCapabilityName(capability)} - - )); - })()} -
-
- - {/* Rate Limits */} - {(selectedModel.tpm || selectedModel.rpm) && ( + {/* Usage Example */}
- Rate Limits -
- {selectedModel.tpm && ( -
- Tokens per Minute: - {selectedModel.tpm.toLocaleString()} -
- )} - {selectedModel.rpm && ( -
- Requests per Minute: - {selectedModel.rpm.toLocaleString()} -
- )} -
-
- )} - - {/* Supported OpenAI Parameters */} - {selectedModel.supported_openai_params && ( -
- Supported OpenAI Parameters -
- {selectedModel.supported_openai_params.map((param) => ( - - {param} - - ))} -
-
- )} - - {/* Usage Example */} -
- Usage Example - - {`import openai +

Usage Example

+ + {`import openai client = openai.OpenAI( api_key="your_api_key", @@ -746,316 +743,310 @@ response = client.chat.completions.create( ) print(response.choices[0].message.content)`} - +
+
-
- )} - + )} + + {/* Agent Details Modal */} - - {selectedAgent && ( -
- {/* Agent Overview */} -
- Agent Overview -
-
- Name: - {selectedAgent.name} -
-
- Version: - v{selectedAgent.version} -
-
- Protocol Version: - {selectedAgent.protocolVersion} -
-
- URL: -
- {selectedAgent.url} - void copyToClipboard(selectedAgent.url)} - className="cursor-pointer text-gray-500 hover:text-blue-500" - /> + !open && handleCancel()}> + + + {selectedAgent?.name || "Agent Details"} + + {selectedAgent && ( +
+ {/* Agent Overview */} +
+

Agent Overview

+
+
+

Name:

+

{selectedAgent.name}

-
-
-
- Description: - {selectedAgent.description} -
-
- - {/* Capabilities */} - {selectedAgent.capabilities && Object.keys(selectedAgent.capabilities).length > 0 && ( -
- Capabilities -
- {Object.entries(selectedAgent.capabilities) - .filter(([_, value]) => value === true) - .map(([key]) => ( - - {key} - - ))} -
-
- )} - - {/* Input/Output Modes */} -
- Input/Output Modes -
-
- Input Modes: -
- {selectedAgent.defaultInputModes?.map((mode) => ( - - {mode} - - )) || Not specified} +
+

Version:

+ v{selectedAgent.version} +
+
+

Protocol Version:

+

{selectedAgent.protocolVersion}

+
+
+

URL:

+
+

{selectedAgent.url}

+ void copyToClipboard(selectedAgent.url)} + className="size-3.5 shrink-0 cursor-pointer text-gray-500 hover:text-blue-500" + /> +
- Output Modes: -
- {selectedAgent.defaultOutputModes?.map((mode) => ( - - {mode} - - )) || Not specified} +

Description:

+

{selectedAgent.description}

+
+
+ + {/* Capabilities */} + {selectedAgent.capabilities && Object.keys(selectedAgent.capabilities).length > 0 && ( +
+

Capabilities

+
+ {Object.entries(selectedAgent.capabilities) + .filter(([_, value]) => value === true) + .map(([key]) => ( + + {key} + + ))} +
+
+ )} + + {/* Input/Output Modes */} +
+

Input/Output Modes

+
+
+

Input Modes:

+
+ {selectedAgent.defaultInputModes?.map((mode) => ( + + {mode} + + )) ||

Not specified

} +
+
+
+

Output Modes:

+
+ {selectedAgent.defaultOutputModes?.map((mode) => ( + + {mode} + + )) ||

Not specified

} +
-
- {/* Skills */} - {selectedAgent.skills && selectedAgent.skills.length > 0 && ( -
- Skills -
- {selectedAgent.skills.map((skill) => ( -
-
-
- {skill.name} - ID: {skill.id} + {/* Skills */} + {selectedAgent.skills && selectedAgent.skills.length > 0 && ( +
+

Skills

+
+ {selectedAgent.skills.map((skill) => ( +
+
+
+

{skill.name}

+

ID: {skill.id}

+
+ {skill.tags && skill.tags.length > 0 && ( +
+ {skill.tags.map((tag) => ( + + {tag} + + ))} +
+ )}
- {skill.tags && skill.tags.length > 0 && ( -
- {skill.tags.map((tag) => ( - - {tag} - - ))} +

{skill.description}

+ {skill.examples && skill.examples.length > 0 && ( +
+

Examples:

+
+ {skill.examples.map((example, idx) => ( + + {example} + + ))} +
)}
- {skill.description} - {skill.examples && skill.examples.length > 0 && ( -
- Examples: -
- {skill.examples.map((example, idx) => ( - - {example} - - ))} -
-
- )} -
- ))} + ))} +
-
- )} + )} - {/* Additional Properties */} - {selectedAgent.supportsAuthenticatedExtendedCard && ( -
- Additional Features - Supports Authenticated Extended Card -
- )} -
- )} - + {/* Additional Properties */} + {selectedAgent.supportsAuthenticatedExtendedCard && ( +
+

Additional Features

+ Supports Authenticated Extended Card +
+ )} +
+ )} + +
{/* MCP Server Details Modal */} - - {selectedMcpServer && ( -
- {/* Server Overview */} -
- Server Overview -
-
- Server Name: - {selectedMcpServer.server_name} -
-
- Server ID: -
- {selectedMcpServer.server_id} - void copyToClipboard(selectedMcpServer.server_id)} - className="cursor-pointer text-gray-500 hover:text-blue-500" - /> + !open && handleCancel()}> + + + {selectedMcpServer?.server_name || "MCP Server Details"} + + {selectedMcpServer && ( +
+ {/* Server Overview */} +
+

Server Overview

+
+
+

Server Name:

+

{selectedMcpServer.server_name}

+
+
+

Server ID:

+
+

{selectedMcpServer.server_id}

+ void copyToClipboard(selectedMcpServer.server_id)} + className="size-3.5 shrink-0 cursor-pointer text-gray-500 hover:text-blue-500" + /> +
+
+ {selectedMcpServer.alias && ( +
+

Alias:

+

{selectedMcpServer.alias}

+
+ )} +
+

Transport:

+ {selectedMcpServer.transport} +
+
+

Auth Type:

+ + {selectedMcpServer.auth_type} + +
+
+

Status:

+ + {selectedMcpServer.status || "unknown"} +
- {selectedMcpServer.alias && ( -
- Alias: - {selectedMcpServer.alias} + {selectedMcpServer.description && ( +
+

Description:

+

{selectedMcpServer.description}

)} -
- Transport: - {selectedMcpServer.transport} -
-
- Auth Type: - - {selectedMcpServer.auth_type} - -
-
- Status: - - {selectedMcpServer.status || "unknown"} - +
+ + {/* Connection Details */} +
+

Connection Details

+
+ {selectedMcpServer.command && ( +
+

Command:

+

{selectedMcpServer.command}

+
+ )}
- {selectedMcpServer.description && ( -
- Description: - {selectedMcpServer.description} + + {/* Tools */} + {selectedMcpServer.allowed_tools && selectedMcpServer.allowed_tools.length > 0 && ( +
+

Allowed Tools

+
+ {selectedMcpServer.allowed_tools.map((tool, idx) => ( + + {tool} + + ))} +
)} -
- {/* Connection Details */} -
- Connection Details -
- {selectedMcpServer.command && ( -
- Command: - - {selectedMcpServer.command} - + {/* Teams */} + {selectedMcpServer.teams && selectedMcpServer.teams.length > 0 && ( +
+

Teams

+
+ {selectedMcpServer.teams.map((team, idx) => ( + + {team} + + ))}
- )} -
-
- - {/* Tools */} - {selectedMcpServer.allowed_tools && selectedMcpServer.allowed_tools.length > 0 && ( -
- Allowed Tools -
- {selectedMcpServer.allowed_tools.map((tool, idx) => ( - - {tool} - - ))} -
-
- )} - - {/* Teams */} - {selectedMcpServer.teams && selectedMcpServer.teams.length > 0 && ( -
- Teams -
- {selectedMcpServer.teams.map((team, idx) => ( - - {team} - - ))} -
-
- )} - - {/* Access Groups */} - {selectedMcpServer.mcp_access_groups && selectedMcpServer.mcp_access_groups.length > 0 && ( -
- Access Groups -
- {selectedMcpServer.mcp_access_groups.map((group, idx) => ( - - {group} - - ))} -
-
- )} - - {/* Metadata */} -
- Metadata -
-
- Created By: - {selectedMcpServer.created_by} -
-
- Updated By: - {selectedMcpServer.updated_by} -
-
- Created At: - {new Date(selectedMcpServer.created_at).toLocaleString()} -
-
- Updated At: - {new Date(selectedMcpServer.updated_at).toLocaleString()} -
- {selectedMcpServer.last_health_check && ( -
- Last Health Check: - {new Date(selectedMcpServer.last_health_check).toLocaleString()} -
- )} -
- {selectedMcpServer.health_check_error && ( -
- Health Check Error: - {selectedMcpServer.health_check_error}
)} -
- {/* Usage Example */} -
- Usage Example - - {`from fastmcp import Client + {/* Access Groups */} + {selectedMcpServer.mcp_access_groups && selectedMcpServer.mcp_access_groups.length > 0 && ( +
+

Access Groups

+
+ {selectedMcpServer.mcp_access_groups.map((group, idx) => ( + + {group} + + ))} +
+
+ )} + + {/* Metadata */} +
+

Metadata

+
+
+

Created By:

+

{selectedMcpServer.created_by}

+
+
+

Updated By:

+

{selectedMcpServer.updated_by}

+
+
+

Created At:

+

{new Date(selectedMcpServer.created_at).toLocaleString()}

+
+
+

Updated At:

+

{new Date(selectedMcpServer.updated_at).toLocaleString()}

+
+ {selectedMcpServer.last_health_check && ( +
+

Last Health Check:

+

{new Date(selectedMcpServer.last_health_check).toLocaleString()}

+
+ )} +
+ {selectedMcpServer.health_check_error && ( +
+

Health Check Error:

+

{selectedMcpServer.health_check_error}

+
+ )} +
+ + {/* Usage Example */} +
+

Usage Example

+ + {`from fastmcp import Client import asyncio # Standard MCP configuration @@ -1088,11 +1079,12 @@ async def main(): if __name__ == "__main__": asyncio.run(main())`} - + +
-
- )} - + )} + +
{/* Make Model Public Form */} = ({ // Derived stats const totalSkills = skills.length; - const domains = useMemo(() => [...new Set(skills.map((s) => s.domain).filter(Boolean))], [skills]); + const domains = useMemo( + () => [...new Set(skills.map((s) => s.domain).filter((domain): domain is string => Boolean(domain)))], + [skills], + ); const namespaces = useMemo(() => [...new Set(skills.map((s) => s.namespace).filter(Boolean))], [skills]); // Filtered table data @@ -73,6 +78,11 @@ const SkillHubDashboard: React.FC = ({ const columns = useMemo(() => getSkillHubTableColumns({ onSkillClick: setSelectedSkill }), []); + const domainItems = useMemo( + () => [{ value: ALL_DOMAINS, label: "All Domains" }, ...domains.map((d) => ({ value: d, label: d }))], + [domains], + ); + const hasActiveFilter = search.trim().length > 0 || domainFilter != null; if (selectedSkill) { @@ -111,21 +121,43 @@ const SkillHubDashboard: React.FC = ({

All {publicPage ? "Public " : ""}Skills

} - placeholder="Search by name, namespace, or tag…" - value={search} - onChange={(e) => setSearch(e.target.value)} - style={{ width: 280 }} - allowClear - /> + items={domainItems} + value={domainFilter ?? ALL_DOMAINS} + onValueChange={(val) => setDomainFilter(val === null || val === ALL_DOMAINS ? undefined : val)} + > + + + + + {domainItems.map((item) => ( + + {item.label} + + ))} + + + + + + + setSearch(e.target.value)} + /> + {search !== "" && ( + + setSearch("")} + > + + + + )} +
= ({ accessTok }; return ( - +
setIsExpanded(!isExpanded)}>
- Link Management +

Link Management

Manage the links that are displayed under 'Useful Links' on the public model hub.

@@ -243,7 +244,7 @@ const UsefulLinksManagement: React.FC = ({ accessTok {isExpanded && (
- Add New Link +

Add New Link

@@ -288,7 +289,7 @@ const UsefulLinksManagement: React.FC = ({ accessTok
- Manage Existing Links +

Manage Existing Links

= ({ accessTok
- + - Display Name - URL - Actions + Display Name + URL + Actions - + {links.map((link, index) => ( diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx index 67c6d7d6cc9..a55beaf517f 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx @@ -12,67 +12,8 @@ vi.mock("../../networking", () => ({ import { makeAgentsPublicCall } from "../../networking"; const mockMakeAgentsPublicCall = vi.mocked(makeAgentsPublicCall); -// Mock antd components -vi.mock("antd", () => ({ - Modal: ({ open, title, children, onCancel, footer }: any) => - open ? ( -
-
{title}
- {children} - {footer} -
- ) : null, - Form: Object.assign(({ children, form }: any) => {children}, { - useForm: () => [ - { - resetFields: vi.fn(), - validateFields: vi.fn(), - getFieldsValue: vi.fn(), - setFieldsValue: vi.fn(), - }, - vi.fn(), - ], - Item: ({ children }: any) =>
{children}
, - }), - Steps: Object.assign( - ({ children, current, className }: any) => ( -
- {children} -
- ), - { - Step: ({ title }: any) =>
{title}
, - }, - ), - Button: ({ children, onClick, disabled, loading, ...props }: any) => ( - - ), - Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => ( - - ), -})); - -// Mock @tremor/react components -vi.mock("@tremor/react", () => ({ - Text: ({ children, className }: any) => {children}, - Title: ({ children }: any) =>

{children}

, - Badge: ({ children, color, size }: any) => ( - - {children} - - ), -})); +const expectDisabledControl = (element: HTMLElement) => + expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true); describe("MakeAgentPublicForm", () => { const mockProps = { @@ -143,7 +84,7 @@ describe("MakeAgentPublicForm", () => { expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument(); // Select all agents using the select all checkbox - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -169,12 +110,11 @@ describe("MakeAgentPublicForm", () => { render(); // Select all agents - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -185,7 +125,6 @@ describe("MakeAgentPublicForm", () => { expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); @@ -232,6 +171,8 @@ describe("MakeAgentPublicForm", () => { const checkboxes = screen.getAllByRole("checkbox"); await act(async () => { fireEvent.click(checkboxes[0]); // Click select all to select all + }); + await act(async () => { fireEvent.click(checkboxes[0]); // Click select all again to deselect all }); @@ -256,8 +197,8 @@ describe("MakeAgentPublicForm", () => { expect(screen.getByText("No agents available.")).toBeInTheDocument(); // Select All checkbox should be disabled - const selectAllCheckbox = screen.getByLabelText("Select All"); - expect(selectAllCheckbox).toBeDisabled(); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" }); + expectDisabledControl(selectAllCheckbox); // Next button should be disabled const nextButton = screen.getByRole("button", { name: "Next" }); @@ -332,7 +273,7 @@ describe("MakeAgentPublicForm", () => { // Select all should be indeterminate now const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); it("should display skills overflow text when agent has more than 3 skills", () => { @@ -369,7 +310,6 @@ describe("MakeAgentPublicForm", () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -379,7 +319,6 @@ describe("MakeAgentPublicForm", () => { expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); @@ -395,7 +334,7 @@ describe("MakeAgentPublicForm", () => { expect(mockProps.onClose).not.toHaveBeenCalled(); }); - it("should show loading state during submit", async () => { + it("should not complete the flow until the submit request resolves", async () => { let resolvePromise: (value: any) => void = () => {}; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; @@ -404,7 +343,6 @@ describe("MakeAgentPublicForm", () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -414,17 +352,20 @@ describe("MakeAgentPublicForm", () => { expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); }); - // Check loading state - expect(submitButton).toHaveAttribute("data-loading", "true"); - expect(submitButton).toBeDisabled(); + expectDisabledControl(submitButton); + await act(async () => { + fireEvent.click(submitButton); + }); + expect(mockMakeAgentsPublicCall).toHaveBeenCalledTimes(1); + expect(mockProps.onSuccess).not.toHaveBeenCalled(); + expect(mockProps.onClose).not.toHaveBeenCalled(); + expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); - // Resolve the promise resolvePromise({}); await waitFor(() => { expect(mockProps.onSuccess).toHaveBeenCalled(); @@ -441,7 +382,7 @@ describe("MakeAgentPublicForm", () => { render(); // Modal should not be rendered - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByText("Make Agents Public")).not.toBeInTheDocument(); }); @@ -500,6 +441,6 @@ describe("MakeAgentPublicForm", () => { // Select all should be indeterminate const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); }); diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx index 0ed73872cee..82336206858 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx @@ -1,11 +1,15 @@ import React, { useState, useEffect } from "react"; -import { Modal, Form, Steps, Button, Checkbox } from "antd"; -import { Text, Title, Badge } from "@tremor/react"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/cva.config"; import { makeAgentsPublicCall } from "../../networking"; import NotificationsManager from "../../molecules/notifications_manager"; import { AgentHubData } from "@/components/AIHub/AgentHubTableColumns"; -const { Step } = Steps; +const STEP_TITLES = ["Select Agents", "Confirm"]; interface MakeAgentPublicFormProps { visible: boolean; @@ -25,12 +29,10 @@ const MakeAgentPublicForm: React.FC = ({ const [currentStep, setCurrentStep] = useState(0); const [selectedAgents, setSelectedAgents] = useState>(new Set()); const [loading, setLoading] = useState(false); - const [form] = Form.useForm(); const handleClose = () => { setCurrentStep(0); setSelectedAgents(new Set()); - form.resetFields(); onClose(); }; @@ -113,29 +115,30 @@ const MakeAgentPublicForm: React.FC = ({ return (
- Select Agents to Make Public +

Select Agents to Make Public

- handleSelectAll(e.target.checked)} - disabled={agentHubData.length === 0} - > +
- +

Select the agents you want to be visible on the public model hub. Users will still require a valid Virtual Key to use these agents. - +

{agentHubData.length === 0 ? (
- No agents available. +

No agents available.

) : ( agentHubData.map((agent) => { @@ -144,25 +147,23 @@ const MakeAgentPublicForm: React.FC = ({
handleAgentSelection(agentId, e.target.checked)} + onCheckedChange={(checked) => handleAgentSelection(agentId, checked === true)} /> -
+
- {agent.name} - - v{agent.version} - +

{agent.name}

+ v{agent.version}
- {agent.description} +

{agent.description}

{agent.skills && agent.skills.length > 0 && (
{agent.skills.slice(0, 3).map((skill) => ( - + {skill.name} ))} {agent.skills.length > 3 && ( - +{agent.skills.length - 3} more +

+{agent.skills.length - 3} more

)}
)} @@ -176,9 +177,9 @@ const MakeAgentPublicForm: React.FC = ({ {selectedAgents.size > 0 && (
- +

{selectedAgents.size} agent{selectedAgents.size !== 1 ? "s" : ""} selected - +

)}
@@ -188,33 +189,31 @@ const MakeAgentPublicForm: React.FC = ({ const renderStep2Content = () => { return (
- Confirm Making Agents Public +

Confirm Making Agents Public

- +

Warning: Once you make these agents public, anyone who can go to the{" "} /ui/model_hub_table will be able to know they exist on the proxy. - +

- Agents to be made public: +

Agents to be made public:

{Array.from(selectedAgents).map((agentId) => { const agent = agentHubData.find((a) => (a.agent_id || a.name) === agentId); return (
-
+
- {agent?.name || agentId} - {agent && ( - - v{agent.version} - - )} +

{agent?.name || agentId}

+ {agent && v{agent.version}}
- {agent?.description && {agent.description}} + {agent?.description && ( +

{agent.description}

+ )}
); @@ -224,10 +223,10 @@ const MakeAgentPublicForm: React.FC = ({
- +

Total: {selectedAgents.size} agent{selectedAgents.size !== 1 ? "s" : ""} will be made public - +

); @@ -247,7 +246,7 @@ const MakeAgentPublicForm: React.FC = ({ const renderStepButtons = () => { return (
- @@ -259,7 +258,8 @@ const MakeAgentPublicForm: React.FC = ({ )} {currentStep === 1 && ( - )} @@ -269,24 +269,42 @@ const MakeAgentPublicForm: React.FC = ({ }; return ( - -
- - - - + !open && handleClose()} disablePointerDismissal> + + + Make Agents Public + - {renderStepContent()} - {renderStepButtons()} - -
+
+
    + {STEP_TITLES.map((title, index) => ( +
  1. + + {index + 1} + + + {title} + +
  2. + ))} +
+ + {renderStepContent()} + {renderStepButtons()} +
+ + ); }; diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx index 994a920b2e4..ff385b3ed7c 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx @@ -12,83 +12,8 @@ vi.mock("../../networking", () => ({ import { makeMCPPublicCall } from "../../networking"; const mockMakeMCPPublicCall = vi.mocked(makeMCPPublicCall); -// Mock antd components -vi.mock("antd", () => ({ - Modal: ({ open, title, children, onCancel, footer }: any) => - open ? ( -
-
{title}
- {children} - {footer} -
- ) : null, - Form: Object.assign(({ children, form }: any) =>
{children}, { - useForm: () => [ - { - resetFields: vi.fn(), - validateFields: vi.fn(), - getFieldsValue: vi.fn(), - setFieldsValue: vi.fn(), - }, - vi.fn(), - ], - Item: ({ children }: any) =>
{children}
, - }), - Steps: Object.assign( - ({ children, current, className }: any) => ( -
- {children} -
- ), - { - Step: ({ title }: any) =>
{title}
, - }, - ), - Button: ({ children, onClick, disabled, loading, ...props }: any) => ( - - ), - Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => ( - - ), -})); - -// Additional @tremor/react mocks. -// NOTE: the comment used to say "Button is already mocked globally" — that was -// incorrect. A file-level vi.mock fully replaces the setup-level mock from -// tests/setupTests.ts, so we must re-apply the Button/Tooltip overrides here. -// Without them, the real Tremor Button leaks through and its useTooltip(300) -// schedules a native setTimeout that can fire post-teardown -> "window is not defined". -vi.mock("@tremor/react", async (importOriginal) => { - const actual = await importOriginal(); - const React = await import("react"); - return { - ...actual, - Text: ({ children, className }: any) => {children}, - Title: ({ children }: any) =>

{children}

, - Badge: ({ children, color, size }: any) => ( - - {children} - - ), - Button: React.forwardRef(({ children, ...props }, ref) => ( - - )), - Tooltip: ({ children }: any) => <>{children}, - }; -}); +const expectDisabledControl = (element: HTMLElement) => + expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true); describe("MakeMCPPublicForm", () => { const mockProps = { @@ -182,7 +107,7 @@ describe("MakeMCPPublicForm", () => { expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument(); // Select all servers using the select all checkbox - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -208,12 +133,11 @@ describe("MakeMCPPublicForm", () => { render(); // Select all servers - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -224,7 +148,6 @@ describe("MakeMCPPublicForm", () => { expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); @@ -271,6 +194,8 @@ describe("MakeMCPPublicForm", () => { const checkboxes = screen.getAllByRole("checkbox"); await act(async () => { fireEvent.click(checkboxes[0]); // Click select all to select all + }); + await act(async () => { fireEvent.click(checkboxes[0]); // Click select all again to deselect all }); @@ -295,8 +220,8 @@ describe("MakeMCPPublicForm", () => { expect(screen.getByText("No MCP servers available.")).toBeInTheDocument(); // Select All checkbox should be disabled - const selectAllCheckbox = screen.getByLabelText("Select All"); - expect(selectAllCheckbox).toBeDisabled(); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" }); + expectDisabledControl(selectAllCheckbox); // Next button should be disabled const nextButton = screen.getByRole("button", { name: "Next" }); @@ -371,7 +296,7 @@ describe("MakeMCPPublicForm", () => { // Select all should be indeterminate now const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); it("should display tools overflow text when server has more than 3 tools", () => { @@ -402,7 +327,6 @@ describe("MakeMCPPublicForm", () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -412,7 +336,6 @@ describe("MakeMCPPublicForm", () => { expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); @@ -428,7 +351,7 @@ describe("MakeMCPPublicForm", () => { expect(mockProps.onClose).not.toHaveBeenCalled(); }); - it("should show loading state during submit", async () => { + it("should not complete the flow until the submit request resolves", async () => { let resolvePromise: (value: any) => void = () => {}; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; @@ -437,7 +360,6 @@ describe("MakeMCPPublicForm", () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -447,17 +369,20 @@ describe("MakeMCPPublicForm", () => { expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); }); - // Check loading state - expect(submitButton).toHaveAttribute("data-loading", "true"); - expect(submitButton).toBeDisabled(); + expectDisabledControl(submitButton); + await act(async () => { + fireEvent.click(submitButton); + }); + expect(mockMakeMCPPublicCall).toHaveBeenCalledTimes(1); + expect(mockProps.onSuccess).not.toHaveBeenCalled(); + expect(mockProps.onClose).not.toHaveBeenCalled(); + expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); - // Resolve the promise resolvePromise({}); await waitFor(() => { expect(mockProps.onSuccess).toHaveBeenCalled(); @@ -474,7 +399,7 @@ describe("MakeMCPPublicForm", () => { render(); // Modal should not be rendered - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByText("Make MCP Servers Public")).not.toBeInTheDocument(); }); @@ -569,6 +494,6 @@ describe("MakeMCPPublicForm", () => { // Select all should be indeterminate const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); }); diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx index b590c3cc1dd..7ef42883400 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx @@ -1,11 +1,25 @@ import React, { useState, useEffect } from "react"; -import { Modal, Form, Steps, Button, Checkbox } from "antd"; -import { Text, Title, Badge } from "@tremor/react"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/cva.config"; import { makeMCPPublicCall } from "../../networking"; import NotificationsManager from "../../molecules/notifications_manager"; import { MCPServerData } from "@/components/AIHub/MCPHubTableColumns"; -const { Step } = Steps; +const STEP_TITLES = ["Select Servers", "Confirm"]; + +const statusVariant = (status?: string) => { + if (status === "active" || status === "healthy") { + return "default" as const; + } + if (status === "inactive" || status === "unhealthy") { + return "destructive" as const; + } + return "outline" as const; +}; interface MakeMCPPublicFormProps { visible: boolean; @@ -25,12 +39,10 @@ const MakeMCPPublicForm: React.FC = ({ const [currentStep, setCurrentStep] = useState(0); const [selectedServers, setSelectedServers] = useState>(new Set()); const [loading, setLoading] = useState(false); - const [form] = Form.useForm(); const handleClose = () => { setCurrentStep(0); setSelectedServers(new Set()); - form.resetFields(); onClose(); }; @@ -114,29 +126,30 @@ const MakeMCPPublicForm: React.FC = ({ return (
- Select MCP Servers to Make Public +

Select MCP Servers to Make Public

- handleSelectAll(e.target.checked)} - disabled={mcpHubData.length === 0} - > +
- +

Select the MCP servers you want to be visible on the public model hub. Users will still require a valid Virtual Key to use these servers. - +

{mcpHubData.length === 0 ? (
- No MCP servers available. +

No MCP servers available.

) : ( mcpHubData.map((server) => { @@ -148,42 +161,25 @@ const MakeMCPPublicForm: React.FC = ({ > handleServerSelection(server.server_id, e.target.checked)} + onCheckedChange={(checked) => handleServerSelection(server.server_id, checked === true)} /> -
-
- {server.server_name} - {isPublic && ( - - Public - - )} - - {server.transport} - - - {server.status || "unknown"} - +
+
+

{server.server_name}

+ {isPublic && Public} + {server.transport} + {server.status || "unknown"}
- {server.description || server.url} +

{server.description || server.url}

{server.allowed_tools && server.allowed_tools.length > 0 && (
{server.allowed_tools.slice(0, 3).map((tool, idx) => ( - + {tool} ))} {server.allowed_tools.length > 3 && ( - +{server.allowed_tools.length - 3} more +

+{server.allowed_tools.length - 3} more

)}
)} @@ -197,9 +193,9 @@ const MakeMCPPublicForm: React.FC = ({ {selectedServers.size > 0 && (
- +

{selectedServers.size} MCP server{selectedServers.size !== 1 ? "s" : ""} selected - +

)}
@@ -209,48 +205,37 @@ const MakeMCPPublicForm: React.FC = ({ const renderStep2Content = () => { return (
- Confirm Making MCP Servers Public +

Confirm Making MCP Servers Public

- +

Warning: Once you make these MCP servers public, anyone who can go to the{" "} /ui/model_hub_table will be able to know they exist on the proxy. - +

- MCP Servers to be made public: +

MCP Servers to be made public:

{Array.from(selectedServers).map((serverId) => { const server = mcpHubData.find((s) => s.server_id === serverId); return (
-
-
- {server?.server_name || serverId} +
+
+

{server?.server_name || serverId}

{server && ( <> - - {server.transport} - - - {server.status || "unknown"} - + {server.transport} + {server.status || "unknown"} )}
- {server?.description && {server.description}} - {server?.url && {server.url}} + {server?.description && ( +

{server.description}

+ )} + {server?.url &&

{server.url}

}
); @@ -260,10 +245,10 @@ const MakeMCPPublicForm: React.FC = ({
- +

Total: {selectedServers.size} MCP server{selectedServers.size !== 1 ? "s" : ""} will be made public - +

); @@ -283,7 +268,7 @@ const MakeMCPPublicForm: React.FC = ({ const renderStepButtons = () => { return (
- @@ -295,7 +280,8 @@ const MakeMCPPublicForm: React.FC = ({ )} {currentStep === 1 && ( - )} @@ -305,24 +291,42 @@ const MakeMCPPublicForm: React.FC = ({ }; return ( - -
- - - - + !open && handleClose()} disablePointerDismissal> + + + Make MCP Servers Public + - {renderStepContent()} - {renderStepButtons()} - -
+
+
    + {STEP_TITLES.map((title, index) => ( +
  1. + + {index + 1} + + + {title} + +
  2. + ))} +
+ + {renderStepContent()} + {renderStepButtons()} +
+ + ); }; diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx index 2b57535f3ad..ac0df137f6a 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx @@ -29,67 +29,8 @@ vi.mock("../../networking", () => ({ import { makeModelGroupPublic } from "../../networking"; const mockMakeModelGroupPublic = vi.mocked(makeModelGroupPublic); -// Mock antd components -vi.mock("antd", () => ({ - Modal: ({ open, title, children, onCancel, footer }: any) => - open ? ( -
-
{title}
- {children} - {footer} -
- ) : null, - Form: Object.assign(({ children, form }: any) =>
{children}, { - useForm: () => [ - { - resetFields: vi.fn(), - validateFields: vi.fn(), - getFieldsValue: vi.fn(), - setFieldsValue: vi.fn(), - }, - vi.fn(), - ], - Item: ({ children }: any) =>
{children}
, - }), - Steps: Object.assign( - ({ children, current, className }: any) => ( -
- {children} -
- ), - { - Step: ({ title }: any) =>
{title}
, - }, - ), - Button: ({ children, onClick, disabled, loading, ...props }: any) => ( - - ), - Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => ( - - ), -})); - -// Mock @tremor/react components -vi.mock("@tremor/react", () => ({ - Text: ({ children, className }: any) => {children}, - Title: ({ children }: any) =>

{children}

, - Badge: ({ children, color, size }: any) => ( - - {children} - - ), -})); +const expectDisabledControl = (element: HTMLElement) => + expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true); // Mock ModelFilters component vi.mock("../../model_filters", () => ({ @@ -190,7 +131,7 @@ describe("MakeModelPublicForm", () => { expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument(); // Select all models using the select all checkbox - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -216,12 +157,11 @@ describe("MakeModelPublicForm", () => { render(); // Select all models - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -232,7 +172,6 @@ describe("MakeModelPublicForm", () => { expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); @@ -279,6 +218,8 @@ describe("MakeModelPublicForm", () => { const checkboxes = screen.getAllByRole("checkbox"); await act(async () => { fireEvent.click(checkboxes[0]); // Click select all to select all + }); + await act(async () => { fireEvent.click(checkboxes[0]); // Click select all again to deselect all }); @@ -303,8 +244,8 @@ describe("MakeModelPublicForm", () => { expect(screen.getByText("No models match the current filters.")).toBeInTheDocument(); // Select All checkbox should be disabled - const selectAllCheckbox = screen.getByLabelText("Select All"); - expect(selectAllCheckbox).toBeDisabled(); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" }); + expectDisabledControl(selectAllCheckbox); // Next button should be disabled const nextButton = screen.getByRole("button", { name: "Next" }); @@ -379,7 +320,7 @@ describe("MakeModelPublicForm", () => { // Select all should be indeterminate now const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); it("should display model badges and information", () => { @@ -402,7 +343,6 @@ describe("MakeModelPublicForm", () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -412,7 +352,6 @@ describe("MakeModelPublicForm", () => { expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); @@ -428,7 +367,7 @@ describe("MakeModelPublicForm", () => { expect(mockProps.onClose).not.toHaveBeenCalled(); }); - it("should show loading state during submit", async () => { + it("should not complete the flow until the submit request resolves", async () => { let resolvePromise: (value: any) => void = () => {}; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; @@ -437,7 +376,6 @@ describe("MakeModelPublicForm", () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -447,17 +385,20 @@ describe("MakeModelPublicForm", () => { expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); }); - // Check loading state - expect(submitButton).toHaveAttribute("data-loading", "true"); - expect(submitButton).toBeDisabled(); + expectDisabledControl(submitButton); + await act(async () => { + fireEvent.click(submitButton); + }); + expect(mockMakeModelGroupPublic).toHaveBeenCalledTimes(1); + expect(mockProps.onSuccess).not.toHaveBeenCalled(); + expect(mockProps.onClose).not.toHaveBeenCalled(); + expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); - // Resolve the promise resolvePromise({}); await waitFor(() => { expect(mockProps.onSuccess).toHaveBeenCalled(); @@ -474,7 +415,7 @@ describe("MakeModelPublicForm", () => { render(); // Modal should not be rendered - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByText("Make Models Public")).not.toBeInTheDocument(); }); @@ -521,21 +462,19 @@ describe("MakeModelPublicForm", () => { // Select all should be indeterminate const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); it("should show selected count", () => { render(); // Should show that 1 model is selected (gpt-3.5-turbo is preselected) - expect(screen.getByText("1")).toBeInTheDocument(); - expect(screen.getByText("model selected")).toBeInTheDocument(); + expect(screen.getByText("model selected")).toHaveTextContent("1 model selected"); }); it("should show confirmation step with selected models", async () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx index 2d0ae1a0e2b..28a34ee1f1a 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx @@ -1,11 +1,15 @@ import React, { useState, useCallback, useEffect } from "react"; -import { Modal, Form, Steps, Button, Checkbox } from "antd"; -import { Text, Title, Badge } from "@tremor/react"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/cva.config"; import { makeModelGroupPublic } from "../../networking"; import ModelFilters from "../../model_filters"; import NotificationsManager from "../../molecules/notifications_manager"; -const { Step } = Steps; +const STEP_TITLES = ["Select Models", "Confirm"]; interface ModelGroupInfo { model_group: string; @@ -44,13 +48,11 @@ const MakeModelPublicForm: React.FC = ({ const [selectedModels, setSelectedModels] = useState>(new Set()); const [filteredData, setFilteredData] = useState([]); const [loading, setLoading] = useState(false); - const [form] = Form.useForm(); const handleClose = () => { setCurrentStep(0); setSelectedModels(new Set()); setFilteredData([]); - form.resetFields(); onClose(); }; @@ -138,23 +140,24 @@ const MakeModelPublicForm: React.FC = ({ return (
- Select Models to Make Public +

Select Models to Make Public

- handleSelectAll(e.target.checked)} - disabled={filteredData.length === 0} - > +
- +

Select the models you want to be visible on the public model hub. Users will still require a valid Virtual Key to use these models. - +

{/* Filters */} = ({
{filteredData.length === 0 ? (
- No models match the current filters. +

No models match the current filters.

) : ( filteredData.map((model) => ( @@ -178,20 +181,16 @@ const MakeModelPublicForm: React.FC = ({ > handleModelSelection(model.model_group, e.target.checked)} + onCheckedChange={(checked) => handleModelSelection(model.model_group, checked === true)} /> -
-
- {model.model_group} - {model.mode && ( - - {model.mode} - - )} +
+
+

{model.model_group}

+ {model.mode && {model.mode}}
{model.providers.map((provider) => ( - + {provider} ))} @@ -205,9 +204,9 @@ const MakeModelPublicForm: React.FC = ({ {selectedModels.size > 0 && (
- +

{selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""} selected - +

)}
@@ -217,29 +216,29 @@ const MakeModelPublicForm: React.FC = ({ const renderStep2Content = () => { return (
- Confirm Making Models Public +

Confirm Making Models Public

- +

Warning: Once you make these models public, anyone who can go to the{" "} /ui/model_hub_table will be able to know they exist on the proxy. - +

- Models to be made public: +

Models to be made public:

{Array.from(selectedModels).map((modelGroup) => { const model = modelHubData.find((m) => m.model_group === modelGroup); return (
-
- {modelGroup} +
+

{modelGroup}

{model && (
{model.providers.map((provider) => ( - + {provider} ))} @@ -254,10 +253,10 @@ const MakeModelPublicForm: React.FC = ({
- +

Total: {selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""} will be made public - +

); @@ -277,7 +276,7 @@ const MakeModelPublicForm: React.FC = ({ const renderStepButtons = () => { return (
- @@ -289,7 +288,8 @@ const MakeModelPublicForm: React.FC = ({ )} {currentStep === 1 && ( - )} @@ -299,24 +299,42 @@ const MakeModelPublicForm: React.FC = ({ }; return ( - -
- - - - + !open && handleClose()} disablePointerDismissal> + + + Make Models Public + - {renderStepContent()} - {renderStepButtons()} - -
+
+
    + {STEP_TITLES.map((title, index) => ( +
  1. + + {index + 1} + + + {title} + +
  2. + ))} +
+ + {renderStepContent()} + {renderStepButtons()} +
+ + ); }; diff --git a/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx b/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx index 94474e78b14..9591fe4bb1f 100644 --- a/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx +++ b/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx @@ -1,7 +1,8 @@ "use client"; import React from "react"; -import { Alert } from "antd"; +import { TriangleAlert } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; interface DebugWarningBannerProps { @@ -17,19 +18,14 @@ export const DebugWarningBanner: React.FC = ({ accessTo } return ( - - Detailed debug logging (LITELLM_LOG=DEBUG) is currently enabled. This mode logs extensive - diagnostic information and will significantly degrade performance. It should only be used for troubleshooting - and disabled in production environments. - - } - type="warning" - showIcon - banner - style={{ marginBottom: 0, borderRadius: 0 }} - /> + + + Performance Warning: Detailed Debug Mode Active + + Detailed debug logging (LITELLM_LOG=DEBUG) is currently enabled. This mode logs extensive + diagnostic information and will significantly degrade performance. It should only be used for troubleshooting + and disabled in production environments. + + ); }; diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx index d6b419ace7c..627d108e65f 100644 --- a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx @@ -42,18 +42,22 @@ describe("LicenseExpiryBannerView", () => { expect(container).toBeEmptyDOMElement(); }); - it("shows a dismissible amber warning within 30 days", () => { + it("shows a dismissible warning within 30 days", () => { const { container } = render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(container.querySelector(".lucide-triangle-alert")).toBeInTheDocument(); expect(screen.getByText(/expires in 20 days/)).toBeInTheDocument(); - expect(container.querySelector(".ant-alert-warning")).toBeInTheDocument(); - expect(screen.queryByRole("button")).toBeInTheDocument(); + expect(screen.getByText(/Renew before it lapses to keep enterprise features/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument(); expect(screen.getByRole("link", { name: "sales@berri.ai" })).toHaveAttribute("href", "mailto:sales@berri.ai"); }); - it("shows a non-dismissible red critical alert within 7 days", () => { + it("shows a non-dismissible critical alert within 7 days", () => { const { container } = render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(container.querySelector(".lucide-circle-alert")).toBeInTheDocument(); expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument(); - expect(container.querySelector(".ant-alert-error")).toBeInTheDocument(); + expect(screen.getByText(/Renew now to avoid losing enterprise features/)).toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); @@ -62,18 +66,19 @@ describe("LicenseExpiryBannerView", () => { expect(screen.getByText(/expires today/)).toBeInTheDocument(); }); - it("shows a non-dismissible red expired alert stating features are disabled", () => { + it("shows a non-dismissible expired alert stating features are disabled", () => { const { container } = render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(container.querySelector(".lucide-circle-alert")).toBeInTheDocument(); expect(screen.getByText(/expired on/)).toBeInTheDocument(); expect(screen.getByText(/features are now disabled/i)).toBeInTheDocument(); - expect(container.querySelector(".ant-alert-error")).toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); it("hides the warning after dismissal and stays hidden within the session", () => { const expiration = daysFromNow(20); const { unmount } = render(); - fireEvent.click(screen.getByRole("button")); + fireEvent.click(screen.getByRole("button", { name: /close/i })); expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument(); unmount(); diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx index c3b20b5fac0..5867a45bc31 100644 --- a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx @@ -1,7 +1,9 @@ "use client"; import React, { useState } from "react"; -import { Alert } from "antd"; +import { CircleAlert, TriangleAlert, X } from "lucide-react"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { Button } from "@/components/ui/button"; import { LicenseInfo } from "@/components/networking"; import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo"; import { formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "@/utils/licenseUtils"; @@ -76,16 +78,22 @@ export const LicenseExpiryBannerView: React.FC = ( }; return ( - + + {tier === "warning" ? ( + + ) : ( + + )} + {message} + {description} + {isDismissible && ( + + + + )} + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx index 8871780ae96..bad740fa361 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx @@ -66,6 +66,27 @@ describe("BlogDropdown", () => { expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument(); }); + it("should not render menu content before the trigger is hovered", () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 1) } }; + renderWithProviders(); + + expect(screen.queryByRole("link", { name: /view all posts/i })).not.toBeInTheDocument(); + expect(screen.queryByText("Post One")).not.toBeInTheDocument(); + }); + + it("should open the menu on hover", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 1) } }; + renderWithProviders(); + + expect(screen.queryByText("Post One")).not.toBeInTheDocument(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Post One")).toBeInTheDocument(); + }); + }); + describe("loading state", () => { it("should show a loading spinner", async () => { mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isLoading: true }; @@ -74,7 +95,7 @@ describe("BlogDropdown", () => { await openDropdown(); await waitFor(() => { - expect(document.querySelector(".anticon-loading")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /loading/i })).toBeInTheDocument(); }); }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx index be659967a0f..5f6b5eacc0f 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx @@ -1,13 +1,17 @@ import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts"; import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass"; -import { DownOutlined, LoadingOutlined } from "@ant-design/icons"; -import { Button, Dropdown, Space, Typography } from "antd"; -import type { MenuProps } from "antd"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ChevronDown, LoaderCircle } from "lucide-react"; import React from "react"; -const { Text, Title, Paragraph } = Typography; - function formatDate(dateStr: string): string { const date = new Date(dateStr + "T00:00:00"); return date.toLocaleDateString("en-US", { @@ -26,63 +30,70 @@ export const BlogDropdown: React.FC = () => { return null; } - let items: MenuProps["items"]; + const renderMenuContent = () => { + if (isLoading) { + return ( +
+ +
+ ); + } - if (isLoading) { - items = [{ key: "loading", label: , disabled: true }]; - } else if (isError) { - items = [ - { - key: "error", - label: ( - - Failed to load posts - - - ), - disabled: true, - }, - ]; - } else if (!data || data.posts.length === 0) { - items = [{ key: "empty", label: No posts available, disabled: true }]; - } else { - items = [ - ...data.posts.slice(0, 5).map((post: BlogPost) => ({ - key: post.url, - label: ( - - - {post.title} - - - {formatDate(post.date)} - - {post.description} - - ), - })), - { type: "divider" as const }, - { - key: "view-all", - label: ( + if (isError) { + return ( +
+ Failed to load posts + +
+ ); + } + + if (!data || data.posts.length === 0) { + return
No posts available
; + } + + return ( + <> + {data.posts.slice(0, 5).map((post: BlogPost) => ( + + +
+ {post.title} +
+ + {formatDate(post.date)} + +

{post.description}

+
+
+ ))} + + View all posts - ), - }, - ]; - } + + + ); + }; // Blog opens a post list; Docs is a single outbound link — navbar adds a layout-only chevron there for alignment. return ( - - - + + + + {renderMenuContent()} + + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx index f6a43196a32..8ec31d74cd6 100644 --- a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx @@ -1,6 +1,6 @@ import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; -import { GithubOutlined, SlackOutlined } from "@ant-design/icons"; -import { Tooltip } from "antd"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Github, Slack } from "lucide-react"; import React from "react"; const iconBtnClass = @@ -18,28 +18,40 @@ export const CommunityEngagementButtons: React.FC = () => { className="flex items-center gap-0.5 rounded-md border border-gray-200/80 bg-gray-50 px-0.5 py-0" aria-label="Community links" > - - - - - - - - - - + + + + } + > + + + LiteLLM Slack community + + + + } + > + + + LiteLLM on GitHub + +
); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx index a3d5db4afd7..f3adcf6d8be 100644 --- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx @@ -5,8 +5,11 @@ import { useHideAutoRouterAnnouncement, } from "@/app/(dashboard)/hooks/useHideAutoRouterAnnouncement"; import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils"; -import { BellOutlined } from "@ant-design/icons"; -import { Badge, Button, Popover, Typography } from "antd"; +import { Badge } from "@/components/ui/badge"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/cva.config"; +import { Bell } from "lucide-react"; import React, { useState } from "react"; export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing"; @@ -24,18 +27,21 @@ export const NotificationsBell: React.FC = () => { const content = (
- - LiteLLM Auto Router - - + LiteLLM Auto Router + Route every request to the cheapest model that can handle it, no prompt changes needed. - +
- + {hasUnread ? ( - ) : null} @@ -44,16 +50,17 @@ export const NotificationsBell: React.FC = () => { ); return ( - - + + + {hasUnread ? : null} + + + {content} ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 28e981c57a1..50c44367020 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -9,23 +9,17 @@ import { setLocalStorageItem, } from "@/utils/localStorageUtils"; import { navAccountDisplayName } from "@/components/Navbar/navDisplayName"; -import { - CrownOutlined, - DownOutlined, - LogoutOutlined, - MailOutlined, - SafetyOutlined, - UserOutlined, -} from "@ant-design/icons"; -import type { MenuProps } from "antd"; -import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd"; -import { ChevronsUpDown } from "lucide-react"; +import { ChevronDown, ChevronsUpDown, Crown, LogOut, Mail, ShieldCheck, User } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import CopyButton from "@/components/shared/CopyButton"; import { cn } from "@/lib/cva.config"; import React, { useEffect, useState } from "react"; -const { Text } = Typography; - function hueFromString(seed: string): number { let h = 0; for (let i = 0; i < seed.length; i += 1) { @@ -80,60 +74,57 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar setDisableShowNewBadge(storedValue === "true"); }, []); - const userItems: MenuProps["items"] = [ - { - key: "logout", - label: ( - - - Logout - - ), - onClick: onLogout, - }, - ]; - const renderUserInfoSection = () => ( - - - - - {userEmail || "-"} - +
+
+
+ + {userEmail || "-"} +
{premiumUser ? ( - } color="gold"> + + Premium - + ) : ( - - }>Standard - + + + }> + + Standard + + Upgrade to Premium for advanced features + + )} - - - - - - User ID - - - {userId || "-"} - - - - - - Role - - {userRole} - - - - Hide New Feature Indicators +
+ +
+
+ + User ID +
+
+ + {userId || "-"} + + +
+
+
+
+ + Role +
+ {userRole} +
+ +
+ Hide New Feature Indicators { + onCheckedChange={(checked) => { setDisableShowNewBadge(checked); if (checked) { setLocalStorageItem("disableShowNewBadge", "true"); @@ -145,13 +136,13 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide new feature indicators" /> - - - Hide All Prompts +
+
+ Hide All Prompts { + onCheckedChange={(checked) => { if (checked) { setLocalStorageItem("disableShowPrompts", "true"); emitLocalStorageChange("disableShowPrompts"); @@ -162,13 +153,13 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide all prompts" /> - - - Hide Blog Posts +
+
+ Hide Blog Posts { + onCheckedChange={(checked) => { if (checked) { setLocalStorageItem("disableBlogPosts", "true"); emitLocalStorageChange("disableBlogPosts"); @@ -179,13 +170,13 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide blog posts" /> - - - Hide Bouncing Icon +
+
+ Hide Bouncing Icon { + onCheckedChange={(checked) => { if (checked) { setLocalStorageItem("disableBouncingIcon", "true"); emitLocalStorageChange("disableBouncingIcon"); @@ -196,8 +187,8 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide bouncing icon" /> - - +
+
); const seed = userEmail || userId || "user"; @@ -206,30 +197,21 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar const displayName = navAccountDisplayName(userEmail, userId); return ( - ( -
- {renderUserInfoSection()} - - {React.cloneElement(menu as React.ReactElement, { - style: { boxShadow: "none" }, - })} -
- )} - > + {variant === "sidebar" ? ( - + ) : ( - + + )} -
+ + {renderUserInfoSection()} + + + + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index 09a4538ae18..f1dec137305 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx @@ -1,9 +1,12 @@ import React from "react"; import { usePathname } from "next/navigation"; -import { Dropdown } from "antd"; -import { AppstoreOutlined, CheckOutlined } from "@ant-design/icons"; -import { ChevronsUpDown } from "lucide-react"; -import type { MenuProps } from "antd"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Check, ChevronsUpDown, LayoutGrid } from "lucide-react"; import { usePluginMode } from "@/contexts/PluginModeContext"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { migratedHref } from "@/utils/migratedPages"; @@ -11,6 +14,13 @@ import { migratedHref } from "@/utils/migratedPages"; const GATEWAY = "ai-gateway"; const CHAT = "chat"; +interface ViewSwitcherItem { + key: string; + label: React.ReactNode; + disabled?: boolean; + onClick?: () => void; +} + export default function ViewSwitcher() { const { mode, setMode, plugins } = usePluginMode(); const { data: uiSettings } = useUISettings(); @@ -29,15 +39,25 @@ export default function ViewSwitcher() { ...plugins.map((p) => ({ key: p.name, label: p.display_name })), ]; - const chatItem = chatEnabled + const selectMode = (key: string) => { + setMode(key); + // The chat route lives outside the dashboard SPA shell that reacts to `mode`, + // so switching modes from there needs a real navigation, not just state. + if (isChatRoute) { + window.location.assign(migratedHref("")); + } + }; + + const chatItem: ViewSwitcherItem = chatEnabled ? { key: CHAT, label: (
Chat - {isChatRoute && } + {isChatRoute && }
), + onClick: () => window.location.assign(migratedHref(CHAT)), } : { key: CHAT, @@ -52,44 +72,43 @@ export default function ViewSwitcher() { ), }; - const items: MenuProps["items"] = [ + const items: ViewSwitcherItem[] = [ ...modeEntries.map((e) => ({ key: e.key, label: (
{e.label} - {!isChatRoute && e.key === mode && } + {!isChatRoute && e.key === mode && }
), + onClick: () => selectMode(e.key), })), chatItem, ]; - const onClick: MenuProps["onClick"] = ({ key }) => { - if (key === CHAT) { - window.location.assign(migratedHref(CHAT)); - return; - } - setMode(key); - // The chat route lives outside the dashboard SPA shell that reacts to `mode`, - // so switching modes from there needs a real navigation, not just state. - if (isChatRoute) { - window.location.assign(migratedHref("")); - } - }; - return ( - - - + + + {items.map((item) => ( + + {item.label} + + ))} + + ); } diff --git a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx index a51d6ba055d..0930270eb4b 100644 --- a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx @@ -1,32 +1,21 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi, beforeEach } from "vitest"; -// Mock the useWorker hook const mockUseWorker = vi.fn(); vi.mock("@/hooks/useWorker", () => ({ useWorker: () => mockUseWorker(), })); -// Mock antd Select -vi.mock("antd", () => ({ - Select: ({ value, options, onChange, style, disabled, ...props }: any) => ( - - ), -})); - -// Mock icon -vi.mock("@ant-design/icons", () => ({ - CloudServerOutlined: () => , -})); - import WorkerDropdown from "./WorkerDropdown"; +async function openWorkerList(user: ReturnType) { + await user.click(screen.getByRole("combobox")); + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "true"); + }); +} + describe("WorkerDropdown", () => { const mockOnWorkerSwitch = vi.fn(); const workers = [ @@ -61,31 +50,7 @@ describe("WorkerDropdown", () => { expect(container).toBeEmptyDOMElement(); }); - it("renders the select when isControlPlane and selectedWorker exist", () => { - mockUseWorker.mockReturnValue({ - isControlPlane: true, - selectedWorker: workers[0], - workers, - }); - - render(); - expect(screen.getByTestId("worker-select")).toBeInTheDocument(); - }); - - it("renders all worker options", () => { - mockUseWorker.mockReturnValue({ - isControlPlane: true, - selectedWorker: workers[0], - workers, - }); - - render(); - expect(screen.getByText("Worker 1")).toBeInTheDocument(); - expect(screen.getByText("Worker 2")).toBeInTheDocument(); - expect(screen.getByText("Worker 3")).toBeInTheDocument(); - }); - - it("sets current worker as selected value", () => { + it("renders a collapsed worker combobox when isControlPlane and selectedWorker exist", () => { mockUseWorker.mockReturnValue({ isControlPlane: true, selectedWorker: workers[1], @@ -93,37 +58,109 @@ describe("WorkerDropdown", () => { }); render(); - const select = screen.getByTestId("worker-select") as HTMLSelectElement; - expect(select.value).toBe("w2"); + expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false"); }); - it("disables the currently selected worker in options", () => { + it("reveals every worker only once the combobox is opened", async () => { mockUseWorker.mockReturnValue({ isControlPlane: true, - selectedWorker: workers[0], + selectedWorker: workers[1], workers, }); - - render(); - const options = screen.getAllByRole("option"); - const selectedOption = options.find((opt) => (opt as HTMLOptionElement).value === "w1"); - expect(selectedOption).toBeDisabled(); - }); - - it("calls onWorkerSwitch when selection changes", async () => { - mockUseWorker.mockReturnValue({ - isControlPlane: true, - selectedWorker: workers[0], - workers, - }); - - render(); - const select = screen.getByTestId("worker-select"); - - const { default: userEvent } = await import("@testing-library/user-event"); const user = userEvent.setup(); - await user.selectOptions(select, "w2"); - expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w2"); + render(); + expect(screen.queryAllByRole("option")).toHaveLength(0); + expect(screen.queryByText("Worker 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Worker 3")).not.toBeInTheDocument(); + + await openWorkerList(user); + + await waitFor(() => { + expect(screen.getByText("Worker 1")).toBeInTheDocument(); + }); + expect(screen.getAllByText("Worker 2").length).toBeGreaterThan(0); + expect(screen.getByText("Worker 3")).toBeInTheDocument(); + }); + + it("marks exactly one option as selected, the current worker", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await openWorkerList(user); + + await waitFor(() => { + const selected = screen.getAllByRole("option").filter((o) => o.getAttribute("aria-selected") === "true"); + expect(selected).toHaveLength(1); + expect(selected[0]).toHaveAccessibleName("Worker 2"); + }); + }); + + it("calls onWorkerSwitch with the id of the worker that was picked", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await openWorkerList(user); + await waitFor(() => { + expect(screen.getByText("Worker 3")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Worker 3")); + + expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w3"); + }); + + it("does not call onWorkerSwitch when the already-current worker is picked", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await openWorkerList(user); + await waitFor(() => { + expect(screen.getByText("Worker 3")).toBeInTheDocument(); + }); + + for (const currentWorkerNode of screen.getAllByText("Worker 2")) { + fireEvent.click(currentWorkerNode); + } + + expect(mockOnWorkerSwitch).not.toHaveBeenCalled(); + }); + + it("filters the worker options by the typed search text", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await openWorkerList(user); + await waitFor(() => { + expect(screen.getByText("Worker 1")).toBeInTheDocument(); + }); + + await user.clear(screen.getByRole("combobox")); + await user.type(screen.getByRole("combobox"), "worker 3"); + + await waitFor(() => { + expect(screen.queryByText("Worker 1")).not.toBeInTheDocument(); + }); + expect(screen.getByText("Worker 3")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx index 186cc611117..432bab8c9ef 100644 --- a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx @@ -1,35 +1,66 @@ "use client"; import React from "react"; -import { Select } from "antd"; -import { CloudServerOutlined } from "@ant-design/icons"; +import { Server } from "lucide-react"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { InputGroupAddon } from "@/components/ui/input-group"; import { useWorker } from "@/hooks/useWorker"; interface WorkerDropdownProps { onWorkerSwitch: (workerId: string) => void; } +interface WorkerOption { + label: string; + value: string; + disabled: boolean; +} + const WorkerDropdown: React.FC = ({ onWorkerSwitch }) => { const { isControlPlane, selectedWorker, workers } = useWorker(); if (!isControlPlane || !selectedWorker) return null; + const options: WorkerOption[] = workers.map((w) => ({ + label: w.name, + value: w.worker_id, + disabled: w.worker_id === selectedWorker.worker_id, + })); + return ( - + +

Drag and drop your CSV file here

+

or

+ Browse files +

Only CSV files (.csv) are supported

+
+ + )} -
-
-
- {parsedData.some((user) => user.status === "success" || user.status === "failed") ? ( -
- Creation Summary - - {parsedData.filter((d) => d.status === "success").length} Successful - - {parsedData.some((d) => d.status === "failed") && ( - - {parsedData.filter((d) => d.status === "failed").length} Failed - + {csvStructureError && ( +
+
+ +
+ CSV Structure Error +

{csvStructureError}

+

+ Please download our template and ensure your CSV follows the required format. +

+
+
+
+ )} +
+
+ ) : ( +
+
+
+ 3 +
+

+ {parsedData.some((user) => user.status === "success" || user.status === "failed") + ? "User Creation Results" + : "Review and create users"} +

+
+ + {parseError && ( +
+
+ +
+

{parseError}

+ {parsedData.some((user) => !user.isValid) && ( +
    +
  • Check the table below for specific errors in each row
  • +
  • + Common issues include invalid email formats, missing required fields, or incorrect role + values +
  • +
  • Fix these issues in your CSV file and upload again
  • +
)}
- ) : ( -
- User Preview - - {parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid - +
+
+ )} + +
+
+
+ {parsedData.some((user) => user.status === "success" || user.status === "failed") ? ( +
+

Creation Summary

+

+ {parsedData.filter((d) => d.status === "success").length} Successful +

+ {parsedData.some((d) => d.status === "failed") && ( +

+ {parsedData.filter((d) => d.status === "failed").length} Failed +

+ )} +
+ ) : ( +
+

User Preview

+

+ {parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid +

+
+ )} +
+ + {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( +
+ +
)}
- {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
+ {parsedData.some((user) => user.status === "success") && ( +
+
+
+ +
+
+

User creation complete

+

+ Next step: Download the credentials file containing + Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests + through LiteLLM. +

+
+
+
+ )} + +
+
+ + + Row + Email + Role + Teams + Budget + Status + + + + {visibleRows.map((record) => ( + + {record.rowNumber} + {record.user_email} + {record.user_role} + {record.teams} + {record.max_budget} + {renderStatusCell(record)} + + ))} + +
+
+ + {pageCount > 1 && ( +
+ + Page {currentPage + 1} of {pageCount} + + +
+ )} + + {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( +
+
)} -
- {parsedData.some((user) => user.status === "success") && ( -
-
-
- -
-
- User creation complete - - Next step: Download the credentials file containing - Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests - through LiteLLM. - -
+ {parsedData.some((user) => user.status === "success" || user.status === "failed") && ( +
+ +
-
- )} - - (!record.isValid ? "bg-red-50" : "")} - /> - - {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
- - -
- )} - - {parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
- - -
- )} + )} + - - )} - - + )} + + + ); }; diff --git a/ui/litellm-dashboard/src/components/common_components/DefaultProxyAdminTag.tsx b/ui/litellm-dashboard/src/components/common_components/DefaultProxyAdminTag.tsx index e42e3cee6da..9ec24bb929b 100644 --- a/ui/litellm-dashboard/src/components/common_components/DefaultProxyAdminTag.tsx +++ b/ui/litellm-dashboard/src/components/common_components/DefaultProxyAdminTag.tsx @@ -1,6 +1,4 @@ -import { Tag, Typography } from "antd"; - -const { Text } = Typography; +import { Badge } from "@/components/ui/badge"; const DEFAULT_USER_ID = "default_user_id"; @@ -8,15 +6,10 @@ interface DefaultProxyAdminTagProps { userId: string | null | undefined; } -/** - * Renders "Default Proxy Admin" as a blue Tag when the given userId is - * the well-known `default_user_id`, otherwise renders the raw value as - * plain text. - */ export default function DefaultProxyAdminTag({ userId }: DefaultProxyAdminTagProps) { if (userId === DEFAULT_USER_ID) { - return Default Proxy Admin; + return Default Proxy Admin; } - return {userId}; + return {userId}; } diff --git a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.test.tsx b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.test.tsx index e27a60cc866..465f7fcfcf0 100644 --- a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.test.tsx @@ -159,6 +159,20 @@ describe("DeleteResourceModal", () => { expect(cancelButton).toBeDisabled(); }); + it("should call onCancel when escape is pressed and no deletion is in flight", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.keyboard("{Escape}"); + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it("should ignore escape while confirmLoading is true so the modal cannot close mid-deletion", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.keyboard("{Escape}"); + expect(mockOnCancel).not.toHaveBeenCalled(); + }); + it("should disable delete button when confirmLoading is true even if requiredConfirmation matches", async () => { const user = userEvent.setup(); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx index 5a6160483a0..42baae3d86c 100644 --- a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx @@ -1,6 +1,10 @@ -import { Alert, Card, Descriptions, Input, Modal, Typography, theme } from "antd"; -import { ExclamationCircleOutlined } from "@ant-design/icons"; +import { CircleAlert } from "lucide-react"; import React, { useState, useEffect } from "react"; +import { Alert, AlertTitle } from "@/components/shared/Alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; interface DeleteResourceModalProps { isOpen: boolean; @@ -8,12 +12,11 @@ interface DeleteResourceModalProps { alertMessage?: string; message: string; resourceInformationTitle?: string; - resourceInformation?: Array< - { - label: string; - value: string | number | undefined | null; - } & Omit, "children"> - >; + resourceInformation?: Array<{ + label: string; + value: string | number | undefined | null; + code?: boolean; + }>; onCancel: () => void; onOk: () => void; confirmLoading: boolean; @@ -32,8 +35,6 @@ export default function DeleteResourceModal({ confirmLoading, requiredConfirmation, }: DeleteResourceModalProps) { - const { Text } = Typography; - const { token } = theme.useToken(); const [requiredConfirmationInput, setRequiredConfirmationInput] = useState(""); useEffect(() => { @@ -43,69 +44,69 @@ export default function DeleteResourceModal({ }, [isOpen]); return ( - -
- {alertMessage && } - - - {resourceInformation && - resourceInformation.map(({ label, value, ...textProps }) => ( - {label}}> - {value ?? "-"} - - ))} - - -
- {message} -
- {requiredConfirmation && ( -
- - Type - - {requiredConfirmation} - - to confirm deletion: - - setRequiredConfirmationInput(e.target.value)} - placeholder={requiredConfirmation} - className="rounded-md" - prefix={} - autoFocus - /> + !open && !confirmLoading && onCancel()}> + + + {title} + +
+ {alertMessage && ( + + {alertMessage} + + )} + + {resourceInformationTitle && ( + + {resourceInformationTitle} + + )} + +
+ {resourceInformation?.map(({ label, value, code }) => ( + +
{label}
+
{code ? {value ?? "-"} : value ?? "-"}
+
+ ))} +
+
+
+
+ {message}
- )} -
- + {requiredConfirmation && ( +
+

+ Type {requiredConfirmation} to confirm deletion: +

+ + + + + setRequiredConfirmationInput(e.target.value)} + placeholder={requiredConfirmation} + autoFocus + /> + +
+ )} +
+ + + + + + ); } diff --git a/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx b/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx index c3540ce757a..9b89d85507b 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from "react"; import { PlusCircleIcon, PencilIcon, TrashIcon } from "@heroicons/react/outline"; -import { Card, Title, Text, Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react"; +import { Card, CardTitle } from "@/components/ui/card"; +import { Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from "@/components/ui/table"; import ModelSelector from "./ModelSelector"; import NotificationsManager from "../molecules/notifications_manager"; @@ -141,7 +142,7 @@ const ModelAliasManager: React.FC = ({ return (
- Add New Alias +

Add New Alias

@@ -186,17 +187,17 @@ const ModelAliasManager: React.FC = ({
- Manage Existing Aliases +

Manage Existing Aliases

- + - Alias Name - Target Model - Actions + Alias Name + Target Model + Actions - + {aliases.map((alias) => ( @@ -284,9 +285,9 @@ const ModelAliasManager: React.FC = ({ {/* Configuration Example */} {showExampleConfig && ( - - Configuration Example - Here's how your current aliases would look in the config: + + Configuration Example +

Here's how your current aliases would look in the config:

model_aliases: diff --git a/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx b/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx index 857e4d3296b..bd7b56f1817 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx @@ -1,28 +1,20 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; import ModelSelector from "./ModelSelector"; vi.mock("@/components/llm_calls/fetch_models", () => ({ fetchAvailableModels: vi.fn().mockResolvedValue([]), })); -const openCustomModelInput = () => { - const selector = document.querySelector(".ant-select-selector"); - expect(selector).toBeTruthy(); - act(() => { - fireEvent.mouseDown(selector!); - }); - act(() => { - fireEvent.click(screen.getByText("Enter custom model")); - }); +const openCustomModelInput = async () => { + const user = userEvent.setup(); + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Enter custom model")); return screen.getByPlaceholderText("Enter custom model name"); }; describe("ModelSelector custom model debounce", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { act(() => { vi.runOnlyPendingTimers(); @@ -30,11 +22,12 @@ describe("ModelSelector custom model debounce", () => { vi.useRealTimers(); }); - it("does not call onChange before the debounce wait elapses", () => { + it("does not call onChange before the debounce wait elapses", async () => { const onChange = vi.fn(); render(); - const input = openCustomModelInput(); + const input = await openCustomModelInput(); + vi.useFakeTimers(); act(() => { fireEvent.change(input, { target: { value: "gpt-4o" } }); @@ -49,11 +42,12 @@ describe("ModelSelector custom model debounce", () => { expect(onChange).not.toHaveBeenCalled(); }); - it("calls onChange exactly once with the last typed value after the wait", () => { + it("calls onChange exactly once with the last typed value after the wait", async () => { const onChange = vi.fn(); render(); - const input = openCustomModelInput(); + const input = await openCustomModelInput(); + vi.useFakeTimers(); act(() => { fireEvent.change(input, { target: { value: "g" } }); @@ -71,11 +65,12 @@ describe("ModelSelector custom model debounce", () => { expect(onChange).toHaveBeenCalledWith("gpt-5.2"); }); - it("does not call onChange when unmounted mid-wait", () => { + it("does not call onChange when unmounted mid-wait", async () => { const onChange = vi.fn(); const { unmount } = render(); - const input = openCustomModelInput(); + const input = await openCustomModelInput(); + vi.useFakeTimers(); act(() => { fireEvent.change(input, { target: { value: "gpt-4o" } }); diff --git a/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx b/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx index f2621cd1acb..a50131256fa 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from "react"; -import { TextInput, Text } from "@tremor/react"; -import { Select } from "antd"; -import { RobotOutlined } from "@ant-design/icons"; +import { Bot } from "lucide-react"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; +import { Input } from "@/components/ui/input"; +import { SearchSelect } from "@/components/shared/SearchSelect"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; const MODEL_SELECT_DEBOUNCE_MS = 500; @@ -80,32 +80,30 @@ const ModelSelector: React.FC = ({ return (
{showLabel && ( - - {labelText} - +

+ {labelText} +

)} - { - if (!option) return false; - const org = organizations?.find((o) => o.organization_id === option.key); - if (!org) return false; - - const searchTerm = input.toLowerCase().trim(); - const orgAlias = (org.organization_alias || "").toLowerCase(); - const orgId = (org.organization_id || "").toLowerCase(); - - return orgAlias.includes(searchTerm) || orgId.includes(searchTerm); - }} - > - {organizations?.map((org) => ( - - {org.organization_alias}{" "} - ({org.organization_id}) - - ))} - +
+ ({ + label: org.organization_alias || org.organization_id, + value: org.organization_id, + sublabel: org.organization_id, + }))} + value={value} + onValueChange={(organizationId) => onChange?.(organizationId)} + placeholder={placeholder} + emptyText={loading ? "Loading organizations…" : "No organizations found"} + disabled={disabled} + inputId={id} + /> +
); }; diff --git a/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx b/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx index e02125dea56..330f852383d 100644 --- a/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx +++ b/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Select } from "antd"; +import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect"; import { getPassThroughEndpointsCall } from "../networking"; interface PassThroughRoutesSelectorProps { @@ -17,6 +17,11 @@ interface PassThroughEndpoint { methods?: string[]; } +const routeOption = (endpoint: PassThroughEndpoint): MultiSelectOption => ({ + label: endpoint.methods?.length ? `${endpoint.methods.join(", ")} ${endpoint.path}` : endpoint.path, + value: endpoint.path, +}); + const PassThroughRoutesSelector: React.FC = ({ onChange, value, @@ -26,7 +31,7 @@ const PassThroughRoutesSelector: React.FC = ({ disabled = false, teamId, }) => { - const [passThroughRoutes, setPassThroughRoutes] = useState>([]); + const [passThroughRoutes, setPassThroughRoutes] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { @@ -37,27 +42,7 @@ const PassThroughRoutesSelector: React.FC = ({ try { const response = await getPassThroughEndpointsCall(accessToken, teamId); if (response.endpoints) { - const routes = response.endpoints.flatMap((endpoint: PassThroughEndpoint) => { - const path = endpoint.path; - const methods = endpoint.methods; - - // If methods are specified, create one entry per method - if (methods && methods.length > 0) { - return methods.map((method) => ({ - label: `${method} ${path}`, - value: path, // Keep value as path for backward compatibility - })); - } - - // If no methods specified, show just the path (all methods supported) - return [ - { - label: path, - value: path, - }, - ]; - }); - setPassThroughRoutes(routes); + setPassThroughRoutes(response.endpoints.map(routeOption)); } } catch (error) { console.error("Error fetching pass through routes:", error); @@ -70,19 +55,16 @@ const PassThroughRoutesSelector: React.FC = ({ }, [accessToken, teamId]); return ( - ({ + label: project.project_alias || project.project_id, + value: project.project_id, + sublabel: project.project_id, + })) + } value={value} - onChange={onChange} + onValueChange={(projectId) => onChange?.(projectId)} + placeholder="Search or select a project" + emptyText={loading ? "Loading projects…" : "No projects found"} disabled={disabled} - loading={loading} - allowClear - notFoundContent={loading ? } size="small" /> : undefined} - filterOption={(input, option) => { - if (!option) return false; - const project = filtered?.find((p) => p.project_id === option.key); - if (!project) return false; - - const searchTerm = input.toLowerCase().trim(); - const alias = (project.project_alias || "").toLowerCase(); - const id = (project.project_id || "").toLowerCase(); - - return alias.includes(searchTerm) || id.includes(searchTerm); - }} - optionFilterProp="children" - > - {!loading && - filtered?.map((project) => ( - - {project.project_alias || project.project_id}{" "} - ({project.project_id}) - - ))} - + inputId={id} + /> ); }; diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx index 5ac3b8b2b64..6f819607e3f 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx @@ -21,14 +21,6 @@ vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({ ), })); -vi.mock("@tremor/react", () => ({ - TabGroup: ({ children }: { children: ReactNode }) =>
{children}
, - TabList: ({ children }: { children: ReactNode }) =>
{children}
, - Tab: ({ children }: { children: ReactNode }) =>
{children}
, - TabPanels: ({ children }: { children: ReactNode }) =>
{children}
, - TabPanel: ({ children }: { children: ReactNode }) =>
{children}
, -})); - vi.mock("../router_settings/RouterSettingsForm", () => ({ default: ({ value, diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx index 56227abe9ea..7570806182d 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react"; -import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useQuery } from "@tanstack/react-query"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { getRouterSettingsCall } from "../networking"; @@ -344,13 +344,13 @@ const RouterSettingsAccordion = forwardRef - - - Loadbalancing - Fallbacks - - - + + + Loadbalancing + Fallbacks + +
+ - - + + - - - + +
+
); }, diff --git a/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx b/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx index 4db36f27553..e283f0550ec 100644 --- a/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx @@ -1,10 +1,16 @@ import React from "react"; -import { Select } from "antd"; - -const { Option } = Select; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; export const NEVER_RESETS_BUDGET_DURATION = "none"; +const DURATION_LABELS: Record = { + [NEVER_RESETS_BUDGET_DURATION]: "Never resets", + "1h": "hourly", + "24h": "daily", + "7d": "weekly", + "30d": "monthly", +}; + interface BudgetDurationDropdownProps { value?: string | null; onChange?: (value: string | undefined) => void; @@ -24,18 +30,21 @@ const BudgetDurationDropdown: React.FC = ({ }) => { return ( ); }; diff --git a/ui/litellm-dashboard/src/components/common_components/chartUtils.test.tsx b/ui/litellm-dashboard/src/components/common_components/chartUtils.test.tsx index b924021863a..01a4d8373c4 100644 --- a/ui/litellm-dashboard/src/components/common_components/chartUtils.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/chartUtils.test.tsx @@ -1,9 +1,11 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { CustomLegend, CustomTooltip } from "./chartUtils"; -import type { CustomTooltipProps } from "@tremor/react"; +import type { ChartTooltipProps } from "@/components/shared/charts/chart_tooltip"; import { SpendMetrics } from "../UsagePage/types"; +type TooltipPayload = NonNullable; + describe("CustomTooltip", () => { const mockPayload = [ { @@ -28,9 +30,9 @@ describe("CustomTooltip", () => { ]; it("should render", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -38,9 +40,9 @@ describe("CustomTooltip", () => { }); it("should return null when not active", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: false, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; const { container } = render(); @@ -48,9 +50,9 @@ describe("CustomTooltip", () => { }); it("should return null when payload is empty", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: [], + payload: [] as unknown as TooltipPayload, label: "2024-01-15", }; const { container } = render(); @@ -58,9 +60,9 @@ describe("CustomTooltip", () => { }); it("should display formatted category names", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -89,9 +91,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithUnderscores, + payload: payloadWithUnderscores as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -120,9 +122,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: spendPayload, + payload: spendPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -130,9 +132,9 @@ describe("CustomTooltip", () => { }); it("should format non-spend numeric values with locale string", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -161,9 +163,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithUndefined, + payload: payloadWithUndefined as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -211,9 +213,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: multiplePayload, + payload: multiplePayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -222,9 +224,9 @@ describe("CustomTooltip", () => { }); it("should convert color names to hex values", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; const { container } = render(); @@ -254,9 +256,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithHexColor, + payload: payloadWithHexColor as unknown as TooltipPayload, label: "2024-01-15", }; const { container } = render(); @@ -286,9 +288,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithoutDataKey as any, + payload: payloadWithoutDataKey as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -304,9 +306,9 @@ describe("CustomTooltip", () => { payload: undefined, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithoutPayload as any, + payload: payloadWithoutPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); diff --git a/ui/litellm-dashboard/src/components/common_components/chartUtils.tsx b/ui/litellm-dashboard/src/components/common_components/chartUtils.tsx index c0930f290f6..0abf004803b 100644 --- a/ui/litellm-dashboard/src/components/common_components/chartUtils.tsx +++ b/ui/litellm-dashboard/src/components/common_components/chartUtils.tsx @@ -1,4 +1,4 @@ -import type { CustomTooltipProps } from "@tremor/react"; +import type { ChartTooltipProps } from "@/components/shared/charts/chart_tooltip"; import { SpendMetrics } from "../UsagePage/types"; interface ChartDataPoint { @@ -16,7 +16,7 @@ const colorNameToHex: { [key: string]: string } = { emerald: "#37bc7d", }; -export const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { +export const CustomTooltip = ({ active, payload, label }: ChartTooltipProps) => { if (active && payload && payload.length) { const formatCategoryName = (name: string): string => { return name diff --git a/ui/litellm-dashboard/src/components/common_components/routerSettingsWiring.test.tsx b/ui/litellm-dashboard/src/components/common_components/routerSettingsWiring.test.tsx index d6ed0ae07db..15c52e71e8f 100644 --- a/ui/litellm-dashboard/src/components/common_components/routerSettingsWiring.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/routerSettingsWiring.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; -import type { ReactElement, ReactNode } from "react"; +import type { ReactElement } from "react"; import { describe, expect, it, vi } from "vitest"; import type { FallbackGroup } from "../Settings/RouterSettings/Fallbacks/FallbackGroupConfig"; import type { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm"; @@ -16,14 +16,6 @@ vi.mock("@/components/llm_calls/fetch_models", () => ({ fetchAvailableModelsForTeam: vi.fn().mockResolvedValue([]), })); -vi.mock("@tremor/react", () => ({ - TabGroup: ({ children }: { children: ReactNode }) =>
{children}
, - TabList: ({ children }: { children: ReactNode }) =>
{children}
, - Tab: ({ children }: { children: ReactNode }) =>
{children}
, - TabPanels: ({ children }: { children: ReactNode }) =>
{children}
, - TabPanel: ({ children }: { children: ReactNode }) =>
{children}
, -})); - vi.mock("../router_settings/RouterSettingsForm", () => ({ default: ({ value }: { value: RouterSettingsFormValue }) => (
{JSON.stringify(value.routerSettings)}
diff --git a/ui/litellm-dashboard/src/components/common_components/simple_table.tsx b/ui/litellm-dashboard/src/components/common_components/simple_table.tsx index 4a858a346d4..17e8d46d21d 100644 --- a/ui/litellm-dashboard/src/components/common_components/simple_table.tsx +++ b/ui/litellm-dashboard/src/components/common_components/simple_table.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Table, TableHead, TableRow, TableHeaderCell, TableBody, TableCell, Text } from "@tremor/react"; +import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; export interface SimpleTableColumn { header: string; @@ -31,20 +31,20 @@ export function SimpleTable({ }: SimpleTableProps) { return (
- + {columns.map((column, index) => ( - + {column.header} - + ))} - + {isLoading ? ( - {loadingMessage} + {loadingMessage} ) : data.length > 0 ? ( @@ -60,7 +60,7 @@ export function SimpleTable({ ) : ( - {emptyMessage} + {emptyMessage} )} diff --git a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx index 7d27886c7f5..35121f41598 100644 --- a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx @@ -1,13 +1,8 @@ -import React, { useMemo, useState, type UIEvent } from "react"; -import { Select, Typography } from "antd"; -import { LoadingOutlined } from "@ant-design/icons"; -import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; +import React, { useMemo, useState } from "react"; +import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; -import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { Team } from "../key_team_helpers/key_list"; -const { Text } = Typography; - interface TeamDropdownProps { value?: string; onChange?: (value: string) => void; @@ -17,10 +12,9 @@ interface TeamDropdownProps { /** Filter teams by organization. */ organizationId?: string | null; pageSize?: number; + id?: string; } -const SCROLL_THRESHOLD = 0.8; - const TeamDropdown: React.FC = ({ value, onChange, @@ -28,15 +22,13 @@ const TeamDropdown: React.FC = ({ disabled, organizationId, pageSize = 20, + id, }) => { - const [searchInput, setSearchInput] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { - wait: DEBOUNCE_WAIT_MS, - }); + const [search, setSearch] = useState(""); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams( pageSize, - debouncedSearch || undefined, + search || undefined, organizationId, ); @@ -54,59 +46,35 @@ const TeamDropdown: React.FC = ({ return result; }, [data]); - const handlePopupScroll = (e: UIEvent) => { - const target = e.currentTarget; - const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight; - if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }; - - const handleSearch = (val: string) => { - setSearchInput(val); - setDebouncedSearch(val); - }; - - const handleChange = (teamId: string | undefined) => { - onChange?.(teamId ?? ""); + const handleChange = (teamId: string) => { + onChange?.(teamId); if (onTeamSelect) { - const team = teamId ? teams.find((t) => t.team_id === teamId) ?? null : null; - onTeamSelect(team); + onTeamSelect(teamId ? teams.find((t) => t.team_id === teamId) ?? null : null); } }; return ( - +
+ ({ + label: team.team_alias || team.team_id, + value: team.team_id, + sublabel: team.team_id, + }))} + value={value || undefined} + onValueChange={handleChange} + onSearchChange={setSearch} + onLoadMore={fetchNextPage} + hasNextPage={hasNextPage} + isLoading={isLoading} + isFetchingNextPage={isFetchingNextPage} + placeholder="Search or select a team" + emptyText="No teams found" + loadingText="Loading teams…" + disabled={disabled} + inputId={id} + /> +
); }; diff --git a/ui/litellm-dashboard/src/components/logging_settings_view.tsx b/ui/litellm-dashboard/src/components/logging_settings_view.tsx index 97eca9d6247..ac3d688308d 100644 --- a/ui/litellm-dashboard/src/components/logging_settings_view.tsx +++ b/ui/litellm-dashboard/src/components/logging_settings_view.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Tag } from "antd"; +import { Badge } from "@/components/ui/badge"; import { CogIcon, BanIcon } from "@heroicons/react/outline"; import { callbackInfo, callback_map, reverse_callback_map } from "./callback_info_helpers"; import { Logo } from "@/components/molecules/logo/Logo"; @@ -29,16 +29,16 @@ export function LoggingSettingsView({ return callbackDisplayName || callbackName; }; - const getEventTypeColor = (eventType: string): string | undefined => { + const getEventTypeVariant = (eventType: string): React.ComponentProps["variant"] => { switch (eventType) { case "success": - return "green"; + return "default"; case "failure": - return "red"; + return "destructive"; case "success_and_failure": - return "blue"; + return "secondary"; default: - return undefined; + return "outline"; } }; @@ -62,7 +62,7 @@ export function LoggingSettingsView({
Logging Integrations - {loggingConfigs.length} + {loggingConfigs.length}
{loggingConfigs.length > 0 ? ( @@ -88,7 +88,9 @@ export function LoggingSettingsView({ - {getEventTypeLabel(config.callback_type)} + + {getEventTypeLabel(config.callback_type)} + ); })} @@ -106,7 +108,7 @@ export function LoggingSettingsView({
Disabled Callbacks - {disabledCallbacks.length} + {disabledCallbacks.length}
{disabledCallbacks.length > 0 ? ( @@ -131,7 +133,7 @@ export function LoggingSettingsView({ Disabled for this key - Disabled + Disabled ); })} diff --git a/ui/litellm-dashboard/src/components/model_filters.tsx b/ui/litellm-dashboard/src/components/model_filters.tsx index bc9ca6bab44..5db144a6be4 100644 --- a/ui/litellm-dashboard/src/components/model_filters.tsx +++ b/ui/litellm-dashboard/src/components/model_filters.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo, useRef } from "react"; -import { Card, Text } from "@tremor/react"; +import { Card } from "@/components/ui/card"; interface ModelGroupInfo { model_group: string; @@ -125,7 +125,7 @@ const ModelFilters: React.FC = ({ const filtersContent = (
- Search Models: +

Search Models:

= ({ />
- Provider: +

Provider:

- Mode: +

Mode:

- Features: +

Features:

- + - Alias Name - Target Model Group - Actions + Alias Name + Target Model Group + Actions - + {aliases.map((alias) => ( @@ -275,8 +276,12 @@ const ModelGroupAliasSettings: React.FC = ({ ) : ( <> - {alias.aliasName} - {alias.targetModelGroup} + + {alias.aliasName} + + + {alias.targetModelGroup} +
{/* Configuration Example */} - - Configuration Example - - Here's how your current aliases would look in the config.yaml: - + + Configuration Example +

Here's how your current aliases would look in the config.yaml:

router_settings: diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index c999ee8035e..6ce92fb9449 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -8,8 +8,8 @@ import { useTheme } from "@/contexts/ThemeContext"; import { clearTokenCookies } from "@/utils/cookieUtils"; import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils"; import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; -import { DownOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"; -import { Tag } from "antd"; +import { Badge } from "@/components/ui/badge"; +import { ChevronDown, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import Link from "next/link"; import React from "react"; import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown"; @@ -71,7 +71,13 @@ const Navbar: React.FC = ({ className="mr-2 flex h-9 w-9 items-center justify-center rounded-md text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900" title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} > - {sidebarCollapsed ? : } + + {sidebarCollapsed ? ( + + ) : ( + + )} + )} @@ -98,7 +104,7 @@ const Navbar: React.FC = ({ 🌑 )} - + = ({ > v{version} - +
)}
@@ -138,7 +144,7 @@ const Navbar: React.FC = ({ > Docs {/* Layout parity with Blog chevron — intentional single-level link */} - + diff --git a/ui/litellm-dashboard/src/components/object_permissions_view.tsx b/ui/litellm-dashboard/src/components/object_permissions_view.tsx index 327e127e8fe..c7baa3d52c2 100644 --- a/ui/litellm-dashboard/src/components/object_permissions_view.tsx +++ b/ui/litellm-dashboard/src/components/object_permissions_view.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Text } from "@tremor/react"; import VectorStorePermissions from "./permissions/VectorStorePermissions"; import MCPServerPermissions from "./permissions/MCPServerPermissions"; import AgentPermissions from "./permissions/AgentPermissions"; @@ -38,14 +37,14 @@ export function ObjectPermissionsView({ accessToken={accessToken} /> -
- Search tools +
+

Search tools

{searchTools.length === 0 ? ( - +

No restriction — all configured search tools are allowed for this team. - +

) : ( - {searchTools.join(", ")} +

{searchTools.join(", ")}

)}
@@ -56,8 +55,8 @@ export function ObjectPermissionsView({
- Object Permissions - Access control for Vector Stores and MCP Servers +

Object Permissions

+

Access control for Vector Stores and MCP Servers

{content} @@ -67,7 +66,7 @@ export function ObjectPermissionsView({ return (
- Object Permissions +

Object Permissions

{content}
); diff --git a/ui/litellm-dashboard/src/components/settings.test.tsx b/ui/litellm-dashboard/src/components/settings.test.tsx index c9bcc1eb5b9..62efa1dc372 100644 --- a/ui/litellm-dashboard/src/components/settings.test.tsx +++ b/ui/litellm-dashboard/src/components/settings.test.tsx @@ -1,8 +1,8 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { Form } from "antd"; +import { FormProvider, useForm } from "react-hook-form"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall } from "./networking"; +import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall, setCallbacksCall } from "./networking"; import Settings, { backendCallbackLogoSrc, CallbackSelector } from "./settings"; vi.mock("./networking", () => ({ @@ -114,42 +114,20 @@ describe("Settings", () => { }); }); - it("should display edit modal with fields when edit is clicked", async () => { - const mockCallback = { - name: "langfuse", - variables: { - LANGFUSE_PUBLIC_KEY: "test-public-key", - LANGFUSE_SECRET_KEY: "test-secret-key", - LANGFUSE_HOST: "https://test.langfuse.com", - SLACK_WEBHOOK_URL: null, - OPENMETER_API_KEY: null, - }, - }; - - const mockCallbackConfig = { - id: "langfuse", - displayName: "Langfuse", - dynamic_params: { - LANGFUSE_PUBLIC_KEY: { - type: "text", - ui_name: "Public Key", - required: true, - }, - LANGFUSE_SECRET_KEY: { - type: "password", - ui_name: "Secret Key", - required: true, - }, - LANGFUSE_HOST: { - type: "text", - ui_name: "Host", - required: false, - }, - }, - }; - + const openLangfuseEditModal = async () => { mockGetCallbacksCall.mockResolvedValue({ - callbacks: [mockCallback], + callbacks: [ + { + name: "langfuse", + variables: { + LANGFUSE_PUBLIC_KEY: "test-public-key", + LANGFUSE_SECRET_KEY: "test-secret-key", + LANGFUSE_HOST: "https://test.langfuse.com", + SLACK_WEBHOOK_URL: null, + OPENMETER_API_KEY: null, + }, + }, + ], available_callbacks: { langfuse: { litellm_callback_name: "langfuse", @@ -160,30 +138,118 @@ describe("Settings", () => { alerts: [], }); - mockGetCallbackConfigsCall.mockResolvedValue([mockCallbackConfig]); + mockGetCallbackConfigsCall.mockResolvedValue([ + { + id: "langfuse", + displayName: "Langfuse", + dynamic_params: { + LANGFUSE_PUBLIC_KEY: { type: "text", ui_name: "Public Key", required: true }, + LANGFUSE_SECRET_KEY: { type: "password", ui_name: "Secret Key", required: true }, + LANGFUSE_HOST: { type: "text", ui_name: "Host", required: false }, + }, + }, + ]); const user = userEvent.setup(); - const { getByText } = render(); + render(); await waitFor(() => { - expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); + expect(screen.getByText("Active Logging Callbacks")).toBeInTheDocument(); }); await waitFor(() => { - expect(getByText("Langfuse")).toBeInTheDocument(); + expect(screen.getByText("Langfuse")).toBeInTheDocument(); }); await user.click(screen.getByTestId("callback-actions-langfuse-success")); await user.click(await screen.findByTestId("callback-action-edit")); await waitFor(() => { - expect(getByText("Edit Callback Settings")).toBeInTheDocument(); + expect(screen.getByText("Edit Callback Settings")).toBeInTheDocument(); + }); + + return user; + }; + + it("should display edit modal with fields when edit is clicked", async () => { + await openLangfuseEditModal(); + + await waitFor(() => { + expect(screen.getByText("Public Key")).toBeInTheDocument(); + expect(screen.getByText("Secret Key")).toBeInTheDocument(); + expect(screen.getByText("Host")).toBeInTheDocument(); }); await waitFor(() => { - expect(getByText("Public Key")).toBeInTheDocument(); - expect(getByText("Secret Key")).toBeInTheDocument(); - expect(getByText("Host")).toBeInTheDocument(); + expect(screen.getByLabelText("Public Key")).toHaveValue("test-public-key"); + }); + expect(screen.getByLabelText("Secret Key")).toHaveValue("test-secret-key"); + expect(screen.getByLabelText("Host")).toHaveValue("https://test.langfuse.com"); + + const danglingLabels = [...document.querySelectorAll("label[for]")].filter( + (label) => document.getElementById(label.getAttribute("for") as string) === null, + ); + expect(danglingLabels).toEqual([]); + }); + + it("should post the edited callback variables when the edit modal is saved", async () => { + const user = await openLangfuseEditModal(); + + await waitFor(() => { + expect(screen.getByLabelText("Host")).toHaveValue("https://test.langfuse.com"); + }); + + await user.clear(screen.getByLabelText("Host")); + await user.type(screen.getByLabelText("Host"), "https://edited.langfuse.com"); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledWith("token", { + environment_variables: { + callback: "langfuse", + LANGFUSE_PUBLIC_KEY: "test-public-key", + LANGFUSE_SECRET_KEY: "test-secret-key", + LANGFUSE_HOST: "https://edited.langfuse.com", + }, + litellm_settings: { success_callback: ["langfuse"] }, + }); + }); + }); + + it("should block the edit submit when a required field is emptied", async () => { + const user = await openLangfuseEditModal(); + + await waitFor(() => { + expect(screen.getByLabelText("Public Key")).toHaveValue("test-public-key"); + }); + + await user.clear(screen.getByLabelText("Public Key")); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save Changes" })); + + expect(await screen.findByText("Please enter the public key")).toBeInTheDocument(); + expect(vi.mocked(setCallbacksCall)).not.toHaveBeenCalled(); + }); + + it("should send the typed webhook url for an alert type when the alerting tab is saved", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("tab", { name: "Alerting Types" })); + + const webhookInput = document.querySelector('input[name="llm_exceptions"]') as HTMLInputElement; + expect(webhookInput).not.toBeNull(); + await user.type(webhookInput, "https://hooks.example.com/llm-exceptions"); + + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledWith("token", { + general_settings: expect.objectContaining({ + alert_to_webhook_url: expect.objectContaining({ + llm_exceptions: "https://hooks.example.com/llm-exceptions", + }), + }), + }); }); }); @@ -252,6 +318,19 @@ describe("backendCallbackLogoSrc", () => { }); }); +const CallbackSelectorHarness = ({ + callbackConfigs, +}: { + callbackConfigs: { id: string; displayName: string; logo?: string }[]; +}) => { + const form = useForm>(); + return ( + + + + ); +}; + describe("CallbackSelector logos", () => { it("resolves backend logos per entry: bare filename, external url, and missing logo", async () => { const callbackConfigs = [ @@ -260,13 +339,9 @@ describe("CallbackSelector logos", () => { { id: "nologo", displayName: "NoLogo" }, ]; - render( -
- - , - ); + render(); - fireEvent.mouseDown(screen.getByRole("combobox")); + await userEvent.click(screen.getByRole("combobox")); expect(await screen.findByAltText("Langfuse logo")).toHaveAttribute("src", "/ui/assets/logos/langfuse.png"); expect(screen.getByAltText("Hosted logo")).toHaveAttribute("src", "https://logos.example.com/hosted.png"); diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 904fd4d611e..34fd9af06db 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -1,31 +1,26 @@ -import { - Button, - Card, - Grid, - SelectItem, - Switch, - Tab, - TabGroup, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - TabList, - TabPanel, - TabPanels, - Text, - TextInput, -} from "@tremor/react"; import React, { useEffect, useState } from "react"; +import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form"; -import { Button as Button2, Form, Input, Modal, Select } from "antd"; +import { Field, FieldError, FieldLabel } from "@/components/shared/form/field"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import EmailSettings from "./email_settings"; import { Logo } from "@/components/molecules/logo/Logo"; import NotificationsManager from "./molecules/notifications_manager"; -import FormItem from "antd/es/form/FormItem"; import AlertingSettings from "./alerting/alerting_settings"; import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; @@ -46,6 +41,8 @@ interface SettingsPageProps { premiumUser: boolean; } +type CallbackFormValues = Record; + const assetsLogoFolder = "/ui/assets/logos/"; export const backendCallbackLogoSrc = (logo: string | null | undefined): string | undefined => { @@ -61,6 +58,9 @@ interface DynamicParamsFieldsProps { } const DynamicParamsFields: React.FC = ({ params, callbackConfigs, selectedCallback }) => { + const { register, formState } = useFormContext(); + const fieldIdPrefix = React.useId(); + if (!params || params.length === 0) { return null; } @@ -73,54 +73,51 @@ const DynamicParamsFields: React.FC = ({ params, callb const paramType = paramConfig.type || "text"; const fieldLabel = paramConfig.ui_name || param.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); const isRequired = paramConfig.required || false; + const fieldId = `${fieldIdPrefix}-${param}`; + const registration = register( + param, + isRequired ? { required: `Please enter the ${fieldLabel.toLowerCase()}` } : undefined, + ); return ( - {fieldLabel} } - name={param} - key={param} - className="mb-4" - rules={ - isRequired - ? [ - { - required: true, - message: `Please enter the ${fieldLabel.toLowerCase()}`, - }, - ] - : undefined - } - > + + + {fieldLabel} + {paramType === "password" ? ( - ) : paramType === "number" ? ( ) : ( - + )} - + + ); })}
); }; +interface CallbackConfigOption { + id: string; + displayName: string; + logo?: string | null; +} + // Shared component for rendering callback selector interface CallbackSelectorProps { callbackConfigs: any[]; @@ -135,42 +132,64 @@ export const CallbackSelector: React.FC = ({ onCallbackChange, disabled = false, }) => { + const { control } = useFormContext(); + const inputId = React.useId(); + const selectedConfig = callbackConfigs.find((config) => config.id === selectedCallback) ?? null; + return ( - - - + rules={disabled ? undefined : { required: "Please select a callback" }} + render={({ field, fieldState }) => ( + + Callback + { + field.onChange(config?.id ?? ""); + onCallbackChange(config?.id ?? ""); + }} + isItemEqualToValue={(a: CallbackConfigOption, b: CallbackConfigOption) => a.id === b.id} + itemToStringLabel={(config: CallbackConfigOption) => config.displayName} + filter={(config: CallbackConfigOption, query: string) => + config.id.toLowerCase().includes(query.trim().toLowerCase()) + } + disabled={disabled} + > + + + No results + + {(callbackConfig: CallbackConfigOption) => ( + +
+
+ +
+ {callbackConfig.displayName} +
+
+ )} +
+
+
+ +
+ )} + /> ); }; @@ -206,8 +225,8 @@ const Settings: React.FC = ({ accessToken, userRole, userID, const [callbacks, setCallbacks] = useState([]); const [isLoadingCallbacks, setIsLoadingCallbacks] = useState(true); const [alerts, setAlerts] = useState([]); - const [addForm] = Form.useForm(); - const [editForm] = Form.useForm(); + const addForm = useForm({ shouldUnregister: true }); + const editForm = useForm({ shouldUnregister: true }); const [selectedCallback, setSelectedCallback] = useState(null); const [catchAllWebhookURL, setCatchAllWebhookURL] = useState(""); const [alertToWebhooks, setAlertToWebhooks] = useState>({}); @@ -254,7 +273,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, const normalized = Object.fromEntries( Object.entries(selectedEditCallback.variables || {}).map(([k, v]) => [k, v ?? ""]), ); - editForm.setFieldsValue({ + editForm.reset({ ...normalized, callback: selectedEditCallback.name, }); @@ -337,11 +356,11 @@ const Settings: React.FC = ({ accessToken, userRole, userID, if (isEdit) { setShowEditCallback(false); - editForm.resetFields(); + editForm.reset(); setSelectedEditCallback(null); } else { setShowAddCallbacksModal(false); - addForm.resetFields(); + addForm.reset(); setSelectedCallback(null); setSelectedCallbackParams([]); } @@ -383,6 +402,23 @@ const Settings: React.FC = ({ accessToken, userRole, userID, setSelectedCallbackParams(params); }; + const closeAddCallbackModal = () => { + setShowAddCallbacksModal(false); + setSelectedCallback(null); + setSelectedCallbackParams([]); + }; + + const cancelAddCallback = () => { + closeAddCallbackModal(); + addForm.reset(); + }; + + const closeEditCallbackModal = () => { + setShowEditCallback(false); + setSelectedEditCallback(null); + editForm.reset(); + }; + const handleSaveAlerts = async () => { if (!accessToken) { return; @@ -447,257 +483,216 @@ const Settings: React.FC = ({ accessToken, userRole, userID, return (
- - - - Logging Callbacks - CloudZero Cost Tracking - Alerting Types - Alerting Settings - Email Alerts - - - - setShowAddCallbacksModal(true)} - onEdit={(cb) => { - setSelectedEditCallback(cb); - setShowEditCallback(true); - }} - onDelete={(cb) => handleDeleteCallback(cb)} - onTest={async (cb) => { - try { - await serviceHealthCheck(accessToken, cb.name); - NotificationsManager.success("Health check triggered"); - } catch (error) { - NotificationsManager.fromBackend(parseErrorMessage(error)); - } - }} - /> - - -
- -
-
- - - - Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "} - - here - - -
- - - - - Slack Webhook URL - - +
+ + + Logging Callbacks + CloudZero Cost Tracking + Alerting Types + Alerting Settings + Email Alerts + + + setShowAddCallbacksModal(true)} + onEdit={(cb) => { + setSelectedEditCallback(cb); + setShowEditCallback(true); + }} + onDelete={(cb) => handleDeleteCallback(cb)} + onTest={async (cb) => { + try { + await serviceHealthCheck(accessToken, cb.name); + NotificationsManager.success("Health check triggered"); + } catch (error) { + NotificationsManager.fromBackend(parseErrorMessage(error)); + } + }} + /> + + +
+ +
+
+ + +

+ Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "} + + here + +

+
+ + + + + Slack Webhook URL + + - - {Object.entries(alerts_to_UI_NAME).map(([key, value], index) => ( - - - {key == "region_outage_alerts" ? ( - premiumUser ? ( - handleSwitchChange(key)} - /> - ) : ( - - ) - ) : ( + + {Object.entries(alerts_to_UI_NAME).map(([key, value], index) => ( + + + {key == "region_outage_alerts" ? ( + premiumUser ? ( handleSwitchChange(key)} + onCheckedChange={() => handleSwitchChange(key)} /> - )} - - - {value} - - - - - - ))} - -
- + ) : ( + + ) + ) : ( + handleSwitchChange(key)} + /> + )} + + +

{value}

+
+ + + + + ))} + + + - - - - - - - - - - - - + + + + + + + + + + +
- { - setShowAddCallbacksModal(false); - setSelectedCallback(null); - setSelectedCallbackParams([]); - }} - footer={null} - > - - {" "} - LiteLLM Docs: Logging - + !open && closeAddCallbackModal()}> + + + Add Logging Callback + + + {" "} + LiteLLM Docs: Logging + -
- - - - -
- { - setShowAddCallbacksModal(false); - setSelectedCallback(null); - setSelectedCallbackParams([]); - addForm.resetFields(); - }} - disabled={isAddingCallback} - > - Cancel - - - {isAddingCallback ? "Adding..." : "Add Callback"} - -
- -
- - { - setShowEditCallback(false); - setSelectedEditCallback(null); - editForm.resetFields(); - }} - footer={null} - > -
- {selectedEditCallback && ( - <> + + {}} - disabled={true} + selectedCallback={selectedCallback} + onCallbackChange={handleSelectedCallbackChange} /> - - )} -
- { - setShowEditCallback(false); - setSelectedEditCallback(null); - editForm.resetFields(); - }} - disabled={isUpdatingCallback} - > - Cancel - - { - editForm.submit(); - }} - loading={isUpdatingCallback} - disabled={isUpdatingCallback} - > - {isUpdatingCallback ? "Saving..." : "Save Changes"} - -
- -
+
+ + +
+ + + + + + !open && closeEditCallbackModal()}> + + + Edit Callback Settings + + +
+ {selectedEditCallback && ( + <> + {}} + disabled={true} + /> + + + + )} + +
+ + +
+ +
+
+
{ expect(screen.getByRole("combobox")).toHaveValue("Growth"); }); + it("shows a value the options do not carry yet instead of blanking the field", () => { + const { rerender } = render(); + expect(screen.getByRole("combobox")).toHaveValue("team-2"); + rerender(); + expect(screen.getByRole("combobox")).toHaveValue("Growth"); + }); + it("shows a clear control only when a value is selected", () => { const { rerender } = render(); expect(document.querySelector('[data-slot="combobox-clear"]')).toBeNull(); diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx index f67a1cffa1d..c6ae11f5729 100644 --- a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx @@ -24,6 +24,7 @@ interface SearchSelectProps { emptyText?: string; disabled?: boolean; className?: string; + inputId?: string; } const matchesQuery = (option: SearchSelectOption, query: string): boolean => { @@ -40,12 +41,18 @@ export function SearchSelect({ emptyText = "No results", disabled = false, className, + inputId, }: SearchSelectProps) { - const selected = options.find((option) => option.value === value) ?? null; + const selected = + value === undefined || value === "" + ? null + : options.find((option) => option.value === value) ?? { label: value, value }; + const items = + selected !== null && !options.some((option) => option.value === selected.value) ? [selected, ...options] : options; return ( onValueChange(item?.value ?? "")} isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value} @@ -54,6 +61,7 @@ export function SearchSelect({ disabled={disabled} > { const user = userEvent.setup({ delay: null }); const resetBudgetItem = await openSettingsEditorForTeam(user, { budget_duration: "30d" }); - const clearIcon = resetBudgetItem.querySelector(".ant-select-clear"); - expect(clearIcon).not.toBeNull(); - fireEvent.mouseDown(clearIcon as Element); + await user.click(within(resetBudgetItem).getByRole("combobox")); + await user.click(await screen.findByText("Never resets")); await waitFor(() => { expect(within(resetBudgetItem).getByText("Never resets")).toBeInTheDocument(); @@ -1554,13 +1553,14 @@ describe("TeamInfoView", () => { await user.click(within(routesFormItem).getByRole("combobox")); - const option = await screen.findByTitle("POST /bedrock-passthrough"); + const option = await screen.findByText("POST /bedrock-passthrough"); await user.click(option); await waitFor(() => { expect(within(routesFormItem).getByText(/\/bedrock-passthrough/)).toBeInTheDocument(); }); + await user.keyboard("{Escape}"); await user.click(screen.getByRole("button", { name: /save changes/i })); await waitFor(() => { diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index 374e36029a0..b87beed048a 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -182,7 +182,8 @@ vi.mock("@heroicons/react/outline", async () => { return { ArrowLeftIcon, TrashIcon, RefreshIcon }; }); -vi.mock("lucide-react", async () => { +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); const React = await import("react"); function CopyIcon() { return React.createElement("span"); @@ -192,7 +193,7 @@ vi.mock("lucide-react", async () => { return React.createElement("span"); } (CheckIcon as any).displayName = "CheckIcon"; - return { CopyIcon, CheckIcon }; + return { ...actual, CopyIcon, CheckIcon }; }); // Heavy children -> async factories & local React diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index caad6fc30fc..cdbd3197f7d 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -961,9 +961,8 @@ describe("KeyEditView", () => { ); const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement; - const clearIcon = resetBudgetItem.querySelector(".ant-select-clear"); - expect(clearIcon).not.toBeNull(); - fireEvent.mouseDown(clearIcon as Element); + await userEvent.click(within(resetBudgetItem).getByRole("combobox")); + await userEvent.click(await screen.findByText("Never resets")); await waitFor(() => { expect(within(resetBudgetItem).getByText("Never resets")).toBeInTheDocument(); @@ -995,7 +994,8 @@ describe("KeyEditView", () => { ); const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement; - fireEvent.mouseDown(resetBudgetItem.querySelector(".ant-select-clear") as Element); + await userEvent.click(within(resetBudgetItem).getByRole("combobox")); + await userEvent.click(await screen.findByText("Never resets")); await userEvent.click(screen.getByRole("button", { name: /save changes/i })); @@ -1251,9 +1251,10 @@ describe("KeyEditView", () => { expect(screen.getByText("Organization")).toBeInTheDocument(); }); - const orgFormItem = screen.getByText("Organization").closest(".ant-form-item"); - const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled"); - expect(disabledSelect).toBeTruthy(); + const orgFormItem = screen.getByText("Organization").closest(".ant-form-item") as HTMLElement; + await userEvent.click(within(orgFormItem).getByRole("combobox")); + + expect(screen.queryByText("Engineering")).not.toBeInTheDocument(); }); it("should not disable the organization dropdown for admin users", async () => { @@ -1273,9 +1274,10 @@ describe("KeyEditView", () => { expect(screen.getByText("Organization")).toBeInTheDocument(); }); - const orgFormItem = screen.getByText("Organization").closest(".ant-form-item"); - const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled"); - expect(disabledSelect).toBeFalsy(); + const orgFormItem = screen.getByText("Organization").closest(".ant-form-item") as HTMLElement; + await userEvent.click(within(orgFormItem).getByRole("combobox")); + + expect(await screen.findByText("Engineering")).toBeInTheDocument(); }); it("should initialize organization from keyData", async () => { @@ -1296,8 +1298,9 @@ describe("KeyEditView", () => { />, ); + const orgFormItem = (await screen.findByText("Organization")).closest(".ant-form-item") as HTMLElement; await waitFor(() => { - expect(screen.getByText("Engineering")).toBeInTheDocument(); + expect(within(orgFormItem).getByRole("combobox")).toHaveValue("Engineering"); }); }); }); diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index 1de232fadb8..ce5337aa7a7 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -1,6 +1,5 @@ "use client"; import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; -import { Col, Grid } from "@tremor/react"; import { jwtDecode } from "jwt-decode"; import React, { useEffect, useState } from "react"; import { fetchTeams } from "./common_components/fetch_teams"; @@ -218,8 +217,8 @@ const UserDashboard: React.FC = ({ return (
- - +
+
= ({ ) : undefined } /> - - +
+
); }; diff --git a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx index 96ac965cf8b..cc2696a85b3 100644 --- a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx @@ -1,5 +1,6 @@ -import React from "react"; -import { Collapse } from "antd"; +import React, { useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { formatNumberWithCommas } from "@/utils/dataUtils"; export interface CostBreakdown { @@ -49,6 +50,7 @@ export const CostBreakdownViewer: React.FC = ({ cacheReadTokens, cacheCreationTokens, }) => { + const [open, setOpen] = useState(false); const isCached = cacheHit?.toLowerCase() === "true"; const hasTokenCounts = promptTokens !== undefined || completionTokens !== undefined; @@ -90,197 +92,195 @@ export const CostBreakdownViewer: React.FC = ({ return (
- -

Cost Breakdown

-
- Total: - - {formatCost(totalSpend)} - {isCached && " (Cached)"} - -
-
- ), - children: ( -
- {/* Step 1: Base Token Costs */} -
- {(() => { - const hasCacheBreakdown = - costBreakdown?.cache_read_cost !== undefined || costBreakdown?.cache_creation_cost !== undefined; - if (hasCacheBreakdown) { - // Separate line items: Input / Cache Read / Cache Write - const rawCost = isCached - ? 0 - : (inputCost ?? 0) - - (costBreakdown?.cache_read_cost ?? 0) - - (costBreakdown?.cache_creation_cost ?? 0); - return ( - <> -
- Input Cost: - - {formatCost(rawCost)} - {rawInputTokens !== undefined && rawInputTokens !== null && ( - - ({rawInputTokens.toLocaleString()} tokens) - - )} - -
- {(costBreakdown?.cache_read_cost ?? 0) > 0 && ( -
- Prompt Cache Read Cost: - - {formatCost(isCached ? 0 : costBreakdown?.cache_read_cost)} - {(cacheReadTokens ?? 0) > 0 && ( - - ({(cacheReadTokens ?? 0).toLocaleString()} tokens) - - )} - -
- )} - {(costBreakdown?.cache_creation_cost ?? 0) > 0 && ( -
- Prompt Cache Write Cost: - - {formatCost(isCached ? 0 : costBreakdown?.cache_creation_cost)} - {(cacheCreationTokens ?? 0) > 0 && ( - - ({(cacheCreationTokens ?? 0).toLocaleString()} tokens) - - )} - -
- )} - - ); - } - return ( + + + {open ? ( + + ) : ( + + )} +
+

Cost Breakdown

+
+ Total: + + {formatCost(totalSpend)} + {isCached && " (Cached)"} + +
+
+
+ +
+ {/* Step 1: Base Token Costs */} +
+ {(() => { + const hasCacheBreakdown = + costBreakdown?.cache_read_cost !== undefined || costBreakdown?.cache_creation_cost !== undefined; + if (hasCacheBreakdown) { + // Separate line items: Input / Cache Read / Cache Write + const rawCost = isCached + ? 0 + : (inputCost ?? 0) - + (costBreakdown?.cache_read_cost ?? 0) - + (costBreakdown?.cache_creation_cost ?? 0); + return ( + <>
Input Cost: - {formatCost(inputCost)} - {promptTokens !== undefined && ( + {formatCost(rawCost)} + {rawInputTokens !== undefined && rawInputTokens !== null && ( - ({promptTokens.toLocaleString()} prompt tokens) + ({rawInputTokens.toLocaleString()} tokens) )}
- ); - })()} + {(costBreakdown?.cache_read_cost ?? 0) > 0 && ( +
+ Prompt Cache Read Cost: + + {formatCost(isCached ? 0 : costBreakdown?.cache_read_cost)} + {(cacheReadTokens ?? 0) > 0 && ( + + ({(cacheReadTokens ?? 0).toLocaleString()} tokens) + + )} + +
+ )} + {(costBreakdown?.cache_creation_cost ?? 0) > 0 && ( +
+ Prompt Cache Write Cost: + + {formatCost(isCached ? 0 : costBreakdown?.cache_creation_cost)} + {(cacheCreationTokens ?? 0) > 0 && ( + + ({(cacheCreationTokens ?? 0).toLocaleString()} tokens) + + )} + +
+ )} + + ); + } + return (
- Output Cost: + Input Cost: - {formatCost(outputCost)} - {completionTokens !== undefined && ( + {formatCost(inputCost)} + {promptTokens !== undefined && ( - ({completionTokens.toLocaleString()} completion tokens) + ({promptTokens.toLocaleString()} prompt tokens) )}
- {costBreakdown?.tool_usage_cost !== undefined && costBreakdown.tool_usage_cost > 0 && ( -
- Tool Usage Cost: - {formatCost(costBreakdown.tool_usage_cost)} -
- )} - {costBreakdown?.additional_costs && - Object.entries(costBreakdown.additional_costs) - .filter(([, value]) => value != null && value !== 0) - .map(([key, value]) => ( -
- {key}: - {formatCost(value)} -
- ))} -
- - {/* Subtotal / Original Cost - hide when cached since it would be $0 */} - {!isCached && ( -
-
- Original LLM Cost: - {formatCost(originalCost)} -
-
- )} - - {/* Step 2: Adjustments (Discount & Margin) */} - {(hasDiscount || hasMargin) && ( -
- {/* Discounts */} - {hasDiscount && ( -
- {costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0 && ( -
- - Discount ({formatPercent(costBreakdown.discount_percent)}): - - -{formatCost(costBreakdown.discount_amount)} -
- )} - {costBreakdown.discount_amount !== undefined && - costBreakdown.discount_percent === undefined && ( -
- Discount Amount: - -{formatCost(costBreakdown.discount_amount)} -
- )} -
- )} - - {/* Margins */} - {hasMargin && ( -
- {costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0 && ( -
- - Margin ({formatPercent(costBreakdown.margin_percent)}): - - - + - {formatCost( - (costBreakdown.margin_total_amount || 0) - (costBreakdown.margin_fixed_amount || 0), - )} - -
- )} - {costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0 && ( -
- Margin: - +{formatCost(costBreakdown.margin_fixed_amount)} -
- )} -
- )} -
- )} - - {/* Final Summary */} -
-
- Final Calculated Cost: - - {formatCost(totalCost)} - {isCached && " (Cached)"} + ); + })()} +
+ Output Cost: + + {formatCost(outputCost)} + {completionTokens !== undefined && ( + + ({completionTokens.toLocaleString()} completion tokens) -
+ )} +
+
+ {costBreakdown?.tool_usage_cost !== undefined && costBreakdown.tool_usage_cost > 0 && ( +
+ Tool Usage Cost: + {formatCost(costBreakdown.tool_usage_cost)} +
+ )} + {costBreakdown?.additional_costs && + Object.entries(costBreakdown.additional_costs) + .filter(([, value]) => value != null && value !== 0) + .map(([key, value]) => ( +
+ {key}: + {formatCost(value)} +
+ ))} +
+ + {/* Subtotal / Original Cost - hide when cached since it would be $0 */} + {!isCached && ( +
+
+ Original LLM Cost: + {formatCost(originalCost)}
- ), - }, - ]} - /> + )} + + {/* Step 2: Adjustments (Discount & Margin) */} + {(hasDiscount || hasMargin) && ( +
+ {/* Discounts */} + {hasDiscount && ( +
+ {costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0 && ( +
+ + Discount ({formatPercent(costBreakdown.discount_percent)}): + + -{formatCost(costBreakdown.discount_amount)} +
+ )} + {costBreakdown.discount_amount !== undefined && costBreakdown.discount_percent === undefined && ( +
+ Discount Amount: + -{formatCost(costBreakdown.discount_amount)} +
+ )} +
+ )} + + {/* Margins */} + {hasMargin && ( +
+ {costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0 && ( +
+ + Margin ({formatPercent(costBreakdown.margin_percent)}): + + + + + {formatCost( + (costBreakdown.margin_total_amount || 0) - (costBreakdown.margin_fixed_amount || 0), + )} + +
+ )} + {costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0 && ( +
+ Margin: + +{formatCost(costBreakdown.margin_fixed_amount)} +
+ )} +
+ )} +
+ )} + + {/* Final Summary */} +
+
+ Final Calculated Cost: + + {formatCost(totalCost)} + {isCached && " (Cached)"} + +
+
+
+
+
); }; diff --git a/ui/litellm-dashboard/src/components/view_logs/EvalViewer/EvalViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/EvalViewer/EvalViewer.tsx index 60b27eb0e3a..217efc7ac27 100644 --- a/ui/litellm-dashboard/src/components/view_logs/EvalViewer/EvalViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/EvalViewer/EvalViewer.tsx @@ -1,8 +1,9 @@ import React from "react"; -import { Card, Tag, Table, Typography, Space, Tooltip } from "antd"; -import { CheckCircleOutlined, CloseCircleOutlined, ExperimentOutlined } from "@ant-design/icons"; - -const { Text } = Typography; +import { CircleCheck, CircleX, FlaskConical } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; interface EvalVerdict { criterion_name: string; @@ -36,10 +37,10 @@ export default function EvalViewer({ data }: EvalViewerProps) { return (
- - + + LLM Judge Results - +
{entries.map((entry, idx) => ( @@ -56,151 +57,159 @@ function EvalEntryCard({ entry }: { entry: EvalInformation }) { // Filter out synthetic "Overall" row the judge sometimes appends — it's already in the header const verdicts = (entry.verdicts || []).filter((v) => (v.criterion_name || "").toLowerCase() !== "overall"); - const columns = [ - { - title: "Criterion", - dataIndex: "criterion_name", - key: "criterion_name", - width: 160, - render: (v: string) => ( - - {v} - - ), - }, - { - title: "Weight", - dataIndex: "weight", - key: "weight", - width: 65, - render: (v: number) => - v != null ? ( - - {v}% - - ) : null, - }, - { - title: "Score", - dataIndex: "score", - key: "score", - width: 65, - render: (v: number) => ( - = 70 ? "#52c41a" : v >= 50 ? "#faad14" : "#ff4d4f", fontWeight: 600 }}>{v} - ), - }, - { - title: ( - - Weighted - - ), - key: "weighted", - width: 75, - render: (_: unknown, row: EvalVerdict) => { - if (row.weight == null) return null; - const contrib = (row.score * row.weight) / 100; - return ( - - {contrib % 1 === 0 ? contrib : contrib.toFixed(1)} - - ); - }, - }, - { - title: "Comment", - dataIndex: "reasoning", - key: "reasoning", - ellipsis: { showTitle: false }, - render: (v: string) => ( - - {v} - - ), - }, - ]; + const hasWeights = verdicts.some((v) => v.weight != null); + const weightedTotal = verdicts.reduce((sum, v) => sum + (v.weight != null ? (v.score * v.weight) / 100 : 0), 0); return ( - - {passed ? ( - - ) : ( - - )} - {entry.eval_name} - {passed ? "PASSED" : "FAILED"} - - - {entry.overall_score?.toFixed(0)} / 100 - {entry.threshold != null && ` (threshold: ${entry.threshold})`} - - - - } - extra={ - - {entry.judge_model && ( - - Judge: {entry.judge_model} - - )} - {entry.iteration != null && ( - - Iter: {entry.iteration + 1} - - )} - - } - > - {entry.eval_error && ( - - Judge error: {entry.eval_error} - - )} + + + +
+ {passed ? ( + + ) : ( + + )} + {entry.eval_name} + {passed ? "PASSED" : "FAILED"} + + + + } + > + {entry.overall_score?.toFixed(0)} / 100 + {entry.threshold != null && ` (threshold: ${entry.threshold})`} + + + Weighted average of all criterion scores. Each criterion has a weight (%) set when the eval was + created — higher-weight criteria count more toward the final score. + + + +
+
+ +
+ {entry.judge_model && ( + + Judge: {entry.judge_model} + + )} + {entry.iteration != null && ( + + Iter: {entry.iteration + 1} + + )} +
+
+
- {verdicts.length > 0 ? ( - { - const hasWeights = verdicts.some((v) => v.weight != null); - if (!hasWeights) return null; - const total = verdicts.reduce((sum, v) => sum + (v.weight != null ? (v.score * v.weight) / 100 : 0), 0); - return ( - - - - Total - - - - - - - {total % 1 === 0 ? total : total.toFixed(1)} - - - - - ); - }} - /> - ) : ( - - Score: {entry.overall_score?.toFixed(1)} — no per-criterion breakdown available. - - )} + + {entry.eval_error && ( + + Judge error: {entry.eval_error} + + )} + + {verdicts.length > 0 ? ( +
+ + + Criterion + Weight + Score + + + + }> + Weighted + + + Score × Weight — how much each criterion contributes to the final score + + + + + Comment + + + + {verdicts.map((row) => { + const contrib = row.weight != null ? (row.score * row.weight) / 100 : null; + return ( + + + + {row.criterion_name} + + + + {row.weight != null ? ( + + {row.weight}% + + ) : null} + + + = 70 ? "#52c41a" : row.score >= 50 ? "#faad14" : "#ff4d4f", + fontWeight: 600, + }} + > + {row.score} + + + + {contrib != null ? ( + + {contrib % 1 === 0 ? contrib : contrib.toFixed(1)} + + ) : null} + + + + + }>{row.reasoning} + {row.reasoning} + + + + + ); + })} + + {hasWeights && ( + + + + + Total + + + + + + + {weightedTotal % 1 === 0 ? weightedTotal : weightedTotal.toFixed(1)} + + + + + + )} +
+ ) : ( + + Score: {entry.overall_score?.toFixed(1)} — no per-criterion breakdown available. + + )} +
); } diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/CompliancePanel.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/CompliancePanel.tsx index ebe89f12a7b..1d74927a264 100644 --- a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/CompliancePanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/CompliancePanel.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Tooltip } from "antd"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { checkEuAiActCompliance, checkGdprCompliance, @@ -66,9 +66,12 @@ const ComplianceCard = ({ {loading ? ( ) : error ? ( - - -- - + + + }>-- + {error} + + ) : data?.compliant ? ( ) : ( diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx index 4625f500c6d..32db72a0a25 100644 --- a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo } from "react"; -import { Tooltip } from "antd"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import PresidioDetectedEntities from "./PresidioDetectedEntities"; import BedrockGuardrailDetails, { BedrockGuardrailResponse, @@ -517,13 +517,20 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => { )} {riskScore != null && success && ( - - - Risk {riskScore}/10 - - + + + + } + > + Risk {riskScore}/10 + + {`Risk score: ${riskScore}/10`} + + )}
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx index ee619291f66..e80b447f402 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx @@ -1,6 +1,9 @@ -import { Button, Space, Tag, Tooltip, Typography } from "antd"; -import { CloseOutlined, UpOutlined, DownOutlined } from "@ant-design/icons"; +import { useState } from "react"; +import { Check, ChevronDown, ChevronUp, Copy, X } from "lucide-react"; import moment from "moment"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { LogEntry } from "../columns"; import { AutoRouterTag } from "@/components/shared/table_cells"; import { ClassifyTag } from "./ClassifyTag"; @@ -10,15 +13,11 @@ import { COLOR_BORDER, COLOR_BACKGROUND, SPACING_MEDIUM, - SPACING_LARGE, FONT_SIZE_HEADER, FONT_SIZE_MEDIUM, FONT_FAMILY_MONO, - SPACING_SMALL, } from "./constants"; -const { Text } = Typography; - interface DrawerHeaderProps { log: LogEntry; onClose: () => void; @@ -96,7 +95,7 @@ function ModelProviderSection({ providerName?: string; }) { return ( - +
{providerLogo && ( )} - - +
+ {model} - + {providerName && ( - + {providerName} - + )} - - +
+
); } @@ -128,24 +127,50 @@ function ModelProviderSection({ * Request ID display with copy functionality */ function RequestIdSection({ requestId }: { requestId: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(requestId); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } catch { + /* clipboard unavailable in non-secure contexts */ + } + }; + return (
- - - {requestId} - - + + + + } + > + {requestId} + + + {requestId} + +
); } @@ -172,21 +197,29 @@ function NavigationSection({ marginLeft: 4, background: "#fafafa", }; + const splitStyle = { width: 1, height: 20, background: COLOR_BORDER }; return ( - }> - - - - + ); +} + function ErrorDescription({ errorInfo }: { errorInfo: any }) { return (
{errorInfo.error_code && (
- Error Code: {errorInfo.error_code} + Error Code: {errorInfo.error_code}
)} {errorInfo.error_message && (
- Message: {errorInfo.error_message} + Message: {errorInfo.error_message}
)}
@@ -241,16 +299,16 @@ function ErrorDescription({ errorInfo }: { errorInfo: any }) { function TagsSection({ tags }: { tags: Record }) { return (
- + Tags - - + +
{Object.entries(tags).map(([key, value]) => ( - + {key}: {String(value)} - + ))} - +
); } @@ -262,12 +320,12 @@ function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: nu }; return ( - + {label} - {maskedCount > 0 && {maskedCount} masked} - + {maskedCount > 0 && {maskedCount} masked} + ); } @@ -291,26 +349,24 @@ const PROMPT_CACHE_DOCS_URL = "https://docs.litellm.ai/docs/completion/prompt_ca function MetricLabel({ label, tooltip, docsUrl }: { label: string; tooltip: string; docsUrl: string }) { return ( - + {label} - + + + } + > + + + {tooltip}{" "} - + Docs - - } - > - - - + +
+ + ); } @@ -333,102 +389,111 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata: return (
- - - {showAnthropicMessagesInputOutput ? ( - <> - {formatNumberWithCommas(uncachedInputTokens)} - - {formatNumberWithCommas(logEntry.completion_tokens)} - - - ) : ( - - - - )} - ${formatNumberWithCommas(logEntry.spend || 0, 8)} - - {logEntry.request_duration_ms != null ? (logEntry.request_duration_ms / 1000).toFixed(3) : "-"} s - - {ttftMs != null && ttftMs > 0 && ( - {(ttftMs / 1000).toFixed(3)} s - )} - - {showResponseCache && ( - - } - > - {isResponseCacheHit ? "Hit" : "Miss"} - - )} - {promptCacheReadTokens > 0 && ( - - } - > - {formatNumberWithCommas(promptCacheReadTokens)} - - )} - {promptCacheCreationTokens > 0 && ( - - } - > - {formatNumberWithCommas(promptCacheCreationTokens)} - - )} - - {metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && ( - - {metadata.litellm_overhead_time_ms.toFixed(2)} ms - - )} - - - {metadata?.attempted_retries !== undefined && metadata?.attempted_retries !== null ? ( - metadata.attempted_retries > 0 ? ( - <> - {metadata.attempted_retries} - {metadata.max_retries !== undefined && metadata.max_retries !== null - ? ` / ${metadata.max_retries}` - : ""} - - ) : ( - None - ) + + + Metrics + + + + {showAnthropicMessagesInputOutput ? ( + <> + {formatNumberWithCommas(uncachedInputTokens)} + + {formatNumberWithCommas(logEntry.completion_tokens)} + + ) : ( - "-" + + + + )} + ${formatNumberWithCommas(logEntry.spend || 0, 8)} + + {logEntry.request_duration_ms != null ? (logEntry.request_duration_ms / 1000).toFixed(3) : "-"} s + + {ttftMs != null && ttftMs > 0 && ( + {(ttftMs / 1000).toFixed(3)} s )} - - - {moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")} - - - {moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")} - - + {showResponseCache && ( + + } + > + + {isResponseCacheHit ? "Hit" : "Miss"} + + + )} + {promptCacheReadTokens > 0 && ( + + } + > + {formatNumberWithCommas(promptCacheReadTokens)} + + )} + {promptCacheCreationTokens > 0 && ( + + } + > + {formatNumberWithCommas(promptCacheCreationTokens)} + + )} + + {metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && ( + + {metadata.litellm_overhead_time_ms.toFixed(2)} ms + + )} + + + {metadata?.attempted_retries !== undefined && metadata?.attempted_retries !== null ? ( + metadata.attempted_retries > 0 ? ( + <> + {metadata.attempted_retries} + {metadata.max_retries !== undefined && metadata.max_retries !== null + ? ` / ${metadata.max_retries}` + : ""} + + ) : ( + + None + + ) + ) : ( + "-" + )} + + + + {moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")} + + + {moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")} + + +
); @@ -449,6 +514,7 @@ function RequestResponseSection({ getFormattedResponse, logEntry, }: RequestResponseSectionProps) { + const [open, setOpen] = useState(true); const [activeTab, setActiveTab] = useState(TAB_REQUEST); const [viewMode, setViewMode] = useState<"pretty" | "json">("pretty"); @@ -476,90 +542,76 @@ function RequestResponseSection({ return (
- { - const target = e.target as HTMLElement; - if (target.closest(".ant-radio-group")) { - e.stopPropagation(); - } - }} - > -

- Request & Response -

- setViewMode(e.target.value)}> - Pretty - JSON - -
- ), - children: ( -
- {viewMode === "pretty" ? ( - - ) : ( - setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)} - tabBarExtraContent={ - - } - items={[ - { - key: TAB_REQUEST, - label: "Request", - children: ( -
- -
- ), - }, - { - key: TAB_RESPONSE, - label: "Response", - children: ( -
- {hasResponse || hasError ? ( - - ) : ( -
- Response data not available -
- )} -
- ), - }, - ]} - /> - )} -
- ), - }, - ]} - /> + + setViewMode(value as "pretty" | "json")}> +
+ + {open ? ( + + ) : ( + + )} +

+ Request & Response +

+
+ + Pretty + JSON + +
+ +
+ + + + + setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)} + > +
+ + Request + Response + + +
+ +
+ +
+
+ +
+ {hasResponse || hasError ? ( + + ) : ( +
+ Response data not available +
+ )} +
+
+
+
+
+
+
+
); } @@ -602,43 +654,40 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[ } function MetadataSection({ metadata }: { metadata: Record }) { + const [open, setOpen] = useState(true); + return (
- Metadata, - children: ( -
-
- -
-
-                  {JSON.stringify(metadata, null, 2)}
-                
-
- ), - }, - ]} - /> + + + {open ? ( + + ) : ( + + )} +

Metadata

+
+ +
+
+ JSON.stringify(metadata, null, 2)} label="Copy Metadata" /> +
+
+              {JSON.stringify(metadata, null, 2)}
+            
+
+
+
); } diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx index 5b27cfc1d0f..83049a12fd2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from "react"; -import { Button, Drawer, Segmented } from "antd"; -import { CheckOutlined, CopyOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons"; -import { Bot, Sparkles, Wrench } from "lucide-react"; +import { Bot, Check, ChevronLeft, ChevronRight, Copy, Sparkles, Wrench } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { LogEntry } from "../columns"; import { AutoRouterIcon, useIsAutoRoutedModelGroup } from "@/components/shared/table_cells"; import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "../constants"; @@ -297,181 +298,186 @@ export function LogDetailsDrawer({ if (!currentLog || !enrichedLog) return null; return ( - { + if (!nextOpen) onClose(); }} > -
- {!isSidebarCollapsed ? ( - + + + {logEntry?.request_id ? `Request ${logEntry.request_id} details` : "Request details"} + +
+ {!isSidebarCollapsed ? ( + + ) : ( + + )} + {!isSidebarCollapsed && ( +
+
+
+
+
+ {isSessionMode ? "Session" : "Trace"} +
+
+ {leftPanelDisplayId} + +
-
-
- {logsForList.length} req - {[ - isSessionMode - ? llmCount - : logsForList.filter( - (row) => !MCP_CALL_TYPES.includes(row.call_type) && !AGENT_CALL_TYPES.includes(row.call_type), - ).length, - isSessionMode - ? agentCount - : logsForList.filter((row) => AGENT_CALL_TYPES.includes(row.call_type)).length, - isSessionMode ? mcpCount : logsForList.filter((row) => MCP_CALL_TYPES.includes(row.call_type)).length, - ].map((count, i) => { - const label = [" LLM", " Agent", " MCP"][i]; - return count > 0 ? ( - +
+ {logsForList.length} req + {[ + isSessionMode + ? llmCount + : logsForList.filter( + (row) => !MCP_CALL_TYPES.includes(row.call_type) && !AGENT_CALL_TYPES.includes(row.call_type), + ).length, + isSessionMode + ? agentCount + : logsForList.filter((row) => AGENT_CALL_TYPES.includes(row.call_type)).length, + isSessionMode + ? mcpCount + : logsForList.filter((row) => MCP_CALL_TYPES.includes(row.call_type)).length, + ].map((count, i) => { + const label = [" LLM", " Agent", " MCP"][i]; + return count > 0 ? ( + + · + {count} + {label} + + ) : null; + })} + · + {isSessionMode ? getSpendString(totalSessionCost) : getSpendString(currentLog.spend || 0)} + {isSessionMode && ( + <> · - {count} - {label} - - ) : null; - })} - · - {isSessionMode ? getSpendString(totalSessionCost) : getSpendString(currentLog.spend || 0)} + {sessionDurationSeconds}s + + )} +
+ {isSessionMode && sessionTruncated && ( +
+ Showing most recent {logsForList.length} of {sessionTotalCount} +
+ )} {isSessionMode && ( - <> - · - {sessionDurationSeconds}s - + setSessionSortMode(value as SessionLogSortMode)} + > + + + Duration + + + Start time + + + )}
- {isSessionMode && sessionTruncated && ( -
- Showing most recent {logsForList.length} of {sessionTotalCount} -
- )} - {isSessionMode && ( - setSessionSortMode(value as SessionLogSortMode)} - /> - )} -
-
- {normalizeGuardrailEntries(metadata?.guardrail_information).length > 0 && ( -
- -
- )} - {isSessionMode ? ( -
- {/* Child events — vertical tree line with horizontal connectors */} -
-
- {logsForList.map((row, idx) => { - const isLast = idx === logsForList.length - 1; - return ( -
-
- {isLast &&
} - { - setSelectedSessionRequestId(row.request_id); - onSelectLog?.(row); - }} - /> -
- ); - })} +
+ {normalizeGuardrailEntries(metadata?.guardrail_information).length > 0 && ( +
+
-
- ) : ( -
- {logsForList.map((row) => ( - onSelectLog?.(row)} - /> - ))} -
- )} + )} + {isSessionMode ? ( +
+ {/* Child events — vertical tree line with horizontal connectors */} +
+
+ {logsForList.map((row, idx) => { + const isLast = idx === logsForList.length - 1; + return ( +
+
+ {isLast &&
} + { + setSelectedSessionRequestId(row.request_id); + onSelectLog?.(row); + }} + /> +
+ ); + })} +
+
+ ) : ( +
+ {logsForList.map((row) => ( + onSelectLog?.(row)} + /> + ))} +
+ )} +
-
- )} + )} -
- -
- + +
+ +
-
- + + ); } diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.test.tsx index 4f8f662aa16..201c9e74ce8 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.test.tsx @@ -4,16 +4,6 @@ import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { RealtimePrettyView, isRealtimeResponse } from "./RealtimePrettyView"; -vi.mock("antd", async () => { - const actual = await vi.importActual("antd"); - return { - ...actual, - message: { - success: vi.fn(), - }, - }; -}); - const sampleRealtimeResponse = { usage: { total_tokens: 587, diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.tsx index 5552c6decbe..5441bcbc4cf 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.tsx @@ -5,19 +5,11 @@ */ import { useState } from "react"; -import { Typography, Tag, Tooltip } from "antd"; -import { - SoundOutlined, - MessageOutlined, - SettingOutlined, - AudioOutlined, - DownOutlined, - UpOutlined, -} from "@ant-design/icons"; +import { ChevronDown, ChevronUp, MessageSquare, Mic, Settings, Volume2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { SectionHeader } from "./SectionHeader"; -const { Text } = Typography; - interface RealtimeEvent { type: string; event_id?: string; @@ -163,34 +155,34 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
{isCollapsed ? ( - + ) : ( - + )}
- - Session + + Session
- + {session.model} - + {turnCount > 0 && ( - + {turnCount} {turnCount === 1 ? "turn" : "turns"} - + )} {session.voice && ( - - {session.voice} - + + {session.voice} + )} {session.modalities && (
{session.modalities.map((m) => ( - - {m === "audio" ? : } {m} - + + {m === "audio" ? : } {m} + ))}
)} @@ -228,8 +220,8 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou {session.instructions && (
- Instructions - +
- + {response.status || "unknown"} - + {usage && ( - + {usage.input_tokens ?? 0} in / {usage.output_tokens ?? 0} out tokens - + )} {response.conversation_id && ( - - - conv: {response.conversation_id.slice(0, 12)}... - - + + + } + > + conv: {response.conversation_id.slice(0, 12)}... + + {response.conversation_id} + + )}
@@ -381,8 +378,8 @@ function OutputMessage({ output }: { output: RealtimeOutputItem }) { return (
- {output.role?.toUpperCase() || "ASSISTANT"} - + {contents.map((c, cIdx) => { const text = c.transcript || c.text; if (!text) return null; @@ -407,20 +404,18 @@ function OutputMessage({ output }: { output: RealtimeOutputItem }) { }} > {c.type === "audio" && ( - )} {c.type === "text" && ( - - + {label} Token Breakdown - +
{ if (typeof value === "number") { return ( - + {formatTokenLabel(key)}: {value.toLocaleString()} - + ); } return null; @@ -483,9 +481,9 @@ function ConfigRow({ label, value }: { label: string; value: any }) { if (value === undefined || value === null) return null; return (
- + {label} - +
{String(value)}
); diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/FormattedToolView.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/FormattedToolView.tsx index c58bde72995..b548aa2f368 100644 --- a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/FormattedToolView.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/FormattedToolView.tsx @@ -2,11 +2,9 @@ * Formatted view of tool definition with parameters table and call data */ -import { Typography, Table } from "antd"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { ParsedTool, ParameterRow } from "./types"; -const { Text } = Typography; - interface FormattedToolViewProps { tool: ParsedTool; } @@ -23,57 +21,27 @@ export function FormattedToolView({ tool }: FormattedToolViewProps) { }), ); - const columns = [ - { - title: "Parameter", - dataIndex: "name", - key: "name", - render: (name: string, record: ParameterRow) => ( - - {name} - {record.required && *} - - ), - }, - { - title: "Type", - dataIndex: "type", - key: "type", - render: (type: string) => ( - - {type} - - ), - }, - { - title: "Description", - dataIndex: "description", - key: "description", - render: (desc: string) => {desc}, - }, - ]; - return (
{/* Description */} {tool.description && (
- {tool.description} - +
)} {/* Parameters Table */} {parameterRows.length > 0 && (
- Parameters - - + +
+ + + Parameter + Type + Description + + + + {parameterRows.map((row) => ( + + + + {row.name} + {row.required && *} + + + + {row.type} + + + {row.description} + + + ))} + +
)} {/* If tool was called, show the arguments used */} {tool.called && tool.callData && (
- Called With - +
- - Description - - setViewMode(e.target.value)}> - Formatted - JSON - + Description + setViewMode(value as ViewMode)}> + + Formatted + JSON + +
{viewMode === "formatted" ? : } diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolItem.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolItem.tsx index 78525e1a661..112364f5ff3 100644 --- a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolItem.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolItem.tsx @@ -3,13 +3,11 @@ */ import { useState } from "react"; -import { Typography, Tag } from "antd"; -import { ToolOutlined, RightOutlined, DownOutlined } from "@ant-design/icons"; +import { ChevronDown, ChevronRight, Wrench } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { ParsedTool } from "./types"; import { ToolExpandedContent } from "./ToolExpandedContent"; -const { Text } = Typography; - interface ToolItemProps { tool: ParsedTool; } @@ -39,18 +37,18 @@ export function ToolItem({ tool }: ToolItemProps) { }} >
- - + + {tool.index}. {tool.name} - +
- {tool.called ? "called" : "not called"} + {tool.called ? "called" : "not called"} {expanded ? ( - + ) : ( - + )}
diff --git a/ui/litellm-dashboard/src/components/view_logs/VectorStoreViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/VectorStoreViewer.tsx index bdafc817020..fd46ba34d67 100644 --- a/ui/litellm-dashboard/src/components/view_logs/VectorStoreViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/VectorStoreViewer.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; -import { Collapse } from "antd"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { getProviderLogoAndName } from "../provider_info_helpers"; interface VectorStoreContent { @@ -31,6 +32,7 @@ interface VectorStoreViewerProps { } export function VectorStoreViewer({ data }: VectorStoreViewerProps) { + const [open, setOpen] = useState(true); const [expandedResults, setExpandedResults] = useState>({}); if (!data || data.length === 0) { @@ -57,110 +59,110 @@ export function VectorStoreViewer({ data }: VectorStoreViewerProps) { return (
- Vector Store Requests, - children: ( -
- {data.map((request, index) => ( -
-
-
-
-
- Query: - {request.query} -
-
- Vector Store ID: - {request.vector_store_id} -
-
- Provider: - - {(() => { - const { logo, displayName } = getProviderLogoAndName(request.custom_llm_provider); - return ( - <> - {logo && {`${displayName}} - {displayName} - - ); - })()} + + + {open ? ( + + ) : ( + + )} +

Vector Store Requests

+
+ +
+ {data.map((request, index) => ( +
+
+
+
+
+ Query: + {request.query} +
+
+ Vector Store ID: + {request.vector_store_id} +
+
+ Provider: + + {(() => { + const { logo, displayName } = getProviderLogoAndName(request.custom_llm_provider); + return ( + <> + {logo && {`${displayName}} + {displayName} + + ); + })()} + +
+
+
+
+ Start Time: + {formatTime(request.start_time)} +
+
+ End Time: + {formatTime(request.end_time)} +
+
+ Duration: + {calculateDuration(request.start_time, request.end_time)} +
+
+
+
+ +

Search Results

+
+ {request.vector_store_search_response.data.map((result, resultIndex) => { + const isExpanded = expandedResults[`${index}-${resultIndex}`] || false; + + return ( +
+
toggleResult(index, resultIndex)} + > + + + +
+ Result {resultIndex + 1} + + Score: {result.score.toFixed(4)}
-
-
- Start Time: - {formatTime(request.start_time)} + + {isExpanded && ( +
+ {result.content.map((content, contentIndex) => ( +
+
{content.type}
+
+                                  {content.text}
+                                
+
+ ))}
-
- End Time: - {formatTime(request.end_time)} -
-
- Duration: - {calculateDuration(request.start_time, request.end_time)} -
-
+ )}
-
- -

Search Results

-
- {request.vector_store_search_response.data.map((result, resultIndex) => { - const isExpanded = expandedResults[`${index}-${resultIndex}`] || false; - - return ( -
-
toggleResult(index, resultIndex)} - > - - - -
- Result {resultIndex + 1} - - Score: {result.score.toFixed(4)} - -
-
- - {isExpanded && ( -
- {result.content.map((content, contentIndex) => ( -
-
{content.type}
-
-                                      {content.text}
-                                    
-
- ))} -
- )} -
- ); - })} -
-
- ))} + ); + })} +
- ), - }, - ]} - /> + ))} +
+ +
); }