From 7765a268af412e0045f6815cc024aaa7567519a8 Mon Sep 17 00:00:00 2001 From: Oleg Shur Date: Tue, 9 Aug 2022 16:13:03 +0300 Subject: [PATCH] Topic statistics (#2413) * Topic statistics * Typo * Code smell * Specs * Specs * Use timestamp helper * Improve coverage * styling --- kafka-ui-react-app/package.json | 2 + kafka-ui-react-app/pnpm-lock.yaml | 31 ++ .../src/components/Schemas/Diff/Diff.tsx | 6 +- .../Schemas/Diff/__test__/Diff.spec.tsx | 6 +- .../Topics/Topic/Details/Details.tsx | 12 + .../Topics/Topic/Details/Messages/Message.tsx | 4 +- .../MessageContent/MessageContent.tsx | 6 +- .../Messages/__test__/Message.spec.tsx | 6 +- .../Statistics/Indicators/SizeStats.tsx | 44 +++ .../Details/Statistics/Indicators/Total.tsx | 42 +++ .../Topic/Details/Statistics/Metrics.tsx | 106 +++++++ .../Details/Statistics/PartitionInfoRow.tsx | 100 ++++++ .../Details/Statistics/PartitionTable.tsx | 40 +++ .../Details/Statistics/Statistics.styles.ts | 44 +++ .../Topic/Details/Statistics/Statistics.tsx | 44 +++ .../Statistics/__test__/Metrics.spec.tsx | 121 ++++++++ .../Statistics/__test__/Statistics.spec.tsx | 35 +++ .../Topic/Details/__test__/Details.spec.tsx | 286 +++++++++++------- .../Topics/Topic/SendMessage/SendMessage.tsx | 2 +- .../common/Metrics/Metrics.styled.tsx | 2 +- .../common/NewTable/ExpanderCell.tsx | 33 ++ .../common/NewTable/Table.styled.ts | 174 +++++++++++ .../src/components/common/NewTable/Table.tsx | 253 ++++++++++++++++ .../common/NewTable/TimestampCell.tsx | 12 + .../common/NewTable/__test__/Table.spec.tsx | 181 +++++++++++ .../src/components/common/NewTable/index.ts | 7 + .../utils/__test__/updateSortingState.spec.ts | 34 +++ .../NewTable/utils/updatePaginationState.ts | 27 ++ .../NewTable/utils/updateSortingState.ts | 26 ++ .../common/ProgressBar/ProgressBar.styled.ts | 22 ++ .../common/ProgressBar/ProgressBar.tsx | 18 ++ .../ProgressBar/__test__/ProgressBar.spec.tsx | 23 ++ .../PropertiesList/PropertiesList.styled.tsx | 17 ++ .../src/lib/__test__/paths.spec.ts | 50 +++ kafka-ui-react-app/src/lib/dateTimeHelpers.ts | 12 + kafka-ui-react-app/src/lib/fixtures/topics.ts | 75 +++++ .../lib/hooks/api/__tests__/topics.spec.ts | 49 ++- .../src/lib/hooks/api/topics.ts | 48 ++- kafka-ui-react-app/src/lib/paths.ts | 11 +- kafka-ui-react-app/src/theme/theme.ts | 19 ++ 40 files changed, 1896 insertions(+), 134 deletions(-) create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Indicators/SizeStats.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Indicators/Total.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Metrics.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionInfoRow.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionTable.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Statistics.styles.ts create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Statistics.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/__test__/Metrics.spec.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/__test__/Statistics.spec.tsx create mode 100644 kafka-ui-react-app/src/components/common/NewTable/ExpanderCell.tsx create mode 100644 kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts create mode 100644 kafka-ui-react-app/src/components/common/NewTable/Table.tsx create mode 100644 kafka-ui-react-app/src/components/common/NewTable/TimestampCell.tsx create mode 100644 kafka-ui-react-app/src/components/common/NewTable/__test__/Table.spec.tsx create mode 100644 kafka-ui-react-app/src/components/common/NewTable/index.ts create mode 100644 kafka-ui-react-app/src/components/common/NewTable/utils/__test__/updateSortingState.spec.ts create mode 100644 kafka-ui-react-app/src/components/common/NewTable/utils/updatePaginationState.ts create mode 100644 kafka-ui-react-app/src/components/common/NewTable/utils/updateSortingState.ts create mode 100644 kafka-ui-react-app/src/components/common/ProgressBar/ProgressBar.styled.ts create mode 100644 kafka-ui-react-app/src/components/common/ProgressBar/ProgressBar.tsx create mode 100644 kafka-ui-react-app/src/components/common/ProgressBar/__test__/ProgressBar.spec.tsx create mode 100644 kafka-ui-react-app/src/components/common/PropertiesList/PropertiesList.styled.tsx create mode 100644 kafka-ui-react-app/src/lib/dateTimeHelpers.ts diff --git a/kafka-ui-react-app/package.json b/kafka-ui-react-app/package.json index 1110162a4f..a74a664d81 100644 --- a/kafka-ui-react-app/package.json +++ b/kafka-ui-react-app/package.json @@ -12,6 +12,7 @@ "@reduxjs/toolkit": "^1.8.3", "@szhsin/react-menu": "^3.1.1", "@tanstack/react-query": "^4.0.5", + "@tanstack/react-table": "^8.5.10", "@testing-library/react": "^13.2.0", "@types/testing-library__jest-dom": "^5.14.5", "@types/yup": "^0.29.13", @@ -31,6 +32,7 @@ "react-ace": "^10.1.0", "react-datepicker": "^4.8.0", "react-dom": "^18.1.0", + "react-error-boundary": "^3.1.4", "react-hook-form": "7.6.9", "react-hot-toast": "^2.3.0", "react-is": "^18.2.0", diff --git a/kafka-ui-react-app/pnpm-lock.yaml b/kafka-ui-react-app/pnpm-lock.yaml index f86fe5f75a..1163b3a30e 100644 --- a/kafka-ui-react-app/pnpm-lock.yaml +++ b/kafka-ui-react-app/pnpm-lock.yaml @@ -14,6 +14,7 @@ specifiers: '@reduxjs/toolkit': ^1.8.3 '@szhsin/react-menu': ^3.1.1 '@tanstack/react-query': ^4.0.5 + '@tanstack/react-table': ^8.5.10 '@testing-library/dom': ^8.11.1 '@testing-library/jest-dom': ^5.16.4 '@testing-library/react': ^13.2.0 @@ -67,6 +68,7 @@ specifiers: react-ace: ^10.1.0 react-datepicker: ^4.8.0 react-dom: ^18.1.0 + react-error-boundary: ^3.1.4 react-hook-form: 7.6.9 react-hot-toast: ^2.3.0 react-is: ^18.2.0 @@ -96,6 +98,7 @@ dependencies: '@reduxjs/toolkit': 1.8.3_ctm756ikdwcjcvyfxxwskzbr6q '@szhsin/react-menu': 3.1.1_ef5jwxihqo6n7gxfmzogljlgcm '@tanstack/react-query': 4.0.5_ef5jwxihqo6n7gxfmzogljlgcm + '@tanstack/react-table': 8.5.10_ef5jwxihqo6n7gxfmzogljlgcm '@testing-library/react': 13.2.0_ef5jwxihqo6n7gxfmzogljlgcm '@types/testing-library__jest-dom': 5.14.5 '@types/yup': 0.29.13 @@ -115,6 +118,7 @@ dependencies: react-ace: 10.1.0_ef5jwxihqo6n7gxfmzogljlgcm react-datepicker: 4.8.0_ef5jwxihqo6n7gxfmzogljlgcm 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-hot-toast: 2.3.0_ef5jwxihqo6n7gxfmzogljlgcm react-is: 18.2.0 @@ -2394,6 +2398,23 @@ packages: use-sync-external-store: 1.2.0_react@18.1.0 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: resolution: {integrity: sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==} engines: {node: '>=12'} @@ -6619,6 +6640,16 @@ packages: react: 18.1.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: resolution: {integrity: sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==} diff --git a/kafka-ui-react-app/src/components/Schemas/Diff/Diff.tsx b/kafka-ui-react-app/src/components/Schemas/Diff/Diff.tsx index 94636fa4b8..9747171c08 100644 --- a/kafka-ui-react-app/src/components/Schemas/Diff/Diff.tsx +++ b/kafka-ui-react-app/src/components/Schemas/Diff/Diff.tsx @@ -1,6 +1,6 @@ import React from 'react'; 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 DiffViewer from 'components/common/DiffViewer/DiffViewer'; import { useNavigate, useLocation } from 'react-router-dom'; @@ -86,7 +86,7 @@ const Diff: React.FC = ({ versions, areVersionsFetched }) => { } onChange={(event) => { navigate( - clusterSchemaSchemaComparePath(clusterName, subject) + clusterSchemaComparePath(clusterName, subject) ); searchParams.set('leftVersion', event.toString()); searchParams.set( @@ -127,7 +127,7 @@ const Diff: React.FC = ({ versions, areVersionsFetched }) => { } onChange={(event) => { navigate( - clusterSchemaSchemaComparePath(clusterName, subject) + clusterSchemaComparePath(clusterName, subject) ); searchParams.set( 'leftVersion', diff --git a/kafka-ui-react-app/src/components/Schemas/Diff/__test__/Diff.spec.tsx b/kafka-ui-react-app/src/components/Schemas/Diff/__test__/Diff.spec.tsx index 029e589b9a..0c614cf661 100644 --- a/kafka-ui-react-app/src/components/Schemas/Diff/__test__/Diff.spec.tsx +++ b/kafka-ui-react-app/src/components/Schemas/Diff/__test__/Diff.spec.tsx @@ -2,13 +2,13 @@ import React from 'react'; import Diff, { DiffProps } from 'components/Schemas/Diff/Diff'; import { render, WithRoute } from 'lib/testHelpers'; import { screen } from '@testing-library/react'; -import { clusterSchemaSchemaComparePath } from 'lib/paths'; +import { clusterSchemaComparePath } from 'lib/paths'; import { versions } from './fixtures'; const defaultClusterName = 'defaultClusterName'; const defaultSubject = 'defaultSubject'; -const defaultPathName = clusterSchemaSchemaComparePath( +const defaultPathName = clusterSchemaComparePath( defaultClusterName, defaultSubject ); @@ -30,7 +30,7 @@ describe('Diff', () => { pathname = `${pathname}?${searchParams.toString()}`; return render( - + { > Settings + (isActive ? 'is-active' : '')} + > + Statistics + }> @@ -180,6 +188,10 @@ const Details: React.FC = () => { path={clusterTopicConsumerGroupsRelativePath} element={} /> + } + /> diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Message.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Message.tsx index 8c86fd1558..3cba8d058e 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Message.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Message.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import dayjs from 'dayjs'; import { TopicMessage } from 'generated-sources'; import useDataSaver from 'lib/hooks/useDataSaver'; import MessageToggleIcon from 'components/common/Icons/MessageToggleIcon'; import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper'; import styled from 'styled-components'; import { Dropdown, DropdownItem } from 'components/common/Dropdown'; +import { formatTimestamp } from 'lib/dateTimeHelpers'; import MessageContent from './MessageContent/MessageContent'; import * as S from './MessageContent/MessageContent.styled'; @@ -64,7 +64,7 @@ const Message: React.FC = ({ {offset} {partition} -
{dayjs(timestamp).format('MM.DD.YYYY HH:mm:ss')}
+
{formatTimestamp(timestamp)}
{key} diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/MessageContent/MessageContent.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/MessageContent/MessageContent.tsx index 0b8d47a5bf..3a1bc9a500 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/MessageContent/MessageContent.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/MessageContent/MessageContent.tsx @@ -2,7 +2,7 @@ import { TopicMessageTimestampTypeEnum, SchemaType } from 'generated-sources'; import React from 'react'; import EditorViewer from 'components/common/EditorViewer/EditorViewer'; import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted'; -import dayjs from 'dayjs'; +import { formatTimestamp } from 'lib/dateTimeHelpers'; import * as S from './MessageContent.styled'; @@ -94,9 +94,7 @@ const MessageContent: React.FC = ({ Timestamp - - {dayjs(timestamp).format('MM.DD.YYYY HH:mm:ss')} - + {formatTimestamp(timestamp)} Timestamp type: {timestampType} diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/__test__/Message.spec.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/__test__/Message.spec.tsx index fd52935d7f..a50341816f 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/__test__/Message.spec.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/__test__/Message.spec.tsx @@ -5,8 +5,8 @@ import Message, { } from 'components/Topics/Topic/Details/Messages/Message'; import { screen } from '@testing-library/react'; import { render } from 'lib/testHelpers'; -import dayjs from 'dayjs'; import userEvent from '@testing-library/user-event'; +import { formatTimestamp } from 'lib/dateTimeHelpers'; const messageContentText = 'messageContentText'; @@ -50,9 +50,7 @@ describe('Message component', () => { expect(screen.getByText(mockMessage.content as string)).toBeInTheDocument(); expect(screen.getByText(mockMessage.key as string)).toBeInTheDocument(); expect( - screen.getByText( - dayjs(mockMessage.timestamp).format('MM.DD.YYYY HH:mm:ss') - ) + screen.getByText(formatTimestamp(mockMessage.timestamp)) ).toBeInTheDocument(); expect(screen.getByText(mockMessage.offset.toString())).toBeInTheDocument(); expect( diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Indicators/SizeStats.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Indicators/SizeStats.tsx new file mode 100644 index 0000000000..a74a0bd24e --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Indicators/SizeStats.tsx @@ -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, +}) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default SizeStats; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Indicators/Total.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Indicators/Total.tsx new file mode 100644 index 0000000000..dec863c000 --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Indicators/Total.tsx @@ -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 = ({ + totalMsgs, + minOffset, + maxOffset, + minTimestamp, + maxTimestamp, + nullKeys, + nullValues, + approxUniqKeys, + approxUniqValues, +}) => ( + + {totalMsgs} + + {`${minOffset} - ${maxOffset}`} + + + {`${formatTimestamp(minTimestamp)} - ${formatTimestamp(maxTimestamp)}`} + + {nullKeys} + + {approxUniqKeys} + + {nullValues} + + {approxUniqValues} + + +); + +export default Total; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Metrics.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Metrics.tsx new file mode 100644 index 0000000000..be10efa5c3 --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Metrics.tsx @@ -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(); + 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 ( + + + + + + {formatTimestamp(data.progress.startedAt, 'hh:mm:ss a')} + + + {data.progress.msgsScanned} /{' '} + + + + + ); + } + + if (!data.result) { + return null; + } + + const totalStats = data.result.totalStats || {}; + const partitionStats = data.result.partitionStats || []; + + return ( + <> + + {formatTimestamp(data.result.finishedAt)} + + + + + + {totalStats.keySize && ( + + )} + {totalStats.valueSize && ( + + )} + + + + ); +}; + +export default Metrics; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionInfoRow.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionInfoRow.tsx new file mode 100644 index 0000000000..1ee8ace97a --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionInfoRow.tsx @@ -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 }> = ({ + row, +}) => { + const { + totalMsgs, + minTimestamp, + maxTimestamp, + nullKeys, + nullValues, + approxUniqKeys, + approxUniqValues, + keySize, + valueSize, + } = row.original; + + return ( + +
+ Partition stats + + + {totalMsgs} + + {formatTimestamp(minTimestamp)} + + {formatTimestamp(maxTimestamp)} + + {nullKeys} + + {nullValues} + + {approxUniqKeys} + + {approxUniqValues} + +
+
+ Keys sizes + + + + + + + + + + + + + + + + + + + + +
+
+ Values sizes + + + + + + + + + + + + + + + + + + + + +
+
+ ); +}; + +export default PartitionInfoRow; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionTable.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionTable.tsx new file mode 100644 index 0000000000..9db1e5a35b --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionTable.tsx @@ -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[]>( + () => [ + { + header: 'Partition ID', + accessorKey: 'partition', + }, + { + header: 'Total Messages', + accessorKey: 'totalMsgs', + }, + { + header: 'Min Offset', + accessorKey: 'minOffset', + }, + { header: 'Max Offset', accessorKey: 'maxOffset' }, + ], + [] + ); + + return ( + true} + renderSubComponent={PartitionInfoRow} + enableSorting + /> + ); +}; + +export default PartitionTable; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Statistics.styles.ts b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Statistics.styles.ts new file mode 100644 index 0000000000..472562eef9 --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Statistics.styles.ts @@ -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; +`; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Statistics.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Statistics.tsx new file mode 100644 index 0000000000..66ea07ba7a --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Statistics.tsx @@ -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(); + const analyzeTopic = useAnalyzeTopic(params); + + return ( + + {({ reset }) => ( + ( + + + + )} + > + + + )} + + ); +}; + +export default Statistics; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/__test__/Metrics.spec.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/__test__/Metrics.spec.tsx new file mode 100644 index 0000000000..621b6b25c0 --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/__test__/Metrics.spec.tsx @@ -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( + + + , + { 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(); + }); +}); diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/__test__/Statistics.spec.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/__test__/Statistics.spec.tsx new file mode 100644 index 0000000000..90f39faa0e --- /dev/null +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/__test__/Statistics.spec.tsx @@ -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( + + + , + { initialEntries: [path] } + ); + }; + + it('renders Metricks component', () => { + (useTopicAnalysis as jest.Mock).mockImplementation(() => ({ + data: { result: 1 }, + })); + + renderComponent(); + expect(screen.getByText('Restart Analysis')).toBeInTheDocument(); + }); +}); diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Details/__test__/Details.spec.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Details/__test__/Details.spec.tsx index 8ed9fb0f6b..12ce5097b7 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/Details/__test__/Details.spec.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/Details/__test__/Details.spec.tsx @@ -4,7 +4,15 @@ import userEvent from '@testing-library/user-event'; import ClusterContext from 'components/contexts/ClusterContext'; import Details from 'components/Topics/Topic/Details/Details'; 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 { externalTopicPayload } from 'lib/fixtures/topics'; import { @@ -32,19 +40,34 @@ jest.mock('lib/hooks/redux', () => ({ 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 mockRecreate = jest.fn(); +const mockClusterName = 'local'; +const topic: Topic = { + ...externalTopicPayload, + cleanUpPolicy: CleanUpPolicy.DELETE, +}; +const defaultPath = clusterTopicPath(mockClusterName, topic.name); describe('Details', () => { - const mockClusterName = 'local'; - - const topic: Topic = { - ...externalTopicPayload, - cleanUpPolicy: CleanUpPolicy.DELETE, - }; - - const renderComponent = (isReadOnly = false) => { - const path = clusterTopicPath(mockClusterName, topic.name); + const renderComponent = (isReadOnly = false, path = defaultPath) => { render( { isTopicDeletionAllowed: true, }} > - +
, @@ -73,113 +96,160 @@ describe('Details', () => { mutateAsync: mockRecreate, })); }); - - describe('when it has readonly flag', () => { - it('does not render the Action button a Topic', () => { - renderComponent(true); - expect(screen.queryByText('Produce Message')).not.toBeInTheDocument(); + describe('Action Bar', () => { + describe('when it has readonly flag', () => { + it('does not render the Action button a Topic', () => { + renderComponent(true); + expect(screen.queryByText('Produce Message')).not.toBeInTheDocument(); + }); }); - }); - describe('when remove topic modal is open', () => { - beforeEach(() => { + describe('when remove topic modal is open', () => { + beforeEach(() => { + renderComponent(); + const openModalButton = screen.getAllByText('Remove Topic')[0]; + userEvent.click(openModalButton); + }); + + it('calls deleteTopic on confirm', async () => { + const submitButton = screen.getAllByRole('button', { + name: 'Confirm', + })[0]; + await act(() => userEvent.click(submitButton)); + expect(mockDelete).toHaveBeenCalledWith(topic.name); + }); + it('closes the modal when cancel button is clicked', async () => { + const cancelButton = screen.getAllByText('Cancel')[0]; + await waitFor(() => userEvent.click(cancelButton)); + expect(cancelButton).not.toBeInTheDocument(); + }); + }); + + describe('when clear messages modal is open', () => { + beforeEach(async () => { + await renderComponent(); + const confirmButton = screen.getAllByText('Clear messages')[0]; + await act(() => userEvent.click(confirmButton)); + }); + + it('it calls clearTopicMessages on confirm', async () => { + const submitButton = screen.getAllByRole('button', { + name: 'Confirm', + })[0]; + await waitFor(() => userEvent.click(submitButton)); + expect(mockUnwrap).toHaveBeenCalledTimes(1); + }); + + it('closes the modal when cancel button is clicked', async () => { + const cancelButton = screen.getAllByText('Cancel')[0]; + await waitFor(() => userEvent.click(cancelButton)); + + expect(cancelButton).not.toBeInTheDocument(); + }); + }); + + describe('when edit settings is clicked', () => { + it('redirects to the edit page', () => { + renderComponent(); + const button = screen.getAllByText('Edit settings')[0]; + userEvent.click(button); + expect(mockNavigate).toHaveBeenCalledWith(clusterTopicEditRelativePath); + }); + }); + + it('redirects to the correct route if topic is deleted', async () => { renderComponent(); - const openModalButton = screen.getAllByText('Remove Topic')[0]; - userEvent.click(openModalButton); - }); - - it('calls deleteTopic on confirm', async () => { - const submitButton = screen.getAllByRole('button', { + const deleteTopicButton = screen.getByText(/Remove topic/i); + await waitFor(() => userEvent.click(deleteTopicButton)); + const submitDeleteButton = screen.getByRole('button', { name: 'Confirm', - })[0]; - await act(() => userEvent.click(submitButton)); - expect(mockDelete).toHaveBeenCalledWith(topic.name); - }); - it('closes the modal when cancel button is clicked', async () => { - const cancelButton = screen.getAllByText('Cancel')[0]; - await waitFor(() => userEvent.click(cancelButton)); - expect(cancelButton).not.toBeInTheDocument(); - }); - }); - - describe('when clear messages modal is open', () => { - beforeEach(async () => { - await renderComponent(); - const confirmButton = screen.getAllByText('Clear messages')[0]; - await act(() => userEvent.click(confirmButton)); + }); + await act(() => userEvent.click(submitDeleteButton)); + expect(mockNavigate).toHaveBeenCalledWith('../..'); }); - it('it calls clearTopicMessages on confirm', async () => { - const submitButton = screen.getAllByRole('button', { - name: 'Confirm', - })[0]; - await waitFor(() => userEvent.click(submitButton)); - expect(mockUnwrap).toHaveBeenCalledTimes(1); - }); - - it('closes the modal when cancel button is clicked', async () => { - const cancelButton = screen.getAllByText('Cancel')[0]; - await waitFor(() => userEvent.click(cancelButton)); - - expect(cancelButton).not.toBeInTheDocument(); - }); - }); - - describe('when edit settings is clicked', () => { - it('redirects to the edit page', () => { + it('shows a confirmation popup on deleting topic messages', () => { renderComponent(); - const button = screen.getAllByText('Edit settings')[0]; - userEvent.click(button); - expect(mockNavigate).toHaveBeenCalledWith(clusterTopicEditRelativePath); + const clearMessagesButton = screen.getAllByText(/Clear messages/i)[0]; + userEvent.click(clearMessagesButton); + + expect( + screen.getByText(/Are you sure want to clear topic messages?/i) + ).toBeInTheDocument(); + }); + + it('shows a confirmation popup on recreating topic', () => { + renderComponent(); + const recreateTopicButton = screen.getByText(/Recreate topic/i); + userEvent.click(recreateTopicButton); + expect( + screen.getByText(/Are you sure want to recreate topic?/i) + ).toBeInTheDocument(); + }); + + it('is calling recreation function after click on Submit button', async () => { + renderComponent(); + const recreateTopicButton = screen.getByText(/Recreate topic/i); + userEvent.click(recreateTopicButton); + const confirmBtn = screen.getByRole('button', { name: /Confirm/i }); + + await waitFor(() => userEvent.click(confirmBtn)); + expect(mockRecreate).toBeCalledTimes(1); + }); + + it('closes popup confirmation window after click on Cancel button', () => { + renderComponent(); + const recreateTopicButton = screen.getByText(/Recreate topic/i); + userEvent.click(recreateTopicButton); + const cancelBtn = screen.getByRole('button', { name: /cancel/i }); + userEvent.click(cancelBtn); + expect( + screen.queryByText(/Are you sure want to recreate topic?/i) + ).not.toBeInTheDocument(); }); }); - it('redirects to the correct route if topic is deleted', async () => { - renderComponent(); - const deleteTopicButton = screen.getByText(/Remove topic/i); - await waitFor(() => userEvent.click(deleteTopicButton)); - const submitDeleteButton = screen.getByRole('button', { name: 'Confirm' }); - await act(() => userEvent.click(submitDeleteButton)); - expect(mockNavigate).toHaveBeenCalledWith('../..'); - }); + 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('shows a confirmation popup on deleting topic messages', () => { - renderComponent(); - const clearMessagesButton = screen.getAllByText(/Clear messages/i)[0]; - userEvent.click(clearMessagesButton); - - expect( - screen.getByText(/Are you sure want to clear topic messages?/i) - ).toBeInTheDocument(); - }); - - it('shows a confirmation popup on recreating topic', () => { - renderComponent(); - const recreateTopicButton = screen.getByText(/Recreate topic/i); - userEvent.click(recreateTopicButton); - expect( - screen.getByText(/Are you sure want to recreate topic?/i) - ).toBeInTheDocument(); - }); - - it('calling recreation function after click on Submit button', async () => { - renderComponent(); - const recreateTopicButton = screen.getByText(/Recreate topic/i); - userEvent.click(recreateTopicButton); - const confirmBtn = screen.getByRole('button', { name: /Confirm/i }); - - await waitFor(() => userEvent.click(confirmBtn)); - expect(mockRecreate).toBeCalledTimes(1); - }); - - it('close popup confirmation window after click on Cancel button', () => { - renderComponent(); - const recreateTopicButton = screen.getByText(/Recreate topic/i); - userEvent.click(recreateTopicButton); - const cancelBtn = screen.getByRole('button', { name: /cancel/i }); - userEvent.click(cancelBtn); - expect( - screen.queryByText(/Are you sure want to recreate topic?/i) - ).not.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' + ); + }); }); }); diff --git a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx index 7813a2b579..781a33aed6 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx @@ -115,7 +115,7 @@ const SendMessage: React.FC = () => { message: (
    {errors.map((e) => ( -
  • {e}
  • +
  • {e}
  • ))}
), diff --git a/kafka-ui-react-app/src/components/common/Metrics/Metrics.styled.tsx b/kafka-ui-react-app/src/components/common/Metrics/Metrics.styled.tsx index 2d66121891..48fb0d723a 100644 --- a/kafka-ui-react-app/src/components/common/Metrics/Metrics.styled.tsx +++ b/kafka-ui-react-app/src/components/common/Metrics/Metrics.styled.tsx @@ -43,7 +43,7 @@ export const IndicatorsWrapper = styled.div` export const SectionTitle = styled.h5` font-weight: 500; - margin: 0 0 0.5rem 0; + margin: 0 0 0.5rem 16px; font-size: 100%; `; diff --git a/kafka-ui-react-app/src/components/common/NewTable/ExpanderCell.tsx b/kafka-ui-react-app/src/components/common/NewTable/ExpanderCell.tsx new file mode 100644 index 0000000000..e1975abb6a --- /dev/null +++ b/kafka-ui-react-app/src/components/common/NewTable/ExpanderCell.tsx @@ -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> = ({ row }) => ( + + {row.getIsExpanded() ? ( + + ) : ( + + )} + +); + +export default ExpanderCell; diff --git a/kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts b/kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts new file mode 100644 index 0000000000..f80d62ce5f --- /dev/null +++ b/kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts @@ -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( + ({ 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( + ({ 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; +`; diff --git a/kafka-ui-react-app/src/components/common/NewTable/Table.tsx b/kafka-ui-react-app/src/components/common/NewTable/Table.tsx new file mode 100644 index 0000000000..244caeb03f --- /dev/null +++ b/kafka-ui-react-app/src/components/common/NewTable/Table.tsx @@ -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 { + data: TData[]; + pageCount?: number; + columns: ColumnDef[]; + renderSubComponent?: React.FC<{ row: Row }>; + getRowCanExpand?: (row: Row) => boolean; + serverSideProcessing?: boolean; + enableSorting?: boolean; +} + +type UpdaterFn = (previousState: T) => T; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const Table: React.FC> = ({ + data, + pageCount, + columns, + getRowCanExpand, + renderSubComponent, + serverSideProcessing = false, + enableSorting = false, +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + const [sorting, setSorting] = React.useState([]); + const [{ pageIndex, pageSize }, setPagination] = + React.useState({ + pageIndex: 0, + pageSize: PER_PAGE, + }); + + const onSortingChange = React.useCallback( + (updater: UpdaterFn) => { + const newState = updateSortingState(updater, searchParams); + setSearchParams(searchParams); + setSorting(newState); + return newState; + }, + [searchParams] + ); + + const onPaginationChange = React.useCallback( + (updater: UpdaterFn) => { + 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, + onPaginationChange: onPaginationChange as OnChangeFn, + getRowCanExpand, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + manualSorting: serverSideProcessing, + manualPagination: serverSideProcessing, + enableSorting, + }); + + return ( + <> + +
+ {table.getHeaderGroups().map((headerGroup) => ( + + {table.getCanSomeRowsExpand() && ( + + )} + {headerGroup.headers.map((header) => ( + +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+
+ ))} +
+ ))} + + + {table.getRowModel().rows.map((row) => ( + + row.getCanExpand() && row.toggleExpanded()} + > + {row.getCanExpand() && ( + + )} + {row.getVisibleCells().map((cell) => ( + + ))} + + {row.getIsExpanded() && renderSubComponent && ( + + + + )} + + ))} + + + {table.getPageCount() > 1 && ( + + + + + + + + + Go to page: + { + const page = e.target.value ? Number(e.target.value) - 1 : 0; + table.setPageIndex(page); + }} + /> + + + + + Page {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount()}{' '} + + + + )} + + ); +}; + +export default Table; diff --git a/kafka-ui-react-app/src/components/common/NewTable/TimestampCell.tsx b/kafka-ui-react-app/src/components/common/NewTable/TimestampCell.tsx new file mode 100644 index 0000000000..9c7475274c --- /dev/null +++ b/kafka-ui-react-app/src/components/common/NewTable/TimestampCell.tsx @@ -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> = ({ getValue }) => ( + {formatTimestamp(getValue())} +); + +export default TimestampCell; diff --git a/kafka-ui-react-app/src/components/common/NewTable/__test__/Table.spec.tsx b/kafka-ui-react-app/src/components/common/NewTable/__test__/Table.spec.tsx new file mode 100644 index 0000000000..e9b21c3e03 --- /dev/null +++ b/kafka-ui-react-app/src/components/common/NewTable/__test__/Table.spec.tsx @@ -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[] = [ + { + header: 'DateTime', + accessorKey: 'timestamp', + cell: TimestampCell, + }, + { + header: 'Text', + accessorKey: 'text', + }, +]; + +const ExpandedRow: React.FC = () =>
I am expanded row
; + +interface Props { + path?: string; + canExpand?: boolean; +} + +const renderComponent = ({ path, canExpand }: Props = {}) => { + render( + +
+ {flexRender( + ExpanderCell, + row.getVisibleCells()[0].getContext() + )} + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + + {renderSubComponent({ row })} + +
!!canExpand} + enableSorting + /> + , + { 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); + }); + }); +}); diff --git a/kafka-ui-react-app/src/components/common/NewTable/index.ts b/kafka-ui-react-app/src/components/common/NewTable/index.ts new file mode 100644 index 0000000000..b2c25771e5 --- /dev/null +++ b/kafka-ui-react-app/src/components/common/NewTable/index.ts @@ -0,0 +1,7 @@ +import Table from './Table'; +import TimestampCell from './TimestampCell'; +import ExpanderCell from './ExpanderCell'; + +export { TimestampCell, ExpanderCell }; + +export default Table; diff --git a/kafka-ui-react-app/src/components/common/NewTable/utils/__test__/updateSortingState.spec.ts b/kafka-ui-react-app/src/components/common/NewTable/utils/__test__/updateSortingState.spec.ts new file mode 100644 index 0000000000..07bd60991b --- /dev/null +++ b/kafka-ui-react-app/src/components/common/NewTable/utils/__test__/updateSortingState.spec.ts @@ -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); + }); +}); diff --git a/kafka-ui-react-app/src/components/common/NewTable/utils/updatePaginationState.ts b/kafka-ui-react-app/src/components/common/NewTable/utils/updatePaginationState.ts new file mode 100644 index 0000000000..85dfd580bc --- /dev/null +++ b/kafka-ui-react-app/src/components/common/NewTable/utils/updatePaginationState.ts @@ -0,0 +1,27 @@ +import { PaginationState } from '@tanstack/react-table'; +import { PER_PAGE } from 'lib/constants'; + +type UpdaterFn = (previousState: T) => T; + +export default ( + updater: UpdaterFn, + 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; +}; diff --git a/kafka-ui-react-app/src/components/common/NewTable/utils/updateSortingState.ts b/kafka-ui-react-app/src/components/common/NewTable/utils/updateSortingState.ts new file mode 100644 index 0000000000..d2aaa3598d --- /dev/null +++ b/kafka-ui-react-app/src/components/common/NewTable/utils/updateSortingState.ts @@ -0,0 +1,26 @@ +import { SortingState } from '@tanstack/react-table'; + +type UpdaterFn = (previousState: T) => T; + +export default ( + updater: UpdaterFn, + 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; +}; diff --git a/kafka-ui-react-app/src/components/common/ProgressBar/ProgressBar.styled.ts b/kafka-ui-react-app/src/components/common/ProgressBar/ProgressBar.styled.ts new file mode 100644 index 0000000000..2d9d0ab6bc --- /dev/null +++ b/kafka-ui-react-app/src/components/common/ProgressBar/ProgressBar.styled.ts @@ -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; + ` +); diff --git a/kafka-ui-react-app/src/components/common/ProgressBar/ProgressBar.tsx b/kafka-ui-react-app/src/components/common/ProgressBar/ProgressBar.tsx new file mode 100644 index 0000000000..17d492cbc6 --- /dev/null +++ b/kafka-ui-react-app/src/components/common/ProgressBar/ProgressBar.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import * as S from './ProgressBar.styled'; + +interface ProgressBarProps { + completed: number; +} + +const ProgressBar: React.FC = ({ completed }) => { + const p = Math.max(Math.min(completed, 100), 0); + return ( + + + + ); +}; + +export default ProgressBar; diff --git a/kafka-ui-react-app/src/components/common/ProgressBar/__test__/ProgressBar.spec.tsx b/kafka-ui-react-app/src/components/common/ProgressBar/__test__/ProgressBar.spec.tsx new file mode 100644 index 0000000000..86e6922ee4 --- /dev/null +++ b/kafka-ui-react-app/src/components/common/ProgressBar/__test__/ProgressBar.spec.tsx @@ -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(); + const bar = screen.getByRole('progressbar'); + expect(bar).toHaveStyleRule('width', `${expected}%`); + }); + }; + + [ + [-143, 0], + [0, 0], + [67, 67], + [143, 100], + ].forEach(([completed, expected]) => + itRendersCorrectPercentage(completed, expected) + ); +}); diff --git a/kafka-ui-react-app/src/components/common/PropertiesList/PropertiesList.styled.tsx b/kafka-ui-react-app/src/components/common/PropertiesList/PropertiesList.styled.tsx new file mode 100644 index 0000000000..d3f986bb2c --- /dev/null +++ b/kafka-ui-react-app/src/components/common/PropertiesList/PropertiesList.styled.tsx @@ -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; +`; diff --git a/kafka-ui-react-app/src/lib/__test__/paths.spec.ts b/kafka-ui-react-app/src/lib/__test__/paths.spec.ts index 45a1ca87e5..3bfbcef7a5 100644 --- a/kafka-ui-react-app/src/lib/__test__/paths.spec.ts +++ b/kafka-ui-react-app/src/lib/__test__/paths.spec.ts @@ -16,6 +16,10 @@ describe('Paths', () => { `${GIT_REPO_LINK}/commit/1234567gh` ); }); + it('getNonExactPath', () => { + expect(paths.getNonExactPath('')).toEqual('/*'); + expect(paths.getNonExactPath('/clusters')).toEqual('/clusters/*'); + }); it('clusterPath', () => { expect(paths.clusterPath(clusterName)).toEqual( `/ui/clusters/${clusterName}` @@ -117,6 +121,17 @@ describe('Paths', () => { 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', () => { expect(paths.clusterTopicsPath(clusterName)).toEqual( @@ -194,6 +209,25 @@ describe('Paths', () => { 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', () => { expect(paths.clusterConnectsPath(clusterName)).toEqual( @@ -331,4 +365,20 @@ describe('Paths', () => { 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) + ); + }); }); diff --git a/kafka-ui-react-app/src/lib/dateTimeHelpers.ts b/kafka-ui-react-app/src/lib/dateTimeHelpers.ts new file mode 100644 index 0000000000..b32a3c2799 --- /dev/null +++ b/kafka-ui-react-app/src/lib/dateTimeHelpers.ts @@ -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); +}; diff --git a/kafka-ui-react-app/src/lib/fixtures/topics.ts b/kafka-ui-react-app/src/lib/fixtures/topics.ts index 68cf2f8069..aa7f3cf058 100644 --- a/kafka-ui-react-app/src/lib/fixtures/topics.ts +++ b/kafka-ui-react-app/src/lib/fixtures/topics.ts @@ -5,6 +5,7 @@ import { Topic, TopicConfig, MessageSchemaSourceEnum, + TopicAnalysis, } from 'generated-sources'; 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 }], + }, + ], + }, +}; diff --git a/kafka-ui-react-app/src/lib/hooks/api/__tests__/topics.spec.ts b/kafka-ui-react-app/src/lib/hooks/api/__tests__/topics.spec.ts index 6ae7fa8709..6909989e34 100644 --- a/kafka-ui-react-app/src/lib/hooks/api/__tests__/topics.spec.ts +++ b/kafka-ui-react-app/src/lib/hooks/api/__tests__/topics.spec.ts @@ -24,6 +24,11 @@ const topicPath = `${topicsPath}/${topicName}`; const topicParams = { clusterName, topicName }; +jest.mock('lib/errorHandling', () => ({ + ...jest.requireActual('lib/errorHandling'), + showServerError: jest.fn(), +})); + describe('Topics hooks', () => { beforeEach(() => fetchMock.restore()); it('handles useTopics', async () => { @@ -57,6 +62,20 @@ describe('Topics hooks', () => { ); 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', () => { it('useCreateTopic', async () => { @@ -107,7 +126,6 @@ describe('Topics hooks', () => { await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); expect(mock.calls()).toHaveLength(1); }); - it('useIncreaseTopicPartitionsCount', async () => { const mock = fetchMock.patchOnce(`${topicPath}/partitions`, {}); const { result } = renderHook( @@ -120,7 +138,6 @@ describe('Topics hooks', () => { await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); expect(mock.calls()).toHaveLength(1); }); - it('useUpdateTopicReplicationFactor', async () => { const mock = fetchMock.patchOnce(`${topicPath}/replications`, {}); const { result } = renderHook( @@ -133,7 +150,6 @@ describe('Topics hooks', () => { await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); expect(mock.calls()).toHaveLength(1); }); - it('useDeleteTopic', async () => { const mock = fetchMock.deleteOnce(topicPath, {}); const { result } = renderHook(() => hooks.useDeleteTopic(clusterName), { @@ -145,7 +161,6 @@ describe('Topics hooks', () => { await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); expect(mock.calls()).toHaveLength(1); }); - it('useRecreateTopic', async () => { const mock = fetchMock.postOnce(topicPath, {}); const { result } = renderHook(() => hooks.useRecreateTopic(topicParams), { @@ -157,7 +172,6 @@ describe('Topics hooks', () => { await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); expect(mock.calls()).toHaveLength(1); }); - it('useSendMessage', async () => { const mock = fetchMock.postOnce(`${topicPath}/messages`, {}); const { result } = renderHook(() => hooks.useSendMessage(topicParams), { @@ -173,5 +187,30 @@ describe('Topics hooks', () => { await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); 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); + }); }); }); diff --git a/kafka-ui-react-app/src/lib/hooks/api/topics.ts b/kafka-ui-react-app/src/lib/hooks/api/topics.ts index ccd4a93e4e..cbc2e9c54c 100644 --- a/kafka-ui-react-app/src/lib/hooks/api/topics.ts +++ b/kafka-ui-react-app/src/lib/hooks/api/topics.ts @@ -36,12 +36,16 @@ export const topicKeys = { [...topicKeys.details(props), 'schema'] as const, consumerGroups: (props: GetTopicDetailsRequest) => [...topicKeys.details(props), 'consumerGroups'] as const, + statistics: (props: GetTopicDetailsRequest) => + [...topicKeys.details(props), 'statistics'] as const, }; export function useTopics(props: GetTopicsRequest) { const { clusterName, ...filters } = props; - return useQuery(topicKeys.list(clusterName, filters), () => - api.getTopics(props) + return useQuery( + topicKeys.list(clusterName, filters), + () => api.getTopics(props), + { keepPreviousData: true } ); } 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)); + }, + }); +} diff --git a/kafka-ui-react-app/src/lib/paths.ts b/kafka-ui-react-app/src/lib/paths.ts index 0be6a514cc..3392b88795 100644 --- a/kafka-ui-react-app/src/lib/paths.ts +++ b/kafka-ui-react-app/src/lib/paths.ts @@ -99,7 +99,7 @@ export const clusterSchemaEditPath = ( clusterName: ClusterName = RouteParams.clusterName, subject: SchemaName = RouteParams.subject ) => `${clusterSchemasPath(clusterName)}/${subject}/edit`; -export const clusterSchemaSchemaComparePath = ( +export const clusterSchemaComparePath = ( clusterName: ClusterName = RouteParams.clusterName, subject: SchemaName = RouteParams.subject ) => `${clusterSchemaPath(clusterName, subject)}/compare`; @@ -127,6 +127,7 @@ export const clusterTopicCopyPath = ( export const clusterTopicSettingsRelativePath = 'settings'; export const clusterTopicMessagesRelativePath = 'messages'; export const clusterTopicConsumerGroupsRelativePath = 'consumer-groups'; +export const clusterTopicStatisticsRelativePath = 'statistics'; export const clusterTopicEditRelativePath = 'edit'; export const clusterTopicSendMessageRelativePath = 'message'; export const clusterTopicPath = ( @@ -162,6 +163,14 @@ export const clusterTopicConsumerGroupsPath = ( clusterName, topicName )}/${clusterTopicConsumerGroupsRelativePath}`; +export const clusterTopicStatisticsPath = ( + clusterName: ClusterName = RouteParams.clusterName, + topicName: TopicName = RouteParams.topicName +) => + `${clusterTopicPath( + clusterName, + topicName + )}/${clusterTopicStatisticsRelativePath}`; export const clusterTopicSendMessagePath = ( clusterName: ClusterName = RouteParams.clusterName, topicName: TopicName = RouteParams.topicName diff --git a/kafka-ui-react-app/src/theme/theme.ts b/kafka-ui-react-app/src/theme/theme.ts index 9be09d77be..799aaa5e5f 100644 --- a/kafka-ui-react-app/src/theme/theme.ts +++ b/kafka-ui-react-app/src/theme/theme.ts @@ -71,6 +71,16 @@ const theme = { backgroundColor: Colors.neutral[5], color: Colors.red[55], }, + list: { + label: { + color: Colors.neutral[50], + }, + }, + progressBar: { + backgroundColor: Colors.neutral[3], + compleatedColor: Colors.green[40], + borderColor: Colors.neutral[10], + }, layout: { backgroundColor: Colors.neutral[0], minWidth: '1200px', @@ -149,6 +159,7 @@ const theme = { 4: { fontSize: '14px', lineHeight: '20px', + fontWeight: 500, }, 5: { fontSize: '12px', @@ -308,6 +319,7 @@ const theme = { }, tr: { backgroundColor: { + normal: Colors.neutral[0], hover: Colors.neutral[5], }, }, @@ -316,6 +328,10 @@ const theme = { normal: Colors.neutral[90], }, }, + expander: { + normal: Colors.brand[50], + hover: Colors.brand[20], + }, }, primaryTab: { color: { @@ -547,6 +563,9 @@ const theme = { editFilterText: { color: Colors.brand[50], }, + statictics: { + createdAtColor: Colors.neutral[50], + }, }; export type ThemeType = typeof theme;