From 6b8dcec64e805472792e4a7ab4c02d0ce8f72a90 Mon Sep 17 00:00:00 2001 From: ishaan-berri <155045088+ishaan-berri@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:34:33 -0700 Subject: [PATCH] feat(ui): add Cmd+K command palette with key search (#45456) * feat(ui): add Cmd+K command palette with header search box Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> * fix(ui): refine command palette selection and results Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> * feat(ui): add subtle Cmd+K discovery hint Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> * fix(ui): address command palette review findings Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/app/(dashboard)/hooks/keys/useKeys.ts | 3 +- .../app/(dashboard)/hooks/teams/useTeams.ts | 9 +- .../src/app/(dashboard)/layout.tsx | 43 +- .../CommandPalette.integration.test.tsx | 442 ++++++++++++++++++ .../CommandPalette/CommandPalette.tsx | 319 +++++++++++++ .../CommandPalette/CommandPaletteHint.tsx | 44 ++ .../CommandPalette/CommandPaletteProvider.tsx | 65 +++ .../CommandPaletteResultRow.tsx | 50 ++ .../CommandPalette/CommandPaletteTrigger.tsx | 50 ++ .../CommandPalette/useShortcutLabel.ts | 20 + .../CommandPalette/useVisibleMenuGroups.ts | 34 ++ .../components/CommandPalette/utils.test.ts | 91 ++++ .../src/components/CommandPalette/utils.ts | 59 +++ .../DashboardHeader.integration.test.tsx | 5 +- .../src/components/DashboardHeader.test.tsx | 45 +- .../src/components/DashboardHeader.tsx | 4 + .../src/components/leftnav.tsx | 166 ++++--- .../src/components/ui/dialog.tsx | 9 +- 18 files changed, 1364 insertions(+), 94 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.tsx create mode 100644 ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteHint.tsx create mode 100644 ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteProvider.tsx create mode 100644 ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteResultRow.tsx create mode 100644 ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx create mode 100644 ui/litellm-dashboard/src/components/CommandPalette/useShortcutLabel.ts create mode 100644 ui/litellm-dashboard/src/components/CommandPalette/useVisibleMenuGroups.ts create mode 100644 ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts create mode 100644 ui/litellm-dashboard/src/components/CommandPalette/utils.ts 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 7e7089e685f..32dac9c2f65 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -110,13 +110,14 @@ export const useKeys = ( page: number, pageSize: number, options: KeyListCallOptions = {}, + queryOptions: { enabled?: boolean } = {}, ): UseQueryResult => { const { accessToken } = useAuthorized(); return useQuery({ queryKey: keyKeys.list({ page, limit: pageSize, ...options }), queryFn: async () => await keyListCall(accessToken!, page, pageSize, options), - enabled: Boolean(accessToken), + enabled: Boolean(accessToken) && (queryOptions.enabled ?? true), staleTime: 30000, // 30 seconds placeholderData: keepPreviousData, }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts index 7d1d035b4d4..2f46e548a7b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts @@ -118,13 +118,14 @@ export const useTeamsTable = ( }; export const teamKeys = createQueryKeys("teams"); -export const useTeams = (): UseQueryResult => { +export const useTeams = (queryOptions: { enabled?: boolean } = {}): UseQueryResult => { const { accessToken, userId, userRole } = useAuthorized(); - return useQuery({ + const teamsQueryOptions = { queryKey: teamKeys.list({}), queryFn: async () => await fetchTeams(accessToken!, userId, userRole, null), - enabled: Boolean(accessToken), - }); + enabled: Boolean(accessToken) && queryOptions.enabled !== false, + }; + return useQuery(teamsQueryOptions); }; const ALL_TEAMS_PAGE_SIZE = 100; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 4762dd2fed1..81d290cdfe4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -23,6 +23,7 @@ import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/s import { Button } from "@/components/ui/button"; import { Menu } from "lucide-react"; import { useMediaQuery } from "usehooks-ts"; +import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider"; const pluginApiClient = createApiClient({ getBaseUrl: () => getProxyBaseUrl() ?? "" }); @@ -168,26 +169,28 @@ function DashboardShell({ children }: { children: React.ReactNode }) { setMobileNavigationKey(null)} /> -
- - } - > - - - } - /> - - - - - - -
{children}
-
+ +
+ + } + > + + + } + /> + + + + + + +
{children}
+
+
diff --git a/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.integration.test.tsx b/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.integration.test.tsx new file mode 100644 index 00000000000..c722194d496 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.integration.test.tsx @@ -0,0 +1,442 @@ +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import type { KeyResponse } from "@/components/key_team_helpers/key_list"; +import { writeStorage } from "@/lib/storage"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; +import { keyDetailHref } from "@/utils/entityLinks"; +import { uiHref } from "@/utils/uiHref"; +import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; +import { COMMAND_PALETTE_HINT_KEY } from "./CommandPaletteHint"; +import { CommandPaletteProvider } from "./CommandPaletteProvider"; +import { CommandPaletteTrigger } from "./CommandPaletteTrigger"; + +const mocks = vi.hoisted(() => ({ + pathname: "/api-keys", + push: vi.fn<(href: string) => void>(), + useKeys: vi.fn(), + teamAlias: "platform-team", + isPlaceholderData: false, +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => mocks.pathname, + useRouter: () => ({ push: mocks.push }), +})); + +vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ + useKeys: mocks.useKeys, +})); +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useTeams: () => ({ + data: [{ team_id: "team-1", team_alias: mocks.teamAlias, members_with_roles: [] }], + }), +})); +vi.mock("@/app/(dashboard)/hooks/useIsOrgAdmin", () => ({ + default: () => false, +})); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: () => ({ data: { values: {} } }), +})); + +const HIGH_VOLUME_KEY = keyFixture("key-prod-0", "high-volume-prod", "sk-...4zoA", "team-1"); +const PROD_KEY = keyFixture("key-prod-1", "prod-backend", "sk-...AHeA", "team-1"); +const STAGING_KEY = keyFixture("key-staging-1", "staging-agent", "sk-...stgB", "unknown-team"); +const KEY_FIXTURES = [HIGH_VOLUME_KEY, PROD_KEY, STAGING_KEY]; +const UI_SETTINGS_RESPONSE = { + server_root_path: "", + proxy_base_url: null, + admin_ui_disabled: false, + auto_redirect_to_sso: false, + sso_configured: false, + is_control_plane: false, + workers: [], +}; +const PROD_KEY_LIST_OPTIONS = { search: "prod", sortBy: "created_at", sortOrder: "desc", expand: "user" }; +const cachedKeyResults = new Map(); + +function keyFixture(token: string, alias: string, keyName: string, teamId: string | null = null): KeyResponse { + return { + token, + token_id: token, + key_name: keyName, + key_alias: alias, + spend: 2.5, + team_id: teamId, + team_alias: "", + } as KeyResponse; +} + +function sessionCookie() { + const encode = (value: object) => + btoa(JSON.stringify(value)).replaceAll("=", "").replaceAll("+", "-").replaceAll("/", "_"); + const claims = { + key: "sk-session-test", + user_id: "test-admin", + user_role: "proxy_admin", + premium_user: true, + auth_header_name: "X-Gateway-Session", + exp: Date.now() / 1000 + 3600, + }; + document.cookie = `token=${encode({ alg: "none" })}.${encode(claims)}.test; Path=/`; +} + +function renderPalette() { + return renderWithProviders( + + + , + ); +} + +beforeEach(() => { + testQueryClient.clear(); + window.localStorage.clear(); + cachedKeyResults.clear(); + vi.clearAllMocks(); + mocks.pathname = "/api-keys"; + mocks.teamAlias = "platform-team"; + mocks.isPlaceholderData = false; + sessionCookie(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => + String(input).includes("/v2/team/list") + ? Response.json({ + teams: [{ team_id: "team-1", team_alias: "platform-team" }], + total_pages: 1, + }) + : Response.json(UI_SETTINGS_RESPONSE), + ), + ); + vi.mocked(useKeys).mockImplementation((_page, _pageSize, options) => { + const query = options.search?.toLowerCase() ?? ""; + const keys = + cachedKeyResults.get(query) ?? + KEY_FIXTURES.filter( + (key) => !query || key.key_alias.toLowerCase().includes(query) || key.key_name.toLowerCase().includes(query), + ); + cachedKeyResults.set(query, keys); + return { + data: { keys, total_count: keys.length, current_page: 1, total_pages: 1 }, + isFetching: false, + isError: false, + isPlaceholderData: mocks.isPlaceholderData, + } as ReturnType; + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + testQueryClient.clear(); + vi.unstubAllGlobals(); + document.cookie = "token=; Max-Age=0; Path=/"; +}); + +describe("CommandPalette integration", () => { + it("shows the discovery hint on the keys route", async () => { + renderPalette(); + + expect(await screen.findByText("to search keys")).toBeVisible(); + }); + + it("opens the palette from the hint and persists that it was seen", async () => { + const user = userEvent.setup(); + renderPalette(); + + await user.click(screen.getByRole("button", { name: /to search keys/ })); + + expect(await screen.findByRole("combobox", { name: "Search" })).toBeVisible(); + expect(screen.queryByText("to search keys")).not.toBeInTheDocument(); + expect(window.localStorage.getItem(COMMAND_PALETTE_HINT_KEY.name)).toBe("true"); + }); + + it("hides and persists the discovery hint when Ctrl+K opens the palette", async () => { + renderPalette(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + + expect(await screen.findByRole("combobox", { name: "Search" })).toBeVisible(); + expect(screen.queryByText("to search keys")).not.toBeInTheDocument(); + expect(window.localStorage.getItem(COMMAND_PALETTE_HINT_KEY.name)).toBe("true"); + }); + + it("dismisses the discovery hint without opening the palette", async () => { + const user = userEvent.setup(); + renderPalette(); + + await user.click(screen.getByRole("button", { name: "Dismiss search hint" })); + + expect(screen.queryByRole("dialog", { name: "Command palette" })).not.toBeInTheDocument(); + expect(screen.queryByText("to search keys")).not.toBeInTheDocument(); + expect(window.localStorage.getItem(COMMAND_PALETTE_HINT_KEY.name)).toBe("true"); + }); + + it("hides the discovery hint in memory when storage cannot persist its dismissal", async () => { + const user = userEvent.setup(); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("Storage unavailable"); + }); + renderPalette(); + + await user.click(await screen.findByRole("button", { name: "Dismiss search hint" })); + + expect(screen.queryByText("to search keys")).not.toBeInTheDocument(); + expect(window.localStorage.getItem(COMMAND_PALETTE_HINT_KEY.name)).toBeNull(); + }); + + it("keeps the discovery hint hidden after closing the palette when storage is unavailable", async () => { + const user = userEvent.setup(); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("Storage unavailable"); + }); + renderPalette(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = await screen.findByRole("combobox", { name: "Search" }); + expect(screen.queryByText("to search keys")).not.toBeInTheDocument(); + + await user.keyboard("{Escape}"); + + expect(screen.queryByText("to search keys")).not.toBeInTheDocument(); + expect(window.localStorage.getItem(COMMAND_PALETTE_HINT_KEY.name)).toBeNull(); + }); + + it("does not show the discovery hint when it has already been seen", () => { + writeStorage(COMMAND_PALETTE_HINT_KEY, true); + renderPalette(); + + expect(screen.queryByText("to search keys")).not.toBeInTheDocument(); + }); + + it("shows global-search copy outside the keys routes", async () => { + mocks.pathname = "/logs"; + renderPalette(); + + expect(await screen.findByText("to search or jump to a page")).toBeVisible(); + }); + + it("opens and focuses with Ctrl+K, rejects extra modifiers, and toggles closed", async () => { + renderPalette(); + fireEvent.keyDown(document, { key: "k", ctrlKey: true, shiftKey: true }); + fireEvent.keyDown(document, { key: "k", ctrlKey: true, altKey: true }); + expect(screen.queryByRole("dialog", { name: "Command palette" })).not.toBeInTheDocument(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = await screen.findByRole("combobox", { name: "Search" }); + expect(input).toHaveFocus(); + fireEvent.change(input, { target: { value: "stale" } }); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + expect(screen.queryByRole("dialog", { name: "Command palette" })).not.toBeInTheDocument(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const reopenedInput = await screen.findByRole("combobox", { name: "Search" }); + expect(reopenedInput).toHaveValue(""); + }); + + it("searches keys, shows key details, and opens the selected key", async () => { + const user = userEvent.setup(); + renderPalette(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = await screen.findByRole("combobox", { name: "Search" }); + expect(input).toHaveAttribute("placeholder", "Search keys by alias or ID…"); + expect(screen.getAllByText("Virtual Keys")).toHaveLength(2); + + await user.type(input, "prod"); + await waitFor(() => { + expect(vi.mocked(useKeys)).toHaveBeenLastCalledWith(1, 8, expect.objectContaining(PROD_KEY_LIST_OPTIONS), { + enabled: true, + }); + }); + const firstKeyOption = await screen.findByRole("option", { name: /high-volume-prod/ }); + const keyOption = await screen.findByRole("option", { name: /prod-backend/ }); + expect(firstKeyOption).toHaveAttribute("aria-selected", "true"); + expect(keyOption).toHaveTextContent("platform-team"); + expect(keyOption).toHaveTextContent("$2.50"); + expect(screen.queryByRole("option", { name: /staging-agent/ })).not.toBeInTheDocument(); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(keyOption).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(input, { key: "Enter" }); + expect(mocks.push).toHaveBeenCalledWith(keyDetailHref(PROD_KEY.token)); + expect(screen.queryByRole("dialog", { name: "Command palette" })).not.toBeInTheDocument(); + }); + + it("opens the first matching key when Enter is pressed without navigating", async () => { + const user = userEvent.setup(); + renderPalette(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = await screen.findByRole("combobox", { name: "Search" }); + await user.type(input, "prod"); + const firstKeyOption = await screen.findByRole("option", { name: /high-volume-prod/ }); + expect(firstKeyOption).toHaveAttribute("aria-selected", "true"); + + fireEvent.keyDown(input, { key: "Enter" }); + expect(mocks.push).toHaveBeenCalledWith(keyDetailHref(HIGH_VOLUME_KEY.token)); + }); + + it("does not activate stale key results while the new query is debouncing", () => { + vi.useFakeTimers(); + renderPalette(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = screen.getByRole("combobox", { name: "Search" }); + fireEvent.change(input, { target: { value: "prod" } }); + vi.advanceTimersByTime(DEBOUNCE_WAIT_MS / 2); + + expect(screen.getByText("Searching…")).toBeVisible(); + expect(screen.queryByRole("option", { name: /high-volume-prod/ })).not.toBeInTheDocument(); + expect(screen.getByRole("option", { name: /Filter the keys table/ })).toBeVisible(); + + fireEvent.keyDown(input, { key: "Enter" }); + + expect(mocks.push).toHaveBeenCalledWith(`${uiHref("api-keys")}?key_search=prod`); + expect(mocks.push).not.toHaveBeenCalledWith(keyDetailHref(HIGH_VOLUME_KEY.token)); + expect(mocks.push).not.toHaveBeenCalledWith(keyDetailHref(PROD_KEY.token)); + }); + + it("does not show placeholder key rows after the query debounce completes", () => { + vi.useFakeTimers(); + mocks.isPlaceholderData = true; + renderPalette(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = screen.getByRole("combobox", { name: "Search" }); + fireEvent.change(input, { target: { value: "prod" } }); + act(() => { + vi.advanceTimersByTime(DEBOUNCE_WAIT_MS); + }); + + expect(screen.getByText("Searching…")).toBeVisible(); + expect(screen.queryByRole("option", { name: /high-volume-prod/ })).not.toBeInTheDocument(); + }); + + it("keeps the same key selected when its subtitle changes", async () => { + const { rerender } = renderPalette(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = await screen.findByRole("combobox", { name: "Search" }); + const secondKeyOption = await screen.findByRole("option", { name: /prod-backend/ }); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(secondKeyOption).toHaveAttribute("aria-selected", "true"); + + mocks.teamAlias = "renamed-platform-team"; + rerender( + + + , + ); + + const updatedSecondKeyOption = await screen.findByRole("option", { name: /prod-backend/ }); + expect(updatedSecondKeyOption).toHaveTextContent("renamed-platform-team"); + expect(updatedSecondKeyOption).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("option", { name: /high-volume-prod/ })).toHaveAttribute("aria-selected", "false"); + }); + + it("omits an unknown team ID from the key subtitle", async () => { + const user = userEvent.setup(); + renderPalette(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = await screen.findByRole("combobox", { name: "Search" }); + await user.type(input, "staging"); + const stagingOption = await screen.findByRole("option", { name: /staging-agent/ }); + expect(stagingOption).not.toHaveTextContent("unknown-team"); + }); + + it("keeps only non-empty groups and the filter action when no keys match", async () => { + const user = userEvent.setup(); + renderPalette(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = await screen.findByRole("combobox", { name: "Search" }); + await user.type(input, "no-match"); + + expect(await screen.findByText("No keys match “no-match”")).toBeVisible(); + expect(screen.getByRole("group", { name: "Keys" })).toHaveTextContent("No keys match “no-match”"); + expect(screen.getByRole("option", { name: /Filter the keys table/ })).toBeVisible(); + expect(screen.queryByRole("group", { name: "Pages" })).not.toBeInTheDocument(); + }); + + it("shows one global no-results message when the query has no matches", async () => { + const user = userEvent.setup(); + mocks.pathname = "/teams"; + renderPalette(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = await screen.findByRole("combobox", { name: "Search" }); + await user.type(input, "no-match"); + + expect(await screen.findByText("No results for “no-match”")).toBeVisible(); + expect(screen.queryByRole("group")).not.toBeInTheDocument(); + expect(screen.queryByRole("option", { name: /Search virtual keys/ })).not.toBeInTheDocument(); + }); + + it("opens the virtual-key table with the current search applied", async () => { + const user = userEvent.setup(); + renderPalette(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = await screen.findByRole("combobox", { name: "Search" }); + await user.type(input, "prod"); + await waitFor(() => { + expect(vi.mocked(useKeys)).toHaveBeenLastCalledWith(1, 8, expect.objectContaining({ search: "prod" }), { + enabled: true, + }); + }); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(mocks.push).toHaveBeenCalledWith(`${uiHref("api-keys")}?key_search=prod`); + }); + + it("opens a matching global page from the teams route", async () => { + const user = userEvent.setup(); + mocks.pathname = "/teams"; + renderPalette(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = await screen.findByRole("combobox", { name: "Search" }); + expect(input).toHaveAttribute("placeholder", "Search pages and actions…"); + await user.type(input, "logs"); + + const logsOption = await screen.findByRole("option", { name: /^Logs/ }); + expect(logsOption).toBeVisible(); + fireEvent.keyDown(input, { key: "Enter" }); + expect(mocks.push).toHaveBeenCalledWith(uiHref("logs")); + }); + + it("clears the global query when switching to key search", async () => { + const user = userEvent.setup(); + mocks.pathname = "/teams"; + renderPalette(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = await screen.findByRole("combobox", { name: "Search" }); + await user.type(input, "keys"); + + expect(await screen.findByRole("option", { name: /Search virtual keys/ })).toBeVisible(); + fireEvent.keyDown(input, { key: "Enter" }); + + const keysInput = await screen.findByRole("combobox", { name: "Search" }); + expect(keysInput).toHaveValue(""); + expect(await screen.findByText("Recent keys")).toBeVisible(); + }); + + it("switches from an empty keys scope to global search on Backspace", async () => { + renderPalette(); + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + const input = await screen.findByRole("combobox", { name: "Search" }); + expect(input).toHaveAttribute("placeholder", "Search keys by alias or ID…"); + + fireEvent.keyDown(input, { key: "Backspace" }); + expect(input).toHaveAttribute("placeholder", "Search pages and actions…"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.tsx b/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.tsx new file mode 100644 index 00000000000..c8a1d8d0f6c --- /dev/null +++ b/ui/litellm-dashboard/src/components/CommandPalette/CommandPalette.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react"; +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; +import { FileText, KeyRound, LoaderCircle, Search } from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; +import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; +import { keyDetailHref } from "@/utils/entityLinks"; +import { routeSegmentForPathname, uiHref } from "@/utils/uiHref"; +import type { KeyResponse } from "@/components/key_team_helpers/key_list"; +import { CommandPaletteResultRow, type CommandPaletteRowData } from "./CommandPaletteResultRow"; +import { useVisibleMenuGroups } from "./useVisibleMenuGroups"; +import { flattenNavItems, matchNavItems, paletteScopeForRoute, type PaletteNavItem, type PaletteScope } from "./utils"; + +type PaletteEntry = CommandPaletteRowData & + ( + | { kind: "key"; key: KeyResponse } + | { kind: "filter" } + | { kind: "search-keys" } + | { kind: "page"; page: PaletteNavItem } + ); + +interface PaletteGroup { + label: string; + entries: PaletteEntry[]; + emptyMessage?: string; + isLoading?: boolean; +} + +const shortcutChipClass = + "rounded-sm border border-border border-b-2 bg-muted px-[3px] font-mono text-muted-foreground"; + +export function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) { + if (!open) return null; + return ; +} + +function CommandPaletteDialog({ setOpen }: { setOpen: (open: boolean) => void }) { + const pathname = usePathname(); + const router = useRouter(); + const [query, setQuery] = useState(""); + const [activeSelection, setActiveSelection] = useState<{ + id: string; + query: string; + scope: PaletteScope; + } | null>(null); + const [scope, setScope] = useState(() => paletteScopeForRoute(routeSegmentForPathname(pathname))); + const [debouncedQuery] = useDebouncedValue(query, { wait: DEBOUNCE_WAIT_MS }); + const trimmedQuery = query.trim(); + const debouncedSearch = debouncedQuery.trim(); + const { data: teams } = useTeams({ enabled: false }); + const teamAliases = useMemo( + () => new Map((teams ?? []).map((team) => [team.team_id, team.team_alias] as const)), + [teams], + ); + const keyListOptions = { + search: debouncedSearch || undefined, + sortBy: "created_at", + sortOrder: "desc", + expand: "user", + }; + const keyResults = useKeys(1, 8, keyListOptions, { enabled: scope === "keys" }); + const isPendingQuery = trimmedQuery !== debouncedSearch; + const hasPlaceholderKeyResults = keyResults.isPlaceholderData; + const isInitialKeyLoad = keyResults.isFetching && !keyResults.data; + const isLoadingKeys = scope === "keys" && (isPendingQuery || hasPlaceholderKeyResults || isInitialKeyLoad); + const visibleGroups = useVisibleMenuGroups(); + const navItems = useMemo(() => flattenNavItems(visibleGroups), [visibleGroups]); + const navMatches = useMemo( + () => (scope === "global" || trimmedQuery ? matchNavItems(navItems, query) : []), + [navItems, query, scope, trimmedQuery], + ); + const groups = useMemo(() => { + const pages: PaletteEntry[] = navMatches.map((page) => ({ + id: `page:${page.route}`, + kind: "page", + title: page.label, + subtitle: page.section, + icon: page.icon ?? , + typeLabel: "Page", + page, + })); + + if (scope === "global") { + const searchKeysAction = !trimmedQuery || "search virtual keys".includes(trimmedQuery.toLowerCase()); + return [ + ...(searchKeysAction + ? [ + { + label: "Actions", + entries: [ + { + id: "action:search-keys", + kind: "search-keys" as const, + title: "Search virtual keys", + subtitle: "Find a key by alias or ID", + icon: , + typeLabel: "Action" as const, + }, + ], + }, + ] + : []), + ...(pages.length > 0 ? [{ label: "Pages", entries: pages }] : []), + ]; + } + + const keyEntries: PaletteEntry[] = (isLoadingKeys ? [] : keyResults.data?.keys ?? []).map((key) => { + const teamAlias = key.team_alias || (key.team_id ? teamAliases.get(key.team_id) : undefined); + const spend = typeof key.spend === "number" && Number.isFinite(key.spend) ? `$${key.spend.toFixed(2)}` : null; + const details = [teamAlias, spend].filter((detail): detail is string => Boolean(detail)); + const subtitle: ReactNode = ( + <> + {key.key_name} + {details.length > 0 && · {details.join(" · ")}} + + ); + return { + id: `key:${key.token}`, + kind: "key", + title: key.key_alias || "Unnamed key", + subtitle, + icon: , + typeLabel: "Key", + key, + }; + }); + const keysGroup: PaletteGroup = { + label: trimmedQuery ? "Keys" : "Recent keys", + entries: keyEntries, + emptyMessage: trimmedQuery ? `No keys match “${trimmedQuery}”` : "No recent keys", + isLoading: isLoadingKeys, + }; + + if (!trimmedQuery) return [keysGroup]; + + const filterEntry: PaletteEntry = { + id: "action:filter-keys", + kind: "filter", + title: `Filter the keys table for “${trimmedQuery}”`, + subtitle: "Open Virtual Keys with this search applied", + icon: , + typeLabel: "Action", + }; + + return [ + keysGroup, + { label: "Actions", entries: [filterEntry] }, + ...(pages.length > 0 ? [{ label: "Pages", entries: pages }] : []), + ]; + }, [isLoadingKeys, keyResults.data?.keys, navMatches, scope, teamAliases, trimmedQuery]); + const selectableEntries = useMemo(() => groups.flatMap((group) => group.entries), [groups]); + const activeEntryId = activeSelection?.query === query && activeSelection.scope === scope ? activeSelection.id : null; + const selectedIndex = selectableEntries.findIndex((entry) => entry.id === activeEntryId); + const activeIndex = selectableEntries.length === 0 ? -1 : Math.max(0, selectedIndex); + const activeOptionId = activeIndex >= 0 ? `command-palette-option-${activeIndex}` : undefined; + const hasKeyError = scope === "keys" && keyResults.isError && keyResults.data === undefined; + + useEffect(() => { + if (activeOptionId) document.getElementById(activeOptionId)?.scrollIntoView?.({ block: "nearest" }); + }, [activeOptionId]); + + const activate = (entry: PaletteEntry) => { + switch (entry.kind) { + case "key": + router.push(keyDetailHref(entry.key.token)); + setOpen(false); + return; + case "filter": + router.push(`${uiHref("api-keys")}?key_search=${encodeURIComponent(trimmedQuery)}`); + setOpen(false); + return; + case "search-keys": + setQuery(""); + setScope("keys"); + return; + case "page": + router.push(uiHref(entry.page.route)); + setOpen(false); + return; + } + }; + + const handleInputKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === "Backspace" && scope === "keys" && query === "") { + event.preventDefault(); + setScope("global"); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + const activeEntry = selectableEntries[activeIndex]; + if (activeEntry) activate(activeEntry); + return; + } + + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + if (selectableEntries.length === 0) return; + const currentIndex = activeIndex < 0 ? 0 : activeIndex; + const nextIndex = + event.key === "ArrowDown" + ? (currentIndex + 1) % selectableEntries.length + : (currentIndex - 1 + selectableEntries.length) % selectableEntries.length; + setActiveSelection({ id: selectableEntries[nextIndex].id, query, scope }); + }; + + const renderGroupContents = (group: PaletteGroup, firstOptionIndex: number): ReactNode => { + if (group.isLoading) { + return ( +
+ + Searching… +
+ ); + } + if (group.entries.length === 0) + return
{group.emptyMessage}
; + return group.entries.map((entry, entryIndex) => { + const currentIndex = firstOptionIndex + entryIndex; + return ( + activate(entry)} + onHover={() => setActiveSelection({ id: entry.id, query, scope })} + /> + ); + }); + }; + + return ( + + + Command palette +
+ + {scope === "keys" && ( + + + Virtual Keys + + )} + { + setQuery(event.currentTarget.value); + }} + onKeyDown={handleInputKeyDown} + /> +
+
+ {hasKeyError && ( +
+ Unable to search keys +
+ )} + {!hasKeyError && scope === "global" && trimmedQuery && groups.length === 0 && ( +
No results for “{trimmedQuery}”
+ )} + {!hasKeyError && + groups.map((group, groupIndex) => { + const firstOptionIndex = groups + .slice(0, groupIndex) + .reduce((count, previousGroup) => count + previousGroup.entries.length, 0); + return ( +
+
+ {group.label} +
+ {renderGroupContents(group, firstOptionIndex)} +
+ ); + })} +
+
+
+ LiteLLM + {scope === "keys" ? "Virtual Keys" : "All pages"} +
+
+ + ↵ Open + + + ↑↓ Navigate + + + esc Close + +
+
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteHint.tsx b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteHint.tsx new file mode 100644 index 00000000000..0a4c3fcda18 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteHint.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Search, X } from "lucide-react"; +import { z } from "zod"; +import { storageKey } from "@/lib/storage"; +import type { PaletteScope } from "./utils"; +import { useCommandPalette } from "./CommandPaletteProvider"; +import { useClientMounted, useShortcutLabel } from "./useShortcutLabel"; + +export const COMMAND_PALETTE_HINT_KEY = storageKey("local", "litellmCommandPaletteHintSeen", z.boolean(), false); + +interface CommandPaletteHintProps { + scope: PaletteScope; + onDismiss: () => void; +} + +export function CommandPaletteHint({ scope, onDismiss }: CommandPaletteHintProps) { + const { setOpen } = useCommandPalette(); + const shortcut = useShortcutLabel(); + const mounted = useClientMounted(); + + if (!mounted) return null; + + return ( +
+ + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteProvider.tsx b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteProvider.tsx new file mode 100644 index 00000000000..b2ec2625c45 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteProvider.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { usePathname } from "next/navigation"; +import { useStoredValue } from "@/lib/storage"; +import { routeSegmentForPathname } from "@/utils/uiHref"; +import { CommandPalette } from "./CommandPalette"; +import { CommandPaletteHint, COMMAND_PALETTE_HINT_KEY } from "./CommandPaletteHint"; +import { paletteScopeForRoute } from "./utils"; + +interface CommandPaletteContextValue { + open: boolean; + setOpen: (open: boolean) => void; + toggle: () => void; +} + +const CommandPaletteContext = createContext(null); + +export function useCommandPalette(): CommandPaletteContextValue { + const context = useContext(CommandPaletteContext); + if (!context) throw new Error("useCommandPalette must be used within CommandPaletteProvider"); + return context; +} + +export function CommandPaletteProvider({ children }: { children: ReactNode }) { + const [open, setOpenState] = useState(false); + const [hintSeen, setHintSeen] = useStoredValue(COMMAND_PALETTE_HINT_KEY); + const [hintDismissedInMemory, setHintDismissedInMemory] = useState(false); + const pathname = usePathname(); + const scope = paletteScopeForRoute(routeSegmentForPathname(pathname)); + const markHintSeen = useCallback(() => { + setHintDismissedInMemory(true); + setHintSeen(true); + }, [setHintSeen]); + const setOpen = useCallback( + (nextOpen: boolean) => { + if (nextOpen) markHintSeen(); + setOpenState(nextOpen); + }, + [markHintSeen], + ); + const toggle = useCallback(() => setOpen(!open), [open, setOpen]); + const value = useMemo(() => ({ open, setOpen, toggle }), [open, setOpen, toggle]); + const shouldShowHint = !hintSeen && !hintDismissedInMemory && !open; + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" && !event.shiftKey && !event.altKey) { + event.preventDefault(); + toggle(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [toggle]); + + return ( + + {children} + + {shouldShowHint && } + + ); +} diff --git a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteResultRow.tsx b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteResultRow.tsx new file mode 100644 index 00000000000..b4d8e2b3e74 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteResultRow.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { ReactNode } from "react"; +import { cn } from "@/lib/cva.config"; + +export interface CommandPaletteRowData { + id: string; + title: string; + subtitle?: ReactNode; + icon: ReactNode; + typeLabel: "Key" | "Page" | "Action"; +} + +export function CommandPaletteResultRow({ + item, + optionId, + active, + onActivate, + onHover, +}: { + item: CommandPaletteRowData; + optionId: string; + active: boolean; + onActivate: () => void; + onHover: () => void; +}) { + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx new file mode 100644 index 00000000000..bd594800736 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Search } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { routeSegmentForPathname } from "@/utils/uiHref"; +import { paletteScopeForRoute } from "./utils"; +import { useCommandPalette } from "./CommandPaletteProvider"; +import { useShortcutLabel } from "./useShortcutLabel"; + +export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean }) { + const pathname = usePathname(); + const { open, setOpen } = useCommandPalette(); + const shortcut = useShortcutLabel(); + const isKeysRoute = paletteScopeForRoute(routeSegmentForPathname(pathname)) === "keys"; + const label = isKeysRoute ? "Search keys…" : "Search or jump to…"; + + if (mobile) { + return ( + + ); + } + + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/components/CommandPalette/useShortcutLabel.ts b/ui/litellm-dashboard/src/components/CommandPalette/useShortcutLabel.ts new file mode 100644 index 00000000000..78e1865c90e --- /dev/null +++ b/ui/litellm-dashboard/src/components/CommandPalette/useShortcutLabel.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { shortcutLabel } from "./utils"; + +const subscribeToShortcut = () => () => {}; +const getClientShortcut = () => + typeof navigator === "undefined" ? "Ctrl K" : shortcutLabel(navigator.platform, navigator.userAgent); +const getServerShortcut = () => "Ctrl K"; +const subscribeToMount = () => () => {}; +const getClientMounted = () => true; +const getServerMounted = () => false; + +export function useShortcutLabel(): string { + return useSyncExternalStore(subscribeToShortcut, getClientShortcut, getServerShortcut); +} + +export function useClientMounted(): boolean { + return useSyncExternalStore(subscribeToMount, getClientMounted, getServerMounted); +} diff --git a/ui/litellm-dashboard/src/components/CommandPalette/useVisibleMenuGroups.ts b/ui/litellm-dashboard/src/components/CommandPalette/useVisibleMenuGroups.ts new file mode 100644 index 00000000000..8c3b9382934 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CommandPalette/useVisibleMenuGroups.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useMemo } from "react"; +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; +import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; +import { isUserTeamAdminForAnyTeam } from "@/utils/roles"; +import { menuGroups, visibleMenuGroups, type MenuVisibilityContext } from "@/components/leftnav"; + +export const useVisibleMenuGroups = () => { + const { userId, userRole, isViewOnly } = useAuthorized(); + const isOrgAdmin = useIsOrgAdmin(); + const { data: teams } = useTeams({ enabled: false }); + const { data: settings } = useUISettings(); + const values = settings?.values; + const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]); + + return useMemo(() => { + const context: MenuVisibilityContext = { + userRole, + isViewOnly, + isOrgAdmin, + isTeamAdmin, + enabledPagesInternalUsers: values?.enabled_ui_pages_internal_users ?? null, + enableProjectsUI: Boolean(values?.enable_projects_ui), + disableAgentsForInternalUsers: Boolean(values?.disable_agents_for_internal_users), + allowAgentsForTeamAdmins: Boolean(values?.allow_agents_for_team_admins), + disableVectorStoresForInternalUsers: Boolean(values?.disable_vector_stores_for_internal_users), + allowVectorStoresForTeamAdmins: Boolean(values?.allow_vector_stores_for_team_admins), + }; + return visibleMenuGroups(menuGroups, context); + }, [isOrgAdmin, isTeamAdmin, isViewOnly, userRole, values]); +}; diff --git a/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts b/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts new file mode 100644 index 00000000000..5bb61756f80 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { visibleMenuGroups, type MenuGroup, menuGroups } from "@/components/leftnav"; +import { flattenNavItems, matchNavItems, paletteScopeForRoute, shortcutLabel, type PaletteNavItem } from "./utils"; + +describe("paletteScopeForRoute", () => { + it.each([ + ["api-keys", "keys"], + ["", "keys"], + ["teams", "global"], + ] as const)("uses the expected palette scope for %s", (routeSegment, expected) => { + expect(paletteScopeForRoute(routeSegment)).toBe(expected); + }); +}); + +describe("flattenNavItems", () => { + it("flattens visible leaves and skips external links", () => { + const groups: MenuGroup[] = [ + { + groupLabel: "Tools", + items: [ + { key: "search-tools", page: "search-tools", label: "Search Tools" }, + { key: "docs", page: "docs", label: "Docs", external_url: "https://example.com" }, + { key: "tools", page: "tools", label: "Tools", children: [{ key: "logs", page: "logs", label: "Logs" }] }, + ], + }, + ]; + + expect(flattenNavItems(groups).map(({ label }) => label)).toEqual(["Search Tools", "Logs"]); + }); +}); + +describe("visibleMenuGroups", () => { + const context = { + userRole: "Internal User", + isViewOnly: false, + isOrgAdmin: false, + isTeamAdmin: false, + }; + + it("applies the internal-user page allowlist", () => { + const visible = visibleMenuGroups(menuGroups, { + ...context, + enabledPagesInternalUsers: ["api-keys"], + }); + + expect(flattenNavItems(visible).map(({ route }) => route)).toEqual(["api-keys"]); + }); + + it("hides Projects when its UI setting is disabled", () => { + const projectAdminContext = { ...context, userRole: "Admin", isTeamAdmin: true }; + const visibleWithFlag = visibleMenuGroups(menuGroups, { ...projectAdminContext, enableProjectsUI: true }); + const visibleWithoutFlag = visibleMenuGroups(menuGroups, { ...projectAdminContext, enableProjectsUI: false }); + + expect(flattenNavItems(visibleWithFlag).some(({ route }) => route === "projects")).toBe(true); + expect(flattenNavItems(visibleWithoutFlag).some(({ route }) => route === "projects")).toBe(false); + }); + + it("keeps admin pages visible despite the internal-user page allowlist", () => { + const visible = visibleMenuGroups(menuGroups, { + ...context, + userRole: "Admin", + enabledPagesInternalUsers: ["api-keys"], + }); + + expect(flattenNavItems(visible).some(({ route }) => route === "users")).toBe(true); + expect(flattenNavItems(visible).some(({ route }) => route === "organizations")).toBe(true); + }); +}); + +describe("matchNavItems", () => { + const items: PaletteNavItem[] = [ + { key: "section", label: "Teams", section: "Logs", route: "teams" }, + { key: "substring", label: "Audit Logs", section: "Access Control", route: "audit-logs" }, + { key: "prefix", label: "Logging", section: "Observability", route: "logging" }, + ]; + + it("ranks label prefixes above label substrings and section matches", () => { + expect(matchNavItems(items, "log").map(({ key }) => key)).toEqual(["prefix", "substring", "section"]); + }); + + it("returns every item in its original order for an empty query", () => { + expect(matchNavItems(items, " ")).toEqual(items); + }); +}); + +describe("shortcutLabel", () => { + it("uses the command key on Mac and Ctrl elsewhere", () => { + expect(shortcutLabel("MacIntel", "Mozilla/5.0")).toBe("⌘K"); + expect(shortcutLabel("Linux x86_64", "Mozilla/5.0 (Windows NT 10.0)")).toBe("Ctrl K"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CommandPalette/utils.ts b/ui/litellm-dashboard/src/components/CommandPalette/utils.ts new file mode 100644 index 00000000000..ad02e155b0e --- /dev/null +++ b/ui/litellm-dashboard/src/components/CommandPalette/utils.ts @@ -0,0 +1,59 @@ +import type { ReactNode } from "react"; +import { labelText, routeOf, sectionText, type MenuGroup } from "@/components/leftnav"; + +export type PaletteScope = "keys" | "global"; + +export interface PaletteNavItem { + key: string; + label: string; + section: string; + route: string; + icon?: ReactNode; +} + +export const paletteScopeForRoute = (routeSegment: string): PaletteScope => + routeSegment === "" || routeSegment === "api-keys" ? "keys" : "global"; + +const matchRank = (item: PaletteNavItem, query: string): number => { + const label = item.label.toLowerCase(); + if (label.startsWith(query)) return 0; + if (label.includes(query)) return 1; + if (item.section.toLowerCase().includes(query)) return 2; + return -1; +}; + +export const flattenNavItems = (groups: readonly MenuGroup[]): PaletteNavItem[] => + groups.flatMap((group) => { + const flattenItems = (items: MenuGroup["items"]): PaletteNavItem[] => + items.flatMap((item) => { + if (item.external_url) return []; + if (item.children) return flattenItems(item.children); + return [ + { + key: item.key, + label: labelText(item), + section: sectionText(group.groupLabel), + route: routeOf(item), + icon: item.icon, + }, + ]; + }); + + return flattenItems(group.items); + }); + +export const matchNavItems = (items: readonly PaletteNavItem[], query: string): PaletteNavItem[] => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return [...items]; + + return items + .map((item, index) => { + return { item, index, rank: matchRank(item, normalizedQuery) }; + }) + .filter(({ rank }) => rank >= 0) + .sort((left, right) => left.rank - right.rank || left.index - right.index) + .map(({ item }) => item); +}; + +export const shortcutLabel = (platform: string, userAgent: string): string => + [platform, userAgent].some((value) => value.toLowerCase().includes("mac")) ? "⌘K" : "Ctrl K"; diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx index 756997b7ede..cb58670c20a 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx @@ -3,6 +3,7 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PluginModeProvider } from "@/contexts/PluginModeContext"; +import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider"; import { DashboardHeader } from "./DashboardHeader"; vi.mock("next/navigation", () => ({ usePathname: () => "/ui/logs" })); @@ -57,7 +58,9 @@ async function openTools() { render( - + + + , ); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx index 63f7b7aa136..822cec9deb3 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx @@ -1,7 +1,8 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { DashboardHeader } from "./DashboardHeader"; import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass"; +import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider"; const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => { const state = { @@ -31,31 +32,43 @@ vi.mock("@/components/Navbar/NotificationsBell/NotificationsBell", () => ({ Noti vi.mock("@/components/Navbar/WorkerDropdown/WorkerDropdown", () => ({ default: () => null })); vi.mock("@/components/liteadmin/LiteAdmin", () => ({ default: () => })); +const renderDashboardHeader = () => + render( + + + , + ); + describe("DashboardHeader breadcrumb", () => { + beforeEach(() => { + localStorage.clear(); + }); + afterEach(() => { state.plugins = []; state.enableChatUI = false; state.pathname = "/ui/logs"; state.isDesktop = false; + localStorage.clear(); }); it("titles the breadcrumb from the current route, not from a sidebar page id", () => { state.pathname = "/ui/models-and-endpoints"; - render(); + renderDashboardHeader(); expect(screen.getByText("Models + Endpoints")).toBeInTheDocument(); }); it("titles the dashboard root as Virtual Keys", () => { state.pathname = "/ui/"; - render(); + renderDashboardHeader(); expect(screen.getByText("Virtual Keys")).toBeInTheDocument(); }); it("roots the breadcrumb in the AI Gateway selector (with a Chat option) and drops the static section crumb when the selector is available", async () => { state.enableChatUI = true; - render(); + renderDashboardHeader(); expect(screen.getByText("Logs")).toBeInTheDocument(); expect(screen.queryByText("Observability")).not.toBeInTheDocument(); @@ -68,7 +81,7 @@ describe("DashboardHeader breadcrumb", () => { }); it("keeps the AI Gateway selector at the root even when there is nothing to switch to (discovery)", () => { - render(); + renderDashboardHeader(); expect(screen.getByRole("button", { name: /AI Gateway/i })).toBeInTheDocument(); expect(screen.getByText("Logs")).toBeInTheDocument(); @@ -76,7 +89,7 @@ describe("DashboardHeader breadcrumb", () => { }); it("styles Docs with the shared product-link class instead of a muted toolbar button", () => { - render(); + renderDashboardHeader(); const docs = screen.getByRole("link", { name: "Docs" }); for (const cls of NAV_PRODUCT_LINK_CLASS.trim().split(/\s+/)) { @@ -86,7 +99,7 @@ describe("DashboardHeader breadcrumb", () => { }); it("renders the tools divider centered rather than stretched to the top of the row", () => { - const { container } = render(); + const { container } = renderDashboardHeader(); const separators = container.querySelectorAll('[data-slot="separator"][data-orientation="vertical"]'); expect(separators).toHaveLength(1); @@ -95,7 +108,7 @@ describe("DashboardHeader breadcrumb", () => { }); it("places LiteAdmin in the header tools ahead of Docs", () => { - render(); + renderDashboardHeader(); const liteAdmin = within(screen.getByRole("banner")).getByRole("button", { name: "LiteAdmin" }); expect(liteAdmin.compareDocumentPosition(screen.getByRole("link", { name: "Docs" }))).toBe( @@ -104,7 +117,7 @@ describe("DashboardHeader breadcrumb", () => { }); it("keeps the gateway selector and tools available from the compact header menu", async () => { - render(); + renderDashboardHeader(); fireEvent.click(screen.getByRole("button", { name: "More options" })); const tools = await screen.findByRole("dialog", { name: "Gateway tools" }); expect(within(tools).getByRole("button", { name: "AI Gateway" })).toBeInTheDocument(); @@ -113,16 +126,24 @@ describe("DashboardHeader breadcrumb", () => { }); it("closes mobile tools when switching to desktop and keeps them closed when returning", async () => { - const { rerender } = render(); + const { rerender } = renderDashboardHeader(); fireEvent.click(screen.getByRole("button", { name: "More options" })); expect(await screen.findByRole("dialog", { name: "Gateway tools" })).toBeInTheDocument(); state.isDesktop = true; - rerender(); + rerender( + + + , + ); expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument(); state.isDesktop = false; - rerender(); + rerender( + + + , + ); expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx index 43440b0c92d..7580f8b69c0 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -27,6 +27,7 @@ import { useMediaQuery } from "usehooks-ts"; import { Ellipsis } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; +import { CommandPaletteTrigger } from "@/components/CommandPalette/CommandPaletteTrigger"; // Top bar for the dashboard shell. Sits only over the content column (the brand // lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout. @@ -62,6 +63,8 @@ export function DashboardHeader({ navigationTrigger }: { navigationTrigger?: Rea + +
{showWorkerSwitch && ( <> @@ -78,6 +81,7 @@ export function DashboardHeader({ navigationTrigger }: { navigationTrigger?: Rea
+ }> diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 4cba12c9f7e..256fe578912 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -103,7 +103,7 @@ interface SidebarProps { allowVectorStoresForTeamAdmins?: boolean; } -interface MenuItem { +export interface MenuItem { key: string; page: string; route?: string; @@ -114,12 +114,92 @@ interface MenuItem { external_url?: string; } -interface MenuGroup { +export interface MenuGroup { groupLabel: string; items: MenuItem[]; roles?: string[]; } +export interface MenuVisibilityContext { + userRole: string; + isViewOnly: boolean; + isOrgAdmin: boolean; + isTeamAdmin: boolean; + enabledPagesInternalUsers?: string[] | null; + enableProjectsUI?: boolean; + disableAgentsForInternalUsers?: boolean; + allowAgentsForTeamAdmins?: boolean; + disableVectorStoresForInternalUsers?: boolean; + allowVectorStoresForTeamAdmins?: boolean; +} + +const adminPageVisibility = (item: MenuItem, context: MenuVisibilityContext, isAdmin: boolean): boolean | null => { + if (item.key !== "organizations" && item.key !== "users") return null; + const hasRoleAccess = !item.roles || item.roles.includes(context.userRole) || context.isOrgAdmin; + if (!hasRoleAccess) return false; + if (!isAdmin && context.enabledPagesInternalUsers != null) { + return context.enabledPagesInternalUsers.includes(item.page); + } + return true; +}; + +const projectPageIsVisible = (item: MenuItem, context: MenuVisibilityContext): boolean => { + if (item.key !== "projects") return true; + if (!context.enableProjectsUI) return false; + return canViewProjectsPage({ + userRole: context.userRole, + isOrgAdmin: context.isOrgAdmin, + isTeamAdmin: context.isTeamAdmin, + }); +}; + +const pageIsDisabledForInternalUsers = ( + item: MenuItem, + pageKey: "agents" | "vector-stores", + isAdmin: boolean, + context: MenuVisibilityContext, +): boolean => { + const isDisabled = + pageKey === "agents" ? context.disableAgentsForInternalUsers : context.disableVectorStoresForInternalUsers; + const allowedForTeamAdmins = + pageKey === "agents" ? context.allowAgentsForTeamAdmins : context.allowVectorStoresForTeamAdmins; + if (item.key !== pageKey || isAdmin || !isDisabled) return false; + return !(allowedForTeamAdmins && context.isTeamAdmin); +}; + +const pageIsEnabledForInternalUser = (item: MenuItem, context: MenuVisibilityContext): boolean => { + const enabledPages = context.enabledPagesInternalUsers; + if (enabledPages == null) return true; + if (item.children?.some((child) => enabledPages.includes(child.page))) return true; + return enabledPages.includes(item.page); +}; + +const menuItemIsVisible = (item: MenuItem, context: MenuVisibilityContext, isAdmin: boolean): boolean => { + if (item.children && item.children.length === 0) return false; + if (item.key === "llm-playground" && context.isViewOnly) return false; + const adminVisibility = adminPageVisibility(item, context, isAdmin); + if (adminVisibility !== null) return adminVisibility; + if (!projectPageIsVisible(item, context)) return false; + if (pageIsDisabledForInternalUsers(item, "agents", isAdmin, context)) return false; + if (pageIsDisabledForInternalUsers(item, "vector-stores", isAdmin, context)) return false; + if (item.roles && !item.roles.includes(context.userRole)) return false; + if (!isAdmin && context.enabledPagesInternalUsers != null) return pageIsEnabledForInternalUser(item, context); + return true; +}; + +export const visibleMenuGroups = (groups: readonly MenuGroup[], context: MenuVisibilityContext): MenuGroup[] => { + const isAdmin = isAdminRole(context.userRole); + const filterItems = (items: MenuItem[]): MenuItem[] => + items + .map((item) => ({ ...item, children: item.children ? filterItems(item.children) : undefined })) + .filter((item) => menuItemIsVisible(item, context, isAdmin)); + + return groups + .filter((group) => !group.roles || group.roles.includes(context.userRole)) + .map((group) => ({ groupLabel: group.groupLabel, items: filterItems(group.items) })) + .filter((group) => group.items.length > 0); +}; + // Menu groups organized by category - defined outside component for export. // Shape (key/page/label/roles/children) is consumed by page_utils.ts; only the // icons changed to lucide as part of the sidebar redesign. @@ -390,7 +470,7 @@ const menuGroups: MenuGroup[] = [ const HOME_ROUTE = "api-keys"; -const routeOf = (item: MenuItem): string => item.route ?? item.page; +export const routeOf = (item: MenuItem): string => item.route ?? item.page; const routeForPathname = (pathname: string): string => routeSegmentForPathname(pathname) || HOME_ROUTE; @@ -422,6 +502,8 @@ const SECTION_DISPLAY: Record = { SETTINGS: "Settings", }; +export const sectionText = (groupLabel: string): string => SECTION_DISPLAY[groupLabel] ?? groupLabel; + const prettify = (key: string): string => key .split(/[-_]/) @@ -435,7 +517,7 @@ export const getBreadcrumb = (pathname: string): { section: string | null; title const route = routeForPathname(pathname); for (const group of menuGroups) { for (const item of group.items) { - const section = SECTION_DISPLAY[group.groupLabel] ?? group.groupLabel; + const section = sectionText(group.groupLabel); if (routeOf(item) === route) return { section, title: labelText(item) }; const child = item.children?.find((c) => routeOf(c) === route); if (child) return { section, title: labelText(child) }; @@ -486,56 +568,32 @@ const Sidebar_: React.FC = ({ const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]); - const filterItemsByRole = (items: MenuItem[]): MenuItem[] => { - const isAdmin = isAdminRole(userRole); - return items - .map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined })) - .filter((item) => { - // A parent whose children were all filtered out renders as a leaf link - // to its own page id, which is not a real route. Drop it instead. - if (item.children && item.children.length === 0) return false; - if (item.key === "llm-playground" && isViewOnly) return false; - if (item.key === "organizations" || item.key === "users") { - const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin; - if (!hasRoleAccess) return false; - if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page); - return true; - } - if ( - item.key === "projects" && - !(enableProjectsUI && canViewProjectsPage({ userRole, isOrgAdmin, isTeamAdmin })) - ) - return false; - if ( - !isAdmin && - item.key === "agents" && - disableAgentsForInternalUsers && - !(allowAgentsForTeamAdmins && isTeamAdmin) - ) - return false; - if ( - !isAdmin && - item.key === "vector-stores" && - disableVectorStoresForInternalUsers && - !(allowVectorStoresForTeamAdmins && isTeamAdmin) - ) - return false; - if (item.roles && !item.roles.includes(userRole)) return false; - if (!isAdmin && enabledPagesInternalUsers != null) { - if (item.children && item.children.length > 0) { - const hasVisibleChildren = item.children.some((child) => enabledPagesInternalUsers.includes(child.page)); - if (hasVisibleChildren) return true; - } - return enabledPagesInternalUsers.includes(item.page); - } - return true; - }); - }; - - const visibleGroups = menuGroups - .filter((group) => !group.roles || group.roles.includes(userRole)) - .map((group) => ({ groupLabel: group.groupLabel, items: filterItemsByRole(group.items) })) - .filter((group) => group.items.length > 0); + const visibleGroups = useMemo(() => { + const context: MenuVisibilityContext = { + userRole, + isViewOnly, + isOrgAdmin, + isTeamAdmin, + enabledPagesInternalUsers, + enableProjectsUI, + disableAgentsForInternalUsers, + allowAgentsForTeamAdmins, + disableVectorStoresForInternalUsers, + allowVectorStoresForTeamAdmins, + }; + return visibleMenuGroups(menuGroups, context); + }, [ + allowAgentsForTeamAdmins, + allowVectorStoresForTeamAdmins, + disableAgentsForInternalUsers, + disableVectorStoresForInternalUsers, + enableProjectsUI, + enabledPagesInternalUsers, + isOrgAdmin, + isTeamAdmin, + isViewOnly, + userRole, + ]); const toggleGroup = (key: string) => { if (collapsed) { diff --git a/ui/litellm-dashboard/src/components/ui/dialog.tsx b/ui/litellm-dashboard/src/components/ui/dialog.tsx index a4c17736595..e5af118a701 100644 --- a/ui/litellm-dashboard/src/components/ui/dialog.tsx +++ b/ui/litellm-dashboard/src/components/ui/dialog.tsx @@ -28,7 +28,10 @@ function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) - +