mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge pull request #40646 from BerriAI/litellm_key_table_entity_links
feat(ui): link the Team, Organization, User and Created By cells on the Virtual Keys page
This commit is contained in:
commit
b1ba92ab0f
10 changed files with 167 additions and 38 deletions
|
|
@ -54,7 +54,7 @@ function ResourceBadge({
|
|||
fallback,
|
||||
}: {
|
||||
resource: AccessGroupResource;
|
||||
href: string;
|
||||
href?: string;
|
||||
fallback: (id: string) => string;
|
||||
}) {
|
||||
const badge = (
|
||||
|
|
|
|||
|
|
@ -473,6 +473,92 @@ it("should display 'Default Proxy Admin' for user_id when value is 'default_user
|
|||
});
|
||||
});
|
||||
|
||||
describe("entity links out of the key rows", () => {
|
||||
const keyRow = async () => (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement;
|
||||
|
||||
const enableColumn = async (user: ReturnType<typeof userEvent.setup>, title: string) => {
|
||||
await user.click(screen.getByRole("button", { name: "Columns" }));
|
||||
await user.click(await screen.findByText(title));
|
||||
await user.keyboard("{Escape}");
|
||||
};
|
||||
|
||||
const enableCreatedByColumn = (user: ReturnType<typeof userEvent.setup>) => enableColumn(user, "Created By");
|
||||
|
||||
it("points the User and Team cells at their detail pages", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
const row = await keyRow();
|
||||
expect(within(row).getByRole("link", { name: "user@example.com" })).toHaveAttribute(
|
||||
"href",
|
||||
"/ui/users?user=user-1",
|
||||
);
|
||||
expect(within(row).getByRole("link", { name: "Test Team" })).toHaveAttribute("href", "/ui/teams?team=team-1");
|
||||
});
|
||||
|
||||
it("points the Organization cell at the org's detail page", async () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, org_id: "org-1" }]));
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
await enableColumn(user, "Organization");
|
||||
|
||||
const row = await keyRow();
|
||||
expect(within(row).getByRole("link", { name: "Test Organization" })).toHaveAttribute(
|
||||
"href",
|
||||
"/ui/organizations?org=org-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("points the Created By cell at the creator's detail page", async () => {
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([
|
||||
{
|
||||
...mockKey,
|
||||
created_by: "creator-1",
|
||||
created_by_user: { user_id: "creator-1", user_email: "creator@example.com", user_alias: "The Creator" },
|
||||
},
|
||||
]),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
await enableCreatedByColumn(user);
|
||||
|
||||
const row = await keyRow();
|
||||
expect(within(row).getByRole("link", { name: "The Creator" })).toHaveAttribute("href", "/ui/users?user=creator-1");
|
||||
});
|
||||
|
||||
it("leaves the default_user_id placeholder unlinked even once it resolves to a named user", async () => {
|
||||
const placeholder = { user_id: "default_user_id", user_email: "admin@example.com", user_alias: "Proxy Admin" };
|
||||
mockUseKeys.mockReturnValue(
|
||||
keysResult([
|
||||
{
|
||||
...mockKey,
|
||||
user_id: placeholder.user_id,
|
||||
user_email: placeholder.user_email,
|
||||
user: placeholder,
|
||||
created_by: placeholder.user_id,
|
||||
created_by_user: placeholder,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
await enableCreatedByColumn(user);
|
||||
|
||||
const row = await keyRow();
|
||||
expect(within(row).getAllByText("Proxy Admin")).toHaveLength(2);
|
||||
expect(within(row).queryByRole("link", { name: "Proxy Admin" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves the litellm-dashboard session team unlinked", async () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, team_id: "litellm-dashboard" }]));
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
const row = await keyRow();
|
||||
expect(within(row).getByText("litellm-dashboard")).toBeInTheDocument();
|
||||
expect(within(row).queryByRole("link", { name: "litellm-dashboard" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render table without crashing when models is null", async () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, models: null as unknown as string[] }]));
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import {
|
|||
StatusBadge,
|
||||
type StatusTone,
|
||||
} from "@/components/shared/table_cells";
|
||||
import { orgDetailHref, teamDetailHref, userDetailHref } from "@/utils/entityLinks";
|
||||
import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels";
|
||||
|
||||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
|
|
@ -27,6 +29,8 @@ 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" },
|
||||
|
|
@ -74,7 +78,7 @@ const UserPopoverCell = ({
|
|||
width: number;
|
||||
}) => {
|
||||
const displayValue = userAlias || userEmail || userId;
|
||||
const isDefaultAdmin = userId === "default_user_id";
|
||||
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]">
|
||||
|
|
@ -95,28 +99,21 @@ const UserPopoverCell = ({
|
|||
</div>
|
||||
);
|
||||
|
||||
if (isDefaultAdmin && !userAlias && !userEmail) {
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger render={<span className="cursor-default" />}>
|
||||
<DefaultProxyAdminTag userId={userId} />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start">{popoverContent}</HoverCardContent>
|
||||
</HoverCard>
|
||||
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="font-mono text-xs truncate block cursor-default"
|
||||
style={{ maxWidth: width, overflow: "hidden" }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{displayValue || "-"}
|
||||
<HoverCardTrigger render={<span className="block" style={{ maxWidth: width, overflow: "hidden" }} />}>
|
||||
{trigger}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start">{popoverContent}</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
|
@ -201,12 +198,12 @@ export const getKeyTableColumns = ({
|
|||
const teamId = info.getValue() as string | null;
|
||||
if (!teamId) return "-";
|
||||
const team = allTeams.find((t) => t.team_id === teamId);
|
||||
const displayValue = team?.team_alias || teamId;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{displayValue}
|
||||
</span>
|
||||
<IdentityCell
|
||||
title={team?.team_alias || teamId}
|
||||
titleClassName={ENTITY_CELL_TITLE_CLASSES}
|
||||
href={teamDetailHref(teamId)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
@ -221,12 +218,12 @@ export const getKeyTableColumns = ({
|
|||
const orgId = info.getValue() as string | null;
|
||||
if (!orgId) return "-";
|
||||
const org = organizations.find((o) => o.organization_id === orgId);
|
||||
const displayValue = org?.organization_alias || orgId;
|
||||
const width = info.cell.column.getSize();
|
||||
return (
|
||||
<span className="font-mono text-xs truncate block" style={{ maxWidth: width, overflow: "hidden" }}>
|
||||
{displayValue}
|
||||
</span>
|
||||
<IdentityCell
|
||||
title={org?.organization_alias || orgId}
|
||||
titleClassName={ENTITY_CELL_TITLE_CLASSES}
|
||||
href={orgDetailHref(orgId)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const DEFAULT_USER_ID = "default_user_id";
|
||||
import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels";
|
||||
|
||||
interface DefaultProxyAdminTagProps {
|
||||
userId: string | null | undefined;
|
||||
}
|
||||
|
||||
export default function DefaultProxyAdminTag({ userId }: DefaultProxyAdminTagProps) {
|
||||
if (userId === DEFAULT_USER_ID) {
|
||||
if (userId === DEFAULT_PROXY_ADMIN_USER_ID) {
|
||||
return <Badge variant="secondary">Default Proxy Admin</Badge>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { EntityLink } from "@/components/shared/EntityLink";
|
||||
import { cx } from "@/lib/cva.config";
|
||||
import { DEFAULT_PROXY_ADMIN_USER_ID } from "@/utils/sentinels";
|
||||
import DefaultProxyAdminTag from "./DefaultProxyAdminTag";
|
||||
|
||||
interface LabeledFieldProps {
|
||||
|
|
@ -24,7 +25,7 @@ export default function LabeledField({
|
|||
defaultUserIdCheck = false,
|
||||
}: LabeledFieldProps) {
|
||||
const isEmpty = !value;
|
||||
const isDefaultUser = defaultUserIdCheck && value === "default_user_id";
|
||||
const isDefaultUser = defaultUserIdCheck && value === DEFAULT_PROXY_ADMIN_USER_ID;
|
||||
const displayValue = isEmpty ? "-" : value;
|
||||
const isCopyable = copyable && !isEmpty && !isDefaultUser;
|
||||
const isLink = href != null && !isEmpty && !isDefaultUser;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ describe("EntityLink", () => {
|
|||
expect(push).toHaveBeenCalledWith("/ui/users?user=u1");
|
||||
});
|
||||
|
||||
it("renders the label as plain text when there is no href to point at", () => {
|
||||
render(<EntityLink>default_user_id</EntityLink>);
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("default_user_id")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves modified clicks to the browser so new-tab shortcuts keep working", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<EntityLink href="/ui/users?user=u1">alice</EntityLink>);
|
||||
|
|
|
|||
|
|
@ -19,12 +19,24 @@ export function useEntityLinkClick(href: string): (e: React.MouseEvent) => void
|
|||
}
|
||||
|
||||
interface EntityLinkProps {
|
||||
href: string;
|
||||
href?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EntityLink({ href, className, children }: EntityLinkProps) {
|
||||
if (!href) {
|
||||
return <span className={cn("inline-block min-w-0 max-w-full truncate font-semibold", className)}>{children}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkedEntity href={href} className={className}>
|
||||
{children}
|
||||
</LinkedEntity>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedEntity({ href, className, children }: EntityLinkProps & { href: string }) {
|
||||
const handleClick = useEntityLinkClick(href);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,29 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
|
||||
|
||||
import { modelGroupHref } from "./entityLinks";
|
||||
import { modelGroupHref, teamDetailHref, userDetailHref } from "./entityLinks";
|
||||
|
||||
describe("userDetailHref", () => {
|
||||
it("targets the users page filtered to the encoded user id", () => {
|
||||
expect(userDetailHref("user-1")).toMatch(/\/users\?user=user-1$/);
|
||||
expect(userDetailHref("a b/c")).toMatch(/\?user=a%20b%2Fc$/);
|
||||
});
|
||||
|
||||
it("returns no href for the proxy admin placeholder, which has no user page", () => {
|
||||
expect(userDetailHref("default_user_id")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("teamDetailHref", () => {
|
||||
it("targets the teams page filtered to the encoded team id", () => {
|
||||
expect(teamDetailHref("team-1")).toMatch(/\/teams\?team=team-1$/);
|
||||
expect(teamDetailHref("a b/c")).toMatch(/\?team=a%20b%2Fc$/);
|
||||
});
|
||||
|
||||
it("returns no href for the Admin UI session team, which has no team page", () => {
|
||||
expect(teamDetailHref("litellm-dashboard")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelGroupHref", () => {
|
||||
it("targets the models page filtered to the encoded model group", () => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { DEFAULT_PROXY_ADMIN_USER_ID, UI_TEAM_ID } from "@/utils/sentinels";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
|
||||
const MODEL_GRANT_SENTINELS: ReadonlySet<string> = new Set([
|
||||
|
|
@ -6,7 +7,8 @@ const MODEL_GRANT_SENTINELS: ReadonlySet<string> = new Set([
|
|||
"no-default-models",
|
||||
]);
|
||||
|
||||
export function teamDetailHref(teamId: string): string {
|
||||
export function teamDetailHref(teamId: string): string | undefined {
|
||||
if (teamId === UI_TEAM_ID) return undefined;
|
||||
return `${uiHref("teams")}?team=${encodeURIComponent(teamId)}`;
|
||||
}
|
||||
|
||||
|
|
@ -14,7 +16,8 @@ export function keyDetailHref(keyToken: string): string {
|
|||
return `${uiHref("api-keys")}?key=${encodeURIComponent(keyToken)}`;
|
||||
}
|
||||
|
||||
export function userDetailHref(userId: string): string {
|
||||
export function userDetailHref(userId: string): string | undefined {
|
||||
if (userId === DEFAULT_PROXY_ADMIN_USER_ID) return undefined;
|
||||
return `${uiHref("users")}?user=${encodeURIComponent(userId)}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
3
ui/litellm-dashboard/src/utils/sentinels.ts
Normal file
3
ui/litellm-dashboard/src/utils/sentinels.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const DEFAULT_PROXY_ADMIN_USER_ID = "default_user_id";
|
||||
|
||||
export const UI_TEAM_ID = "litellm-dashboard";
|
||||
Loading…
Add table
Reference in a new issue