diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.test.tsx index 984b8135466..5785d0b37a1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.test.tsx @@ -8,6 +8,8 @@ import { MemoryRow } from "@/components/networking"; import { MemoryTable } from "./MemoryTable"; +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); + const makeMemory = (overrides: Partial = {}): MemoryRow => ({ memory_id: "mem-1", key: "user:profile", @@ -36,6 +38,23 @@ const baseProps = { }; describe("MemoryTable", () => { + it("links the User ID and Team ID cells to their detail pages", () => { + render(); + + expect(screen.getByRole("link", { name: "user-42" })).toHaveAttribute("href", "/ui/users?user=user-42"); + expect(screen.getByRole("link", { name: "team-7" })).toHaveAttribute("href", "/ui/teams?team=team-7"); + }); + + it("leaves the proxy admin and dashboard sentinels unlinked", () => { + const sentinelRow = makeMemory({ user_id: "default_user_id", team_id: "litellm-dashboard" }); + render(); + + expect(screen.getByText("default_user_id")).toBeInTheDocument(); + expect(screen.getByText("litellm-dashboard")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "default_user_id" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "litellm-dashboard" })).not.toBeInTheDocument(); + }); + it("renders every column header", () => { render(); for (const header of ["ID", "Name", "Preview", "User ID", "Team ID", "Updated"]) { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTableColumns.tsx index 6b2a6b08704..62b8ec624fa 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTableColumns.tsx @@ -14,6 +14,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/cva.config"; +import { teamDetailHref, userDetailHref } from "@/utils/entityLinks"; interface MemoryRowActionsProps { row: MemoryRow; @@ -109,7 +110,10 @@ export const getMemoryTableColumns = ({ header: "User ID", size: 160, enableSorting: false, - cell: ({ row }) => , + cell: ({ row }) => { + const userId = row.original.user_id; + return ; + }, }, { id: "team_id", @@ -118,7 +122,10 @@ export const getMemoryTableColumns = ({ header: "Team ID", size: 160, enableSorting: false, - cell: ({ row }) => , + cell: ({ row }) => { + const teamId = row.original.team_id; + return ; + }, }, { id: "updated_at", diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx index c715210fdde..c6720ecc7b1 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx @@ -4,6 +4,10 @@ import { describe, expect, it, vi } from "vitest"; import { IdCell } from "./id_cell"; +const { routerPushMock } = vi.hoisted(() => ({ routerPushMock: vi.fn() })); + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: routerPushMock }) })); + const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() })); vi.mock("@/utils/dataUtils", async (importOriginal) => ({ @@ -83,4 +87,23 @@ describe("IdCell", () => { render(); expect(screen.getByTestId("key-id-cell")).toHaveTextContent("k-1"); }); + + it("renders the id as a link and routes client side when href is set", async () => { + const user = userEvent.setup(); + render(); + + const link = screen.getByRole("link", { name: "user-42" }); + expect(link).toHaveAttribute("href", "/ui/users?user=user-42"); + expect(link).toHaveClass("cursor-pointer"); + + await user.click(link); + expect(routerPushMock).toHaveBeenCalledWith("/ui/users?user=user-42"); + }); + + it("stays plain text when href is undefined", () => { + render(); + + expect(screen.getByText("default_user_id").tagName).toBe("SPAN"); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx index 33c7f835e64..47d0d750223 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx +++ b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx @@ -3,6 +3,7 @@ import { Copy } from "lucide-react"; import * as React from "react"; +import { useEntityLinkClick } from "@/components/shared/EntityLink"; import { cn } from "@/lib/cva.config"; import { copyToClipboard } from "@/utils/dataUtils"; @@ -13,6 +14,7 @@ export type IdCellVariant = "pill" | "plain"; interface IdCellProps { value: string | null | undefined; variant?: IdCellVariant; + href?: string; onClick?: (value: string) => void; copyable?: boolean; copyLabel?: string; @@ -38,6 +40,7 @@ const VARIANT_CLASS: Record export function IdCell({ value, variant = "pill", + href, onClick, copyable = false, copyLabel = "Copy ID", @@ -52,16 +55,17 @@ export function IdCell({ return {fallback}; } + const linked = !!href && !disabled; const clickable = !!onClick && !disabled; const classes = cn( VARIANT_CLASS[variant].base, - clickable && VARIANT_CLASS[variant].clickable, + (linked || clickable) && VARIANT_CLASS[variant].clickable, truncate && "block max-w-[15ch] truncate", disabled && "opacity-50", className, ); - const idElement = clickable ? ( + const unlinkedElement = clickable ? ( @@ -71,6 +75,14 @@ export function IdCell({ ); + const idElement = linked ? ( + + {value} + + ) : ( + unlinkedElement + ); + const withTooltip = ; if (!copyable) { @@ -94,3 +106,21 @@ export function IdCell({ ); } + +interface IdLinkProps extends React.ComponentPropsWithoutRef<"a"> { + href: string; + dataTestId?: string; +} + +const IdLink = React.forwardRef(function IdLink( + { href, dataTestId, children, ...props }, + ref, +) { + const handleClick = useEntityLinkClick(href); + + return ( + + {children} + + ); +});