mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
Merge pull request #40647 from BerriAI/litellm_team_keys_table_entity_links
feat(ui): link the entity cells on the team detail page's keys table
This commit is contained in:
commit
dca71e214b
7 changed files with 181 additions and 114 deletions
|
|
@ -9,17 +9,17 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/h
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DateCell,
|
||||
ENTITY_CELL_TITLE_CLASSES,
|
||||
IdCell,
|
||||
IdentityCell,
|
||||
ModelsCell,
|
||||
SpendBudgetCell,
|
||||
StatusBadge,
|
||||
UserPopoverCell,
|
||||
type StatusTone,
|
||||
} from "@/components/shared/table_cells";
|
||||
import { orgDetailHref, teamDetailHref, userDetailHref } from "@/utils/entityLinks";
|
||||
import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels";
|
||||
import { orgDetailHref, teamDetailHref } from "@/utils/entityLinks";
|
||||
|
||||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import { Organization } from "../networking";
|
||||
|
||||
|
|
@ -29,8 +29,6 @@ interface KeyStatus {
|
|||
tooltip?: string;
|
||||
}
|
||||
|
||||
const ENTITY_CELL_TITLE_CLASSES = "font-mono text-xs font-normal";
|
||||
|
||||
const SPEND_BUDGET_SORT_FIELDS: DataTableSortField[] = [
|
||||
{ id: "spend", label: "Spend" },
|
||||
{ id: "max_budget", label: "Budget" },
|
||||
|
|
@ -66,60 +64,6 @@ const getKeyStatus = (key: KeyResponse): KeyStatus => {
|
|||
};
|
||||
};
|
||||
|
||||
const UserPopoverCell = ({
|
||||
userAlias,
|
||||
userEmail,
|
||||
userId,
|
||||
width,
|
||||
}: {
|
||||
userAlias: string | null;
|
||||
userEmail: string | null;
|
||||
userId: string | null;
|
||||
width: number;
|
||||
}) => {
|
||||
const displayValue = userAlias || userEmail || userId;
|
||||
const isDefaultAdmin = userId === DEFAULT_PROXY_ADMIN_USER_ID;
|
||||
|
||||
const popoverContent = (
|
||||
<div className="flex flex-col gap-2 text-xs min-w-[200px] max-w-[300px]">
|
||||
{[
|
||||
{ label: "User Alias", value: userAlias },
|
||||
{ label: "User Email", value: userEmail },
|
||||
{ label: "User ID", value: userId },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex flex-col min-w-0">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
{value ? (
|
||||
<IdCell value={value} variant="plain" copyable className="max-w-full" />
|
||||
) : (
|
||||
<span className="font-mono">-</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const trigger =
|
||||
isDefaultAdmin && !userAlias && !userEmail ? (
|
||||
<DefaultProxyAdminTag userId={userId} />
|
||||
) : (
|
||||
<IdentityCell
|
||||
title={displayValue || "-"}
|
||||
titleClassName={ENTITY_CELL_TITLE_CLASSES}
|
||||
href={userId ? userDetailHref(userId) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger render={<span className="block" style={{ maxWidth: width, overflow: "hidden" }} />}>
|
||||
{trigger}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start">{popoverContent}</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
};
|
||||
|
||||
const InfoHeader = ({ label, tooltip }: { label: string; tooltip: string }) => (
|
||||
<span className="flex items-center gap-1">
|
||||
{label}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
"use client";
|
||||
|
||||
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { userDetailHref } from "@/utils/entityLinks";
|
||||
import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels";
|
||||
|
||||
import { IdCell } from "./id_cell";
|
||||
import { IdentityCell } from "./identity_cell";
|
||||
|
||||
export const ENTITY_CELL_TITLE_CLASSES = "font-mono text-xs font-normal";
|
||||
|
||||
interface UserPopoverCellProps {
|
||||
userAlias: string | null;
|
||||
userEmail: string | null;
|
||||
userId: string | null;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function UserPopoverCell({ userAlias, userEmail, userId, width }: UserPopoverCellProps) {
|
||||
const displayValue = userAlias || userEmail || userId;
|
||||
const isDefaultAdmin = userId === DEFAULT_PROXY_ADMIN_USER_ID;
|
||||
|
||||
const popoverContent = (
|
||||
<div className="flex flex-col gap-2 text-xs min-w-[200px] max-w-[300px]">
|
||||
{[
|
||||
{ label: "User Alias", value: userAlias },
|
||||
{ label: "User Email", value: userEmail },
|
||||
{ label: "User ID", value: userId },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex flex-col min-w-0">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
{value ? (
|
||||
<IdCell value={value} variant="plain" copyable copyLabel={`Copy ${label}`} className="max-w-full" />
|
||||
) : (
|
||||
<span className="font-mono">-</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const trigger =
|
||||
isDefaultAdmin && !userAlias && !userEmail ? (
|
||||
<DefaultProxyAdminTag userId={userId} />
|
||||
) : (
|
||||
<IdentityCell
|
||||
title={displayValue || "-"}
|
||||
titleClassName={ENTITY_CELL_TITLE_CLASSES}
|
||||
href={userId ? userDetailHref(userId) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger render={<span className="block" style={{ maxWidth: width, overflow: "hidden" }} />}>
|
||||
{trigger}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start">{popoverContent}</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -71,6 +71,14 @@ describe("IdCell", () => {
|
|||
expect(rowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the copy button after the field it copies", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<IdCell value="alice@example.com" copyable copyLabel="Copy User Email" />);
|
||||
expect(screen.queryByRole("button", { name: "Copy ID" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Copy User Email" }));
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith("alice@example.com");
|
||||
});
|
||||
|
||||
it("passes dataTestId through to the id element", () => {
|
||||
render(<IdCell value="k-1" dataTestId="key-id-cell" />);
|
||||
expect(screen.getByTestId("key-id-cell")).toHaveTextContent("k-1");
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface IdCellProps {
|
|||
variant?: IdCellVariant;
|
||||
onClick?: (value: string) => void;
|
||||
copyable?: boolean;
|
||||
copyLabel?: string;
|
||||
truncate?: boolean;
|
||||
fallback?: string;
|
||||
tooltip?: React.ReactNode;
|
||||
|
|
@ -39,6 +40,7 @@ export function IdCell({
|
|||
variant = "pill",
|
||||
onClick,
|
||||
copyable = false,
|
||||
copyLabel = "Copy ID",
|
||||
truncate = true,
|
||||
fallback = "-",
|
||||
tooltip,
|
||||
|
|
@ -80,7 +82,7 @@ export function IdCell({
|
|||
{withTooltip}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy ID"
|
||||
aria-label={copyLabel}
|
||||
className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ export { ModelsCell } from "./models_cell";
|
|||
export { MoneyCell } from "./money_cell";
|
||||
export { SpendBudgetCell } from "./spend_budget_cell";
|
||||
export { StatusBadge, type StatusTone } from "./status_badge";
|
||||
export { UserPopoverCell, ENTITY_CELL_TITLE_CLASSES } from "./UserPopoverCell";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
|
|||
useKeys: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
|
||||
vi.mock("../key_team_helpers/fetch_available_models_team_key", () => ({
|
||||
getModelDisplayName: vi.fn((model: string) => model),
|
||||
}));
|
||||
|
|
@ -384,4 +386,66 @@ describe("TeamVirtualKeysTable", () => {
|
|||
expect(screen.getByText("Key Info View")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("entity links out of the key rows", () => {
|
||||
const renderRow = async (key: KeyResponse, organization: Organization | null = null) => {
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: { keys: [key], total_count: 1, current_page: 1, total_pages: 1 } as KeysResponse,
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} organization={organization} />);
|
||||
await screen.findByText(key.key_alias as string);
|
||||
return screen.getByRole("row", { name: new RegExp(key.key_alias as string) });
|
||||
};
|
||||
|
||||
it("points the Organization ID cell at the org's detail page", async () => {
|
||||
const row = await renderRow(createMockKey({ organization_id: null }), mockOrganization);
|
||||
expect(within(row).getByRole("link", { name: "org-123" })).toHaveAttribute(
|
||||
"href",
|
||||
"/ui/organizations?org=org-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("points the User Email and User ID cells at the owning user's detail page", async () => {
|
||||
const row = await renderRow(
|
||||
createMockKey({ user_id: "user-1", user: { user_id: "user-1", user_email: "alice@example.com" } }),
|
||||
);
|
||||
expect(within(row).getByRole("link", { name: "alice@example.com" })).toHaveAttribute(
|
||||
"href",
|
||||
"/ui/users?user=user-1",
|
||||
);
|
||||
expect(within(row).getByRole("link", { name: "user-1" })).toHaveAttribute("href", "/ui/users?user=user-1");
|
||||
});
|
||||
|
||||
it("points the Created By cell at the creator's detail page", async () => {
|
||||
const row = await renderRow(
|
||||
createMockKey({
|
||||
created_by: "creator-1",
|
||||
created_by_user: { user_id: "creator-1", user_email: "creator@example.com", user_alias: "The Creator" },
|
||||
}),
|
||||
);
|
||||
expect(within(row).getByRole("link", { name: "The Creator" })).toHaveAttribute(
|
||||
"href",
|
||||
"/ui/users?user=creator-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the default_user_id placeholder unlinked in the User ID and Created By cells", async () => {
|
||||
const placeholder = { user_id: "default_user_id", user_email: "admin@example.com", user_alias: "Proxy Admin" };
|
||||
const ownedAndCreatedByPlaceholder = {
|
||||
user_id: placeholder.user_id,
|
||||
user: placeholder,
|
||||
created_by: placeholder.user_id,
|
||||
created_by_user: placeholder,
|
||||
};
|
||||
const row = await renderRow(createMockKey(ownedAndCreatedByPlaceholder));
|
||||
expect(within(row).getByText("Default Proxy Admin")).toBeInTheDocument();
|
||||
expect(within(row).getByText("Proxy Admin")).toBeInTheDocument();
|
||||
expect(within(row).queryByRole("link", { name: "Proxy Admin" })).not.toBeInTheDocument();
|
||||
expect(within(row).queryByRole("link", { name: placeholder.user_email })).not.toBeInTheDocument();
|
||||
expect(within(row).queryByRole("link", { name: "Default Proxy Admin" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
"use client";
|
||||
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import {
|
||||
DateCell,
|
||||
ENTITY_CELL_TITLE_CLASSES,
|
||||
IdCell,
|
||||
IdentityCell,
|
||||
MoneyCell,
|
||||
UserPopoverCell,
|
||||
} from "@/components/shared/table_cells";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFilterDrawer,
|
||||
|
|
@ -11,8 +17,9 @@ import {
|
|||
DataTableToolbar,
|
||||
} from "@/components/shared/DataTable";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { orgDetailHref, userDetailHref } from "@/utils/entityLinks";
|
||||
import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { ColumnDef, ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
|
|
@ -168,7 +175,15 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
header: "Organization ID",
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: (info) => (info.getValue() ? info.renderValue() : "-"),
|
||||
cell: (info) => {
|
||||
const orgId = info.getValue() as string | null;
|
||||
if (!orgId) return "-";
|
||||
return (
|
||||
<SimpleTooltip content={orgId}>
|
||||
<IdentityCell title={orgId} titleClassName={ENTITY_CELL_TITLE_CLASSES} href={orgDetailHref(orgId)} />
|
||||
</SimpleTooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "user_email",
|
||||
|
|
@ -179,9 +194,14 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
cell: (info) => {
|
||||
const user = info.getValue() as { user_email?: string } | undefined;
|
||||
const value = user?.user_email;
|
||||
const userId = info.row.original.user_id;
|
||||
return (
|
||||
<SimpleTooltip content={value}>
|
||||
<span className="block max-w-full truncate font-mono text-xs">{value ?? "-"}</span>
|
||||
<IdentityCell
|
||||
title={value ?? "-"}
|
||||
titleClassName={ENTITY_CELL_TITLE_CLASSES}
|
||||
href={value && userId ? userDetailHref(userId) : undefined}
|
||||
/>
|
||||
</SimpleTooltip>
|
||||
);
|
||||
},
|
||||
|
|
@ -194,10 +214,16 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const userId = info.getValue() as string | null;
|
||||
const displayValue = userId === "default_user_id" ? "Default Proxy Admin" : userId;
|
||||
if (userId === DEFAULT_PROXY_ADMIN_USER_ID) {
|
||||
return <DefaultProxyAdminTag userId={userId} />;
|
||||
}
|
||||
return (
|
||||
<SimpleTooltip content={displayValue}>
|
||||
<span className="block max-w-full truncate font-mono text-xs">{displayValue ?? "-"}</span>
|
||||
<SimpleTooltip content={userId}>
|
||||
<IdentityCell
|
||||
title={userId ?? "-"}
|
||||
titleClassName={ENTITY_CELL_TITLE_CLASSES}
|
||||
href={userId ? userDetailHref(userId) : undefined}
|
||||
/>
|
||||
</SimpleTooltip>
|
||||
);
|
||||
},
|
||||
|
|
@ -221,53 +247,13 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
const userId = info.getValue() as string | null;
|
||||
if (!userId) return "-";
|
||||
const { created_by_user } = info.row.original;
|
||||
const userAlias = created_by_user?.user_alias ?? null;
|
||||
const userEmail = created_by_user?.user_email ?? null;
|
||||
const isDefaultAdmin = userId === "default_user_id";
|
||||
const displayValue = userAlias || userEmail || userId;
|
||||
|
||||
const popoverContent = (
|
||||
<div className="flex min-w-[200px] max-w-[300px] flex-col gap-2 text-xs">
|
||||
{[
|
||||
{ label: "User Alias", value: userAlias },
|
||||
{ label: "User Email", value: userEmail },
|
||||
{ label: "User ID", value: userId },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex flex-col min-w-0">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
{value ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">{value}</span>
|
||||
<CopyButton value={value} label={`Copy ${label}`} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono">-</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isDefaultAdmin && !userAlias && !userEmail) {
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger render={<span className="cursor-default" />}>
|
||||
<DefaultProxyAdminTag userId={userId} />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start">{popoverContent}</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger
|
||||
render={<span className="block max-w-full cursor-default truncate font-mono text-xs" />}
|
||||
>
|
||||
{displayValue}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start">{popoverContent}</HoverCardContent>
|
||||
</HoverCard>
|
||||
<UserPopoverCell
|
||||
userAlias={created_by_user?.user_alias ?? null}
|
||||
userEmail={created_by_user?.user_email ?? null}
|
||||
userId={userId}
|
||||
width={130}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue