Merge branch 'litellm_internal_staging' into litellm_shadcn_permissions_0814

This commit is contained in:
Yuneng Jiang 2026-08-14 09:59:12 -07:00
commit e897e2b0aa
No known key found for this signature in database
87 changed files with 4807 additions and 4500 deletions

View file

@ -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 }) => {

View file

@ -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);
});

View file

@ -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

View file

@ -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();

View file

@ -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);

View file

@ -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();

View file

@ -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();

View file

@ -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
}
@ -2377,15 +2296,7 @@
"count": 1
}
},
"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
}
@ -2434,17 +2345,11 @@
}
},
"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
}
@ -2454,14 +2359,6 @@
"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
@ -2470,29 +2367,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
@ -2503,22 +2382,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": {
@ -2527,9 +2393,6 @@
},
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/check_openapi_schema.tsx": {
@ -2554,17 +2417,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": {
@ -2696,9 +2553,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": {
@ -2764,18 +2618,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
}
@ -2837,9 +2685,6 @@
"src/components/navbar.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/networking.tsx": {
@ -2865,9 +2710,6 @@
"src/components/object_permissions_view.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/onboarding_link.tsx": {
@ -3051,15 +2893,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
}
@ -3465,9 +3301,6 @@
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"prefer-const": {
"count": 1
},
@ -3485,29 +3318,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
}
@ -3520,31 +3339,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
}
@ -3554,36 +3359,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

View file

@ -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();
});

View file

@ -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(<CostTrackingSettings {...ADMIN_PROPS} />);
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({});

View file

@ -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 }) => (
<CollapsibleTrigger className={SECTION_HEADER_CLASS}>
<div className="flex flex-col items-start w-full">
<span className="block text-lg font-semibold text-gray-900">{title}</span>
<span className="block text-sm text-gray-500 mt-1">{description}</span>
</div>
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
</CollapsibleTrigger>
);
const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, userRole, accessToken }) => {
const [selectedProvider, setSelectedProvider] = useState<string | undefined>(undefined);
const [newDiscount, setNewDiscount] = useState<string>("");
@ -42,9 +65,9 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
const [percentageValue, setPercentageValue] = useState<string>("");
const [fixedAmountValue, setFixedAmountValue] = useState<string>("");
const [models, setModels] = useState<string[]>([]);
const [pendingRemoval, setPendingRemoval] = useState<PendingRemoval | null>(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<CostTrackingSettingsProps> = ({ userID, use
handleAddProvider();
};
const handleRemoveProvider = async (provider: string, providerDisplayName: string) => {
modal.confirm({
title: "Remove Provider Discount",
icon: <ExclamationCircleOutlined />,
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<CostTrackingSettingsProps> = ({ userID, use
setMarginType("percentage");
};
const handleRemoveMargin = async (provider: string, providerDisplayName: string) => {
modal.confirm({
title: "Remove Provider Margin",
icon: <ExclamationCircleOutlined />,
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<CostTrackingSettingsProps> = ({ userID, use
return (
<div className="w-full p-8">
{contextHolder}
{/* Header Section - Outside the card */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-6">
<div>
<div className="flex items-center gap-2">
<Title>Cost Tracking Settings</Title>
<p className="text-xl font-medium text-gray-900">Cost Tracking Settings</p>
<DocsMenu items={DOCS_LINKS} />
</div>
<Text className="text-gray-500 mt-1">
<p className="text-gray-500 mt-1">
Configure cost discounts and margins for different LLM providers. Changes are saved automatically.
</Text>
</p>
</div>
</div>
@ -178,90 +193,78 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
<div className="bg-white rounded-lg shadow-sm w-full max-w-full space-y-4">
{/* Accordion 1: Provider Discounts - Only for proxy admins */}
{isProxyAdmin && (
<Accordion>
<AccordionHeader className="px-6 py-4">
<div className="flex flex-col items-start w-full">
<Text className="text-lg font-semibold text-gray-900">Provider Discounts</Text>
<Text className="text-sm text-gray-500 mt-1">
Apply percentage-based discounts to reduce costs for specific providers
</Text>
</div>
</AccordionHeader>
<AccordionBody className="px-0">
<TabGroup>
<TabList className="px-6 pt-4">
<Tab>Discounts</Tab>
<Tab>Test It</Tab>
</TabList>
<TabPanels>
<TabPanel>
<div className="p-6">
<div className="flex justify-end mb-4">
<Button onClick={() => setIsModalVisible(true)}>+ Add Provider Discount</Button>
<Collapsible className="rounded-lg border">
<SectionHeader
title="Provider Discounts"
description="Apply percentage-based discounts to reduce costs for specific providers"
/>
<CollapsibleContent className="px-0">
<Tabs defaultValue="discounts">
<TabsList className="mx-6 mt-4">
<TabsTrigger value="discounts">Discounts</TabsTrigger>
<TabsTrigger value="test-it">Test It</TabsTrigger>
</TabsList>
<TabsContent value="discounts">
<div className="p-6">
<div className="flex justify-end mb-4">
<Button onClick={() => setIsModalVisible(true)}>+ Add Provider Discount</Button>
</div>
{isFetching ? (
<div className="py-12 text-center">
<p className="text-gray-500">Loading configuration...</p>
</div>
{isFetching ? (
<div className="py-12 text-center">
<Text className="text-gray-500">Loading configuration...</Text>
</div>
) : Object.keys(discountConfig).length > 0 ? (
<ProviderDiscountTable
discountConfig={discountConfig}
onDiscountChange={handleDiscountChange}
onRemoveProvider={handleRemoveProvider}
/>
) : (
<div className="py-16 px-6 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
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"
/>
</svg>
<Text className="text-gray-700 font-medium mb-2">No provider discounts configured</Text>
<Text className="text-gray-500 text-sm">
Click &quot;Add Provider Discount&quot; to get started
</Text>
</div>
)}
</div>
</TabPanel>
<TabPanel>
<div className="px-6 pb-4">
<HowItWorks />
</div>
</TabPanel>
</TabPanels>
</TabGroup>
</AccordionBody>
</Accordion>
) : Object.keys(discountConfig).length > 0 ? (
<ProviderDiscountTable
discountConfig={discountConfig}
onDiscountChange={handleDiscountChange}
onRemoveProvider={handleRemoveProvider}
/>
) : (
<div className="py-16 px-6 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
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"
/>
</svg>
<p className="text-gray-700 font-medium mb-2">No provider discounts configured</p>
<p className="text-gray-500 text-sm">Click &quot;Add Provider Discount&quot; to get started</p>
</div>
)}
</div>
</TabsContent>
<TabsContent value="test-it">
<div className="px-6 pb-4">
<HowItWorks />
</div>
</TabsContent>
</Tabs>
</CollapsibleContent>
</Collapsible>
)}
{/* Accordion 2: Fee/Price Margin - Only for proxy admins */}
{isProxyAdmin && (
<Accordion>
<AccordionHeader className="px-6 py-4">
<div className="flex flex-col items-start w-full">
<Text className="text-lg font-semibold text-gray-900">Fee/Price Margin</Text>
<Text className="text-sm text-gray-500 mt-1">
Add fees or margins to LLM costs for internal billing and cost recovery
</Text>
</div>
</AccordionHeader>
<AccordionBody className="px-0">
<Collapsible className="rounded-lg border">
<SectionHeader
title="Fee/Price Margin"
description="Add fees or margins to LLM costs for internal billing and cost recovery"
/>
<CollapsibleContent className="px-0">
<div className="p-6">
<div className="flex justify-end mb-4">
<Button onClick={() => setIsMarginModalVisible(true)}>+ Add Provider Margin</Button>
</div>
{isFetching ? (
<div className="py-12 text-center">
<Text className="text-gray-500">Loading configuration...</Text>
<p className="text-gray-500">Loading configuration...</p>
</div>
) : Object.keys(marginConfig).length > 0 ? (
<ProviderMarginTable
@ -284,33 +287,49 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ 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"
/>
</svg>
<Text className="text-gray-700 font-medium mb-2">No provider margins configured</Text>
<Text className="text-gray-500 text-sm">Click &quot;Add Provider Margin&quot; to get started</Text>
<p className="text-gray-700 font-medium mb-2">No provider margins configured</p>
<p className="text-gray-500 text-sm">Click &quot;Add Provider Margin&quot; to get started</p>
</div>
)}
</div>
</AccordionBody>
</Accordion>
</CollapsibleContent>
</Collapsible>
)}
{/* Accordion 3: Pricing Calculator - Available to all roles */}
<Accordion defaultOpen={true}>
<AccordionHeader className="px-6 py-4">
<div className="flex flex-col items-start w-full">
<Text className="text-lg font-semibold text-gray-900">Pricing Calculator</Text>
<Text className="text-sm text-gray-500 mt-1">
Estimate LLM costs based on expected token usage and request volume
</Text>
</div>
</AccordionHeader>
<AccordionBody className="px-0">
<Collapsible defaultOpen={true} className="rounded-lg border">
<SectionHeader
title="Pricing Calculator"
description="Estimate LLM costs based on expected token usage and request volume"
/>
<CollapsibleContent className="px-0">
<div className="p-6">
<PricingCalculator accessToken={accessToken} models={models} />
</div>
</AccordionBody>
</Accordion>
</CollapsibleContent>
</Collapsible>
</div>
{pendingRemoval && (
<AlertDialog open onOpenChange={(open) => !open && setPendingRemoval(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{REMOVAL_COPY[pendingRemoval.kind].title}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove the {REMOVAL_COPY[pendingRemoval.kind].noun} for{" "}
{pendingRemoval.displayName}?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirmRemoval}>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Modal
title={
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
@ -328,10 +347,10 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
}}
>
<div className="mt-6">
<Text className="text-sm text-gray-600 mb-6">
<p className="text-sm text-gray-600 mb-6">
Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5%
discount).
</Text>
</p>
<Form form={form} onFinish={handleFormSubmit} layout="vertical" className="space-y-6">
<AddProviderForm
discountConfig={discountConfig}
@ -362,10 +381,10 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
}}
>
<div className="mt-6">
<Text className="text-sm text-gray-600 mb-6">
<p className="text-sm text-gray-600 mb-6">
Select a provider (or &quot;Global&quot; for all providers) and configure the margin. You can use
percentage-based or fixed amount.
</Text>
</p>
<Form form={marginForm} layout="vertical" className="space-y-6">
<AddMarginForm
marginConfig={marginConfig}

View file

@ -41,6 +41,16 @@ const DEFAULT_PROPS = {
models: ["gpt-4", "gpt-3.5-turbo", "claude-3-sonnet"],
};
const dataRows = (): HTMLElement[] =>
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(<PricingCalculator {...DEFAULT_PROPS} />);
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(<PricingCalculator {...DEFAULT_PROPS} />);
expect(screen.getAllByRole("spinbutton")).toHaveLength(3);
});
it("should offer a model picker per row", () => {
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
expect(screen.getAllByRole("combobox")).toHaveLength(1);
});
it("should remove a row when its delete button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
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);
});
});

View file

@ -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<PricingCalculatorProps> = ({ accessToken, mode
const multiModelResult = getMultiModelResult(entries);
const columns = [
{
title: "Model",
dataIndex: "model",
key: "model",
width: "35%",
render: (_: string, record: ModelEntry) => (
<Select
showSearch
placeholder="Select a model"
value={record.model || undefined}
onChange={(value) => handleEntryChange(record.id, "model", value)}
optionFilterProp="label"
filterOption={(input, option) =>
String(option?.label ?? "")
.toLowerCase()
.includes(input.toLowerCase())
}
options={models.map((model) => ({
value: model,
label: model,
}))}
style={{ width: "100%" }}
size="small"
/>
),
},
{
title: "Input Tokens",
dataIndex: "input_tokens",
key: "input_tokens",
width: "18%",
render: (_: number, record: ModelEntry) => (
<InputNumber
min={0}
value={record.input_tokens}
onChange={(value) => handleEntryChange(record.id, "input_tokens", value ?? 0)}
style={{ width: "100%" }}
size="small"
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
/>
),
},
{
title: "Output Tokens",
dataIndex: "output_tokens",
key: "output_tokens",
width: "18%",
render: (_: number, record: ModelEntry) => (
<InputNumber
min={0}
value={record.output_tokens}
onChange={(value) => handleEntryChange(record.id, "output_tokens", value ?? 0)}
style={{ width: "100%" }}
size="small"
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
/>
),
},
{
title: `Requests/${timePeriod === "day" ? "Day" : "Month"}`,
dataIndex: timePeriod === "day" ? "num_requests_per_day" : "num_requests_per_month",
key: "num_requests",
width: "20%",
render: (_: number | undefined, record: ModelEntry) => (
<InputNumber
min={0}
value={timePeriod === "day" ? record.num_requests_per_day : record.num_requests_per_month}
onChange={(value) =>
handleEntryChange(
record.id,
timePeriod === "day" ? "num_requests_per_day" : "num_requests_per_month",
value ?? undefined,
)
}
style={{ width: "100%" }}
size="small"
placeholder="-"
formatter={(value) => (value ? `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "")}
/>
),
},
{
title: "",
key: "actions",
width: 50,
render: (_: unknown, record: ModelEntry) => (
<Button
type="text"
icon={<DeleteOutlined />}
onClick={() => handleRemoveEntry(record.id)}
disabled={entries.length === 1}
danger
size="small"
/>
),
},
];
const modelOptions = models.map((model) => ({ label: model, value: model }));
const requestsField = timePeriod === "day" ? "num_requests_per_day" : "num_requests_per_month";
return (
<div className="space-y-4">
<div className="flex items-center justify-end mb-2">
<Radio.Group
<RadioGroup
value={timePeriod}
onChange={(e) => handleTimePeriodChange(e.target.value)}
size="small"
optionType="button"
buttonStyle="solid"
onValueChange={(value) => handleTimePeriodChange(value as TimePeriod)}
className="flex w-auto items-center gap-4"
>
<Radio.Button value="day">Per Day</Radio.Button>
<Radio.Button value="month">Per Month</Radio.Button>
</Radio.Group>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<RadioGroupItem value="day" />
Per Day
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<RadioGroupItem value="month" />
Per Month
</label>
</RadioGroup>
</div>
<Table
columns={columns}
dataSource={entries}
rowKey="id"
pagination={false}
size="small"
footer={() => (
<Button type="dashed" onClick={handleAddEntry} icon={<PlusOutlined />} className="w-full">
Add Another Model
</Button>
)}
/>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[35%]">Model</TableHead>
<TableHead className="w-[18%]">Input Tokens</TableHead>
<TableHead className="w-[18%]">Output Tokens</TableHead>
<TableHead className="w-[20%]">Requests/{timePeriod === "day" ? "Day" : "Month"}</TableHead>
<TableHead className="w-[50px]">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((record, index) => (
<TableRow key={record.id}>
<TableCell className="whitespace-normal">
<SearchSelect
options={modelOptions}
value={record.model || undefined}
onValueChange={(value) => handleEntryChange(record.id, "model", value)}
placeholder="Select a model"
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
className="h-8"
value={record.input_tokens}
onChange={(e) =>
handleEntryChange(record.id, "input_tokens", e.target.value === "" ? 0 : Number(e.target.value))
}
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
className="h-8"
value={record.output_tokens}
onChange={(e) =>
handleEntryChange(record.id, "output_tokens", e.target.value === "" ? 0 : Number(e.target.value))
}
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
className="h-8"
placeholder="-"
value={record[requestsField] ?? ""}
onChange={(e) =>
handleEntryChange(
record.id,
requestsField,
e.target.value === "" ? undefined : Number(e.target.value),
)
}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove model row ${index + 1}`}
onClick={() => handleRemoveEntry(record.id)}
disabled={entries.length === 1}
className="text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={5}>
<Button variant="outline" onClick={handleAddEntry} className="w-full border-dashed">
<Plus className="size-3.5" />
Add Another Model
</Button>
</TableCell>
</TableRow>
</TableFooter>
</Table>
<MultiCostResults multiResult={multiModelResult} timePeriod={timePeriod} />
</div>

View file

@ -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(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
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(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
expect(shownBreakdown()).toBeNull();
});
it("should expand the model breakdown row when the expand button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
// 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(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
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(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
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(
<MultiCostResults
multiResult={makeMultiResult({
entries: [
{
entry: { id: "e1", model: "gpt-4", input_tokens: 1000, output_tokens: 500 },
result: makeCostResponse(),
loading: false,
error: null,
},
{
entry: { id: "e2", model: "bad-model", input_tokens: 0, output_tokens: 0 },
result: null,
loading: false,
error: "Pricing not found",
},
],
})}
timePeriod="day"
/>,
);
expect(screen.getAllByRole("button", { name: /cost breakdown for / })).toHaveLength(1);
});
});

View file

@ -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<{
<div className="space-y-3 bg-gray-50 p-4 rounded-lg">
{loading && (
<div className="flex items-center gap-2 text-gray-500 text-sm">
<Spin indicator={<LoadingOutlined spin />} size="small" />
<UiLoadingSpinner className="size-3.5" />
<span>Updating...</span>
</div>
)}
<div className="grid grid-cols-4 gap-4">
<div>
<Text className="text-xs text-gray-500 block">Total/Request</Text>
<Text className="text-base font-semibold text-blue-600">{formatCost(result.cost_per_request)}</Text>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">Total/Request</p>
<p className="text-base font-semibold text-blue-600 break-words">{formatCost(result.cost_per_request)}</p>
</div>
<div>
<Text className="text-xs text-gray-500 block">Input Cost</Text>
<Text className="text-sm">{formatCost(result.input_cost_per_request)}</Text>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">Input Cost</p>
<p className="text-sm break-words">{formatCost(result.input_cost_per_request)}</p>
</div>
<div>
<Text className="text-xs text-gray-500 block">Output Cost</Text>
<Text className="text-sm">{formatCost(result.output_cost_per_request)}</Text>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">Output Cost</p>
<p className="text-sm break-words">{formatCost(result.output_cost_per_request)}</p>
</div>
<div>
<Text className="text-xs text-gray-500 block">Margin Fee</Text>
<Text className={`text-sm ${result.margin_cost_per_request > 0 ? "text-amber-600" : ""}`}>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">Margin Fee</p>
<p className={`text-sm break-words ${result.margin_cost_per_request > 0 ? "text-amber-600" : ""}`}>
{formatCost(result.margin_cost_per_request)}
</Text>
</p>
</div>
</div>
{periodCost !== null && (
<div className="grid grid-cols-4 gap-4 pt-2 border-t border-gray-200">
<div>
<Text className="text-xs text-gray-500 block">
<div className="min-w-0">
<p className="text-xs text-gray-500 block">
{periodLabel} Total ({formatRequests(periodRequests)} req)
</Text>
<Text className={`text-base font-semibold ${timePeriod === "day" ? "text-green-600" : "text-purple-600"}`}>
</p>
<p
className={`text-base font-semibold break-words ${timePeriod === "day" ? "text-green-600" : "text-purple-600"}`}
>
{formatCost(periodCost)}
</Text>
</p>
</div>
<div>
<Text className="text-xs text-gray-500 block">{periodLabel} Input</Text>
<Text className="text-sm">{formatCost(periodInputCost)}</Text>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">{periodLabel} Input</p>
<p className="text-sm break-words">{formatCost(periodInputCost)}</p>
</div>
<div>
<Text className="text-xs text-gray-500 block">{periodLabel} Output</Text>
<Text className="text-sm">{formatCost(periodOutputCost)}</Text>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">{periodLabel} Output</p>
<p className="text-sm break-words">{formatCost(periodOutputCost)}</p>
</div>
<div>
<Text className="text-xs text-gray-500 block">{periodLabel} Margin Fee</Text>
<Text className={`text-sm ${(periodMarginCost ?? 0) > 0 ? "text-amber-600" : ""}`}>
<div className="min-w-0">
<p className="text-xs text-gray-500 block">{periodLabel} Margin Fee</p>
<p className={`text-sm break-words ${(periodMarginCost ?? 0) > 0 ? "text-amber-600" : ""}`}>
{formatCost(periodMarginCost)}
</Text>
</p>
</div>
</div>
)}
@ -124,7 +130,7 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
if (!hasAnyResult && !isAnyLoading && !hasAnyError) {
return (
<div className="py-6 text-center border border-dashed border-gray-300 rounded-lg bg-gray-50">
<Text className="text-gray-500">Select models above to see cost estimates</Text>
<p className="text-gray-500">Select models above to see cost estimates</p>
</div>
);
}
@ -133,8 +139,8 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
if (!hasAnyResult && isAnyLoading && !hasAnyError) {
return (
<div className="py-6 text-center">
<Spin indicator={<LoadingOutlined spin />} />
<Text className="text-gray-500 block mt-2">Calculating costs...</Text>
<UiLoadingSpinner className="inline-block size-5" />
<p className="text-gray-500 block mt-2">Calculating costs...</p>
</div>
);
}
@ -143,10 +149,10 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
if (!hasAnyResult && hasAnyError) {
return (
<div className="space-y-4">
<Divider className="my-4" />
<Separator className="my-4" />
<div className="flex items-center justify-between">
<Text className="text-base font-semibold text-gray-900">Cost Estimates</Text>
{isAnyLoading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
<p className="text-base font-semibold text-gray-900">Cost Estimates</p>
{isAnyLoading && <UiLoadingSpinner className="size-3.5" />}
</div>
{/* Error Messages */}
{errorEntries.map((e) => (
@ -174,102 +180,10 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ 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;
},
) => (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{text}</span>
{record.provider && (
<Tag color="blue" className="text-xs">
{record.provider}
</Tag>
)}
{record.loading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
</div>
{record.error && <div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded-sm"> {record.error}</div>}
{record.hasZeroCost && !record.error && (
<div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded-sm">
No pricing data found for this model. Set base_model in config.
</div>
)}
</div>
),
},
{
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 ? (
<span className="text-gray-400">-</span>
) : (
<span className="font-mono text-sm">{formatCost(value)}</span>
),
},
{
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 ? (
<span className="text-gray-400">-</span>
) : (
<span className={`font-mono text-sm ${(value ?? 0) > 0 ? "text-amber-600" : "text-gray-400"}`}>
{formatCost(value)}
</span>
),
},
{
title: periodLabel,
dataIndex: periodCostKey,
key: "period_cost",
align: "right" as const,
render: (value: number | null, record: { error?: string | null }) =>
record.error ? (
<span className="text-gray-400">-</span>
) : (
<span className="font-mono text-sm">{formatCost(value)}</span>
),
},
{
title: "",
key: "expand",
width: 40,
render: (_: unknown, record: { id: string; error?: string | null }) =>
record.error ? null : (
<Button
size="xs"
variant="light"
onClick={() => toggleExpanded(record.id)}
className="text-gray-400 hover:text-gray-600"
>
{expandedModels.has(record.id) ? <DownOutlined /> : <RightOutlined />}
</Button>
),
},
];
// 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<MultiCostResultsProps> = ({ multiResult, timePe
return (
<div className="space-y-4">
<Divider className="my-4" />
<Separator className="my-4" />
<div className="flex items-center justify-between">
<Text className="text-base font-semibold text-gray-900">Cost Estimates</Text>
<p className="text-base font-semibold text-gray-900">Cost Estimates</p>
<div className="flex items-center gap-2">
{isAnyLoading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
{isAnyLoading && <UiLoadingSpinner className="size-3.5" />}
<MultiExportDropdown multiResult={multiResult} />
</div>
</div>
{/* Combined Totals - Always show when there are results */}
<Card size="small" className="bg-linear-to-r from-slate-50 to-blue-50 border-slate-200">
<Row gutter={[16, 8]}>
<Col xs={24} sm={12}>
<Statistic
title={<span className="text-xs">Total Per Request</span>}
value={formatCost(multiResult.totals.cost_per_request)}
valueStyle={{ color: "#1890ff", fontSize: "18px", fontFamily: "monospace" }}
/>
</Col>
<Col xs={24} sm={12}>
<Statistic
title={<span className="text-xs">Total {periodLabel}</span>}
value={formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)}
valueStyle={{
color: timePeriod === "day" ? "#52c41a" : "#722ed1",
fontSize: "18px",
fontFamily: "monospace",
}}
/>
</Col>
</Row>
<Card size="sm" className="px-4 bg-linear-to-r from-slate-50 to-blue-50">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2">
<div className="min-w-0">
<span className="text-xs text-gray-500">Total Per Request</span>
<div className="text-lg font-mono text-blue-600 break-words">
{formatCost(multiResult.totals.cost_per_request)}
</div>
</div>
<div className="min-w-0">
<span className="text-xs text-gray-500">Total {periodLabel}</span>
<div
className={`text-lg font-mono break-words ${timePeriod === "day" ? "text-green-600" : "text-purple-600"}`}
>
{formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)}
</div>
</div>
</div>
{hasMargin && (
<Row gutter={[16, 8]} className="mt-3 pt-3 border-t border-slate-200">
<Col xs={24} sm={12}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 mt-3 pt-3 border-t border-slate-200">
<div className="min-w-0">
<div className="text-xs text-gray-500">Margin Fee/Request</div>
<div className="text-sm font-mono text-amber-600">
<div className="text-sm font-mono text-amber-600 break-words">
{formatCost(multiResult.totals.margin_per_request)}
</div>
</Col>
<Col xs={24} sm={12}>
</div>
<div className="min-w-0">
<div className="text-xs text-gray-500">{periodLabel} Margin Fee</div>
<div className="text-sm font-mono text-amber-600">
<div className="text-sm font-mono text-amber-600 break-words">
{formatCost(timePeriod === "day" ? multiResult.totals.daily_margin : multiResult.totals.monthly_margin)}
</div>
</Col>
</Row>
</div>
</div>
)}
</Card>
{/* Per-Model Table */}
{summaryData.length > 0 && (
<Table
columns={summaryColumns}
dataSource={summaryData}
pagination={false}
size="small"
className="border border-gray-200 rounded-lg"
expandable={{
expandedRowKeys: Array.from(expandedModels),
expandedRowRender: (record) => {
const entry = validEntries.find((e) => e.entry.id === record.id);
if (!entry?.result) return null;
<Table className="border border-gray-200 rounded-lg">
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className="text-right">Per Request</TableHead>
<TableHead className="text-right">Margin Fee</TableHead>
<TableHead className="text-right">{periodLabel}</TableHead>
<TableHead className="w-10">
<span className="sr-only">Cost breakdown</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<div className="py-2">
<SingleModelBreakdown result={entry.result} loading={entry.loading} timePeriod={timePeriod} />
</div>
<React.Fragment key={record.id}>
<TableRow>
<TableCell className="whitespace-normal">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm break-words">{record.model}</span>
{record.provider && (
<Badge variant="secondary" className="text-xs">
{record.provider}
</Badge>
)}
{record.loading && <UiLoadingSpinner className="size-3.5" />}
</div>
{record.error && (
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded-sm"> {record.error}</div>
)}
{record.hasZeroCost && !record.error && (
<div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded-sm">
No pricing data found for this model. Set base_model in config.
</div>
)}
</div>
</TableCell>
<TableCell className="text-right">
{record.error ? (
<span className="text-gray-400">-</span>
) : (
<span className="font-mono text-sm">{formatCost(record.cost_per_request)}</span>
)}
</TableCell>
<TableCell className="text-right">
{record.error ? (
<span className="text-gray-400">-</span>
) : (
<span
className={`font-mono text-sm ${(record.margin_cost_per_request ?? 0) > 0 ? "text-amber-600" : "text-gray-400"}`}
>
{formatCost(record.margin_cost_per_request)}
</span>
)}
</TableCell>
<TableCell className="text-right">
{record.error ? (
<span className="text-gray-400">-</span>
) : (
<span className="font-mono text-sm">{formatCost(periodCost)}</span>
)}
</TableCell>
<TableCell className="text-right">
{!record.error && (
<Button
variant="ghost"
size="icon-xs"
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Hide" : "Show"} cost breakdown for ${record.model}`}
onClick={() => toggleExpanded(record.id)}
className="text-gray-400 hover:text-gray-600"
>
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</Button>
)}
</TableCell>
</TableRow>
{isExpanded && breakdownEntry?.result && (
<TableRow>
<TableCell colSpan={5} className="whitespace-normal">
<div className="py-2">
<SingleModelBreakdown
result={breakdownEntry.result}
loading={breakdownEntry.loading}
timePeriod={timePeriod}
/>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
},
showExpandColumn: false,
}}
/>
})}
</TableBody>
</Table>
)}
</div>
);

View file

@ -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) => <table>{children}</table>,
TableHead: ({ children }: any) => <thead>{children}</thead>,
TableRow: ({ children }: any) => <tr>{children}</tr>,
TableHeaderCell: ({ children }: any) => <th>{children}</th>,
TableBody: ({ children }: any) => <tbody>{children}</tbody>,
TableCell: ({ children }: any) => <td>{children}</td>,
Text: ({ children }: any) => <span>{children}</span>,
TextInput: ({ value, onValueChange, onKeyDown, placeholder, ...rest }: any) => (
<input
value={value}
onChange={(e) => onValueChange?.(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
{...rest}
/>
),
Icon: ({ icon: IconComponent, onClick }: any) => {
const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon";
return <button onClick={onClick} aria-label={name} />;
},
}));
const DEFAULT_DISCOUNT_CONFIG = {
openai: 0.05,
anthropic: 0.1,
};
const ROW_ACTION_NAME = {
edit: /^Edit discount for /,
save: /^Save discount for /,
cancel: /^Cancel editing discount for /,
remove: /^Remove discount for /,
} as const;
const rowAction = (action: keyof typeof ROW_ACTION_NAME): HTMLElement =>
screen.getByRole("button", { name: ROW_ACTION_NAME[action] });
describe("ProviderDiscountTable", () => {
const onDiscountChange = vi.fn();
const onRemoveProvider = vi.fn();
@ -75,9 +47,9 @@ describe("ProviderDiscountTable", () => {
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByText("Provider")).toBeInTheDocument();
expect(screen.getByText("Discount Percentage")).toBeInTheDocument();
expect(screen.getByText("Actions")).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Provider" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Discount Percentage" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Actions" })).toBeInTheDocument();
});
it("should display provider display names in the table", () => {
@ -91,6 +63,21 @@ describe("ProviderDiscountTable", () => {
expect(screen.getByText("OpenAI")).toBeInTheDocument();
});
it("should sort rows by provider display name", () => {
renderWithProviders(
<ProviderDiscountTable
discountConfig={DEFAULT_DISCOUNT_CONFIG}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
const rows = screen.getAllByRole("row").slice(1);
expect(rows.map((row) => row.textContent)).toEqual([
expect.stringContaining("Anthropic"),
expect.stringContaining("OpenAI"),
]);
});
it("should display the formatted discount percentage", () => {
renderWithProviders(
<ProviderDiscountTable
@ -102,6 +89,17 @@ describe("ProviderDiscountTable", () => {
expect(screen.getByText("5.0%")).toBeInTheDocument();
});
it("should render the provider logo alongside the display name", () => {
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByRole("img", { name: "OpenAI logo" })).toBeInTheDocument();
});
it("should show a text input when the edit icon is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
@ -112,8 +110,7 @@ describe("ProviderDiscountTable", () => {
/>,
);
const pencilButton = screen.getByRole("button", { name: /PencilAltIcon/i });
await user.click(pencilButton);
await user.click(rowAction("edit"));
expect(screen.getByPlaceholderText("5")).toBeInTheDocument();
});
@ -128,11 +125,26 @@ describe("ProviderDiscountTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(rowAction("edit"));
expect(screen.queryByText("5.0%")).not.toBeInTheDocument();
});
it("should seed the edit input with the current discount as a percentage", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
await user.click(rowAction("edit"));
expect(screen.getByPlaceholderText("5")).toHaveValue("5");
});
it("should call onDiscountChange with the new value when the save icon is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
@ -143,17 +155,57 @@ describe("ProviderDiscountTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(rowAction("edit"));
const input = screen.getByPlaceholderText("5");
await user.clear(input);
await user.type(input, "10");
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
await user.click(rowAction("save"));
expect(onDiscountChange).toHaveBeenCalledWith("openai", "0.1");
});
it("should save the edited discount when Enter is pressed", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
await user.click(rowAction("edit"));
const input = screen.getByPlaceholderText("5");
await user.clear(input);
await user.type(input, "10{Enter}");
expect(onDiscountChange).toHaveBeenCalledWith("openai", "0.1");
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
});
it("should abandon the edit when Escape is pressed", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
await user.click(rowAction("edit"));
const input = screen.getByPlaceholderText("5");
await user.clear(input);
await user.type(input, "10{Escape}");
expect(onDiscountChange).not.toHaveBeenCalled();
expect(screen.getByText("5.0%")).toBeInTheDocument();
});
it("should restore the display view after saving", async () => {
const user = userEvent.setup();
renderWithProviders(
@ -164,8 +216,8 @@ describe("ProviderDiscountTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
await user.click(rowAction("edit"));
await user.click(rowAction("save"));
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
});
@ -180,30 +232,14 @@ describe("ProviderDiscountTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(screen.getByRole("button", { name: /XIcon/i }));
await user.click(rowAction("edit"));
await user.click(rowAction("cancel"));
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
expect(onDiscountChange).not.toHaveBeenCalled();
expect(screen.getByText("5.0%")).toBeInTheDocument();
});
it("should not call onDiscountChange when canceling edit", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(screen.getByRole("button", { name: /XIcon/i }));
expect(onDiscountChange).not.toHaveBeenCalled();
});
it("should call onRemoveProvider with the provider key and display name when the trash icon is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
@ -214,7 +250,7 @@ describe("ProviderDiscountTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
await user.click(rowAction("remove"));
expect(onRemoveProvider).toHaveBeenCalledWith("openai", "OpenAI");
});
@ -229,12 +265,42 @@ describe("ProviderDiscountTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(rowAction("edit"));
const input = screen.getByPlaceholderText("5");
await user.clear(input);
await user.type(input, "150");
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
await user.click(rowAction("save"));
expect(onDiscountChange).not.toHaveBeenCalled();
});
it("should expose each row action as a button named for its provider", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByRole("button", { name: "Edit discount for OpenAI" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove discount for OpenAI" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Edit discount for OpenAI" }));
expect(screen.getByRole("button", { name: "Save discount for OpenAI" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel editing discount for OpenAI" })).toBeInTheDocument();
});
it("should render the empty message when no discounts are configured", () => {
renderWithProviders(
<ProviderDiscountTable
discountConfig={{}}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByText("No provider discounts configured")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,7 @@
import React, { useState } from "react";
import { TextInput, Icon, Text } from "@tremor/react";
import { TrashIcon, PencilAltIcon, CheckIcon, XIcon } from "@heroicons/react/outline";
import { Check, SquarePen, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SimpleTable } from "@/components/common_components/simple_table";
import { DiscountConfig } from "./types";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
@ -79,45 +80,57 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
},
{
header: "Discount Percentage",
cell: (row) => (
<div className="flex items-center gap-2">
{editingProvider === row.provider ? (
<>
<TextInput
value={editValue}
onValueChange={setEditValue}
onKeyDown={(e) => handleKeyDown(e, row.provider)}
placeholder="5"
className="w-20"
autoFocus
/>
<span className="text-gray-600">%</span>
<Icon
icon={CheckIcon}
size="sm"
onClick={() => handleSaveEdit(row.provider)}
className="cursor-pointer text-green-600 hover:text-green-700"
/>
<Icon
icon={XIcon}
size="sm"
onClick={handleCancelEdit}
className="cursor-pointer text-gray-600 hover:text-gray-700"
/>
</>
) : (
<>
<Text className="font-medium">{(row.discount * 100).toFixed(1)}%</Text>
<Icon
icon={PencilAltIcon}
size="sm"
onClick={() => handleStartEdit(row.provider, row.discount)}
className="cursor-pointer text-blue-600 hover:text-blue-700"
/>
</>
)}
</div>
),
cell: (row) => {
const { displayName } = getProviderLogoAndName(row.provider);
return (
<div className="flex items-center gap-2">
{editingProvider === row.provider ? (
<>
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => handleKeyDown(e, row.provider)}
placeholder="5"
className="w-20"
autoFocus
/>
<span className="text-gray-600">%</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Save discount for ${displayName}`}
onClick={() => handleSaveEdit(row.provider)}
className="cursor-pointer text-green-600 hover:text-green-700"
>
<Check className="size-5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Cancel editing discount for ${displayName}`}
onClick={handleCancelEdit}
className="cursor-pointer text-gray-600 hover:text-gray-700"
>
<X className="size-5" />
</Button>
</>
) : (
<>
<p className="font-medium">{(row.discount * 100).toFixed(1)}%</p>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit discount for ${displayName}`}
onClick={() => handleStartEdit(row.provider, row.discount)}
className="cursor-pointer text-blue-600 hover:text-blue-700"
>
<SquarePen className="size-5" />
</Button>
</>
)}
</div>
);
},
width: "250px",
},
{
@ -125,12 +138,15 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
cell: (row) => {
const { displayName } = getProviderLogoAndName(row.provider);
return (
<Icon
icon={TrashIcon}
size="sm"
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove discount for ${displayName}`}
onClick={() => onRemoveProvider(row.provider, displayName)}
className="cursor-pointer hover:text-red-600"
/>
>
<Trash2 className="size-5" />
</Button>
);
},
width: "80px",

View file

@ -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) => <table>{children}</table>,
TableHead: ({ children }: any) => <thead>{children}</thead>,
TableRow: ({ children }: any) => <tr>{children}</tr>,
TableHeaderCell: ({ children }: any) => <th>{children}</th>,
TableBody: ({ children }: any) => <tbody>{children}</tbody>,
TableCell: ({ children }: any) => <td>{children}</td>,
Text: ({ children }: any) => <span>{children}</span>,
TextInput: ({ value, onValueChange, placeholder, autoFocus, className }: any) => (
<input
value={value}
onChange={(e) => onValueChange?.(e.target.value)}
placeholder={placeholder}
autoFocus={autoFocus}
className={className}
/>
),
Icon: ({ icon: IconComponent, onClick }: any) => {
const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon";
return <button onClick={onClick} aria-label={name} />;
},
}));
const rowAction = (action: keyof typeof ROW_ACTION_NAME): HTMLElement =>
screen.getByRole("button", { name: ROW_ACTION_NAME[action] });
describe("ProviderMarginTable", () => {
const onMarginChange = vi.fn();
@ -71,9 +43,9 @@ describe("ProviderMarginTable", () => {
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByText("Provider")).toBeInTheDocument();
expect(screen.getByText("Margin")).toBeInTheDocument();
expect(screen.getByText("Actions")).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Provider" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Margin" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Actions" })).toBeInTheDocument();
});
it("should display the provider display name", () => {
@ -122,6 +94,21 @@ describe("ProviderMarginTable", () => {
expect(screen.getByText("Global (All Providers)")).toBeInTheDocument();
});
it("should sort the global row above provider rows", () => {
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1, global: 0.05 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>,
);
const rows = screen.getAllByRole("row").slice(1);
expect(rows.map((row) => row.textContent)).toEqual([
expect.stringContaining("Global (All Providers)"),
expect.stringContaining("OpenAI"),
]);
});
it("should display a numeric margin as a percentage", () => {
renderWithProviders(
<ProviderMarginTable
@ -165,12 +152,28 @@ describe("ProviderMarginTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(rowAction("edit"));
expect(screen.getByPlaceholderText("10")).toBeInTheDocument();
expect(screen.getByPlaceholderText("0.001")).toBeInTheDocument();
});
it("should seed the percentage input from a numeric margin and leave the fixed amount blank", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>,
);
await user.click(rowAction("edit"));
expect(screen.getByPlaceholderText("10")).toHaveValue("10");
expect(screen.getByPlaceholderText("0.001")).toHaveValue("");
});
it("should call onMarginChange with a percentage value when save is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
@ -181,17 +184,37 @@ describe("ProviderMarginTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(rowAction("edit"));
const percentInput = screen.getByPlaceholderText("10");
await user.clear(percentInput);
await user.type(percentInput, "20");
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
await user.click(rowAction("save"));
expect(onMarginChange).toHaveBeenCalledWith("openai", 0.2);
});
it("should call onMarginChange with a fixed-amount-only object when the percentage is cleared", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>,
);
await user.click(rowAction("edit"));
await user.clear(screen.getByPlaceholderText("10"));
await user.type(screen.getByPlaceholderText("0.001"), "0.002");
await user.click(rowAction("save"));
expect(onMarginChange).toHaveBeenCalledWith("openai", { fixed_amount: 0.002 });
});
it("should cancel edit mode without calling onMarginChange when X is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
@ -202,8 +225,8 @@ describe("ProviderMarginTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(screen.getByRole("button", { name: /XIcon/i }));
await user.click(rowAction("edit"));
await user.click(rowAction("cancel"));
expect(onMarginChange).not.toHaveBeenCalled();
expect(screen.queryByPlaceholderText("10")).not.toBeInTheDocument();
@ -219,7 +242,7 @@ describe("ProviderMarginTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
await user.click(rowAction("remove"));
expect(onRemoveProvider).toHaveBeenCalledWith("openai", "OpenAI");
});
@ -234,11 +257,50 @@ describe("ProviderMarginTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
await user.click(rowAction("remove"));
expect(onRemoveProvider).toHaveBeenCalledWith("global", "Global");
});
it("should expose each row action as a button named for its provider", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByRole("button", { name: "Edit margin for OpenAI" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove margin for OpenAI" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Edit margin for OpenAI" }));
expect(screen.getByRole("button", { name: "Save margin for OpenAI" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel editing margin for OpenAI" })).toBeInTheDocument();
});
it("should name the global row's actions after the global provider", () => {
renderWithProviders(
<ProviderMarginTable
marginConfig={{ global: 0.05 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>,
);
expect(screen.getByRole("button", { name: "Edit margin for Global" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove margin for Global" })).toBeInTheDocument();
});
it("should render the empty message when no margins are configured", () => {
renderWithProviders(
<ProviderMarginTable marginConfig={{}} onMarginChange={onMarginChange} onRemoveProvider={onRemoveProvider} />,
);
expect(screen.getByText("No provider margins configured")).toBeInTheDocument();
});
describe("when both percentage and fixed amount are entered", () => {
it("should call onMarginChange with an object containing both values", async () => {
const user = userEvent.setup();
@ -250,7 +312,7 @@ describe("ProviderMarginTable", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(rowAction("edit"));
const percentInput = screen.getByPlaceholderText("10");
await user.clear(percentInput);
@ -259,7 +321,7 @@ describe("ProviderMarginTable", () => {
const fixedInput = screen.getByPlaceholderText("0.001");
await user.type(fixedInput, "0.002");
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
await user.click(rowAction("save"));
expect(onMarginChange).toHaveBeenCalledWith("openai", {
percentage: 0.05,

View file

@ -1,6 +1,7 @@
import React, { useState } from "react";
import { TextInput, Icon, Text } from "@tremor/react";
import { TrashIcon, PencilAltIcon, CheckIcon, XIcon } from "@heroicons/react/outline";
import { Check, SquarePen, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SimpleTable } from "@/components/common_components/simple_table";
import { MarginConfig } from "./types";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
@ -17,6 +18,9 @@ interface ProviderMarginRow {
margin: number | { percentage?: number; fixed_amount?: number };
}
const marginRowDisplayName = (provider: string): string =>
provider === "global" ? "Global" : getProviderLogoAndName(provider).displayName;
const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
marginConfig,
onMarginChange,
@ -119,67 +123,82 @@ const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
},
{
header: "Margin",
cell: (row) => (
<div className="flex items-center gap-2">
{editingProvider === row.provider ? (
<>
<div className="flex items-center gap-2">
<TextInput
value={editPercentage}
onValueChange={setEditPercentage}
placeholder="10"
className="w-20"
autoFocus
/>
<span className="text-gray-600">%</span>
<span className="text-gray-400">+</span>
<span className="text-gray-600">$</span>
<TextInput
value={editFixedAmount}
onValueChange={setEditFixedAmount}
placeholder="0.001"
className="w-24"
/>
</div>
<Icon
icon={CheckIcon}
size="sm"
onClick={() => handleSaveEdit(row.provider)}
className="cursor-pointer text-green-600 hover:text-green-700"
/>
<Icon
icon={XIcon}
size="sm"
onClick={handleCancelEdit}
className="cursor-pointer text-gray-600 hover:text-gray-700"
/>
</>
) : (
<>
<Text className="font-medium">{formatMargin(row.margin)}</Text>
<Icon
icon={PencilAltIcon}
size="sm"
onClick={() => handleStartEdit(row.provider, row.margin)}
className="cursor-pointer text-blue-600 hover:text-blue-700"
/>
</>
)}
</div>
),
cell: (row) => {
const displayName = marginRowDisplayName(row.provider);
return (
<div className="flex items-center gap-2">
{editingProvider === row.provider ? (
<>
<div className="flex items-center gap-2">
<Input
value={editPercentage}
onChange={(e) => setEditPercentage(e.target.value)}
placeholder="10"
className="w-20"
autoFocus
/>
<span className="text-gray-600">%</span>
<span className="text-gray-400">+</span>
<span className="text-gray-600">$</span>
<Input
value={editFixedAmount}
onChange={(e) => setEditFixedAmount(e.target.value)}
placeholder="0.001"
className="w-24"
/>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Save margin for ${displayName}`}
onClick={() => handleSaveEdit(row.provider)}
className="cursor-pointer text-green-600 hover:text-green-700"
>
<Check className="size-5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Cancel editing margin for ${displayName}`}
onClick={handleCancelEdit}
className="cursor-pointer text-gray-600 hover:text-gray-700"
>
<X className="size-5" />
</Button>
</>
) : (
<>
<p className="font-medium">{formatMargin(row.margin)}</p>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit margin for ${displayName}`}
onClick={() => handleStartEdit(row.provider, row.margin)}
className="cursor-pointer text-blue-600 hover:text-blue-700"
>
<SquarePen className="size-5" />
</Button>
</>
)}
</div>
);
},
width: "350px",
},
{
header: "Actions",
cell: (row) => {
const displayName = row.provider === "global" ? "Global" : getProviderLogoAndName(row.provider).displayName;
const displayName = marginRowDisplayName(row.provider);
return (
<Icon
icon={TrashIcon}
size="sm"
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove margin for ${displayName}`}
onClick={() => onRemoveProvider(row.provider, displayName)}
className="cursor-pointer hover:text-red-600"
/>
>
<Trash2 className="size-5" />
</Button>
);
},
width: "80px",

View file

@ -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);

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,14 @@
import React, { useMemo, useState } from "react";
import { SearchOutlined } from "@ant-design/icons";
import { SortingState } from "@tanstack/react-table";
import { Input, Select } from "antd";
import { Inbox } from "lucide-react";
import { Inbox, Search, X } from "lucide-react";
import { Plugin } from "@/components/claude_code_plugins/types";
import { DataTable } from "@/components/shared/DataTable";
import { getSkillHubTableColumns } from "@/components/AIHub/SkillHubTableColumns";
import SkillDetail from "@/components/claude_code_plugins/skill_detail";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const ALL_DOMAINS = "__all_domains__";
interface SkillHubDashboardProps {
skills: Plugin[];
@ -48,7 +50,10 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
// 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<SkillHubDashboardProps> = ({
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<SkillHubDashboardProps> = ({
<h3 className="text-sm font-semibold text-gray-700">All {publicPage ? "Public " : ""}Skills</h3>
<div className="flex items-center gap-2">
<Select
placeholder="All Domains"
allowClear
value={domainFilter}
onChange={(val) => setDomainFilter(val)}
style={{ width: 160 }}
options={domains.map((d) => ({ label: d, value: d }))}
/>
<Input
prefix={<SearchOutlined className="text-gray-400" />}
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)}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{domainItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<InputGroup className="w-[280px]">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search by name, namespace, or tag…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search !== "" && (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
variant="ghost"
aria-label="Clear search"
onClick={() => setSearch("")}
>
<X className="size-3.5" />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
</div>
</div>
<DataTable

View file

@ -2,7 +2,8 @@ import TableIconActionButton from "@/components/common_components/IconActionButt
import NotificationsManager from "@/components/molecules/notifications_manager";
import { isAdminRole } from "@/utils/roles";
import { ChevronDownIcon, ChevronRightIcon, ExternalLinkIcon, PlusCircleIcon } from "@heroicons/react/outline";
import { Card, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text, Title } from "@tremor/react";
import { Card } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import Link from "next/link";
import React, { useEffect, useState } from "react";
import { getProxyBaseUrl, getPublicModelHubInfo, updateUsefulLinksCall } from "../networking";
@ -223,10 +224,10 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
};
return (
<Card className="mb-6">
<Card className="mb-6 px-6">
<div className="flex items-center justify-between cursor-pointer" onClick={() => setIsExpanded(!isExpanded)}>
<div className="flex flex-col">
<Title className="mb-0">Link Management</Title>
<h3 className="mb-0 text-lg font-semibold">Link Management</h3>
<p className="text-sm text-gray-500">
Manage the links that are displayed under &apos;Useful Links&apos; on the public model hub.
</p>
@ -243,7 +244,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
{isExpanded && (
<div className="mt-4">
<div className="mb-6">
<Text className="text-sm font-medium text-gray-700 mb-2">Add New Link</Text>
<p className="text-sm font-medium text-gray-700 mb-2">Add New Link</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Display Name</label>
@ -288,7 +289,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
</div>
</div>
<div className="flex items-center justify-between mb-2">
<Text className="text-sm font-medium text-gray-700">Manage Existing Links</Text>
<p className="text-sm font-medium text-gray-700">Manage Existing Links</p>
<div className="flex items-center space-x-2">
<Link
href={`${getProxyBaseUrl()}/ui/model_hub_table`}
@ -328,13 +329,13 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
<TableHeader>
<TableRow>
<TableHeaderCell className="py-1 h-8">Display Name</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">URL</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">Actions</TableHeaderCell>
<TableHead className="py-1 h-8">Display Name</TableHead>
<TableHead className="py-1 h-8">URL</TableHead>
<TableHead className="py-1 h-8">Actions</TableHead>
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{links.map((link, index) => (
<TableRow key={link.id} className="h-8">

View file

@ -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 ? (
<div data-testid="modal">
<div>{title}</div>
{children}
{footer}
</div>
) : null,
Form: Object.assign(({ children, form }: any) => <form data-testid="form">{children}</form>, {
useForm: () => [
{
resetFields: vi.fn(),
validateFields: vi.fn(),
getFieldsValue: vi.fn(),
setFieldsValue: vi.fn(),
},
vi.fn(),
],
Item: ({ children }: any) => <div>{children}</div>,
}),
Steps: Object.assign(
({ children, current, className }: any) => (
<div data-testid="steps" className={className}>
{children}
</div>
),
{
Step: ({ title }: any) => <div>{title}</div>,
},
),
Button: ({ children, onClick, disabled, loading, ...props }: any) => (
<button onClick={onClick} disabled={disabled || loading} data-loading={loading} {...props}>
{children}
</button>
),
Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
disabled={disabled}
data-indeterminate={indeterminate}
/>
{children}
</label>
),
}));
// Mock @tremor/react components
vi.mock("@tremor/react", () => ({
Text: ({ children, className }: any) => <span className={className}>{children}</span>,
Title: ({ children }: any) => <h3>{children}</h3>,
Badge: ({ children, color, size }: any) => (
<span data-color={color} data-size={size}>
{children}
</span>
),
}));
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(<MakeAgentPublicForm {...mockProps} />);
// 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(<MakeAgentPublicForm {...mockProps} />);
// 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(<MakeAgentPublicForm {...mockProps} />);
// 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(<MakeAgentPublicForm {...invisibleProps} />);
// 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();
});
});

View file

@ -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<MakeAgentPublicFormProps> = ({
const [currentStep, setCurrentStep] = useState(0);
const [selectedAgents, setSelectedAgents] = useState<Set<string>>(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<MakeAgentPublicFormProps> = ({
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Title>Select Agents to Make Public</Title>
<h3 className="text-lg font-semibold">Select Agents to Make Public</h3>
<div className="flex items-center space-x-2">
<Checkbox
checked={allAgentsSelected}
indeterminate={isIndeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
disabled={agentHubData.length === 0}
>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={allAgentsSelected}
indeterminate={isIndeterminate}
onCheckedChange={(checked) => handleSelectAll(checked === true)}
disabled={agentHubData.length === 0}
/>
Select All {agentHubData.length > 0 && `(${agentHubData.length})`}
</Checkbox>
</label>
</div>
</div>
<Text className="text-sm text-gray-600">
<p className="text-sm text-gray-600">
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.
</Text>
</p>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
<div className="space-y-3">
{agentHubData.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Text>No agents available.</Text>
<p>No agents available.</p>
</div>
) : (
agentHubData.map((agent) => {
@ -144,25 +147,23 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
<div key={agentId} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50">
<Checkbox
checked={selectedAgents.has(agentId)}
onChange={(e) => handleAgentSelection(agentId, e.target.checked)}
onCheckedChange={(checked) => handleAgentSelection(agentId, checked === true)}
/>
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<Text className="font-medium">{agent.name}</Text>
<Badge color="blue" size="sm">
v{agent.version}
</Badge>
<p className="font-medium break-words">{agent.name}</p>
<Badge variant="secondary">v{agent.version}</Badge>
</div>
<Text className="text-xs text-gray-600 mt-1">{agent.description}</Text>
<p className="text-xs text-gray-600 mt-1 break-words">{agent.description}</p>
{agent.skills && agent.skills.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{agent.skills.slice(0, 3).map((skill) => (
<Badge key={skill.id} color="purple" size="xs">
<Badge key={skill.id} variant="outline">
{skill.name}
</Badge>
))}
{agent.skills.length > 3 && (
<Text className="text-xs text-gray-500">+{agent.skills.length - 3} more</Text>
<p className="text-xs text-gray-500">+{agent.skills.length - 3} more</p>
)}
</div>
)}
@ -176,9 +177,9 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
{selectedAgents.size > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
<p className="text-sm text-blue-800">
<strong>{selectedAgents.size}</strong> agent{selectedAgents.size !== 1 ? "s" : ""} selected
</Text>
</p>
</div>
)}
</div>
@ -188,33 +189,31 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
const renderStep2Content = () => {
return (
<div className="space-y-4">
<Title>Confirm Making Agents Public</Title>
<h3 className="text-lg font-semibold">Confirm Making Agents Public</h3>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<Text className="text-sm text-yellow-800">
<p className="text-sm text-yellow-800">
<strong>Warning:</strong> Once you make these agents public, anyone who can go to the{" "}
<code>/ui/model_hub_table</code> will be able to know they exist on the proxy.
</Text>
</p>
</div>
<div className="space-y-3">
<Text className="font-medium">Agents to be made public:</Text>
<p className="font-medium">Agents to be made public:</p>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedAgents).map((agentId) => {
const agent = agentHubData.find((a) => (a.agent_id || a.name) === agentId);
return (
<div key={agentId} className="flex items-center justify-between p-2 bg-gray-50 rounded-sm">
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<Text className="font-medium">{agent?.name || agentId}</Text>
{agent && (
<Badge color="blue" size="xs">
v{agent.version}
</Badge>
)}
<p className="font-medium break-words">{agent?.name || agentId}</p>
{agent && <Badge variant="secondary">v{agent.version}</Badge>}
</div>
{agent?.description && <Text className="text-xs text-gray-600 mt-1">{agent.description}</Text>}
{agent?.description && (
<p className="text-xs text-gray-600 mt-1 break-words">{agent.description}</p>
)}
</div>
</div>
);
@ -224,10 +223,10 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
<p className="text-sm text-blue-800">
Total: <strong>{selectedAgents.size}</strong> agent{selectedAgents.size !== 1 ? "s" : ""} will be made
public
</Text>
</p>
</div>
</div>
);
@ -247,7 +246,7 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
const renderStepButtons = () => {
return (
<div className="flex justify-between mt-6">
<Button onClick={currentStep === 0 ? handleClose : handlePrevious}>
<Button variant="outline" onClick={currentStep === 0 ? handleClose : handlePrevious}>
{currentStep === 0 ? "Cancel" : "Previous"}
</Button>
@ -259,7 +258,8 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} loading={loading}>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Make Public
</Button>
)}
@ -269,24 +269,42 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
};
return (
<Modal
title="Make Agents Public"
open={visible}
onCancel={handleClose}
footer={null}
width={1200}
maskClosable={false}
>
<Form form={form} layout="vertical">
<Steps current={currentStep} className="mb-6">
<Step title="Select Agents" />
<Step title="Confirm" />
</Steps>
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1200px]">
<DialogHeader>
<DialogTitle>Make Agents Public</DialogTitle>
</DialogHeader>
{renderStepContent()}
{renderStepButtons()}
</Form>
</Modal>
<div>
<ol className="mb-6 flex items-center gap-6">
{STEP_TITLES.map((title, index) => (
<li
key={title}
className="flex items-center gap-2"
aria-current={currentStep === index ? "step" : undefined}
>
<span
className={cn(
"flex size-6 items-center justify-center rounded-full border text-xs",
currentStep === index
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground",
)}
>
{index + 1}
</span>
<span className={cn("text-sm", currentStep === index ? "font-medium" : "text-muted-foreground")}>
{title}
</span>
</li>
))}
</ol>
{renderStepContent()}
{renderStepButtons()}
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -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 ? (
<div data-testid="modal">
<div>{title}</div>
{children}
{footer}
</div>
) : null,
Form: Object.assign(({ children, form }: any) => <form data-testid="form">{children}</form>, {
useForm: () => [
{
resetFields: vi.fn(),
validateFields: vi.fn(),
getFieldsValue: vi.fn(),
setFieldsValue: vi.fn(),
},
vi.fn(),
],
Item: ({ children }: any) => <div>{children}</div>,
}),
Steps: Object.assign(
({ children, current, className }: any) => (
<div data-testid="steps" className={className}>
{children}
</div>
),
{
Step: ({ title }: any) => <div>{title}</div>,
},
),
Button: ({ children, onClick, disabled, loading, ...props }: any) => (
<button onClick={onClick} disabled={disabled || loading} data-loading={loading} {...props}>
{children}
</button>
),
Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
disabled={disabled}
data-indeterminate={indeterminate}
/>
{children}
</label>
),
}));
// 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<typeof import("@tremor/react")>();
const React = await import("react");
return {
...actual,
Text: ({ children, className }: any) => <span className={className}>{children}</span>,
Title: ({ children }: any) => <h3>{children}</h3>,
Badge: ({ children, color, size }: any) => (
<span data-color={color} data-size={size}>
{children}
</span>
),
Button: React.forwardRef<HTMLButtonElement, any>(({ children, ...props }, ref) => (
<button {...props} ref={ref}>
{children}
</button>
)),
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(<MakeMCPPublicForm {...mockProps} />);
// 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(<MakeMCPPublicForm {...mockProps} />);
// 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(<MakeMCPPublicForm {...mockProps} />);
// 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(<MakeMCPPublicForm {...invisibleProps} />);
// 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();
});
});

View file

@ -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<MakeMCPPublicFormProps> = ({
const [currentStep, setCurrentStep] = useState(0);
const [selectedServers, setSelectedServers] = useState<Set<string>>(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<MakeMCPPublicFormProps> = ({
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Title>Select MCP Servers to Make Public</Title>
<h3 className="text-lg font-semibold">Select MCP Servers to Make Public</h3>
<div className="flex items-center space-x-2">
<Checkbox
checked={allServersSelected}
indeterminate={isIndeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
disabled={mcpHubData.length === 0}
>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={allServersSelected}
indeterminate={isIndeterminate}
onCheckedChange={(checked) => handleSelectAll(checked === true)}
disabled={mcpHubData.length === 0}
/>
Select All {mcpHubData.length > 0 && `(${mcpHubData.length})`}
</Checkbox>
</label>
</div>
</div>
<Text className="text-sm text-gray-600">
<p className="text-sm text-gray-600">
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.
</Text>
</p>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
<div className="space-y-3">
{mcpHubData.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Text>No MCP servers available.</Text>
<p>No MCP servers available.</p>
</div>
) : (
mcpHubData.map((server) => {
@ -148,42 +161,25 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
>
<Checkbox
checked={selectedServers.has(server.server_id)}
onChange={(e) => handleServerSelection(server.server_id, e.target.checked)}
onCheckedChange={(checked) => handleServerSelection(server.server_id, checked === true)}
/>
<div className="flex-1">
<div className="flex items-center space-x-2">
<Text className="font-medium">{server.server_name}</Text>
{isPublic && (
<Badge color="emerald" size="sm">
Public
</Badge>
)}
<Badge color="blue" size="sm">
{server.transport}
</Badge>
<Badge
color={
server.status === "active" || server.status === "healthy"
? "green"
: server.status === "inactive" || server.status === "unhealthy"
? "red"
: "gray"
}
size="sm"
>
{server.status || "unknown"}
</Badge>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium break-words">{server.server_name}</p>
{isPublic && <Badge>Public</Badge>}
<Badge variant="secondary">{server.transport}</Badge>
<Badge variant={statusVariant(server.status)}>{server.status || "unknown"}</Badge>
</div>
<Text className="text-xs text-gray-600 mt-1">{server.description || server.url}</Text>
<p className="text-xs text-gray-600 mt-1 break-words">{server.description || server.url}</p>
{server.allowed_tools && server.allowed_tools.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{server.allowed_tools.slice(0, 3).map((tool, idx) => (
<Badge key={idx} color="purple" size="xs">
<Badge key={idx} variant="outline">
{tool}
</Badge>
))}
{server.allowed_tools.length > 3 && (
<Text className="text-xs text-gray-500">+{server.allowed_tools.length - 3} more</Text>
<p className="text-xs text-gray-500">+{server.allowed_tools.length - 3} more</p>
)}
</div>
)}
@ -197,9 +193,9 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
{selectedServers.size > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
<p className="text-sm text-blue-800">
<strong>{selectedServers.size}</strong> MCP server{selectedServers.size !== 1 ? "s" : ""} selected
</Text>
</p>
</div>
)}
</div>
@ -209,48 +205,37 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
const renderStep2Content = () => {
return (
<div className="space-y-4">
<Title>Confirm Making MCP Servers Public</Title>
<h3 className="text-lg font-semibold">Confirm Making MCP Servers Public</h3>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<Text className="text-sm text-yellow-800">
<p className="text-sm text-yellow-800">
<strong>Warning:</strong> Once you make these MCP servers public, anyone who can go to the{" "}
<code>/ui/model_hub_table</code> will be able to know they exist on the proxy.
</Text>
</p>
</div>
<div className="space-y-3">
<Text className="font-medium">MCP Servers to be made public:</Text>
<p className="font-medium">MCP Servers to be made public:</p>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedServers).map((serverId) => {
const server = mcpHubData.find((s) => s.server_id === serverId);
return (
<div key={serverId} className="flex items-center justify-between p-2 bg-gray-50 rounded-sm">
<div className="flex-1">
<div className="flex items-center space-x-2">
<Text className="font-medium">{server?.server_name || serverId}</Text>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium break-words">{server?.server_name || serverId}</p>
{server && (
<>
<Badge color="blue" size="xs">
{server.transport}
</Badge>
<Badge
color={
server.status === "active" || server.status === "healthy"
? "green"
: server.status === "inactive" || server.status === "unhealthy"
? "red"
: "gray"
}
size="xs"
>
{server.status || "unknown"}
</Badge>
<Badge variant="secondary">{server.transport}</Badge>
<Badge variant={statusVariant(server.status)}>{server.status || "unknown"}</Badge>
</>
)}
</div>
{server?.description && <Text className="text-xs text-gray-600 mt-1">{server.description}</Text>}
{server?.url && <Text className="text-xs text-gray-500 mt-1">{server.url}</Text>}
{server?.description && (
<p className="text-xs text-gray-600 mt-1 break-words">{server.description}</p>
)}
{server?.url && <p className="text-xs text-gray-500 mt-1 break-words">{server.url}</p>}
</div>
</div>
);
@ -260,10 +245,10 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
<p className="text-sm text-blue-800">
Total: <strong>{selectedServers.size}</strong> MCP server{selectedServers.size !== 1 ? "s" : ""} will be
made public
</Text>
</p>
</div>
</div>
);
@ -283,7 +268,7 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
const renderStepButtons = () => {
return (
<div className="flex justify-between mt-6">
<Button onClick={currentStep === 0 ? handleClose : handlePrevious}>
<Button variant="outline" onClick={currentStep === 0 ? handleClose : handlePrevious}>
{currentStep === 0 ? "Cancel" : "Previous"}
</Button>
@ -295,7 +280,8 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} loading={loading}>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Make Public
</Button>
)}
@ -305,24 +291,42 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
};
return (
<Modal
title="Make MCP Servers Public"
open={visible}
onCancel={handleClose}
footer={null}
width={1200}
maskClosable={false}
>
<Form form={form} layout="vertical">
<Steps current={currentStep} className="mb-6">
<Step title="Select Servers" />
<Step title="Confirm" />
</Steps>
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1200px]">
<DialogHeader>
<DialogTitle>Make MCP Servers Public</DialogTitle>
</DialogHeader>
{renderStepContent()}
{renderStepButtons()}
</Form>
</Modal>
<div>
<ol className="mb-6 flex items-center gap-6">
{STEP_TITLES.map((title, index) => (
<li
key={title}
className="flex items-center gap-2"
aria-current={currentStep === index ? "step" : undefined}
>
<span
className={cn(
"flex size-6 items-center justify-center rounded-full border text-xs",
currentStep === index
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground",
)}
>
{index + 1}
</span>
<span className={cn("text-sm", currentStep === index ? "font-medium" : "text-muted-foreground")}>
{title}
</span>
</li>
))}
</ol>
{renderStepContent()}
{renderStepButtons()}
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -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 ? (
<div data-testid="modal">
<div>{title}</div>
{children}
{footer}
</div>
) : null,
Form: Object.assign(({ children, form }: any) => <form data-testid="form">{children}</form>, {
useForm: () => [
{
resetFields: vi.fn(),
validateFields: vi.fn(),
getFieldsValue: vi.fn(),
setFieldsValue: vi.fn(),
},
vi.fn(),
],
Item: ({ children }: any) => <div>{children}</div>,
}),
Steps: Object.assign(
({ children, current, className }: any) => (
<div data-testid="steps" className={className}>
{children}
</div>
),
{
Step: ({ title }: any) => <div>{title}</div>,
},
),
Button: ({ children, onClick, disabled, loading, ...props }: any) => (
<button onClick={onClick} disabled={disabled || loading} data-loading={loading} {...props}>
{children}
</button>
),
Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
disabled={disabled}
data-indeterminate={indeterminate}
/>
{children}
</label>
),
}));
// Mock @tremor/react components
vi.mock("@tremor/react", () => ({
Text: ({ children, className }: any) => <span className={className}>{children}</span>,
Title: ({ children }: any) => <h3>{children}</h3>,
Badge: ({ children, color, size }: any) => (
<span data-color={color} data-size={size}>
{children}
</span>
),
}));
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(<MakeModelPublicForm {...mockProps} />);
// 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(<MakeModelPublicForm {...mockProps} />);
// 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(<MakeModelPublicForm {...mockProps} />);
// 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(<MakeModelPublicForm {...invisibleProps} />);
// 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(<MakeModelPublicForm {...mockProps} />);
// 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(<MakeModelPublicForm {...mockProps} />);
// Navigate to confirm step
const nextButton = screen.getByRole("button", { name: "Next" });
await act(async () => {
fireEvent.click(nextButton);

View file

@ -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<MakeModelPublicFormProps> = ({
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
const [filteredData, setFilteredData] = useState<ModelGroupInfo[]>([]);
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<MakeModelPublicFormProps> = ({
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Title>Select Models to Make Public</Title>
<h3 className="text-lg font-semibold">Select Models to Make Public</h3>
<div className="flex items-center space-x-2">
<Checkbox
checked={allModelsSelected}
indeterminate={isIndeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
disabled={filteredData.length === 0}
>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={allModelsSelected}
indeterminate={isIndeterminate}
onCheckedChange={(checked) => handleSelectAll(checked === true)}
disabled={filteredData.length === 0}
/>
Select All {filteredData.length > 0 && `(${filteredData.length})`}
</Checkbox>
</label>
</div>
</div>
<Text className="text-sm text-gray-600">
<p className="text-sm text-gray-600">
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.
</Text>
</p>
{/* Filters */}
<ModelFilters
@ -168,7 +171,7 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
<div className="space-y-3">
{filteredData.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Text>No models match the current filters.</Text>
<p>No models match the current filters.</p>
</div>
) : (
filteredData.map((model) => (
@ -178,20 +181,16 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
>
<Checkbox
checked={selectedModels.has(model.model_group)}
onChange={(e) => handleModelSelection(model.model_group, e.target.checked)}
onCheckedChange={(checked) => handleModelSelection(model.model_group, checked === true)}
/>
<div className="flex-1">
<div className="flex items-center space-x-2">
<Text className="font-medium">{model.model_group}</Text>
{model.mode && (
<Badge color="green" size="sm">
{model.mode}
</Badge>
)}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium break-words">{model.model_group}</p>
{model.mode && <Badge>{model.mode}</Badge>}
</div>
<div className="flex flex-wrap gap-1 mt-1">
{model.providers.map((provider) => (
<Badge key={provider} color="blue" size="xs">
<Badge key={provider} variant="secondary">
{provider}
</Badge>
))}
@ -205,9 +204,9 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
{selectedModels.size > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
<p className="text-sm text-blue-800">
<strong>{selectedModels.size}</strong> model{selectedModels.size !== 1 ? "s" : ""} selected
</Text>
</p>
</div>
)}
</div>
@ -217,29 +216,29 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
const renderStep2Content = () => {
return (
<div className="space-y-4">
<Title>Confirm Making Models Public</Title>
<h3 className="text-lg font-semibold">Confirm Making Models Public</h3>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<Text className="text-sm text-yellow-800">
<p className="text-sm text-yellow-800">
<strong>Warning:</strong> Once you make these models public, anyone who can go to the{" "}
<code>/ui/model_hub_table</code> will be able to know they exist on the proxy.
</Text>
</p>
</div>
<div className="space-y-3">
<Text className="font-medium">Models to be made public:</Text>
<p className="font-medium">Models to be made public:</p>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedModels).map((modelGroup) => {
const model = modelHubData.find((m) => m.model_group === modelGroup);
return (
<div key={modelGroup} className="flex items-center justify-between p-2 bg-gray-50 rounded-sm">
<div>
<Text className="font-medium">{modelGroup}</Text>
<div className="min-w-0">
<p className="font-medium break-words">{modelGroup}</p>
{model && (
<div className="flex flex-wrap gap-1 mt-1">
{model.providers.map((provider) => (
<Badge key={provider} color="blue" size="xs">
<Badge key={provider} variant="secondary">
{provider}
</Badge>
))}
@ -254,10 +253,10 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
<p className="text-sm text-blue-800">
Total: <strong>{selectedModels.size}</strong> model{selectedModels.size !== 1 ? "s" : ""} will be made
public
</Text>
</p>
</div>
</div>
);
@ -277,7 +276,7 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
const renderStepButtons = () => {
return (
<div className="flex justify-between mt-6">
<Button onClick={currentStep === 0 ? handleClose : handlePrevious}>
<Button variant="outline" onClick={currentStep === 0 ? handleClose : handlePrevious}>
{currentStep === 0 ? "Cancel" : "Previous"}
</Button>
@ -289,7 +288,8 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} loading={loading}>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Make Public
</Button>
)}
@ -299,24 +299,42 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
};
return (
<Modal
title="Make Models Public"
open={visible}
onCancel={handleClose}
footer={null}
width={1200}
maskClosable={false}
>
<Form form={form} layout="vertical">
<Steps current={currentStep} className="mb-6">
<Step title="Select Models" />
<Step title="Confirm" />
</Steps>
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1200px]">
<DialogHeader>
<DialogTitle>Make Models Public</DialogTitle>
</DialogHeader>
{renderStepContent()}
{renderStepButtons()}
</Form>
</Modal>
<div>
<ol className="mb-6 flex items-center gap-6">
{STEP_TITLES.map((title, index) => (
<li
key={title}
className="flex items-center gap-2"
aria-current={currentStep === index ? "step" : undefined}
>
<span
className={cn(
"flex size-6 items-center justify-center rounded-full border text-xs",
currentStep === index
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground",
)}
>
{index + 1}
</span>
<span className={cn("text-sm", currentStep === index ? "font-medium" : "text-muted-foreground")}>
{title}
</span>
</li>
))}
</ol>
{renderStepContent()}
{renderStepButtons()}
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -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<DebugWarningBannerProps> = ({ accessTo
}
return (
<Alert
message="Performance Warning: Detailed Debug Mode Active"
description={
<>
Detailed debug logging (<code>LITELLM_LOG=DEBUG</code>) 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 }}
/>
<Alert variant="warning" className="rounded-none border-x-0 border-t-0">
<TriangleAlert className="size-4" aria-hidden />
<AlertTitle>Performance Warning: Detailed Debug Mode Active</AlertTitle>
<AlertDescription>
Detailed debug logging (<code>LITELLM_LOG=DEBUG</code>) 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.
</AlertDescription>
</Alert>
);
};

View file

@ -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(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(20))} />);
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(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(5))} />);
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(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(-3))} />);
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(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
fireEvent.click(screen.getByRole("button"));
fireEvent.click(screen.getByRole("button", { name: /close/i }));
expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument();
unmount();

View file

@ -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<LicenseExpiryBannerViewProps> = (
};
return (
<Alert
message={message}
description={description}
type={tier === "warning" ? "warning" : "error"}
showIcon
banner
closable={isDismissible}
onClose={handleClose}
style={{ marginBottom: 0, borderRadius: 0 }}
/>
<Alert variant={tier === "warning" ? "warning" : "error"} className="rounded-none border-x-0 border-t-0">
{tier === "warning" ? (
<TriangleAlert className="size-4" aria-hidden />
) : (
<CircleAlert className="size-4" aria-hidden />
)}
<AlertTitle>{message}</AlertTitle>
<AlertDescription>{description}</AlertDescription>
{isDismissible && (
<AlertAction>
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={handleClose}>
<X className="size-4" />
</Button>
</AlertAction>
)}
</Alert>
);
};

View file

@ -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(<BlogDropdown />);
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(<BlogDropdown />);
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();
});
});
});

View file

@ -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 (
<div className="flex items-center px-2 py-1.5 text-sm">
<LoaderCircle role="img" aria-label="loading" className="size-4 animate-spin" />
</div>
);
}
if (isLoading) {
items = [{ key: "loading", label: <LoadingOutlined />, disabled: true }];
} else if (isError) {
items = [
{
key: "error",
label: (
<Space>
<Text type="danger">Failed to load posts</Text>
<Button size="small" onClick={() => refetch()}>
Retry
</Button>
</Space>
),
disabled: true,
},
];
} else if (!data || data.posts.length === 0) {
items = [{ key: "empty", label: <Text type="secondary">No posts available</Text>, disabled: true }];
} else {
items = [
...data.posts.slice(0, 5).map((post: BlogPost) => ({
key: post.url,
label: (
<a href={post.url} target="_blank" rel="noopener noreferrer" style={{ display: "block", width: 380 }}>
<Title level={5} style={{ marginBottom: 2 }}>
{post.title}
</Title>
<Text type="secondary" style={{ fontSize: 11 }}>
{formatDate(post.date)}
</Text>
<Paragraph ellipsis={{ rows: 2 }}>{post.description}</Paragraph>
</a>
),
})),
{ type: "divider" as const },
{
key: "view-all",
label: (
if (isError) {
return (
<div className="flex items-center gap-2 px-2 py-1.5 text-sm">
<span className="text-destructive">Failed to load posts</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Retry
</Button>
</div>
);
}
if (!data || data.posts.length === 0) {
return <div className="px-2 py-1.5 text-sm text-muted-foreground">No posts available</div>;
}
return (
<>
{data.posts.slice(0, 5).map((post: BlogPost) => (
<DropdownMenuItem key={post.url}>
<a href={post.url} target="_blank" rel="noopener noreferrer" style={{ display: "block", width: 380 }}>
<h5 className="text-sm font-semibold" style={{ marginBottom: 2 }}>
{post.title}
</h5>
<span className="text-muted-foreground" style={{ fontSize: 11 }}>
{formatDate(post.date)}
</span>
<p className="line-clamp-2">{post.description}</p>
</a>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem>
<a href="https://docs.litellm.ai/blog" target="_blank" rel="noopener noreferrer">
View all posts
</a>
),
},
];
}
</DropdownMenuItem>
</>
);
};
// Blog opens a post list; Docs is a single outbound link — navbar adds a layout-only chevron there for alignment.
return (
<Dropdown menu={{ items }} trigger={["hover"]} placement="bottomRight">
<Button type="text" className={`${NAV_PRODUCT_LINK_CLASS} border-0! bg-transparent!`}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
openOnHover
closeDelay={100}
render={<Button variant="ghost" className={`${NAV_PRODUCT_LINK_CLASS} border-0! bg-transparent!`} />}
>
Blog
<DownOutlined className="text-[10px] text-gray-500" aria-hidden />
</Button>
</Dropdown>
<ChevronDown className="size-2.5 text-gray-500" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="w-auto">
{renderMenuContent()}
</DropdownMenuContent>
</DropdownMenu>
);
};

View file

@ -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"
>
<Tooltip title="LiteLLM Slack community">
<a
href="https://www.litellm.ai/support"
target="_blank"
rel="noopener noreferrer"
className={iconBtnClass}
aria-label="Join Slack"
>
<SlackOutlined className="text-lg" />
</a>
</Tooltip>
<Tooltip title="LiteLLM on GitHub">
<a
href="https://github.com/BerriAI/litellm"
target="_blank"
rel="noopener noreferrer"
className={iconBtnClass}
aria-label="LiteLLM on GitHub"
>
<GithubOutlined className="text-lg" />
</a>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<a
href="https://www.litellm.ai/support"
target="_blank"
rel="noopener noreferrer"
className={iconBtnClass}
aria-label="Join Slack"
/>
}
>
<Slack className="size-[18px]" />
</TooltipTrigger>
<TooltipContent>LiteLLM Slack community</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<a
href="https://github.com/BerriAI/litellm"
target="_blank"
rel="noopener noreferrer"
className={iconBtnClass}
aria-label="LiteLLM on GitHub"
/>
}
>
<Github className="size-[18px]" />
</TooltipTrigger>
<TooltipContent>LiteLLM on GitHub</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};

View file

@ -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 = (
<div className="max-w-[280px]">
<Typography.Title level={5} className="mt-0! mb-2!">
LiteLLM Auto Router
</Typography.Title>
<Typography.Paragraph type="secondary" className="mb-3! text-sm leading-snug">
<PopoverTitle className="mt-0! mb-2!">LiteLLM Auto Router</PopoverTitle>
<PopoverDescription className="mb-3! text-sm leading-snug">
Route every request to the cheapest model that can handle it, no prompt changes needed.
</Typography.Paragraph>
</PopoverDescription>
<div className="flex flex-wrap items-center gap-2">
<Button type="primary" size="small" href={AUTO_ROUTER_DOCS_URL} target="_blank" rel="noopener noreferrer">
<a
className={cn(buttonVariants({ size: "sm" }))}
href={AUTO_ROUTER_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
>
Read the docs
</Button>
</a>
{hasUnread ? (
<Button type="link" size="small" className="px-1!" onClick={markDismissed}>
<Button variant="link" size="sm" className="px-1!" onClick={markDismissed}>
Mark as read
</Button>
) : null}
@ -44,16 +50,17 @@ export const NotificationsBell: React.FC = () => {
);
return (
<Popover content={content} trigger="click" open={open} onOpenChange={setOpen} placement="bottomRight">
<Button
type="text"
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className="flex! h-9! w-9! items-center justify-center rounded-md! text-gray-600 transition-colors hover:bg-gray-100! hover:text-gray-900!"
aria-label="Notifications"
>
<Badge dot={hasUnread} color="#1677ff" size="small" offset={[8, 2]}>
<BellOutlined className="text-base" aria-hidden />
</Badge>
</Button>
<span className="relative inline-flex">
<Bell className="size-4" aria-hidden />
{hasUnread ? <Badge className="absolute -top-0.5 -right-1 size-1.5 p-0" aria-hidden /> : null}
</span>
</PopoverTrigger>
<PopoverContent align="end">{content}</PopoverContent>
</Popover>
);
};

View file

@ -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<UserDropdownProps> = ({ onLogout, variant = "navbar
setDisableShowNewBadge(storedValue === "true");
}, []);
const userItems: MenuProps["items"] = [
{
key: "logout",
label: (
<Space>
<LogoutOutlined />
Logout
</Space>
),
onClick: onLogout,
},
];
const renderUserInfoSection = () => (
<Space direction="vertical" size="small" style={{ width: "100%", padding: "12px" }}>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Space>
<MailOutlined />
<Text type="secondary">{userEmail || "-"}</Text>
</Space>
<div className="flex w-full flex-col gap-2 p-3 text-sm">
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Mail className="size-4" />
<span className="text-muted-foreground">{userEmail || "-"}</span>
</div>
{premiumUser ? (
<Tag icon={<CrownOutlined />} color="gold">
<Badge>
<Crown className="size-3" />
Premium
</Tag>
</Badge>
) : (
<Tooltip title="Upgrade to Premium for advanced features" placement="left">
<Tag icon={<CrownOutlined />}>Standard</Tag>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<Badge variant="outline" />}>
<Crown className="size-3" />
Standard
</TooltipTrigger>
<TooltipContent side="left">Upgrade to Premium for advanced features</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</Space>
<Divider style={{ margin: "8px 0" }} />
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Space>
<UserOutlined />
<Text type="secondary">User ID</Text>
</Space>
<Text copyable ellipsis style={{ maxWidth: "150px" }} title={userId || "-"}>
{userId || "-"}
</Text>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Space>
<SafetyOutlined />
<Text type="secondary">Role</Text>
</Space>
<Text>{userRole}</Text>
</Space>
<Divider style={{ margin: "8px 0" }} />
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide New Feature Indicators</Text>
</div>
<Separator className="my-2" />
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
<User className="size-4" />
<span className="text-muted-foreground">User ID</span>
</div>
<div className="flex items-center gap-1">
<span className="max-w-[150px] truncate" title={userId || "-"}>
{userId || "-"}
</span>
<CopyButton value={userId} label="Copy User ID" />
</div>
</div>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ShieldCheck className="size-4" />
<span className="text-muted-foreground">Role</span>
</div>
<span>{userRole}</span>
</div>
<Separator className="my-2" />
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide New Feature Indicators</span>
<Switch
size="small"
size="sm"
checked={disableShowNewBadge}
onChange={(checked) => {
onCheckedChange={(checked) => {
setDisableShowNewBadge(checked);
if (checked) {
setLocalStorageItem("disableShowNewBadge", "true");
@ -145,13 +136,13 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
}}
aria-label="Toggle hide new feature indicators"
/>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide All Prompts</Text>
</div>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide All Prompts</span>
<Switch
size="small"
size="sm"
checked={disableShowPrompts}
onChange={(checked) => {
onCheckedChange={(checked) => {
if (checked) {
setLocalStorageItem("disableShowPrompts", "true");
emitLocalStorageChange("disableShowPrompts");
@ -162,13 +153,13 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
}}
aria-label="Toggle hide all prompts"
/>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide Blog Posts</Text>
</div>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide Blog Posts</span>
<Switch
size="small"
size="sm"
checked={disableBlogPosts}
onChange={(checked) => {
onCheckedChange={(checked) => {
if (checked) {
setLocalStorageItem("disableBlogPosts", "true");
emitLocalStorageChange("disableBlogPosts");
@ -179,13 +170,13 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
}}
aria-label="Toggle hide blog posts"
/>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide Bouncing Icon</Text>
</div>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-muted-foreground">Hide Bouncing Icon</span>
<Switch
size="small"
size="sm"
checked={disableBouncingIcon}
onChange={(checked) => {
onCheckedChange={(checked) => {
if (checked) {
setLocalStorageItem("disableBouncingIcon", "true");
emitLocalStorageChange("disableBouncingIcon");
@ -196,8 +187,8 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
}}
aria-label="Toggle hide bouncing icon"
/>
</Space>
</Space>
</div>
</div>
);
const seed = userEmail || userId || "user";
@ -206,30 +197,21 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
const displayName = navAccountDisplayName(userEmail, userId);
return (
<Dropdown
trigger={["click"]}
placement={variant === "sidebar" ? "topLeft" : "bottomRight"}
menu={{ items: userItems }}
popupRender={(menu) => (
<div className="rounded-lg bg-white shadow-lg" data-testid="user-dropdown-panel">
{renderUserInfoSection()}
<Divider style={{ margin: 0 }} />
{React.cloneElement(menu as React.ReactElement, {
style: { boxShadow: "none" },
})}
</div>
)}
>
<Popover>
{variant === "sidebar" ? (
<button
type="button"
className={cn(
"flex w-full items-center rounded-lg border border-transparent transition-colors hover:bg-sidebar-accent",
collapsed ? "justify-center px-0 py-1" : "gap-2.5 px-2 py-1.5 text-left",
)}
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
aria-haspopup="menu"
title={collapsed ? displayName : undefined}
<PopoverTrigger
render={
<button
type="button"
className={cn(
"flex w-full items-center rounded-lg border border-transparent transition-colors hover:bg-sidebar-accent",
collapsed ? "justify-center px-0 py-1" : "gap-2.5 px-2 py-1.5 text-left",
)}
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
aria-haspopup="dialog"
title={collapsed ? displayName : undefined}
/>
}
>
<Avatar className="size-[30px] shadow-inner ring-1 ring-black/5" aria-hidden>
<AvatarFallback className="font-semibold text-white" style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}>
@ -245,13 +227,17 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
<ChevronsUpDown size={16} strokeWidth={1.75} className="shrink-0 text-muted-foreground" aria-hidden />
</>
)}
</button>
</PopoverTrigger>
) : (
<Button
type="text"
className="flex! max-w-[min(200px,34vw)] items-center gap-2 rounded-md! py-0.5! pl-1! pr-2! transition-colors hover:bg-gray-100!"
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
aria-haspopup="menu"
<PopoverTrigger
render={
<button
type="button"
className="flex! max-w-[min(200px,34vw)] items-center gap-2 rounded-md! py-0.5! pl-1! pr-2! transition-colors hover:bg-gray-100!"
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
aria-haspopup="dialog"
/>
}
>
<Avatar className="shadow-inner ring-1 ring-black/5" aria-hidden>
<AvatarFallback className="font-semibold text-white" style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}>
@ -261,10 +247,27 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
<span className="hidden min-w-0 truncate text-left text-sm font-medium leading-none text-gray-900 md:inline">
{displayName}
</span>
<DownOutlined className="hidden shrink-0 text-[10px] text-gray-400 md:inline" aria-hidden />
</Button>
<ChevronDown className="hidden size-2.5 shrink-0 text-gray-400 md:inline" aria-hidden />
</PopoverTrigger>
)}
</Dropdown>
<PopoverContent
align={variant === "sidebar" ? "start" : "end"}
side={variant === "sidebar" ? "top" : "bottom"}
className="w-auto gap-0 rounded-lg bg-white p-1 shadow-lg"
data-testid="user-dropdown-panel"
>
{renderUserInfoSection()}
<Separator />
<button
type="button"
onClick={onLogout}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
>
<LogOut className="size-4" />
Logout
</button>
</PopoverContent>
</Popover>
);
};

View file

@ -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: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">Chat</span>
{isChatRoute && <CheckOutlined className="text-blue-600" />}
{isChatRoute && <Check className="size-4 text-blue-600" />}
</div>
),
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: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">{e.label}</span>
{!isChatRoute && e.key === mode && <CheckOutlined className="text-blue-600" />}
{!isChatRoute && e.key === mode && <Check className="size-4 text-blue-600" />}
</div>
),
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 (
<Dropdown menu={{ items, onClick, selectedKeys: [isChatRoute ? CHAT : mode] }} trigger={["click"]}>
<button
type="button"
className="flex h-8 max-w-[220px] items-center gap-1.5 rounded-md border border-border bg-background pl-1.5 pr-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
type="button"
className="flex h-8 max-w-[220px] items-center gap-1.5 rounded-md border border-border bg-background pl-1.5 pr-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
/>
}
>
<span className="flex size-5 flex-none items-center justify-center rounded bg-muted text-muted-foreground">
<AppstoreOutlined className="text-[13px]" />
<LayoutGrid className="size-[13px]" />
</span>
<span className="truncate">{activeLabel}</span>
<ChevronsUpDown className="size-3.5 flex-none text-muted-foreground" />
</button>
</Dropdown>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto">
{items.map((item) => (
<DropdownMenuItem key={item.key} disabled={item.disabled} onClick={item.onClick}>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -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) => (
<select data-testid="worker-select" value={value} style={style} onChange={(e) => onChange?.(e.target.value)}>
{options?.map((opt: any) => (
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
{opt.label}
</option>
))}
</select>
),
}));
// Mock icon
vi.mock("@ant-design/icons", () => ({
CloudServerOutlined: () => <span data-testid="cloud-icon" />,
}));
import WorkerDropdown from "./WorkerDropdown";
async function openWorkerList(user: ReturnType<typeof userEvent.setup>) {
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(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
expect(screen.getByTestId("worker-select")).toBeInTheDocument();
});
it("renders all worker options", () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[0],
workers,
});
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
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(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
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(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
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(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
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(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
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(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
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(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
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(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
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(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
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();
});
});

View file

@ -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<WorkerDropdownProps> = ({ 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 (
<Select
showSearch
filterOption={(input, option) => ((option?.label as string) ?? "").toLowerCase().includes(input.toLowerCase())}
value={selectedWorker.worker_id}
style={{ minWidth: 180 }}
suffixIcon={<CloudServerOutlined />}
options={workers.map((w) => ({
label: w.name,
value: w.worker_id,
disabled: w.worker_id === selectedWorker.worker_id,
}))}
onChange={(newWorkerId) => {
onWorkerSwitch(newWorkerId);
<Combobox
items={options}
value={options.find((option) => option.value === selectedWorker.worker_id) ?? null}
itemToStringLabel={(option: WorkerOption) => option.label}
onValueChange={(option: WorkerOption | null) => {
if (option) {
onWorkerSwitch(option.value);
}
}}
/>
>
<ComboboxInput className="min-w-[180px]" aria-label="Worker">
<InputGroupAddon align="inline-start">
<Server className="size-4" />
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent>
<ComboboxEmpty>No matching workers</ComboboxEmpty>
<ComboboxList>
{(option: WorkerOption) => (
<ComboboxItem key={option.value} value={option} disabled={option.disabled}>
{option.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
};

View file

@ -1,4 +1,5 @@
import { render } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import BulkCreateUsersButton from "./bulk_create_users_button";
@ -20,9 +21,55 @@ vi.mock("./molecules/notifications_manager", () => ({
},
}));
const csvFile = () =>
new File(["user_email,user_role\nnew.hire@example.com,internal_user\n"], "users.csv", { type: "text/csv" });
const openUploadStep = async () => {
const user = userEvent.setup();
render(<BulkCreateUsersButton accessToken="test-token" teams={[]} possibleUIRoles={null} />);
await user.click(screen.getByText("+ Bulk Invite Users"));
return user;
};
describe("BulkCreateUsersButton", () => {
it("should render", () => {
const { getByText } = render(<BulkCreateUsersButton accessToken="test-token" teams={[]} possibleUIRoles={null} />);
expect(getByText("+ Bulk Invite Users")).toBeInTheDocument();
});
it("parses a CSV chosen through the file input", async () => {
await openUploadStep();
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput, { target: { files: [csvFile()] } });
expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument();
});
it("parses a CSV dropped onto the drop zone", async () => {
await openUploadStep();
const dropZone = screen.getByLabelText(/drag and drop your csv file here/i).closest("label");
fireEvent.drop(dropZone as HTMLLabelElement, { dataTransfer: { files: [csvFile()], types: ["Files"] } });
expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument();
});
it("exposes the drop zone as a label for a keyboard-reachable file input", async () => {
await openUploadStep();
const fileInput = screen.getByLabelText(/drag and drop your csv file here/i) as HTMLInputElement;
expect(fileInput).toHaveAttribute("type", "file");
expect(fileInput).toHaveAttribute("accept", ".csv");
expect(fileInput).toBeVisible();
const dropZone = fileInput.closest("label") as HTMLLabelElement;
expect(fileInput.id).not.toBe("");
expect(dropZone.htmlFor).toBe(fileInput.id);
const danglingLabels = [...document.querySelectorAll("label[for]")].filter(
(label) => document.getElementById(label.getAttribute("for") as string) === null,
);
expect(danglingLabels).toEqual([]);
});
});

View file

@ -1,14 +1,8 @@
import React, { useState, useEffect } from "react";
import { Text } from "@tremor/react";
import { Button, Modal, Table, Upload, Typography } from "antd";
import {
UploadOutlined,
DownloadOutlined,
WarningOutlined,
FileTextOutlined,
DeleteOutlined,
FileExclamationOutlined,
} from "@ant-design/icons";
import { Button, buttonVariants } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Download, FileText, FileWarning, Trash2, TriangleAlert, Upload } from "lucide-react";
import { userCreateCall, invitationCreateCall, getProxyUISettings } from "./networking";
import Papa from "papaparse";
import { CheckCircleIcon, XCircleIcon, ExclamationIcon } from "@heroicons/react/outline";
@ -38,6 +32,8 @@ interface UserData {
invitation_link?: string;
}
const PREVIEW_PAGE_SIZE = 5;
// Define an interface for the UI settings
interface UISettings {
PROXY_BASE_URL: string | null;
@ -61,6 +57,9 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uiSettings, setUISettings] = useState<UISettings | null>(null);
const [baseUrl, setBaseUrl] = useState("http://localhost:4000");
const [isDraggingOver, setIsDraggingOver] = useState(false);
const [pageIndex, setPageIndex] = useState(0);
const csvInputId = React.useId();
useEffect(() => {
// Get UI settings
@ -93,7 +92,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
if (file.type !== "text/csv" && !file.name.endsWith(".csv")) {
setFileError(`Invalid file type: ${file.name}. Please upload a CSV file (.csv extension).`);
NotificationsManager.fromBackend("Invalid file type. Please upload a CSV file.");
return false;
return;
}
// Check file size (limit to 5MB)
@ -101,7 +100,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
setFileError(
`File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Please upload a CSV file smaller than 5MB.`,
);
return false;
return;
}
Papa.parse(file, {
@ -262,7 +261,27 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
},
header: false,
});
return false;
};
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFileUpload(file);
}
};
const handleDragOver = (event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault();
setIsDraggingOver(true);
};
const handleDrop = (event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault();
setIsDraggingOver(false);
const file = event.dataTransfer.files?.[0];
if (file) {
handleFileUpload(file);
}
};
const removeSelectedFile = () => {
@ -273,6 +292,12 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
setFileError(null);
};
const resetParsedData = () => {
setParsedData([]);
setParseError(null);
setPageIndex(0);
};
const handleBulkCreate = async () => {
setIsProcessing(true);
const updatedData = parsedData.map((user) => ({ ...user, status: "pending" }));
@ -434,340 +459,395 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
window.URL.revokeObjectURL(url);
};
const columns = [
{
title: "Row",
dataIndex: "rowNumber",
key: "rowNumber",
width: 80,
},
{
title: "Email",
dataIndex: "user_email",
key: "user_email",
},
{
title: "Role",
dataIndex: "user_role",
key: "user_role",
},
{
title: "Teams",
dataIndex: "teams",
key: "teams",
},
{
title: "Budget",
dataIndex: "max_budget",
key: "max_budget",
},
{
title: "Status",
key: "status",
render: (_: any, record: UserData) => {
if (!record.isValid) {
return (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
<span className="text-red-500">Invalid</span>
</div>
{record.error && <span className="text-sm text-red-500 ml-7">{record.error}</span>}
</div>
);
}
if (!record.status || record.status === "pending") {
return <span className="text-gray-500">Pending</span>;
}
if (record.status === "success") {
return (
<div>
<div className="flex items-center">
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
<span className="text-green-500">Success</span>
</div>
{record.invitation_link && (
<div className="mt-1">
<div className="flex items-center">
<span className="text-xs text-gray-500 truncate max-w-[150px]">{record.invitation_link}</span>
<CopyToClipboard
text={record.invitation_link}
onCopy={() => NotificationsManager.success("Invitation link copied!")}
>
<button className="ml-1 text-blue-500 text-xs hover:text-blue-700">Copy</button>
</CopyToClipboard>
</div>
</div>
)}
</div>
);
}
return (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
<span className="text-red-500">Failed</span>
</div>
{record.error && <span className="text-sm text-red-500 ml-7">{JSON.stringify(record.error)}</span>}
const renderStatusCell = (record: UserData) => {
if (!record.isValid) {
return (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
<span className="text-red-500">Invalid</span>
</div>
);
},
},
];
{record.error && <span className="text-sm text-red-500 ml-7">{record.error}</span>}
</div>
);
}
if (!record.status || record.status === "pending") {
return <span className="text-gray-500">Pending</span>;
}
if (record.status === "success") {
return (
<div>
<div className="flex items-center">
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
<span className="text-green-500">Success</span>
</div>
{record.invitation_link && (
<div className="mt-1">
<div className="flex items-center">
<span className="text-xs text-gray-500 truncate max-w-[150px]">{record.invitation_link}</span>
<CopyToClipboard
text={record.invitation_link}
onCopy={() => NotificationsManager.success("Invitation link copied!")}
>
<button className="ml-1 text-blue-500 text-xs hover:text-blue-700">Copy</button>
</CopyToClipboard>
</div>
</div>
)}
</div>
);
}
return (
<div>
<div className="flex items-center">
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
<span className="text-red-500">Failed</span>
</div>
{record.error && <span className="text-sm text-red-500 ml-7">{JSON.stringify(record.error)}</span>}
</div>
);
};
const pageCount = Math.max(1, Math.ceil(parsedData.length / PREVIEW_PAGE_SIZE));
const currentPage = Math.min(pageIndex, pageCount - 1);
const visibleRows = parsedData.slice(currentPage * PREVIEW_PAGE_SIZE, (currentPage + 1) * PREVIEW_PAGE_SIZE);
return (
<>
<Button type="primary" className="mb-0" onClick={() => setIsModalVisible(true)}>
<Button className="mb-0" onClick={() => setIsModalVisible(true)}>
+ Bulk Invite Users
</Button>
<Modal
title="Bulk Invite Users"
open={isModalVisible}
width={800}
onCancel={() => setIsModalVisible(false)}
bodyStyle={{ maxHeight: "70vh", overflow: "auto" }}
footer={null}
>
<div className="flex flex-col">
{/* Step indicator */}
{parsedData.length === 0 ? (
<div className="mb-6">
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
1
</div>
<h3 className="text-lg font-medium">Download and fill the template</h3>
</div>
<div className="ml-11 mb-6">
<p className="mb-4">Add multiple users at once by following these steps:</p>
<ol className="list-decimal list-inside space-y-2 ml-2 mb-4">
<li>Download our CSV template</li>
<li>Add your users&apos; information to the spreadsheet</li>
<li>Save the file and upload it here</li>
<li>After creation, download the results file containing the Virtual Keys for each user</li>
</ol>
<div className="bg-gray-50 p-4 rounded-md border border-gray-200 mb-4">
<h4 className="font-medium mb-2">Template Column Names</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">user_email</p>
<p className="text-sm text-gray-600">User&apos;s email address (required)</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">user_role</p>
<p className="text-sm text-gray-600">
User&apos;s role (one of: &quot;proxy_admin&quot;, &quot;proxy_admin_viewer&quot;,
&quot;internal_user&quot;, &quot;internal_user_viewer&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">teams</p>
<p className="text-sm text-gray-600">
Comma-separated team IDs (e.g., &quot;team-1,team-2&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">max_budget</p>
<p className="text-sm text-gray-600">Maximum budget as a number (e.g., &quot;100&quot;)</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">budget_duration</p>
<p className="text-sm text-gray-600">
Budget reset period (e.g., &quot;30d&quot;, &quot;1mo&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">models</p>
<p className="text-sm text-gray-600">
Comma-separated allowed models (e.g., &quot;gpt-3.5-turbo,gpt-4&quot;)
</p>
</div>
</div>
<Dialog open={isModalVisible} onOpenChange={(open) => !open && setIsModalVisible(false)}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>Bulk Invite Users</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
{/* Step indicator */}
{parsedData.length === 0 ? (
<div className="mb-6">
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
1
</div>
<h3 className="text-lg font-medium">Download and fill the template</h3>
</div>
<Button type="primary" size="large" className="w-full md:w-auto" icon={<DownloadOutlined />}>
Download CSV Template
</Button>
</div>
<div className="ml-11 mb-6">
<p className="mb-4">Add multiple users at once by following these steps:</p>
<ol className="list-decimal list-inside space-y-2 ml-2 mb-4">
<li>Download our CSV template</li>
<li>Add your users&apos; information to the spreadsheet</li>
<li>Save the file and upload it here</li>
<li>After creation, download the results file containing the Virtual Keys for each user</li>
</ol>
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
2
</div>
<h3 className="text-lg font-medium">Upload your completed CSV</h3>
</div>
<div className="ml-11">
{selectedFile ? (
<div
className={`mb-4 p-4 rounded-md border ${fileError ? "bg-red-50 border-red-200" : "bg-blue-50 border-blue-200"}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
{fileError ? (
<FileExclamationOutlined className="text-red-500 text-xl mr-3" />
) : (
<FileTextOutlined className="text-blue-500 text-xl mr-3" />
)}
<div className="bg-gray-50 p-4 rounded-md border border-gray-200 mb-4">
<h4 className="font-medium mb-2">Template Column Names</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 shrink-0"></div>
<div>
<Typography.Text strong className={fileError ? "text-red-800" : "text-blue-800"}>
{selectedFile.name}
</Typography.Text>
<Typography.Text className={`block text-xs ${fileError ? "text-red-600" : "text-blue-600"}`}>
{(selectedFile.size / 1024).toFixed(1)} KB {new Date().toLocaleDateString()}
</Typography.Text>
<p className="font-medium">user_email</p>
<p className="text-sm text-gray-600">User&apos;s email address (required)</p>
</div>
</div>
<Button
size="small"
onClick={removeSelectedFile}
className="flex items-center"
icon={<DeleteOutlined />}
>
Remove
</Button>
</div>
{fileError ? (
<div className="mt-3 text-red-600 text-sm flex items-start">
<WarningOutlined className="mr-2 mt-0.5" />
<span>{fileError}</span>
</div>
) : (
!csvStructureError && (
<div className="mt-3 flex items-center">
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-blue-500 h-1.5 rounded-full w-full animate-pulse"></div>
</div>
<span className="ml-2 text-xs text-blue-600">Processing...</span>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">user_role</p>
<p className="text-sm text-gray-600">
User&apos;s role (one of: &quot;proxy_admin&quot;, &quot;proxy_admin_viewer&quot;,
&quot;internal_user&quot;, &quot;internal_user_viewer&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">teams</p>
<p className="text-sm text-gray-600">
Comma-separated team IDs (e.g., &quot;team-1,team-2&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">max_budget</p>
<p className="text-sm text-gray-600">Maximum budget as a number (e.g., &quot;100&quot;)</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">budget_duration</p>
<p className="text-sm text-gray-600">
Budget reset period (e.g., &quot;30d&quot;, &quot;1mo&quot;)
</p>
</div>
</div>
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
<div>
<p className="font-medium">models</p>
<p className="text-sm text-gray-600">
Comma-separated allowed models (e.g., &quot;gpt-3.5-turbo,gpt-4&quot;)
</p>
</div>
)
)}
</div>
) : (
<Upload beforeUpload={handleFileUpload} accept=".csv" maxCount={1} showUploadList={false}>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-500 transition-colors cursor-pointer">
<UploadOutlined className="text-3xl text-gray-400 mb-2" />
<p className="mb-1">Drag and drop your CSV file here</p>
<p className="text-sm text-gray-500 mb-3">or</p>
<Button size="small">Browse files</Button>
<p className="text-xs text-gray-500 mt-4">Only CSV files (.csv) are supported</p>
</div>
</Upload>
)}
{csvStructureError && (
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-md">
<div className="flex items-start">
<ExclamationIcon className="h-5 w-5 text-yellow-500 mr-2 mt-0.5" />
<div>
<Typography.Text strong className="text-yellow-800">
CSV Structure Error
</Typography.Text>
<Typography.Paragraph className="text-yellow-700 mt-1 mb-0">
{csvStructureError}
</Typography.Paragraph>
<Typography.Paragraph className="text-yellow-700 mt-2 mb-0">
Please download our template and ensure your CSV follows the required format.
</Typography.Paragraph>
</div>
</div>
</div>
)}
</div>
</div>
) : (
<div className="mb-6">
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
3
<Button size="lg" className="w-full md:w-auto">
<Download className="size-4" />
Download CSV Template
</Button>
</div>
<h3 className="text-lg font-medium">
{parsedData.some((user) => user.status === "success" || user.status === "failed")
? "User Creation Results"
: "Review and create users"}
</h3>
</div>
{parseError && (
<div className="ml-11 mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="flex items-start">
<WarningOutlined className="text-red-500 mr-2 mt-1" />
<div>
<Text className="text-red-600 font-medium">{parseError}</Text>
{parsedData.some((user) => !user.isValid) && (
<ul className="mt-2 list-disc list-inside text-red-600 text-sm">
<li>Check the table below for specific errors in each row</li>
<li>
Common issues include invalid email formats, missing required fields, or incorrect role
values
</li>
<li>Fix these issues in your CSV file and upload again</li>
</ul>
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
2
</div>
<h3 className="text-lg font-medium">Upload your completed CSV</h3>
</div>
<div className="ml-11">
{selectedFile ? (
<div
className={`mb-4 p-4 rounded-md border ${fileError ? "bg-red-50 border-red-200" : "bg-blue-50 border-blue-200"}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center min-w-0">
{fileError ? (
<FileWarning className="size-5 shrink-0 text-red-500 mr-3" />
) : (
<FileText className="size-5 shrink-0 text-blue-500 mr-3" />
)}
<div className="min-w-0">
<strong className={`break-words ${fileError ? "text-red-800" : "text-blue-800"}`}>
{selectedFile.name}
</strong>
<span className={`block text-xs ${fileError ? "text-red-600" : "text-blue-600"}`}>
{(selectedFile.size / 1024).toFixed(1)} KB {new Date().toLocaleDateString()}
</span>
</div>
</div>
<Button variant="outline" size="sm" onClick={removeSelectedFile} className="flex items-center">
<Trash2 className="size-4" />
Remove
</Button>
</div>
{fileError ? (
<div className="mt-3 text-red-600 text-sm flex items-start">
<TriangleAlert className="size-3.5 shrink-0 mr-2 mt-0.5" />
<span className="min-w-0 break-words">{fileError}</span>
</div>
) : (
!csvStructureError && (
<div className="mt-3 flex items-center">
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div className="bg-blue-500 h-1.5 rounded-full w-full animate-pulse"></div>
</div>
<span className="ml-2 text-xs text-blue-600">Processing...</span>
</div>
)
)}
</div>
</div>
</div>
)}
) : (
<label
htmlFor={csvInputId}
className="block"
onDragOver={handleDragOver}
onDragLeave={() => setIsDraggingOver(false)}
onDrop={handleDrop}
>
<div
className={`border-2 border-dashed ${isDraggingOver ? "border-blue-500" : "border-gray-300"} rounded-lg p-8 text-center hover:border-blue-500 focus-within:border-blue-500 transition-colors cursor-pointer`}
>
<input
id={csvInputId}
type="file"
accept=".csv"
className="sr-only"
onChange={handleFileInputChange}
/>
<Upload className="size-[30px] text-gray-400 mb-2" />
<p className="mb-1">Drag and drop your CSV file here</p>
<p className="text-sm text-gray-500 mb-3">or</p>
<span className={buttonVariants({ variant: "outline", size: "sm" })}>Browse files</span>
<p className="text-xs text-gray-500 mt-4">Only CSV files (.csv) are supported</p>
</div>
</label>
)}
<div className="ml-11">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center">
{parsedData.some((user) => user.status === "success" || user.status === "failed") ? (
<div className="flex items-center">
<Text className="text-lg font-medium mr-3">Creation Summary</Text>
<Text className="text-sm bg-green-100 text-green-800 px-2 py-1 rounded-sm mr-2">
{parsedData.filter((d) => d.status === "success").length} Successful
</Text>
{parsedData.some((d) => d.status === "failed") && (
<Text className="text-sm bg-red-100 text-red-800 px-2 py-1 rounded-sm">
{parsedData.filter((d) => d.status === "failed").length} Failed
</Text>
{csvStructureError && (
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-md">
<div className="flex items-start">
<ExclamationIcon className="h-5 w-5 shrink-0 text-yellow-500 mr-2 mt-0.5" />
<div className="min-w-0">
<strong className="text-yellow-800">CSV Structure Error</strong>
<p className="text-yellow-700 mt-1 mb-0 break-words">{csvStructureError}</p>
<p className="text-yellow-700 mt-2 mb-0">
Please download our template and ensure your CSV follows the required format.
</p>
</div>
</div>
</div>
)}
</div>
</div>
) : (
<div className="mb-6">
<div className="flex items-center mb-4">
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
3
</div>
<h3 className="text-lg font-medium">
{parsedData.some((user) => user.status === "success" || user.status === "failed")
? "User Creation Results"
: "Review and create users"}
</h3>
</div>
{parseError && (
<div className="ml-11 mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="flex items-start">
<TriangleAlert className="size-4 shrink-0 text-red-500 mr-2 mt-1" />
<div className="min-w-0">
<p className="text-red-600 font-medium break-words">{parseError}</p>
{parsedData.some((user) => !user.isValid) && (
<ul className="mt-2 list-disc list-inside text-red-600 text-sm">
<li>Check the table below for specific errors in each row</li>
<li>
Common issues include invalid email formats, missing required fields, or incorrect role
values
</li>
<li>Fix these issues in your CSV file and upload again</li>
</ul>
)}
</div>
) : (
<div className="flex items-center">
<Text className="text-lg font-medium mr-3">User Preview</Text>
<Text className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded-sm">
{parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid
</Text>
</div>
</div>
)}
<div className="ml-11">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center">
{parsedData.some((user) => user.status === "success" || user.status === "failed") ? (
<div className="flex items-center">
<p className="text-lg font-medium mr-3">Creation Summary</p>
<p className="text-sm bg-green-100 text-green-800 px-2 py-1 rounded-sm mr-2">
{parsedData.filter((d) => d.status === "success").length} Successful
</p>
{parsedData.some((d) => d.status === "failed") && (
<p className="text-sm bg-red-100 text-red-800 px-2 py-1 rounded-sm">
{parsedData.filter((d) => d.status === "failed").length} Failed
</p>
)}
</div>
) : (
<div className="flex items-center">
<p className="text-lg font-medium mr-3">User Preview</p>
<p className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded-sm">
{parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid
</p>
</div>
)}
</div>
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex space-x-3">
<Button variant="outline" onClick={resetParsedData}>
Back
</Button>
<Button
onClick={handleBulkCreate}
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
>
{isProcessing ? "Creating..." : `Create ${parsedData.filter((d) => d.isValid).length} Users`}
</Button>
</div>
)}
</div>
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex space-x-3">
{parsedData.some((user) => user.status === "success") && (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
<div className="flex items-start">
<div className="mr-3 mt-1">
<CheckCircleIcon className="h-5 w-5 text-blue-500" />
</div>
<div>
<p className="font-medium text-blue-800">User creation complete</p>
<p className="block text-sm text-blue-700 mt-1">
<span className="font-medium">Next step:</span> Download the credentials file containing
Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests
through LiteLLM.
</p>
</div>
</div>
</div>
)}
<div className="max-h-[300px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Row</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Teams</TableHead>
<TableHead>Budget</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((record) => (
<TableRow key={record.rowNumber} className={!record.isValid ? "bg-red-50" : ""}>
<TableCell>{record.rowNumber}</TableCell>
<TableCell className="whitespace-normal break-words">{record.user_email}</TableCell>
<TableCell className="whitespace-normal break-words">{record.user_role}</TableCell>
<TableCell className="whitespace-normal break-words">{record.teams}</TableCell>
<TableCell>{record.max_budget}</TableCell>
<TableCell className="whitespace-normal break-words">{renderStatusCell(record)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{pageCount > 1 && (
<div className="flex items-center justify-end gap-3 mt-2">
<span className="text-sm text-gray-500">
Page {currentPage + 1} of {pageCount}
</span>
<Button
onClick={() => {
setParsedData([]);
setParseError(null);
}}
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPage - 1)}
disabled={currentPage === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex(currentPage + 1)}
disabled={currentPage >= pageCount - 1}
>
Next
</Button>
</div>
)}
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex justify-end mt-4">
<Button variant="outline" onClick={resetParsedData} className="mr-3">
Back
</Button>
<Button
type="primary"
onClick={handleBulkCreate}
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
>
@ -775,77 +855,24 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
</Button>
</div>
)}
</div>
{parsedData.some((user) => user.status === "success") && (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
<div className="flex items-start">
<div className="mr-3 mt-1">
<CheckCircleIcon className="h-5 w-5 text-blue-500" />
</div>
<div>
<Text className="font-medium text-blue-800">User creation complete</Text>
<Text className="block text-sm text-blue-700 mt-1">
<span className="font-medium">Next step:</span> Download the credentials file containing
Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests
through LiteLLM.
</Text>
</div>
{parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex justify-end mt-4">
<Button variant="outline" onClick={resetParsedData} className="mr-3">
Start New Bulk Import
</Button>
<Button onClick={downloadResults}>
<Download className="size-4" />
Download User Credentials
</Button>
</div>
</div>
)}
<Table
dataSource={parsedData}
columns={columns}
size="small"
pagination={{ pageSize: 5 }}
scroll={{ y: 300 }}
rowClassName={(record) => (!record.isValid ? "bg-red-50" : "")}
/>
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex justify-end mt-4">
<Button
onClick={() => {
setParsedData([]);
setParseError(null);
}}
className="mr-3"
>
Back
</Button>
<Button
type="primary"
onClick={handleBulkCreate}
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
>
{isProcessing ? "Creating..." : `Create ${parsedData.filter((d) => d.isValid).length} Users`}
</Button>
</div>
)}
{parsedData.some((user) => user.status === "success" || user.status === "failed") && (
<div className="flex justify-end mt-4">
<Button
onClick={() => {
setParsedData([]);
setParseError(null);
}}
className="mr-3"
>
Start New Bulk Import
</Button>
<Button type="primary" onClick={downloadResults} icon={<DownloadOutlined />}>
Download User Credentials
</Button>
</div>
)}
)}
</div>
</div>
</div>
)}
</div>
</Modal>
)}
</div>
</DialogContent>
</Dialog>
</>
);
};

View file

@ -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 <Tag color="blue">Default Proxy Admin</Tag>;
return <Badge variant="secondary">Default Proxy Admin</Badge>;
}
return <Text>{userId}</Text>;
return <span>{userId}</span>;
}

View file

@ -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(<DeleteResourceModal {...defaultProps} />);
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(<DeleteResourceModal {...defaultProps} confirmLoading={true} />);
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(<DeleteResourceModal {...defaultProps} confirmLoading={true} requiredConfirmation="DELETE" />);

View file

@ -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<React.ComponentProps<typeof Typography.Text>, "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 (
<Modal
title={title}
open={isOpen}
onOk={onOk}
onCancel={onCancel}
confirmLoading={confirmLoading}
okText={confirmLoading ? "Deleting..." : "Delete"}
cancelText="Cancel"
okButtonProps={{
danger: true,
disabled: (!!requiredConfirmation && requiredConfirmationInput !== requiredConfirmation) || confirmLoading,
}}
cancelButtonProps={{ disabled: confirmLoading }}
>
<div className="space-y-4">
{alertMessage && <Alert message={alertMessage} type="warning" />}
<Card
title={resourceInformationTitle}
className="mt-4"
styles={{
body: { padding: "16px" },
header: {
backgroundColor: token.colorErrorBg,
borderColor: token.colorErrorBorder,
},
}}
style={{
backgroundColor: token.colorErrorBg,
borderColor: token.colorErrorBorder,
}}
>
<Descriptions column={1} size="small">
{resourceInformation &&
resourceInformation.map(({ label, value, ...textProps }) => (
<Descriptions.Item key={label} label={<span className="font-semibold">{label}</span>}>
<Text {...textProps}>{value ?? "-"}</Text>
</Descriptions.Item>
))}
</Descriptions>
</Card>
<div>
<Text>{message}</Text>
</div>
{requiredConfirmation && (
<div className="mb-6 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<Text className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
<Text>Type </Text>
<Text strong type="danger">
{requiredConfirmation}
</Text>
<Text> to confirm deletion:</Text>
</Text>
<Input
value={requiredConfirmationInput}
onChange={(e) => setRequiredConfirmationInput(e.target.value)}
placeholder={requiredConfirmation}
className="rounded-md"
prefix={<ExclamationCircleOutlined style={{ color: token.colorError }} />}
autoFocus
/>
<Dialog open={isOpen} onOpenChange={(open) => !open && !confirmLoading && onCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{alertMessage && (
<Alert variant="warning">
<AlertTitle>{alertMessage}</AlertTitle>
</Alert>
)}
<Card size="sm" className="mt-4">
{resourceInformationTitle && (
<CardHeader className="border-b">
<CardTitle>{resourceInformationTitle}</CardTitle>
</CardHeader>
)}
<CardContent>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1">
{resourceInformation?.map(({ label, value, code }) => (
<React.Fragment key={label}>
<dt className="font-semibold">{label}</dt>
<dd className="min-w-0 break-words">{code ? <code>{value ?? "-"}</code> : value ?? "-"}</dd>
</React.Fragment>
))}
</dl>
</CardContent>
</Card>
<div>
<span>{message}</span>
</div>
)}
</div>
</Modal>
{requiredConfirmation && (
<div className="mb-6 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
Type <span className="font-semibold text-destructive">{requiredConfirmation}</span> to confirm deletion:
</p>
<InputGroup className="rounded-md">
<InputGroupAddon>
<CircleAlert className="size-3.5 text-destructive" />
</InputGroupAddon>
<InputGroupInput
value={requiredConfirmationInput}
onChange={(e) => setRequiredConfirmationInput(e.target.value)}
placeholder={requiredConfirmation}
autoFocus
/>
</InputGroup>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={confirmLoading}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onOk}
disabled={(!!requiredConfirmation && requiredConfirmationInput !== requiredConfirmation) || confirmLoading}
>
{confirmLoading ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -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<ModelAliasManagerProps> = ({
return (
<div className="mt-4">
<div className="mb-6">
<Text className="text-sm font-medium text-gray-700 mb-2">Add New Alias</Text>
<p className="text-sm font-medium text-gray-700 mb-2">Add New Alias</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Alias Name</label>
@ -186,17 +187,17 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
</div>
</div>
<Text className="text-sm font-medium text-gray-700 mb-2">Manage Existing Aliases</Text>
<p className="text-sm font-medium text-gray-700 mb-2">Manage Existing Aliases</p>
<div className="rounded-lg custom-border relative mb-6">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
<TableHeader>
<TableRow>
<TableHeaderCell className="py-1 h-8">Alias Name</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">Target Model</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">Actions</TableHeaderCell>
<TableHead className="py-1 h-8">Alias Name</TableHead>
<TableHead className="py-1 h-8">Target Model</TableHead>
<TableHead className="py-1 h-8">Actions</TableHead>
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{aliases.map((alias) => (
<TableRow key={alias.id} className="h-8">
@ -284,9 +285,9 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
{/* Configuration Example */}
{showExampleConfig && (
<Card>
<Title className="mb-4">Configuration Example</Title>
<Text className="text-gray-600 mb-4">Here&apos;s how your current aliases would look in the config:</Text>
<Card className="px-6">
<CardTitle className="mb-4">Configuration Example</CardTitle>
<p className="text-gray-600 mb-4">Here&apos;s how your current aliases would look in the config:</p>
<div className="bg-gray-100 rounded-lg p-4 font-mono text-sm">
<div className="text-gray-700">
model_aliases:

View file

@ -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(<ModelSelector accessToken="test-token" onChange={onChange} />);
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(<ModelSelector accessToken="test-token" onChange={onChange} />);
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(<ModelSelector accessToken="test-token" onChange={onChange} />);
const input = openCustomModelInput();
const input = await openCustomModelInput();
vi.useFakeTimers();
act(() => {
fireEvent.change(input, { target: { value: "gpt-4o" } });

View file

@ -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<ModelSelectorProps> = ({
return (
<div>
{showLabel && (
<Text className="font-medium block mb-2 text-gray-700 flex items-center">
<RobotOutlined className="mr-2" /> {labelText}
</Text>
<p className="font-medium block mb-2 text-gray-700 flex items-center">
<Bot className="mr-2 size-3.5" /> {labelText}
</p>
)}
<Select
value={selectedModel}
placeholder={placeholder}
onChange={onModelChange}
options={[
...Array.from(new Set(modelInfo.map((option) => option.model_group))).map((model_group, index) => ({
value: model_group,
label: model_group,
key: index,
})),
{ value: "custom", label: "Enter custom model", key: "custom" },
]}
style={{ width: "100%", ...style }}
showSearch={true}
className={`rounded-md ${className || ""}`}
disabled={disabled}
/>
<div style={{ width: "100%", ...style }} className={`rounded-md ${className || ""}`}>
<SearchSelect
options={[
...Array.from(new Set(modelInfo.map((option) => option.model_group))).map((model_group) => ({
value: model_group,
label: model_group,
})),
{ value: "custom", label: "Enter custom model" },
]}
value={selectedModel}
placeholder={placeholder}
onValueChange={onModelChange}
disabled={disabled}
/>
</div>
{showCustomModelInput && (
<TextInput
<Input
className="mt-2"
placeholder="Enter custom model name"
onValueChange={debouncedSelect}
onChange={(e) => debouncedSelect(e.target.value)}
disabled={disabled}
/>
)}

View file

@ -54,12 +54,28 @@ describe("OrganizationDropdown", () => {
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("Engineering"));
expect(onChange).toHaveBeenCalledWith("org-1", expect.anything());
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0]).toBe("org-1");
});
it("should add ant-select-disabled class when disabled prop is true", () => {
const { container } = render(<OrganizationDropdown organizations={MOCK_ORGS} disabled={true} />);
expect(container.querySelector(".ant-select-disabled")).toBeTruthy();
it("should filter options by organization id", async () => {
const user = userEvent.setup();
render(<OrganizationDropdown organizations={MOCK_ORGS} />);
await user.click(screen.getByRole("combobox"));
await user.type(screen.getByRole("combobox"), "org-2");
expect(await screen.findByText("Sales")).toBeInTheDocument();
expect(screen.queryByText("Engineering")).not.toBeInTheDocument();
});
it("should not open the option list when disabled prop is true", async () => {
const user = userEvent.setup();
render(<OrganizationDropdown organizations={MOCK_ORGS} disabled={true} />);
await user.click(screen.getByRole("combobox"));
expect(screen.queryByText("Engineering")).not.toBeInTheDocument();
});
it("should render with empty organizations list", () => {

View file

@ -1,9 +1,7 @@
import React from "react";
import { Select, Typography } from "antd";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { Organization } from "../networking";
const { Text } = Typography;
interface OrganizationDropdownProps {
organizations?: Organization[] | null;
value?: string;
@ -12,6 +10,7 @@ interface OrganizationDropdownProps {
loading?: boolean;
style?: React.CSSProperties;
placeholder?: string;
id?: string;
}
const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
@ -22,36 +21,24 @@ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
loading,
style,
placeholder = "All Organizations",
id,
}) => {
return (
<Select
showSearch
placeholder={placeholder}
value={value}
onChange={onChange}
disabled={disabled}
loading={loading}
allowClear
style={{ minWidth: 280, ...style }}
filterOption={(input, option) => {
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) => (
<Select.Option key={org.organization_id} value={org.organization_id}>
<span className="font-medium">{org.organization_alias}</span>{" "}
<Text type="secondary">({org.organization_id})</Text>
</Select.Option>
))}
</Select>
<div style={{ minWidth: 280, ...style }}>
<SearchSelect
options={(organizations ?? []).map((org) => ({
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}
/>
</div>
);
};

View file

@ -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<PassThroughRoutesSelectorProps> = ({
onChange,
value,
@ -26,7 +31,7 @@ const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
disabled = false,
teamId,
}) => {
const [passThroughRoutes, setPassThroughRoutes] = useState<Array<{ label: string; value: string }>>([]);
const [passThroughRoutes, setPassThroughRoutes] = useState<MultiSelectOption[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
@ -37,27 +42,7 @@ const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
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<PassThroughRoutesSelectorProps> = ({
}, [accessToken, teamId]);
return (
<Select
mode="tags"
placeholder={placeholder}
onChange={onChange}
value={value}
loading={loading}
className={className}
allowClear
<MultiSelect
options={passThroughRoutes}
optionFilterProp="label"
showSearch
style={{ width: "100%" }}
value={value}
onValueChange={(routes) => onChange?.(routes)}
placeholder={placeholder}
emptyText="No pass through routes found"
loading={loading}
allowCustomValues
disabled={disabled}
className={className}
/>
);
};

View file

@ -1,5 +1,4 @@
import React from "react";
import { Text } from "@tremor/react";
import LoggingSettings from "../team/LoggingSettings";
interface PremiumLoggingSettingsProps {
@ -29,14 +28,14 @@ export function PremiumLoggingSettings({
</div>
</div>
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<Text className="text-sm text-yellow-800">
<p className="text-sm text-yellow-800">
Setting Key/Team logging settings is a LiteLLM Enterprise feature. Global Logging Settings are available for
all free users. Get a trial key{" "}
<a href="https://www.litellm.ai/#pricing" target="_blank" rel="noopener noreferrer" className="underline">
here
</a>
.
</Text>
</p>
</div>
</div>
);

View file

@ -1,6 +1,5 @@
import React from "react";
import { Select, Spin } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
interface ProjectDropdownProps {
@ -11,42 +10,38 @@ interface ProjectDropdownProps {
loading?: boolean;
/** When set, only show projects belonging to this team */
teamId?: string | null;
id?: string;
}
const ProjectDropdown: React.FC<ProjectDropdownProps> = ({ projects, value, onChange, disabled, loading, teamId }) => {
const ProjectDropdown: React.FC<ProjectDropdownProps> = ({
projects,
value,
onChange,
disabled,
loading,
teamId,
id,
}) => {
const filtered = teamId ? projects?.filter((p) => p.team_id === teamId) : projects;
return (
<Select
showSearch
placeholder="Search or select a project"
<SearchSelect
options={
loading
? []
: (filtered ?? []).map((project) => ({
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 ? <Spin indicator={<LoadingOutlined spin />} 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) => (
<Select.Option key={project.project_id} value={project.project_id}>
<span className="font-medium">{project.project_alias || project.project_id}</span>{" "}
<span className="text-gray-500">({project.project_id})</span>
</Select.Option>
))}
</Select>
inputId={id}
/>
);
};

View file

@ -21,14 +21,6 @@ vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({
),
}));
vi.mock("@tremor/react", () => ({
TabGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Tab: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabPanels: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabPanel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../router_settings/RouterSettingsForm", () => ({
default: ({
value,

View file

@ -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<RouterSettingsAccordionRef, RouterSet
return (
<div className="w-full">
<TabGroup className="w-full">
<TabList variant="line" defaultValue="1" className="px-8 pt-4">
<Tab value="1">Loadbalancing</Tab>
<Tab value="2">Fallbacks</Tab>
</TabList>
<TabPanels className="px-8 py-6">
<TabPanel>
<Tabs defaultValue="1" className="w-full">
<TabsList variant="line" className="px-8 pt-4">
<TabsTrigger value="1">Loadbalancing</TabsTrigger>
<TabsTrigger value="2">Fallbacks</TabsTrigger>
</TabsList>
<div className="px-8 py-6">
<TabsContent value="1" keepMounted>
<RouterSettingsForm
value={formValue}
onChange={setFormValue}
@ -358,17 +358,17 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
availableRoutingStrategies={availableRoutingStrategies}
routingStrategyDescriptions={routingStrategyDescriptions}
/>
</TabPanel>
<TabPanel>
</TabsContent>
<TabsContent value="2" keepMounted>
<FallbackSelectionForm
groups={fallbackGroups}
onGroupsChange={handleFallbackGroupsChange}
availableModels={availableModels}
maxGroups={5}
/>
</TabPanel>
</TabPanels>
</TabGroup>
</TabsContent>
</div>
</Tabs>
</div>
);
},

View file

@ -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<string, string> = {
[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<BudgetDurationDropdownProps> = ({
}) => {
return (
<Select
style={{ width: "100%", ...style }}
value={value || undefined}
onChange={onChange}
className={className}
placeholder={placeholder}
allowClear
items={DURATION_LABELS}
value={value || null}
onValueChange={(next: string | null) => onChange?.(next ?? undefined)}
>
{showNeverResets ? <Option value={NEVER_RESETS_BUDGET_DURATION}>Never resets</Option> : null}
<Option value="1h">hourly</Option>
<Option value="24h">daily</Option>
<Option value="7d">weekly</Option>
<Option value="30d">monthly</Option>
<SelectTrigger className={`w-full ${className}`} style={style}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={null}>{placeholder}</SelectItem>
{showNeverResets ? <SelectItem value={NEVER_RESETS_BUDGET_DURATION}>Never resets</SelectItem> : null}
<SelectItem value="1h">hourly</SelectItem>
<SelectItem value="24h">daily</SelectItem>
<SelectItem value="7d">weekly</SelectItem>
<SelectItem value="30d">monthly</SelectItem>
</SelectContent>
</Select>
);
};

View file

@ -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<ChartTooltipProps["payload"]>;
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(<CustomTooltip {...props} />);
@ -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(<CustomTooltip {...props} />);
@ -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(<CustomTooltip {...props} />);
@ -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(<CustomTooltip {...props} />);
@ -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(<CustomTooltip {...props} />);
@ -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(<CustomTooltip {...props} />);
@ -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(<CustomTooltip {...props} />);
@ -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(<CustomTooltip {...props} />);
@ -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(<CustomTooltip {...props} />);
@ -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(<CustomTooltip {...props} />);
@ -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(<CustomTooltip {...props} />);
@ -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(<CustomTooltip {...props} />);
@ -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(<CustomTooltip {...props} />);

View file

@ -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

View file

@ -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 }) => <div>{children}</div>,
TabList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Tab: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabPanels: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabPanel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../router_settings/RouterSettingsForm", () => ({
default: ({ value }: { value: RouterSettingsFormValue }) => (
<div data-testid="loadbalancing">{JSON.stringify(value.routerSettings)}</div>

View file

@ -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<T> {
header: string;
@ -31,20 +31,20 @@ export function SimpleTable<T>({
}: SimpleTableProps<T>) {
return (
<Table>
<TableHead>
<TableHeader>
<TableRow>
{columns.map((column, index) => (
<TableHeaderCell key={index} style={{ width: column.width }}>
<TableHead key={index} style={{ width: column.width }}>
{column.header}
</TableHeaderCell>
</TableHead>
))}
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
<Text className="text-gray-500">{loadingMessage}</Text>
<span className="text-gray-500">{loadingMessage}</span>
</TableCell>
</TableRow>
) : data.length > 0 ? (
@ -60,7 +60,7 @@ export function SimpleTable<T>({
) : (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
<Text className="text-gray-500">{emptyMessage}</Text>
<span className="text-gray-500">{emptyMessage}</span>
</TableCell>
</TableRow>
)}

View file

@ -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<TeamDropdownProps> = ({
value,
onChange,
@ -28,15 +22,13 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
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<TeamDropdownProps> = ({
return result;
}, [data]);
const handlePopupScroll = (e: UIEvent<HTMLDivElement>) => {
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 (
<Select
showSearch
placeholder="Search or select a team"
value={value || undefined}
onChange={handleChange}
disabled={disabled}
allowClear
filterOption={false}
onSearch={handleSearch}
searchValue={searchInput}
onPopupScroll={handlePopupScroll}
loading={isLoading}
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No teams found"}
data-testid="team-dropdown"
popupRender={(menu) => (
<>
{menu}
{isFetchingNextPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
>
{teams.map((team) => (
<Select.Option key={team.team_id} value={team.team_id}>
<span className="font-medium">{team.team_alias}</span> <Text type="secondary">({team.team_id})</Text>
</Select.Option>
))}
</Select>
<div data-testid="team-dropdown">
<PaginatedSearchSelect
options={teams.map((team) => ({
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}
/>
</div>
);
};

View file

@ -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<typeof Badge>["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({
<div className="flex items-center gap-2">
<CogIcon className="h-4 w-4 text-blue-600" />
<span className="font-semibold text-gray-900">Logging Integrations</span>
<Tag color="blue">{loggingConfigs.length}</Tag>
<Badge variant="secondary">{loggingConfigs.length}</Badge>
</div>
{loggingConfigs.length > 0 ? (
@ -88,7 +88,9 @@ export function LoggingSettingsView({
</span>
</div>
</div>
<Tag color={getEventTypeColor(config.callback_type)}>{getEventTypeLabel(config.callback_type)}</Tag>
<Badge variant={getEventTypeVariant(config.callback_type)}>
{getEventTypeLabel(config.callback_type)}
</Badge>
</div>
);
})}
@ -106,7 +108,7 @@ export function LoggingSettingsView({
<div className="flex items-center gap-2">
<BanIcon className="h-4 w-4 text-red-600" />
<span className="font-semibold text-gray-900">Disabled Callbacks</span>
<Tag color="red">{disabledCallbacks.length}</Tag>
<Badge variant="destructive">{disabledCallbacks.length}</Badge>
</div>
{disabledCallbacks.length > 0 ? (
@ -131,7 +133,7 @@ export function LoggingSettingsView({
<span className="block text-xs text-red-600">Disabled for this key</span>
</div>
</div>
<Tag color="red">Disabled</Tag>
<Badge variant="destructive">Disabled</Badge>
</div>
);
})}

View file

@ -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<ModelFiltersProps> = ({
const filtersContent = (
<div className="flex flex-wrap gap-4 items-center">
<div>
<Text className="text-sm font-medium mb-2">Search Models:</Text>
<p className="text-sm font-medium mb-2">Search Models:</p>
<input
type="text"
placeholder="Search model names..."
@ -135,7 +135,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
/>
</div>
<div>
<Text className="text-sm font-medium mb-2">Provider:</Text>
<p className="text-sm font-medium mb-2">Provider:</p>
<select
value={selectedProvider}
onChange={(e) => setSelectedProvider(e.target.value)}
@ -153,7 +153,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
</select>
</div>
<div>
<Text className="text-sm font-medium mb-2">Mode:</Text>
<p className="text-sm font-medium mb-2">Mode:</p>
<select
value={selectedMode}
onChange={(e) => setSelectedMode(e.target.value)}
@ -171,7 +171,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
</select>
</div>
<div>
<Text className="text-sm font-medium mb-2">Features:</Text>
<p className="text-sm font-medium mb-2">Features:</p>
<select
value={selectedFeature}
onChange={(e) => setSelectedFeature(e.target.value)}
@ -204,7 +204,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
);
if (showFiltersCard) {
return <Card className={`mb-6 ${className}`}>{filtersContent}</Card>;
return <Card className={`mb-6 px-6 ${className}`}>{filtersContent}</Card>;
}
return <div className={className}>{filtersContent}</div>;

View file

@ -1,7 +1,8 @@
import React, { useState, useEffect } from "react";
import { PlusCircleIcon, PencilIcon, TrashIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
import { setCallbacksCall } from "./networking";
import { Card, Title, Text, Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
import { Card, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import NotificationsManager from "./molecules/notifications_manager";
type ModelGroupAliasValue = string | { model: string; hidden?: boolean };
@ -148,10 +149,10 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
);
return (
<Card className="mb-6">
<Card className="mb-6 px-6">
<div className="flex items-center justify-between cursor-pointer" onClick={() => setIsExpanded(!isExpanded)}>
<div className="flex flex-col">
<Title className="mb-0">Model Group Alias Settings</Title>
<CardTitle className="mb-0">Model Group Alias Settings</CardTitle>
<p className="text-sm text-gray-500">
Create aliases for your model groups to simplify API calls. For example, you can create an alias
&apos;gpt-4o&apos; that points to &apos;gpt-4o-mini-openai&apos; model group.
@ -169,7 +170,7 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
{isExpanded && (
<div className="mt-4">
<div className="mb-6">
<Text className="text-sm font-medium text-gray-700 mb-2">Add New Alias</Text>
<p className="text-sm font-medium text-gray-700 mb-2">Add New Alias</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Alias Name</label>
@ -214,17 +215,17 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
</div>
</div>
<Text className="text-sm font-medium text-gray-700 mb-2">Manage Existing Aliases</Text>
<p className="text-sm font-medium text-gray-700 mb-2">Manage Existing Aliases</p>
<div className="rounded-lg custom-border relative mb-6">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
<TableHeader>
<TableRow>
<TableHeaderCell className="py-1 h-8">Alias Name</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">Target Model Group</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">Actions</TableHeaderCell>
<TableHead className="py-1 h-8">Alias Name</TableHead>
<TableHead className="py-1 h-8">Target Model Group</TableHead>
<TableHead className="py-1 h-8">Actions</TableHead>
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{aliases.map((alias) => (
<TableRow key={alias.id} className="h-8">
@ -275,8 +276,12 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
</>
) : (
<>
<TableCell className="py-0.5 text-sm text-gray-900">{alias.aliasName}</TableCell>
<TableCell className="py-0.5 text-sm text-gray-500">{alias.targetModelGroup}</TableCell>
<TableCell className="py-0.5 text-sm whitespace-normal text-gray-900">
{alias.aliasName}
</TableCell>
<TableCell className="py-0.5 text-sm whitespace-normal text-gray-500">
{alias.targetModelGroup}
</TableCell>
<TableCell className="py-0.5 whitespace-nowrap">
<div className="flex space-x-2">
<button
@ -299,7 +304,7 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
))}
{aliases.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="py-0.5 text-sm text-gray-500 text-center">
<TableCell colSpan={3} className="py-0.5 text-sm whitespace-normal text-gray-500 text-center">
No aliases added yet. Add a new alias above.
</TableCell>
</TableRow>
@ -310,11 +315,9 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
</div>
{/* Configuration Example */}
<Card>
<Title className="mb-4">Configuration Example</Title>
<Text className="text-gray-600 mb-4">
Here&apos;s how your current aliases would look in the config.yaml:
</Text>
<Card className="px-6">
<CardTitle className="mb-4">Configuration Example</CardTitle>
<p className="text-gray-600 mb-4">Here&apos;s how your current aliases would look in the config.yaml:</p>
<div className="bg-gray-100 rounded-lg p-4 font-mono text-sm">
<div className="text-gray-700">
router_settings:

View file

@ -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<NavbarProps> = ({
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"}
>
<span className="text-lg">{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}</span>
<span className="text-lg">
{sidebarCollapsed ? (
<PanelLeftOpen className="size-[18px]" />
) : (
<PanelLeftClose className="size-[18px]" />
)}
</span>
</button>
)}
@ -98,7 +104,7 @@ const Navbar: React.FC<NavbarProps> = ({
🌑
</span>
)}
<Tag className="relative z-10 cursor-pointer text-xs font-medium">
<Badge variant="outline" className="relative z-10 cursor-pointer text-xs font-medium">
<a
href="https://docs.litellm.ai/release_notes"
target="_blank"
@ -107,7 +113,7 @@ const Navbar: React.FC<NavbarProps> = ({
>
v{version}
</a>
</Tag>
</Badge>
</div>
)}
</div>
@ -138,7 +144,7 @@ const Navbar: React.FC<NavbarProps> = ({
>
Docs
{/* Layout parity with Blog chevron — intentional single-level link */}
<DownOutlined className="pointer-events-none text-[10px] opacity-0" aria-hidden />
<ChevronDown className="pointer-events-none size-2.5 opacity-0" aria-hidden />
</a>
<BlogDropdown />
</nav>

View file

@ -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}
/>
<AgentPermissions agents={agents} agentAccessGroups={agentAccessGroups} accessToken={accessToken} />
<div className="rounded-md border border-gray-100 p-4">
<Text className="text-sm font-medium text-gray-800">Search tools</Text>
<div className="min-w-0 rounded-md border border-gray-100 p-4">
<p className="text-sm font-medium text-gray-800">Search tools</p>
{searchTools.length === 0 ? (
<Text className="mt-1 block text-xs text-gray-500">
<p className="mt-1 block text-xs text-gray-500">
No restriction all configured search tools are allowed for this team.
</Text>
</p>
) : (
<Text className="mt-1 block text-xs text-gray-700">{searchTools.join(", ")}</Text>
<p className="mt-1 block text-xs break-words text-gray-700">{searchTools.join(", ")}</p>
)}
</div>
</div>
@ -56,8 +55,8 @@ export function ObjectPermissionsView({
<div className={`@container bg-white border border-gray-200 rounded-lg p-6 ${className}`}>
<div className="flex items-center gap-2 mb-6">
<div>
<Text className="font-semibold text-gray-900">Object Permissions</Text>
<Text className="text-xs text-gray-500">Access control for Vector Stores and MCP Servers</Text>
<p className="font-semibold text-gray-900">Object Permissions</p>
<p className="text-xs text-gray-500">Access control for Vector Stores and MCP Servers</p>
</div>
</div>
{content}
@ -67,7 +66,7 @@ export function ObjectPermissionsView({
return (
<div className={`${className}`}>
<Text className="font-medium text-gray-900 mb-3">Object Permissions</Text>
<p className="font-medium text-gray-900 mb-3">Object Permissions</p>
{content}
</div>
);

View file

@ -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(<Settings {...defaultProps} />);
render(<Settings {...defaultProps} />);
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(<Settings {...defaultProps} />);
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<Record<string, string>>();
return (
<FormProvider {...form}>
<CallbackSelector callbackConfigs={callbackConfigs} selectedCallback={null} onCallbackChange={vi.fn()} />
</FormProvider>
);
};
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(
<Form>
<CallbackSelector callbackConfigs={callbackConfigs} selectedCallback={null} onCallbackChange={vi.fn()} />
</Form>,
);
render(<CallbackSelectorHarness callbackConfigs={callbackConfigs} />);
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");

View file

@ -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<string, string>;
const assetsLogoFolder = "/ui/assets/logos/";
export const backendCallbackLogoSrc = (logo: string | null | undefined): string | undefined => {
@ -61,6 +58,9 @@ interface DynamicParamsFieldsProps {
}
const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callbackConfigs, selectedCallback }) => {
const { register, formState } = useFormContext<CallbackFormValues>();
const fieldIdPrefix = React.useId();
if (!params || params.length === 0) {
return null;
}
@ -73,54 +73,51 @@ const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ 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 (
<FormItem
label={<span className="text-sm font-medium text-gray-700">{fieldLabel} </span>}
name={param}
key={param}
className="mb-4"
rules={
isRequired
? [
{
required: true,
message: `Please enter the ${fieldLabel.toLowerCase()}`,
},
]
: undefined
}
>
<Field key={param} className="mb-4">
<FieldLabel htmlFor={fieldId}>
<span className="text-sm font-medium text-gray-700">{fieldLabel} </span>
</FieldLabel>
{paramType === "password" ? (
<Input.Password
size="large"
<Input
id={fieldId}
type="password"
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
className="w-full rounded-md border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500"
{...registration}
/>
) : paramType === "number" ? (
<Input
id={fieldId}
type="number"
size="large"
placeholder={`Enter ${fieldLabel.toLowerCase()}`}
className="w-full rounded-md border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500"
min={0}
max={1}
step={0.1}
{...registration}
/>
) : (
<Input
size="large"
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
className="w-full rounded-md border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500"
/>
<Input id={fieldId} placeholder={`Enter your ${fieldLabel.toLowerCase()}`} {...registration} />
)}
</FormItem>
<FieldError errors={[formState.errors[param]]} />
</Field>
);
})}
</div>
);
};
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<CallbackSelectorProps> = ({
onCallbackChange,
disabled = false,
}) => {
const { control } = useFormContext<CallbackFormValues>();
const inputId = React.useId();
const selectedConfig = callbackConfigs.find((config) => config.id === selectedCallback) ?? null;
return (
<FormItem
label="Callback"
<Controller
control={control}
name="callback"
rules={disabled ? undefined : [{ required: true, message: "Please select a callback" }]}
>
<Select
placeholder="Choose a logging callback..."
size="large"
className="w-full"
showSearch
disabled={disabled}
value={selectedCallback}
filterOption={(input, option) => {
return (option?.value?.toString() ?? "").toLowerCase().includes(input.toLowerCase());
}}
onChange={onCallbackChange}
>
{callbackConfigs.map((callbackConfig) => {
return (
<SelectItem key={callbackConfig.id} value={callbackConfig.id}>
<div className="flex items-center space-x-3 py-1">
<div className="w-6 h-6 flex items-center justify-center">
<Logo
src={backendCallbackLogoSrc(callbackConfig.logo)}
label={callbackConfig.displayName}
className="w-6 h-6 rounded-sm object-contain"
/>
</div>
<span className="font-medium text-gray-900">{callbackConfig.displayName}</span>
</div>
</SelectItem>
);
})}
</Select>
</FormItem>
rules={disabled ? undefined : { required: "Please select a callback" }}
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor={inputId}>Callback</FieldLabel>
<Combobox
items={callbackConfigs}
value={selectedConfig}
onValueChange={(config: CallbackConfigOption | null) => {
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}
>
<ComboboxInput
id={inputId}
placeholder="Choose a logging callback..."
className="w-full"
disabled={disabled}
onBlur={field.onBlur}
aria-invalid={fieldState.error !== undefined || undefined}
/>
<ComboboxContent>
<ComboboxEmpty>No results</ComboboxEmpty>
<ComboboxList>
{(callbackConfig: CallbackConfigOption) => (
<ComboboxItem key={callbackConfig.id} value={callbackConfig}>
<div className="flex items-center space-x-3 py-1">
<div className="w-6 h-6 flex items-center justify-center">
<Logo
src={backendCallbackLogoSrc(callbackConfig.logo)}
label={callbackConfig.displayName}
className="w-6 h-6 rounded-sm object-contain"
/>
</div>
<span className="font-medium text-gray-900">{callbackConfig.displayName}</span>
</div>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
);
};
@ -206,8 +225,8 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
const [callbacks, setCallbacks] = useState<AlertingObject[]>([]);
const [isLoadingCallbacks, setIsLoadingCallbacks] = useState(true);
const [alerts, setAlerts] = useState<any[]>([]);
const [addForm] = Form.useForm();
const [editForm] = Form.useForm();
const addForm = useForm<CallbackFormValues>({ shouldUnregister: true });
const editForm = useForm<CallbackFormValues>({ shouldUnregister: true });
const [selectedCallback, setSelectedCallback] = useState<string | null>(null);
const [catchAllWebhookURL, setCatchAllWebhookURL] = useState<string>("");
const [alertToWebhooks, setAlertToWebhooks] = useState<Record<string, string>>({});
@ -254,7 +273,7 @@ const Settings: React.FC<SettingsPageProps> = ({ 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<SettingsPageProps> = ({ 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<SettingsPageProps> = ({ 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<SettingsPageProps> = ({ accessToken, userRole, userID,
return (
<div className="mx-4">
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
<TabGroup>
<TabList variant="line" defaultValue="1">
<Tab value="1">Logging Callbacks</Tab>
<Tab value="2">CloudZero Cost Tracking</Tab>
<Tab value="2">Alerting Types</Tab>
<Tab value="3">Alerting Settings</Tab>
<Tab value="4">Email Alerts</Tab>
</TabList>
<TabPanels>
<TabPanel>
<LoggingCallbacksTable
callbacks={callbacks}
availableCallbacks={allCallbacks}
isLoading={isLoadingCallbacks}
onAdd={() => 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));
}
}}
/>
</TabPanel>
<TabPanel>
<div className="p-8">
<CloudZeroCostTracking />
</div>
</TabPanel>
<TabPanel>
<Card>
<Text className="my-2">
Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "}
<a href="https://api.slack.com/messaging/webhooks" target="_blank" style={{ color: "blue" }}>
here
</a>
</Text>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell></TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
<TableHeaderCell>Slack Webhook URL</TableHeaderCell>
</TableRow>
</TableHead>
<div className="grid grid-cols-1 gap-2 p-8 w-full mt-2">
<Tabs defaultValue="logging-callbacks">
<TabsList variant="line">
<TabsTrigger value="logging-callbacks">Logging Callbacks</TabsTrigger>
<TabsTrigger value="cloudzero-cost-tracking">CloudZero Cost Tracking</TabsTrigger>
<TabsTrigger value="alerting-types">Alerting Types</TabsTrigger>
<TabsTrigger value="alerting-settings">Alerting Settings</TabsTrigger>
<TabsTrigger value="email-alerts">Email Alerts</TabsTrigger>
</TabsList>
<TabsContent value="logging-callbacks">
<LoggingCallbacksTable
callbacks={callbacks}
availableCallbacks={allCallbacks}
isLoading={isLoadingCallbacks}
onAdd={() => 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));
}
}}
/>
</TabsContent>
<TabsContent value="cloudzero-cost-tracking">
<div className="p-8">
<CloudZeroCostTracking />
</div>
</TabsContent>
<TabsContent value="alerting-types">
<Card className="p-6">
<p className="my-2">
Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "}
<a href="https://api.slack.com/messaging/webhooks" target="_blank" style={{ color: "blue" }}>
here
</a>
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Slack Webhook URL</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(alerts_to_UI_NAME).map(([key, value], index) => (
<TableRow key={index}>
<TableCell>
{key == "region_outage_alerts" ? (
premiumUser ? (
<Switch
id="switch"
name="switch"
checked={isAlertOn(key)}
onChange={() => handleSwitchChange(key)}
/>
) : (
<Button className="flex items-center justify-center">
<a href="https://forms.gle/W3U4PZpJGFHWtHyA9" target="_blank">
Enterprise Feature
</a>
</Button>
)
) : (
<TableBody>
{Object.entries(alerts_to_UI_NAME).map(([key, value], index) => (
<TableRow key={index}>
<TableCell>
{key == "region_outage_alerts" ? (
premiumUser ? (
<Switch
id="switch"
name="switch"
checked={isAlertOn(key)}
onChange={() => handleSwitchChange(key)}
onCheckedChange={() => handleSwitchChange(key)}
/>
)}
</TableCell>
<TableCell>
<Text>{value}</Text>
</TableCell>
<TableCell>
<TextInput
name={key}
type="password"
defaultValue={
alertToWebhooks && alertToWebhooks[key]
? alertToWebhooks[key]
: (catchAllWebhookURL as string)
}
></TextInput>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Button size="xs" className="mt-2" onClick={handleSaveAlerts}>
Save Changes
</Button>
) : (
<Button className="flex items-center justify-center">
<a href="https://forms.gle/W3U4PZpJGFHWtHyA9" target="_blank">
Enterprise Feature
</a>
</Button>
)
) : (
<Switch
id="switch"
name="switch"
checked={isAlertOn(key)}
onCheckedChange={() => handleSwitchChange(key)}
/>
)}
</TableCell>
<TableCell className="whitespace-normal break-words">
<p>{value}</p>
</TableCell>
<TableCell>
<Input
name={key}
type="password"
defaultValue={
alertToWebhooks && alertToWebhooks[key]
? alertToWebhooks[key]
: (catchAllWebhookURL as string)
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Button size="xs" className="mt-2" onClick={handleSaveAlerts}>
Save Changes
</Button>
<Button
onClick={async () => {
try {
await serviceHealthCheck(accessToken, "slack");
NotificationsManager.success(
"Alert test triggered. Test request to slack made - check logs/alerts on slack to verify",
);
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
}}
className="mx-2"
>
Test Alerts
</Button>
</Card>
</TabPanel>
<TabPanel>
<AlertingSettings accessToken={accessToken} premiumUser={premiumUser} />
</TabPanel>
<TabPanel>
<EmailSettings accessToken={accessToken} premiumUser={premiumUser} alerts={alerts} />
</TabPanel>
</TabPanels>
</TabGroup>
</Grid>
<Button
onClick={async () => {
try {
await serviceHealthCheck(accessToken, "slack");
NotificationsManager.success(
"Alert test triggered. Test request to slack made - check logs/alerts on slack to verify",
);
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
}}
className="mx-2"
>
Test Alerts
</Button>
</Card>
</TabsContent>
<TabsContent value="alerting-settings">
<AlertingSettings accessToken={accessToken} premiumUser={premiumUser} />
</TabsContent>
<TabsContent value="email-alerts">
<EmailSettings accessToken={accessToken} premiumUser={premiumUser} alerts={alerts} />
</TabsContent>
</Tabs>
</div>
<Modal
title="Add Logging Callback"
open={showAddCallbacksModal}
width={800}
onCancel={() => {
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setSelectedCallbackParams([]);
}}
footer={null}
>
<a
href="https://docs.litellm.ai/docs/proxy/logging"
className="mb-8 mt-4"
target="_blank"
style={{ color: "blue" }}
>
{" "}
LiteLLM Docs: Logging
</a>
<Dialog open={showAddCallbacksModal} onOpenChange={(open) => !open && closeAddCallbackModal()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>Add Logging Callback</DialogTitle>
</DialogHeader>
<a
href="https://docs.litellm.ai/docs/proxy/logging"
className="mb-8 mt-4"
target="_blank"
style={{ color: "blue" }}
>
{" "}
LiteLLM Docs: Logging
</a>
<Form
form={addForm}
onFinish={addNewCallbackCall}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<CallbackSelector
callbackConfigs={callbackConfigs}
selectedCallback={selectedCallback}
onCallbackChange={handleSelectedCallbackChange}
/>
<DynamicParamsFields
params={selectedCallbackParams}
callbackConfigs={callbackConfigs}
selectedCallback={selectedCallback}
/>
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
<Button2
onClick={() => {
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setSelectedCallbackParams([]);
addForm.resetFields();
}}
disabled={isAddingCallback}
>
Cancel
</Button2>
<Button2 htmlType="submit" loading={isAddingCallback} disabled={isAddingCallback}>
{isAddingCallback ? "Adding..." : "Add Callback"}
</Button2>
</div>
</Form>
</Modal>
<Modal
open={showEditCallback}
width={800}
title={"Edit Callback Settings"}
onCancel={() => {
setShowEditCallback(false);
setSelectedEditCallback(null);
editForm.resetFields();
}}
footer={null}
>
<Form
form={editForm}
onFinish={updateCallbackCall}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
{selectedEditCallback && (
<>
<FormProvider {...addForm}>
<form onSubmit={addForm.handleSubmit(addNewCallbackCall)}>
<CallbackSelector
callbackConfigs={callbackConfigs}
selectedCallback={selectedEditCallback.name}
onCallbackChange={() => {}}
disabled={true}
selectedCallback={selectedCallback}
onCallbackChange={handleSelectedCallbackChange}
/>
<DynamicParamsFields
params={getDynamicParamsForCallback(
selectedEditCallback.name,
callbackConfigs,
selectedEditCallback.variables,
)}
params={selectedCallbackParams}
callbackConfigs={callbackConfigs}
selectedCallback={selectedEditCallback.name}
selectedCallback={selectedCallback}
/>
</>
)}
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
<Button2
onClick={() => {
setShowEditCallback(false);
setSelectedEditCallback(null);
editForm.resetFields();
}}
disabled={isUpdatingCallback}
>
Cancel
</Button2>
<Button2
onClick={() => {
editForm.submit();
}}
loading={isUpdatingCallback}
disabled={isUpdatingCallback}
>
{isUpdatingCallback ? "Saving..." : "Save Changes"}
</Button2>
</div>
</Form>
</Modal>
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
<Button type="button" variant="outline" onClick={cancelAddCallback} disabled={isAddingCallback}>
Cancel
</Button>
<Button type="submit" disabled={isAddingCallback}>
{isAddingCallback ? "Adding..." : "Add Callback"}
</Button>
</div>
</form>
</FormProvider>
</DialogContent>
</Dialog>
<Dialog open={showEditCallback} onOpenChange={(open) => !open && closeEditCallbackModal()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
<DialogHeader>
<DialogTitle>Edit Callback Settings</DialogTitle>
</DialogHeader>
<FormProvider {...editForm}>
<form onSubmit={editForm.handleSubmit(updateCallbackCall)}>
{selectedEditCallback && (
<>
<CallbackSelector
callbackConfigs={callbackConfigs}
selectedCallback={selectedEditCallback.name}
onCallbackChange={() => {}}
disabled={true}
/>
<DynamicParamsFields
params={getDynamicParamsForCallback(
selectedEditCallback.name,
callbackConfigs,
selectedEditCallback.variables,
)}
callbackConfigs={callbackConfigs}
selectedCallback={selectedEditCallback.name}
/>
</>
)}
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
<Button type="button" variant="outline" onClick={closeEditCallbackModal} disabled={isUpdatingCallback}>
Cancel
</Button>
<Button type="submit" disabled={isUpdatingCallback}>
{isUpdatingCallback ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
</FormProvider>
</DialogContent>
</Dialog>
<DeleteResourceModal
isOpen={showDeleteConfirmModal}

View file

@ -21,6 +21,13 @@ describe("SearchSelect", () => {
expect(screen.getByRole("combobox")).toHaveValue("Growth");
});
it("shows a value the options do not carry yet instead of blanking the field", () => {
const { rerender } = render(<SearchSelect options={[]} value="team-2" onValueChange={vi.fn()} />);
expect(screen.getByRole("combobox")).toHaveValue("team-2");
rerender(<SearchSelect options={OPTIONS} value="team-2" onValueChange={vi.fn()} />);
expect(screen.getByRole("combobox")).toHaveValue("Growth");
});
it("shows a clear control only when a value is selected", () => {
const { rerender } = render(<SearchSelect options={OPTIONS} onValueChange={vi.fn()} />);
expect(document.querySelector('[data-slot="combobox-clear"]')).toBeNull();

View file

@ -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 (
<Combobox
items={options}
items={items}
value={selected}
onValueChange={(item: SearchSelectOption | null) => onValueChange(item?.value ?? "")}
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
@ -54,6 +61,7 @@ export function SearchSelect({
disabled={disabled}
>
<ComboboxInput
id={inputId}
placeholder={placeholder}
showClear={value != null && value !== ""}
className={`h-8 w-full text-sm ${className ?? ""}`}

View file

@ -989,9 +989,8 @@ describe("TeamInfoView", () => {
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(() => {

View file

@ -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");
});
});
});

View file

@ -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<UserDashboardProps> = ({
return (
<div className="mx-4 h-[75vh]">
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
<Col numColSpan={1} className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2 p-8 w-full mt-2">
<div className="col-span-1 flex flex-col gap-2">
<VirtualKeysTable
headerActions={
canCreateKey ? (
@ -235,8 +234,8 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
) : undefined
}
/>
</Col>
</Grid>
</div>
</div>
</div>
);
};

View file

@ -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<CostBreakdownViewerProps> = ({
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<CostBreakdownViewerProps> = ({
return (
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
<Collapse
expandIconPosition="start"
items={[
{
key: "1",
label: (
<div className="flex items-center justify-between w-full">
<h3 className="text-lg font-medium text-gray-900">Cost Breakdown</h3>
<div className="flex items-center space-x-2 mr-4">
<span className="text-sm text-gray-500">Total:</span>
<span className="text-sm font-semibold text-gray-900">
{formatCost(totalSpend)}
{isCached && " (Cached)"}
</span>
</div>
</div>
),
children: (
<div className="p-6 space-y-4">
{/* Step 1: Base Token Costs */}
<div className="space-y-2 max-w-2xl">
{(() => {
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 (
<>
<div className="flex text-sm">
<span className="text-gray-600 font-medium w-1/3">Input Cost:</span>
<span className="text-gray-900">
{formatCost(rawCost)}
{rawInputTokens !== undefined && rawInputTokens !== null && (
<span className="text-gray-500 font-normal ml-1">
({rawInputTokens.toLocaleString()} tokens)
</span>
)}
</span>
</div>
{(costBreakdown?.cache_read_cost ?? 0) > 0 && (
<div className="flex text-sm">
<span className="text-gray-600 font-medium w-1/3">Prompt Cache Read Cost:</span>
<span className="text-gray-900">
{formatCost(isCached ? 0 : costBreakdown?.cache_read_cost)}
{(cacheReadTokens ?? 0) > 0 && (
<span className="text-gray-500 font-normal ml-1">
({(cacheReadTokens ?? 0).toLocaleString()} tokens)
</span>
)}
</span>
</div>
)}
{(costBreakdown?.cache_creation_cost ?? 0) > 0 && (
<div className="flex text-sm">
<span className="text-gray-600 font-medium w-1/3">Prompt Cache Write Cost:</span>
<span className="text-gray-900">
{formatCost(isCached ? 0 : costBreakdown?.cache_creation_cost)}
{(cacheCreationTokens ?? 0) > 0 && (
<span className="text-gray-500 font-normal ml-1">
({(cacheCreationTokens ?? 0).toLocaleString()} tokens)
</span>
)}
</span>
</div>
)}
</>
);
}
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left">
{open ? (
<ChevronDown className="size-3.5 shrink-0 text-gray-500" />
) : (
<ChevronRight className="size-3.5 shrink-0 text-gray-500" />
)}
<div className="flex items-center justify-between w-full">
<h3 className="text-lg font-medium text-gray-900">Cost Breakdown</h3>
<div className="flex items-center space-x-2 mr-4">
<span className="text-sm text-gray-500">Total:</span>
<span className="text-sm font-semibold text-gray-900">
{formatCost(totalSpend)}
{isCached && " (Cached)"}
</span>
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-6 space-y-4">
{/* Step 1: Base Token Costs */}
<div className="space-y-2 max-w-2xl">
{(() => {
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 (
<>
<div className="flex text-sm">
<span className="text-gray-600 font-medium w-1/3">Input Cost:</span>
<span className="text-gray-900">
{formatCost(inputCost)}
{promptTokens !== undefined && (
{formatCost(rawCost)}
{rawInputTokens !== undefined && rawInputTokens !== null && (
<span className="text-gray-500 font-normal ml-1">
({promptTokens.toLocaleString()} prompt tokens)
({rawInputTokens.toLocaleString()} tokens)
</span>
)}
</span>
</div>
);
})()}
{(costBreakdown?.cache_read_cost ?? 0) > 0 && (
<div className="flex text-sm">
<span className="text-gray-600 font-medium w-1/3">Prompt Cache Read Cost:</span>
<span className="text-gray-900">
{formatCost(isCached ? 0 : costBreakdown?.cache_read_cost)}
{(cacheReadTokens ?? 0) > 0 && (
<span className="text-gray-500 font-normal ml-1">
({(cacheReadTokens ?? 0).toLocaleString()} tokens)
</span>
)}
</span>
</div>
)}
{(costBreakdown?.cache_creation_cost ?? 0) > 0 && (
<div className="flex text-sm">
<span className="text-gray-600 font-medium w-1/3">Prompt Cache Write Cost:</span>
<span className="text-gray-900">
{formatCost(isCached ? 0 : costBreakdown?.cache_creation_cost)}
{(cacheCreationTokens ?? 0) > 0 && (
<span className="text-gray-500 font-normal ml-1">
({(cacheCreationTokens ?? 0).toLocaleString()} tokens)
</span>
)}
</span>
</div>
)}
</>
);
}
return (
<div className="flex text-sm">
<span className="text-gray-600 font-medium w-1/3">Output Cost:</span>
<span className="text-gray-600 font-medium w-1/3">Input Cost:</span>
<span className="text-gray-900">
{formatCost(outputCost)}
{completionTokens !== undefined && (
{formatCost(inputCost)}
{promptTokens !== undefined && (
<span className="text-gray-500 font-normal ml-1">
({completionTokens.toLocaleString()} completion tokens)
({promptTokens.toLocaleString()} prompt tokens)
</span>
)}
</span>
</div>
{costBreakdown?.tool_usage_cost !== undefined && costBreakdown.tool_usage_cost > 0 && (
<div className="flex text-sm">
<span className="text-gray-600 font-medium w-1/3">Tool Usage Cost:</span>
<span className="text-gray-900">{formatCost(costBreakdown.tool_usage_cost)}</span>
</div>
)}
{costBreakdown?.additional_costs &&
Object.entries(costBreakdown.additional_costs)
.filter(([, value]) => value != null && value !== 0)
.map(([key, value]) => (
<div key={key} className="flex text-sm">
<span className="text-gray-600 font-medium w-1/3">{key}:</span>
<span className="text-gray-900">{formatCost(value)}</span>
</div>
))}
</div>
{/* Subtotal / Original Cost - hide when cached since it would be $0 */}
{!isCached && (
<div className="pt-2 border-t border-gray-100 max-w-2xl">
<div className="flex text-sm font-semibold">
<span className="text-gray-900 w-1/3">Original LLM Cost:</span>
<span className="text-gray-900">{formatCost(originalCost)}</span>
</div>
</div>
)}
{/* Step 2: Adjustments (Discount & Margin) */}
{(hasDiscount || hasMargin) && (
<div className="pt-2 space-y-2 max-w-2xl">
{/* Discounts */}
{hasDiscount && (
<div className="space-y-2">
{costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0 && (
<div className="flex text-sm text-gray-600">
<span className="font-medium w-1/3">
Discount ({formatPercent(costBreakdown.discount_percent)}):
</span>
<span className="text-gray-900">-{formatCost(costBreakdown.discount_amount)}</span>
</div>
)}
{costBreakdown.discount_amount !== undefined &&
costBreakdown.discount_percent === undefined && (
<div className="flex text-sm text-gray-600">
<span className="font-medium w-1/3">Discount Amount:</span>
<span className="text-gray-900">-{formatCost(costBreakdown.discount_amount)}</span>
</div>
)}
</div>
)}
{/* Margins */}
{hasMargin && (
<div className="space-y-2">
{costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0 && (
<div className="flex text-sm text-gray-600">
<span className="font-medium w-1/3">
Margin ({formatPercent(costBreakdown.margin_percent)}):
</span>
<span className="text-gray-900">
+
{formatCost(
(costBreakdown.margin_total_amount || 0) - (costBreakdown.margin_fixed_amount || 0),
)}
</span>
</div>
)}
{costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0 && (
<div className="flex text-sm text-gray-600">
<span className="font-medium w-1/3">Margin:</span>
<span className="text-gray-900">+{formatCost(costBreakdown.margin_fixed_amount)}</span>
</div>
)}
</div>
)}
</div>
)}
{/* Final Summary */}
<div className="mt-4 pt-4 border-t border-gray-200 max-w-2xl">
<div className="flex items-center">
<span className="font-bold text-sm text-gray-900 w-1/3">Final Calculated Cost:</span>
<span className="text-sm font-bold text-gray-900">
{formatCost(totalCost)}
{isCached && " (Cached)"}
);
})()}
<div className="flex text-sm">
<span className="text-gray-600 font-medium w-1/3">Output Cost:</span>
<span className="text-gray-900">
{formatCost(outputCost)}
{completionTokens !== undefined && (
<span className="text-gray-500 font-normal ml-1">
({completionTokens.toLocaleString()} completion tokens)
</span>
</div>
)}
</span>
</div>
{costBreakdown?.tool_usage_cost !== undefined && costBreakdown.tool_usage_cost > 0 && (
<div className="flex text-sm">
<span className="text-gray-600 font-medium w-1/3">Tool Usage Cost:</span>
<span className="text-gray-900">{formatCost(costBreakdown.tool_usage_cost)}</span>
</div>
)}
{costBreakdown?.additional_costs &&
Object.entries(costBreakdown.additional_costs)
.filter(([, value]) => value != null && value !== 0)
.map(([key, value]) => (
<div key={key} className="flex text-sm">
<span className="text-gray-600 font-medium w-1/3">{key}:</span>
<span className="text-gray-900">{formatCost(value)}</span>
</div>
))}
</div>
{/* Subtotal / Original Cost - hide when cached since it would be $0 */}
{!isCached && (
<div className="pt-2 border-t border-gray-100 max-w-2xl">
<div className="flex text-sm font-semibold">
<span className="text-gray-900 w-1/3">Original LLM Cost:</span>
<span className="text-gray-900">{formatCost(originalCost)}</span>
</div>
</div>
),
},
]}
/>
)}
{/* Step 2: Adjustments (Discount & Margin) */}
{(hasDiscount || hasMargin) && (
<div className="pt-2 space-y-2 max-w-2xl">
{/* Discounts */}
{hasDiscount && (
<div className="space-y-2">
{costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0 && (
<div className="flex text-sm text-gray-600">
<span className="font-medium w-1/3">
Discount ({formatPercent(costBreakdown.discount_percent)}):
</span>
<span className="text-gray-900">-{formatCost(costBreakdown.discount_amount)}</span>
</div>
)}
{costBreakdown.discount_amount !== undefined && costBreakdown.discount_percent === undefined && (
<div className="flex text-sm text-gray-600">
<span className="font-medium w-1/3">Discount Amount:</span>
<span className="text-gray-900">-{formatCost(costBreakdown.discount_amount)}</span>
</div>
)}
</div>
)}
{/* Margins */}
{hasMargin && (
<div className="space-y-2">
{costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0 && (
<div className="flex text-sm text-gray-600">
<span className="font-medium w-1/3">
Margin ({formatPercent(costBreakdown.margin_percent)}):
</span>
<span className="text-gray-900">
+
{formatCost(
(costBreakdown.margin_total_amount || 0) - (costBreakdown.margin_fixed_amount || 0),
)}
</span>
</div>
)}
{costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0 && (
<div className="flex text-sm text-gray-600">
<span className="font-medium w-1/3">Margin:</span>
<span className="text-gray-900">+{formatCost(costBreakdown.margin_fixed_amount)}</span>
</div>
)}
</div>
)}
</div>
)}
{/* Final Summary */}
<div className="mt-4 pt-4 border-t border-gray-200 max-w-2xl">
<div className="flex items-center">
<span className="font-bold text-sm text-gray-900 w-1/3">Final Calculated Cost:</span>
<span className="text-sm font-bold text-gray-900">
{formatCost(totalCost)}
{isCached && " (Cached)"}
</span>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};

View file

@ -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 (
<div className="mb-6">
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<ExperimentOutlined style={{ fontSize: 16, color: "#6366f1" }} />
<Text strong style={{ fontSize: 15 }}>
<FlaskConical className="size-4" style={{ color: "#6366f1" }} />
<span className="font-semibold" style={{ fontSize: 15 }}>
LLM Judge Results
</Text>
</span>
</div>
{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) => (
<Text strong style={{ whiteSpace: "nowrap" }}>
{v}
</Text>
),
},
{
title: "Weight",
dataIndex: "weight",
key: "weight",
width: 65,
render: (v: number) =>
v != null ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{v}%
</Text>
) : null,
},
{
title: "Score",
dataIndex: "score",
key: "score",
width: 65,
render: (v: number) => (
<Text style={{ color: v >= 70 ? "#52c41a" : v >= 50 ? "#faad14" : "#ff4d4f", fontWeight: 600 }}>{v}</Text>
),
},
{
title: (
<Tooltip title="Score × Weight — how much each criterion contributes to the final score">
<span style={{ borderBottom: "1px dashed #aaa", cursor: "help" }}>Weighted</span>
</Tooltip>
),
key: "weighted",
width: 75,
render: (_: unknown, row: EvalVerdict) => {
if (row.weight == null) return null;
const contrib = (row.score * row.weight) / 100;
return (
<Text type="secondary" style={{ fontSize: 12 }}>
{contrib % 1 === 0 ? contrib : contrib.toFixed(1)}
</Text>
);
},
},
{
title: "Comment",
dataIndex: "reasoning",
key: "reasoning",
ellipsis: { showTitle: false },
render: (v: string) => (
<Tooltip title={v}>
<span style={{ fontSize: 12 }}>{v}</span>
</Tooltip>
),
},
];
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 (
<Card
size="small"
className="mb-3"
style={{ borderLeft: `3px solid ${scoreColor}` }}
title={
<Space>
{passed ? (
<CheckCircleOutlined style={{ color: "#52c41a" }} />
) : (
<CloseCircleOutlined style={{ color: "#ff4d4f" }} />
)}
<Text strong>{entry.eval_name}</Text>
<Tag color={passed ? "success" : "error"}>{passed ? "PASSED" : "FAILED"}</Tag>
<Tooltip
title={`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.`}
>
<Text type="secondary" style={{ fontSize: 12, cursor: "help", borderBottom: "1px dashed #aaa" }}>
{entry.overall_score?.toFixed(0)} / 100
{entry.threshold != null && ` (threshold: ${entry.threshold})`}
</Text>
</Tooltip>
</Space>
}
extra={
<Space size="small">
{entry.judge_model && (
<Text type="secondary" style={{ fontSize: 12 }}>
Judge: {entry.judge_model}
</Text>
)}
{entry.iteration != null && (
<Text type="secondary" style={{ fontSize: 12 }}>
Iter: {entry.iteration + 1}
</Text>
)}
</Space>
}
>
{entry.eval_error && (
<Text type="warning" style={{ display: "block", marginBottom: 8, fontSize: 12 }}>
Judge error: {entry.eval_error}
</Text>
)}
<Card size="sm" className="mb-3" style={{ borderLeft: `3px solid ${scoreColor}` }}>
<CardHeader>
<CardTitle>
<div className="flex flex-wrap items-center gap-2">
{passed ? (
<CircleCheck className="size-4" style={{ color: "#52c41a" }} />
) : (
<CircleX className="size-4" style={{ color: "#ff4d4f" }} />
)}
<span className="font-semibold">{entry.eval_name}</span>
<Badge variant={passed ? "secondary" : "destructive"}>{passed ? "PASSED" : "FAILED"}</Badge>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span
className="text-muted-foreground"
style={{ fontSize: 12, cursor: "help", borderBottom: "1px dashed #aaa" }}
/>
}
>
{entry.overall_score?.toFixed(0)} / 100
{entry.threshold != null && ` (threshold: ${entry.threshold})`}
</TooltipTrigger>
<TooltipContent>
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.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardTitle>
<CardAction>
<div className="flex items-center gap-2">
{entry.judge_model && (
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
Judge: {entry.judge_model}
</span>
)}
{entry.iteration != null && (
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
Iter: {entry.iteration + 1}
</span>
)}
</div>
</CardAction>
</CardHeader>
{verdicts.length > 0 ? (
<Table
dataSource={verdicts}
columns={columns}
pagination={false}
size="small"
rowKey="criterion_name"
scroll={{ x: true }}
summary={() => {
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 (
<Table.Summary.Row>
<Table.Summary.Cell index={0}>
<Text strong style={{ fontSize: 12 }}>
Total
</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={1} />
<Table.Summary.Cell index={2} />
<Table.Summary.Cell index={3}>
<Text strong style={{ fontSize: 12, color: scoreColor }}>
{total % 1 === 0 ? total : total.toFixed(1)}
</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={4} />
</Table.Summary.Row>
);
}}
/>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
Score: {entry.overall_score?.toFixed(1)} no per-criterion breakdown available.
</Text>
)}
<CardContent>
{entry.eval_error && (
<span className="text-amber-600" style={{ display: "block", marginBottom: 8, fontSize: 12 }}>
Judge error: {entry.eval_error}
</span>
)}
{verdicts.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead style={{ width: 160 }}>Criterion</TableHead>
<TableHead style={{ width: 65 }}>Weight</TableHead>
<TableHead style={{ width: 65 }}>Score</TableHead>
<TableHead style={{ width: 75 }}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span style={{ borderBottom: "1px dashed #aaa", cursor: "help" }} />}>
Weighted
</TooltipTrigger>
<TooltipContent>
Score × Weight how much each criterion contributes to the final score
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableHead>
<TableHead>Comment</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{verdicts.map((row) => {
const contrib = row.weight != null ? (row.score * row.weight) / 100 : null;
return (
<TableRow key={row.criterion_name}>
<TableCell>
<span className="font-semibold" style={{ whiteSpace: "nowrap" }}>
{row.criterion_name}
</span>
</TableCell>
<TableCell>
{row.weight != null ? (
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
{row.weight}%
</span>
) : null}
</TableCell>
<TableCell>
<span
style={{
color: row.score >= 70 ? "#52c41a" : row.score >= 50 ? "#faad14" : "#ff4d4f",
fontWeight: 600,
}}
>
{row.score}
</span>
</TableCell>
<TableCell>
{contrib != null ? (
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
{contrib % 1 === 0 ? contrib : contrib.toFixed(1)}
</span>
) : null}
</TableCell>
<TableCell>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span style={{ fontSize: 12 }} />}>{row.reasoning}</TooltipTrigger>
<TooltipContent>{row.reasoning}</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
</TableRow>
);
})}
</TableBody>
{hasWeights && (
<TableFooter>
<TableRow>
<TableCell>
<span className="font-semibold" style={{ fontSize: 12 }}>
Total
</span>
</TableCell>
<TableCell />
<TableCell />
<TableCell>
<span className="font-semibold" style={{ fontSize: 12, color: scoreColor }}>
{weightedTotal % 1 === 0 ? weightedTotal : weightedTotal.toFixed(1)}
</span>
</TableCell>
<TableCell />
</TableRow>
</TableFooter>
)}
</Table>
) : (
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
Score: {entry.overall_score?.toFixed(1)} no per-criterion breakdown available.
</span>
)}
</CardContent>
</Card>
);
}

View file

@ -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 ? (
<SpinnerIcon />
) : error ? (
<Tooltip title={error}>
<span className="text-gray-400 text-sm">--</span>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span className="text-gray-400 text-sm" />}>--</TooltipTrigger>
<TooltipContent>{error}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : data?.compliant ? (
<CheckIcon />
) : (

View file

@ -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 && (
<Tooltip title={`Risk score: ${riskScore}/10`}>
<span
className={`px-2 py-0.5 border rounded-sm text-[11px] font-semibold shrink-0 ${getRiskColor(riskScore)}`}
>
Risk {riskScore}/10
</span>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span
className={`px-2 py-0.5 border rounded-sm text-[11px] font-semibold shrink-0 ${getRiskColor(riskScore)}`}
/>
}
>
Risk {riskScore}/10
</TooltipTrigger>
<TooltipContent>{`Risk score: ${riskScore}/10`}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>

View file

@ -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 (
<Space size={SPACING_MEDIUM} style={{ marginBottom: SPACING_MEDIUM }}>
<div className="flex items-center gap-2" style={{ marginBottom: SPACING_MEDIUM }}>
{providerLogo && (
<img
src={providerLogo}
@ -108,19 +107,19 @@ function ModelProviderSection({
}}
/>
)}
<Space size={SPACING_MEDIUM} direction="horizontal">
<Text strong style={{ fontSize: 14 }}>
<div className="flex items-center gap-2">
<span className="font-semibold" style={{ fontSize: 14 }}>
{model}
</Text>
</span>
{providerName && (
<Text type="secondary" style={{ fontSize: 12 }}>
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
{providerName}
</Text>
</span>
)}
<AutoRouterTag modelGroup={modelGroup} />
<ClassifyTag origin={internalCallOrigin} />
</Space>
</Space>
</div>
</div>
);
}
@ -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 (
<div style={{ flex: 1, minWidth: 0 }}>
<Tooltip title={requestId}>
<Text
strong
copyable={{ text: requestId, tooltips: ["Copy Request ID", "Copied!"] }}
style={{
fontSize: FONT_SIZE_HEADER,
fontFamily: FONT_FAMILY_MONO,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "block",
}}
>
{requestId}
</Text>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span
className="font-semibold"
style={{
fontSize: FONT_SIZE_HEADER,
fontFamily: FONT_FAMILY_MONO,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "block",
}}
/>
}
>
{requestId}
<button
type="button"
aria-label={copied ? "Copied!" : "Copy Request ID"}
onClick={handleCopy}
className="ml-1 align-middle text-muted-foreground hover:text-foreground"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
</TooltipTrigger>
<TooltipContent>{requestId}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
@ -172,21 +197,29 @@ function NavigationSection({
marginLeft: 4,
background: "#fafafa",
};
const splitStyle = { width: 1, height: 20, background: COLOR_BORDER };
return (
<Space size={SPACING_SMALL} split={<div style={{ width: 1, height: 20, background: COLOR_BORDER }} />}>
<Button type="text" size="small" onClick={onPrevious}>
<UpOutlined />
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={onPrevious}>
<ChevronUp className="size-4" />
<span style={keyboardShortcutStyle}>K</span>
</Button>
<Button type="text" size="small" onClick={onNext}>
<DownOutlined />
<div style={splitStyle} />
<Button variant="ghost" size="sm" onClick={onNext}>
<ChevronDown className="size-4" />
<span style={keyboardShortcutStyle}>J</span>
</Button>
<Tooltip title="ESC to close">
<Button type="text" icon={<CloseOutlined />} onClick={onClose} />
</Tooltip>
</Space>
<div style={splitStyle} />
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<Button variant="ghost" size="icon-sm" onClick={onClose} />}>
<X className="size-4" />
</TooltipTrigger>
<TooltipContent>ESC to close</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
@ -205,17 +238,17 @@ function StatusBar({
environment: string;
}) {
return (
<Space size={SPACING_LARGE}>
<Tag color={statusColor}>{statusLabel}</Tag>
<Tag>Env: {environment}</Tag>
<Space size={SPACING_MEDIUM}>
<Text type="secondary" style={{ fontSize: FONT_SIZE_MEDIUM }}>
<div className="flex items-center gap-3">
<Badge variant={statusColor === "error" ? "destructive" : "secondary"}>{statusLabel}</Badge>
<Badge variant="outline">Env: {environment}</Badge>
<div className="flex items-center gap-2">
<span className="text-muted-foreground" style={{ fontSize: FONT_SIZE_MEDIUM }}>
{moment(log.startTime).format("MMM D, YYYY h:mm:ss A")}
</Text>
<Text type="secondary" style={{ fontSize: FONT_SIZE_MEDIUM }}>
</span>
<span className="text-muted-foreground" style={{ fontSize: FONT_SIZE_MEDIUM }}>
({moment(log.startTime).fromNow()})
</Text>
</Space>
</Space>
</span>
</div>
</div>
);
}

View file

@ -177,12 +177,19 @@ describe("LogDetailContent", () => {
expect(screen.getByText("Loading request & response data...")).toBeInTheDocument();
});
it("should display Request & Response section with Pretty and JSON view modes", () => {
it("should switch the Request & Response body between the Pretty and JSON view modes", async () => {
const user = userEvent.setup();
render(<LogDetailContent logEntry={createLogEntry()} />);
expect(screen.getByText("Request & Response")).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Pretty" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "JSON" })).toBeInTheDocument();
expect(screen.getByText("Pretty")).toBeInTheDocument();
expect(screen.getByText("JSON")).toBeInTheDocument();
await user.click(screen.getByText("JSON"));
expect(screen.getByRole("tab", { name: "Request" })).toBeInTheDocument();
await user.click(screen.getByText("Pretty"));
expect(screen.queryByRole("tab", { name: "Request" })).not.toBeInTheDocument();
});
it("should display Request and Response tabs when JSON view is selected", async () => {
@ -259,7 +266,7 @@ describe("LogDetailContent", () => {
render(<LogDetailContent logEntry={createLogEntry({ cache_hit: "True" })} />);
expect(screen.getByText("Response Cache")).toBeInTheDocument();
expect(screen.getByText("Hit").closest(".ant-tag")).toHaveClass("ant-tag-green");
expect(screen.getByText("Hit").className).toMatch(/green/);
});
it("should show prompt cache tokens without an alarming red tag when only provider prompt caching occurred", () => {
@ -282,7 +289,7 @@ describe("LogDetailContent", () => {
expect(screen.getByText("34,462")).toBeInTheDocument();
expect(screen.getByText("Prompt Cache Creation Tokens")).toBeInTheDocument();
expect(screen.getByText("83")).toBeInTheDocument();
expect(screen.getByText("Miss").closest(".ant-tag")).not.toHaveClass("ant-tag-red");
expect(screen.getByText("Miss").className).not.toMatch(/red|destructive/);
expect(screen.queryByText("Cache Hit")).not.toBeInTheDocument();
});
@ -310,8 +317,10 @@ describe("LogDetailContent", () => {
const user = userEvent.setup();
render(<LogDetailContent logEntry={createLogEntry({ cache_hit: "True" })} />);
const label = screen.getByText("Response Cache").closest(".ant-space") as HTMLElement;
await user.hover(within(label).getByRole("img", { name: "info-circle" }));
expect(screen.getByText("Response Cache")).toBeInTheDocument();
const infoIcons = screen.getAllByRole("img", { name: /info/i });
expect(infoIcons).toHaveLength(1);
await user.hover(infoIcons[0]);
expect(await screen.findByRole("link", { name: "Docs" })).toHaveAttribute(
"href",
@ -333,8 +342,10 @@ describe("LogDetailContent", () => {
/>,
);
const label = screen.getByText("Prompt Cache Read Tokens").closest(".ant-space") as HTMLElement;
await user.hover(within(label).getByRole("img", { name: "info-circle" }));
expect(screen.getByText("Prompt Cache Read Tokens")).toBeInTheDocument();
const infoIcons = screen.getAllByRole("img", { name: /info/i });
expect(infoIcons).toHaveLength(1);
await user.hover(infoIcons[0]);
expect(await screen.findByRole("link", { name: "Docs" })).toHaveAttribute(
"href",
@ -370,7 +381,7 @@ describe("LogDetailContent", () => {
expect(screen.queryByText("LiteLLM Overhead")).not.toBeInTheDocument();
});
const retriesItem = () => screen.getByText("Retries").closest(".ant-descriptions-item") as HTMLElement;
const retriesItem = () => screen.getByText("Retries").parentElement as HTMLElement;
it("should display attempted_retries / max_retries for Retries when attempted_retries > 0", () => {
render(
@ -386,7 +397,7 @@ describe("LogDetailContent", () => {
render(<LogDetailContent logEntry={createLogEntry({ metadata: { status: "success", attempted_retries: 0 } })} />);
const noneTag = within(retriesItem()).getByText("None");
expect(noneTag.closest(".ant-tag")).toHaveClass("ant-tag-green");
expect(noneTag.className).toMatch(/green/);
});
it("should display '-' for Retries when attempted_retries is absent from metadata", () => {
@ -444,8 +455,8 @@ describe("LogDetailContent", () => {
/>,
);
const descriptions = screen.getByText("Provider").closest(".ant-descriptions-item");
const descriptions = screen.getByText("Provider").parentElement as HTMLElement;
expect(descriptions).toBeInTheDocument();
expect(within(descriptions as HTMLElement).getByText("-")).toBeInTheDocument();
expect(within(descriptions).getByText("-")).toBeInTheDocument();
});
});

View file

@ -1,7 +1,13 @@
import { useState } from "react";
import { Typography, Descriptions, Card, Tag, Tabs, Alert, Collapse, Radio, Space, Spin, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Check, ChevronDown, ChevronRight, CircleAlert, Copy, Info } from "lucide-react";
import moment from "moment";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { LogEntry } from "../columns";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { PROMPT_CACHE_CREATION_TOOLTIP, PROMPT_CACHE_READ_TOOLTIP } from "@/utils/promptCacheUsage";
@ -32,13 +38,10 @@ import {
FONT_SIZE_SMALL,
FONT_FAMILY_MONO,
SPACING_XLARGE,
SPACING_MEDIUM,
} from "./constants";
import { ToolsSection } from "../ToolsSection";
import { PrettyMessagesView } from "./PrettyMessagesView";
const { Text } = Typography;
export interface LogDetailContentProps {
logEntry: LogEntry;
/** When true, log details (messages/response) are still being lazy-loaded. */
@ -100,13 +103,16 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
<div style={{ padding: `${DRAWER_CONTENT_PADDING} ${DRAWER_CONTENT_PADDING} 0` }}>
{/* Error Alert */}
{hasError && errorInfo && (
<Alert
type="error"
showIcon
message="Request Failed"
description={<ErrorDescription errorInfo={errorInfo} />}
className="mb-6"
/>
<div
role="alert"
className="mb-6 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm"
>
<CircleAlert className="size-4 shrink-0 text-destructive" />
<div>
<div className="font-medium text-destructive">Request Failed</div>
<ErrorDescription errorInfo={errorInfo} />
</div>
</div>
)}
{/* Tags */}
@ -116,26 +122,31 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
{/* Request Details */}
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
<Card title="Request Details" size="small" bordered={false} style={{ marginBottom: 0 }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="Model">{logEntry.model}</Descriptions.Item>
<Descriptions.Item label="Provider">{logEntry.custom_llm_provider || "-"}</Descriptions.Item>
<Descriptions.Item label="Call Type">{logEntry.call_type}</Descriptions.Item>
<Descriptions.Item label="Model ID">
<TruncatedValue value={logEntry.model_id} />
</Descriptions.Item>
<Descriptions.Item label="API Base">
<TruncatedValue value={logEntry.api_base} maxWidth={API_BASE_MAX_WIDTH} />
</Descriptions.Item>
{logEntry.requester_ip_address && (
<Descriptions.Item label="IP Address">{logEntry.requester_ip_address}</Descriptions.Item>
)}
{hasGuardrailData && (
<Descriptions.Item label="Guardrail">
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
</Descriptions.Item>
)}
</Descriptions>
<Card size="sm" style={{ marginBottom: 0 }}>
<CardHeader>
<CardTitle>Request Details</CardTitle>
</CardHeader>
<CardContent>
<DescriptionList>
<DescriptionItem label="Model">{logEntry.model}</DescriptionItem>
<DescriptionItem label="Provider">{logEntry.custom_llm_provider || "-"}</DescriptionItem>
<DescriptionItem label="Call Type">{logEntry.call_type}</DescriptionItem>
<DescriptionItem label="Model ID">
<TruncatedValue value={logEntry.model_id} />
</DescriptionItem>
<DescriptionItem label="API Base">
<TruncatedValue value={logEntry.api_base} maxWidth={API_BASE_MAX_WIDTH} />
</DescriptionItem>
{logEntry.requester_ip_address && (
<DescriptionItem label="IP Address">{logEntry.requester_ip_address}</DescriptionItem>
)}
{hasGuardrailData && (
<DescriptionItem label="Guardrail">
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
</DescriptionItem>
)}
</DescriptionList>
</CardContent>
</Card>
</div>
@ -170,7 +181,7 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
{/* Request/Response JSON */}
{isLoadingDetails ? (
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6 p-8 text-center">
<Spin size="default" />
<UiLoadingSpinner className="inline-block size-5" />
<div style={{ marginTop: 8, color: "#999" }}>Loading request &amp; response data...</div>
</div>
) : (
@ -221,17 +232,64 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
// Helper Components
// ============================================================================
function DescriptionList({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">{children}</div>;
}
function DescriptionItem({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-0.5">
<span className="shrink-0 text-muted-foreground after:content-[':']">{label}</span>
<span className="min-w-0 break-words">{children}</span>
</div>
);
}
function CopyButton({
getText,
label,
disabled = false,
}: {
getText: () => string;
label: string;
disabled?: boolean;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(getText());
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* clipboard unavailable in non-secure contexts */
}
};
return (
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopy}
disabled={disabled}
aria-label={copied ? "Copied!" : label}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</Button>
);
}
function ErrorDescription({ errorInfo }: { errorInfo: any }) {
return (
<div>
{errorInfo.error_code && (
<div>
<Text strong>Error Code:</Text> {errorInfo.error_code}
<span className="font-semibold">Error Code:</span> {errorInfo.error_code}
</div>
)}
{errorInfo.error_message && (
<div>
<Text strong>Message:</Text> {errorInfo.error_message}
<span className="font-semibold">Message:</span> {errorInfo.error_message}
</div>
)}
</div>
@ -241,16 +299,16 @@ function ErrorDescription({ errorInfo }: { errorInfo: any }) {
function TagsSection({ tags }: { tags: Record<string, any> }) {
return (
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden p-4 mb-6">
<Text strong style={{ display: "block", marginBottom: 8, fontSize: 16 }}>
<span className="font-semibold" style={{ display: "block", marginBottom: 8, fontSize: 16 }}>
Tags
</Text>
<Space size={SPACING_MEDIUM} wrap>
</span>
<div className="flex flex-wrap items-center gap-2">
{Object.entries(tags).map(([key, value]) => (
<Tag key={key}>
<Badge key={key} variant="outline">
{key}: {String(value)}
</Tag>
</Badge>
))}
</Space>
</div>
</div>
);
}
@ -262,12 +320,12 @@ function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: nu
};
return (
<Space size={SPACING_MEDIUM}>
<span className="inline-flex items-center gap-2">
<a onClick={handleClick} style={{ cursor: "pointer" }}>
{label}
</a>
{maskedCount > 0 && <Tag color="blue">{maskedCount} masked</Tag>}
</Space>
{maskedCount > 0 && <Badge variant="secondary">{maskedCount} masked</Badge>}
</span>
);
}
@ -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 (
<Space size={4}>
<span className="inline-flex items-center gap-1">
{label}
<Tooltip
title={
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={<span role="img" aria-label={`${label} info`} className="inline-flex text-muted-foreground" />}
>
<Info className="size-3.5" />
</TooltipTrigger>
<TooltipContent>
{tooltip}{" "}
<a
href={docsUrl}
target="_blank"
rel="noreferrer"
style={{ color: "#91caff", textDecoration: "underline" }}
>
<a href={docsUrl} target="_blank" rel="noreferrer" className="underline">
Docs
</a>
</>
}
>
<InfoCircleOutlined style={{ color: "#8c8c8c" }} />
</Tooltip>
</Space>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
);
}
@ -333,102 +389,111 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata:
return (
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
<Card title="Metrics" size="small" style={{ marginBottom: 0 }}>
<Descriptions column={2} size="small">
{showAnthropicMessagesInputOutput ? (
<>
<Descriptions.Item label="Input Tokens">{formatNumberWithCommas(uncachedInputTokens)}</Descriptions.Item>
<Descriptions.Item label="Output Tokens">
{formatNumberWithCommas(logEntry.completion_tokens)}
</Descriptions.Item>
</>
) : (
<Descriptions.Item label="Tokens">
<TokenFlow
prompt={logEntry.prompt_tokens}
completion={logEntry.completion_tokens}
total={logEntry.total_tokens}
/>
</Descriptions.Item>
)}
<Descriptions.Item label="Cost">${formatNumberWithCommas(logEntry.spend || 0, 8)}</Descriptions.Item>
<Descriptions.Item label="Duration">
{logEntry.request_duration_ms != null ? (logEntry.request_duration_ms / 1000).toFixed(3) : "-"} s
</Descriptions.Item>
{ttftMs != null && ttftMs > 0 && (
<Descriptions.Item label="Time to First Token">{(ttftMs / 1000).toFixed(3)} s</Descriptions.Item>
)}
{showResponseCache && (
<Descriptions.Item
label={
<MetricLabel
label="Response Cache"
tooltip={RESPONSE_CACHE_TOOLTIP}
docsUrl={RESPONSE_CACHE_DOCS_URL}
/>
}
>
<Tag color={isResponseCacheHit ? "green" : "default"}>{isResponseCacheHit ? "Hit" : "Miss"}</Tag>
</Descriptions.Item>
)}
{promptCacheReadTokens > 0 && (
<Descriptions.Item
label={
<MetricLabel
label="Prompt Cache Read Tokens"
tooltip={PROMPT_CACHE_READ_TOOLTIP}
docsUrl={PROMPT_CACHE_DOCS_URL}
/>
}
>
{formatNumberWithCommas(promptCacheReadTokens)}
</Descriptions.Item>
)}
{promptCacheCreationTokens > 0 && (
<Descriptions.Item
label={
<MetricLabel
label="Prompt Cache Creation Tokens"
tooltip={PROMPT_CACHE_CREATION_TOOLTIP}
docsUrl={PROMPT_CACHE_DOCS_URL}
/>
}
>
{formatNumberWithCommas(promptCacheCreationTokens)}
</Descriptions.Item>
)}
{metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
<Descriptions.Item label="LiteLLM Overhead">
{metadata.litellm_overhead_time_ms.toFixed(2)} ms
</Descriptions.Item>
)}
<Descriptions.Item label="Retries">
{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}`
: ""}
</>
) : (
<Tag color="green">None</Tag>
)
<Card size="sm" style={{ marginBottom: 0 }}>
<CardHeader>
<CardTitle>Metrics</CardTitle>
</CardHeader>
<CardContent>
<DescriptionList>
{showAnthropicMessagesInputOutput ? (
<>
<DescriptionItem label="Input Tokens">{formatNumberWithCommas(uncachedInputTokens)}</DescriptionItem>
<DescriptionItem label="Output Tokens">
{formatNumberWithCommas(logEntry.completion_tokens)}
</DescriptionItem>
</>
) : (
"-"
<DescriptionItem label="Tokens">
<TokenFlow
prompt={logEntry.prompt_tokens}
completion={logEntry.completion_tokens}
total={logEntry.total_tokens}
/>
</DescriptionItem>
)}
<DescriptionItem label="Cost">${formatNumberWithCommas(logEntry.spend || 0, 8)}</DescriptionItem>
<DescriptionItem label="Duration">
{logEntry.request_duration_ms != null ? (logEntry.request_duration_ms / 1000).toFixed(3) : "-"} s
</DescriptionItem>
{ttftMs != null && ttftMs > 0 && (
<DescriptionItem label="Time to First Token">{(ttftMs / 1000).toFixed(3)} s</DescriptionItem>
)}
</Descriptions.Item>
<Descriptions.Item label="Start Time">
{moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
</Descriptions.Item>
<Descriptions.Item label="End Time">
{moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
</Descriptions.Item>
</Descriptions>
{showResponseCache && (
<DescriptionItem
label={
<MetricLabel
label="Response Cache"
tooltip={RESPONSE_CACHE_TOOLTIP}
docsUrl={RESPONSE_CACHE_DOCS_URL}
/>
}
>
<Badge variant="secondary" className={isResponseCacheHit ? "bg-green-100 text-green-700" : undefined}>
{isResponseCacheHit ? "Hit" : "Miss"}
</Badge>
</DescriptionItem>
)}
{promptCacheReadTokens > 0 && (
<DescriptionItem
label={
<MetricLabel
label="Prompt Cache Read Tokens"
tooltip={PROMPT_CACHE_READ_TOOLTIP}
docsUrl={PROMPT_CACHE_DOCS_URL}
/>
}
>
{formatNumberWithCommas(promptCacheReadTokens)}
</DescriptionItem>
)}
{promptCacheCreationTokens > 0 && (
<DescriptionItem
label={
<MetricLabel
label="Prompt Cache Creation Tokens"
tooltip={PROMPT_CACHE_CREATION_TOOLTIP}
docsUrl={PROMPT_CACHE_DOCS_URL}
/>
}
>
{formatNumberWithCommas(promptCacheCreationTokens)}
</DescriptionItem>
)}
{metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
<DescriptionItem label="LiteLLM Overhead">
{metadata.litellm_overhead_time_ms.toFixed(2)} ms
</DescriptionItem>
)}
<DescriptionItem label="Retries">
{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}`
: ""}
</>
) : (
<Badge variant="secondary" className="bg-green-100 text-green-700">
None
</Badge>
)
) : (
"-"
)}
</DescriptionItem>
<DescriptionItem label="Start Time">
{moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
</DescriptionItem>
<DescriptionItem label="End Time">
{moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
</DescriptionItem>
</DescriptionList>
</CardContent>
</Card>
</div>
);
@ -449,6 +514,7 @@ function RequestResponseSection({
getFormattedResponse,
logEntry,
}: RequestResponseSectionProps) {
const [open, setOpen] = useState(true);
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
const [viewMode, setViewMode] = useState<"pretty" | "json">("pretty");
@ -476,90 +542,76 @@ function RequestResponseSection({
return (
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
<Collapse
defaultActiveKey={["1"]}
expandIconPosition="start"
items={[
{
key: "1",
label: (
<div
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest(".ant-radio-group")) {
e.stopPropagation();
}
}}
>
<h3 className="text-lg font-medium text-gray-900" style={{ margin: 0 }}>
Request & Response
</h3>
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
<Radio.Button value="pretty">Pretty</Radio.Button>
<Radio.Button value="json">JSON</Radio.Button>
</Radio.Group>
</div>
),
children: (
<div>
{viewMode === "pretty" ? (
<PrettyMessagesView
request={getRawRequest()}
response={getFormattedResponse()}
metrics={{
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
input_cost: inputCost,
output_cost: outputCost,
}}
/>
) : (
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
tabBarExtraContent={
<Text
copyable={{
text: getCopyText(),
tooltips: ["Copy JSON", "Copied!"],
}}
disabled={activeTab === TAB_RESPONSE && !hasResponse && !hasError}
/>
}
items={[
{
key: TAB_REQUEST,
label: "Request",
children: (
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
<JsonViewer data={getRawRequest()} mode="formatted" />
</div>
),
},
{
key: TAB_RESPONSE,
label: "Response",
children: (
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
{hasResponse || hasError ? (
<JsonViewer data={getFormattedResponse()} mode="formatted" />
) : (
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
Response data not available
</div>
)}
</div>
),
},
]}
/>
)}
</div>
),
},
]}
/>
<Collapsible open={open} onOpenChange={setOpen}>
<Tabs value={viewMode} onValueChange={(value) => setViewMode(value as "pretty" | "json")}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}>
<CollapsibleTrigger className="flex flex-1 items-center gap-3 px-4 py-3 text-left">
{open ? (
<ChevronDown className="size-3.5 shrink-0 text-gray-500" />
) : (
<ChevronRight className="size-3.5 shrink-0 text-gray-500" />
)}
<h3 className="text-lg font-medium text-gray-900" style={{ margin: 0 }}>
Request & Response
</h3>
</CollapsibleTrigger>
<TabsList className="mr-4">
<TabsTrigger value="pretty">Pretty</TabsTrigger>
<TabsTrigger value="json">JSON</TabsTrigger>
</TabsList>
</div>
<CollapsibleContent>
<div>
<TabsContent value="pretty">
<PrettyMessagesView
request={getRawRequest()}
response={getFormattedResponse()}
metrics={{
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
input_cost: inputCost,
output_cost: outputCost,
}}
/>
</TabsContent>
<TabsContent value="json">
<Tabs
value={activeTab}
onValueChange={(key) => setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
>
<div className="flex items-center justify-between">
<TabsList>
<TabsTrigger value={TAB_REQUEST}>Request</TabsTrigger>
<TabsTrigger value={TAB_RESPONSE}>Response</TabsTrigger>
</TabsList>
<CopyButton
getText={getCopyText}
label="Copy JSON"
disabled={activeTab === TAB_RESPONSE && !hasResponse && !hasError}
/>
</div>
<TabsContent value={TAB_REQUEST}>
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
<JsonViewer data={getRawRequest()} mode="formatted" />
</div>
</TabsContent>
<TabsContent value={TAB_RESPONSE}>
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
{hasResponse || hasError ? (
<JsonViewer data={getFormattedResponse()} mode="formatted" />
) : (
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
Response data not available
</div>
)}
</div>
</TabsContent>
</Tabs>
</TabsContent>
</div>
</CollapsibleContent>
</Tabs>
</Collapsible>
</div>
);
}
@ -602,43 +654,40 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[
}
function MetadataSection({ metadata }: { metadata: Record<string, any> }) {
const [open, setOpen] = useState(true);
return (
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
<Collapse
defaultActiveKey={["1"]}
expandIconPosition="start"
items={[
{
key: "1",
label: <h3 className="text-lg font-medium text-gray-900">Metadata</h3>,
children: (
<div>
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}>
<Text
copyable={{
text: JSON.stringify(metadata, null, 2),
tooltips: ["Copy Metadata", "Copied!"],
}}
/>
</div>
<pre
style={{
maxHeight: METADATA_MAX_HEIGHT,
overflowY: "auto",
fontSize: FONT_SIZE_SMALL,
fontFamily: FONT_FAMILY_MONO,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
margin: 0,
}}
>
{JSON.stringify(metadata, null, 2)}
</pre>
</div>
),
},
]}
/>
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left">
{open ? (
<ChevronDown className="size-3.5 shrink-0 text-gray-500" />
) : (
<ChevronRight className="size-3.5 shrink-0 text-gray-500" />
)}
<h3 className="text-lg font-medium text-gray-900">Metadata</h3>
</CollapsibleTrigger>
<CollapsibleContent>
<div>
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}>
<CopyButton getText={() => JSON.stringify(metadata, null, 2)} label="Copy Metadata" />
</div>
<pre
style={{
maxHeight: METADATA_MAX_HEIGHT,
overflowY: "auto",
fontSize: FONT_SIZE_SMALL,
fontFamily: FONT_FAMILY_MONO,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
margin: 0,
}}
>
{JSON.stringify(metadata, null, 2)}
</pre>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}

View file

@ -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 (
<Drawer
title={null}
placement="right"
onClose={onClose}
<Sheet
open={open}
width={DRAWER_WIDTH}
closable={false}
mask={true}
maskClosable={true}
styles={{
body: { padding: 0, overflow: "hidden" },
header: { display: "none" },
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
>
<div style={{ height: "100%" }} className="flex relative">
{!isSidebarCollapsed ? (
<Button
type="text"
size="small"
icon={<LeftOutlined />}
onClick={() => setIsSidebarCollapsed(true)}
className="absolute top-2 left-2 z-20 bg-white! border! border-slate-200! rounded-md!"
aria-label="Collapse trace sidebar"
/>
) : (
<Button
type="text"
size="small"
icon={<RightOutlined />}
onClick={() => setIsSidebarCollapsed(false)}
className="absolute top-2 left-2 z-20 bg-white! border! border-slate-200! rounded-md!"
aria-label="Expand trace sidebar"
/>
)}
{!isSidebarCollapsed && (
<div className="border-r border-slate-200 bg-slate-50 flex flex-col" style={{ width: SIDEBAR_WIDTH_PX }}>
<div className="pl-12 pr-3 py-2 border-b border-slate-200 bg-white">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-[10px] uppercase tracking-wide text-slate-500">
{isSessionMode ? "Session" : "Trace"}
</div>
<div className="font-mono text-[12px] text-slate-900 leading-tight flex items-center gap-1">
<span className="truncate">{leftPanelDisplayId}</span>
<button
type="button"
onClick={handleCopyLeftPanelId}
className="text-slate-400 hover:text-slate-600"
aria-label="Copy trace id"
>
{copiedLeftPanelId ? (
<CheckOutlined className="text-[11px]" />
) : (
<CopyOutlined className="text-[11px]" />
)}
</button>
<SheetContent
side="right"
showCloseButton={false}
className="gap-0 overflow-hidden p-0 data-[side=right]:sm:max-w-none"
style={{ width: DRAWER_WIDTH }}
>
<SheetTitle className="sr-only">
{logEntry?.request_id ? `Request ${logEntry.request_id} details` : "Request details"}
</SheetTitle>
<div style={{ height: "100%" }} className="flex relative">
{!isSidebarCollapsed ? (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setIsSidebarCollapsed(true)}
className="absolute top-2 left-2 z-20 bg-white! border! border-slate-200! rounded-md!"
aria-label="Collapse trace sidebar"
>
<ChevronLeft className="size-4" />
</Button>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setIsSidebarCollapsed(false)}
className="absolute top-2 left-2 z-20 bg-white! border! border-slate-200! rounded-md!"
aria-label="Expand trace sidebar"
>
<ChevronRight className="size-4" />
</Button>
)}
{!isSidebarCollapsed && (
<div className="border-r border-slate-200 bg-slate-50 flex flex-col" style={{ width: SIDEBAR_WIDTH_PX }}>
<div className="pl-12 pr-3 py-2 border-b border-slate-200 bg-white">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-[10px] uppercase tracking-wide text-slate-500">
{isSessionMode ? "Session" : "Trace"}
</div>
<div className="font-mono text-[12px] text-slate-900 leading-tight flex items-center gap-1">
<span className="truncate">{leftPanelDisplayId}</span>
<button
type="button"
onClick={handleCopyLeftPanelId}
className="text-slate-400 hover:text-slate-600"
aria-label="Copy trace id"
>
{copiedLeftPanelId ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
</div>
</div>
</div>
</div>
<div className="mt-1 text-[11px] text-slate-500 font-mono">
{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 ? (
<span key={label}>
<div className="mt-1 text-[11px] text-slate-500 font-mono">
{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 ? (
<span key={label}>
<span className="mx-1.5">·</span>
{count}
{label}
</span>
) : null;
})}
<span className="mx-1.5">·</span>
{isSessionMode ? getSpendString(totalSessionCost) : getSpendString(currentLog.spend || 0)}
{isSessionMode && (
<>
<span className="mx-1.5">·</span>
{count}
{label}
</span>
) : null;
})}
<span className="mx-1.5">·</span>
{isSessionMode ? getSpendString(totalSessionCost) : getSpendString(currentLog.spend || 0)}
{sessionDurationSeconds}s
</>
)}
</div>
{isSessionMode && sessionTruncated && (
<div className="mt-1 text-[11px] text-amber-600 font-mono">
Showing most recent {logsForList.length} of {sessionTotalCount}
</div>
)}
{isSessionMode && (
<>
<span className="mx-1.5">·</span>
{sessionDurationSeconds}s
</>
<Tabs
className="mt-1.5"
value={sessionSortMode}
onValueChange={(value) => setSessionSortMode(value as SessionLogSortMode)}
>
<TabsList className="w-full">
<TabsTrigger value="duration" className="text-[11px]">
Duration
</TabsTrigger>
<TabsTrigger value="start_time" className="text-[11px]">
Start time
</TabsTrigger>
</TabsList>
</Tabs>
)}
</div>
{isSessionMode && sessionTruncated && (
<div className="mt-1 text-[11px] text-amber-600 font-mono">
Showing most recent {logsForList.length} of {sessionTotalCount}
</div>
)}
{isSessionMode && (
<Segmented
block
size="small"
className="mt-1.5 [&_.ant-segmented-item-label]:text-[11px]"
options={[
{ label: "Duration", value: "duration" },
{ label: "Start time", value: "start_time" },
]}
value={sessionSortMode}
onChange={(value) => setSessionSortMode(value as SessionLogSortMode)}
/>
)}
</div>
<div className="flex-1 overflow-y-auto">
{normalizeGuardrailEntries(metadata?.guardrail_information).length > 0 && (
<div className="px-3 pt-2">
<GuardrailJumpLink guardrailEntries={normalizeGuardrailEntries(metadata?.guardrail_information)} />
</div>
)}
{isSessionMode ? (
<div className="py-1">
{/* Child events — vertical tree line with horizontal connectors */}
<div className="relative pl-2">
<div className="absolute left-4 top-1 bottom-1 border-l border-slate-300" />
{logsForList.map((row, idx) => {
const isLast = idx === logsForList.length - 1;
return (
<div key={row.request_id} className="relative">
<div className="absolute left-4 top-3 w-3 border-t border-slate-300" />
{isLast && <div className="absolute left-4 top-3 bottom-0 w-px bg-slate-50" />}
<TraceEventRow
row={row}
isSelected={row.request_id === currentLog.request_id}
onClick={() => {
setSelectedSessionRequestId(row.request_id);
onSelectLog?.(row);
}}
/>
</div>
);
})}
<div className="flex-1 overflow-y-auto">
{normalizeGuardrailEntries(metadata?.guardrail_information).length > 0 && (
<div className="px-3 pt-2">
<GuardrailJumpLink guardrailEntries={normalizeGuardrailEntries(metadata?.guardrail_information)} />
</div>
</div>
) : (
<div className="py-1">
{logsForList.map((row) => (
<TraceEventRow
key={row.request_id}
row={row}
isSelected={row.request_id === currentLog.request_id}
onClick={() => onSelectLog?.(row)}
/>
))}
</div>
)}
)}
{isSessionMode ? (
<div className="py-1">
{/* Child events — vertical tree line with horizontal connectors */}
<div className="relative pl-2">
<div className="absolute left-4 top-1 bottom-1 border-l border-slate-300" />
{logsForList.map((row, idx) => {
const isLast = idx === logsForList.length - 1;
return (
<div key={row.request_id} className="relative">
<div className="absolute left-4 top-3 w-3 border-t border-slate-300" />
{isLast && <div className="absolute left-4 top-3 bottom-0 w-px bg-slate-50" />}
<TraceEventRow
row={row}
isSelected={row.request_id === currentLog.request_id}
onClick={() => {
setSelectedSessionRequestId(row.request_id);
onSelectLog?.(row);
}}
/>
</div>
);
})}
</div>
</div>
) : (
<div className="py-1">
{logsForList.map((row) => (
<TraceEventRow
key={row.request_id}
row={row}
isSelected={row.request_id === currentLog.request_id}
onClick={() => onSelectLog?.(row)}
/>
))}
</div>
)}
</div>
</div>
</div>
)}
)}
<div className="flex-1 flex flex-col overflow-hidden">
<DrawerHeader
log={currentLog}
onClose={onClose}
onPrevious={selectPreviousLog}
onNext={selectNextLog}
statusLabel={statusLabel}
statusColor={statusColor}
environment={environment}
/>
<div className="flex-1 overflow-y-auto">
<LogDetailContent
logEntry={enrichedLog}
isLoadingDetails={isLoadingDetails}
accessToken={accessToken ?? null}
<div className="flex-1 flex flex-col overflow-hidden">
<DrawerHeader
log={currentLog}
onClose={onClose}
onPrevious={selectPreviousLog}
onNext={selectNextLog}
statusLabel={statusLabel}
statusColor={statusColor}
environment={environment}
/>
<div className="flex-1 overflow-y-auto">
<LogDetailContent
logEntry={enrichedLog}
isLoadingDetails={isLoadingDetails}
accessToken={accessToken ?? null}
/>
</div>
</div>
</div>
</div>
</Drawer>
</SheetContent>
</Sheet>
);
}

View file

@ -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<typeof import("antd")>("antd");
return {
...actual,
message: {
success: vi.fn(),
},
};
});
const sampleRealtimeResponse = {
usage: {
total_tokens: 587,

View file

@ -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
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center" }}>
{isCollapsed ? (
<DownOutlined style={{ fontSize: 10, color: "#8c8c8c" }} />
<ChevronDown className="size-2.5 text-muted-foreground" />
) : (
<UpOutlined style={{ fontSize: 10, color: "#8c8c8c" }} />
<ChevronUp className="size-2.5 text-muted-foreground" />
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<SettingOutlined style={{ color: "#8c8c8c", fontSize: 14 }} />
<Text style={{ fontWeight: 500, fontSize: 14 }}>Session</Text>
<Settings className="size-3.5 text-muted-foreground" />
<span style={{ fontWeight: 500, fontSize: 14 }}>Session</span>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
{session.model}
</Text>
</span>
{turnCount > 0 && (
<Tag color="purple" style={{ margin: 0, fontWeight: 500 }}>
<Badge variant="secondary" style={{ margin: 0, fontWeight: 500 }}>
{turnCount} {turnCount === 1 ? "turn" : "turns"}
</Tag>
</Badge>
)}
{session.voice && (
<Tag color="blue" style={{ margin: 0 }}>
<SoundOutlined /> {session.voice}
</Tag>
<Badge variant="secondary" style={{ margin: 0 }}>
<Volume2 className="size-3" /> {session.voice}
</Badge>
)}
{session.modalities && (
<div style={{ display: "flex", gap: 4 }}>
{session.modalities.map((m) => (
<Tag key={m} style={{ margin: 0 }}>
{m === "audio" ? <AudioOutlined /> : <MessageOutlined />} {m}
</Tag>
<Badge key={m} variant="outline" style={{ margin: 0 }}>
{m === "audio" ? <Mic className="size-3" /> : <MessageSquare className="size-3" />} {m}
</Badge>
))}
</div>
)}
@ -228,8 +220,8 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
{session.instructions && (
<div style={{ marginTop: 12 }}>
<Text
type="secondary"
<span
className="text-muted-foreground"
style={{
fontSize: 10,
letterSpacing: "0.5px",
@ -239,7 +231,7 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
}}
>
Instructions
</Text>
</span>
<div
style={{
fontSize: 12,
@ -344,20 +336,25 @@ function ResponseTurn({ response, index }: { response: RealtimeResponse; index:
marginBottom: 8,
}}
>
<Tag color={response.status === "completed" ? "green" : "orange"} style={{ margin: 0 }}>
<Badge variant={response.status === "completed" ? "secondary" : "outline"} style={{ margin: 0 }}>
{response.status || "unknown"}
</Tag>
</Badge>
{usage && (
<Text type="secondary" style={{ fontSize: 11 }}>
<span className="text-muted-foreground" style={{ fontSize: 11 }}>
{usage.input_tokens ?? 0} in / {usage.output_tokens ?? 0} out tokens
</Text>
</span>
)}
{response.conversation_id && (
<Tooltip title={response.conversation_id}>
<Text type="secondary" style={{ fontSize: 11, cursor: "help" }}>
conv: {response.conversation_id.slice(0, 12)}...
</Text>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={<span className="text-muted-foreground" style={{ fontSize: 11, cursor: "help" }} />}
>
conv: {response.conversation_id.slice(0, 12)}...
</TooltipTrigger>
<TooltipContent>{response.conversation_id}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
@ -381,8 +378,8 @@ function OutputMessage({ output }: { output: RealtimeOutputItem }) {
return (
<div style={{ marginBottom: 8 }}>
<Text
type="secondary"
<span
className="text-muted-foreground"
style={{
fontSize: 10,
letterSpacing: "0.5px",
@ -392,7 +389,7 @@ function OutputMessage({ output }: { output: RealtimeOutputItem }) {
}}
>
{output.role?.toUpperCase() || "ASSISTANT"}
</Text>
</span>
{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" && (
<AudioOutlined
<Mic
className="size-3 text-muted-foreground"
style={{
color: "#8c8c8c",
fontSize: 12,
marginTop: 3,
flexShrink: 0,
}}
/>
)}
{c.type === "text" && (
<MessageOutlined
<MessageSquare
className="size-3 text-muted-foreground"
style={{
color: "#8c8c8c",
fontSize: 12,
marginTop: 3,
flexShrink: 0,
}}
@ -453,9 +448,12 @@ function TokenBreakdown({ label, details }: { label: string; details: Record<str
return (
<div style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 10, letterSpacing: "0.5px", textTransform: "uppercase" }}>
<span
className="text-muted-foreground"
style={{ fontSize: 10, letterSpacing: "0.5px", textTransform: "uppercase" }}
>
{label} Token Breakdown
</Text>
</span>
<div
style={{
display: "flex",
@ -467,9 +465,9 @@ function TokenBreakdown({ label, details }: { label: string; details: Record<str
{entries.map(([key, value]) => {
if (typeof value === "number") {
return (
<Tag key={key} style={{ margin: 0 }}>
<Badge key={key} variant="outline" style={{ margin: 0 }}>
{formatTokenLabel(key)}: {value.toLocaleString()}
</Tag>
</Badge>
);
}
return null;
@ -483,9 +481,9 @@ function ConfigRow({ label, value }: { label: string; value: any }) {
if (value === undefined || value === null) return null;
return (
<div>
<Text type="secondary" style={{ fontSize: 11 }}>
<span className="text-muted-foreground" style={{ fontSize: 11 }}>
{label}
</Text>
</span>
<div style={{ fontSize: 13, color: "#262626" }}>{String(value)}</div>
</div>
);

View file

@ -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) => (
<Text code>
{name}
{record.required && <Text type="danger">*</Text>}
</Text>
),
},
{
title: "Type",
dataIndex: "type",
key: "type",
render: (type: string) => (
<Text code style={{ color: "#1890ff" }}>
{type}
</Text>
),
},
{
title: "Description",
dataIndex: "description",
key: "description",
render: (desc: string) => <Text type="secondary">{desc}</Text>,
},
];
return (
<div>
{/* Description */}
{tool.description && (
<div style={{ marginBottom: 16 }}>
<Text
<span
style={{
lineHeight: 1.6,
whiteSpace: "pre-wrap",
}}
>
{tool.description}
</Text>
</span>
</div>
)}
{/* Parameters Table */}
{parameterRows.length > 0 && (
<div>
<Text
type="secondary"
<span
className="text-muted-foreground"
style={{
fontSize: 12,
display: "block",
@ -81,16 +49,42 @@ export function FormattedToolView({ tool }: FormattedToolViewProps) {
}}
>
Parameters
</Text>
<Table dataSource={parameterRows} columns={columns} pagination={false} size="small" bordered />
</span>
<Table>
<TableHeader>
<TableRow>
<TableHead>Parameter</TableHead>
<TableHead>Type</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{parameterRows.map((row) => (
<TableRow key={row.key}>
<TableCell>
<code>
{row.name}
{row.required && <span className="text-destructive">*</span>}
</code>
</TableCell>
<TableCell>
<code className="text-blue-600">{row.type}</code>
</TableCell>
<TableCell>
<span className="text-muted-foreground">{row.description}</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* If tool was called, show the arguments used */}
{tool.called && tool.callData && (
<div style={{ marginTop: 16 }}>
<Text
type="secondary"
<span
className="text-muted-foreground"
style={{
fontSize: 12,
display: "block",
@ -98,7 +92,7 @@ export function FormattedToolView({ tool }: FormattedToolViewProps) {
}}
>
Called With
</Text>
</span>
<div
style={{
background: "#f6ffed",

View file

@ -3,13 +3,11 @@
*/
import { useState } from "react";
import { Typography, Radio } from "antd";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ParsedTool } from "./types";
import { FormattedToolView } from "./FormattedToolView";
import { JsonToolView } from "./JsonToolView";
const { Text } = Typography;
type ViewMode = "formatted" | "json";
interface ToolExpandedContentProps {
@ -29,13 +27,13 @@ export function ToolExpandedContent({ tool }: ToolExpandedContentProps) {
marginBottom: 12,
}}
>
<Text type="secondary" style={{ fontSize: 12 }}>
Description
</Text>
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
<Radio.Button value="formatted">Formatted</Radio.Button>
<Radio.Button value="json">JSON</Radio.Button>
</Radio.Group>
<span className="text-xs text-muted-foreground">Description</span>
<Tabs value={viewMode} onValueChange={(value) => setViewMode(value as ViewMode)}>
<TabsList>
<TabsTrigger value="formatted">Formatted</TabsTrigger>
<TabsTrigger value="json">JSON</TabsTrigger>
</TabsList>
</Tabs>
</div>
{viewMode === "formatted" ? <FormattedToolView tool={tool} /> : <JsonToolView tool={tool} />}

View file

@ -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) {
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<ToolOutlined style={{ color: "#8c8c8c", fontSize: 14 }} />
<Text style={{ fontSize: 14 }}>
<Wrench className="size-3.5 text-muted-foreground" />
<span style={{ fontSize: 14 }}>
{tool.index}. {tool.name}
</Text>
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Tag color={tool.called ? "blue" : "default"}>{tool.called ? "called" : "not called"}</Tag>
<Badge variant={tool.called ? "default" : "secondary"}>{tool.called ? "called" : "not called"}</Badge>
{expanded ? (
<DownOutlined style={{ fontSize: 12, color: "#8c8c8c" }} />
<ChevronDown className="size-3 text-muted-foreground" />
) : (
<RightOutlined style={{ fontSize: 12, color: "#8c8c8c" }} />
<ChevronRight className="size-3 text-muted-foreground" />
)}
</div>
</div>

View file

@ -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<Record<string, boolean>>({});
if (!data || data.length === 0) {
@ -57,110 +59,110 @@ export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
return (
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
<Collapse
defaultActiveKey={["1"]}
expandIconPosition="start"
items={[
{
key: "1",
label: <h3 className="text-lg font-medium text-gray-900">Vector Store Requests</h3>,
children: (
<div className="p-4">
{data.map((request, index) => (
<div key={index} className="mb-6 last:mb-0">
<div className="bg-white rounded-lg border p-4 mb-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex">
<span className="font-medium w-1/3">Query:</span>
<span className="font-mono">{request.query}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Vector Store ID:</span>
<span className="font-mono">{request.vector_store_id}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Provider:</span>
<span className="flex items-center">
{(() => {
const { logo, displayName } = getProviderLogoAndName(request.custom_llm_provider);
return (
<>
{logo && <img src={logo} alt={`${displayName} logo`} className="h-5 w-5 mr-2" />}
{displayName}
</>
);
})()}
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left">
{open ? (
<ChevronDown className="size-3.5 shrink-0 text-gray-500" />
) : (
<ChevronRight className="size-3.5 shrink-0 text-gray-500" />
)}
<h3 className="text-lg font-medium text-gray-900">Vector Store Requests</h3>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-4">
{data.map((request, index) => (
<div key={index} className="mb-6 last:mb-0">
<div className="bg-white rounded-lg border p-4 mb-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex">
<span className="font-medium w-1/3">Query:</span>
<span className="font-mono">{request.query}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Vector Store ID:</span>
<span className="font-mono">{request.vector_store_id}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Provider:</span>
<span className="flex items-center">
{(() => {
const { logo, displayName } = getProviderLogoAndName(request.custom_llm_provider);
return (
<>
{logo && <img src={logo} alt={`${displayName} logo`} className="h-5 w-5 mr-2" />}
{displayName}
</>
);
})()}
</span>
</div>
</div>
<div className="space-y-2">
<div className="flex">
<span className="font-medium w-1/3">Start Time:</span>
<span>{formatTime(request.start_time)}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">End Time:</span>
<span>{formatTime(request.end_time)}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Duration:</span>
<span>{calculateDuration(request.start_time, request.end_time)}</span>
</div>
</div>
</div>
</div>
<h4 className="font-medium mb-2">Search Results</h4>
<div className="space-y-2">
{request.vector_store_search_response.data.map((result, resultIndex) => {
const isExpanded = expandedResults[`${index}-${resultIndex}`] || false;
return (
<div key={resultIndex} className="border rounded-lg overflow-hidden">
<div
className="flex items-center p-3 bg-gray-50 cursor-pointer"
onClick={() => toggleResult(index, resultIndex)}
>
<svg
className={`w-5 h-5 mr-2 transition-transform ${isExpanded ? "transform rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<div className="flex items-center">
<span className="font-medium mr-2">Result {resultIndex + 1}</span>
<span className="text-gray-500 text-sm">
Score: <span className="font-mono">{result.score.toFixed(4)}</span>
</span>
</div>
</div>
<div className="space-y-2">
<div className="flex">
<span className="font-medium w-1/3">Start Time:</span>
<span>{formatTime(request.start_time)}</span>
{isExpanded && (
<div className="p-3 border-t bg-white">
{result.content.map((content, contentIndex) => (
<div key={contentIndex} className="mb-2 last:mb-0">
<div className="text-xs text-gray-500 mb-1">{content.type}</div>
<pre className="text-xs font-mono whitespace-pre-wrap break-all bg-gray-50 p-2 rounded-sm">
{content.text}
</pre>
</div>
))}
</div>
<div className="flex">
<span className="font-medium w-1/3">End Time:</span>
<span>{formatTime(request.end_time)}</span>
</div>
<div className="flex">
<span className="font-medium w-1/3">Duration:</span>
<span>{calculateDuration(request.start_time, request.end_time)}</span>
</div>
</div>
)}
</div>
</div>
<h4 className="font-medium mb-2">Search Results</h4>
<div className="space-y-2">
{request.vector_store_search_response.data.map((result, resultIndex) => {
const isExpanded = expandedResults[`${index}-${resultIndex}`] || false;
return (
<div key={resultIndex} className="border rounded-lg overflow-hidden">
<div
className="flex items-center p-3 bg-gray-50 cursor-pointer"
onClick={() => toggleResult(index, resultIndex)}
>
<svg
className={`w-5 h-5 mr-2 transition-transform ${isExpanded ? "transform rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<div className="flex items-center">
<span className="font-medium mr-2">Result {resultIndex + 1}</span>
<span className="text-gray-500 text-sm">
Score: <span className="font-mono">{result.score.toFixed(4)}</span>
</span>
</div>
</div>
{isExpanded && (
<div className="p-3 border-t bg-white">
{result.content.map((content, contentIndex) => (
<div key={contentIndex} className="mb-2 last:mb-0">
<div className="text-xs text-gray-500 mb-1">{content.type}</div>
<pre className="text-xs font-mono whitespace-pre-wrap break-all bg-gray-50 p-2 rounded-sm">
{content.text}
</pre>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
))}
);
})}
</div>
</div>
),
},
]}
/>
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}