From 9a53edbda0b6d0e8a206725831a0b680fa8784f8 Mon Sep 17 00:00:00 2001 From: trean Date: Wed, 21 Aug 2024 12:57:03 +0200 Subject: [PATCH] List of collections as a table with more info (#213) * collections list as a table * collections pagination * collection search upd * moved a component * add an action menu to the collection table * upd * moved vectors configuration from DatasetsTableRow to a separate component, used it in the CollectionsList * some fixes * CollectionsList render test * fixes * display sparse vectors in VectorsConfigChip * optimizations --- package-lock.json | 12 +- .../Collections/CollectionsList.jsx | 137 ++++++++++++++++ src/components/Collections/SearchBar.jsx | 1 + .../Collections/collectionList.test.jsx | 81 +++++++++ src/components/{ => Common}/InfoBanner.jsx | 0 src/components/Common/VectorsConfigChip.jsx | 68 ++++++++ src/components/Datasets/DatasetsTableRow.jsx | 37 +---- src/components/Snapshots/SnapshotsTab.jsx | 2 +- src/components/Snapshots/SnapshotsUpload.jsx | 8 +- src/pages/Collections.jsx | 154 ++++++++++++++---- 10 files changed, 420 insertions(+), 80 deletions(-) create mode 100644 src/components/Collections/CollectionsList.jsx create mode 100644 src/components/Collections/collectionList.test.jsx rename src/components/{ => Common}/InfoBanner.jsx (100%) create mode 100644 src/components/Common/VectorsConfigChip.jsx diff --git a/package-lock.json b/package-lock.json index 9ae83f26..0558f241 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2972,9 +2972,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001578", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001578.tgz", - "integrity": "sha512-J/jkFgsQ3NEl4w2lCoM9ZPxrD+FoBNJ7uJUpGVjIg/j0OwJosWM36EPDv+Yyi0V4twBk9pPmlFS+PLykgEvUmg==", + "version": "1.0.30001651", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", + "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", "funding": [ { "type": "opencollective", @@ -10925,9 +10925,9 @@ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" }, "caniuse-lite": { - "version": "1.0.30001578", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001578.tgz", - "integrity": "sha512-J/jkFgsQ3NEl4w2lCoM9ZPxrD+FoBNJ7uJUpGVjIg/j0OwJosWM36EPDv+Yyi0V4twBk9pPmlFS+PLykgEvUmg==" + "version": "1.0.30001651", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", + "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==" }, "canvas-color-tracker": { "version": "1.2.1", diff --git a/src/components/Collections/CollectionsList.jsx b/src/components/Collections/CollectionsList.jsx new file mode 100644 index 00000000..54033c04 --- /dev/null +++ b/src/components/Collections/CollectionsList.jsx @@ -0,0 +1,137 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { Box, MenuItem, TableCell, TableContainer, TableRow, Typography } from '@mui/material'; +import { styled, useTheme } from '@mui/material/styles'; +import { TableBodyWithGaps, TableHeadWithGaps, TableWithGaps } from '../Common/TableWithGaps'; +import { Dot } from '../Common/Dot'; +import DeleteDialog from './DeleteDialog'; +import ActionsMenu from '../Common/ActionsMenu'; +import VectorsConfigChip from '../Common/VectorsConfigChip'; + +const StyledLink = styled(Link)` + text-decoration: none; + color: inherit; + &:hover { + text-decoration: underline; + } +`; + +const CollectionTableRow = ({ collection, getCollectionsCall }) => { + const [openDeleteDialog, setOpenDeleteDialog] = useState(false); + const theme = useTheme(); + + return ( + + + + {collection.name} + + + + + + {collection.status} + + + + + {collection.points_count} + + + + {collection.segments_count} + + + {collection.config.params.shard_number} + + + + + + + + Take Snapshot + + + Visualize + + + Graph + + setOpenDeleteDialog(true)} sx={{ color: theme.palette.error.main }}> + Delete + + + + + + ); +}; + +CollectionTableRow.propTypes = { + collection: PropTypes.object.isRequired, + getCollectionsCall: PropTypes.func.isRequired, +}; + +const HeaderTableCell = styled(TableCell)` + font-weight: bold; +`; + +const CollectionsList = ({ collections, getCollectionsCall }) => { + return ( + + + + + Name + Status + + Points (Approx) + + + Segments + + + Shards + + + Vectors Configuration +
+ (Name, Size, Distance) +
+ + Actions + +
+
+ + {collections.length > 0 && + collections.map((collection) => ( + + ))} + +
+
+ ); +}; + +CollectionsList.propTypes = { + collections: PropTypes.array.isRequired, + getCollectionsCall: PropTypes.func.isRequired, +}; + +export default CollectionsList; diff --git a/src/components/Collections/SearchBar.jsx b/src/components/Collections/SearchBar.jsx index 65929f1a..ce195ea0 100644 --- a/src/components/Collections/SearchBar.jsx +++ b/src/components/Collections/SearchBar.jsx @@ -26,6 +26,7 @@ function InputWithIcon({ value, setValue, actions }) { } + size={'small'} sx={{ maxWidth: 500 }} /> diff --git a/src/components/Collections/collectionList.test.jsx b/src/components/Collections/collectionList.test.jsx new file mode 100644 index 00000000..1e19a372 --- /dev/null +++ b/src/components/Collections/collectionList.test.jsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import CollectionsList from './CollectionsList'; +import { describe, it, expect } from 'vitest'; + +vi.mock('../../context/client-context', () => ({ + useClient: () => ({ + client: { + deleteCollection: vi.fn().mockResolvedValue({}), + }, + }), +})); + +const COLLECTIONS = [ + { + name: 'Collection 1', + status: 'green', + points_count: 1000, + segments_count: 10, + config: { + params: { + shard_number: 2, + vectors: { + size: 128, + distance: 'cosine', + }, + }, + }, + }, + { + name: 'Collection 2', + status: 'yellow', + points_count: 500, + segments_count: 5, + config: { + params: { + shard_number: 1, + vectors: { + vector1: { + size: 64, + distance: 'euclidean', + }, + vector2: { + size: 32, + distance: 'manhattan', + }, + }, + }, + }, + }, +]; + +describe('CollectionsList', () => { + it('should render CollectionsList with given data', () => { + render( + + {}} /> + + ); + expect(screen.getByText('Collection 1')).toBeInTheDocument(); + expect(screen.getByText('Collection 2')).toBeInTheDocument(); + }); + + it('should render CollectionTableRow with given data', () => { + render( + + {}} /> + + ); + expect(screen.getByText('green')).toBeInTheDocument(); + expect(screen.getByText('yellow')).toBeInTheDocument(); + expect(screen.getByText('1000')).toBeInTheDocument(); + expect(screen.getByText('500')).toBeInTheDocument(); + expect(screen.getByText('128')).toBeInTheDocument(); + expect(screen.getByText('cosine')).toBeInTheDocument(); + expect(screen.getByText('64')).toBeInTheDocument(); + expect(screen.getByText('euclidean')).toBeInTheDocument(); + expect(screen.getByText('32')).toBeInTheDocument(); + expect(screen.getByText('manhattan')).toBeInTheDocument(); + }); +}); diff --git a/src/components/InfoBanner.jsx b/src/components/Common/InfoBanner.jsx similarity index 100% rename from src/components/InfoBanner.jsx rename to src/components/Common/InfoBanner.jsx diff --git a/src/components/Common/VectorsConfigChip.jsx b/src/components/Common/VectorsConfigChip.jsx new file mode 100644 index 00000000..a10c6fd1 --- /dev/null +++ b/src/components/Common/VectorsConfigChip.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Card, Grid } from '@mui/material'; + +const VectorsConfigChip = ({ collectionConfigParams, sx = {} }) => { + return ( + <> + {collectionConfigParams.vectors.size && ( + + + default + + + {collectionConfigParams.vectors.size} + + + {collectionConfigParams.vectors.distance} + + {/* model is not always present */} + {collectionConfigParams.vectors.model && ( + + {collectionConfigParams.vectors.model} + + )} + + )} + {!collectionConfigParams.vectors.size && + Object.keys(collectionConfigParams.vectors).map((vector) => ( + + + {vector} + + + {collectionConfigParams.vectors[vector].size} + + + {collectionConfigParams.vectors[vector].distance} + + {/* model is not always present */} + {collectionConfigParams.vectors[vector].model && ( + + {collectionConfigParams.vectors[vector].model} + + )} + + ))} + {collectionConfigParams.sparse_vectors && + Object.keys(collectionConfigParams.sparse_vectors).map((vector) => ( + + + {vector} + + + Sparse + + + + ))} + + ); +}; + +VectorsConfigChip.propTypes = { + collectionConfigParams: PropTypes.object.isRequired, + sx: PropTypes.object, +}; + +export default VectorsConfigChip; diff --git a/src/components/Datasets/DatasetsTableRow.jsx b/src/components/Datasets/DatasetsTableRow.jsx index 43051799..0972e035 100644 --- a/src/components/Datasets/DatasetsTableRow.jsx +++ b/src/components/Datasets/DatasetsTableRow.jsx @@ -2,9 +2,10 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import prettyBytes from 'pretty-bytes'; import { useTheme } from '@mui/material/styles'; -import { Box, Card, CircularProgress, Grid, IconButton, TableCell, TableRow, Tooltip, Typography } from '@mui/material'; +import { Box, CircularProgress, IconButton, TableCell, TableRow, Tooltip, Typography } from '@mui/material'; import { Download, FolderZip } from '@mui/icons-material'; import ImportDatasetDialog from './ImportDatasetDialog'; +import VectorsConfigChip from '../Common/VectorsConfigChip'; export const DatasetsTableRow = ({ dataset, importDataset }) => { const theme = useTheme(); @@ -63,39 +64,7 @@ export const DatasetsTableRow = ({ dataset, importDataset }) => { {prettyBytes(dataset.size)} - {dataset.vectors.size && ( - - - default - - - {dataset.vectors.size} - - - {dataset.vectors.distance} - - - {dataset.vectors.model} - - - )} - {!dataset.vectors.size && - Object.keys(dataset.vectors).map((vector) => ( - - - {vector} - - - {dataset.vectors[vector].size} - - - {dataset.vectors[vector].distance} - - - {dataset.vectors[vector].model} - - - ))} + {dataset.vectorCount} diff --git a/src/components/Snapshots/SnapshotsTab.jsx b/src/components/Snapshots/SnapshotsTab.jsx index 6eef126b..aa3d2a1c 100644 --- a/src/components/Snapshots/SnapshotsTab.jsx +++ b/src/components/Snapshots/SnapshotsTab.jsx @@ -8,7 +8,7 @@ import PhotoCamera from '@mui/icons-material/PhotoCamera'; import { TableWithGaps, TableHeadWithGaps, TableBodyWithGaps } from '../Common/TableWithGaps'; import { SnapshotsTableRow } from './SnapshotsTableRow'; import { pumpFile, updateProgress } from '../../common/utils'; -import InfoBanner from '../InfoBanner'; +import InfoBanner from '../Common/InfoBanner'; export const SnapshotsTab = ({ collectionName }) => { const { client: qdrantClient } = useClient(); diff --git a/src/components/Snapshots/SnapshotsUpload.jsx b/src/components/Snapshots/SnapshotsUpload.jsx index de31bef9..7636e9cf 100644 --- a/src/components/Snapshots/SnapshotsUpload.jsx +++ b/src/components/Snapshots/SnapshotsUpload.jsx @@ -4,7 +4,7 @@ import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import Box from '@mui/material/Box'; import Tooltip from '@mui/material/Tooltip'; -import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; import UploadFile from '@mui/icons-material/UploadFile'; import Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; @@ -28,9 +28,9 @@ export const SnapshotsUpload = ({ onComplete, sx }) => { return ( - - - + a.name.localeCompare(b.name))); - setErrorMessage(null); - } catch (error) { + const getErrorMessageWithApiKey = useCallback( + (error) => { const apiKey = qdrantClient.getApiKey(); - const message = getErrorMessage(error, { withApiKey: { apiKey } }); - message && setErrorMessage(message); - setRawCollections(null); - } - } + return getErrorMessage(error, { withApiKey: { apiKey } }); + }, + [qdrantClient] + ); + + const getCollectionsCall = useCallback( + async (page = 1) => { + try { + const allCollections = await qdrantClient.getCollections(); + const sortedCollections = allCollections.collections.sort((a, b) => a.name.localeCompare(b.name)); + setCollections(sortedCollections); + + const nextPageCollections = sortedCollections.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + const nextRawCollections = await Promise.all( + nextPageCollections.map(async (collection) => { + const collectionData = await qdrantClient.getCollection(collection.name); + return { + name: collection.name, + ...collectionData, + }; + }) + ); + + setRawCollections(nextRawCollections.sort((a, b) => a.name.localeCompare(b.name))); + setErrorMessage(null); + } catch (error) { + const message = getErrorMessageWithApiKey(error); + message && setErrorMessage(message); + setRawCollections(null); + } + }, + [qdrantClient, getErrorMessageWithApiKey] + ); + + const getFilteredCollections = useCallback( + async (query) => { + try { + const filteredCollections = collections.filter((collection) => collection.name.match(query)); + setCollections(filteredCollections); + const nextRawCollections = await Promise.all( + filteredCollections.map(async (collection) => { + const collectionData = await qdrantClient.getCollection(collection.name); + return { + name: collection.name, + ...collectionData, + }; + }) + ); + + setRawCollections(nextRawCollections.sort((a, b) => a.name.localeCompare(b.name))); + setErrorMessage(null); + } catch (error) { + const message = getErrorMessageWithApiKey(error); + message && setErrorMessage(message); + setRawCollections(null); + } + }, + [collections, qdrantClient, getErrorMessageWithApiKey] + ); useEffect(() => { - getCollectionsCall(); - }, []); + getCollectionsCall(currentPage); + }, [currentPage, getCollectionsCall]); useEffect(() => { - setCollections(rawCollections?.filter((user) => user.name.includes(searchQuery))); - }, [searchQuery, rawCollections]); + if (!searchQuery) { + getCollectionsCall(currentPage); + } else { + debouncedGetFilteredCollections(searchQuery); + } + }, [searchQuery, currentPage, getCollectionsCall]); + + const debouncedGetFilteredCollections = useMemo( + () => debounce(getFilteredCollections, 100), + [getFilteredCollections] + ); + + const handlePageChange = (event, value) => { + setCurrentPage(value); + }; return ( <> {errorMessage !== null && } - - - Collections + + + + + Collections + + + + getCollectionsCall(currentPage)} key={'snapshots'} /> - ]} - /> + {errorMessage && ( - + ⚠ Error: {errorMessage} )} {!collections && !errorMessage && ( 🔃 Loading... + )} {collections && !errorMessage && collections.length === 0 && ( - + 📪 No collection is present )} - {collections && - !errorMessage && - collections?.map((collection) => ( - - - - ))} + + {rawCollections?.length && !errorMessage ? ( + + getCollectionsCall(currentPage)} + /> + {collections.length > PAGE_SIZE && ( + + + + )} + + ) : ( + <> + )}