mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
Merge pull request #23301 from BerriAI/litellm_user_email_keys
[Feature] UI - Keys: Improve VirtualKeysTable Column Display
This commit is contained in:
commit
d9e6758655
3 changed files with 147 additions and 68 deletions
|
|
@ -57,6 +57,18 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
|
|||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useOrganizations hook
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||
useOrganizations: vi.fn().mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
organization_id: "org-1",
|
||||
organization_alias: "Test Organization",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock fetchTeams to prevent network calls
|
||||
vi.mock("@/app/(dashboard)/networking", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/app/(dashboard)/networking")>();
|
||||
|
|
@ -125,6 +137,7 @@ const mockKey: KeyResponse = {
|
|||
user: {
|
||||
user_email: "user@example.com",
|
||||
user_id: "user-1",
|
||||
user_alias: null,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -380,7 +393,7 @@ it("should render table headers correctly", () => {
|
|||
// Check that main headers are rendered (testing the header.isPlaceholder condition path)
|
||||
expect(screen.getByText("Key ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("Key Alias")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team Alias")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("Models")).toBeInTheDocument();
|
||||
expect(screen.getByText("Spend (USD)")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -463,6 +476,8 @@ it("should display 'Default Proxy Admin' for user_id when value is 'default_user
|
|||
const keyWithDefaultUserId = {
|
||||
...mockKey,
|
||||
user_id: "default_user_id",
|
||||
user_email: "",
|
||||
user: { user_id: "default_user_id", user_email: "", user_alias: null },
|
||||
};
|
||||
|
||||
mockUseFilterLogic.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
|
|
@ -25,13 +26,14 @@ import {
|
|||
Text,
|
||||
} from "@tremor/react";
|
||||
import { InfoCircleOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { Button as AntButton, Popover, Skeleton, Tooltip } from "antd";
|
||||
import { Button as AntButton, Popover, Skeleton, Tooltip, Typography } from "antd";
|
||||
import React, { useEffect, useDeferredValue, useMemo, useState } from "react";
|
||||
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
|
||||
import { useFilterLogic } from "../key_team_helpers/filter_logic";
|
||||
import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import FilterComponent, { FilterOption } from "../molecules/filter";
|
||||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
import { Organization } from "../networking";
|
||||
import KeyInfoView from "../templates/key_info_view";
|
||||
|
||||
|
|
@ -51,6 +53,8 @@ interface VirtualKeysTableProps {
|
|||
*/
|
||||
|
||||
export function VirtualKeysTable({ teams, organizations, onSortChange, currentSort }: VirtualKeysTableProps) {
|
||||
const { data: fetchedOrganizations } = useOrganizations();
|
||||
const resolvedOrganizations = fetchedOrganizations ?? organizations ?? [];
|
||||
const [selectedKey, setSelectedKey] = useState<KeyResponse | null>(null);
|
||||
const [sorting, setSorting] = React.useState<SortingState>(() => {
|
||||
if (currentSort) {
|
||||
|
|
@ -86,6 +90,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
|||
} = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, {
|
||||
sortBy: sortBy || undefined,
|
||||
sortOrder: sortOrder || undefined,
|
||||
expand: "user",
|
||||
});
|
||||
const [expandedAccordions, setExpandedAccordions] = useState<Record<string, boolean>>({});
|
||||
|
||||
|
|
@ -172,11 +177,9 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
|||
const value = info.getValue() as string;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span className={`font-mono text-xs truncate block`} style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{value ?? "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{value ?? "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
@ -191,76 +194,110 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
|||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_id",
|
||||
header: "Team Alias",
|
||||
header: "Team",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row, getValue }) => {
|
||||
const teamId = getValue() as string;
|
||||
const team = teams?.find((t) => t.team_id === teamId);
|
||||
return team?.team_alias || "Unknown";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "team_id",
|
||||
accessorKey: "team_id",
|
||||
header: "Team ID",
|
||||
size: 80,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string | null;
|
||||
const teamId = info.getValue() as string | null;
|
||||
if (!teamId) return "-";
|
||||
const team = teams?.find((t) => t.team_id === teamId);
|
||||
const displayValue = team?.team_alias || teamId;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span className={`font-mono text-xs truncate block`} style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{value ?? "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{displayValue}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "organization_id",
|
||||
id: "organization_alias",
|
||||
accessorKey: "org_id",
|
||||
header: "Organization ID",
|
||||
header: "Organization",
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: (info) => (info.getValue() ? info.renderValue() : "-"),
|
||||
},
|
||||
{
|
||||
id: "user_email",
|
||||
accessorKey: "user",
|
||||
header: "User Email",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const user = info.getValue() as any;
|
||||
const value = user?.user_email;
|
||||
const orgId = info.getValue() as string | null;
|
||||
if (!orgId) return "-";
|
||||
const org = resolvedOrganizations.find((o) => o.organization_id === orgId);
|
||||
const displayValue = org?.organization_alias || orgId;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span className={`font-mono text-xs truncate block`} style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{value ?? "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{displayValue}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "user_id",
|
||||
accessorKey: "user_id",
|
||||
header: "User ID",
|
||||
size: 70,
|
||||
id: "user",
|
||||
accessorKey: "user",
|
||||
header: () => (
|
||||
<span className="flex items-center gap-1">
|
||||
User
|
||||
<Popover
|
||||
content="Displays the first available value: User Alias, User Email, or User ID."
|
||||
trigger="hover"
|
||||
>
|
||||
<InfoCircleOutlined className="text-gray-400 text-xs cursor-help" />
|
||||
</Popover>
|
||||
</span>
|
||||
),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const userId = info.getValue() as string | null;
|
||||
const displayValue = userId === "default_user_id" ? "Default Proxy Admin" : userId;
|
||||
const width = info.cell.column.getSize();
|
||||
cell: ({ row }) => {
|
||||
const key = row.original;
|
||||
const userAlias = key.user?.user_alias ?? null;
|
||||
const userEmail = key.user?.user_email ?? key.user_email ?? null;
|
||||
const userId = key.user_id ?? null;
|
||||
const isDefaultAdmin = userId === "default_user_id";
|
||||
const displayValue = userAlias || userEmail || userId;
|
||||
const width = 160;
|
||||
|
||||
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-gray-400">{label}</span>
|
||||
{value ? (
|
||||
<Typography.Text
|
||||
className="font-mono text-xs"
|
||||
ellipsis={{ tooltip: value }}
|
||||
copyable
|
||||
>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<span className="font-mono">-</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isDefaultAdmin && !userAlias && !userEmail) {
|
||||
return (
|
||||
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
|
||||
<span className="cursor-default">
|
||||
<DefaultProxyAdminTag userId={userId} />
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={displayValue}>
|
||||
<span className={`font-mono text-xs truncate block`} style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{displayValue ?? "-"}
|
||||
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
|
||||
<span
|
||||
className="font-mono text-xs truncate block cursor-default"
|
||||
style={{ maxWidth: width, overflow: "hidden" }}
|
||||
>
|
||||
{displayValue || "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
@ -279,18 +316,48 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
|||
id: "created_by",
|
||||
accessorKey: "created_by",
|
||||
header: "Created By",
|
||||
size: 70,
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string | null;
|
||||
const displayValue = value === "default_user_id" ? "Default Proxy Admin" : value;
|
||||
const width = info.cell.column.getSize();
|
||||
const userId = info.getValue() as string | null;
|
||||
if (!userId) return "-";
|
||||
const isDefaultAdmin = userId === "default_user_id";
|
||||
const width = 160;
|
||||
|
||||
const popoverContent = (
|
||||
<div className="flex flex-col gap-2 text-xs min-w-[200px] max-w-[300px]">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-gray-400">User ID</span>
|
||||
<Typography.Text
|
||||
className="font-mono text-xs"
|
||||
ellipsis={{ tooltip: userId }}
|
||||
copyable
|
||||
>
|
||||
{userId}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isDefaultAdmin) {
|
||||
return (
|
||||
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
|
||||
<span className="cursor-default">
|
||||
<DefaultProxyAdminTag userId={userId} />
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={displayValue}>
|
||||
<span className={`font-mono text-xs truncate block`} style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{displayValue ?? "-"}
|
||||
<Popover content={popoverContent} trigger="hover" placement="bottomLeft">
|
||||
<span
|
||||
className="font-mono text-xs truncate block cursor-default"
|
||||
style={{ maxWidth: width, overflow: "hidden" }}
|
||||
>
|
||||
{userId}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
@ -477,7 +544,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
|||
);
|
||||
},
|
||||
},
|
||||
], []);
|
||||
], [teams, resolvedOrganizations]);
|
||||
|
||||
const filterOptions: FilterOption[] = [
|
||||
{
|
||||
|
|
@ -535,8 +602,6 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
|||
},
|
||||
];
|
||||
|
||||
console.log(`keys: ${JSON.stringify(keys)}`);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredKeys,
|
||||
columns: columns.filter((col) => col.id !== "expander"),
|
||||
|
|
@ -548,13 +613,11 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
|
|||
},
|
||||
onSortingChange: (updaterOrValue) => {
|
||||
const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue;
|
||||
console.log(`newSorting: ${JSON.stringify(newSorting)}`);
|
||||
setSorting(newSorting);
|
||||
if (newSorting && newSorting.length > 0) {
|
||||
const sortState = newSorting[0];
|
||||
const sortBy = sortState.id;
|
||||
const sortOrder = sortState.desc ? "desc" : "asc";
|
||||
console.log(`sortBy: ${sortBy}, sortOrder: ${sortOrder}`);
|
||||
// Update filters state without triggering debouncedSearch
|
||||
// The useKeys hook will automatically refetch with the new sort parameters
|
||||
handleFilterChange(
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export interface KeyResponse {
|
|||
user?: {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
user_alias: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue