From b715d69558ea8bb6fac31a3c6ff2d00c40d7cbe9 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 9 Jul 2026 17:51:19 -0700 Subject: [PATCH] feat(ui): add /api-keys/[keyid] virtual key detail route Clicking a key's Key ID in the Virtual Keys table now navigates to a dedicated /api-keys/[keyid] route instead of swapping an inline detail view, so a key's details get a real, linkable URL. The route reuses the existing KeyInfoView and fetches the single key by its hash through a new useKeyInfo hook (the /key/list pipeline filtered by key_hash); its back button returns to the list The dashboard is a Next.js static export (output: export), which cannot resolve a runtime-created dynamic segment on a hard load, so without an SPA fallback at the serving layer a click or refresh of /ui/api-keys/ returns 404. The single-container proxy serves /ui via a bare StaticFiles mount with no fallback; the microservices helm chart already has one in ui/nginx.conf. The proxy-side fallback is a follow-up --- ui/litellm-dashboard/eslint-metrics.json | 2 +- .../api-keys/[keyid]/KeyDetailPage.tsx | 34 ++ .../app/(dashboard)/api-keys/[keyid]/page.tsx | 9 + .../src/app/(dashboard)/hooks/keys/useKeys.ts | 15 + .../VirtualKeysPage/VirtualKeysTable.test.tsx | 22 +- .../VirtualKeysPage/VirtualKeysTable.tsx | 369 +++++++++--------- 6 files changed, 253 insertions(+), 198 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/api-keys/[keyid]/KeyDetailPage.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/api-keys/[keyid]/page.tsx diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index d69b3e1f729..42169372dd6 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,7 +1,7 @@ { "@typescript-eslint/no-explicit-any": 1980, "complexity": 128, - "local/no-large-inline-object-arg": 512, + "local/no-large-inline-object-arg": 513, "local/no-long-condition-chain": 233, "max-depth": 59, "no-console": 15 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/[keyid]/KeyDetailPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/[keyid]/KeyDetailPage.tsx new file mode 100644 index 00000000000..61a175f0b00 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/[keyid]/KeyDetailPage.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useKeyInfo } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { useAllTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import LoadingScreen from "@/components/common_components/LoadingScreen"; +import KeyInfoView from "@/components/templates/key_info_view"; +import { migratedHref } from "@/utils/migratedPages"; +import { useParams, useRouter } from "next/navigation"; + +export default function KeyDetailPage() { + const router = useRouter(); + const params = useParams(); + const { isLoading: authLoading, isAuthorized } = useAuthorized(); + + const rawKeyId = params?.keyid; + const keyId = decodeURIComponent(Array.isArray(rawKeyId) ? rawKeyId[0] : rawKeyId ?? ""); + + const { data: keyData, isPending } = useKeyInfo(keyId); + const { data: teams } = useAllTeams(); + + if (authLoading || !isAuthorized || isPending) { + return ; + } + + return ( + router.push(migratedHref("api-keys"))} + /> + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/[keyid]/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/[keyid]/page.tsx new file mode 100644 index 00000000000..1c71c5f532a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/[keyid]/page.tsx @@ -0,0 +1,9 @@ +import KeyDetailPage from "./KeyDetailPage"; + +export function generateStaticParams() { + return [{ keyid: "placeholder" }]; +} + +export default function ApiKeyDetailPage() { + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts index dc1b247b06c..534be82bd06 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -113,6 +113,21 @@ export const useKeys = ( }); }; +export const useKeyInfo = (keyId: string): UseQueryResult => { + const { accessToken } = useAuthorized(); + + return useQuery({ + queryKey: keyKeys.detail(keyId), + queryFn: async () => { + const response: KeysResponse = await keyListCall(accessToken!, 1, 1, { keyHash: keyId, expand: "user" }); + const keys = response?.keys ?? []; + return keys.find((key) => key.token === keyId) ?? keys[0] ?? null; + }, + enabled: Boolean(accessToken && keyId), + staleTime: 30000, // 30 seconds + }); +}; + export const deletedKeyKeys = createQueryKeys("deletedKeys"); export const useDeletedKeys = ( page: number, diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index 02f5d588149..55ee2cb5e9d 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -7,6 +7,11 @@ import { KeyResponse, Team } from "../key_team_helpers/key_list"; import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; +const { mockRouterPush } = vi.hoisted(() => ({ mockRouterPush: vi.fn() })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockRouterPush }), +})); + // Resolve debounced values synchronously so an applied filter lands in the useKeys query within the test tick. vi.mock("@tanstack/react-pacer/debouncer", async () => { const React = await vi.importActual("react"); @@ -243,24 +248,19 @@ it("should handle column resizing hover events", () => { expect(resizer.style.opacity).toBe("0"); }); -it("should open KeyInfoView when clicking on a key ID button", async () => { +it("navigates to the key's detail route (with the key hash) when clicking the key ID button", async () => { renderWithProviders(); await waitFor(() => { expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); }); + fireEvent.click(screen.getByText("sk-1234567890abcdef")); + + expect(mockRouterPush).toHaveBeenCalledTimes(1); + expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining("api-keys/sk-1234567890abcdef")); + // The row navigates away instead of swapping to an inline detail view: the table stays put. expect(screen.getByText(/Showing.*results/)).toBeInTheDocument(); - - const keyIdButton = screen.getByText("sk-1234567890abcdef"); - fireEvent.click(keyIdButton); - - await waitFor(() => { - expect(screen.getByText("Back to Keys")).toBeInTheDocument(); - expect(screen.getByText("Created At")).toBeInTheDocument(); - }); - - expect(screen.queryByText(/Showing.*results/)).not.toBeInTheDocument(); }); it("should display 'Default Proxy Admin' for user_id when value is 'default_user_id'", async () => { diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index cae6dc54df5..bb41e707b3e 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -23,7 +23,8 @@ import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSele import { KeyResponse, Team } from "../key_team_helpers/key_list"; import FilterComponent, { FilterOption } from "../molecules/filter"; import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; -import KeyInfoView from "../templates/key_info_view"; +import { useRouter } from "next/navigation"; +import { migratedHref } from "@/utils/migratedPages"; type KeyFilterState = { "Team ID": string; @@ -57,7 +58,7 @@ const toKeyListFilters = (filters: KeyFilterState): KeyListFilterOptions => ({ export function VirtualKeysTable() { const { data: fetchedOrganizations, isLoading: isOrgsLoading } = useOrganizations(); const resolvedOrganizations = useMemo(() => fetchedOrganizations ?? [], [fetchedOrganizations]); - const [selectedKey, setSelectedKey] = useState(null); + const router = useRouter(); const [sorting, setSorting] = React.useState([{ id: "created_at", desc: true }]); const [tablePagination, setTablePagination] = React.useState({ pageIndex: 0, @@ -135,7 +136,12 @@ export function VirtualKeysTable() { header: "Key ID", size: 100, enableSorting: true, - cell: (info) => setSelectedKey(info.row.original)} />, + cell: (info) => ( + router.push(migratedHref(`api-keys/${encodeURIComponent(info.row.original.token)}/`))} + /> + ), }, { id: "key_alias", @@ -522,7 +528,7 @@ export function VirtualKeysTable() { }, }, ], - [allTeams, resolvedOrganizations], + [allTeams, resolvedOrganizations, router], ); const filterOptions: FilterOption[] = [ @@ -611,200 +617,191 @@ export function VirtualKeysTable() { const rangeLabel = `${start} - ${end}`; return (
- {selectedKey ? ( - setSelectedKey(null)} - keyData={selectedKey} - teams={allTeams} - /> - ) : ( -
-
- +
+
+ +
+ +
+
+ {isLoading ? ( + + ) : ( + + Showing {rangeLabel} of {totalCount} results + + )} + + } + onClick={handleRefresh} + disabled={isButtonLoading} + title="Fetch data" + > + {isButtonLoading ? "Fetching" : "Fetch"} +
-
-
- {isLoading ? ( - - ) : ( - - Showing {rangeLabel} of {totalCount} results - - )} +
+ {isLoading ? ( + + ) : ( + + Page {pageIndex + 1} of {table.getPageCount()} + + )} - } - onClick={handleRefresh} - disabled={isButtonLoading} - title="Fetch data" + {isLoading ? ( + + ) : ( +
+ Previous + + )} -
- {isLoading ? ( - - ) : ( - - Page {pageIndex + 1} of {table.getPageCount()} - - )} - - {isLoading ? ( - - ) : ( - - )} - - {isLoading ? ( - - ) : ( - - )} -
+ {isLoading ? ( + + ) : ( + + )}
-
-
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - { - const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); - if (resizer) { - (resizer as HTMLElement).style.opacity = "0.5"; - } - }} - onMouseLeave={() => { - const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); - if (resizer && !header.column.getIsResizing()) { - (resizer as HTMLElement).style.opacity = "0"; - } - }} - onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined} - > -
-
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.id !== "actions" && header.column.getCanSort() && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} -
header.column.resetSize()} - onMouseDown={header.getResizeHandler()} - onTouchStart={header.getResizeHandler()} - className={`resizer ${table.options.columnResizeDirection} ${header.column.getIsResizing() ? "isResizing" : ""}`} - style={{ - position: "absolute", - right: 0, - top: 0, - height: "100%", - width: "5px", - background: header.column.getIsResizing() ? "#3b82f6" : "transparent", - cursor: "col-resize", - userSelect: "none", - touchAction: "none", - opacity: header.column.getIsResizing() ? 1 : 0, - }} - /> +
+
+
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + { + const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); + if (resizer) { + (resizer as HTMLElement).style.opacity = "0.5"; + } + }} + onMouseLeave={() => { + const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); + if (resizer && !header.column.getIsResizing()) { + (resizer as HTMLElement).style.opacity = "0"; + } + }} + onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined} + > +
+
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())}
- + {header.id !== "actions" && header.column.getCanSort() && ( +
+ {header.column.getIsSorted() ? ( + { + asc: , + desc: , + }[header.column.getIsSorted() as string] + ) : ( + + )} +
+ )} +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={`resizer ${table.options.columnResizeDirection} ${header.column.getIsResizing() ? "isResizing" : ""}`} + style={{ + position: "absolute", + right: 0, + top: 0, + height: "100%", + width: "5px", + background: header.column.getIsResizing() ? "#3b82f6" : "transparent", + cursor: "col-resize", + userSelect: "none", + touchAction: "none", + opacity: header.column.getIsResizing() ? 1 : 0, + }} + /> +
+ + ))} + + ))} + + + {isLoading ? ( + + +
+

🚅 Loading keys...

+
+
+
+ ) : keyList.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + 3 ? "px-0" : ""}`} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} - ))} - - - {isLoading ? ( - - -
-

🚅 Loading keys...

-
-
-
- ) : keyList.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - 3 ? "px-0" : ""}`} - > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No keys found

-
-
-
- )} -
-
-
+ )) + ) : ( + + +
+

No keys found

+
+
+
+ )} + +
- )} +
); }