mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
feat(ui): link the entity cells on the team detail page's keys table
The team detail page's Virtual Keys table showed Organization ID, User Email, User ID and Created By as dead text, so getting from a key to the org or user behind it meant copying an id and searching for it. Those four cells now render as links, reusing the sentinel-aware href helpers, so default_user_id and the litellm-dashboard team stay plain text instead of pointing at pages that do not exist. The Created By cell was a verbatim copy of the Virtual Keys page's user popover, so that moved into the shared table_cells kit and both tables now use the one implementation. Claude-Session: https://claude.ai/code/session_01NfwfQhamRNnSqgXMUjf3h4
This commit is contained in:
parent
880ccc76a5
commit
56e2d8846d
5 changed files with 168 additions and 113 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 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,64 @@ 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} />);
|
||||
return (await screen.findByText(key.key_alias as string)).closest("tr") as HTMLElement;
|
||||
};
|
||||
|
||||
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 row = await renderRow(
|
||||
createMockKey({
|
||||
user_id: placeholder.user_id,
|
||||
user: placeholder,
|
||||
created_by: placeholder.user_id,
|
||||
created_by_user: placeholder,
|
||||
}),
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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