From ec9ab43d202c7a65271cf6fdc906b171f8d7968c Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 10 Aug 2026 15:24:20 -0700 Subject: [PATCH] feat(ui): show vector store indexes on the Vector Stores page (#36306) * feat(ui): show vector store indexes on the Vector Stores page Adds a proxy-admin-only Indexes tab listing rows from GET /v1/indexes: index name, backing vector store, provider index, creator, and created date. The tab is hidden for non proxy-admin roles to match the endpoint's gate, and data loads lazily on first visit. * feat(ui): link index rows to their vector store and creator Vector Store cells open the store's info view when the name resolves to a registered store, and Created By cells deep link to the users page via a new userDetailHref, with the users page reading the user query param through nuqs so the link is shareable. * feat(ui): link docs and note supported providers on Indexes tab * fix(ui): show not-found state instead of infinite loading for missing vector store --- .../users/_components/view_users.test.tsx | 5 +- .../users/_components/view_users.tsx | 18 +-- .../vector-stores/_components/IndexesTab.tsx | 102 +++++++++++++++++ .../_components/IndexesTable.test.tsx | 100 ++++++++++++++++ .../_components/IndexesTable.tsx | 62 ++++++++++ .../_components/IndexesTableColumns.tsx | 108 ++++++++++++++++++ .../vector-stores/_components/index.test.tsx | 101 +++++++++++++++- .../vector-stores/_components/index.tsx | 14 ++- .../_components/vector_store_info.test.tsx | 81 +++++++++++++ .../_components/vector_store_info.tsx | 55 ++++++--- .../src/components/networking.tsx | 15 +++ ui/litellm-dashboard/src/utils/entityLinks.ts | 4 + 12 files changed, 634 insertions(+), 31 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTab.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTableColumns.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx index 5fcc55c1e98..42f21cd7b69 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx @@ -1,10 +1,11 @@ /* @vitest-environment jsdom */ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../../../../../tests/test-utils"; import ViewUserDashboard from "./view_users"; const userListCall = vi.fn(); @@ -78,7 +79,7 @@ const defaultProps = { }; const renderDashboard = () => - render( + renderWithProviders( , diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx index 2c1d28d82f8..9eb7645fb2e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.tsx @@ -1,4 +1,5 @@ import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; +import { parseAsString, useQueryState } from "nuqs"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "antd"; @@ -72,7 +73,7 @@ const ViewUserDashboard: React.FC = ({ const [selectionMode, setSelectionMode] = useState(false); const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false); - const [selectedUserId, setSelectedUserId] = useState(null); + const [selectedUserId, setSelectedUserId] = useQueryState("user", parseAsString.withOptions({ history: "push" })); const [openInEditMode, setOpenInEditMode] = useState(false); const [editModalVisible, setEditModalVisible] = useState(false); @@ -139,15 +140,18 @@ const ViewUserDashboard: React.FC = ({ setRowSelection({}); }, []); - const handleUserClick = useCallback((userId: string, openInEdit: boolean = false) => { - setSelectedUserId(userId); - setOpenInEditMode(openInEdit); - }, []); + const handleUserClick = useCallback( + (userId: string, openInEdit: boolean = false) => { + void setSelectedUserId(userId); + setOpenInEditMode(openInEdit); + }, + [setSelectedUserId], + ); const handleCloseUserInfo = useCallback(() => { - setSelectedUserId(null); + void setSelectedUserId(null); setOpenInEditMode(false); - }, []); + }, [setSelectedUserId]); const handleDelete = useCallback((user: UserInfo) => { setUserToDelete(user); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTab.tsx new file mode 100644 index 00000000000..bf53433adea --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTab.tsx @@ -0,0 +1,102 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; + +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { indexesListCall } from "@/components/networking"; +import { VectorStore } from "@/components/vector_store_management/types"; + +import IndexesTable from "./IndexesTable"; + +export interface VectorStoreIndex { + id: string; + index_name: string; + litellm_params: { + vector_store_index: string; + vector_store_name: string; + }; + index_info?: Record | null; + created_at?: string | null; + created_by?: string | null; + updated_at?: string | null; + updated_by?: string | null; +} + +interface IndexesTabProps { + accessToken: string | null; + vectorStores: VectorStore[]; + onViewVectorStore: (vectorStoreId: string) => void; +} + +const IndexesTab: React.FC = ({ accessToken, vectorStores, onViewVectorStore }) => { + const [indexes, setIndexes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const vectorStoreIdsByName = useMemo( + () => + new Map( + vectorStores.flatMap((store) => + store.vector_store_name ? [[store.vector_store_name, store.vector_store_id] as const] : [], + ), + ), + [vectorStores], + ); + + const resolveVectorStoreId = useCallback((name: string) => vectorStoreIdsByName.get(name), [vectorStoreIdsByName]); + + useEffect(() => { + const fetchIndexes = async () => { + if (!accessToken) { + setIsLoading(false); + return; + } + try { + const response = await indexesListCall(accessToken); + setIndexes(response.data || []); + } catch (error) { + console.error("Error fetching indexes:", error); + NotificationsManager.fromBackend("Error fetching indexes: " + error); + } finally { + setIsLoading(false); + } + }; + fetchIndexes(); + }, [accessToken]); + + return ( +
+

+ Vector store indexes registered on this proxy via the /v1/indexes API. See the{" "} + + vector store index docs + {" "} + for how this works. Index passthrough is supported for Azure AI Search and Milvus today; support for more + providers can be added, so please{" "} + + file a GitHub issue + {" "} + if you want your provider supported. +

+
+ +
+
+ ); +}; + +export default IndexesTab; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.test.tsx new file mode 100644 index 00000000000..f31c51f3fba --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.test.tsx @@ -0,0 +1,100 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import type { VectorStoreIndex } from "./IndexesTab"; +import IndexesTable from "./IndexesTable"; + +vi.mock("next/navigation", async () => ({ + ...(await vi.importActual("next/navigation")), + useRouter: () => ({ push: vi.fn() }), +})); + +const newerIndex: VectorStoreIndex = { + id: "idx-newer", + index_name: "newer-index", + litellm_params: { vector_store_index: "provider-newer", vector_store_name: "newer-store" }, + created_by: "admin@example.com", + created_at: "2024-02-20T10:30:00Z", +}; + +const olderIndex: VectorStoreIndex = { + id: "idx-older", + index_name: "older-index", + litellm_params: { vector_store_index: "provider-older", vector_store_name: "older-store" }, + created_by: "admin@example.com", + created_at: "2024-01-10T09:15:00Z", +}; + +const undatedIndex: VectorStoreIndex = { + id: "idx-undated", + index_name: "undated-index", + litellm_params: { vector_store_index: "provider-undated", vector_store_name: "undated-store" }, + created_by: null, + created_at: null, +}; + +const noResolve = () => undefined; + +describe("IndexesTable", () => { + it("should display the empty state when no indexes are registered", () => { + render(); + expect(screen.getByText("No indexes registered yet")).toBeInTheDocument(); + }); + + it("should render index rows with dash fallbacks for missing created_by and created_at", () => { + render( + , + ); + expect(screen.getByText("newer-index")).toBeInTheDocument(); + expect(screen.getByText("newer-store")).toBeInTheDocument(); + expect(screen.getByText("provider-newer")).toBeInTheDocument(); + expect(screen.getByText("admin@example.com")).toBeInTheDocument(); + const undatedRow = screen.getByText("undated-index").closest("tr"); + expect(undatedRow).not.toBeNull(); + expect(within(undatedRow as HTMLElement).getAllByText("-")).toHaveLength(2); + }); + + it("should sort by created_at descending by default", () => { + render( + , + ); + const rows = screen.getAllByRole("row").slice(1); + expect(within(rows[0]).getByText("newer-index")).toBeInTheDocument(); + expect(within(rows[1]).getByText("older-index")).toBeInTheDocument(); + }); + + it("should call onViewVectorStore with the resolved id when the vector store cell is clicked", async () => { + const user = userEvent.setup(); + const onViewVectorStore = vi.fn(); + render( + (name === "newer-store" ? "vs-newer" : undefined)} + onViewVectorStore={onViewVectorStore} + />, + ); + await user.click(screen.getByRole("button", { name: "newer-store" })); + expect(onViewVectorStore).toHaveBeenCalledWith("vs-newer"); + }); + + it("should render an unresolvable vector store name as plain text without a clickable cell", () => { + render(); + expect(screen.getByText("newer-store")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "newer-store" })).not.toBeInTheDocument(); + }); + + it("should link created_by to the user detail deep link", () => { + render(); + const link = screen.getByRole("link", { name: "admin@example.com" }); + expect(link.getAttribute("href")).toMatch(/\/users\?user=admin%40example\.com$/); + }); + + it("should keep the dash fallback and render no link for a null created_by", () => { + render(); + const row = screen.getByText("undated-index").closest("tr"); + expect(row).not.toBeNull(); + expect(within(row as HTMLElement).queryByRole("link")).not.toBeInTheDocument(); + expect(within(row as HTMLElement).getAllByText("-").length).toBeGreaterThan(0); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.tsx new file mode 100644 index 00000000000..927fd48acb6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { SortingState } from "@tanstack/react-table"; +import { Inbox } from "lucide-react"; +import React, { useMemo, useState } from "react"; + +import { DataTable } from "@/components/shared/DataTable"; + +import type { VectorStoreIndex } from "./IndexesTab"; +import { getIndexesTableColumns } from "./IndexesTableColumns"; + +interface IndexesTableProps { + data: VectorStoreIndex[]; + resolveVectorStoreId: (name: string) => string | undefined; + onViewVectorStore: (vectorStoreId: string) => void; + isLoading?: boolean; +} + +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; + +function EmptyState() { + return ( +
+
+ +
+
No indexes registered yet
+
Indexes registered on this proxy will appear here.
+
+ ); +} + +const IndexesTable: React.FC = ({ + data, + resolveVectorStoreId, + onViewVectorStore, + isLoading = false, +}) => { + const [sorting, setSorting] = useState(DEFAULT_SORTING); + + const columns = useMemo( + () => getIndexesTableColumns({ resolveVectorStoreId, onViewVectorStore }), + [resolveVectorStoreId, onViewVectorStore], + ); + + return ( + row.id || String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + isLoading={isLoading} + loadingMessage="Loading indexes…" + noDataMessage={} + size="compact" + /> + ); +}; + +export default IndexesTable; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTableColumns.tsx new file mode 100644 index 00000000000..c21665f87cd --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTableColumns.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DateCell, IdentityCell } from "@/components/shared/table_cells"; +import { userDetailHref } from "@/utils/entityLinks"; + +import type { VectorStoreIndex } from "./IndexesTab"; + +interface IndexesTableColumnsDeps { + resolveVectorStoreId: (name: string) => string | undefined; + onViewVectorStore: (vectorStoreId: string) => void; +} + +export const getIndexesTableColumns = ({ + resolveVectorStoreId, + onViewVectorStore, +}: IndexesTableColumnsDeps): ColumnDef[] => [ + { + id: "index_name", + accessorKey: "index_name", + meta: { title: "Index Name" }, + header: ({ column }) => , + size: 220, + enableSorting: true, + cell: ({ row }) => ( + + {row.original.index_name || "-"} + + ), + }, + { + id: "vector_store_name", + accessorFn: (row) => row.litellm_params.vector_store_name, + meta: { title: "Vector Store" }, + header: ({ column }) => , + size: 200, + enableSorting: true, + cell: ({ row }) => { + const name = row.original.litellm_params.vector_store_name; + const vectorStoreId = name ? resolveVectorStoreId(name) : undefined; + if (vectorStoreId) { + return ( + onViewVectorStore(vectorStoreId)} + /> + ); + } + return ( + + {name || "-"} + + ); + }, + }, + { + id: "vector_store_index", + accessorFn: (row) => row.litellm_params.vector_store_index, + meta: { title: "Provider Index" }, + header: "Provider Index", + size: 220, + enableSorting: false, + cell: ({ row }) => ( + + {row.original.litellm_params.vector_store_index || "-"} + + ), + }, + { + id: "created_by", + accessorKey: "created_by", + meta: { title: "Created By" }, + header: "Created By", + size: 160, + enableSorting: false, + cell: ({ row }) => { + const createdBy = row.original.created_by; + if (createdBy) { + return ( + + ); + } + return -; + }, + }, + { + id: "created_at", + accessorKey: "created_at", + sortingFn: "datetime", + meta: { title: "Created At" }, + header: ({ column }) => , + size: 150, + enableSorting: true, + cell: ({ row }) => , + }, +]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx index 521c1f879ee..7137da201d8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx @@ -1,8 +1,8 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { vectorStoreListCall } from "@/components/networking"; +import { credentialListCall, indexesListCall, vectorStoreListCall } from "@/components/networking"; import VectorStoreManagement from "./index"; @@ -10,6 +10,7 @@ vi.mock("@/components/networking", () => ({ vectorStoreListCall: vi.fn(), vectorStoreDeleteCall: vi.fn(), credentialListCall: vi.fn(), + indexesListCall: vi.fn(), })); vi.mock("./VectorStoreTable", () => ({ @@ -20,11 +21,18 @@ vi.mock("./VectorStoreTable", () => ({ })); vi.mock("./VectorStoreForm", () => ({ __esModule: true, default: () => null })); -vi.mock("./vector_store_info", () => ({ __esModule: true, default: () => null })); +vi.mock("./vector_store_info", () => ({ + __esModule: true, + default: ({ vectorStoreId }: { vectorStoreId: string }) => ( +
{vectorStoreId}
+ ), +})); vi.mock("./CreateVectorStore", () => ({ __esModule: true, default: () => null })); vi.mock("./TestVectorStoreTab", () => ({ __esModule: true, default: () => null })); const mockVectorStoreListCall = vi.mocked(vectorStoreListCall); +const mockCredentialListCall = vi.mocked(credentialListCall); +const mockIndexesListCall = vi.mocked(indexesListCall); const openManageTab = async (user: ReturnType) => { await user.click(screen.getByRole("tab", { name: "Manage Vector Stores" })); @@ -60,3 +68,90 @@ describe("VectorStoreManagement loading state", () => { expect(mockVectorStoreListCall).toHaveBeenCalledWith("sk-test"); }); }); + +describe("VectorStoreManagement Indexes tab", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockVectorStoreListCall.mockResolvedValue({ data: [] }); + mockCredentialListCall.mockResolvedValue({ credentials: [] }); + }); + + it("should render fetched indexes for a proxy admin after the Indexes tab is clicked", async () => { + const user = userEvent.setup(); + mockIndexesListCall.mockResolvedValue({ + object: "list", + data: [ + { + id: "idx-1", + index_name: "support-docs-index", + litellm_params: { vector_store_index: "pinecone-support-docs", vector_store_name: "support-docs-store" }, + }, + ], + }); + render(); + await user.click(screen.getByRole("tab", { name: "Indexes" })); + expect(await screen.findByText("support-docs-index")).toBeInTheDocument(); + expect(screen.getByText("support-docs-store")).toBeInTheDocument(); + expect(mockIndexesListCall).toHaveBeenCalledWith("sk-test"); + }); + + it("should not render the Indexes tab for an Admin Viewer", async () => { + render(); + await waitFor(() => expect(mockVectorStoreListCall).toHaveBeenCalledWith("sk-test")); + expect(screen.getByRole("tab", { name: "Manage Vector Stores" })).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Indexes" })).not.toBeInTheDocument(); + }); + + it("should swap to the vector store info view when an index's vector store name is clicked", async () => { + const user = userEvent.setup(); + mockVectorStoreListCall.mockResolvedValue({ + data: [ + { + vector_store_id: "vs-1", + vector_store_name: "support-docs-store", + custom_llm_provider: "bedrock", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + ], + }); + mockIndexesListCall.mockResolvedValue({ + object: "list", + data: [ + { + id: "idx-1", + index_name: "support-docs-index", + litellm_params: { vector_store_index: "pinecone-support-docs", vector_store_name: "support-docs-store" }, + }, + ], + }); + render(); + await user.click(screen.getByRole("tab", { name: "Indexes" })); + await user.click(await screen.findByRole("button", { name: "support-docs-store" })); + expect(await screen.findByTestId("vector-store-info-view")).toHaveTextContent("vs-1"); + expect(screen.queryByText("Vector Store Management")).not.toBeInTheDocument(); + }); + + it("should link to the feature docs and a GitHub issue for unsupported providers on the Indexes tab", async () => { + const user = userEvent.setup(); + mockIndexesListCall.mockResolvedValue({ object: "list", data: [] }); + render(); + await user.click(screen.getByRole("tab", { name: "Indexes" })); + expect(screen.getByRole("link", { name: "vector store index docs" })).toHaveAttribute( + "href", + "https://docs.litellm.ai/docs/providers/azure_ai/azure_ai_vector_stores_passthrough", + ); + expect(screen.getByRole("link", { name: "file a GitHub issue" })).toHaveAttribute( + "href", + "https://github.com/BerriAI/litellm/issues", + ); + expect(screen.getByText(/supported for Azure AI Search and Milvus today/)).toBeInTheDocument(); + }); + + it("should not call indexesListCall until the Indexes tab is clicked", async () => { + render(); + await waitFor(() => expect(mockVectorStoreListCall).toHaveBeenCalledWith("sk-test")); + expect(screen.getByRole("tab", { name: "Indexes" })).toBeInTheDocument(); + expect(mockIndexesListCall).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx index 8afa49ea148..1e526131fa3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx @@ -13,7 +13,8 @@ import DeleteResourceModal from "@/components/common_components/DeleteResourceMo import VectorStoreInfoView from "./vector_store_info"; import CreateVectorStore from "./CreateVectorStore"; import TestVectorStoreTab from "./TestVectorStoreTab"; -import { isAdminRole } from "@/utils/roles"; +import IndexesTab from "./IndexesTab"; +import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -163,6 +164,11 @@ const VectorStoreManagement: React.FC = ({ accessToken, userID Test Vector Store + {isProxyAdminRole(userRole || "") && ( + + Indexes + + )} @@ -188,6 +194,12 @@ const VectorStoreManagement: React.FC = ({ accessToken, userID + + {isProxyAdminRole(userRole || "") && ( + + + + )} {/* Create Vector Store Modal */} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.test.tsx new file mode 100644 index 00000000000..b5dc359b328 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { credentialListCall, vectorStoreInfoCall } from "@/components/networking"; + +import VectorStoreInfoView from "./vector_store_info"; + +vi.mock("@/components/networking", () => ({ + vectorStoreInfoCall: vi.fn(), + vectorStoreUpdateCall: vi.fn(), + credentialListCall: vi.fn(), +})); + +vi.mock("./VectorStoreTester", () => ({ __esModule: true, default: () => null })); + +const mockVectorStoreInfoCall = vi.mocked(vectorStoreInfoCall); +const mockCredentialListCall = vi.mocked(credentialListCall); + +describe("VectorStoreInfoView", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCredentialListCall.mockResolvedValue({ credentials: [] }); + }); + + it("should render the store details once the fetch resolves", async () => { + mockVectorStoreInfoCall.mockResolvedValue({ + vector_store: { + vector_store_id: "vs-1", + vector_store_name: "support-docs-store", + custom_llm_provider: "bedrock", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + }); + render( + , + ); + expect(await screen.findByText("Vector Store ID: vs-1")).toBeInTheDocument(); + }); + + it("should show a not-found state with a working back button when the fetch fails instead of loading forever", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + mockVectorStoreInfoCall.mockRejectedValue(new Error("Vector store not found")); + render( + , + ); + expect(await screen.findByText("Vector store not found")).toBeInTheDocument(); + expect(screen.getByText(/vs-gone could not be loaded/)).toBeInTheDocument(); + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /Back to Vector Stores/ })); + expect(onClose).toHaveBeenCalled(); + }); + + it("should show the not-found state when the fetch resolves without a vector store", async () => { + mockVectorStoreInfoCall.mockResolvedValue({ vector_store: null }); + render( + , + ); + expect(await screen.findByText("Vector store not found")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx index e5646037d14..4f94a7d56f6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx @@ -33,6 +33,7 @@ const VectorStoreInfoView: React.FC = ({ }) => { const [form] = Form.useForm(); const [vectorStoreDetails, setVectorStoreDetails] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); const [isEditing, setIsEditing] = useState(editVectorStore); const [metadataString, setMetadataString] = useState("{}"); const [credentials, setCredentials] = useState([]); @@ -40,31 +41,35 @@ const VectorStoreInfoView: React.FC = ({ const fetchVectorStoreDetails = async () => { if (!accessToken) return; try { + setLoadFailed(false); const response = await vectorStoreInfoCall(accessToken, vectorStoreId); - if (response && response.vector_store) { - setVectorStoreDetails(response.vector_store); + if (!response || !response.vector_store) { + setLoadFailed(true); + return; + } + setVectorStoreDetails(response.vector_store); - // If metadata exists and is an object, stringify it for display/editing - if (response.vector_store.vector_store_metadata) { - const metadata = - typeof response.vector_store.vector_store_metadata === "string" - ? JSON.parse(response.vector_store.vector_store_metadata) - : response.vector_store.vector_store_metadata; - setMetadataString(JSON.stringify(metadata, null, 2)); - } + // If metadata exists and is an object, stringify it for display/editing + if (response.vector_store.vector_store_metadata) { + const metadata = + typeof response.vector_store.vector_store_metadata === "string" + ? JSON.parse(response.vector_store.vector_store_metadata) + : response.vector_store.vector_store_metadata; + setMetadataString(JSON.stringify(metadata, null, 2)); + } - if (editVectorStore) { - form.setFieldsValue({ - vector_store_id: response.vector_store.vector_store_id, - custom_llm_provider: response.vector_store.custom_llm_provider, - vector_store_name: response.vector_store.vector_store_name, - vector_store_description: response.vector_store.vector_store_description, - }); - } + if (editVectorStore) { + form.setFieldsValue({ + vector_store_id: response.vector_store.vector_store_id, + custom_llm_provider: response.vector_store.custom_llm_provider, + vector_store_name: response.vector_store.vector_store_name, + vector_store_description: response.vector_store.vector_store_description, + }); } } catch (error) { console.error("Error fetching vector store details:", error); NotificationsManager.fromBackend("Error fetching vector store details: " + error); + setLoadFailed(true); } }; @@ -113,6 +118,20 @@ const VectorStoreInfoView: React.FC = ({ } }; + if (loadFailed) { + return ( +
+ + Vector store not found + + Vector store {vectorStoreId} could not be loaded. It may have been deleted. + +
+ ); + } + if (!vectorStoreDetails) { return
Loading...
; } diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 17a5ca37990..4a396fa83fc 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -66,6 +66,7 @@ import type { } from "@/app/(dashboard)/caching/_components/coordination_redis_settings/types"; import { MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE } from "./mcp_tools/constants"; import type { ComplexityRouterConfigPayload } from "./add_model/build_complexity_router_config"; +import type { VectorStoreIndex } from "@/app/(dashboard)/vector-stores/_components/IndexesTab"; import type { RoutingDecision } from "./view_logs/LogDetailsDrawer/RoutingDecisionCard"; import { createApiClient, @@ -5622,6 +5623,20 @@ export const vectorStoreListCall = async ( } }; +export interface IndexesListResponse { + object: string; + data: VectorStoreIndex[]; +} + +export const indexesListCall = async (accessToken: string): Promise => { + try { + return await apiClient.get(`/v1/indexes`, { accessToken }); + } catch (error) { + console.error("Error listing indexes:", error); + throw error; + } +}; + export const vectorStoreDeleteCall = async (accessToken: string, vectorStoreId: string): Promise => { try { let url = proxyBaseUrl ? `${proxyBaseUrl}/vector_store/delete` : `/vector_store/delete`; diff --git a/ui/litellm-dashboard/src/utils/entityLinks.ts b/ui/litellm-dashboard/src/utils/entityLinks.ts index 85052448866..b0829d15d1d 100644 --- a/ui/litellm-dashboard/src/utils/entityLinks.ts +++ b/ui/litellm-dashboard/src/utils/entityLinks.ts @@ -7,3 +7,7 @@ export function teamDetailHref(teamId: string): string { export function keyDetailHref(keyToken: string): string { return `${migratedHref("api-keys")}?key=${encodeURIComponent(keyToken)}`; } + +export function userDetailHref(userId: string): string { + return `${migratedHref("users")}?user=${encodeURIComponent(userId)}`; +}