mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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
This commit is contained in:
parent
76ad1c319d
commit
ec9ab43d20
12 changed files with 634 additions and 31 deletions
|
|
@ -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(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<ViewUserDashboard {...defaultProps} />
|
||||
</QueryClientProvider>,
|
||||
|
|
|
|||
|
|
@ -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<ViewUserDashboardProps> = ({
|
|||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [isBulkEditModalVisible, setIsBulkEditModalVisible] = useState(false);
|
||||
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(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<ViewUserDashboardProps> = ({
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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<IndexesTabProps> = ({ accessToken, vectorStores, onViewVectorStore }) => {
|
||||
const [indexes, setIndexes] = useState<VectorStoreIndex[]>([]);
|
||||
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 (
|
||||
<div className="w-full">
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Vector store indexes registered on this proxy via the <code>/v1/indexes</code> API. See the{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/providers/azure_ai/azure_ai_vector_stores_passthrough"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
vector store index docs
|
||||
</a>{" "}
|
||||
for how this works. Index passthrough is supported for Azure AI Search and Milvus today; support for more
|
||||
providers can be added, so please{" "}
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
file a GitHub issue
|
||||
</a>{" "}
|
||||
if you want your provider supported.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 pt-2 pb-2 w-full">
|
||||
<IndexesTable
|
||||
data={indexes}
|
||||
isLoading={isLoading}
|
||||
resolveVectorStoreId={resolveVectorStoreId}
|
||||
onViewVectorStore={onViewVectorStore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndexesTab;
|
||||
|
|
@ -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(<IndexesTable data={[]} resolveVectorStoreId={noResolve} onViewVectorStore={vi.fn()} />);
|
||||
expect(screen.getByText("No indexes registered yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render index rows with dash fallbacks for missing created_by and created_at", () => {
|
||||
render(
|
||||
<IndexesTable data={[newerIndex, undatedIndex]} resolveVectorStoreId={noResolve} onViewVectorStore={vi.fn()} />,
|
||||
);
|
||||
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(
|
||||
<IndexesTable data={[olderIndex, newerIndex]} resolveVectorStoreId={noResolve} onViewVectorStore={vi.fn()} />,
|
||||
);
|
||||
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(
|
||||
<IndexesTable
|
||||
data={[newerIndex]}
|
||||
resolveVectorStoreId={(name) => (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(<IndexesTable data={[newerIndex]} resolveVectorStoreId={noResolve} onViewVectorStore={vi.fn()} />);
|
||||
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(<IndexesTable data={[newerIndex]} resolveVectorStoreId={noResolve} onViewVectorStore={vi.fn()} />);
|
||||
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(<IndexesTable data={[undatedIndex]} resolveVectorStoreId={noResolve} onViewVectorStore={vi.fn()} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No indexes registered yet</div>
|
||||
<div className="text-sm text-muted-foreground">Indexes registered on this proxy will appear here.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const IndexesTable: React.FC<IndexesTableProps> = ({
|
||||
data,
|
||||
resolveVectorStoreId,
|
||||
onViewVectorStore,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getIndexesTableColumns({ resolveVectorStoreId, onViewVectorStore }),
|
||||
[resolveVectorStoreId, onViewVectorStore],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
getRowId={(row, index) => row.id || String(index)}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading indexes…"
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndexesTable;
|
||||
|
|
@ -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<VectorStoreIndex>[] => [
|
||||
{
|
||||
id: "index_name",
|
||||
accessorKey: "index_name",
|
||||
meta: { title: "Index Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Index Name" />,
|
||||
size: 220,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-60 truncate text-sm font-medium" title={row.original.index_name}>
|
||||
{row.original.index_name || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "vector_store_name",
|
||||
accessorFn: (row) => row.litellm_params.vector_store_name,
|
||||
meta: { title: "Vector Store" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Vector Store" />,
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.litellm_params.vector_store_name;
|
||||
const vectorStoreId = name ? resolveVectorStoreId(name) : undefined;
|
||||
if (vectorStoreId) {
|
||||
return (
|
||||
<IdentityCell
|
||||
title={name}
|
||||
titleClassName="font-normal"
|
||||
className="max-w-60"
|
||||
onClick={() => onViewVectorStore(vectorStoreId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="block max-w-60 truncate text-sm" title={name}>
|
||||
{name || "-"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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 }) => (
|
||||
<span
|
||||
className="block max-w-60 truncate font-mono text-xs"
|
||||
title={row.original.litellm_params.vector_store_index}
|
||||
>
|
||||
{row.original.litellm_params.vector_store_index || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<IdentityCell
|
||||
title={createdBy}
|
||||
titleClassName="font-normal"
|
||||
className="max-w-48"
|
||||
href={userDetailHref(createdBy)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span className="block max-w-48 truncate text-sm">-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
sortingFn: "datetime",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
},
|
||||
];
|
||||
|
|
@ -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 }) => (
|
||||
<div data-testid="vector-store-info-view">{vectorStoreId}</div>
|
||||
),
|
||||
}));
|
||||
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<typeof userEvent.setup>) => {
|
||||
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(<VectorStoreManagement accessToken="sk-test" userID="user-1" userRole="Admin" />);
|
||||
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(<VectorStoreManagement accessToken="sk-test" userID="user-1" userRole="Admin Viewer" />);
|
||||
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(<VectorStoreManagement accessToken="sk-test" userID="user-1" userRole="Admin" />);
|
||||
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(<VectorStoreManagement accessToken="sk-test" userID="user-1" userRole="Admin" />);
|
||||
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(<VectorStoreManagement accessToken="sk-test" userID="user-1" userRole="Admin" />);
|
||||
await waitFor(() => expect(mockVectorStoreListCall).toHaveBeenCalledWith("sk-test"));
|
||||
expect(screen.getByRole("tab", { name: "Indexes" })).toBeInTheDocument();
|
||||
expect(mockIndexesListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<VectorStoreProps> = ({ accessToken, userID
|
|||
<TabsTrigger value="test" className="flex-none rounded-none px-4 py-2">
|
||||
Test Vector Store
|
||||
</TabsTrigger>
|
||||
{isProxyAdminRole(userRole || "") && (
|
||||
<TabsTrigger value="indexes" className="flex-none rounded-none px-4 py-2">
|
||||
Indexes
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent keepMounted={hasVisited("create")} value="create">
|
||||
|
|
@ -188,6 +194,12 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
|
|||
<TabsContent keepMounted={hasVisited("test")} value="test">
|
||||
<TestVectorStoreTab accessToken={accessToken} vectorStores={vectorStores} />
|
||||
</TabsContent>
|
||||
|
||||
{isProxyAdminRole(userRole || "") && (
|
||||
<TabsContent keepMounted={hasVisited("indexes")} value="indexes">
|
||||
<IndexesTab accessToken={accessToken} vectorStores={vectorStores} onViewVectorStore={handleView} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{/* Create Vector Store Modal */}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<VectorStoreInfoView
|
||||
vectorStoreId="vs-1"
|
||||
onClose={vi.fn()}
|
||||
accessToken="sk-test"
|
||||
is_admin={true}
|
||||
editVectorStore={false}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<VectorStoreInfoView
|
||||
vectorStoreId="vs-gone"
|
||||
onClose={onClose}
|
||||
accessToken="sk-test"
|
||||
is_admin={true}
|
||||
editVectorStore={false}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<VectorStoreInfoView
|
||||
vectorStoreId="vs-gone"
|
||||
onClose={vi.fn()}
|
||||
accessToken="sk-test"
|
||||
is_admin={true}
|
||||
editVectorStore={false}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("Vector store not found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -33,6 +33,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [vectorStoreDetails, setVectorStoreDetails] = useState<VectorStore | null>(null);
|
||||
const [loadFailed, setLoadFailed] = useState<boolean>(false);
|
||||
const [isEditing, setIsEditing] = useState<boolean>(editVectorStore);
|
||||
const [metadataString, setMetadataString] = useState<string>("{}");
|
||||
const [credentials, setCredentials] = useState<CredentialItem[]>([]);
|
||||
|
|
@ -40,31 +41,35 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
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<VectorStoreInfoViewProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
if (loadFailed) {
|
||||
return (
|
||||
<div className="p-4 max-w-full">
|
||||
<Button icon={ArrowLeftIcon} variant="light" className="mb-4" onClick={onClose}>
|
||||
Back to Vector Stores
|
||||
</Button>
|
||||
<Title>Vector store not found</Title>
|
||||
<Text className="text-gray-500">
|
||||
Vector store {vectorStoreId} could not be loaded. It may have been deleted.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!vectorStoreDetails) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IndexesListResponse> => {
|
||||
try {
|
||||
return await apiClient.get<IndexesListResponse>(`/v1/indexes`, { accessToken });
|
||||
} catch (error) {
|
||||
console.error("Error listing indexes:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const vectorStoreDeleteCall = async (accessToken: string, vectorStoreId: string): Promise<void> => {
|
||||
try {
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/vector_store/delete` : `/vector_store/delete`;
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue