diff --git a/src/components/Collections/CollectionsList.jsx b/src/components/Collections/CollectionsList.jsx index bed120e1c..1bb5ab638 100644 --- a/src/components/Collections/CollectionsList.jsx +++ b/src/components/Collections/CollectionsList.jsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { Link } from 'react-router'; -import { Checkbox, MenuItem, TableCell, TableRow, Typography, Table } from '@mui/material'; +import { Box, Checkbox, MenuItem, TableCell, TableRow, Tooltip, Typography, Table } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import { StyledTableBody, @@ -74,6 +74,44 @@ CollectionNameCell.propTypes = { collection: PropTypes.object.isRequired, }; +// With custom sharding `shard_number` is only the *default* number of shards per shard key: +// every shard key can be created with its own `shards_number`. So the total number of shards +// comes from the collection cluster info, and is never derived from `shard_number`. +const ShardsCell = ({ collectionParams, shardCount, shardKeysCount }) => { + const shardNumber = collectionParams.shard_number; + + if (collectionParams.sharding_method !== 'custom') { + return {shardNumber}; + } + + if (shardCount == null) { + return ( + + {`${shardNumber} / key`} + + ); + } + + const keysLabel = shardKeysCount === 1 ? '1 shard key' : `${shardKeysCount} shard keys`; + + return ( + + + {shardCount} + + {keysLabel} + + + + ); +}; + +ShardsCell.propTypes = { + collectionParams: PropTypes.object.isRequired, + shardCount: PropTypes.number, + shardKeysCount: PropTypes.number, +}; + const CollectionTableRow = ({ collection, getCollectionsCall, @@ -145,7 +183,11 @@ const CollectionTableRow = ({ {collection.segments_count} - {collection.config.params.shard_number} + diff --git a/src/components/Collections/collectionList.test.jsx b/src/components/Collections/collectionList.test.jsx index a1d1507bc..a2806c8c6 100644 --- a/src/components/Collections/collectionList.test.jsx +++ b/src/components/Collections/collectionList.test.jsx @@ -60,6 +60,65 @@ const COLLECTIONS = [ }, ]; +const CUSTOM_SHARDED_COLLECTIONS = [ + { + name: 'Custom sharded', + status: 'green', + points_count: 100, + segments_count: 7, + config: { + params: { + shard_number: 2, + sharding_method: 'custom', + vectors: { + size: 128, + distance: 'cosine', + }, + }, + }, + shard_count: 6, + shard_keys_count: 3, + aliases: [], + }, + { + name: 'Custom sharded without cluster info', + status: 'green', + points_count: 200, + segments_count: 8, + config: { + params: { + shard_number: 4, + sharding_method: 'custom', + vectors: { + size: 128, + distance: 'cosine', + }, + }, + }, + aliases: [], + }, + { + // shard keys created with their own `shards_number`: 2 + 2 + 1 shards + name: 'Custom sharded with uneven shard keys', + status: 'green', + points_count: 300, + segments_count: 9, + config: { + params: { + shard_number: 2, + sharding_method: 'custom', + vectors: { + size: 128, + distance: 'cosine', + }, + }, + }, + shard_count: 5, + shard_keys_count: 3, + aliases: [], + }, +]; + const DEFAULT_SELECTION_PROPS = { selectedCollections: new Set(), handleToggleSelect: vi.fn(), @@ -222,4 +281,69 @@ describe('CollectionsList', () => { expect(btn.closest('li')).toHaveAttribute('aria-disabled', 'true'); }); }); + it('should render shard_number as-is for automatic sharding', () => { + render( + + {}} + refreshCollection={vi.fn()} + isRefreshing={false} + {...DEFAULT_SELECTION_PROPS} + /> + + ); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.queryByText(/shard keys/)).not.toBeInTheDocument(); + }); + + it('should render the total number of shards for custom sharding', () => { + render( + + {}} + refreshCollection={vi.fn()} + isRefreshing={false} + {...DEFAULT_SELECTION_PROPS} + /> + + ); + // 6 shards in total (3 shard keys x 2 shards per key), not the `shard_number` of 2 + expect(screen.getByText('6')).toBeInTheDocument(); + expect(screen.getAllByText('3 shard keys')).toHaveLength(2); + expect(screen.queryByText('2')).not.toBeInTheDocument(); + }); + + it('should mark shard_number as per-key when the total is unknown for custom sharding', () => { + render( + + {}} + refreshCollection={vi.fn()} + isRefreshing={false} + {...DEFAULT_SELECTION_PROPS} + /> + + ); + expect(screen.getByText('4 / key')).toBeInTheDocument(); + }); + + it('should not derive the total from shard_number when shard keys have different shard counts', () => { + render( + + {}} + refreshCollection={vi.fn()} + isRefreshing={false} + {...DEFAULT_SELECTION_PROPS} + /> + + ); + // 3 shard keys with 2 + 2 + 1 shards, which is not `shard_keys_count` * `shard_number` + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByLabelText(/5 shard\(s\) across 3 shard keys/)).toBeInTheDocument(); + }); }); diff --git a/src/pages/Collections.jsx b/src/pages/Collections.jsx index b60b84ba5..9a9ff3be6 100644 --- a/src/pages/Collections.jsx +++ b/src/pages/Collections.jsx @@ -27,7 +27,7 @@ function Collections() { const [searchQuery, setSearchQuery] = useState(''); const [errorMessage, setErrorMessage] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); - const { client: qdrantClient } = useClient(); + const { client: qdrantClient, isRestricted } = useClient(); const [currentPage, setCurrentPage] = useState(1); const PAGE_SIZE = 5; const [selectedCollections, setSelectedCollections] = useState(new Set()); @@ -44,6 +44,35 @@ function Collections() { [qdrantClient] ); + // `shard_number` is only the *default* number of shards per shard key for custom sharding + // (each shard key can be created with its own `shards_number`), so the actual total shard + // count has to be read from the collection cluster info. + const getShardInfo = useCallback( + async (collectionName, collectionData) => { + if (isRestricted || collectionData.config?.params?.sharding_method !== 'custom') { + return {}; + } + try { + const res = await qdrantClient.api('cluster').collectionClusterInfo({ collection_name: collectionName }); + const clusterInfo = res.data?.result ?? {}; + const shards = [...(clusterInfo.local_shards ?? []), ...(clusterInfo.remote_shards ?? [])]; + const shardKeys = new Set(shards.filter((shard) => shard.shard_key != null).map((shard) => shard.shard_key)); + const shardCount = Number(clusterInfo.shard_count); + if (!Number.isFinite(shardCount)) { + return {}; + } + return { + shard_count: shardCount, + shard_keys_count: shardKeys.size, + }; + } catch { + // Cluster info is unavailable (e.g. insufficient permissions), fall back to `shard_number` + return {}; + } + }, + [qdrantClient, isRestricted] + ); + const getCollectionsCall = useCallback( async (page = 1) => { try { @@ -61,9 +90,11 @@ function Collections() { .map((alias) => alias.alias_name); try { const collectionData = await qdrantClient.getCollection(collection.name); + const shardInfo = await getShardInfo(collection.name, collectionData); return { name: collection.name, ...collectionData, + ...shardInfo, aliases: [...collectionAliases], }; } catch (error) { @@ -84,7 +115,7 @@ function Collections() { setRawCollections(null); } }, - [qdrantClient, getErrorMessageWithApiKey] + [qdrantClient, getErrorMessageWithApiKey, getShardInfo] ); const getFilteredCollectionsCall = useCallback( @@ -97,9 +128,11 @@ function Collections() { filtered.map(async (collection) => { try { const collectionData = await qdrantClient.getCollection(collection.name); + const shardInfo = await getShardInfo(collection.name, collectionData); return { name: collection.name, ...collectionData, + ...shardInfo, }; } catch (error) { return { @@ -118,7 +151,7 @@ function Collections() { setRawCollections(null); } }, - [collections, qdrantClient, getErrorMessageWithApiKey] + [collections, qdrantClient, getErrorMessageWithApiKey, getShardInfo] ); useEffect(() => { @@ -150,8 +183,9 @@ function Collections() { setIsRefreshing(true); try { const collectionData = await qdrantClient.getCollection(collectionName); + const shardInfo = await getShardInfo(collectionName, collectionData); setRawCollections((prev) => - prev.map((c) => (c.name === collectionName ? { ...c, ...collectionData, error: null } : c)) + prev.map((c) => (c.name === collectionName ? { ...c, ...collectionData, ...shardInfo, error: null } : c)) ); setErrorMessage(null); } catch (error) { @@ -161,7 +195,7 @@ function Collections() { setIsRefreshing(false); } }, - [qdrantClient, getErrorMessageWithApiKey] + [qdrantClient, getErrorMessageWithApiKey, getShardInfo] ); const handlePageChange = (event, value) => {