Topic statistics (#2413)

* Topic statistics

* Typo

* Code smell

* Specs

* Specs

* Use timestamp helper

* Improve coverage

* styling
This commit is contained in:
Oleg Shur 2022-08-09 16:13:03 +03:00 committed by GitHub
parent 757bf9526e
commit 7765a268af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1896 additions and 134 deletions

View file

@ -12,6 +12,7 @@
"@reduxjs/toolkit": "^1.8.3", "@reduxjs/toolkit": "^1.8.3",
"@szhsin/react-menu": "^3.1.1", "@szhsin/react-menu": "^3.1.1",
"@tanstack/react-query": "^4.0.5", "@tanstack/react-query": "^4.0.5",
"@tanstack/react-table": "^8.5.10",
"@testing-library/react": "^13.2.0", "@testing-library/react": "^13.2.0",
"@types/testing-library__jest-dom": "^5.14.5", "@types/testing-library__jest-dom": "^5.14.5",
"@types/yup": "^0.29.13", "@types/yup": "^0.29.13",
@ -31,6 +32,7 @@
"react-ace": "^10.1.0", "react-ace": "^10.1.0",
"react-datepicker": "^4.8.0", "react-datepicker": "^4.8.0",
"react-dom": "^18.1.0", "react-dom": "^18.1.0",
"react-error-boundary": "^3.1.4",
"react-hook-form": "7.6.9", "react-hook-form": "7.6.9",
"react-hot-toast": "^2.3.0", "react-hot-toast": "^2.3.0",
"react-is": "^18.2.0", "react-is": "^18.2.0",

View file

@ -14,6 +14,7 @@ specifiers:
'@reduxjs/toolkit': ^1.8.3 '@reduxjs/toolkit': ^1.8.3
'@szhsin/react-menu': ^3.1.1 '@szhsin/react-menu': ^3.1.1
'@tanstack/react-query': ^4.0.5 '@tanstack/react-query': ^4.0.5
'@tanstack/react-table': ^8.5.10
'@testing-library/dom': ^8.11.1 '@testing-library/dom': ^8.11.1
'@testing-library/jest-dom': ^5.16.4 '@testing-library/jest-dom': ^5.16.4
'@testing-library/react': ^13.2.0 '@testing-library/react': ^13.2.0
@ -67,6 +68,7 @@ specifiers:
react-ace: ^10.1.0 react-ace: ^10.1.0
react-datepicker: ^4.8.0 react-datepicker: ^4.8.0
react-dom: ^18.1.0 react-dom: ^18.1.0
react-error-boundary: ^3.1.4
react-hook-form: 7.6.9 react-hook-form: 7.6.9
react-hot-toast: ^2.3.0 react-hot-toast: ^2.3.0
react-is: ^18.2.0 react-is: ^18.2.0
@ -96,6 +98,7 @@ dependencies:
'@reduxjs/toolkit': 1.8.3_ctm756ikdwcjcvyfxxwskzbr6q '@reduxjs/toolkit': 1.8.3_ctm756ikdwcjcvyfxxwskzbr6q
'@szhsin/react-menu': 3.1.1_ef5jwxihqo6n7gxfmzogljlgcm '@szhsin/react-menu': 3.1.1_ef5jwxihqo6n7gxfmzogljlgcm
'@tanstack/react-query': 4.0.5_ef5jwxihqo6n7gxfmzogljlgcm '@tanstack/react-query': 4.0.5_ef5jwxihqo6n7gxfmzogljlgcm
'@tanstack/react-table': 8.5.10_ef5jwxihqo6n7gxfmzogljlgcm
'@testing-library/react': 13.2.0_ef5jwxihqo6n7gxfmzogljlgcm '@testing-library/react': 13.2.0_ef5jwxihqo6n7gxfmzogljlgcm
'@types/testing-library__jest-dom': 5.14.5 '@types/testing-library__jest-dom': 5.14.5
'@types/yup': 0.29.13 '@types/yup': 0.29.13
@ -115,6 +118,7 @@ dependencies:
react-ace: 10.1.0_ef5jwxihqo6n7gxfmzogljlgcm react-ace: 10.1.0_ef5jwxihqo6n7gxfmzogljlgcm
react-datepicker: 4.8.0_ef5jwxihqo6n7gxfmzogljlgcm react-datepicker: 4.8.0_ef5jwxihqo6n7gxfmzogljlgcm
react-dom: 18.1.0_react@18.1.0 react-dom: 18.1.0_react@18.1.0
react-error-boundary: 3.1.4_react@18.1.0
react-hook-form: 7.6.9_react@18.1.0 react-hook-form: 7.6.9_react@18.1.0
react-hot-toast: 2.3.0_ef5jwxihqo6n7gxfmzogljlgcm react-hot-toast: 2.3.0_ef5jwxihqo6n7gxfmzogljlgcm
react-is: 18.2.0 react-is: 18.2.0
@ -2394,6 +2398,23 @@ packages:
use-sync-external-store: 1.2.0_react@18.1.0 use-sync-external-store: 1.2.0_react@18.1.0
dev: false dev: false
/@tanstack/react-table/8.5.10_ef5jwxihqo6n7gxfmzogljlgcm:
resolution: {integrity: sha512-TG+iyqtZD5/N7gCDNM8HJc+ZWbUAkSjv8JaVqk2eYs4xaTUfPnTTsG0vJYqGkoxp8i2GFY78dRx1FDCsctYPGA==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16'
react-dom: '>=16'
dependencies:
'@tanstack/table-core': 8.5.10
react: 18.1.0
react-dom: 18.1.0_react@18.1.0
dev: false
/@tanstack/table-core/8.5.10:
resolution: {integrity: sha512-L1GU/BAF7k50vfk1qDvHkRLhEKSjE46EtCuWRrbdu2UKP4mKClTEeL4/zMr6iefMo8QgWa+Gc0CTVVfYcFLlLA==}
engines: {node: '>=12'}
dev: false
/@testing-library/dom/8.13.0: /@testing-library/dom/8.13.0:
resolution: {integrity: sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==} resolution: {integrity: sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -6619,6 +6640,16 @@ packages:
react: 18.1.0 react: 18.1.0
scheduler: 0.22.0 scheduler: 0.22.0
/react-error-boundary/3.1.4_react@18.1.0:
resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==}
engines: {node: '>=10', npm: '>=6'}
peerDependencies:
react: '>=16.13.1'
dependencies:
'@babel/runtime': 7.17.9
react: 18.1.0
dev: false
/react-fast-compare/3.2.0: /react-fast-compare/3.2.0:
resolution: {integrity: sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==} resolution: {integrity: sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==}

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { SchemaSubject } from 'generated-sources'; import { SchemaSubject } from 'generated-sources';
import { clusterSchemaSchemaComparePath, ClusterSubjectParam } from 'lib/paths'; import { clusterSchemaComparePath, ClusterSubjectParam } from 'lib/paths';
import PageLoader from 'components/common/PageLoader/PageLoader'; import PageLoader from 'components/common/PageLoader/PageLoader';
import DiffViewer from 'components/common/DiffViewer/DiffViewer'; import DiffViewer from 'components/common/DiffViewer/DiffViewer';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
@ -86,7 +86,7 @@ const Diff: React.FC<DiffProps> = ({ versions, areVersionsFetched }) => {
} }
onChange={(event) => { onChange={(event) => {
navigate( navigate(
clusterSchemaSchemaComparePath(clusterName, subject) clusterSchemaComparePath(clusterName, subject)
); );
searchParams.set('leftVersion', event.toString()); searchParams.set('leftVersion', event.toString());
searchParams.set( searchParams.set(
@ -127,7 +127,7 @@ const Diff: React.FC<DiffProps> = ({ versions, areVersionsFetched }) => {
} }
onChange={(event) => { onChange={(event) => {
navigate( navigate(
clusterSchemaSchemaComparePath(clusterName, subject) clusterSchemaComparePath(clusterName, subject)
); );
searchParams.set( searchParams.set(
'leftVersion', 'leftVersion',

View file

@ -2,13 +2,13 @@ import React from 'react';
import Diff, { DiffProps } from 'components/Schemas/Diff/Diff'; import Diff, { DiffProps } from 'components/Schemas/Diff/Diff';
import { render, WithRoute } from 'lib/testHelpers'; import { render, WithRoute } from 'lib/testHelpers';
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { clusterSchemaSchemaComparePath } from 'lib/paths'; import { clusterSchemaComparePath } from 'lib/paths';
import { versions } from './fixtures'; import { versions } from './fixtures';
const defaultClusterName = 'defaultClusterName'; const defaultClusterName = 'defaultClusterName';
const defaultSubject = 'defaultSubject'; const defaultSubject = 'defaultSubject';
const defaultPathName = clusterSchemaSchemaComparePath( const defaultPathName = clusterSchemaComparePath(
defaultClusterName, defaultClusterName,
defaultSubject defaultSubject
); );
@ -30,7 +30,7 @@ describe('Diff', () => {
pathname = `${pathname}?${searchParams.toString()}`; pathname = `${pathname}?${searchParams.toString()}`;
return render( return render(
<WithRoute path={clusterSchemaSchemaComparePath()}> <WithRoute path={clusterSchemaComparePath()}>
<Diff <Diff
versions={props.versions} versions={props.versions}
areVersionsFetched={props.areVersionsFetched} areVersionsFetched={props.areVersionsFetched}

View file

@ -7,6 +7,7 @@ import {
clusterTopicConsumerGroupsRelativePath, clusterTopicConsumerGroupsRelativePath,
clusterTopicEditRelativePath, clusterTopicEditRelativePath,
clusterTopicSendMessageRelativePath, clusterTopicSendMessageRelativePath,
clusterTopicStatisticsRelativePath,
} from 'lib/paths'; } from 'lib/paths';
import ClusterContext from 'components/contexts/ClusterContext'; import ClusterContext from 'components/contexts/ClusterContext';
import PageHeading from 'components/common/PageHeading/PageHeading'; import PageHeading from 'components/common/PageHeading/PageHeading';
@ -33,6 +34,7 @@ import Messages from './Messages/Messages';
import Overview from './Overview/Overview'; import Overview from './Overview/Overview';
import Settings from './Settings/Settings'; import Settings from './Settings/Settings';
import TopicConsumerGroups from './ConsumerGroups/TopicConsumerGroups'; import TopicConsumerGroups from './ConsumerGroups/TopicConsumerGroups';
import Statistics from './Statistics/Statistics';
const HeaderControlsWrapper = styled.div` const HeaderControlsWrapper = styled.div`
display: flex; display: flex;
@ -164,6 +166,12 @@ const Details: React.FC = () => {
> >
Settings Settings
</NavLink> </NavLink>
<NavLink
to={clusterTopicStatisticsRelativePath}
className={({ isActive }) => (isActive ? 'is-active' : '')}
>
Statistics
</NavLink>
</Navbar> </Navbar>
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>
<Routes> <Routes>
@ -180,6 +188,10 @@ const Details: React.FC = () => {
path={clusterTopicConsumerGroupsRelativePath} path={clusterTopicConsumerGroupsRelativePath}
element={<TopicConsumerGroups />} element={<TopicConsumerGroups />}
/> />
<Route
path={clusterTopicStatisticsRelativePath}
element={<Statistics />}
/>
</Routes> </Routes>
</Suspense> </Suspense>
</div> </div>

View file

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import dayjs from 'dayjs';
import { TopicMessage } from 'generated-sources'; import { TopicMessage } from 'generated-sources';
import useDataSaver from 'lib/hooks/useDataSaver'; import useDataSaver from 'lib/hooks/useDataSaver';
import MessageToggleIcon from 'components/common/Icons/MessageToggleIcon'; import MessageToggleIcon from 'components/common/Icons/MessageToggleIcon';
import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper'; import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper';
import styled from 'styled-components'; import styled from 'styled-components';
import { Dropdown, DropdownItem } from 'components/common/Dropdown'; import { Dropdown, DropdownItem } from 'components/common/Dropdown';
import { formatTimestamp } from 'lib/dateTimeHelpers';
import MessageContent from './MessageContent/MessageContent'; import MessageContent from './MessageContent/MessageContent';
import * as S from './MessageContent/MessageContent.styled'; import * as S from './MessageContent/MessageContent.styled';
@ -64,7 +64,7 @@ const Message: React.FC<Props> = ({
<td>{offset}</td> <td>{offset}</td>
<td>{partition}</td> <td>{partition}</td>
<td> <td>
<div>{dayjs(timestamp).format('MM.DD.YYYY HH:mm:ss')}</div> <div>{formatTimestamp(timestamp)}</div>
</td> </td>
<StyledDataCell title={key}>{key}</StyledDataCell> <StyledDataCell title={key}>{key}</StyledDataCell>
<StyledDataCell> <StyledDataCell>

View file

@ -2,7 +2,7 @@ import { TopicMessageTimestampTypeEnum, SchemaType } from 'generated-sources';
import React from 'react'; import React from 'react';
import EditorViewer from 'components/common/EditorViewer/EditorViewer'; import EditorViewer from 'components/common/EditorViewer/EditorViewer';
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted'; import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
import dayjs from 'dayjs'; import { formatTimestamp } from 'lib/dateTimeHelpers';
import * as S from './MessageContent.styled'; import * as S from './MessageContent.styled';
@ -94,9 +94,7 @@ const MessageContent: React.FC<MessageContentProps> = ({
<S.Metadata> <S.Metadata>
<S.MetadataLabel>Timestamp</S.MetadataLabel> <S.MetadataLabel>Timestamp</S.MetadataLabel>
<span> <span>
<S.MetadataValue> <S.MetadataValue>{formatTimestamp(timestamp)}</S.MetadataValue>
{dayjs(timestamp).format('MM.DD.YYYY HH:mm:ss')}
</S.MetadataValue>
<S.MetadataMeta>Timestamp type: {timestampType}</S.MetadataMeta> <S.MetadataMeta>Timestamp type: {timestampType}</S.MetadataMeta>
</span> </span>
</S.Metadata> </S.Metadata>

View file

@ -5,8 +5,8 @@ import Message, {
} from 'components/Topics/Topic/Details/Messages/Message'; } from 'components/Topics/Topic/Details/Messages/Message';
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { render } from 'lib/testHelpers'; import { render } from 'lib/testHelpers';
import dayjs from 'dayjs';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { formatTimestamp } from 'lib/dateTimeHelpers';
const messageContentText = 'messageContentText'; const messageContentText = 'messageContentText';
@ -50,9 +50,7 @@ describe('Message component', () => {
expect(screen.getByText(mockMessage.content as string)).toBeInTheDocument(); expect(screen.getByText(mockMessage.content as string)).toBeInTheDocument();
expect(screen.getByText(mockMessage.key as string)).toBeInTheDocument(); expect(screen.getByText(mockMessage.key as string)).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText(formatTimestamp(mockMessage.timestamp))
dayjs(mockMessage.timestamp).format('MM.DD.YYYY HH:mm:ss')
)
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText(mockMessage.offset.toString())).toBeInTheDocument(); expect(screen.getByText(mockMessage.offset.toString())).toBeInTheDocument();
expect( expect(

View file

@ -0,0 +1,44 @@
import React from 'react';
import * as Metrics from 'components/common/Metrics';
import { TopicAnalysisSizeStats } from 'generated-sources';
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
const SizeStats: React.FC<{
stats: TopicAnalysisSizeStats;
title: string;
}> = ({
stats: { sum, min, max, avg, prctl50, prctl75, prctl95, prctl99, prctl999 },
title,
}) => (
<Metrics.Section title={title}>
<Metrics.Indicator label="Total size">
<BytesFormatted value={sum} />
</Metrics.Indicator>
<Metrics.Indicator label="Min size">
<BytesFormatted value={min} />
</Metrics.Indicator>
<Metrics.Indicator label="Max size">
<BytesFormatted value={max} />
</Metrics.Indicator>
<Metrics.Indicator label="Avg key">
<BytesFormatted value={avg} />
</Metrics.Indicator>
<Metrics.Indicator label="Percentile 50">
<BytesFormatted value={prctl50} />
</Metrics.Indicator>
<Metrics.Indicator label="Percentile 75">
<BytesFormatted value={prctl75} />
</Metrics.Indicator>
<Metrics.Indicator label="Percentile 95">
<BytesFormatted value={prctl95} />
</Metrics.Indicator>
<Metrics.Indicator label="Percentile 99">
<BytesFormatted value={prctl99} />
</Metrics.Indicator>
<Metrics.Indicator label="Percentile 999">
<BytesFormatted value={prctl999} />
</Metrics.Indicator>
</Metrics.Section>
);
export default SizeStats;

View file

@ -0,0 +1,42 @@
import React from 'react';
import * as Metrics from 'components/common/Metrics';
import { TopicAnalysisStats } from 'generated-sources';
import { formatTimestamp } from 'lib/dateTimeHelpers';
const Total: React.FC<TopicAnalysisStats> = ({
totalMsgs,
minOffset,
maxOffset,
minTimestamp,
maxTimestamp,
nullKeys,
nullValues,
approxUniqKeys,
approxUniqValues,
}) => (
<Metrics.Section title="Messages">
<Metrics.Indicator label="Total number">{totalMsgs}</Metrics.Indicator>
<Metrics.Indicator label="Offsets min-max">
{`${minOffset} - ${maxOffset}`}
</Metrics.Indicator>
<Metrics.Indicator label="Timestamp min-max">
{`${formatTimestamp(minTimestamp)} - ${formatTimestamp(maxTimestamp)}`}
</Metrics.Indicator>
<Metrics.Indicator label="Null keys">{nullKeys}</Metrics.Indicator>
<Metrics.Indicator
label="Uniq keys"
title="Approximate number of unique keys"
>
{approxUniqKeys}
</Metrics.Indicator>
<Metrics.Indicator label="Null values">{nullValues}</Metrics.Indicator>
<Metrics.Indicator
label="Uniq values"
title="Approximate number of unique values"
>
{approxUniqValues}
</Metrics.Indicator>
</Metrics.Section>
);
export default Total;

View file

@ -0,0 +1,106 @@
import React from 'react';
import {
useAnalyzeTopic,
useCancelTopicAnalysis,
useTopicAnalysis,
} from 'lib/hooks/api/topics';
import useAppParams from 'lib/hooks/useAppParams';
import { RouteParamsClusterTopic } from 'lib/paths';
import { Button } from 'components/common/Button/Button';
import * as Informers from 'components/common/Metrics';
import ProgressBar from 'components/common/ProgressBar/ProgressBar';
import {
List,
Label,
} from 'components/common/PropertiesList/PropertiesList.styled';
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
import { formatTimestamp } from 'lib/dateTimeHelpers';
import * as S from './Statistics.styles';
import Total from './Indicators/Total';
import SizeStats from './Indicators/SizeStats';
import PartitionTable from './PartitionTable';
const Metrics: React.FC = () => {
const params = useAppParams<RouteParamsClusterTopic>();
const [isAnalyzing, setIsAnalyzing] = React.useState(true);
const analyzeTopic = useAnalyzeTopic(params);
const cancelTopicAnalysis = useCancelTopicAnalysis(params);
const { data } = useTopicAnalysis(params, isAnalyzing);
React.useEffect(() => {
if (data && !data.progress) {
setIsAnalyzing(false);
}
}, [data]);
if (!data) {
return null;
}
if (data.progress) {
return (
<S.ProgressContainer>
<ProgressBar completed={data.progress.completenessPercent || 0} />
<Button
onClick={async () => {
await cancelTopicAnalysis.mutateAsync();
setIsAnalyzing(true);
}}
buttonType="primary"
buttonSize="M"
>
Stop Analysis
</Button>
<List>
<Label>Started at</Label>
<span>{formatTimestamp(data.progress.startedAt, 'hh:mm:ss a')}</span>
<Label>Scanned messages</Label>
<span>
{data.progress.msgsScanned} /{' '}
<BytesFormatted value={data.progress.bytesScanned} />
</span>
</List>
</S.ProgressContainer>
);
}
if (!data.result) {
return null;
}
const totalStats = data.result.totalStats || {};
const partitionStats = data.result.partitionStats || [];
return (
<>
<S.ActionsBar>
<S.CreatedAt>{formatTimestamp(data.result.finishedAt)}</S.CreatedAt>
<Button
onClick={async () => {
await analyzeTopic.mutateAsync();
setIsAnalyzing(true);
}}
buttonType="primary"
buttonSize="S"
>
Restart Analysis
</Button>
</S.ActionsBar>
<Informers.Wrapper>
<Total {...totalStats} />
{totalStats.keySize && (
<SizeStats stats={totalStats.keySize} title="Key size" />
)}
{totalStats.valueSize && (
<SizeStats stats={totalStats.valueSize} title="Value size" />
)}
</Informers.Wrapper>
<PartitionTable data={partitionStats} />
</>
);
};
export default Metrics;

View file

@ -0,0 +1,100 @@
import { Row } from '@tanstack/react-table';
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
import Heading from 'components/common/heading/Heading.styled';
import {
List,
Label,
} from 'components/common/PropertiesList/PropertiesList.styled';
import { TopicAnalysisStats } from 'generated-sources';
import { formatTimestamp } from 'lib/dateTimeHelpers';
import React from 'react';
import * as S from './Statistics.styles';
const PartitionInfoRow: React.FC<{ row: Row<TopicAnalysisStats> }> = ({
row,
}) => {
const {
totalMsgs,
minTimestamp,
maxTimestamp,
nullKeys,
nullValues,
approxUniqKeys,
approxUniqValues,
keySize,
valueSize,
} = row.original;
return (
<S.PartitionInfo>
<div>
<Heading level={4}>Partition stats</Heading>
<List>
<Label>Total message</Label>
<span>{totalMsgs}</span>
<Label>Min. timestamp</Label>
<span>{formatTimestamp(minTimestamp)}</span>
<Label>Max. timestamp</Label>
<span>{formatTimestamp(maxTimestamp)}</span>
<Label>Null keys amount</Label>
<span>{nullKeys}</span>
<Label>Null values amount</Label>
<span>{nullValues}</span>
<Label>Approx. unique keys amount</Label>
<span>{approxUniqKeys}</span>
<Label>Approx. unique values amount</Label>
<span>{approxUniqValues}</span>
</List>
</div>
<div>
<Heading level={4}>Keys sizes</Heading>
<List>
<Label>Total keys size</Label>
<BytesFormatted value={keySize?.sum} />
<Label>Min key size</Label>
<BytesFormatted value={keySize?.min} />
<Label>Max key size</Label>
<BytesFormatted value={keySize?.max} />
<Label>Avg key size</Label>
<BytesFormatted value={keySize?.avg} />
<Label>Percentile 50</Label>
<BytesFormatted value={keySize?.prctl50} />
<Label>Percentile 75</Label>
<BytesFormatted value={keySize?.prctl75} />
<Label>Percentile 95</Label>
<BytesFormatted value={keySize?.prctl95} />
<Label>Percentile 99</Label>
<BytesFormatted value={keySize?.prctl99} />
<Label>Percentile 999</Label>
<BytesFormatted value={keySize?.prctl999} />
</List>
</div>
<div>
<Heading level={4}>Values sizes</Heading>
<List>
<Label>Total keys size</Label>
<BytesFormatted value={valueSize?.sum} />
<Label>Min key size</Label>
<BytesFormatted value={valueSize?.min} />
<Label>Max key size</Label>
<BytesFormatted value={valueSize?.max} />
<Label>Avg key size</Label>
<BytesFormatted value={valueSize?.avg} />
<Label>Percentile 50</Label>
<BytesFormatted value={valueSize?.prctl50} />
<Label>Percentile 75</Label>
<BytesFormatted value={valueSize?.prctl75} />
<Label>Percentile 95</Label>
<BytesFormatted value={valueSize?.prctl95} />
<Label>Percentile 99</Label>
<BytesFormatted value={valueSize?.prctl99} />
<Label>Percentile 999</Label>
<BytesFormatted value={valueSize?.prctl999} />
</List>
</div>
</S.PartitionInfo>
);
};
export default PartitionInfoRow;

View file

@ -0,0 +1,40 @@
/* eslint-disable react/no-unstable-nested-components */
import React from 'react';
import { TopicAnalysisStats } from 'generated-sources';
import { ColumnDef } from '@tanstack/react-table';
import Table from 'components/common/NewTable';
import PartitionInfoRow from './PartitionInfoRow';
const PartitionTable: React.FC<{ data: TopicAnalysisStats[] }> = ({ data }) => {
const columns = React.useMemo<ColumnDef<TopicAnalysisStats>[]>(
() => [
{
header: 'Partition ID',
accessorKey: 'partition',
},
{
header: 'Total Messages',
accessorKey: 'totalMsgs',
},
{
header: 'Min Offset',
accessorKey: 'minOffset',
},
{ header: 'Max Offset', accessorKey: 'maxOffset' },
],
[]
);
return (
<Table
data={data}
columns={columns}
getRowCanExpand={() => true}
renderSubComponent={PartitionInfoRow}
enableSorting
/>
);
};
export default PartitionTable;

View file

@ -0,0 +1,44 @@
import {
Label,
List,
} from 'components/common/PropertiesList/PropertiesList.styled';
import styled from 'styled-components';
export const ProgressContainer = styled.div`
padding: 1.5rem 1rem;
background: ${({ theme }) => theme.code.backgroundColor};
justify-content: center;
align-items: center;
display: flex;
flex-direction: column;
height: 300px;
text-align: center;
${List} {
opacity: 0.5;
${Label} {
text-align: right;
}
}
`;
export const ActionsBar = styled.div`
display: flex;
justify-content: end;
gap: 8px;
padding: 10px 20px;
align-items: center;
`;
export const CreatedAt = styled.div`
font-size: 12px;
line-height: 1.5;
color: ${({ theme }) => theme.statictics.createdAtColor};
`;
export const PartitionInfo = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
column-gap: 24px;
`;

View file

@ -0,0 +1,44 @@
/* eslint-disable react/no-unstable-nested-components */
import React from 'react';
import { useAnalyzeTopic } from 'lib/hooks/api/topics';
import useAppParams from 'lib/hooks/useAppParams';
import { RouteParamsClusterTopic } from 'lib/paths';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { Button } from 'components/common/Button/Button';
import * as S from './Statistics.styles';
import Metrics from './Metrics';
const Statistics: React.FC = () => {
const params = useAppParams<RouteParamsClusterTopic>();
const analyzeTopic = useAnalyzeTopic(params);
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<S.ProgressContainer>
<Button
onClick={async () => {
await analyzeTopic.mutateAsync();
resetErrorBoundary();
}}
buttonType="primary"
buttonSize="M"
>
Start Analysis
</Button>
</S.ProgressContainer>
)}
>
<Metrics />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
};
export default Statistics;

View file

@ -0,0 +1,121 @@
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { render, WithRoute } from 'lib/testHelpers';
import Statistics from 'components/Topics/Topic/Details/Statistics/Statistics';
import { clusterTopicStatisticsPath } from 'lib/paths';
import {
useTopicAnalysis,
useCancelTopicAnalysis,
useAnalyzeTopic,
} from 'lib/hooks/api/topics';
import { topicStatsPayload } from 'lib/fixtures/topics';
import userEvent from '@testing-library/user-event';
const clusterName = 'local';
const topicName = 'topic';
jest.mock('lib/hooks/api/topics', () => ({
...jest.requireActual('lib/hooks/api/topics'),
useTopicAnalysis: jest.fn(),
useCancelTopicAnalysis: jest.fn(),
useAnalyzeTopic: jest.fn(),
}));
describe('Metrics', () => {
const renderComponent = () => {
const path = clusterTopicStatisticsPath(clusterName, topicName);
return render(
<WithRoute path={clusterTopicStatisticsPath()}>
<Statistics />
</WithRoute>,
{ initialEntries: [path] }
);
};
describe('when analysis is in progress', () => {
const cancelMock = jest.fn();
beforeEach(() => {
(useCancelTopicAnalysis as jest.Mock).mockImplementation(() => ({
mutateAsync: cancelMock,
}));
(useTopicAnalysis as jest.Mock).mockImplementation(() => ({
data: {
progress: {
...topicStatsPayload.progress,
completenessPercent: undefined,
},
result: undefined,
},
}));
renderComponent();
});
it('renders Stop Analysis button', () => {
const btn = screen.getByRole('button', { name: 'Stop Analysis' });
expect(btn).toBeInTheDocument();
userEvent.click(btn);
expect(cancelMock).toHaveBeenCalled();
});
it('renders Progress bar', () => {
const progressbar = screen.getByRole('progressbar');
expect(progressbar).toBeInTheDocument();
expect(progressbar).toHaveStyleRule('width', '0%');
});
});
describe('when analysis is completed', () => {
const restartMock = jest.fn();
beforeEach(() => {
(useTopicAnalysis as jest.Mock).mockImplementation(() => ({
data: { ...topicStatsPayload, progress: undefined },
}));
(useAnalyzeTopic as jest.Mock).mockImplementation(() => ({
mutateAsync: restartMock,
}));
renderComponent();
});
it('renders metrics', async () => {
const btn = screen.getByRole('button', { name: 'Restart Analysis' });
expect(btn).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.getAllByRole('group').length).toEqual(3);
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('renders restarts analisis', async () => {
const btn = screen.getByRole('button', { name: 'Restart Analysis' });
await waitFor(() => userEvent.click(btn));
expect(restartMock).toHaveBeenCalled();
});
it('renders expandable table', async () => {
expect(screen.getByRole('table')).toBeInTheDocument();
const rows = screen.getAllByRole('row');
expect(rows.length).toEqual(3);
const btns = screen.getAllByRole('button', { name: 'Expand row' });
expect(btns.length).toEqual(2);
expect(screen.queryByText('Partition stats')).not.toBeInTheDocument();
userEvent.click(btns[0]);
expect(screen.getAllByText('Partition stats').length).toEqual(1);
userEvent.click(btns[1]);
expect(screen.getAllByText('Partition stats').length).toEqual(2);
});
});
it('returns empty container', () => {
(useTopicAnalysis as jest.Mock).mockImplementation(() => ({
data: undefined,
}));
renderComponent();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
it('returns empty container', () => {
(useTopicAnalysis as jest.Mock).mockImplementation(() => ({
data: {},
}));
renderComponent();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,35 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { render, WithRoute } from 'lib/testHelpers';
import Statistics from 'components/Topics/Topic/Details/Statistics/Statistics';
import { clusterTopicStatisticsPath } from 'lib/paths';
import { useTopicAnalysis } from 'lib/hooks/api/topics';
const clusterName = 'local';
const topicName = 'topic';
jest.mock('lib/hooks/api/topics', () => ({
...jest.requireActual('lib/hooks/api/topics'),
useTopicAnalysis: jest.fn(),
}));
describe('Statistics', () => {
const renderComponent = () => {
const path = clusterTopicStatisticsPath(clusterName, topicName);
return render(
<WithRoute path={clusterTopicStatisticsPath()}>
<Statistics />
</WithRoute>,
{ initialEntries: [path] }
);
};
it('renders Metricks component', () => {
(useTopicAnalysis as jest.Mock).mockImplementation(() => ({
data: { result: 1 },
}));
renderComponent();
expect(screen.getByText('Restart Analysis')).toBeInTheDocument();
});
});

View file

@ -4,7 +4,15 @@ import userEvent from '@testing-library/user-event';
import ClusterContext from 'components/contexts/ClusterContext'; import ClusterContext from 'components/contexts/ClusterContext';
import Details from 'components/Topics/Topic/Details/Details'; import Details from 'components/Topics/Topic/Details/Details';
import { render, WithRoute } from 'lib/testHelpers'; import { render, WithRoute } from 'lib/testHelpers';
import { clusterTopicEditRelativePath, clusterTopicPath } from 'lib/paths'; import {
clusterTopicConsumerGroupsPath,
clusterTopicEditRelativePath,
clusterTopicMessagesPath,
clusterTopicPath,
clusterTopicSettingsPath,
clusterTopicStatisticsPath,
getNonExactPath,
} from 'lib/paths';
import { CleanUpPolicy, Topic } from 'generated-sources'; import { CleanUpPolicy, Topic } from 'generated-sources';
import { externalTopicPayload } from 'lib/fixtures/topics'; import { externalTopicPayload } from 'lib/fixtures/topics';
import { import {
@ -32,19 +40,34 @@ jest.mock('lib/hooks/redux', () => ({
useAppDispatch: useDispatchMock, useAppDispatch: useDispatchMock,
})); }));
jest.mock('components/Topics/Topic/Details/Overview/Overview', () => () => (
<>OverviewMock</>
));
jest.mock('components/Topics/Topic/Details/Messages/Messages', () => () => (
<>MessagesMock</>
));
jest.mock('components/Topics/Topic/Details/Settings/Settings', () => () => (
<>SettingsMock</>
));
jest.mock(
'components/Topics/Topic/Details/ConsumerGroups/TopicConsumerGroups',
() => () => <>ConsumerGroupsMock</>
);
jest.mock('components/Topics/Topic/Details/Statistics/Statistics', () => () => (
<>StatisticsMock</>
));
const mockDelete = jest.fn(); const mockDelete = jest.fn();
const mockRecreate = jest.fn(); const mockRecreate = jest.fn();
const mockClusterName = 'local';
describe('Details', () => { const topic: Topic = {
const mockClusterName = 'local';
const topic: Topic = {
...externalTopicPayload, ...externalTopicPayload,
cleanUpPolicy: CleanUpPolicy.DELETE, cleanUpPolicy: CleanUpPolicy.DELETE,
}; };
const defaultPath = clusterTopicPath(mockClusterName, topic.name);
const renderComponent = (isReadOnly = false) => { describe('Details', () => {
const path = clusterTopicPath(mockClusterName, topic.name); const renderComponent = (isReadOnly = false, path = defaultPath) => {
render( render(
<ClusterContext.Provider <ClusterContext.Provider
value={{ value={{
@ -54,7 +77,7 @@ describe('Details', () => {
isTopicDeletionAllowed: true, isTopicDeletionAllowed: true,
}} }}
> >
<WithRoute path={clusterTopicPath()}> <WithRoute path={getNonExactPath(clusterTopicPath())}>
<Details /> <Details />
</WithRoute> </WithRoute>
</ClusterContext.Provider>, </ClusterContext.Provider>,
@ -73,7 +96,7 @@ describe('Details', () => {
mutateAsync: mockRecreate, mutateAsync: mockRecreate,
})); }));
}); });
describe('Action Bar', () => {
describe('when it has readonly flag', () => { describe('when it has readonly flag', () => {
it('does not render the Action button a Topic', () => { it('does not render the Action button a Topic', () => {
renderComponent(true); renderComponent(true);
@ -138,7 +161,9 @@ describe('Details', () => {
renderComponent(); renderComponent();
const deleteTopicButton = screen.getByText(/Remove topic/i); const deleteTopicButton = screen.getByText(/Remove topic/i);
await waitFor(() => userEvent.click(deleteTopicButton)); await waitFor(() => userEvent.click(deleteTopicButton));
const submitDeleteButton = screen.getByRole('button', { name: 'Confirm' }); const submitDeleteButton = screen.getByRole('button', {
name: 'Confirm',
});
await act(() => userEvent.click(submitDeleteButton)); await act(() => userEvent.click(submitDeleteButton));
expect(mockNavigate).toHaveBeenCalledWith('../..'); expect(mockNavigate).toHaveBeenCalledWith('../..');
}); });
@ -162,7 +187,7 @@ describe('Details', () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('calling recreation function after click on Submit button', async () => { it('is calling recreation function after click on Submit button', async () => {
renderComponent(); renderComponent();
const recreateTopicButton = screen.getByText(/Recreate topic/i); const recreateTopicButton = screen.getByText(/Recreate topic/i);
userEvent.click(recreateTopicButton); userEvent.click(recreateTopicButton);
@ -172,7 +197,7 @@ describe('Details', () => {
expect(mockRecreate).toBeCalledTimes(1); expect(mockRecreate).toBeCalledTimes(1);
}); });
it('close popup confirmation window after click on Cancel button', () => { it('closes popup confirmation window after click on Cancel button', () => {
renderComponent(); renderComponent();
const recreateTopicButton = screen.getByText(/Recreate topic/i); const recreateTopicButton = screen.getByText(/Recreate topic/i);
userEvent.click(recreateTopicButton); userEvent.click(recreateTopicButton);
@ -182,4 +207,49 @@ describe('Details', () => {
screen.queryByText(/Are you sure want to recreate topic?/i) screen.queryByText(/Are you sure want to recreate topic?/i)
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
});
describe('Internal routing', () => {
const itExpectsCorrectPageRendered = (
path: string,
tab: string,
selector: string
) => {
renderComponent(false, path);
expect(screen.getByText(tab)).toHaveClass('is-active');
expect(screen.getByText(selector)).toBeInTheDocument();
};
it('renders Overview tab by default', () => {
itExpectsCorrectPageRendered(defaultPath, 'Overview', 'OverviewMock');
});
it('renders Messages tabs', () => {
itExpectsCorrectPageRendered(
clusterTopicMessagesPath(),
'Messages',
'MessagesMock'
);
});
it('renders Consumers tab', () => {
itExpectsCorrectPageRendered(
clusterTopicConsumerGroupsPath(),
'Consumers',
'ConsumerGroupsMock'
);
});
it('renders Settings tab', () => {
itExpectsCorrectPageRendered(
clusterTopicSettingsPath(),
'Settings',
'SettingsMock'
);
});
it('renders Statistics tab', () => {
itExpectsCorrectPageRendered(
clusterTopicStatisticsPath(),
'Statistics',
'StatisticsMock'
);
});
});
}); });

View file

@ -115,7 +115,7 @@ const SendMessage: React.FC = () => {
message: ( message: (
<ul> <ul>
{errors.map((e) => ( {errors.map((e) => (
<li>{e}</li> <li key={e}>{e}</li>
))} ))}
</ul> </ul>
), ),

View file

@ -43,7 +43,7 @@ export const IndicatorsWrapper = styled.div`
export const SectionTitle = styled.h5` export const SectionTitle = styled.h5`
font-weight: 500; font-weight: 500;
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 16px;
font-size: 100%; font-size: 100%;
`; `;

View file

@ -0,0 +1,33 @@
import { CellContext } from '@tanstack/react-table';
import React from 'react';
import * as S from './Table.styled';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ExpanderCell: React.FC<CellContext<any, unknown>> = ({ row }) => (
<S.ExpaderButton
width="16"
height="20"
viewBox="0 -2 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="button"
aria-label="Expand row"
>
{row.getIsExpanded() ? (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14 16C15.1046 16 16 15.1046 16 14L16 2C16 0.895431 15.1046 -7.8281e-08 14 -1.74846e-07L2 -1.22392e-06C0.895432 -1.32048e-06 1.32048e-06 0.895429 1.22392e-06 2L1.74846e-07 14C7.8281e-08 15.1046 0.895431 16 2 16L14 16ZM5 7C4.44772 7 4 7.44771 4 8C4 8.55228 4.44772 9 5 9L11 9C11.5523 9 12 8.55228 12 8C12 7.44772 11.5523 7 11 7L5 7Z"
/>
) : (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 2C0 0.895431 0.895431 0 2 0H14C15.1046 0 16 0.895431 16 2V14C16 15.1046 15.1046 16 14 16H2C0.895431 16 0 15.1046 0 14V2ZM8 4C8.55229 4 9 4.44772 9 5V7H11C11.5523 7 12 7.44772 12 8C12 8.55229 11.5523 9 11 9H9V11C9 11.5523 8.55229 12 8 12C7.44772 12 7 11.5523 7 11V9H5C4.44772 9 4 8.55228 4 8C4 7.44771 4.44772 7 5 7H7V5C7 4.44772 7.44772 4 8 4Z"
/>
)}
</S.ExpaderButton>
);
export default ExpanderCell;

View file

@ -0,0 +1,174 @@
import styled from 'styled-components';
export const ExpaderButton = styled.svg(
({ theme: { table } }) => `
& > path {
fill: ${table.expander.normal};
&:hover {
fill: ${table.expander.hover};
}
}
`
);
interface ThProps {
sortable?: boolean;
sortOrder?: 'desc' | 'asc' | false;
expander?: boolean;
}
const sortableMixin = (normalColor: string, hoverColor: string) => `
cursor: pointer;
padding-right: 18px;
position: relative;
&::before,
&::after {
border: 4px solid transparent;
content: '';
display: block;
height: 0;
right: 5px;
top: 50%;
position: absolute;
}
&::before {
border-bottom-color: ${normalColor};
margin-top: -9px;
}
&::after {
border-top-color: ${normalColor};
margin-top: 1px;
}
&:hover {
color: ${hoverColor};
}
`;
const ASCMixin = (color: string) => `
color: ${color};
&:before {
border-bottom-color: ${color};
}
&:after {
border-top-color: rgba(0, 0, 0, 0.2);
}
`;
const DESCMixin = (color: string) => `
color: ${color};
&:before {
border-bottom-color: rgba(0, 0, 0, 0.2);
}
&:after {
border-top-color: ${color};
}
`;
export const Th = styled.th<ThProps>(
({ theme: { table }, sortable, sortOrder, expander }) => `
padding: 4px 0 4px 24px;
border-bottom-width: 1px;
vertical-align: middle;
text-align: left;
font-family: Inter, sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: 0em;
text-align: left;
background: ${table.th.backgroundColor.normal};
width: ${expander ? '5px' : 'auto'};
& > div {
cursor: default;
color: ${table.th.color.normal};
${
sortable ? sortableMixin(table.th.color.normal, table.th.color.hover) : ''
}
${sortable && sortOrder === 'asc' && ASCMixin(table.th.color.active)}
${sortable && sortOrder === 'desc' && DESCMixin(table.th.color.active)}
}
`
);
interface RowProps {
expandable?: boolean;
expanded?: boolean;
}
export const Row = styled.tr<RowProps>(
({ theme: { table }, expanded, expandable }) => `
cursor: ${expandable ? 'pointer' : 'default'};
background-color: ${table.tr.backgroundColor[expanded ? 'hover' : 'normal']};
&:hover {
background-color: ${table.tr.backgroundColor.hover};
}
`
);
export const ExpandedRowInfo = styled.div`
background-color: ${({ theme }) => theme.table.tr.backgroundColor.normal};
padding: 24px;
border-radius: 8px;
margin: 0 8px 8px 0;
`;
export const Nowrap = styled.div`
white-space: nowrap;
`;
export const Table = styled.table(
({ theme: { table } }) => `
width: 100%;
td {
border-top: 1px #f1f2f3 solid;
font-size: 14px;
font-weight: 400;
padding: 8px 8px 8px 24px;
color: ${table.td.color.normal};
vertical-align: middle;
max-width: 350px;
word-wrap: break-word;
& > a {
color: ${table.link.color};
font-weight: 500;
text-overflow: ellipsis;
}
}
`
);
export const Pagination = styled.div`
display: flex;
justify-content: space-between;
padding: 16px;
line-height: 32px;
`;
export const Pages = styled.div`
display: flex;
justify-content: left;
white-space: nowrap;
flex-wrap: nowrap;
gap: 8px;
`;
export const GoToPage = styled.label`
display: flex;
flex-wrap: nowrap;
gap: 8px;
margin-left: 8px;
`;
export const PageInfo = styled.div`
display: flex;
justify-content: right;
gap: 8px;
font-size: 14px;
flex-wrap: nowrap;
white-space: nowrap;
margin-left: 16px;
`;

View file

@ -0,0 +1,253 @@
import React from 'react';
import {
flexRender,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
ColumnDef,
Row,
SortingState,
OnChangeFn,
PaginationState,
getPaginationRowModel,
} from '@tanstack/react-table';
import { useSearchParams } from 'react-router-dom';
import { PER_PAGE } from 'lib/constants';
import { Button } from 'components/common/Button/Button';
import Input from 'components/common/Input/Input';
import * as S from './Table.styled';
import updateSortingState from './utils/updateSortingState';
import updatePaginationState from './utils/updatePaginationState';
import ExpanderCell from './ExpanderCell';
interface TableProps<TData> {
data: TData[];
pageCount?: number;
columns: ColumnDef<TData>[];
renderSubComponent?: React.FC<{ row: Row<TData> }>;
getRowCanExpand?: (row: Row<TData>) => boolean;
serverSideProcessing?: boolean;
enableSorting?: boolean;
}
type UpdaterFn<T> = (previousState: T) => T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Table: React.FC<TableProps<any>> = ({
data,
pageCount,
columns,
getRowCanExpand,
renderSubComponent,
serverSideProcessing = false,
enableSorting = false,
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const [sorting, setSorting] = React.useState<SortingState>([]);
const [{ pageIndex, pageSize }, setPagination] =
React.useState<PaginationState>({
pageIndex: 0,
pageSize: PER_PAGE,
});
const onSortingChange = React.useCallback(
(updater: UpdaterFn<SortingState>) => {
const newState = updateSortingState(updater, searchParams);
setSearchParams(searchParams);
setSorting(newState);
return newState;
},
[searchParams]
);
const onPaginationChange = React.useCallback(
(updater: UpdaterFn<PaginationState>) => {
const newState = updatePaginationState(updater, searchParams);
setSearchParams(searchParams);
setPagination(newState);
return newState;
},
[searchParams]
);
React.useEffect(() => {
const sortBy = searchParams.get('sortBy');
const sortDirection = searchParams.get('sortDirection');
const page = searchParams.get('page');
const perPage = searchParams.get('perPage');
if (sortBy) {
setSorting([
{
id: sortBy,
desc: sortDirection === 'desc',
},
]);
} else {
setSorting([]);
}
if (page || perPage) {
setPagination({
pageIndex: Number(page || 0),
pageSize: Number(perPage || PER_PAGE),
});
}
}, []);
const pagination = React.useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
);
const table = useReactTable({
data,
pageCount,
columns,
state: {
sorting,
pagination,
},
onSortingChange: onSortingChange as OnChangeFn<SortingState>,
onPaginationChange: onPaginationChange as OnChangeFn<PaginationState>,
getRowCanExpand,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualSorting: serverSideProcessing,
manualPagination: serverSideProcessing,
enableSorting,
});
return (
<>
<S.Table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{table.getCanSomeRowsExpand() && (
<S.Th expander key={`${headerGroup.id}-expander`} />
)}
{headerGroup.headers.map((header) => (
<S.Th
key={header.id}
colSpan={header.colSpan}
sortable={header.column.getCanSort()}
sortOrder={header.column.getIsSorted()}
onClick={header.column.getToggleSortingHandler()}
>
<div>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
</S.Th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<React.Fragment key={row.id}>
<S.Row
expandable={row.getCanExpand()}
expanded={row.getIsExpanded()}
onClick={() => row.getCanExpand() && row.toggleExpanded()}
>
{row.getCanExpand() && (
<td key={`${row.id}-expander`}>
{flexRender(
ExpanderCell,
row.getVisibleCells()[0].getContext()
)}
</td>
)}
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</S.Row>
{row.getIsExpanded() && renderSubComponent && (
<S.Row expanded>
<td colSpan={row.getVisibleCells().length + 1}>
<S.ExpandedRowInfo>
{renderSubComponent({ row })}
</S.ExpandedRowInfo>
</td>
</S.Row>
)}
</React.Fragment>
))}
</tbody>
</S.Table>
{table.getPageCount() > 1 && (
<S.Pagination>
<S.Pages>
<Button
buttonType="secondary"
buttonSize="M"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
</Button>
<Button
buttonType="secondary"
buttonSize="M"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
buttonType="secondary"
buttonSize="M"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
<Button
buttonType="secondary"
buttonSize="M"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
</Button>
<S.GoToPage>
<span>Go to page:</span>
<Input
type="number"
defaultValue={table.getState().pagination.pageIndex + 1}
inputSize="M"
max={table.getPageCount()}
min={1}
onChange={(e) => {
const page = e.target.value ? Number(e.target.value) - 1 : 0;
table.setPageIndex(page);
}}
/>
</S.GoToPage>
</S.Pages>
<S.PageInfo>
<span>
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}{' '}
</span>
</S.PageInfo>
</S.Pagination>
)}
</>
);
};
export default Table;

View file

@ -0,0 +1,12 @@
import { CellContext } from '@tanstack/react-table';
import { formatTimestamp } from 'lib/dateTimeHelpers';
import React from 'react';
import * as S from './Table.styled';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TimestampCell: React.FC<CellContext<any, any>> = ({ getValue }) => (
<S.Nowrap>{formatTimestamp(getValue())}</S.Nowrap>
);
export default TimestampCell;

View file

@ -0,0 +1,181 @@
import React from 'react';
import { render, WithRoute } from 'lib/testHelpers';
import Table, { TimestampCell } from 'components/common/NewTable';
import { screen, waitFor } from '@testing-library/dom';
import { ColumnDef } from '@tanstack/react-table';
import userEvent from '@testing-library/user-event';
import { formatTimestamp } from 'lib/dateTimeHelpers';
import { act } from '@testing-library/react';
const data = [
{ timestamp: 1660034383725, text: 'lorem' },
{ timestamp: 1660034399999, text: 'ipsum' },
{ timestamp: 1660034399922, text: 'dolor' },
{ timestamp: 1660034199922, text: 'sit' },
];
type Datum = typeof data[0];
const columns: ColumnDef<Datum>[] = [
{
header: 'DateTime',
accessorKey: 'timestamp',
cell: TimestampCell,
},
{
header: 'Text',
accessorKey: 'text',
},
];
const ExpandedRow: React.FC = () => <div>I am expanded row</div>;
interface Props {
path?: string;
canExpand?: boolean;
}
const renderComponent = ({ path, canExpand }: Props = {}) => {
render(
<WithRoute path="/">
<Table
columns={columns}
data={data}
renderSubComponent={ExpandedRow}
getRowCanExpand={() => !!canExpand}
enableSorting
/>
</WithRoute>,
{ initialEntries: [path || ''] }
);
};
describe('Table', () => {
it('renders table', () => {
renderComponent();
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('renders TimestampCell', () => {
renderComponent();
expect(
screen.getByText(formatTimestamp(data[0].timestamp))
).toBeInTheDocument();
});
describe('ExpanderCell', () => {
it('renders button', () => {
renderComponent({ canExpand: true });
const btns = screen.getAllByRole('button', { name: 'Expand row' });
expect(btns.length).toEqual(data.length);
expect(screen.queryByText('I am expanded row')).not.toBeInTheDocument();
userEvent.click(btns[2]);
expect(screen.getByText('I am expanded row')).toBeInTheDocument();
userEvent.click(btns[0]);
expect(screen.getAllByText('I am expanded row').length).toEqual(2);
});
it('does not render button', () => {
renderComponent({ canExpand: false });
expect(
screen.queryByRole('button', { name: 'Expand row' })
).not.toBeInTheDocument();
expect(screen.queryByText('I am expanded row')).not.toBeInTheDocument();
});
});
describe('Pagination', () => {
it('does not render page buttons', () => {
renderComponent();
expect(
screen.queryByRole('button', { name: 'Next' })
).not.toBeInTheDocument();
});
it('renders page buttons', async () => {
renderComponent({ path: '?perPage=1' });
// Check it renders header row and only one data row
expect(screen.getAllByRole('row').length).toEqual(2);
expect(screen.getByText('lorem')).toBeInTheDocument();
// Check it renders page buttons
const firstBtn = screen.getByRole('button', { name: '⇤' });
const prevBtn = screen.getByRole('button', { name: '← Previous' });
const nextBtn = screen.getByRole('button', { name: 'Next →' });
const lastBtn = screen.getByRole('button', { name: '⇥' });
expect(firstBtn).toBeInTheDocument();
expect(firstBtn).toBeDisabled();
expect(prevBtn).toBeInTheDocument();
expect(prevBtn).toBeDisabled();
expect(nextBtn).toBeInTheDocument();
expect(nextBtn).toBeEnabled();
expect(lastBtn).toBeInTheDocument();
expect(lastBtn).toBeEnabled();
userEvent.click(nextBtn);
expect(screen.getByText('ipsum')).toBeInTheDocument();
expect(prevBtn).toBeEnabled();
expect(firstBtn).toBeEnabled();
userEvent.click(lastBtn);
expect(screen.getByText('sit')).toBeInTheDocument();
expect(lastBtn).toBeDisabled();
expect(nextBtn).toBeDisabled();
userEvent.click(prevBtn);
expect(screen.getByText('dolor')).toBeInTheDocument();
userEvent.click(firstBtn);
expect(screen.getByText('lorem')).toBeInTheDocument();
});
it('renders go to page input', async () => {
renderComponent({ path: '?perPage=1' });
// Check it renders header row and only one data row
expect(screen.getAllByRole('row').length).toEqual(2);
expect(screen.getByText('lorem')).toBeInTheDocument();
const input = screen.getByRole('spinbutton', { name: 'Go to page:' });
expect(input).toBeInTheDocument();
userEvent.clear(input);
userEvent.type(input, '2');
expect(screen.getByText('ipsum')).toBeInTheDocument();
});
});
describe('Sorting', () => {
it('sort rows', async () => {
await act(() =>
renderComponent({ path: '/?sortBy=text&&sortDirection=desc' })
);
expect(screen.getAllByRole('row').length).toEqual(data.length + 1);
const th = screen.getByRole('columnheader', { name: 'Text' });
expect(th).toBeInTheDocument();
let rows = [];
// Check initial sort order by text column is descending
rows = screen.getAllByRole('row');
expect(rows[4].textContent?.indexOf('dolor')).toBeGreaterThan(-1);
expect(rows[3].textContent?.indexOf('ipsum')).toBeGreaterThan(-1);
expect(rows[2].textContent?.indexOf('lorem')).toBeGreaterThan(-1);
expect(rows[1].textContent?.indexOf('sit')).toBeGreaterThan(-1);
// Disable sorting by text column
await waitFor(() => userEvent.click(th));
rows = screen.getAllByRole('row');
expect(rows[1].textContent?.indexOf('lorem')).toBeGreaterThan(-1);
expect(rows[2].textContent?.indexOf('ipsum')).toBeGreaterThan(-1);
expect(rows[3].textContent?.indexOf('dolor')).toBeGreaterThan(-1);
expect(rows[4].textContent?.indexOf('sit')).toBeGreaterThan(-1);
// Sort by text column ascending
await waitFor(() => userEvent.click(th));
rows = screen.getAllByRole('row');
expect(rows[1].textContent?.indexOf('dolor')).toBeGreaterThan(-1);
expect(rows[2].textContent?.indexOf('ipsum')).toBeGreaterThan(-1);
expect(rows[3].textContent?.indexOf('lorem')).toBeGreaterThan(-1);
expect(rows[4].textContent?.indexOf('sit')).toBeGreaterThan(-1);
});
});
});

View file

@ -0,0 +1,7 @@
import Table from './Table';
import TimestampCell from './TimestampCell';
import ExpanderCell from './ExpanderCell';
export { TimestampCell, ExpanderCell };
export default Table;

View file

@ -0,0 +1,34 @@
import updateSortingState from 'components/common/NewTable/utils/updateSortingState';
import { SortingState } from '@tanstack/react-table';
import compact from 'lodash/compact';
const updater = (previousState: SortingState): SortingState => {
return compact(
previousState.map(({ id, desc }) => {
if (!id) return null;
return { id, desc: !desc };
})
);
};
describe('updateSortingState', () => {
it('should update the sorting state', () => {
const searchParams = new URLSearchParams();
searchParams.set('sortBy', 'date');
searchParams.set('sortDirection', 'desc');
const newState = updateSortingState(updater, searchParams);
expect(searchParams.get('sortBy')).toBe('date');
expect(searchParams.get('sortDirection')).toBe('asc');
expect(newState.length).toBe(1);
expect(newState[0].id).toBe('date');
expect(newState[0].desc).toBe(false);
});
it('should update the sorting state', () => {
const searchParams = new URLSearchParams();
const newState = updateSortingState(updater, searchParams);
expect(searchParams.get('sortBy')).toBeNull();
expect(searchParams.get('sortDirection')).toBeNull();
expect(newState.length).toBe(0);
});
});

View file

@ -0,0 +1,27 @@
import { PaginationState } from '@tanstack/react-table';
import { PER_PAGE } from 'lib/constants';
type UpdaterFn<T> = (previousState: T) => T;
export default (
updater: UpdaterFn<PaginationState>,
searchParams: URLSearchParams
) => {
const previousState: PaginationState = {
pageIndex: Number(searchParams.get('page') || 0),
pageSize: Number(searchParams.get('perPage') || PER_PAGE),
};
const newState = updater(previousState);
if (newState.pageIndex !== 0) {
searchParams.set('page', newState.pageIndex.toString());
} else {
searchParams.delete('page');
}
if (newState.pageSize !== PER_PAGE) {
searchParams.set('perPage', newState.pageSize.toString());
} else {
searchParams.delete('perPage');
}
return newState;
};

View file

@ -0,0 +1,26 @@
import { SortingState } from '@tanstack/react-table';
type UpdaterFn<T> = (previousState: T) => T;
export default (
updater: UpdaterFn<SortingState>,
searchParams: URLSearchParams
) => {
const previousState: SortingState = [
{
id: searchParams.get('sortBy') || '',
desc: searchParams.get('sortDirection') === 'desc',
},
];
const newState = updater(previousState);
if (newState.length > 0) {
const { id, desc } = newState[0];
searchParams.set('sortBy', id);
searchParams.set('sortDirection', desc ? 'desc' : 'asc');
} else {
searchParams.delete('sortBy');
searchParams.delete('sortDirection');
}
return newState;
};

View file

@ -0,0 +1,22 @@
import styled, { css } from 'styled-components';
export const Wrapper = styled.div`
height: 10px;
width: '100%';
min-width: 200px;
background-color: ${({ theme }) => theme.progressBar.backgroundColor};
border-radius: 5px;
margin: 16px;
border: 1px solid ${({ theme }) => theme.progressBar.borderColor};
`;
export const Filler = styled.div<{ completed: number }>(
({ theme: { progressBar }, completed }) => css`
height: 100%;
width: ${completed}%;
background-color: ${progressBar.compleatedColor};
border-radius: 5px;
text-align: 'right';
transition: width 1.2s linear;
`
);

View file

@ -0,0 +1,18 @@
import React from 'react';
import * as S from './ProgressBar.styled';
interface ProgressBarProps {
completed: number;
}
const ProgressBar: React.FC<ProgressBarProps> = ({ completed }) => {
const p = Math.max(Math.min(completed, 100), 0);
return (
<S.Wrapper>
<S.Filler role="progressbar" completed={p} />
</S.Wrapper>
);
};
export default ProgressBar;

View file

@ -0,0 +1,23 @@
import React from 'react';
import { render } from 'lib/testHelpers';
import ProgressBar from 'components/common/ProgressBar/ProgressBar';
import { screen } from '@testing-library/dom';
describe('Progressbar', () => {
const itRendersCorrectPercentage = (completed: number, expected: number) => {
it('renders correct percentage', () => {
render(<ProgressBar completed={completed} />);
const bar = screen.getByRole('progressbar');
expect(bar).toHaveStyleRule('width', `${expected}%`);
});
};
[
[-143, 0],
[0, 0],
[67, 67],
[143, 100],
].forEach(([completed, expected]) =>
itRendersCorrectPercentage(completed, expected)
);
});

View file

@ -0,0 +1,17 @@
import styled from 'styled-components';
export const List = styled.div`
display: grid;
grid-template-columns: repeat(2, max-content);
gap: 8px;
column-gap: 24px;
margin-top: 16px;
text-align: left;
`;
export const Label = styled.div`
font-size: 14px;
font-weight: 500;
color: ${({ theme }) => theme.list.label.color};
white-space: nowrap;
`;

View file

@ -16,6 +16,10 @@ describe('Paths', () => {
`${GIT_REPO_LINK}/commit/1234567gh` `${GIT_REPO_LINK}/commit/1234567gh`
); );
}); });
it('getNonExactPath', () => {
expect(paths.getNonExactPath('')).toEqual('/*');
expect(paths.getNonExactPath('/clusters')).toEqual('/clusters/*');
});
it('clusterPath', () => { it('clusterPath', () => {
expect(paths.clusterPath(clusterName)).toEqual( expect(paths.clusterPath(clusterName)).toEqual(
`/ui/clusters/${clusterName}` `/ui/clusters/${clusterName}`
@ -117,6 +121,17 @@ describe('Paths', () => {
paths.clusterSchemaEditPath(RouteParams.clusterName, RouteParams.subject) paths.clusterSchemaEditPath(RouteParams.clusterName, RouteParams.subject)
); );
}); });
it('clusterSchemaComparePath', () => {
expect(paths.clusterSchemaComparePath(clusterName, schemaId)).toEqual(
`${paths.clusterSchemaPath(clusterName, schemaId)}/compare`
);
expect(paths.clusterSchemaComparePath()).toEqual(
paths.clusterSchemaComparePath(
RouteParams.clusterName,
RouteParams.subject
)
);
});
it('clusterTopicsPath', () => { it('clusterTopicsPath', () => {
expect(paths.clusterTopicsPath(clusterName)).toEqual( expect(paths.clusterTopicsPath(clusterName)).toEqual(
@ -194,6 +209,25 @@ describe('Paths', () => {
paths.clusterTopicEditPath(RouteParams.clusterName, RouteParams.topicName) paths.clusterTopicEditPath(RouteParams.clusterName, RouteParams.topicName)
); );
}); });
it('clusterTopicCopyPath', () => {
expect(paths.clusterTopicCopyPath(clusterName)).toEqual(
`${paths.clusterTopicsPath(clusterName)}/copy`
);
expect(paths.clusterTopicCopyPath()).toEqual(
paths.clusterTopicCopyPath(RouteParams.clusterName)
);
});
it('clusterTopicStatisticsPath', () => {
expect(paths.clusterTopicStatisticsPath(clusterName, topicId)).toEqual(
`${paths.clusterTopicPath(clusterName, topicId)}/statistics`
);
expect(paths.clusterTopicStatisticsPath()).toEqual(
paths.clusterTopicStatisticsPath(
RouteParams.clusterName,
RouteParams.topicName
)
);
});
it('clusterConnectsPath', () => { it('clusterConnectsPath', () => {
expect(paths.clusterConnectsPath(clusterName)).toEqual( expect(paths.clusterConnectsPath(clusterName)).toEqual(
@ -331,4 +365,20 @@ describe('Paths', () => {
paths.clusterKsqlDbQueryPath(RouteParams.clusterName) paths.clusterKsqlDbQueryPath(RouteParams.clusterName)
); );
}); });
it('clusterKsqlDbTablesPath', () => {
expect(paths.clusterKsqlDbTablesPath(clusterName)).toEqual(
`${paths.clusterKsqlDbPath(clusterName)}/tables`
);
expect(paths.clusterKsqlDbTablesPath()).toEqual(
paths.clusterKsqlDbTablesPath(RouteParams.clusterName)
);
});
it('clusterKsqlDbStreamsPath', () => {
expect(paths.clusterKsqlDbStreamsPath(clusterName)).toEqual(
`${paths.clusterKsqlDbPath(clusterName)}/streams`
);
expect(paths.clusterKsqlDbStreamsPath()).toEqual(
paths.clusterKsqlDbStreamsPath(RouteParams.clusterName)
);
});
}); });

View file

@ -0,0 +1,12 @@
import dayjs from 'dayjs';
export const formatTimestamp = (
timestamp: number | string | Date | undefined,
format = 'MM.DD.YY hh:mm:ss a'
): string => {
if (!timestamp) {
return '';
}
return dayjs(timestamp).format(format);
};

View file

@ -5,6 +5,7 @@ import {
Topic, Topic,
TopicConfig, TopicConfig,
MessageSchemaSourceEnum, MessageSchemaSourceEnum,
TopicAnalysis,
} from 'generated-sources'; } from 'generated-sources';
export const internalTopicPayload = { export const internalTopicPayload = {
@ -208,3 +209,77 @@ export const topicMessageSchema = {
`, `,
}, },
}; };
const topicStatsSize = {
sum: 0,
avg: 0,
prctl50: 0,
prctl75: 0,
prctl95: 0,
prctl99: 0,
prctl999: 0,
};
export const topicStatsPayload: TopicAnalysis = {
progress: {
startedAt: 1659984559167,
completenessPercent: 43,
msgsScanned: 18077002,
bytesScanned: 6750901718,
},
result: {
startedAt: 1659984559095,
finishedAt: 1659984617816,
totalStats: {
totalMsgs: 18194715,
minOffset: 98869591,
maxOffset: 100576010,
minTimestamp: 1659719759485,
maxTimestamp: 1659984603419,
nullKeys: 18194715,
nullValues: 0,
approxUniqKeys: 0,
approxUniqValues: 17817283,
keySize: topicStatsSize,
valueSize: topicStatsSize,
hourlyMsgCounts: [
{ hourStart: 1659718800000, count: 16157 },
{ hourStart: 1659722400000, count: 225790 },
],
},
partitionStats: [
{
partition: 0,
totalMsgs: 1515285,
minOffset: 99060726,
maxOffset: 100576010,
minTimestamp: 1659722684090,
maxTimestamp: 1659984603419,
nullKeys: 1515285,
nullValues: 0,
approxUniqKeys: 0,
approxUniqValues: 1515285,
keySize: topicStatsSize,
valueSize: topicStatsSize,
hourlyMsgCounts: [
{ hourStart: 1659722400000, count: 18040 },
{ hourStart: 1659726000000, count: 20070 },
],
},
{
partition: 1,
totalMsgs: 1534422,
minOffset: 98897827,
maxOffset: 100432248,
minTimestamp: 1659722803993,
maxTimestamp: 1659984603416,
nullKeys: 1534422,
nullValues: 0,
approxUniqKeys: 0,
approxUniqValues: 1516431,
keySize: topicStatsSize,
valueSize: topicStatsSize,
hourlyMsgCounts: [{ hourStart: 1659722400000, count: 19058 }],
},
],
},
};

View file

@ -24,6 +24,11 @@ const topicPath = `${topicsPath}/${topicName}`;
const topicParams = { clusterName, topicName }; const topicParams = { clusterName, topicName };
jest.mock('lib/errorHandling', () => ({
...jest.requireActual('lib/errorHandling'),
showServerError: jest.fn(),
}));
describe('Topics hooks', () => { describe('Topics hooks', () => {
beforeEach(() => fetchMock.restore()); beforeEach(() => fetchMock.restore());
it('handles useTopics', async () => { it('handles useTopics', async () => {
@ -57,6 +62,20 @@ describe('Topics hooks', () => {
); );
await expectQueryWorks(mock, result); await expectQueryWorks(mock, result);
}); });
describe('useTopicAnalysis', () => {
it('handles useTopicAnalysis', async () => {
const mock = fetchMock.getOnce(`${topicPath}/analysis`, {});
const { result } = renderQueryHook(() =>
hooks.useTopicAnalysis(topicParams)
);
await expectQueryWorks(mock, result);
});
it('disables useTopicAnalysis', async () => {
const mock = fetchMock.getOnce(`${topicPath}/analysis`, {});
renderQueryHook(() => hooks.useTopicAnalysis(topicParams, false));
expect(mock.calls()).toHaveLength(0);
});
});
describe('mutatations', () => { describe('mutatations', () => {
it('useCreateTopic', async () => { it('useCreateTopic', async () => {
@ -107,7 +126,6 @@ describe('Topics hooks', () => {
await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(mock.calls()).toHaveLength(1); expect(mock.calls()).toHaveLength(1);
}); });
it('useIncreaseTopicPartitionsCount', async () => { it('useIncreaseTopicPartitionsCount', async () => {
const mock = fetchMock.patchOnce(`${topicPath}/partitions`, {}); const mock = fetchMock.patchOnce(`${topicPath}/partitions`, {});
const { result } = renderHook( const { result } = renderHook(
@ -120,7 +138,6 @@ describe('Topics hooks', () => {
await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(mock.calls()).toHaveLength(1); expect(mock.calls()).toHaveLength(1);
}); });
it('useUpdateTopicReplicationFactor', async () => { it('useUpdateTopicReplicationFactor', async () => {
const mock = fetchMock.patchOnce(`${topicPath}/replications`, {}); const mock = fetchMock.patchOnce(`${topicPath}/replications`, {});
const { result } = renderHook( const { result } = renderHook(
@ -133,7 +150,6 @@ describe('Topics hooks', () => {
await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(mock.calls()).toHaveLength(1); expect(mock.calls()).toHaveLength(1);
}); });
it('useDeleteTopic', async () => { it('useDeleteTopic', async () => {
const mock = fetchMock.deleteOnce(topicPath, {}); const mock = fetchMock.deleteOnce(topicPath, {});
const { result } = renderHook(() => hooks.useDeleteTopic(clusterName), { const { result } = renderHook(() => hooks.useDeleteTopic(clusterName), {
@ -145,7 +161,6 @@ describe('Topics hooks', () => {
await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(mock.calls()).toHaveLength(1); expect(mock.calls()).toHaveLength(1);
}); });
it('useRecreateTopic', async () => { it('useRecreateTopic', async () => {
const mock = fetchMock.postOnce(topicPath, {}); const mock = fetchMock.postOnce(topicPath, {});
const { result } = renderHook(() => hooks.useRecreateTopic(topicParams), { const { result } = renderHook(() => hooks.useRecreateTopic(topicParams), {
@ -157,7 +172,6 @@ describe('Topics hooks', () => {
await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(mock.calls()).toHaveLength(1); expect(mock.calls()).toHaveLength(1);
}); });
it('useSendMessage', async () => { it('useSendMessage', async () => {
const mock = fetchMock.postOnce(`${topicPath}/messages`, {}); const mock = fetchMock.postOnce(`${topicPath}/messages`, {});
const { result } = renderHook(() => hooks.useSendMessage(topicParams), { const { result } = renderHook(() => hooks.useSendMessage(topicParams), {
@ -173,5 +187,30 @@ describe('Topics hooks', () => {
await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(mock.calls()).toHaveLength(1); expect(mock.calls()).toHaveLength(1);
}); });
it('useAnalyzeTopic', async () => {
const mock = fetchMock.postOnce(`${topicPath}/analysis`, {});
const { result } = renderHook(() => hooks.useAnalyzeTopic(topicParams), {
wrapper: TestQueryClientProvider,
});
await act(() => {
result.current.mutateAsync();
});
await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(mock.calls()).toHaveLength(1);
});
it('useCancelTopicAnalysis', async () => {
const mock = fetchMock.deleteOnce(`${topicPath}/analysis`, {});
const { result } = renderHook(
() => hooks.useCancelTopicAnalysis(topicParams),
{
wrapper: TestQueryClientProvider,
}
);
await act(() => {
result.current.mutateAsync();
});
await waitFor(() => expect(result.current.isSuccess).toBeTruthy());
expect(mock.calls()).toHaveLength(1);
});
}); });
}); });

View file

@ -36,12 +36,16 @@ export const topicKeys = {
[...topicKeys.details(props), 'schema'] as const, [...topicKeys.details(props), 'schema'] as const,
consumerGroups: (props: GetTopicDetailsRequest) => consumerGroups: (props: GetTopicDetailsRequest) =>
[...topicKeys.details(props), 'consumerGroups'] as const, [...topicKeys.details(props), 'consumerGroups'] as const,
statistics: (props: GetTopicDetailsRequest) =>
[...topicKeys.details(props), 'statistics'] as const,
}; };
export function useTopics(props: GetTopicsRequest) { export function useTopics(props: GetTopicsRequest) {
const { clusterName, ...filters } = props; const { clusterName, ...filters } = props;
return useQuery(topicKeys.list(clusterName, filters), () => return useQuery(
api.getTopics(props) topicKeys.list(clusterName, filters),
() => api.getTopics(props),
{ keepPreviousData: true }
); );
} }
export function useTopicDetails(props: GetTopicDetailsRequest) { export function useTopicDetails(props: GetTopicDetailsRequest) {
@ -236,3 +240,43 @@ export function useSendMessage(props: GetTopicDetailsRequest) {
} }
); );
} }
// Statistics
export function useTopicAnalysis(
props: GetTopicDetailsRequest,
enabled = true
) {
return useQuery(
topicKeys.statistics(props),
() => api.getTopicAnalysis(props),
{
enabled,
refetchInterval: 1000,
useErrorBoundary: true,
retry: false,
suspense: false,
}
);
}
export function useAnalyzeTopic(props: GetTopicDetailsRequest) {
const client = useQueryClient();
return useMutation(() => api.analyzeTopic(props), {
onSuccess: () => {
showSuccessAlert({
message: `Topic analysis successfully started`,
});
client.invalidateQueries(topicKeys.statistics(props));
},
});
}
export function useCancelTopicAnalysis(props: GetTopicDetailsRequest) {
const client = useQueryClient();
return useMutation(() => api.cancelTopicAnalysis(props), {
onSuccess: () => {
showSuccessAlert({
message: `Topic analysis canceled`,
});
client.invalidateQueries(topicKeys.statistics(props));
},
});
}

View file

@ -99,7 +99,7 @@ export const clusterSchemaEditPath = (
clusterName: ClusterName = RouteParams.clusterName, clusterName: ClusterName = RouteParams.clusterName,
subject: SchemaName = RouteParams.subject subject: SchemaName = RouteParams.subject
) => `${clusterSchemasPath(clusterName)}/${subject}/edit`; ) => `${clusterSchemasPath(clusterName)}/${subject}/edit`;
export const clusterSchemaSchemaComparePath = ( export const clusterSchemaComparePath = (
clusterName: ClusterName = RouteParams.clusterName, clusterName: ClusterName = RouteParams.clusterName,
subject: SchemaName = RouteParams.subject subject: SchemaName = RouteParams.subject
) => `${clusterSchemaPath(clusterName, subject)}/compare`; ) => `${clusterSchemaPath(clusterName, subject)}/compare`;
@ -127,6 +127,7 @@ export const clusterTopicCopyPath = (
export const clusterTopicSettingsRelativePath = 'settings'; export const clusterTopicSettingsRelativePath = 'settings';
export const clusterTopicMessagesRelativePath = 'messages'; export const clusterTopicMessagesRelativePath = 'messages';
export const clusterTopicConsumerGroupsRelativePath = 'consumer-groups'; export const clusterTopicConsumerGroupsRelativePath = 'consumer-groups';
export const clusterTopicStatisticsRelativePath = 'statistics';
export const clusterTopicEditRelativePath = 'edit'; export const clusterTopicEditRelativePath = 'edit';
export const clusterTopicSendMessageRelativePath = 'message'; export const clusterTopicSendMessageRelativePath = 'message';
export const clusterTopicPath = ( export const clusterTopicPath = (
@ -162,6 +163,14 @@ export const clusterTopicConsumerGroupsPath = (
clusterName, clusterName,
topicName topicName
)}/${clusterTopicConsumerGroupsRelativePath}`; )}/${clusterTopicConsumerGroupsRelativePath}`;
export const clusterTopicStatisticsPath = (
clusterName: ClusterName = RouteParams.clusterName,
topicName: TopicName = RouteParams.topicName
) =>
`${clusterTopicPath(
clusterName,
topicName
)}/${clusterTopicStatisticsRelativePath}`;
export const clusterTopicSendMessagePath = ( export const clusterTopicSendMessagePath = (
clusterName: ClusterName = RouteParams.clusterName, clusterName: ClusterName = RouteParams.clusterName,
topicName: TopicName = RouteParams.topicName topicName: TopicName = RouteParams.topicName

View file

@ -71,6 +71,16 @@ const theme = {
backgroundColor: Colors.neutral[5], backgroundColor: Colors.neutral[5],
color: Colors.red[55], color: Colors.red[55],
}, },
list: {
label: {
color: Colors.neutral[50],
},
},
progressBar: {
backgroundColor: Colors.neutral[3],
compleatedColor: Colors.green[40],
borderColor: Colors.neutral[10],
},
layout: { layout: {
backgroundColor: Colors.neutral[0], backgroundColor: Colors.neutral[0],
minWidth: '1200px', minWidth: '1200px',
@ -149,6 +159,7 @@ const theme = {
4: { 4: {
fontSize: '14px', fontSize: '14px',
lineHeight: '20px', lineHeight: '20px',
fontWeight: 500,
}, },
5: { 5: {
fontSize: '12px', fontSize: '12px',
@ -308,6 +319,7 @@ const theme = {
}, },
tr: { tr: {
backgroundColor: { backgroundColor: {
normal: Colors.neutral[0],
hover: Colors.neutral[5], hover: Colors.neutral[5],
}, },
}, },
@ -316,6 +328,10 @@ const theme = {
normal: Colors.neutral[90], normal: Colors.neutral[90],
}, },
}, },
expander: {
normal: Colors.brand[50],
hover: Colors.brand[20],
},
}, },
primaryTab: { primaryTab: {
color: { color: {
@ -547,6 +563,9 @@ const theme = {
editFilterText: { editFilterText: {
color: Colors.brand[50], color: Colors.brand[50],
}, },
statictics: {
createdAtColor: Colors.neutral[50],
},
}; };
export type ThemeType = typeof theme; export type ThemeType = typeof theme;