Merge pull request #33357 from BerriAI/litellm_/gallant-carson-880e37

refactor(ui): migrate policies, deleted keys, deleted teams, budgets, and search tools tables onto shared DataTable
This commit is contained in:
yuneng-jiang 2026-07-16 07:11:15 -07:00 • committed by GitHub
parent ff06119aa9
commit dce1beadca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1485 additions and 1409 deletions

View file

@ -950,19 +950,6 @@
"count": 1
}
},
"src/app/(dashboard)/policies/_components/policy_table.test.tsx": {
"react/display-name": {
"count": 1
}
},
"src/app/(dashboard)/policies/_components/policy_table.tsx": {
"no-nested-ternary": {
"count": 2
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/policies/_components/policy_test_panel.tsx": {
"no-restricted-imports": {
"count": 1
@ -1448,22 +1435,6 @@
"count": 1
}
},
"src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/EntityUsageExport/ExportSummary.tsx": {
"no-restricted-imports": {
"count": 1

View file

@ -0,0 +1,87 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import BudgetTable from "./BudgetTable";
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
const makeBudget = (overrides: Partial<budgetItem> = {}): budgetItem => ({
budget_id: "budget-1",
max_budget: 100,
tpm_limit: 1000,
rpm_limit: 10,
updated_at: "2024-01-01T00:00:00Z",
...overrides,
});
const defaultProps = {
budgets: [makeBudget()],
isLoading: false,
canModify: true,
onEditClick: vi.fn(),
onDeleteClick: vi.fn(),
};
describe("BudgetTable", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should display budget information", () => {
renderWithProviders(<BudgetTable {...defaultProps} />);
expect(screen.getByText("budget-1")).toBeInTheDocument();
expect(screen.getByText("$100.00")).toBeInTheDocument();
expect(screen.getByText("1000")).toBeInTheDocument();
expect(screen.getByText("10")).toBeInTheDocument();
});
it("should show n/a for missing rate limits and Unlimited for a missing max budget", () => {
renderWithProviders(
<BudgetTable {...defaultProps} budgets={[makeBudget({ max_budget: null, tpm_limit: null, rpm_limit: null })]} />,
);
expect(screen.getAllByText("n/a")).toHaveLength(2);
expect(screen.getByText("Unlimited")).toBeInTheDocument();
});
it("should sort budgets by updated_at descending", () => {
const budgets = [
makeBudget({ budget_id: "budget-old", updated_at: "2024-01-01T00:00:00Z" }),
makeBudget({ budget_id: "budget-new", updated_at: "2024-06-01T00:00:00Z" }),
];
renderWithProviders(<BudgetTable {...defaultProps} budgets={budgets} />);
const rows = screen.getAllByRole("row").slice(1);
expect(within(rows[0]).getByText("budget-new")).toBeInTheDocument();
expect(within(rows[1]).getByText("budget-old")).toBeInTheDocument();
});
it("should call onEditClick from the actions menu", async () => {
const user = userEvent.setup();
renderWithProviders(<BudgetTable {...defaultProps} />);
await user.click(screen.getByTestId("budget-actions-budget-1"));
await user.click(await screen.findByTestId("budget-action-edit"));
expect(defaultProps.onEditClick).toHaveBeenCalledWith(defaultProps.budgets[0]);
});
it("should call onDeleteClick from the actions menu", async () => {
const user = userEvent.setup();
renderWithProviders(<BudgetTable {...defaultProps} />);
await user.click(screen.getByTestId("budget-actions-budget-1"));
await user.click(await screen.findByTestId("budget-action-delete"));
expect(defaultProps.onDeleteClick).toHaveBeenCalledWith(defaultProps.budgets[0]);
});
it("should not render the actions menu when the user cannot modify budgets", () => {
renderWithProviders(<BudgetTable {...defaultProps} canModify={false} />);
expect(screen.queryByTestId("budget-actions-budget-1")).not.toBeInTheDocument();
});
it("should show skeleton rows when loading", () => {
renderWithProviders(<BudgetTable {...defaultProps} budgets={[]} isLoading />);
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
});
it("should show the empty state when there are no budgets", () => {
renderWithProviders(<BudgetTable {...defaultProps} budgets={[]} />);
expect(screen.getByText("No budgets yet")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,57 @@
"use client";
import { Inbox } from "lucide-react";
import React, { useMemo } from "react";
import { DataTable } from "@/components/shared/DataTable";
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { getBudgetTableColumns } from "./BudgetTableColumns";
interface BudgetTableProps {
budgets: budgetItem[];
isLoading: boolean;
canModify: boolean;
onEditClick: (budget: budgetItem) => void;
onDeleteClick: (budget: budgetItem) => void;
}
function EmptyState() {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No budgets yet</div>
<div className="text-sm text-muted-foreground">
Create a budget to set spend, TPM and RPM limits for customers.
</div>
</div>
);
}
const BudgetTable: React.FC<BudgetTableProps> = ({ budgets, isLoading, canModify, onEditClick, onDeleteClick }) => {
const rows = useMemo(
() => [...budgets].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()),
[budgets],
);
const columns = useMemo(
() => getBudgetTableColumns({ canModify, onEditClick, onDeleteClick }),
[canModify, onEditClick, onDeleteClick],
);
return (
<DataTable
data={rows}
columns={columns}
getRowId={(budget, index) => budget.budget_id || String(index)}
isLoading={isLoading}
loadingMessage="Loading budgets…"
noDataMessage={<EmptyState />}
size="compact"
/>
);
};
export default BudgetTable;

View file

@ -0,0 +1,124 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { IdCell, MoneyCell } from "@/components/shared/table_cells";
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
function RateLimitCell({ value }: { value: number | null }) {
if (value == null) {
return <span className="text-muted-foreground">n/a</span>;
}
return <span className="tabular-nums">{value}</span>;
}
interface BudgetRowActionsProps {
budget: budgetItem;
onEditClick: (budget: budgetItem) => void;
onDeleteClick: (budget: budgetItem) => void;
}
function BudgetRowActions({ budget, onEditClick, onDeleteClick }: BudgetRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open budget actions"
data-testid={`budget-actions-${budget.budget_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="budget-action-edit" onClick={() => onEditClick(budget)}>
<Pencil />
Edit budget
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
data-testid="budget-action-delete"
onClick={() => onDeleteClick(budget)}
>
<Trash2 />
Delete budget
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface BudgetTableColumnsDeps {
canModify: boolean;
onEditClick: (budget: budgetItem) => void;
onDeleteClick: (budget: budgetItem) => void;
}
export const getBudgetTableColumns = ({
canModify,
onEditClick,
onDeleteClick,
}: BudgetTableColumnsDeps): ColumnDef<budgetItem>[] => [
{
id: "budget_id",
accessorKey: "budget_id",
meta: { title: "Budget ID" },
header: "Budget ID",
size: 220,
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.budget_id} variant="plain" />,
},
{
id: "max_budget",
accessorKey: "max_budget",
meta: { title: "Max Budget", numeric: true },
header: "Max Budget",
size: 120,
enableSorting: false,
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={2} showZero emptyText="Unlimited" />,
},
{
id: "tpm_limit",
accessorKey: "tpm_limit",
meta: { title: "TPM", numeric: true },
header: "TPM",
size: 100,
enableSorting: false,
cell: ({ row }) => <RateLimitCell value={row.original.tpm_limit} />,
},
{
id: "rpm_limit",
accessorKey: "rpm_limit",
meta: { title: "RPM", numeric: true },
header: "RPM",
size: 100,
enableSorting: false,
cell: ({ row }) => <RateLimitCell value={row.original.rpm_limit} />,
},
...(canModify
? [
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<BudgetRowActions budget={row.original} onEditClick={onEditClick} onDeleteClick={onDeleteClick} />
</div>
),
} satisfies ColumnDef<budgetItem>,
]
: []),
];

View file

@ -1,5 +1,6 @@
import { fireEvent, render, waitFor, screen } from "@testing-library/react";
import { act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import BudgetPanel from "./budget_panel";
@ -57,7 +58,8 @@ describe("Budget Panel", () => {
});
});
it("should open delete modal when clicking delete icon", async () => {
it("should open delete modal from the actions menu", async () => {
const user = userEvent.setup();
vi.mocked(useBudgets).mockReturnValue({
data: [
{
@ -77,11 +79,8 @@ describe("Budget Panel", () => {
expect(screen.getByText("budget-to-delete")).toBeInTheDocument();
});
const deleteButton = screen.getByTestId("delete-budget-button");
act(() => {
fireEvent.click(deleteButton);
});
await user.click(screen.getByTestId("budget-actions-budget-to-delete"));
await user.click(await screen.findByTestId("budget-action-delete"));
await waitFor(() => {
expect(screen.getByText("Delete Budget?")).toBeInTheDocument();
@ -89,6 +88,7 @@ describe("Budget Panel", () => {
});
it("should successfully delete a budget", async () => {
const user = userEvent.setup();
const deleteMutateAsync = vi.fn().mockResolvedValue(undefined);
vi.mocked(useBudgets).mockReturnValue({
data: [
@ -113,17 +113,13 @@ describe("Budget Panel", () => {
expect(screen.getByText("budget-to-delete")).toBeInTheDocument();
});
// Open delete modal
const deleteButton = screen.getByTestId("delete-budget-button");
act(() => {
fireEvent.click(deleteButton);
});
await user.click(screen.getByTestId("budget-actions-budget-to-delete"));
await user.click(await screen.findByTestId("budget-action-delete"));
await waitFor(() => {
expect(screen.getByText("Delete Budget?")).toBeInTheDocument();
});
// Confirm delete
const confirmButton = screen.getByRole("button", { name: /delete/i });
act(() => {
fireEvent.click(confirmButton);
@ -148,6 +144,7 @@ describe("Budget Panel", () => {
});
it("should handle delete error", async () => {
const user = userEvent.setup();
const deleteMutateAsync = vi.fn().mockRejectedValue(new Error("Delete failed"));
vi.mocked(useBudgets).mockReturnValue({
data: [
@ -172,17 +169,13 @@ describe("Budget Panel", () => {
expect(screen.getByText("budget-to-delete")).toBeInTheDocument();
});
// Open delete modal
const deleteButton = screen.getByTestId("delete-budget-button");
act(() => {
fireEvent.click(deleteButton);
});
await user.click(screen.getByTestId("budget-actions-budget-to-delete"));
await user.click(await screen.findByTestId("budget-action-delete"));
await waitFor(() => {
expect(screen.getByText("Delete Budget?")).toBeInTheDocument();
});
// Confirm delete
const confirmButton = screen.getByRole("button", { name: /delete/i });
act(() => {
fireEvent.click(confirmButton);
@ -193,7 +186,8 @@ describe("Budget Panel", () => {
});
});
it("should open edit modal when clicking edit icon", async () => {
it("should open edit modal from the actions menu", async () => {
const user = userEvent.setup();
vi.mocked(useBudgets).mockReturnValue({
data: [
{
@ -213,11 +207,8 @@ describe("Budget Panel", () => {
expect(screen.getByText("budget-to-edit")).toBeInTheDocument();
});
const editButton = screen.getByTestId("edit-budget-button");
act(() => {
fireEvent.click(editButton);
});
await user.click(screen.getByTestId("budget-actions-budget-to-edit"));
await user.click(await screen.findByTestId("budget-action-edit"));
await waitFor(() => {
expect(screen.getByText("Edit Budget")).toBeInTheDocument();

View file

@ -3,30 +3,14 @@
*
*/
import {
Button,
Card,
Tab,
TabGroup,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TabList,
TabPanel,
TabPanels,
Text,
} from "@tremor/react";
import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text } from "@tremor/react";
import React, { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { MoneyCell } from "@/components/shared/table_cells";
import BudgetModal from "./budget_modal";
import BudgetTable from "./BudgetTable";
import EditBudgetModal from "./edit_budget_modal";
import { CREATE_END_USER_CURL_COMMAND, CHAT_COMPLETIONS_CURL_COMMAND, OPENAI_SDK_PYTHON_CODE } from "./constants";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
@ -46,7 +30,7 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
// Admin Viewer follows the read-parity rule: see budgets, no writes.
const canModify = isProxyAdminRole(userRole ?? "");
const { data: budgetList = [] } = useBudgets();
const { data: budgetList = [], isLoading } = useBudgets();
const deleteBudget = useDeleteBudget();
const handleEditCall = async (budget: budgetItem) => {
@ -109,51 +93,14 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
existingBudget={selectedBudget}
/>
)}
<Card>
<Text>Create a budget to assign to customers.</Text>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Budget ID</TableHeaderCell>
<TableHeaderCell>Max Budget</TableHeaderCell>
<TableHeaderCell>TPM</TableHeaderCell>
<TableHeaderCell>RPM</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{budgetList
.slice()
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.map((value: budgetItem) => (
<TableRow key={value.budget_id}>
<TableCell>{value.budget_id}</TableCell>
<TableCell>
<MoneyCell value={value.max_budget} decimals={2} showZero emptyText="Unlimited" />
</TableCell>
<TableCell>{value.tpm_limit ? value.tpm_limit : "n/a"}</TableCell>
<TableCell>{value.rpm_limit ? value.rpm_limit : "n/a"}</TableCell>
{canModify && (
<>
<TableIconActionButton
variant="Edit"
tooltipText="Edit budget"
onClick={() => handleEditCall(value)}
dataTestId="edit-budget-button"
/>
<TableIconActionButton
variant="Delete"
tooltipText="Delete budget"
onClick={() => handleDeleteClick(value)}
dataTestId="delete-budget-button"
/>
</>
)}
</TableRow>
))}
</TableBody>
</Table>
</Card>
<Text className="mb-4">Create a budget to assign to customers.</Text>
<BudgetTable
budgets={budgetList}
isLoading={isLoading}
canModify={canModify}
onEditClick={handleEditCall}
onDeleteClick={handleDeleteClick}
/>
<DeleteResourceModal
isOpen={isDeleteModalVisible}
title="Delete Budget?"

View file

@ -118,10 +118,10 @@ export const useDeletedKeys = (
page: number,
pageSize: number,
options: KeyListCallOptions = {},
): UseQueryResult<KeysResponse> => {
): UseQueryResult<DeletedKeysResponse> => {
const { accessToken } = useAuthorized();
return useQuery<KeysResponse>({
return useQuery<DeletedKeysResponse>({
queryKey: deletedKeyKeys.list({ page, limit: pageSize, ...options }),
queryFn: async () => await keyListCall(accessToken!, page, pageSize, { ...options, status: "deleted" }),
enabled: Boolean(accessToken),

View file

@ -1,47 +1,11 @@
import React from "react";
import { screen } from "@testing-library/react";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/../tests/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import PolicyTable from "./policy_table";
import PolicyTable from "./PolicyTable";
import { Policy } from "@/components/policies/types";
vi.mock("@heroicons/react/outline", () => ({
TrashIcon: function TrashIcon() {
return null;
},
PencilIcon: function PencilIcon() {
return null;
},
SwitchVerticalIcon: function SwitchVerticalIcon() {
return null;
},
ChevronUpIcon: function ChevronUpIcon() {
return null;
},
ChevronDownIcon: function ChevronDownIcon() {
return null;
},
}));
vi.mock("@tremor/react", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tremor/react")>();
return {
...actual,
Button: React.forwardRef<HTMLButtonElement, any>(({ children, ...props }, ref) =>
React.createElement("button", { ...props, ref }, children),
),
Icon: ({ icon: IconComp, onClick, className }: any) =>
React.createElement(
"button",
{ type: "button", onClick, className },
IconComp?.displayName ?? IconComp?.name ?? "icon",
),
Tooltip: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children),
Badge: ({ children }: { children?: React.ReactNode }) => React.createElement("span", null, children),
};
});
const makePolicy = (overrides: Partial<Policy> = {}): Policy => ({
policy_id: "policy-id-1",
policy_name: "test-policy",
@ -71,20 +35,21 @@ describe("PolicyTable", () => {
renderWithProviders(<PolicyTable {...defaultProps} />);
expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.getByText("Description")).toBeInTheDocument();
expect(screen.getByText("Actions")).toBeInTheDocument();
expect(screen.getByText("Guardrails (Add)")).toBeInTheDocument();
expect(screen.getByText("Created At")).toBeInTheDocument();
});
it("should show a loading message when isLoading is true", () => {
it("should show skeleton rows when isLoading is true", () => {
renderWithProviders(<PolicyTable {...defaultProps} isLoading />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
});
it("should show 'No policies found' when there are no policies", () => {
it("should show the empty state when there are no policies", () => {
renderWithProviders(<PolicyTable {...defaultProps} />);
expect(screen.getByText(/no policies found/i)).toBeInTheDocument();
expect(screen.getByText("No policies found")).toBeInTheDocument();
});
it("should render a button with the policy name for each grouped policy", () => {
it("should render a clickable name cell for each grouped policy", () => {
const policies = [
makePolicy({ policy_name: "alpha-policy", policy_id: "id-1" }),
makePolicy({ policy_name: "beta-policy", policy_id: "id-2" }),
@ -94,7 +59,18 @@ describe("PolicyTable", () => {
expect(screen.getByRole("button", { name: "beta-policy" })).toBeInTheDocument();
});
it("should call onViewClick with the policy_id when the policy name button is clicked", async () => {
it("should sort rows by policy name ascending by default", () => {
const policies = [
makePolicy({ policy_name: "zeta-policy", policy_id: "id-z" }),
makePolicy({ policy_name: "alpha-policy", policy_id: "id-a" }),
];
renderWithProviders(<PolicyTable {...defaultProps} policies={policies} />);
const rows = screen.getAllByRole("row").slice(1);
expect(within(rows[0]).getByText("alpha-policy")).toBeInTheDocument();
expect(within(rows[1]).getByText("zeta-policy")).toBeInTheDocument();
});
it("should call onViewClick with the policy_id when the policy name is clicked", async () => {
const user = userEvent.setup();
const policy = makePolicy({ policy_name: "my-policy", policy_id: "view-id-1" });
renderWithProviders(<PolicyTable {...defaultProps} policies={[policy]} />);
@ -102,36 +78,46 @@ describe("PolicyTable", () => {
expect(defaultProps.onViewClick).toHaveBeenCalledWith("view-id-1");
});
it("should call onDeleteClick with policy_id and policy_name when the delete icon is clicked", async () => {
it("should call onDeleteClick with policy_id and policy_name from the actions menu", async () => {
const user = userEvent.setup();
const policy = makePolicy({ policy_name: "del-policy", policy_id: "del-id-1" });
renderWithProviders(<PolicyTable {...defaultProps} policies={[policy]} />);
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
await user.click(screen.getByTestId("policy-actions-del-id-1"));
await user.click(await screen.findByTestId("policy-action-delete"));
expect(defaultProps.onDeleteClick).toHaveBeenCalledWith("del-id-1", "del-policy");
});
it("should call onEditClick with the policy when the edit icon is clicked", async () => {
it("should call onEditClick with the policy from the actions menu", async () => {
const user = userEvent.setup();
const policy = makePolicy({ policy_name: "edit-policy", policy_id: "edit-id-1" });
renderWithProviders(<PolicyTable {...defaultProps} policies={[policy]} />);
await user.click(screen.getByRole("button", { name: /PencilIcon/i }));
await user.click(screen.getByTestId("policy-actions-edit-id-1"));
await user.click(await screen.findByTestId("policy-action-edit"));
expect(defaultProps.onEditClick).toHaveBeenCalledWith(policy);
});
it("should not show admin action icons for non-admins", () => {
it("should not show the actions menu for non-admins", () => {
const policy = makePolicy();
renderWithProviders(<PolicyTable {...defaultProps} policies={[policy]} isAdmin={false} />);
expect(screen.queryByRole("button", { name: /TrashIcon/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /PencilIcon/i })).not.toBeInTheDocument();
expect(screen.queryByTestId(`policy-actions-${policy.policy_id}`)).not.toBeInTheDocument();
});
it("should show a version badge when multiple versions of the same policy name exist", () => {
const policies = [
makePolicy({ policy_name: "versioned", policy_id: "v1", version_status: "published", version_number: 1 }),
makePolicy({ policy_name: "versioned", policy_id: "v2", version_status: "production", version_number: 2 }),
];
const publishedVersion: Partial<Policy> = {
policy_name: "versioned",
policy_id: "v1",
version_status: "published",
version_number: 1,
};
const productionVersion: Partial<Policy> = {
policy_name: "versioned",
policy_id: "v2",
version_status: "production",
version_number: 2,
};
const policies = [makePolicy(publishedVersion), makePolicy(productionVersion)];
renderWithProviders(<PolicyTable {...defaultProps} policies={policies} />);
expect(screen.getByText(/2 version/i)).toBeInTheDocument();
expect(screen.getByText("2 versions")).toBeInTheDocument();
});
it("should group policies with the same name into a single row", () => {
@ -140,10 +126,10 @@ describe("PolicyTable", () => {
makePolicy({ policy_name: "shared", policy_id: "s2", version_status: "production" }),
];
renderWithProviders(<PolicyTable {...defaultProps} policies={policies} />);
expect(screen.getAllByRole("button", { name: "shared" })).toHaveLength(1);
expect(screen.getAllByText("shared")).toHaveLength(1);
});
it("should show an overflow tag when more than 2 guardrails_add exist", () => {
it("should show an overflow badge when more than 2 guardrails_add exist", () => {
const policy = makePolicy({ guardrails_add: ["g1", "g2", "g3", "g4"] });
renderWithProviders(<PolicyTable {...defaultProps} policies={[policy]} />);
expect(screen.getByText("+2")).toBeInTheDocument();
@ -156,7 +142,7 @@ describe("PolicyTable", () => {
makePolicy({ policy_name: "grouped", policy_id: "prod-id", version_status: "production" }),
];
renderWithProviders(<PolicyTable {...defaultProps} policies={policies} />);
await user.click(screen.getByRole("button", { name: "grouped" }));
await user.click(screen.getByRole("button", { name: /grouped/ }));
expect(defaultProps.onViewClick).toHaveBeenCalledWith("prod-id");
});
});

View file

@ -0,0 +1,82 @@
"use client";
import { SortingState } from "@tanstack/react-table";
import { Inbox } from "lucide-react";
import React, { useMemo, useState } from "react";
import { DataTable } from "@/components/shared/DataTable";
import { Policy } from "@/components/policies/types";
import { getPolicyTableColumns, PolicyRow } from "./PolicyTableColumns";
/** One row per policy name; primaryPolicy is used for display and for Edit (FlowBuilder loads all versions) */
function groupPoliciesByName(policies: Policy[]): PolicyRow[] {
const names = Array.from(new Set(policies.map((policy) => policy.policy_name || "(unnamed)")));
return names.map((policyName) => {
const versions = policies.filter((policy) => (policy.policy_name || "(unnamed)") === policyName);
const primary =
versions.find((version) => version.version_status === "production") ??
[...versions].sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0))[0];
return { policy_name: policyName, primaryPolicy: primary, versionCount: versions.length };
});
}
interface PolicyTableProps {
policies: Policy[];
isLoading: boolean;
onDeleteClick: (policyId: string, policyName: string) => void;
onEditClick: (policy: Policy) => void;
onViewClick: (policyId: string) => void;
isAdmin?: boolean;
}
const DEFAULT_SORTING: SortingState = [{ id: "policy_name", desc: false }];
function EmptyState() {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No policies found</div>
<div className="text-sm text-muted-foreground">
Create a policy to bundle guardrails and apply them across teams.
</div>
</div>
);
}
const PolicyTable: React.FC<PolicyTableProps> = ({
policies,
isLoading,
onDeleteClick,
onEditClick,
onViewClick,
isAdmin = false,
}) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const rows = useMemo(() => groupPoliciesByName(policies), [policies]);
const columns = useMemo(() => {
const deps = { isAdmin, onViewClick, onEditClick, onDeleteClick };
return getPolicyTableColumns(deps);
}, [isAdmin, onViewClick, onEditClick, onDeleteClick]);
return (
<DataTable
data={rows}
columns={columns}
getRowId={(row) => row.policy_name}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading policies…"
noDataMessage={<EmptyState />}
size="compact"
/>
);
};
export default PolicyTable;

View file

@ -0,0 +1,207 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
import { Policy } from "@/components/policies/types";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
export interface PolicyRow {
policy_name: string;
primaryPolicy: Policy;
versionCount: number;
}
function GuardrailChips({ guardrails, tone }: { guardrails: string[]; tone: "success" | "error" }) {
if (guardrails.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="flex flex-wrap items-center gap-1">
{guardrails.slice(0, 2).map((guardrail) => (
<StatusBadge key={guardrail} tone={tone} label={guardrail} />
))}
{guardrails.length > 2 && (
<StatusBadge tone="neutral" label={`+${guardrails.length - 2}`} tooltip={guardrails.slice(2).join(", ")} />
)}
</div>
);
}
interface PolicyRowActionsProps {
policy: Policy;
onEditClick: (policy: Policy) => void;
onDeleteClick: (policyId: string, policyName: string) => void;
}
function PolicyRowActions({ policy, onEditClick, onDeleteClick }: PolicyRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open policy actions"
data-testid={`policy-actions-${policy.policy_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="policy-action-edit" onClick={() => onEditClick(policy)}>
<Pencil />
Edit policy
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
data-testid="policy-action-delete"
onClick={() => onDeleteClick(policy.policy_id, policy.policy_name || "Unnamed Policy")}
>
<Trash2 />
Delete policy
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface PolicyTableColumnsDeps {
isAdmin: boolean;
onViewClick: (policyId: string) => void;
onEditClick: (policy: Policy) => void;
onDeleteClick: (policyId: string, policyName: string) => void;
}
export const getPolicyTableColumns = ({
isAdmin,
onViewClick,
onEditClick,
onDeleteClick,
}: PolicyTableColumnsDeps): ColumnDef<PolicyRow>[] => [
{
id: "policy_name",
accessorKey: "policy_name",
meta: { title: "Name", skeleton: "twoLine" },
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
size: 220,
enableSorting: true,
cell: ({ row }) => (
<IdentityCell
title={row.original.policy_name}
titleClassName="max-w-60"
badge={
row.original.versionCount > 1 ? (
<StatusBadge tone="neutral" label={`${row.original.versionCount} versions`} />
) : undefined
}
onClick={() => onViewClick(row.original.primaryPolicy.policy_id)}
/>
),
},
{
id: "description",
accessorFn: (row) => row.primaryPolicy.description ?? "",
meta: { title: "Description" },
header: "Description",
size: 220,
enableSorting: false,
cell: ({ row }) => {
const description = row.original.primaryPolicy.description;
if (!description) {
return <span className="text-muted-foreground">-</span>;
}
return (
<span className="block max-w-60 truncate text-muted-foreground" title={description}>
{description}
</span>
);
},
},
{
id: "inherit",
accessorFn: (row) => row.primaryPolicy.inherit ?? "",
meta: { title: "Inherits From", skeleton: "badge" },
header: "Inherits From",
size: 150,
enableSorting: false,
cell: ({ row }) => {
const inherit = row.original.primaryPolicy.inherit;
if (!inherit) {
return <span className="text-muted-foreground">-</span>;
}
return <StatusBadge tone="info" label={inherit} />;
},
},
{
id: "guardrails_add",
meta: { title: "Guardrails (Add)", skeleton: "chips" },
header: "Guardrails (Add)",
size: 180,
enableSorting: false,
cell: ({ row }) => <GuardrailChips guardrails={row.original.primaryPolicy.guardrails_add ?? []} tone="success" />,
},
{
id: "guardrails_remove",
meta: { title: "Guardrails (Remove)", skeleton: "chips" },
header: "Guardrails (Remove)",
size: 180,
enableSorting: false,
cell: ({ row }) => <GuardrailChips guardrails={row.original.primaryPolicy.guardrails_remove ?? []} tone="error" />,
},
{
id: "model_condition",
meta: { title: "Model Condition" },
header: "Model Condition",
size: 160,
enableSorting: false,
cell: ({ row }) => {
const model = row.original.primaryPolicy.condition?.model;
if (!model) {
return <span className="text-muted-foreground">-</span>;
}
return (
<code className="block max-w-40 truncate rounded-sm bg-muted px-1 py-0.5 font-mono text-xs" title={model}>
{model}
</code>
);
},
},
{
id: "created_at",
accessorFn: (row) => row.primaryPolicy.created_at ?? "",
meta: { title: "Created At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
size: 150,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.primaryPolicy.created_at} />,
},
...(isAdmin
? [
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<PolicyRowActions
policy={row.original.primaryPolicy}
onEditClick={onEditClick}
onDeleteClick={onDeleteClick}
/>
</div>
),
} satisfies ColumnDef<PolicyRow>,
]
: []),
];

View file

@ -5,7 +5,7 @@ import { Alert } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import { InfoCircleOutlined } from "@ant-design/icons";
import { isAdminRole } from "@/utils/roles";
import PolicyTable from "./policy_table";
import PolicyTable from "./PolicyTable";
import PolicyInfoView from "./policy_info";
import AddPolicyForm from "./add_policy_form";
import { FlowBuilderPage } from "./pipeline_flow_builder";

View file

@ -1,326 +0,0 @@
import React, { useMemo, useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Icon, Button, Badge } from "@tremor/react";
import { TrashIcon, PencilIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
import { Tooltip, Tag } from "antd";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import { DateCell } from "@/components/shared/table_cells";
import { Policy } from "@/components/policies/types";
/** One row per policy name; primaryPolicy is used for display and for Edit (FlowBuilder loads all versions) */
interface PolicyRow {
policy_name: string;
primaryPolicy: Policy;
versionCount: number;
}
function groupPoliciesByName(policies: Policy[]): PolicyRow[] {
const byName = new Map<string, Policy[]>();
for (const p of policies) {
const name = p.policy_name || "(unnamed)";
if (!byName.has(name)) byName.set(name, []);
byName.get(name)!.push(p);
}
const rows: PolicyRow[] = [];
for (const [policyName, versions] of byName) {
// Prefer production, then highest version_number
const primary =
versions.find((v) => v.version_status === "production") ??
[...versions].sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0))[0] ??
versions[0];
rows.push({ policy_name: policyName, primaryPolicy: primary, versionCount: versions.length });
}
return rows.sort((a, b) => a.policy_name.localeCompare(b.policy_name));
}
interface PolicyTableProps {
policies: Policy[];
isLoading: boolean;
onDeleteClick: (policyId: string, policyName: string) => void;
onEditClick: (policy: Policy) => void;
onViewClick: (policyId: string) => void;
isAdmin?: boolean;
}
const PolicyTable: React.FC<PolicyTableProps> = ({
policies,
isLoading,
onDeleteClick,
onEditClick,
onViewClick,
isAdmin = false,
}) => {
const [sorting, setSorting] = useState<SortingState>([{ id: "policy_name", desc: false }]);
const rows = useMemo(() => groupPoliciesByName(policies), [policies]);
const columns: ColumnDef<PolicyRow>[] = [
{
header: "Name",
accessorKey: "policy_name",
cell: ({ row }) => {
const { primaryPolicy, versionCount } = row.original;
return (
<div className="flex items-center gap-2">
<Tooltip
title={`${primaryPolicy.policy_name || "-"}${versionCount > 1 ? ` (${versionCount} versions)` : ""}`}
>
<Button
size="xs"
variant="light"
className="font-medium text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left"
onClick={() => primaryPolicy.policy_id && onViewClick(primaryPolicy.policy_id)}
>
{primaryPolicy.policy_name || "-"}
</Button>
</Tooltip>
{versionCount > 1 && (
<Badge color="gray" size="xs">
{versionCount} version{versionCount !== 1 ? "s" : ""}
</Badge>
)}
</div>
);
},
},
{
header: "Description",
accessorFn: (row) => row.primaryPolicy.description ?? "",
cell: ({ row }) => {
const policy = row.original.primaryPolicy;
return (
<Tooltip title={policy.description}>
<span className="text-xs truncate max-w-[200px] block">{policy.description || "-"}</span>
</Tooltip>
);
},
},
{
header: "Inherits From",
accessorFn: (row) => row.primaryPolicy.inherit ?? "",
cell: ({ row }) => {
const policy = row.original.primaryPolicy;
return policy.inherit ? (
<Badge color="blue" size="xs">
{policy.inherit}
</Badge>
) : (
<span className="text-xs text-gray-400">-</span>
);
},
},
{
header: "Guardrails (Add)",
accessorFn: (row) => (row.primaryPolicy.guardrails_add ?? []).join(", "),
cell: ({ row }) => {
const policy = row.original.primaryPolicy;
const guardrails = policy.guardrails_add || [];
if (guardrails.length === 0) {
return <span className="text-xs text-gray-400">-</span>;
}
return (
<div className="flex flex-wrap gap-1">
{guardrails.slice(0, 2).map((g, i) => (
<Tag key={i} color="green" className="text-xs">
{g}
</Tag>
))}
{guardrails.length > 2 && (
<Tooltip title={guardrails.slice(2).join(", ")}>
<Tag className="text-xs">+{guardrails.length - 2}</Tag>
</Tooltip>
)}
</div>
);
},
},
{
header: "Guardrails (Remove)",
accessorFn: (row) => (row.primaryPolicy.guardrails_remove ?? []).join(", "),
cell: ({ row }) => {
const policy = row.original.primaryPolicy;
const guardrails = policy.guardrails_remove || [];
if (guardrails.length === 0) {
return <span className="text-xs text-gray-400">-</span>;
}
return (
<div className="flex flex-wrap gap-1">
{guardrails.slice(0, 2).map((g, i) => (
<Tag key={i} color="red" className="text-xs">
{g}
</Tag>
))}
{guardrails.length > 2 && (
<Tooltip title={guardrails.slice(2).join(", ")}>
<Tag className="text-xs">+{guardrails.length - 2}</Tag>
</Tooltip>
)}
</div>
);
},
},
{
header: "Model Condition",
accessorFn: (row) => {
const m = row.primaryPolicy.condition?.model;
return typeof m === "string" ? m : JSON.stringify(m ?? "");
},
cell: ({ row }) => {
const policy = row.original.primaryPolicy;
const modelCondition = policy.condition?.model;
if (!modelCondition) {
return <span className="text-xs text-gray-400">-</span>;
}
return (
<Tooltip title={typeof modelCondition === "string" ? modelCondition : JSON.stringify(modelCondition)}>
<code className="text-xs bg-gray-100 px-1 py-0.5 rounded-sm">
{typeof modelCondition === "string"
? modelCondition.length > 20
? modelCondition.slice(0, 20) + "..."
: modelCondition
: "Multiple"}
</code>
</Tooltip>
);
},
},
{
header: "Created At",
id: "created_at",
accessorFn: (row) => row.primaryPolicy.created_at ?? "",
cell: ({ row }) => <DateCell value={row.original.primaryPolicy.created_at} />,
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => {
const { primaryPolicy } = row.original;
const policy = primaryPolicy;
return (
<div className="flex space-x-2">
{isAdmin && (
<>
<Tooltip title="Edit policy">
<Icon
icon={PencilIcon}
size="sm"
onClick={() => onEditClick(policy)}
className="cursor-pointer hover:text-blue-500"
/>
</Tooltip>
<Tooltip title="Delete policy">
<Icon
icon={TrashIcon}
size="sm"
onClick={() =>
policy.policy_id && onDeleteClick(policy.policy_id, policy.policy_name || "Unnamed Policy")
}
className="cursor-pointer hover:text-red-500"
/>
</Tooltip>
</>
)}
</div>
);
},
},
];
const table = useReactTable({
data: rows,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableSorting: true,
});
return (
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
className={`py-1 h-8 ${
header.id === "actions" ? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]" : ""
}`}
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</div>
{header.id !== "actions" && (
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
)}
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>Loading...</p>
</div>
</TableCell>
</TableRow>
) : rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.original.policy_name} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
cell.column.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No policies found</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
};
export default PolicyTable;

View file

@ -1,104 +0,0 @@
import { Tag } from "antd";
import { ColumnsType } from "antd/es/table";
import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import { SearchTool } from "./types";
export const searchToolColumns = (
onView: (searchToolId: string) => void,
onEdit: (searchToolId: string) => void,
onDelete: (searchToolId: string) => void,
availableProviders: Array<{ provider_name: string; ui_friendly_name: string }>,
): ColumnsType<SearchTool> => [
{
title: "Search Tool ID",
dataIndex: "search_tool_id",
key: "search_tool_id",
render: (_, tool) => {
const isFromConfig = tool.is_from_config;
if (isFromConfig) {
return <span className="text-xs">-</span>;
}
return <IdCell value={tool.search_tool_id} onClick={onView} />;
},
},
{
title: "Name",
dataIndex: "search_tool_name",
key: "search_tool_name",
render: (name: string) => <span className="font-medium">{name}</span>,
},
{
title: "Provider",
key: "provider",
render: (_, tool) => {
const provider = tool.litellm_params.search_provider;
const providerInfo = availableProviders.find((p) => p.provider_name === provider);
const displayName = providerInfo?.ui_friendly_name || provider;
return <span className="text-sm">{displayName}</span>;
},
},
{
title: "Created At",
dataIndex: "created_at",
key: "created_at",
render: (_, tool) => {
return <DateCell value={tool.created_at} precision="date" />;
},
},
{
title: "Updated At",
dataIndex: "updated_at",
key: "updated_at",
render: (_, tool) => {
return <DateCell value={tool.updated_at} precision="date" />;
},
},
{
title: "Source",
key: "source",
render: (_, tool) => {
const isFromConfig = tool.is_from_config ?? false;
return <Tag color={isFromConfig ? "default" : "blue"}>{isFromConfig ? "Config" : "DB"}</Tag>;
},
},
{
title: "Actions",
key: "actions",
render: (_, tool) => {
const toolId = tool.search_tool_id;
const isFromConfig = tool.is_from_config ?? false;
return (
<div className="flex items-center gap-2">
<TableIconActionButton
variant="Edit"
tooltipText="Edit search tool"
disabled={isFromConfig}
disabledTooltipText="Config search tool cannot be edited on the dashboard. Please edit it from the config file."
onClick={() => {
if (toolId && !isFromConfig) {
onEdit(toolId);
}
}}
/>
<TableIconActionButton
variant="Delete"
tooltipText="Delete search tool"
disabled={isFromConfig}
disabledTooltipText="Config search tool cannot be deleted on the dashboard. Please delete it from the config file."
onClick={() => {
if (toolId && !isFromConfig) {
onDelete(toolId);
}
}}
/>
</div>
);
},
},
];

View file

@ -0,0 +1,117 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import SearchToolTable from "./SearchToolTable";
import { AvailableSearchProvider, SearchTool } from "./types";
const makeSearchTool = (overrides: Partial<SearchTool> = {}): SearchTool => ({
search_tool_id: "tool-1",
search_tool_name: "Perplexity Search",
litellm_params: {
search_provider: "perplexity",
},
created_at: "2024-01-15T10:30:00Z",
updated_at: "2024-01-16T10:30:00Z",
...overrides,
});
const availableProviders: AvailableSearchProvider[] = [
{ provider_name: "perplexity", ui_friendly_name: "Perplexity AI" },
];
const defaultProps = {
searchTools: [makeSearchTool()],
isLoading: false,
availableProviders,
onView: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
};
describe("SearchToolTable", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should display search tool information with the friendly provider name", () => {
renderWithProviders(<SearchToolTable {...defaultProps} />);
expect(screen.getByText("Perplexity Search")).toBeInTheDocument();
expect(screen.getByText("tool-1")).toBeInTheDocument();
expect(screen.getByText("Perplexity AI")).toBeInTheDocument();
expect(screen.getByText("DB")).toBeInTheDocument();
});
it("should call onView when the search tool ID is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<SearchToolTable {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /tool-1/ }));
expect(defaultProps.onView).toHaveBeenCalledWith("tool-1");
});
it("should call onEdit and onDelete from the actions menu for a DB tool", async () => {
const user = userEvent.setup();
renderWithProviders(<SearchToolTable {...defaultProps} />);
await user.click(screen.getByTestId("search-tool-actions-tool-1"));
await user.click(await screen.findByTestId("search-tool-action-edit"));
expect(defaultProps.onEdit).toHaveBeenCalledWith("tool-1");
await user.click(screen.getByTestId("search-tool-actions-tool-1"));
await user.click(await screen.findByTestId("search-tool-action-delete"));
expect(defaultProps.onDelete).toHaveBeenCalledWith("tool-1");
});
it("should show a dash instead of a clickable ID for config tools", () => {
const configTool = makeSearchTool({ search_tool_id: "config-tool", is_from_config: true });
renderWithProviders(<SearchToolTable {...defaultProps} searchTools={[configTool]} />);
expect(screen.queryByRole("button", { name: /config-tool/ })).not.toBeInTheDocument();
});
it("should disable Edit and Delete for config tools and suppress their callbacks", async () => {
const user = userEvent.setup();
const configTool = makeSearchTool({ search_tool_id: "config-tool", is_from_config: true });
renderWithProviders(<SearchToolTable {...defaultProps} searchTools={[configTool]} />);
await user.click(screen.getByTestId("search-tool-actions-config-tool"));
const editItem = await screen.findByTestId("search-tool-action-edit");
const deleteItem = await screen.findByTestId("search-tool-action-delete");
expect(editItem).toHaveAttribute("data-disabled");
expect(deleteItem).toHaveAttribute("data-disabled");
await user.click(editItem);
await user.click(deleteItem);
expect(defaultProps.onEdit).not.toHaveBeenCalled();
expect(defaultProps.onDelete).not.toHaveBeenCalled();
});
it("should sort tools by created_at descending by default", () => {
const tools = [
makeSearchTool({
search_tool_id: "tool-old",
search_tool_name: "older-tool",
created_at: "2024-01-01T00:00:00Z",
}),
makeSearchTool({
search_tool_id: "tool-new",
search_tool_name: "newer-tool",
created_at: "2024-06-01T00:00:00Z",
}),
];
renderWithProviders(<SearchToolTable {...defaultProps} searchTools={tools} />);
const rows = screen.getAllByRole("row").slice(1);
expect(within(rows[0]).getByText("newer-tool")).toBeInTheDocument();
expect(within(rows[1]).getByText("older-tool")).toBeInTheDocument();
});
it("should show skeleton rows when loading", () => {
renderWithProviders(<SearchToolTable {...defaultProps} searchTools={[]} isLoading />);
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
});
it("should show the empty state when there are no search tools", () => {
renderWithProviders(<SearchToolTable {...defaultProps} searchTools={[]} />);
expect(screen.getByText("No search tools configured")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,66 @@
"use client";
import { SortingState } from "@tanstack/react-table";
import { Inbox } from "lucide-react";
import React, { useMemo, useState } from "react";
import { DataTable } from "@/components/shared/DataTable";
import { getSearchToolTableColumns, searchToolKey } from "./SearchToolTableColumns";
import { AvailableSearchProvider, SearchTool } from "./types";
interface SearchToolTableProps {
searchTools: SearchTool[];
isLoading: boolean;
availableProviders: AvailableSearchProvider[];
onView: (searchToolId: string) => void;
onEdit: (searchToolId: string) => void;
onDelete: (searchToolId: string) => void;
}
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
function EmptyState() {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No search tools configured</div>
<div className="text-sm text-muted-foreground">Add a search tool to enable web search for your models.</div>
</div>
);
}
const SearchToolTable: React.FC<SearchToolTableProps> = ({
searchTools,
isLoading,
availableProviders,
onView,
onEdit,
onDelete,
}) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo(() => {
const deps = { availableProviders, onView, onEdit, onDelete };
return getSearchToolTableColumns(deps);
}, [availableProviders, onView, onEdit, onDelete]);
return (
<DataTable
data={searchTools}
columns={columns}
getRowId={(tool, index) => searchToolKey(tool) || String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading search tools…"
noDataMessage={<EmptyState />}
size="compact"
/>
);
};
export default SearchToolTable;

View file

@ -0,0 +1,168 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import { AvailableSearchProvider, SearchTool } from "./types";
const CONFIG_EDIT_HINT = "Config search tools cannot be edited on the dashboard. Please edit the config file.";
const CONFIG_DELETE_HINT = "Config search tools cannot be deleted on the dashboard. Please edit the config file.";
export const searchToolKey = (tool: SearchTool): string => tool.search_tool_id || tool.search_tool_name;
interface SearchToolRowActionsProps {
tool: SearchTool;
onEdit: (searchToolId: string) => void;
onDelete: (searchToolId: string) => void;
}
function SearchToolRowActions({ tool, onEdit, onDelete }: SearchToolRowActionsProps) {
const isFromConfig = tool.is_from_config ?? false;
const toolId = tool.search_tool_id;
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open search tool actions"
data-testid={`search-tool-actions-${searchToolKey(tool)}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
disabled={isFromConfig || !toolId}
data-testid="search-tool-action-edit"
title={isFromConfig ? CONFIG_EDIT_HINT : undefined}
onClick={() => toolId && onEdit(toolId)}
>
<Pencil />
Edit search tool
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={isFromConfig || !toolId}
data-testid="search-tool-action-delete"
title={isFromConfig ? CONFIG_DELETE_HINT : undefined}
onClick={() => toolId && onDelete(toolId)}
>
<Trash2 />
Delete search tool
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface SearchToolTableColumnsDeps {
availableProviders: AvailableSearchProvider[];
onView: (searchToolId: string) => void;
onEdit: (searchToolId: string) => void;
onDelete: (searchToolId: string) => void;
}
export const getSearchToolTableColumns = ({
availableProviders,
onView,
onEdit,
onDelete,
}: SearchToolTableColumnsDeps): ColumnDef<SearchTool>[] => [
{
id: "search_tool_id",
accessorKey: "search_tool_id",
meta: { title: "Search Tool ID" },
header: ({ column }) => <DataTableSortHeader column={column} title="Search Tool ID" />,
size: 200,
enableSorting: true,
cell: ({ row }) => {
const tool = row.original;
const toolId = tool.search_tool_id;
if (tool.is_from_config || !toolId) {
return <span className="text-muted-foreground">-</span>;
}
return (
<IdentityCell title={toolId} titleClassName="font-mono text-xs font-normal" onClick={() => onView(toolId)} />
);
},
},
{
id: "search_tool_name",
accessorKey: "search_tool_name",
meta: { title: "Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
size: 200,
enableSorting: true,
cell: ({ row }) => (
<span className="block max-w-60 truncate text-sm font-medium" title={row.original.search_tool_name}>
{row.original.search_tool_name || "-"}
</span>
),
},
{
id: "provider",
meta: { title: "Provider" },
header: "Provider",
size: 160,
enableSorting: false,
cell: ({ row }) => {
const provider = row.original.litellm_params.search_provider;
const providerInfo = availableProviders.find((candidate) => candidate.provider_name === provider);
return <span className="text-sm">{providerInfo?.ui_friendly_name || provider}</span>;
},
},
{
id: "created_at",
accessorKey: "created_at",
meta: { title: "Created At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
size: 130,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
{
id: "updated_at",
accessorKey: "updated_at",
meta: { title: "Updated At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" />,
size: 130,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.updated_at} precision="date" />,
},
{
id: "source",
meta: { title: "Source", skeleton: "badge" },
header: "Source",
size: 100,
enableSorting: false,
cell: ({ row }) => {
const isFromConfig = row.original.is_from_config ?? false;
return <StatusBadge tone={isFromConfig ? "neutral" : "info"} label={isFromConfig ? "Config" : "DB"} />;
},
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<SearchToolRowActions tool={row.original} onEdit={onEdit} onDelete={onDelete} />
</div>
),
},
];

View file

@ -1,8 +1,7 @@
import { isAdminRole } from "@/utils/roles";
import { LoadingOutlined } from "@ant-design/icons";
import { useQuery } from "@tanstack/react-query";
import { Button, Text, Title } from "@tremor/react";
import { Form, Input, Modal, Select, Spin, Table } from "antd";
import { Form, Input, Modal, Select } from "antd";
import React, { useState } from "react";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import NotificationsManager from "@/components/molecules/notifications_manager";
@ -13,7 +12,7 @@ import {
updateSearchTool,
} from "@/components/networking";
import CreateSearchTool from "./CreateSearchTools";
import { searchToolColumns } from "./SearchToolColumn";
import SearchToolTable from "./SearchToolTable";
import { SearchToolView } from "./SearchToolView";
import { AvailableSearchProvider, SearchTool } from "./types";
@ -58,34 +57,29 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
const [isEditModalVisible, setEditModalVisible] = useState(false);
const [form] = Form.useForm();
const columns = React.useMemo(
() =>
searchToolColumns(
(toolId: string) => {
setSelectedToolId(toolId);
setEditTool(false);
},
(toolId: string) => {
const tool = searchTools?.find((t) => t.search_tool_id === toolId);
if (tool) {
form.setFieldsValue({
search_tool_name: tool.search_tool_name,
search_provider: tool.litellm_params.search_provider,
api_key: tool.litellm_params.api_key,
api_base: tool.litellm_params.api_base,
timeout: tool.litellm_params.timeout,
max_retries: tool.litellm_params.max_retries,
description: tool.search_tool_info?.description,
});
setSelectedToolId(toolId);
setEditModalVisible(true);
}
},
handleDelete,
availableProviders,
),
[availableProviders, searchTools, form],
);
const handleView = (toolId: string) => {
setSelectedToolId(toolId);
setEditTool(false);
};
const handleEditOpen = (toolId: string) => {
const tool = searchTools?.find((t) => t.search_tool_id === toolId);
if (!tool) {
return;
}
const editFormValues = {
search_tool_name: tool.search_tool_name,
search_provider: tool.litellm_params.search_provider,
api_key: tool.litellm_params.api_key,
api_base: tool.litellm_params.api_base,
timeout: tool.litellm_params.timeout,
max_retries: tool.litellm_params.max_retries,
description: tool.search_tool_info?.description,
};
form.setFieldsValue(editFormValues);
setSelectedToolId(toolId);
setEditModalVisible(true);
};
function handleDelete(toolId: string) {
setToolToDelete(toolId);
@ -220,19 +214,14 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
/>
) : (
<div className="w-full h-full">
<Spin spinning={isLoadingTools} indicator={<LoadingOutlined spin />} size="large">
<Table
bordered
dataSource={searchTools || []}
columns={columns}
rowKey={(record) => record.search_tool_id || record.search_tool_name}
pagination={false}
locale={{
emptyText: "No search tools configured",
}}
size="small"
/>
</Spin>
<SearchToolTable
searchTools={searchTools || []}
isLoading={isLoadingTools}
availableProviders={availableProviders}
onView={handleView}
onEdit={handleEditOpen}
onDelete={handleDelete}
/>
</div>
);

View file

@ -1,4 +1,5 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, it, expect, beforeEach, MockedFunction } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import DeletedKeysPage from "./DeletedKeysPage";
@ -13,6 +14,9 @@ const mockUseDeletedKeys = useDeletedKeys as MockedFunction<typeof useDeletedKey
const mockDeletedKey: DeletedKeyResponse = {
token: "sk-1234567890abcdef",
token_id: "key-1",
key_type: "llm_api",
project_id: null,
last_active: null,
key_name: "test-key",
key_alias: "Test Key Alias",
spend: 5.5,
@ -78,9 +82,8 @@ beforeEach(() => {
current_page: 1,
total_pages: 1,
},
isPending: false,
isFetching: false,
} as any);
isLoading: false,
} as unknown as ReturnType<typeof useDeletedKeys>);
});
it("should render DeletedKeysPage component", () => {
@ -89,14 +92,32 @@ it("should render DeletedKeysPage component", () => {
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
});
it("should handle loading state", () => {
it("should show skeleton rows while the initial load is pending", () => {
mockUseDeletedKeys.mockReturnValue({
data: undefined,
isPending: true,
isFetching: false,
} as any);
isLoading: true,
} as unknown as ReturnType<typeof useDeletedKeys>);
renderWithProviders(<DeletedKeysPage />);
expect(screen.getByText("🚅 Loading keys...")).toBeInTheDocument();
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
});
it("should request the next page from the hook when the pagination next button is clicked", async () => {
const user = userEvent.setup();
mockUseDeletedKeys.mockReturnValue({
data: {
keys: [mockDeletedKey],
total_count: 120,
current_page: 1,
total_pages: 3,
},
isLoading: false,
} as unknown as ReturnType<typeof useDeletedKeys>);
renderWithProviders(<DeletedKeysPage />);
expect(mockUseDeletedKeys).toHaveBeenLastCalledWith(1, 50);
await user.click(screen.getByTestId("pagination-next"));
expect(mockUseDeletedKeys).toHaveBeenLastCalledWith(2, 50);
});

View file

@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { PaginationState } from "@tanstack/react-table";
import { Alert } from "antd";
import { useDeletedKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
@ -7,10 +8,9 @@ import { DeletedKeysTable } from "./DeletedKeysTable/DeletedKeysTable";
export default function DeletedKeysPage() {
const { premiumUser } = useAuthorized();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize] = useState(50);
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 50 });
const { data: keysData, isPending: isLoading, isFetching } = useDeletedKeys(pageIndex + 1, pageSize);
const { data: keysData, isLoading } = useDeletedKeys(pagination.pageIndex + 1, pagination.pageSize);
return (
<div className="flex flex-col gap-4">
@ -27,10 +27,8 @@ export default function DeletedKeysPage() {
keys={keysData?.keys || []}
totalCount={keysData?.total_count || 0}
isLoading={isLoading}
isFetching={isFetching}
pageIndex={pageIndex}
pageSize={pageSize}
onPageChange={setPageIndex}
pagination={pagination}
onPaginationChange={setPagination}
/>
</div>
);

View file

@ -1,101 +1,88 @@
import { screen } from "@testing-library/react";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, it, expect, beforeEach } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
import { DeletedKeysTable } from "./DeletedKeysTable";
import { DeletedKeyResponse } from "@/app/(dashboard)/hooks/keys/useKeys";
const mockDeletedKey: DeletedKeyResponse = {
token: "sk-1234567890abcdef",
token_id: "key-1",
key_name: "test-key",
key_alias: "Test Key Alias",
spend: 5.5,
max_budget: 100,
expires: "2024-12-31T23:59:59Z",
models: ["gpt-3.5-turbo"],
aliases: {},
config: {},
user_id: "user-1",
team_id: "team-1",
max_parallel_requests: 10,
metadata: {},
tpm_limit: 1000,
rpm_limit: 100,
duration: "30d",
budget_duration: "1m",
budget_reset_at: "2024-12-01T00:00:00Z",
allowed_cache_controls: [],
allowed_routes: [],
permissions: {},
model_spend: {},
model_max_budget: {},
soft_budget_cooldown: false,
blocked: false,
litellm_budget_table: {},
organization_id: "org-1",
created_at: "2024-11-01T10:00:00Z",
updated_at: "2024-11-15T10:00:00Z",
team_spend: 5.5,
team_alias: "Test Team",
team_tpm_limit: 5000,
team_rpm_limit: 500,
team_max_budget: 500,
team_models: ["gpt-3.5-turbo"],
team_blocked: false,
soft_budget: 50,
team_model_aliases: {},
team_member_spend: 0,
team_metadata: {},
end_user_id: "end-user-1",
end_user_tpm_limit: 100,
end_user_rpm_limit: 10,
end_user_max_budget: 10,
last_refreshed_at: Date.now(),
api_key: "sk-1234567890abcdef",
user_role: "user",
rpm_limit_per_model: {},
tpm_limit_per_model: {},
user_tpm_limit: 1000,
user_rpm_limit: 100,
user_email: "user@example.com",
deleted_at: "2024-11-15T10:00:00Z",
deleted_by: "user-1",
const makeDeletedKey = (overrides: Partial<DeletedKeyResponse> = {}): DeletedKeyResponse =>
({
token: "sk-1234567890abcdef",
token_id: "key-1",
key_name: "test-key",
key_alias: "Test Key Alias",
spend: 5.5,
max_budget: 100,
models: ["gpt-3.5-turbo"],
user_id: "user-1",
team_id: "team-1",
organization_id: "org-1",
created_at: "2024-11-01T10:00:00Z",
updated_at: "2024-11-15T10:00:00Z",
created_by: "creator-1",
team_alias: "Test Team",
user_email: "user@example.com",
deleted_at: "2024-11-15T10:00:00Z",
deleted_by: "user-1",
...overrides,
}) as DeletedKeyResponse;
const defaultProps = {
keys: [makeDeletedKey()],
totalCount: 1,
isLoading: false,
pagination: { pageIndex: 0, pageSize: 50 },
onPaginationChange: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("should render DeletedKeysTable component", () => {
renderWithProviders(
<DeletedKeysTable
keys={[mockDeletedKey]}
totalCount={1}
isLoading={false}
isFetching={false}
pageIndex={0}
pageSize={50}
onPageChange={vi.fn()}
/>,
);
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
});
it("should display key information correctly", () => {
renderWithProviders(
<DeletedKeysTable
keys={[mockDeletedKey]}
totalCount={1}
isLoading={false}
isFetching={false}
pageIndex={0}
pageSize={50}
onPageChange={vi.fn()}
/>,
);
it("should display key information", () => {
renderWithProviders(<DeletedKeysTable {...defaultProps} />);
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
expect(screen.getByText("sk-1234567890abcdef")).toBeInTheDocument();
expect(screen.getByText("Showing 1 - 1 of 1 results")).toBeInTheDocument();
expect(screen.getByText("user@example.com")).toBeInTheDocument();
});
it("should show the total count in the pagination footer", () => {
renderWithProviders(<DeletedKeysTable {...defaultProps} totalCount={120} />);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 120");
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 3");
});
it("should propagate pagination changes when the next page button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<DeletedKeysTable {...defaultProps} totalCount={120} />);
await user.click(screen.getByTestId("pagination-next"));
expect(defaultProps.onPaginationChange).toHaveBeenCalled();
});
it("should sort the current page by deleted_at descending by default", () => {
const keys = [
makeDeletedKey({ token: "sk-older", key_alias: "older-key", deleted_at: "2024-01-01T10:00:00Z" }),
makeDeletedKey({ token: "sk-newer", key_alias: "newer-key", deleted_at: "2024-06-01T10:00:00Z" }),
];
renderWithProviders(<DeletedKeysTable {...defaultProps} keys={keys} totalCount={2} />);
const rows = screen.getAllByRole("row").slice(1);
expect(within(rows[0]).getByText("newer-key")).toBeInTheDocument();
expect(within(rows[1]).getByText("older-key")).toBeInTheDocument();
});
it("should show skeleton rows when loading", () => {
renderWithProviders(<DeletedKeysTable {...defaultProps} keys={[]} isLoading />);
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
});
it("should show the empty state when there are no deleted keys", () => {
renderWithProviders(<DeletedKeysTable {...defaultProps} keys={[]} totalCount={0} />);
expect(screen.getByText("No deleted keys found")).toBeInTheDocument();
});

View file

@ -1,364 +1,63 @@
"use client";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react";
import { Tooltip } from "antd";
import React, { useState } from "react";
import { KeyResponse } from "../../key_team_helpers/key_list";
import { OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import { Inbox } from "lucide-react";
import { useMemo, useState } from "react";
import { DataTable } from "@/components/shared/DataTable";
import { DeletedKeyResponse } from "@/app/(dashboard)/hooks/keys/useKeys";
import { getDeletedKeysTableColumns } from "./DeletedKeysTableColumns";
interface DeletedKeysTableProps {
keys: KeyResponse[];
keys: DeletedKeyResponse[];
totalCount: number;
isLoading: boolean;
isFetching: boolean;
pageIndex: number;
pageSize: number;
onPageChange: (pageIndex: number) => void;
pagination: PaginationState;
onPaginationChange: OnChangeFn<PaginationState>;
}
const DEFAULT_SORTING: SortingState = [{ id: "deleted_at", desc: true }];
function EmptyState() {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No deleted keys found</div>
<div className="text-sm text-muted-foreground">Keys deleted from this proxy will show up here.</div>
</div>
);
}
export function DeletedKeysTable({
keys,
totalCount,
isLoading,
isFetching,
pageIndex,
pageSize,
onPageChange,
pagination,
onPaginationChange,
}: DeletedKeysTableProps) {
const [sorting, setSorting] = useState<SortingState>([
{
id: "deleted_at",
desc: true,
},
]);
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const [tablePagination, setTablePagination] = useState<PaginationState>({
pageIndex,
pageSize,
});
// Sync pagination state when prop changes
React.useEffect(() => {
setTablePagination({ pageIndex, pageSize });
}, [pageIndex, pageSize]);
const columns: ColumnDef<KeyResponse>[] = [
{
id: "token",
accessorKey: "token",
header: "Key ID",
size: 150,
maxSize: 250,
cell: (info) => <IdCell value={info.getValue() as string} variant="plain" />,
},
{
id: "key_alias",
accessorKey: "key_alias",
header: "Key Alias",
size: 150,
maxSize: 200,
cell: (info) => {
const value = info.getValue() as string;
return (
<Tooltip title={value}>
<span className="font-mono text-xs truncate block max-w-[200px]">{value ?? "-"}</span>
</Tooltip>
);
},
},
{
id: "team_alias",
accessorKey: "team_alias",
header: "Team Alias",
size: 120,
maxSize: 180,
cell: (info) => {
const value = info.getValue() as string;
return <span className="truncate block max-w-[180px]">{value || "-"}</span>;
},
},
{
id: "spend",
accessorKey: "spend",
header: "Spend (USD)",
size: 100,
maxSize: 140,
cell: (info) => <MoneyCell value={info.getValue() as number} decimals={4} />,
},
{
id: "max_budget",
accessorKey: "max_budget",
header: "Budget (USD)",
size: 110,
maxSize: 150,
cell: (info) => (
<MoneyCell value={info.getValue() as number | null} decimals={0} emptyText="Unlimited" showZero />
),
},
{
id: "user_email",
accessorKey: "user_email",
header: "User Email",
size: 160,
maxSize: 250,
cell: (info) => {
const value = info.getValue() as string;
return (
<Tooltip title={value}>
<span className="font-mono text-xs truncate block max-w-[250px]">{value ?? "-"}</span>
</Tooltip>
);
},
},
{
id: "user_id",
accessorKey: "user_id",
header: "User ID",
size: 120,
maxSize: 200,
cell: (info) => <IdCell value={info.getValue() as string | null} variant="plain" />,
},
{
id: "created_at",
accessorKey: "created_at",
header: "Created At",
size: 120,
maxSize: 140,
cell: (info) => <DateCell value={info.getValue() as string | null | undefined} precision="date" />,
},
{
id: "created_by",
accessorKey: "created_by",
header: "Created By",
size: 120,
maxSize: 180,
cell: (info) => {
const value = (info.row.original as any).created_by as string | null | undefined;
return (
<Tooltip title={value || undefined}>
<span className="truncate block max-w-[180px]">{value || "-"}</span>
</Tooltip>
);
},
},
{
id: "deleted_at",
accessorKey: "deleted_at",
header: "Deleted At",
size: 120,
maxSize: 140,
cell: (info) => (
<DateCell value={(info.row.original as any).deleted_at as string | null | undefined} precision="date" />
),
},
{
id: "deleted_by",
accessorKey: "deleted_by",
header: "Deleted By",
size: 120,
maxSize: 180,
cell: (info) => {
const value = (info.row.original as any).deleted_by as string | null | undefined;
return (
<Tooltip title={value || undefined}>
<span className="truncate block max-w-[180px]">{value || "-"}</span>
</Tooltip>
);
},
},
];
const table = useReactTable({
data: keys,
columns,
columnResizeMode: "onChange",
columnResizeDirection: "ltr",
state: {
sorting,
pagination: tablePagination,
},
onSortingChange: setSorting,
onPaginationChange: (updater) => {
const newPagination = typeof updater === "function" ? updater(tablePagination) : updater;
setTablePagination(newPagination);
onPageChange(newPagination.pageIndex);
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
enableSorting: true,
manualSorting: false,
manualPagination: true,
pageCount: Math.ceil(totalCount / pageSize),
});
const { pageIndex: currentPageIndex } = table.getState().pagination;
const start = currentPageIndex * pageSize + 1;
const end = Math.min((currentPageIndex + 1) * pageSize, totalCount);
const rangeLabel = `${start} - ${end}`;
const columns = useMemo(() => getDeletedKeysTableColumns(), []);
return (
<div className="w-full h-full overflow-hidden">
<div className="border-b py-4 flex-1 overflow-hidden">
<div className="flex items-center justify-between w-full mb-4">
{isLoading || isFetching ? (
<span className="inline-flex text-sm text-gray-700">Loading...</span>
) : (
<span className="inline-flex text-sm text-gray-700">
Showing {rangeLabel} of {totalCount} results
</span>
)}
<div className="inline-flex items-center gap-2">
{isLoading || isFetching ? (
<span className="text-sm text-gray-700">Loading...</span>
) : (
<span className="text-sm text-gray-700">
Page {currentPageIndex + 1} of {table.getPageCount()}
</span>
)}
<button
onClick={() => table.previousPage()}
disabled={isLoading || isFetching || !table.getCanPreviousPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => table.nextPage()}
disabled={isLoading || isFetching || !table.getCanNextPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
<div className="h-[75vh] overflow-auto">
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1" style={{ width: table.getCenterTotalSize() }}>
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
data-header-id={header.id}
className={`py-1 h-8 relative hover:bg-gray-50`}
style={{
width: header.getSize(),
maxWidth: header.column.columnDef.maxSize,
position: "relative",
}}
onMouseEnter={() => {
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
if (resizer) {
(resizer as HTMLElement).style.opacity = "0.5";
}
}}
onMouseLeave={() => {
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
if (resizer && !header.column.getIsResizing()) {
(resizer as HTMLElement).style.opacity = "0";
}
}}
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
<div
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={`resizer ${table.options.columnResizeDirection} ${header.column.getIsResizing() ? "isResizing" : ""}`}
style={{
position: "absolute",
right: 0,
top: 0,
height: "100%",
width: "5px",
background: header.column.getIsResizing() ? "#3b82f6" : "transparent",
cursor: "col-resize",
userSelect: "none",
touchAction: "none",
opacity: header.column.getIsResizing() ? 1 : 0,
}}
/>
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading || isFetching ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>🚅 Loading keys...</p>
</div>
</TableCell>
</TableRow>
) : keys.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.getSize(),
maxWidth: cell.column.columnDef.maxSize,
whiteSpace: "pre-wrap",
overflow: "hidden",
}}
className="py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No deleted keys found</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
</div>
</div>
<DataTable
data={keys}
columns={columns}
getRowId={(key, index) => key.token || String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
paginationMode="server"
pagination={pagination}
onPaginationChange={onPaginationChange}
rowCount={totalCount}
isLoading={isLoading}
loadingMessage="Loading deleted keys…"
noDataMessage={<EmptyState />}
size="compact"
/>
);
}

View file

@ -0,0 +1,130 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
import { DeletedKeyResponse } from "@/app/(dashboard)/hooks/keys/useKeys";
function TruncatedTextCell({ value }: { value: string | null | undefined }) {
if (!value) {
return <span className="text-muted-foreground">-</span>;
}
return (
<span className="block max-w-60 truncate" title={value}>
{value}
</span>
);
}
export const getDeletedKeysTableColumns = (): ColumnDef<DeletedKeyResponse>[] => [
{
id: "token",
accessorKey: "token",
meta: { title: "Key ID" },
header: "Key ID",
size: 150,
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.token} variant="plain" />,
},
{
id: "key_alias",
accessorKey: "key_alias",
meta: { title: "Key Alias" },
header: "Key Alias",
size: 150,
enableSorting: false,
cell: ({ row }) => {
const value = row.original.key_alias;
if (!value) {
return <span className="text-muted-foreground">-</span>;
}
return (
<span className="block max-w-60 truncate font-mono text-xs" title={value}>
{value}
</span>
);
},
},
{
id: "team_alias",
accessorKey: "team_alias",
meta: { title: "Team Alias" },
header: "Team Alias",
size: 120,
enableSorting: false,
cell: ({ row }) => <TruncatedTextCell value={row.original.team_alias} />,
},
{
id: "spend",
accessorKey: "spend",
meta: { title: "Spend (USD)", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" />,
size: 100,
enableSorting: true,
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
},
{
id: "max_budget",
accessorKey: "max_budget",
meta: { title: "Budget (USD)", numeric: true },
header: "Budget (USD)",
size: 110,
enableSorting: false,
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={0} emptyText="Unlimited" showZero />,
},
{
id: "user_email",
accessorKey: "user_email",
meta: { title: "User Email" },
header: "User Email",
size: 160,
enableSorting: false,
cell: ({ row }) => <TruncatedTextCell value={row.original.user_email} />,
},
{
id: "user_id",
accessorKey: "user_id",
meta: { title: "User ID" },
header: "User ID",
size: 120,
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.user_id} variant="plain" />,
},
{
id: "created_at",
accessorKey: "created_at",
meta: { title: "Created At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
size: 120,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
{
id: "created_by",
accessorKey: "created_by",
meta: { title: "Created By" },
header: "Created By",
size: 120,
enableSorting: false,
cell: ({ row }) => <TruncatedTextCell value={row.original.created_by} />,
},
{
id: "deleted_at",
accessorKey: "deleted_at",
meta: { title: "Deleted At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Deleted At" />,
size: 120,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.deleted_at} precision="date" />,
},
{
id: "deleted_by",
accessorKey: "deleted_by",
meta: { title: "Deleted By" },
header: "Deleted By",
size: 120,
enableSorting: false,
cell: ({ row }) => <TruncatedTextCell value={row.original.deleted_by} />,
},
];

View file

@ -32,9 +32,8 @@ beforeEach(() => {
mockUseDeletedTeams.mockReturnValue({
data: [mockDeletedTeam],
isPending: false,
isFetching: false,
} as any);
isLoading: false,
} as unknown as ReturnType<typeof useDeletedTeams>);
});
it("should render DeletedTeamsPage component", () => {
@ -43,14 +42,13 @@ it("should render DeletedTeamsPage component", () => {
expect(screen.getByText("Test Team")).toBeInTheDocument();
});
it("should handle loading state", () => {
it("should show skeleton rows while the initial load is pending", () => {
mockUseDeletedTeams.mockReturnValue({
data: undefined,
isPending: true,
isFetching: false,
} as any);
isLoading: true,
} as unknown as ReturnType<typeof useDeletedTeams>);
renderWithProviders(<DeletedTeamsPage />);
expect(screen.getByText("🚅 Loading teams...")).toBeInTheDocument();
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
});

View file

@ -6,7 +6,7 @@ import { DeletedTeamsTable } from "./DeletedTeamsTable/DeletedTeamsTable";
export default function DeletedTeamsPage() {
const { premiumUser } = useAuthorized();
const { data: teamsData, isPending: isLoading, isFetching } = useDeletedTeams(1, 100);
const { data: teamsData, isLoading } = useDeletedTeams(1, 100);
return (
<div className="flex flex-col gap-4">
@ -19,7 +19,7 @@ export default function DeletedTeamsPage() {
description="Deleted team auditing is graduating from beta into our Enterprise audit & compliance suite."
/>
)}
<DeletedTeamsTable teams={teamsData || []} isLoading={isLoading} isFetching={isFetching} />
<DeletedTeamsTable teams={teamsData || []} isLoading={isLoading} />
</div>
);
}

View file

@ -1,10 +1,10 @@
import { screen } from "@testing-library/react";
import { screen, within } from "@testing-library/react";
import { vi, it, expect, beforeEach } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
import { DeletedTeamsTable } from "./DeletedTeamsTable";
import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
const mockDeletedTeam: DeletedTeam = {
const makeDeletedTeam = (overrides: Partial<DeletedTeam> = {}): DeletedTeam => ({
team_id: "team-1",
team_alias: "Test Team",
models: ["gpt-3.5-turbo", "gpt-4"],
@ -19,22 +19,41 @@ const mockDeletedTeam: DeletedTeam = {
deleted_at: "2024-11-15T10:00:00Z",
deleted_by: "user-1",
spend: 100.5,
};
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
});
it("should render DeletedTeamsTable component", () => {
renderWithProviders(<DeletedTeamsTable teams={[mockDeletedTeam]} isLoading={false} isFetching={false} />);
expect(screen.getByText("Test Team")).toBeInTheDocument();
});
it("should display team information correctly", () => {
renderWithProviders(<DeletedTeamsTable teams={[mockDeletedTeam]} isLoading={false} isFetching={false} />);
it("should display team information", () => {
renderWithProviders(<DeletedTeamsTable teams={[makeDeletedTeam()]} isLoading={false} />);
expect(screen.getByText("Test Team")).toBeInTheDocument();
expect(screen.getByText("team-1")).toBeInTheDocument();
expect(screen.getByText("Showing 1 team")).toBeInTheDocument();
expect(screen.getByText("org-1")).toBeInTheDocument();
});
it("should sort teams by deleted_at descending by default", () => {
const teams = [
makeDeletedTeam({ team_id: "team-old", team_alias: "older-team", deleted_at: "2024-01-01T10:00:00Z" }),
makeDeletedTeam({ team_id: "team-new", team_alias: "newer-team", deleted_at: "2024-06-01T10:00:00Z" }),
];
renderWithProviders(<DeletedTeamsTable teams={teams} isLoading={false} />);
const rows = screen.getAllByRole("row").slice(1);
expect(within(rows[0]).getByText("newer-team")).toBeInTheDocument();
expect(within(rows[1]).getByText("older-team")).toBeInTheDocument();
});
it("should show skeleton rows when loading", () => {
renderWithProviders(<DeletedTeamsTable teams={[]} isLoading />);
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
});
it("should show the empty state when there are no deleted teams", () => {
renderWithProviders(<DeletedTeamsTable teams={[]} isLoading={false} />);
expect(screen.getByText("No deleted teams found")).toBeInTheDocument();
});

View file

@ -1,299 +1,50 @@
"use client";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Badge, Text } from "@tremor/react";
import { Tooltip } from "antd";
import React, { useState } from "react";
import { SortingState } from "@tanstack/react-table";
import { Inbox } from "lucide-react";
import { useMemo, useState } from "react";
import { DataTable } from "@/components/shared/DataTable";
import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
import { getDeletedTeamsTableColumns } from "./DeletedTeamsTableColumns";
interface DeletedTeamsTableProps {
teams: DeletedTeam[];
isLoading: boolean;
isFetching: boolean;
}
export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeamsTableProps) {
const [sorting, setSorting] = useState<SortingState>([
{
id: "deleted_at",
desc: true,
},
]);
const columns: ColumnDef<DeletedTeam>[] = [
{
id: "team_alias",
accessorKey: "team_alias",
header: "Team Name",
size: 150,
maxSize: 200,
cell: (info) => {
const value = info.getValue() as string;
return (
<Tooltip title={value || undefined}>
<span className="truncate block max-w-[200px]">{value || "-"}</span>
</Tooltip>
);
},
},
{
id: "team_id",
accessorKey: "team_id",
header: "Team ID",
size: 150,
maxSize: 250,
cell: (info) => <IdCell value={info.getValue() as string} />,
},
{
id: "created_at",
accessorKey: "created_at",
header: "Created",
size: 120,
maxSize: 140,
cell: (info) => <DateCell value={info.getValue() as string | null | undefined} precision="date" />,
},
{
id: "spend",
accessorKey: "spend",
header: "Spend (USD)",
size: 100,
maxSize: 140,
cell: (info) => <MoneyCell value={info.getValue() as number | undefined} decimals={4} />,
},
{
id: "max_budget",
accessorKey: "max_budget",
header: "Budget (USD)",
size: 110,
maxSize: 150,
cell: (info) => (
<MoneyCell value={info.getValue() as number | null} decimals={0} emptyText="Unlimited" showZero />
),
},
{
id: "models",
accessorKey: "models",
header: "Models",
size: 200,
maxSize: 300,
cell: (info) => {
const models = info.getValue() as string[];
if (!Array.isArray(models) || models.length === 0) {
return (
<Badge size={"xs"} color="red">
<Text>All Proxy Models</Text>
</Badge>
);
}
return (
<div className="flex flex-wrap gap-1 max-w-[300px]">
{models.slice(0, 3).map((model: string, index: number) =>
model === "all-proxy-models" ? (
<Badge key={index} size={"xs"} color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<Badge key={index} size={"xs"} color="blue">
<Text>
{model.length > 30 ? `${getModelDisplayName(model).slice(0, 30)}...` : getModelDisplayName(model)}
</Text>
</Badge>
),
)}
{models.length > 3 && (
<Badge size={"xs"} color="gray">
<Text>
+{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"}
</Text>
</Badge>
)}
</div>
);
},
},
{
id: "organization_id",
accessorKey: "organization_id",
header: "Organization",
size: 150,
maxSize: 200,
cell: (info) => <IdCell value={info.getValue() as string} variant="plain" />,
},
{
id: "deleted_at",
accessorKey: "deleted_at",
header: "Deleted At",
size: 120,
maxSize: 140,
cell: (info) => <DateCell value={info.getValue() as string | null | undefined} precision="date" />,
},
{
id: "deleted_by",
accessorKey: "deleted_by",
header: "Deleted By",
size: 120,
maxSize: 180,
cell: (info) => {
const value = (info.row.original as any).deleted_by as string | null | undefined;
return (
<Tooltip title={value || undefined}>
<span className="truncate block max-w-[180px]">{value || "-"}</span>
</Tooltip>
);
},
},
];
const table = useReactTable({
data: teams,
columns,
columnResizeMode: "onChange",
columnResizeDirection: "ltr",
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableSorting: true,
manualSorting: false,
});
const DEFAULT_SORTING: SortingState = [{ id: "deleted_at", desc: true }];
function EmptyState() {
return (
<div className="w-full h-full overflow-hidden">
<div className="border-b py-4 flex-1 overflow-hidden">
<div className="flex items-center justify-between w-full mb-4">
{isLoading || isFetching ? (
<span className="inline-flex text-sm text-gray-700">Loading...</span>
) : (
<span className="inline-flex text-sm text-gray-700">
Showing {teams.length} {teams.length === 1 ? "team" : "teams"}
</span>
)}
</div>
<div className="h-[75vh] overflow-auto">
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1" style={{ width: table.getCenterTotalSize() }}>
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
data-header-id={header.id}
className={`py-1 h-8 relative hover:bg-gray-50`}
style={{
width: header.getSize(),
maxWidth: header.column.columnDef.maxSize,
position: "relative",
}}
onMouseEnter={() => {
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
if (resizer) {
(resizer as HTMLElement).style.opacity = "0.5";
}
}}
onMouseLeave={() => {
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
if (resizer && !header.column.getIsResizing()) {
(resizer as HTMLElement).style.opacity = "0";
}
}}
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
<div
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={`resizer ${table.options.columnResizeDirection} ${header.column.getIsResizing() ? "isResizing" : ""}`}
style={{
position: "absolute",
right: 0,
top: 0,
height: "100%",
width: "5px",
background: header.column.getIsResizing() ? "#3b82f6" : "transparent",
cursor: "col-resize",
userSelect: "none",
touchAction: "none",
opacity: header.column.getIsResizing() ? 1 : 0,
}}
/>
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading || isFetching ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>🚅 Loading teams...</p>
</div>
</TableCell>
</TableRow>
) : teams.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.getSize(),
maxWidth: cell.column.columnDef.maxSize,
whiteSpace: "pre-wrap",
overflow: "hidden",
}}
className="py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No deleted teams found</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No deleted teams found</div>
<div className="text-sm text-muted-foreground">Teams deleted from this proxy will show up here.</div>
</div>
);
}
export function DeletedTeamsTable({ teams, isLoading }: DeletedTeamsTableProps) {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo(() => getDeletedTeamsTableColumns(), []);
return (
<DataTable
data={teams}
columns={columns}
getRowId={(team, index) => team.team_id || String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading deleted teams…"
noDataMessage={<EmptyState />}
size="compact"
/>
);
}

View file

@ -0,0 +1,111 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdCell, ModelsCell, MoneyCell } from "@/components/shared/table_cells";
import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
{
id: "team_alias",
accessorKey: "team_alias",
meta: { title: "Team Name" },
header: "Team Name",
size: 150,
enableSorting: false,
cell: ({ row }) => {
const value = row.original.team_alias;
if (!value) {
return <span className="text-muted-foreground">-</span>;
}
return (
<span className="block max-w-60 truncate font-medium" title={value}>
{value}
</span>
);
},
},
{
id: "team_id",
accessorKey: "team_id",
meta: { title: "Team ID" },
header: "Team ID",
size: 150,
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.team_id} variant="plain" />,
},
{
id: "created_at",
accessorKey: "created_at",
meta: { title: "Created" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
size: 120,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
{
id: "spend",
accessorKey: "spend",
meta: { title: "Spend (USD)", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" />,
size: 100,
enableSorting: true,
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
},
{
id: "max_budget",
accessorKey: "max_budget",
meta: { title: "Budget (USD)", numeric: true },
header: "Budget (USD)",
size: 110,
enableSorting: false,
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={0} emptyText="Unlimited" showZero />,
},
{
id: "models",
accessorKey: "models",
meta: { title: "Models", skeleton: "chips" },
header: "Models",
size: 200,
enableSorting: false,
cell: ({ row }) => <ModelsCell models={row.original.models} />,
},
{
id: "organization_id",
accessorKey: "organization_id",
meta: { title: "Organization" },
header: "Organization",
size: 150,
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.organization_id} variant="plain" />,
},
{
id: "deleted_at",
accessorKey: "deleted_at",
meta: { title: "Deleted At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Deleted At" />,
size: 120,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.deleted_at} precision="date" />,
},
{
id: "deleted_by",
accessorKey: "deleted_by",
meta: { title: "Deleted By" },
header: "Deleted By",
size: 120,
enableSorting: false,
cell: ({ row }) => {
const value = row.original.deleted_by;
if (!value) {
return <span className="text-muted-foreground">-</span>;
}
return (
<span className="block max-w-60 truncate" title={value}>
{value}
</span>
);
},
},
];