From d53b2d0a578062b12d546dd69c32184aa81343ab Mon Sep 17 00:00:00 2001 From: trean Date: Wed, 30 Aug 2023 14:16:46 +0200 Subject: [PATCH] Collection info tab (#103) * wip collection info tab * wip collection info tab * wip collection info tab, some refactoring, status indicator * added collection cluster info * snackbars * configs upd for tests, test for CollectionClusterInfo * cluster info refactoring * variant heading for CardHeader * format * bug fix --- .eslintrc.yml | 1 + .../CollectionCluster/ClusterInfo.jsx | 68 ++++++++++++++++ .../CollectionCluster/ClusterInfoHead.jsx | 28 +++++++ .../CollectionCluster/ClusterShardRow.jsx | 36 +++++++++ .../collectionCluster.test.jsx | 71 +++++++++++++++++ src/components/Collections/CollectionInfo.jsx | 79 +++++++++++++++++++ src/components/Common/Dot.jsx | 15 ++++ src/components/Points/DataGridList.jsx | 78 ++++++++++++++++++ src/components/Points/PointCard.jsx | 4 +- src/components/Points/PointPayloadList.jsx | 47 ----------- .../VisualizeChart/ViewPointModal.jsx | 4 +- src/pages/Collection.jsx | 21 +++-- src/setupTests.js | 11 ++- src/theme/index.js | 21 ++++- 14 files changed, 423 insertions(+), 61 deletions(-) create mode 100644 src/components/Collections/CollectionCluster/ClusterInfo.jsx create mode 100644 src/components/Collections/CollectionCluster/ClusterInfoHead.jsx create mode 100644 src/components/Collections/CollectionCluster/ClusterShardRow.jsx create mode 100644 src/components/Collections/CollectionCluster/collectionCluster.test.jsx create mode 100644 src/components/Collections/CollectionInfo.jsx create mode 100644 src/components/Common/Dot.jsx create mode 100644 src/components/Points/DataGridList.jsx delete mode 100644 src/components/Points/PointPayloadList.jsx diff --git a/.eslintrc.yml b/.eslintrc.yml index ce7062dd..c1b61443 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -37,3 +37,4 @@ rules: }, ], } +ignorePatterns: ['node_modules/', 'build/', '*.test.js', '*.test.jsx'] \ No newline at end of file diff --git a/src/components/Collections/CollectionCluster/ClusterInfo.jsx b/src/components/Collections/CollectionCluster/ClusterInfo.jsx new file mode 100644 index 00000000..febddae8 --- /dev/null +++ b/src/components/Collections/CollectionCluster/ClusterInfo.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Card, CardHeader, Table, TableBody } from '@mui/material'; +import { CopyButton } from '../../Common/CopyButton'; +import ClusterInfoHead from './ClusterInfoHead'; +import ClusterShardRow from './ClusterShardRow'; + +const ClusterInfo = ({ collectionCluster, ...other }) => { + const shards = [ + ...(collectionCluster.result?.local_shards || []), + ...(collectionCluster.result?.remote_shards || []), + ]; + + const shardRows = shards.map((shard) => ( + + )); + + return ( + + } + /> + + + {shardRows} +
+
+ ); +}; + +ClusterInfo.defaultProps = { + collectionCluster: { + result: {}, + }, +}; + +ClusterInfo.propTypes = { + collectionCluster: PropTypes.shape({ + result: PropTypes.shape({ + peer_id: PropTypes.number, + local_shards: PropTypes.arrayOf( + PropTypes.shape({ + shard_id: PropTypes.number, + state: PropTypes.string, + }) + ), + remote_shards: PropTypes.arrayOf( + PropTypes.shape({ + shard_id: PropTypes.number, + peer_id: PropTypes.number, + state: PropTypes.string, + }) + ), + }), + }).isRequired, + other: PropTypes.object, +}; + +export default ClusterInfo; diff --git a/src/components/Collections/CollectionCluster/ClusterInfoHead.jsx b/src/components/Collections/CollectionCluster/ClusterInfoHead.jsx new file mode 100644 index 00000000..4a0bc1cc --- /dev/null +++ b/src/components/Collections/CollectionCluster/ClusterInfoHead.jsx @@ -0,0 +1,28 @@ +import { TableCell, TableHead, TableRow, Typography } from '@mui/material'; +import React from 'react'; + +const ClusterInfoHead = () => { + return ( + + + + + Shard ID + + + + + Location + + + + + Status + + + + + ); +}; + +export default ClusterInfoHead; diff --git a/src/components/Collections/CollectionCluster/ClusterShardRow.jsx b/src/components/Collections/CollectionCluster/ClusterShardRow.jsx new file mode 100644 index 00000000..95a182f0 --- /dev/null +++ b/src/components/Collections/CollectionCluster/ClusterShardRow.jsx @@ -0,0 +1,36 @@ +import { TableCell, TableRow, Typography } from '@mui/material'; +import React from 'react'; +import PropTypes from 'prop-types'; + +const ClusterShardRow = ({ shard, clusterPeerId }) => { + return ( + + + + {shard.shard_id} + + + + + {shard.peer_id ? `Remote (${shard.peer_id})` : `Local (${clusterPeerId ?? 'unknown'})`} + + + + + {shard.state} + + + + ); +}; + +ClusterShardRow.propTypes = { + shard: PropTypes.shape({ + shard_id: PropTypes.number, + peer_id: PropTypes.number, + state: PropTypes.string, + }).isRequired, + clusterPeerId: PropTypes.number, +}; + +export default ClusterShardRow; diff --git a/src/components/Collections/CollectionCluster/collectionCluster.test.jsx b/src/components/Collections/CollectionCluster/collectionCluster.test.jsx new file mode 100644 index 00000000..a1965b70 --- /dev/null +++ b/src/components/Collections/CollectionCluster/collectionCluster.test.jsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ClusterInfo from './ClusterInfo'; +import ClusterShardRow from './ClusterShardRow'; + +const CLUSTER_INFO = { + result: { + peer_id: 5644950770669488, + shard_count: 3, + local_shards: [ + { + shard_id: 0, + points_count: 62223, + state: 'Active', + }, + { + shard_id: 2, + points_count: 65999, + state: 'Active', + }, + ], + remote_shards: [ + { + shard_id: 0, + peer_id: 5255497362296823, + state: 'Active', + }, + { + shard_id: 1, + peer_id: 5255497362296823, + state: 'Active', + }, + { + shard_id: 1, + peer_id: 8741461806010521, + state: 'Active', + }, + { + shard_id: 2, + peer_id: 8741461806010521, + state: 'Active', + }, + ], + shard_transfers: [], + }, + status: 'ok', + time: 0.00002203, +}; + +describe('collection cluster info', () => { + it('should render ClusterShardRow with given data', () => { + const shard = CLUSTER_INFO.result.remote_shards[0]; + render( + + + + +
+ ); + expect(screen.getByTestId('shard-row').children[0].children[0].textContent).toBe(shard.shard_id.toString()); + expect(screen.getByText(`Remote (${shard.peer_id})`)).toBeTruthy(); + expect(screen.getByText(shard.state)).toBeTruthy(); + }); + + it('should render CollectionClusterInfo with given data', () => { + const shardReplicasCount = CLUSTER_INFO.result.local_shards.length + CLUSTER_INFO.result.remote_shards.length; + render(); + expect(screen.getByText('Collection Cluster Info')).toBeTruthy(); + expect(screen.getAllByTestId('shard-row').length).toBe(shardReplicasCount); + }); +}); diff --git a/src/components/Collections/CollectionInfo.jsx b/src/components/Collections/CollectionInfo.jsx new file mode 100644 index 00000000..061fc18b --- /dev/null +++ b/src/components/Collections/CollectionInfo.jsx @@ -0,0 +1,79 @@ +import React, { memo, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { Box, Card, CardContent, CardHeader, Typography } from '@mui/material'; +import { useClient } from '../../context/client-context'; +import { DataGridList } from '../Points/DataGridList'; +import { CopyButton } from '../Common/CopyButton'; +import { Dot } from '../Common/Dot'; +import ClusterInfo from './CollectionCluster/ClusterInfo'; +import { useSnackbar } from 'notistack'; +import { getSnackbarOptions } from '../Common/utils/snackbarOptions'; + +export const CollectionInfo = ({ collectionName }) => { + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + const { client: qdrantClient } = useClient(); + const [collection, setCollection] = React.useState({}); + const [clusterInfo, setClusterInfo] = React.useState(null); + + useEffect(() => { + qdrantClient + .getCollection(collectionName) + .then((res) => { + setCollection(() => { + return { ...res }; + }); + }) + .catch((err) => { + enqueueSnackbar(err.message, getSnackbarOptions('error', closeSnackbar)); + }); + + qdrantClient + .api('cluster') + .collectionClusterInfo({ collection_name: collectionName }) + .then((res) => { + setClusterInfo(() => { + return { ...res.data }; + }); + }) + .catch((err) => { + enqueueSnackbar(err.message, getSnackbarOptions('error', closeSnackbar)); + }); + }, [collectionName]); + + return ( + + + } + /> + + + {collection.status} + + ), + }} + /> + + + + {clusterInfo && } + + ); +}; + +CollectionInfo.displayName = 'CollectionInfo'; + +CollectionInfo.propTypes = { + collectionName: PropTypes.string.isRequired, +}; + +export default memo(CollectionInfo); diff --git a/src/components/Common/Dot.jsx b/src/components/Common/Dot.jsx new file mode 100644 index 00000000..719278ae --- /dev/null +++ b/src/components/Common/Dot.jsx @@ -0,0 +1,15 @@ +import { styled } from '@mui/material/styles'; + +/** + * Status indicator dot. + * @type {StyledComponent, JSX.IntrinsicElements[string], {}>} + */ +export const Dot = styled('div')( + ({ color }) => ` + border-radius: 50%; + background-color: ${color}; + width: 10px; + height: 10px; + display: inline-block; +` +); diff --git a/src/components/Points/DataGridList.jsx b/src/components/Points/DataGridList.jsx new file mode 100644 index 00000000..8ecf64d1 --- /dev/null +++ b/src/components/Points/DataGridList.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { JsonViewer } from '@textea/json-viewer'; +import { useTheme } from '@mui/material/styles'; +import { Divider, Grid, Typography } from '@mui/material'; + +/** + * A list of key-value pairs, where the value is either a string or an object. + * if the value is an object, it will be rendered as a JSON tree. + * @param {Object} data - key-value pairs to render + * @param {Object} specialCases - key-value pairs to render, where the value is JSX element + * @return {unknown[]} - array of JSX elements + */ +export const DataGridList = function ({ data = {}, specialCases = {} }) { + const theme = useTheme(); + const specialKeys = Object.keys(specialCases) || []; + + return Object.keys(data).map((key) => { + return ( +
+ + + + {key} + + + + + {/* special cases */} + {specialKeys?.includes(key) && specialCases[key]} + + {/* objects */} + {typeof data[key] === 'object' && !specialKeys.includes(key) && ( + + {' '} + {' '} + + )} + + {/* other types of values */} + {typeof data[key] !== 'object' && !specialKeys.includes(key) && ( + + {'\t'} {data[key].toString()} + + )} + + + +
+ ); + }); +}; + +DataGridList.defaultProps = { + data: {}, + specialCases: {}, +}; + +DataGridList.propTypes = { + data: PropTypes.object.isRequired, + specialCases: PropTypes.shape({ + key: PropTypes.string, + value: PropTypes.element, + }), +}; diff --git a/src/components/Points/PointCard.jsx b/src/components/Points/PointCard.jsx index a325575d..31b09ad3 100644 --- a/src/components/Points/PointCard.jsx +++ b/src/components/Points/PointCard.jsx @@ -10,7 +10,7 @@ import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; import Vectors from './PointVectors'; import { PayloadEditor } from './PayloadEditor'; -import { PointPayloadList } from './PointPayloadList'; +import { DataGridList } from './DataGridList'; import { CopyButton } from '../Common/CopyButton'; const PointCard = (props) => { @@ -92,7 +92,7 @@ const PointCard = (props) => { - + {point.payload && ( diff --git a/src/components/Points/PointPayloadList.jsx b/src/components/Points/PointPayloadList.jsx deleted file mode 100644 index 50a81126..00000000 --- a/src/components/Points/PointPayloadList.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { JsonViewer } from '@textea/json-viewer'; -import { useTheme } from '@mui/material/styles'; -import { Divider, Grid, Typography } from '@mui/material'; - -export const PointPayloadList = function ({ data }) { - const theme = useTheme(); - - return Object.keys(data.payload).map((key) => { - return ( -
- - - - {key} - - - - - {typeof data.payload[key] === 'object' ? ( - - {' '} - {' '} - - ) : ( - - {'\t'} {data.payload[key].toString()} - - )} - - - -
- ); - }); -}; - -PointPayloadList.propTypes = { - data: PropTypes.object.isRequired, -}; diff --git a/src/components/VisualizeChart/ViewPointModal.jsx b/src/components/VisualizeChart/ViewPointModal.jsx index c484b30b..ab8e805a 100644 --- a/src/components/VisualizeChart/ViewPointModal.jsx +++ b/src/components/VisualizeChart/ViewPointModal.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { Box, Button, Dialog, DialogContent, DialogActions, DialogTitle, Paper, Typography } from '@mui/material'; import { alpha } from '@mui/material'; import { useTheme } from '@mui/material/styles'; -import { PointPayloadList } from '../Points/PointPayloadList'; +import { DataGridList } from '../Points/DataGridList'; import { CopyButton } from '../Common/CopyButton'; const ViewPointModal = (props) => { @@ -42,7 +42,7 @@ const ViewPointModal = (props) => { - + ))} diff --git a/src/pages/Collection.jsx b/src/pages/Collection.jsx index e92cfd57..6a9910e6 100644 --- a/src/pages/Collection.jsx +++ b/src/pages/Collection.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; import { useClient } from '../context/client-context'; import { Typography, Grid, Button, Tabs, Tab } from '@mui/material'; @@ -8,22 +8,23 @@ import SimilarSerachfield from '../components/Points/SimilarSerachfield'; import { CenteredFrame } from '../components/Common/CenteredFrame'; import Box from '@mui/material/Box'; import { SnapshotsTab } from '../components/Snapshots/SnapshotsTab'; +import CollectionInfo from '../components/Collections/CollectionInfo'; function Collection() { const pageSize = 10; const { collectionName } = useParams(); - const [points, setPoints] = React.useState(null); - const [vector, setVector] = React.useState(null); - const [offset, setOffset] = React.useState(null); + const [points, setPoints] = useState(null); + const [vector, setVector] = useState(null); + const [offset, setOffset] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [recommendationIds, setRecommendationIds] = useState([]); const { client: qdrantClient } = useClient(); const navigate = useNavigate(); const location = useLocation(); - const [currentTab, setCurrentTab] = React.useState(location.hash.slice(1) || 'points'); + const [currentTab, setCurrentTab] = useState(location.hash.slice(1) || 'points'); - const [nextPageOffset, setNextPageOffset] = React.useState(null); + const [nextPageOffset, setNextPageOffset] = useState(null); const handleTabChange = (event, newValue) => { if (typeof newValue !== 'string') { @@ -42,7 +43,7 @@ function Collection() { } }; - React.useEffect(() => { + useEffect(() => { const getPoints = async () => { if (recommendationIds.length !== 0) { try { @@ -95,12 +96,18 @@ function Collection() { +
+ {currentTab === 'info' && ( + + + + )} {currentTab === 'points' && ( <> diff --git a/src/setupTests.js b/src/setupTests.js index fda2a1cc..098df9be 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1,4 +1,11 @@ -import matchers from '@testing-library/jest-dom/extend-expect'; -import { expect } from 'vitest'; +import { expect, afterEach } from 'vitest'; +import { cleanup } from '@testing-library/react'; +import matchers from '@testing-library/jest-dom/matchers'; +// extends Vitest's expect method with methods from react-testing-library expect.extend(matchers); + +// runs a cleanup after each test case (e.g. clearing jsdom) +afterEach(() => { + cleanup(); +}); diff --git a/src/theme/index.js b/src/theme/index.js index 3b986853..f5c8d524 100644 --- a/src/theme/index.js +++ b/src/theme/index.js @@ -1,7 +1,26 @@ import { createTheme as createMuiTheme } from '@mui/material/styles'; import { darkThemeOptions } from './dark-theme'; import { lightThemeOptions } from './light-theme'; +import { alpha } from '@mui/material'; + +const themeOptions = { + components: { + MuiCardHeader: { + styleOverrides: { + // this adds variant="heading" support + // to the CardHeader component + root: ({ theme, ownerState }) => { + if (ownerState?.variant === 'heading') { + return { + backgroundColor: alpha(theme.palette.primary.main, 0.05), + }; + } + }, + }, + }, + }, +}; export const createTheme = (config) => { - return createMuiTheme(config, config.palette.mode === 'dark' ? darkThemeOptions : lightThemeOptions); + return createMuiTheme(config, themeOptions, config.palette.mode === 'dark' ? darkThemeOptions : lightThemeOptions); };