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 (
+
+ }
+ />
+
+
+ );
+};
+
+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);
};