Browse Source

Topic statistics (#2413)

* Topic statistics

* Typo

* Code smell

* Specs

* Specs

* Use timestamp helper

* Improve coverage

* styling
Oleg Shur 2 years ago
parent
commit
7765a268af
40 changed files with 1886 additions and 124 deletions
  1. 2 0
      kafka-ui-react-app/package.json
  2. 31 0
      kafka-ui-react-app/pnpm-lock.yaml
  3. 3 3
      kafka-ui-react-app/src/components/Schemas/Diff/Diff.tsx
  4. 3 3
      kafka-ui-react-app/src/components/Schemas/Diff/__test__/Diff.spec.tsx
  5. 12 0
      kafka-ui-react-app/src/components/Topics/Topic/Details/Details.tsx
  6. 2 2
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Message.tsx
  7. 2 4
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/MessageContent/MessageContent.tsx
  8. 2 4
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/__test__/Message.spec.tsx
  9. 44 0
      kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Indicators/SizeStats.tsx
  10. 42 0
      kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Indicators/Total.tsx
  11. 106 0
      kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Metrics.tsx
  12. 100 0
      kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionInfoRow.tsx
  13. 40 0
      kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/PartitionTable.tsx
  14. 44 0
      kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Statistics.styles.ts
  15. 44 0
      kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/Statistics.tsx
  16. 121 0
      kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/__test__/Metrics.spec.tsx
  17. 35 0
      kafka-ui-react-app/src/components/Topics/Topic/Details/Statistics/__test__/Statistics.spec.tsx
  18. 168 98
      kafka-ui-react-app/src/components/Topics/Topic/Details/__test__/Details.spec.tsx
  19. 1 1
      kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx
  20. 1 1
      kafka-ui-react-app/src/components/common/Metrics/Metrics.styled.tsx
  21. 33 0
      kafka-ui-react-app/src/components/common/NewTable/ExpanderCell.tsx
  22. 174 0
      kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts
  23. 253 0
      kafka-ui-react-app/src/components/common/NewTable/Table.tsx
  24. 12 0
      kafka-ui-react-app/src/components/common/NewTable/TimestampCell.tsx
  25. 181 0
      kafka-ui-react-app/src/components/common/NewTable/__test__/Table.spec.tsx
  26. 7 0
      kafka-ui-react-app/src/components/common/NewTable/index.ts
  27. 34 0
      kafka-ui-react-app/src/components/common/NewTable/utils/__test__/updateSortingState.spec.ts
  28. 27 0
      kafka-ui-react-app/src/components/common/NewTable/utils/updatePaginationState.ts
  29. 26 0
      kafka-ui-react-app/src/components/common/NewTable/utils/updateSortingState.ts
  30. 22 0
      kafka-ui-react-app/src/components/common/ProgressBar/ProgressBar.styled.ts
  31. 18 0
      kafka-ui-react-app/src/components/common/ProgressBar/ProgressBar.tsx
  32. 23 0
      kafka-ui-react-app/src/components/common/ProgressBar/__test__/ProgressBar.spec.tsx
  33. 17 0
      kafka-ui-react-app/src/components/common/PropertiesList/PropertiesList.styled.tsx
  34. 50 0
      kafka-ui-react-app/src/lib/__test__/paths.spec.ts
  35. 12 0
      kafka-ui-react-app/src/lib/dateTimeHelpers.ts
  36. 75 0
      kafka-ui-react-app/src/lib/fixtures/topics.ts
  37. 44 5
      kafka-ui-react-app/src/lib/hooks/api/__tests__/topics.spec.ts
  38. 46 2
      kafka-ui-react-app/src/lib/hooks/api/topics.ts
  39. 10 1
      kafka-ui-react-app/src/lib/paths.ts
  40. 19 0
      kafka-ui-react-app/src/theme/theme.ts

+ 2 - 0
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",

+ 31 - 0
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==}
 

+ 3 - 3
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<DiffProps> = ({ 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<DiffProps> = ({ versions, areVersionsFetched }) => {
                       }
                       onChange={(event) => {
                         navigate(
-                          clusterSchemaSchemaComparePath(clusterName, subject)
+                          clusterSchemaComparePath(clusterName, subject)
                         );
                         searchParams.set(
                           'leftVersion',

+ 3 - 3
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(
-      <WithRoute path={clusterSchemaSchemaComparePath()}>
+      <WithRoute path={clusterSchemaComparePath()}>
         <Diff
           versions={props.versions}
           areVersionsFetched={props.areVersionsFetched}

+ 12 - 0
kafka-ui-react-app/src/components/Topics/Topic/Details/Details.tsx

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

+ 2 - 2
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<Props> = ({
         <td>{offset}</td>
         <td>{partition}</td>
         <td>
-          <div>{dayjs(timestamp).format('MM.DD.YYYY HH:mm:ss')}</div>
+          <div>{formatTimestamp(timestamp)}</div>
         </td>
         <StyledDataCell title={key}>{key}</StyledDataCell>
         <StyledDataCell>

+ 2 - 4
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<MessageContentProps> = ({
             <S.Metadata>
               <S.MetadataLabel>Timestamp</S.MetadataLabel>
               <span>
-                <S.MetadataValue>
-                  {dayjs(timestamp).format('MM.DD.YYYY HH:mm:ss')}
-                </S.MetadataValue>
+                <S.MetadataValue>{formatTimestamp(timestamp)}</S.MetadataValue>
                 <S.MetadataMeta>Timestamp type: {timestampType}</S.MetadataMeta>
               </span>
             </S.Metadata>

+ 2 - 4
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(

+ 44 - 0
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,
+}) => (
+  <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;

+ 42 - 0
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<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;

+ 106 - 0
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<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;

+ 100 - 0
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<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;

+ 40 - 0
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<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;

+ 44 - 0
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;
+`;

+ 44 - 0
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<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;

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

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

+ 168 - 98
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(
       <ClusterContext.Provider
         value={{
@@ -54,7 +77,7 @@ describe('Details', () => {
           isTopicDeletionAllowed: true,
         }}
       >
-        <WithRoute path={clusterTopicPath()}>
+        <WithRoute path={getNonExactPath(clusterTopicPath())}>
           <Details />
         </WithRoute>
       </ClusterContext.Provider>,
@@ -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(() => {
-      renderComponent();
-      const openModalButton = screen.getAllByText('Remove Topic')[0];
-      userEvent.click(openModalButton);
+    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();
+      });
     });
 
-    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 clear messages modal is open', () => {
-    beforeEach(async () => {
-      await renderComponent();
-      const confirmButton = screen.getAllByText('Clear messages')[0];
-      await act(() => userEvent.click(confirmButton));
+    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('it calls clearTopicMessages on confirm', async () => {
-      const submitButton = screen.getAllByRole('button', {
+    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',
-      })[0];
-      await waitFor(() => userEvent.click(submitButton));
-      expect(mockUnwrap).toHaveBeenCalledTimes(1);
+      });
+      await act(() => userEvent.click(submitDeleteButton));
+      expect(mockNavigate).toHaveBeenCalledWith('../..');
     });
 
-    it('closes the modal when cancel button is clicked', async () => {
-      const cancelButton = screen.getAllByText('Cancel')[0];
-      await waitFor(() => userEvent.click(cancelButton));
+    it('shows a confirmation popup on deleting topic messages', () => {
+      renderComponent();
+      const clearMessagesButton = screen.getAllByText(/Clear messages/i)[0];
+      userEvent.click(clearMessagesButton);
 
-      expect(cancelButton).not.toBeInTheDocument();
+      expect(
+        screen.getByText(/Are you sure want to clear topic messages?/i)
+      ).toBeInTheDocument();
     });
-  });
 
-  describe('when edit settings is clicked', () => {
-    it('redirects to the edit page', () => {
+    it('shows a confirmation popup on recreating topic', () => {
       renderComponent();
-      const button = screen.getAllByText('Edit settings')[0];
-      userEvent.click(button);
-      expect(mockNavigate).toHaveBeenCalledWith(clusterTopicEditRelativePath);
+      const recreateTopicButton = screen.getByText(/Recreate topic/i);
+      userEvent.click(recreateTopicButton);
+      expect(
+        screen.getByText(/Are you sure want to recreate topic?/i)
+      ).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('../..');
-  });
-
-  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('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 });
 
-  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);
+    });
 
-    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('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();
+  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'
+      );
+    });
   });
 });

+ 1 - 1
kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx

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

+ 1 - 1
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%;
 `;
 

+ 33 - 0
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<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;

+ 174 - 0
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<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 - 0
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<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;

+ 12 - 0
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<CellContext<any, any>> = ({ getValue }) => (
+  <S.Nowrap>{formatTimestamp(getValue())}</S.Nowrap>
+);
+
+export default TimestampCell;

+ 181 - 0
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<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);
+    });
+  });
+});

+ 7 - 0
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;

+ 34 - 0
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);
+  });
+});

+ 27 - 0
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<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;
+};

+ 26 - 0
kafka-ui-react-app/src/components/common/NewTable/utils/updateSortingState.ts

@@ -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;
+};

+ 22 - 0
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;
+  `
+);

+ 18 - 0
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<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;

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

+ 17 - 0
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;
+`;

+ 50 - 0
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)
+    );
+  });
 });

+ 12 - 0
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);
+};

+ 75 - 0
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 }],
+      },
+    ],
+  },
+};

+ 44 - 5
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);
+    });
   });
 });

+ 46 - 2
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));
+    },
+  });
+}

+ 10 - 1
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

+ 19 - 0
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;