Topic statistics (#2413)
* Topic statistics * Typo * Code smell * Specs * Specs * Use timestamp helper * Improve coverage * styling
This commit is contained in:
parent
757bf9526e
commit
7765a268af
40 changed files with 1896 additions and 134 deletions
|
@ -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",
|
||||||
|
|
31
kafka-ui-react-app/pnpm-lock.yaml
generated
31
kafka-ui-react-app/pnpm-lock.yaml
generated
|
@ -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==}
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||||
|
`;
|
|
@ -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;
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
),
|
),
|
||||||
|
|
|
@ -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%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
||||||
|
`;
|
253
kafka-ui-react-app/src/components/common/NewTable/Table.tsx
Normal file
253
kafka-ui-react-app/src/components/common/NewTable/Table.tsx
Normal 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;
|
|
@ -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;
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,7 @@
|
||||||
|
import Table from './Table';
|
||||||
|
import TimestampCell from './TimestampCell';
|
||||||
|
import ExpanderCell from './ExpanderCell';
|
||||||
|
|
||||||
|
export { TimestampCell, ExpanderCell };
|
||||||
|
|
||||||
|
export default Table;
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
`
|
||||||
|
);
|
|
@ -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;
|
|
@ -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)
|
||||||
|
);
|
||||||
|
});
|
|
@ -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;
|
||||||
|
`;
|
|
@ -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)
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
12
kafka-ui-react-app/src/lib/dateTimeHelpers.ts
Normal file
12
kafka-ui-react-app/src/lib/dateTimeHelpers.ts
Normal 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);
|
||||||
|
};
|
|
@ -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 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Add table
Reference in a new issue