Bläddra i källkod

[Experimental] New Messages layout (#2462)

Oleg Shur 2 år sedan
förälder
incheckning
d9e74deb28
61 ändrade filer med 1740 tillägg och 276 borttagningar
  1. 4 2
      kafka-ui-react-app/package.json
  2. 31 5
      kafka-ui-react-app/pnpm-lock.yaml
  3. 1 1
      kafka-ui-react-app/src/components/Brokers/Broker/Configs/Configs.tsx
  4. 1 7
      kafka-ui-react-app/src/components/Connect/List/ListPage.tsx
  5. 1 2
      kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/ResetOffsets.tsx
  6. 3 9
      kafka-ui-react-app/src/components/ConsumerGroups/List/List.tsx
  7. 1 7
      kafka-ui-react-app/src/components/Schemas/List/List.tsx
  8. 1 7
      kafka-ui-react-app/src/components/Topics/List/ListPage.tsx
  9. 3 3
      kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZone/__test__/DangerZone.spec.tsx
  10. 2 2
      kafka-ui-react-app/src/components/Topics/Topic/Messages/Filters/AddFilter.tsx
  11. 4 9
      kafka-ui-react-app/src/components/Topics/Topic/Messages/Filters/Filters.tsx
  12. 1 1
      kafka-ui-react-app/src/components/Topics/Topic/Messages/Filters/utils.ts
  13. 0 2
      kafka-ui-react-app/src/components/Topics/Topic/Messages/MessageContent/MessageContent.styled.ts
  14. 1 1
      kafka-ui-react-app/src/components/Topics/Topic/Messages/__test__/utils.spec.ts
  15. 53 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Advanced Filter/AdvancedFilter.tsx
  16. 118 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Advanced Filter/Form.tsx
  17. 75 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/FiltersBar.styled.ts
  18. 144 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/Form.tsx
  19. 46 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/Meta.tsx
  20. 112 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/utils.ts
  21. 61 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Messages.styled.ts
  22. 137 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Messages.tsx
  23. 25 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/ActionsCell.tsx
  24. 55 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessageContent/MessageContent.styled.ts
  25. 106 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessageContent/MessageContent.tsx
  26. 41 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessagesTable.tsx
  27. 39 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/StatusBar.tsx
  28. 50 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/consumingModes.ts
  29. 65 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/handleNextPageClick.ts
  30. 0 1
      kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.styled.tsx
  31. 11 16
      kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx
  32. 9 22
      kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/SendMessage.spec.tsx
  33. 17 7
      kafka-ui-react-app/src/components/Topics/Topic/Topic.tsx
  34. 7 2
      kafka-ui-react-app/src/components/Topics/Topic/__test__/Topic.spec.tsx
  35. 5 5
      kafka-ui-react-app/src/components/common/Dropdown/Dropdown.tsx
  36. 4 4
      kafka-ui-react-app/src/components/common/Input/Input.styled.ts
  37. 2 2
      kafka-ui-react-app/src/components/common/Input/Input.tsx
  38. 6 0
      kafka-ui-react-app/src/components/common/Input/InputLabel.styled.ts
  39. 1 1
      kafka-ui-react-app/src/components/common/MultiSelect/MultiSelect.styled.ts
  40. 1 0
      kafka-ui-react-app/src/components/common/Navigation/Navbar.styled.ts
  41. 8 0
      kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts
  42. 6 8
      kafka-ui-react-app/src/components/common/NewTable/Table.tsx
  43. 11 0
      kafka-ui-react-app/src/components/common/NewTable/TimestampCell copy.tsx
  44. 6 1
      kafka-ui-react-app/src/components/common/PropertiesList/PropertiesList.styled.tsx
  45. 16 10
      kafka-ui-react-app/src/components/common/Search/Search.tsx
  46. 21 21
      kafka-ui-react-app/src/components/common/Search/__tests__/Search.spec.tsx
  47. 36 0
      kafka-ui-react-app/src/components/common/SlidingSidebar/SlidingSidebar.styled.ts
  48. 32 0
      kafka-ui-react-app/src/components/common/SlidingSidebar/SlidingSidebar.tsx
  49. 3 0
      kafka-ui-react-app/src/components/common/SlidingSidebar/index.ts
  50. 0 11
      kafka-ui-react-app/src/lib/__test__/paths.spec.ts
  51. 3 0
      kafka-ui-react-app/src/lib/constants.ts
  52. 22 0
      kafka-ui-react-app/src/lib/dateTimeHelpers.ts
  53. 66 0
      kafka-ui-react-app/src/lib/hooks/__tests__/useBoolean.spec.ts
  54. 0 66
      kafka-ui-react-app/src/lib/hooks/__tests__/useModal.spec.ts
  55. 177 0
      kafka-ui-react-app/src/lib/hooks/api/topicMessages.tsx
  56. 21 0
      kafka-ui-react-app/src/lib/hooks/useBoolean.ts
  57. 20 0
      kafka-ui-react-app/src/lib/hooks/useLocalStorage.ts
  58. 41 0
      kafka-ui-react-app/src/lib/hooks/useMessageFiltersStore.ts
  59. 0 32
      kafka-ui-react-app/src/lib/hooks/useModal.ts
  60. 0 9
      kafka-ui-react-app/src/lib/paths.ts
  61. 7 0
      kafka-ui-react-app/src/theme/theme.ts

+ 4 - 2
kafka-ui-react-app/package.json

@@ -9,6 +9,7 @@
     "@babel/plugin-transform-react-jsx": "^7.18.6",
     "@hookform/error-message": "^2.0.0",
     "@hookform/resolvers": "^2.7.1",
+    "@microsoft/fetch-event-source": "^2.0.1",
     "@reduxjs/toolkit": "^1.8.3",
     "@szhsin/react-menu": "^3.1.1",
     "@tanstack/react-query": "^4.0.5",
@@ -36,7 +37,7 @@
     "react-hook-form": "7.6.9",
     "react-hot-toast": "^2.3.0",
     "react-is": "^18.2.0",
-    "react-multi-select-component": "^4.0.6",
+    "react-multi-select-component": "^4.3.3",
     "react-redux": "^8.0.2",
     "react-router-dom": "^6.3.0",
     "redux": "^4.2.0",
@@ -46,7 +47,8 @@
     "vite": "^3.0.2",
     "vite-tsconfig-paths": "^3.5.0",
     "whatwg-fetch": "^3.6.2",
-    "yup": "^0.32.9"
+    "yup": "^0.32.9",
+    "zustand": "^4.1.1"
   },
   "lint-staged": {
     "*.{ts,tsx}": [

+ 31 - 5
kafka-ui-react-app/pnpm-lock.yaml

@@ -10,6 +10,7 @@ specifiers:
   '@hookform/error-message': ^2.0.0
   '@hookform/resolvers': ^2.7.1
   '@jest/types': ^29.0.3
+  '@microsoft/fetch-event-source': ^2.0.1
   '@openapitools/openapi-generator-cli': ^2.5.1
   '@reduxjs/toolkit': ^1.8.3
   '@szhsin/react-menu': ^3.1.1
@@ -72,7 +73,7 @@ specifiers:
   react-hook-form: 7.6.9
   react-hot-toast: ^2.3.0
   react-is: ^18.2.0
-  react-multi-select-component: ^4.0.6
+  react-multi-select-component: ^4.3.3
   react-redux: ^8.0.2
   react-router-dom: ^6.3.0
   redux: ^4.2.0
@@ -88,6 +89,7 @@ specifiers:
   vite-tsconfig-paths: ^3.5.0
   whatwg-fetch: ^3.6.2
   yup: ^0.32.9
+  zustand: ^4.1.1
 
 dependencies:
   '@babel/core': 7.18.2
@@ -95,6 +97,7 @@ dependencies:
   '@babel/plugin-transform-react-jsx': 7.18.6_@babel+core@7.18.2
   '@hookform/error-message': 2.0.0_l2dcsysovzdujulgxvsen7vbsm
   '@hookform/resolvers': 2.8.9_react-hook-form@7.6.9
+  '@microsoft/fetch-event-source': 2.0.1
   '@reduxjs/toolkit': 1.8.3_ctm756ikdwcjcvyfxxwskzbr6q
   '@szhsin/react-menu': 3.1.1_ef5jwxihqo6n7gxfmzogljlgcm
   '@tanstack/react-query': 4.0.5_ef5jwxihqo6n7gxfmzogljlgcm
@@ -122,7 +125,7 @@ dependencies:
   react-hook-form: 7.6.9_react@18.1.0
   react-hot-toast: 2.3.0_ef5jwxihqo6n7gxfmzogljlgcm
   react-is: 18.2.0
-  react-multi-select-component: 4.0.6_react@18.1.0
+  react-multi-select-component: 4.3.3_ef5jwxihqo6n7gxfmzogljlgcm
   react-redux: 8.0.2_nfqigfgwurfoimtkde74cji6ga
   react-router-dom: 6.3.0_ef5jwxihqo6n7gxfmzogljlgcm
   redux: 4.2.0
@@ -133,6 +136,7 @@ dependencies:
   vite-tsconfig-paths: 3.5.0_vite@3.0.2
   whatwg-fetch: 3.6.2
   yup: 0.32.11
+  zustand: 4.1.1_react@18.1.0
 
 devDependencies:
   '@babel/preset-env': 7.18.2_@babel+core@7.18.2
@@ -3059,6 +3063,10 @@ packages:
       '@jridgewell/resolve-uri': 3.0.7
       '@jridgewell/sourcemap-codec': 1.4.13
 
+  /@microsoft/fetch-event-source/2.0.1:
+    resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
+    dev: false
+
   /@nestjs/common/8.4.4_47vcjb2de6lyibr6g4enoa5lyu:
     resolution: {integrity: sha512-QHi7QcgH/5Jinz+SCfIZJkFHc6Cch1YsAEGFEhi6wSp6MILb0sJMQ1CX06e9tCOAjSlBwaJj4PH0eFCVau5v9Q==}
     peerDependencies:
@@ -7659,12 +7667,14 @@ packages:
   /react-is/18.2.0:
     resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
 
-  /react-multi-select-component/4.0.6_react@18.1.0:
-    resolution: {integrity: sha512-cNpDv8vh1kWkJiMsa097tTUqWLVTQn+La4aXlgoGOQVpOSH9u1fbj1+MsvnLQjTBySuDx+pzm/DpbIoma/i1Fw==}
+  /react-multi-select-component/4.3.3_ef5jwxihqo6n7gxfmzogljlgcm:
+    resolution: {integrity: sha512-V8cDJC3M7F27PWv1baV8FpJReHa/SbpJGL80CmXwnlMkDK2KMlQSRDmDzBnmCjcbROIgoztdW+gYBpqo9BIF4g==}
     peerDependencies:
-      react: '>=17'
+      react: ^16 || ^17 || ^18
+      react-dom: ^16 || ^17 || ^18
     dependencies:
       react: 18.1.0
+      react-dom: 18.1.0_react@18.1.0
     dev: false
 
   /react-onclickoutside/6.12.1_ef5jwxihqo6n7gxfmzogljlgcm:
@@ -8934,3 +8944,19 @@ packages:
       property-expr: 2.0.4
       toposort: 2.0.2
     dev: false
+
+  /zustand/4.1.1_react@18.1.0:
+    resolution: {integrity: sha512-h4F3WMqsZgvvaE0n3lThx4MM81Ls9xebjvrABNzf5+jb3/03YjNTSgZXeyrvXDArMeV9untvWXRw1tY+ntPYbA==}
+    engines: {node: '>=12.7.0'}
+    peerDependencies:
+      immer: '>=9.0'
+      react: '>=16.8'
+    peerDependenciesMeta:
+      immer:
+        optional: true
+      react:
+        optional: true
+    dependencies:
+      react: 18.1.0
+      use-sync-external-store: 1.2.0_react@18.1.0
+    dev: false

+ 1 - 1
kafka-ui-react-app/src/components/Brokers/Broker/Configs/Configs.tsx

@@ -68,7 +68,7 @@ const Configs: React.FC = () => {
     <>
       <S.SearchWrapper>
         <Search
-          handleSearch={setKeyword}
+          onChange={setKeyword}
           placeholder="Search by Key"
           value={keyword}
         />

+ 1 - 7
kafka-ui-react-app/src/components/Connect/List/ListPage.tsx

@@ -7,7 +7,6 @@ import * as Metrics from 'components/common/Metrics';
 import PageHeading from 'components/common/PageHeading/PageHeading';
 import { Button } from 'components/common/Button/Button';
 import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
-import useSearch from 'lib/hooks/useSearch';
 import PageLoader from 'components/common/PageLoader/PageLoader';
 import { ConnectorState } from 'generated-sources';
 import { useConnectors } from 'lib/hooks/api/kafkaConnect';
@@ -17,7 +16,6 @@ import List from './List';
 const ListPage: React.FC = () => {
   const { isReadOnly } = React.useContext(ClusterContext);
   const { clusterName } = useAppParams<ClusterNameRoute>();
-  const [search, handleSearch] = useSearch();
 
   // Fetches all connectors from the API, without search criteria. Used to display general metrics.
   const { data: connectorsMetrics, isLoading } = useConnectors(clusterName);
@@ -70,11 +68,7 @@ const ListPage: React.FC = () => {
         </Metrics.Section>
       </Metrics.Wrapper>
       <ControlPanelWrapper hasInput>
-        <Search
-          handleSearch={handleSearch}
-          placeholder="Search by Connect Name, Status or Type"
-          value={search}
-        />
+        <Search placeholder="Search by Connect Name, Status or Type" />
       </ControlPanelWrapper>
       <Suspense fallback={<PageLoader />}>
         <List />

+ 1 - 2
kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/ResetOffsets.tsx

@@ -8,8 +8,7 @@ import {
   useFieldArray,
   useForm,
 } from 'react-hook-form';
-import MultiSelect from 'react-multi-select-component';
-import { Option } from 'react-multi-select-component/dist/lib/interfaces';
+import { MultiSelect, Option } from 'react-multi-select-component';
 import DatePicker from 'react-datepicker';
 import 'react-datepicker/dist/react-datepicker.css';
 import groupBy from 'lodash/groupBy';

+ 3 - 9
kafka-ui-react-app/src/components/ConsumerGroups/List/List.tsx

@@ -7,7 +7,6 @@ import {
   ConsumerGroupOrdering,
   SortOrder,
 } from 'generated-sources';
-import useSearch from 'lib/hooks/useSearch';
 import { useAppDispatch } from 'lib/hooks/redux';
 import useAppParams from 'lib/hooks/useAppParams';
 import { clusterConsumerGroupDetailsPath, ClusterNameRoute } from 'lib/paths';
@@ -23,7 +22,6 @@ export interface Props {
 }
 
 const List: React.FC<Props> = ({ consumerGroups, totalPages }) => {
-  const [searchText, handleSearchText] = useSearch();
   const dispatch = useAppDispatch();
   const { clusterName } = useAppParams<ClusterNameRoute>();
   const [searchParams] = useSearchParams();
@@ -40,10 +38,10 @@ const List: React.FC<Props> = ({ consumerGroups, totalPages }) => {
           undefined,
         page: Number(searchParams.get('page') || 1),
         perPage: Number(searchParams.get('perPage') || PER_PAGE),
-        search: searchText,
+        search: searchParams.get('q') || '',
       })
     );
-  }, [clusterName, searchText, dispatch, searchParams]);
+  }, [clusterName, dispatch, searchParams]);
 
   const columns = React.useMemo<ColumnDef<ConsumerGroupDetails>[]>(
     () => [
@@ -87,11 +85,7 @@ const List: React.FC<Props> = ({ consumerGroups, totalPages }) => {
     <>
       <PageHeading text="Consumers" />
       <ControlPanelWrapper hasInput>
-        <Search
-          placeholder="Search by Consumer Group ID"
-          value={searchText}
-          handleSearch={handleSearchText}
-        />
+        <Search placeholder="Search by Consumer Group ID" />
       </ControlPanelWrapper>
       <Table
         columns={columns}

+ 1 - 7
kafka-ui-react-app/src/components/Schemas/List/List.tsx

@@ -19,7 +19,6 @@ import PageLoader from 'components/common/PageLoader/PageLoader';
 import { resetLoaderById } from 'redux/reducers/loader/loaderSlice';
 import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
 import Search from 'components/common/Search/Search';
-import useSearch from 'lib/hooks/useSearch';
 import PlusIcon from 'components/common/Icons/PlusIcon';
 import Table, { LinkCell } from 'components/common/NewTable';
 import { ColumnDef } from '@tanstack/react-table';
@@ -38,7 +37,6 @@ const List: React.FC = () => {
   const isFetched = useAppSelector(getAreSchemasFulfilled);
   const totalPages = useAppSelector((state) => state.schemas.totalPages);
   const [searchParams] = useSearchParams();
-  const [searchText, handleSearchText] = useSearch();
 
   React.useEffect(() => {
     dispatch(
@@ -82,11 +80,7 @@ const List: React.FC = () => {
         )}
       </PageHeading>
       <ControlPanelWrapper hasInput>
-        <Search
-          placeholder="Search by Schema Name"
-          value={searchText}
-          handleSearch={handleSearchText}
-        />
+        <Search placeholder="Search by Schema Name" />
       </ControlPanelWrapper>
       {isFetched ? (
         <Table

+ 1 - 7
kafka-ui-react-app/src/components/Topics/List/ListPage.tsx

@@ -9,13 +9,11 @@ import PageHeading from 'components/common/PageHeading/PageHeading';
 import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
 import Switch from 'components/common/Switch/Switch';
 import PlusIcon from 'components/common/Icons/PlusIcon';
-import useSearch from 'lib/hooks/useSearch';
 import PageLoader from 'components/common/PageLoader/PageLoader';
 import TopicTable from 'components/Topics/List/TopicTable';
 
 const ListPage: React.FC = () => {
   const { isReadOnly } = React.useContext(ClusterContext);
-  const [searchQuery, handleSearchQuery] = useSearch();
   const [searchParams, setSearchParams] = useSearchParams();
 
   // Set the search params to the url based on the localStorage value
@@ -59,11 +57,7 @@ const ListPage: React.FC = () => {
         )}
       </PageHeading>
       <ControlPanelWrapper hasInput>
-        <Search
-          handleSearch={handleSearchQuery}
-          placeholder="Search by Topic Name"
-          value={searchQuery}
-        />
+        <Search placeholder="Search by Topic Name" />
         <label>
           <Switch
             name="ShowInternalTopics"

+ 3 - 3
kafka-ui-react-app/src/components/Topics/Topic/Edit/DangerZone/__test__/DangerZone.spec.tsx

@@ -5,11 +5,11 @@ import DangerZone, {
 import { act, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render, WithRoute } from 'lib/testHelpers';
-import { clusterTopicSendMessagePath } from 'lib/paths';
 import {
   useIncreaseTopicPartitionsCount,
   useUpdateTopicReplicationFactor,
 } from 'lib/hooks/api/topics';
+import { clusterTopicPath } from 'lib/paths';
 
 const defaultPartitions = 3;
 const defaultReplicationFactor = 3;
@@ -24,14 +24,14 @@ jest.mock('lib/hooks/api/topics', () => ({
 
 const renderComponent = (props?: Partial<DangerZoneProps>) =>
   render(
-    <WithRoute path={clusterTopicSendMessagePath()}>
+    <WithRoute path={clusterTopicPath()}>
       <DangerZone
         defaultPartitions={defaultPartitions}
         defaultReplicationFactor={defaultReplicationFactor}
         {...props}
       />
     </WithRoute>,
-    { initialEntries: [clusterTopicSendMessagePath(clusterName, topicName)] }
+    { initialEntries: [clusterTopicPath(clusterName, topicName)] }
   );
 
 const clickOnDialogSubmitButton = () => {

+ 2 - 2
kafka-ui-react-app/src/components/Topics/Topic/Messages/Filters/AddFilter.tsx

@@ -5,7 +5,7 @@ import { FilterEdit } from 'components/Topics/Topic/Messages/Filters/FilterModal
 import SavedFilters from 'components/Topics/Topic/Messages/Filters/SavedFilters';
 import SavedIcon from 'components/common/Icons/SavedIcon';
 import QuestionIcon from 'components/common/Icons/QuestionIcon';
-import useModal from 'lib/hooks/useModal';
+import useBoolean from 'lib/hooks/useBoolean';
 
 import AddEditFilterContainer from './AddEditFilterContainer';
 import InfoModal from './InfoModal';
@@ -39,7 +39,7 @@ const AddFilter: React.FC<FilterModalProps> = ({
   onClickSavedFilters,
   activeFilter,
 }) => {
-  const { isOpen, toggle } = useModal();
+  const { value: isOpen, toggle } = useBoolean();
 
   const onSubmit = React.useCallback(
     async (values: AddMessageFilters) => {

+ 4 - 9
kafka-ui-react-app/src/components/Topics/Topic/Messages/Filters/Filters.tsx

@@ -14,7 +14,7 @@ import React, { useContext } from 'react';
 import omitBy from 'lodash/omitBy';
 import { useNavigate, useLocation } from 'react-router-dom';
 import MultiSelect from 'components/common/MultiSelect/MultiSelect.styled';
-import { Option } from 'react-multi-select-component/dist/lib/interfaces';
+import { Option } from 'react-multi-select-component';
 import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
 import { BASE_PARAMS } from 'lib/constants';
 import Select from 'components/common/Select/Select';
@@ -25,7 +25,7 @@ import FilterModal, {
 } from 'components/Topics/Topic/Messages/Filters/FilterModal';
 import { SeekDirectionOptions } from 'components/Topics/Topic/Messages/Messages';
 import TopicMessagesContext from 'components/contexts/TopicMessagesContext';
-import useModal from 'lib/hooks/useModal';
+import useBoolean from 'lib/hooks/useBoolean';
 import { RouteParamsClusterTopic } from 'lib/paths';
 import useAppParams from 'lib/hooks/useAppParams';
 import PlusIcon from 'components/common/Icons/PlusIcon';
@@ -95,7 +95,7 @@ const Filters: React.FC<FiltersProps> = ({
   const { searchParams, seekDirection, isLive, changeSeekDirection } =
     useContext(TopicMessagesContext);
 
-  const { isOpen, toggle } = useModal();
+  const { value: isOpen, toggle } = useBoolean();
 
   const source = React.useRef<EventSource | null>(null);
 
@@ -393,12 +393,7 @@ const Filters: React.FC<FiltersProps> = ({
     <S.FiltersWrapper>
       <div>
         <S.FilterInputs>
-          <Search
-            placeholder="Search"
-            value={query}
-            disabled={isTailing}
-            handleSearch={(value: string) => setQuery(value)}
-          />
+          <Search placeholder="Search" disabled={isTailing} />
           <S.SeekTypeSelectorWrapper>
             <S.SeekTypeSelect
               id="selectSeekType"

+ 1 - 1
kafka-ui-react-app/src/components/Topics/Topic/Messages/Filters/utils.ts

@@ -1,6 +1,6 @@
 import { Partition, SeekType } from 'generated-sources';
 import compact from 'lodash/compact';
-import { Option } from 'react-multi-select-component/dist/lib/interfaces';
+import { Option } from 'react-multi-select-component';
 
 export const filterOptions = (options: Option[], filter: string) => {
   if (!filter) {

+ 0 - 2
kafka-ui-react-app/src/components/Topics/Topic/Messages/MessageContent/MessageContent.styled.ts

@@ -31,7 +31,6 @@ export const ContentBox = styled.div`
   & nav {
     padding-bottom: 16px;
   }
-
   ${SEditorViewer.Wrapper} {
     flex-grow: 1;
   }
@@ -94,5 +93,4 @@ export const Tab = styled.button<{ $active?: boolean }>(
     }
   `
 );
-
 export const Tabs = styled.nav``;

+ 1 - 1
kafka-ui-react-app/src/components/Topics/Topic/Messages/__test__/utils.spec.ts

@@ -1,4 +1,4 @@
-import { Option } from 'react-multi-select-component/dist/lib/interfaces';
+import { Option } from 'react-multi-select-component';
 import {
   filterOptions,
   getOffsetFromSeekToParam,

+ 53 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Advanced Filter/AdvancedFilter.tsx

@@ -0,0 +1,53 @@
+import React from 'react';
+import { useMessageFiltersStore } from 'lib/hooks/useMessageFiltersStore';
+import * as StyledTable from 'components/common/NewTable/Table.styled';
+import Heading from 'components/common/heading/Heading.styled';
+import { Dropdown, DropdownItem } from 'components/common/Dropdown';
+
+import Form from './Form';
+
+const AdvancedFilter = () => {
+  const { save, apply, filters, remove } = useMessageFiltersStore();
+  return (
+    <div>
+      <Heading level={4}>Add new filter</Heading>
+      <Form save={save} apply={apply} />
+      {filters.length > 0 && (
+        <>
+          <Heading level={4}>Saved Filters</Heading>
+          <StyledTable.Table>
+            <thead>
+              <tr>
+                <StyledTable.Th>Name</StyledTable.Th>
+                <StyledTable.Th>Value</StyledTable.Th>
+                <StyledTable.Th> </StyledTable.Th>
+              </tr>
+            </thead>
+            <tbody>
+              {filters.map((filter) => (
+                <tr key={filter.name}>
+                  <td>{filter.name}</td>
+                  <td>
+                    <pre>{filter.value}</pre>
+                  </td>
+                  <td>
+                    <Dropdown>
+                      <DropdownItem onClick={() => apply(filter)}>
+                        Apply Filter
+                      </DropdownItem>
+                      <DropdownItem onClick={() => remove(filter.name)}>
+                        Delete filter
+                      </DropdownItem>
+                    </Dropdown>
+                  </td>
+                </tr>
+              ))}
+            </tbody>
+          </StyledTable.Table>
+        </>
+      )}
+    </div>
+  );
+};
+
+export default AdvancedFilter;

+ 118 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Advanced Filter/Form.tsx

@@ -0,0 +1,118 @@
+import React from 'react';
+import * as S from 'components/Topics/Topic/Messages/Filters/Filters.styled';
+import { InputLabel } from 'components/common/Input/InputLabel.styled';
+import Input from 'components/common/Input/Input';
+import { FormProvider, Controller, useForm } from 'react-hook-form';
+import { ErrorMessage } from '@hookform/error-message';
+import { Button } from 'components/common/Button/Button';
+import { FormError } from 'components/common/Input/Input.styled';
+import Editor from 'components/common/Editor/Editor';
+import { yupResolver } from '@hookform/resolvers/yup';
+import yup from 'lib/yupExtended';
+import { AdvancedFilter } from 'lib/hooks/useMessageFiltersStore';
+
+const validationSchema = yup.object().shape({
+  value: yup.string().required(),
+  name: yup.string().required(),
+});
+
+export interface FormProps {
+  name?: string;
+  value?: string;
+  save(filter: AdvancedFilter): void;
+  apply(filter: AdvancedFilter): void;
+}
+
+const Form: React.FC<FormProps> = ({ name, value, save, apply }) => {
+  const methods = useForm<AdvancedFilter>({
+    mode: 'onChange',
+    resolver: yupResolver(validationSchema),
+  });
+  const {
+    handleSubmit,
+    control,
+    formState: { isDirty, isSubmitting, isValid, errors },
+    reset,
+    getValues,
+  } = methods;
+
+  const onSubmit = React.useCallback(
+    (values: AdvancedFilter) => {
+      apply(values);
+      reset({ name: '', value: '' });
+    },
+    [reset, save]
+  );
+
+  const onSave = React.useCallback(() => {
+    save(getValues());
+    handleSubmit(onSubmit);
+  }, []);
+
+  return (
+    <FormProvider {...methods}>
+      <form onSubmit={handleSubmit(onSubmit)} aria-label="Filters submit Form">
+        <div>
+          <InputLabel>Filter code</InputLabel>
+          <Controller
+            control={control}
+            name="value"
+            defaultValue={value}
+            render={({ field }) => (
+              <Editor
+                value={field.value}
+                minLines={5}
+                maxLines={28}
+                onChange={field.onChange}
+                setOptions={{
+                  showLineNumbers: false,
+                }}
+              />
+            )}
+          />
+        </div>
+        <div>
+          <FormError>
+            <ErrorMessage errors={errors} name="value" />
+          </FormError>
+        </div>
+        <div>
+          <InputLabel>Display name</InputLabel>
+          <Input
+            inputSize="M"
+            placeholder="Enter Name"
+            autoComplete="off"
+            name="name"
+            defaultValue={name}
+          />
+        </div>
+        <div>
+          <FormError>
+            <ErrorMessage errors={errors} name="name" />
+          </FormError>
+        </div>
+        <S.FilterButtonWrapper>
+          <Button
+            buttonSize="M"
+            buttonType="secondary"
+            type="submit"
+            disabled={!isValid || isSubmitting || !isDirty}
+            onClick={onSave}
+          >
+            Save & Apply
+          </Button>
+          <Button
+            buttonSize="M"
+            buttonType="primary"
+            type="submit"
+            disabled={isSubmitting || !isDirty}
+          >
+            Apply Filter
+          </Button>
+        </S.FilterButtonWrapper>
+      </form>
+    </FormProvider>
+  );
+};
+
+export default Form;

+ 75 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/FiltersBar.styled.ts

@@ -0,0 +1,75 @@
+import styled from 'styled-components';
+import DatePicker from 'react-datepicker';
+
+export const Meta = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  padding: 6px 16px;
+  border-bottom: 1px solid ${({ theme }) => theme.layout.stuffBorderColor};
+`;
+
+export const MetaRow = styled.div`
+  display: flex;
+  align-items: center;
+  gap: 20px;
+`;
+
+export const Metric = styled.div`
+  color: ${({ theme }) => theme.metrics.filters.color.normal};
+  font-size: 12px;
+  display: flex;
+`;
+
+export const MetricIcon = styled.div`
+  color: ${({ theme }) => theme.metrics.filters.color.icon};
+  padding-right: 6px;
+  height: 12px;
+`;
+
+export const MetaMessage = styled.div.attrs({
+  role: 'contentLoader',
+})`
+  color: ${({ theme }) => theme.heading.h3.color};
+  font-size: 12px;
+  display: flex;
+  gap: 8px;
+`;
+
+export const StopLoading = styled.div`
+  color: ${({ theme }) => theme.pageLoader.borderColor};
+  cursor: pointer;
+`;
+
+export const FilterRow = styled.div`
+  margin: 8px 0 8px;
+`;
+export const FilterFooter = styled.div`
+  display: flex;
+  gap: 8px;
+  justify-content: end;
+  margin: 16px 0;
+`;
+
+export const DatePickerInput = styled(DatePicker)`
+  height: 32px;
+  border: 1px ${(props) => props.theme.select.borderColor.normal} solid;
+  border-radius: 4px;
+  font-size: 14px;
+  width: 100%;
+  padding-left: 12px;
+  color: ${(props) => props.theme.select.color.normal};
+
+  background-image: url('data:image/svg+xml,%3Csvg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 1L5 5L9 1" stroke="%23454F54"/%3E%3C/svg%3E%0A') !important;
+  background-repeat: no-repeat !important;
+  background-position-x: 96% !important;
+  background-position-y: 55% !important;
+  appearance: none !important;
+
+  &:hover {
+    cursor: pointer;
+  }
+  &:focus {
+    outline: none;
+  }
+`;

+ 144 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/Form.tsx

@@ -0,0 +1,144 @@
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { useSearchParams } from 'react-router-dom';
+import Input from 'components/common/Input/Input';
+import { ConsumingMode } from 'lib/hooks/api/topicMessages';
+import Select from 'components/common/Select/Select';
+import { InputLabel } from 'components/common/Input/InputLabel.styled';
+import { Option } from 'react-multi-select-component';
+import { Button } from 'components/common/Button/Button';
+import { Partition } from 'generated-sources';
+import { getModeOptions } from 'components/Topics/Topic/MessagesV2/utils/consumingModes';
+
+import * as S from './FiltersBar.styled';
+import { setSeekTo } from './utils';
+
+type FormValues = {
+  mode: ConsumingMode;
+  offset: string;
+  time: Date;
+  partitions: Option[];
+};
+
+const Form: React.FC<{ isFetching: boolean; partitions: Partition[] }> = ({
+  isFetching,
+  partitions,
+}) => {
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  const {
+    handleSubmit,
+    setValue,
+    watch,
+    formState: { isDirty },
+    reset,
+  } = useForm<FormValues>({
+    defaultValues: {
+      mode: searchParams.get('m') || 'newest',
+      offset: searchParams.get('o') || '0',
+      time: searchParams.get('t')
+        ? new Date(Number(searchParams.get('t')))
+        : Date.now(),
+    } as FormValues,
+  });
+  const mode = watch('mode');
+  const offset = watch('offset');
+  const time = watch('time');
+
+  const onSubmit = (values: FormValues) => {
+    searchParams.set('m', values.mode);
+    searchParams.delete('o');
+    searchParams.delete('t');
+    searchParams.delete('a');
+    searchParams.delete('page');
+    if (values.mode === 'fromOffset' || values.mode === 'toOffset') {
+      searchParams.set('o', values.offset);
+    } else if (values.mode === 'sinceTime' || values.mode === 'untilTime') {
+      searchParams.set('t', `${values.time.getTime()}`);
+    }
+    setSeekTo(searchParams, partitions);
+    setSearchParams(searchParams);
+    reset(values);
+  };
+
+  const handleTimestampChange = (value: Date | null) => {
+    if (value) {
+      setValue('time', value, { shouldDirty: true });
+    }
+  };
+  const handleOffsetChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    setValue('offset', e.target.value, { shouldDirty: true });
+  };
+  const handleRefresh: React.MouseEventHandler<HTMLButtonElement> = (e) => {
+    e.stopPropagation();
+    e.preventDefault();
+    searchParams.set('a', `${Number(searchParams.get('a') || 0) + 1}`);
+    setSearchParams(searchParams);
+  };
+
+  return (
+    <form onSubmit={handleSubmit(onSubmit)}>
+      <S.FilterRow>
+        <InputLabel>Mode</InputLabel>
+        <Select
+          selectSize="M"
+          minWidth="100%"
+          value={mode}
+          options={getModeOptions()}
+          isLive={mode === 'live' && isFetching}
+          onChange={(option: string | number) =>
+            setValue('mode', option as ConsumingMode, { shouldDirty: true })
+          }
+        />
+      </S.FilterRow>
+      {['sinceTime', 'untilTime'].includes(mode) && (
+        <S.FilterRow>
+          <InputLabel>Time</InputLabel>
+          <S.DatePickerInput
+            selected={time}
+            onChange={handleTimestampChange}
+            showTimeInput
+            timeInputLabel="Time:"
+            dateFormat="MMMM d, yyyy HH:mm"
+            placeholderText="Select timestamp"
+          />
+        </S.FilterRow>
+      )}
+      {['fromOffset', 'toOffset'].includes(mode) && (
+        <S.FilterRow>
+          <InputLabel>Offset</InputLabel>
+          <Input
+            type="text"
+            inputSize="M"
+            value={offset}
+            placeholder="Offset"
+            onChange={handleOffsetChange}
+          />
+        </S.FilterRow>
+      )}
+      <S.FilterFooter>
+        <Button
+          buttonType="secondary"
+          disabled={!isDirty}
+          buttonSize="S"
+          onClick={() => reset()}
+        >
+          Clear All
+        </Button>
+        <Button
+          buttonType="secondary"
+          buttonSize="S"
+          disabled={isDirty || isFetching}
+          onClick={handleRefresh}
+        >
+          Refresh
+        </Button>
+        <Button buttonType="primary" disabled={!isDirty} buttonSize="S">
+          Apply Mode
+        </Button>
+      </S.FilterFooter>
+    </form>
+  );
+};
+
+export default Form;

+ 46 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/Meta.tsx

@@ -0,0 +1,46 @@
+import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
+import ArrowDownIcon from 'components/common/Icons/ArrowDownIcon';
+import ClockIcon from 'components/common/Icons/ClockIcon';
+import FileIcon from 'components/common/Icons/FileIcon';
+import { TopicMessageConsuming } from 'generated-sources';
+import { formatMilliseconds } from 'lib/dateTimeHelpers';
+import React from 'react';
+
+import * as S from './FiltersBar.styled';
+
+interface MetaProps {
+  meta?: TopicMessageConsuming;
+  phase?: string;
+  isFetching: boolean;
+}
+
+const Meta: React.FC<MetaProps> = ({ meta = {} }) => {
+  const { bytesConsumed, messagesConsumed, elapsedMs } = meta;
+
+  return (
+    <S.Meta>
+      <S.MetaRow>
+        <S.Metric title="Messages Consumed">
+          <S.MetricIcon>
+            <FileIcon />
+          </S.MetricIcon>
+          <span>{messagesConsumed || 0} msg.</span>
+        </S.Metric>
+        <S.Metric title="Bytes Consumed">
+          <S.MetricIcon>
+            <ArrowDownIcon />
+          </S.MetricIcon>
+          <BytesFormatted value={bytesConsumed || 0} />
+        </S.Metric>
+        <S.Metric title="Elapsed Time">
+          <S.MetricIcon>
+            <ClockIcon />
+          </S.MetricIcon>
+          <span>{formatMilliseconds(elapsedMs)}</span>
+        </S.Metric>
+      </S.MetaRow>
+    </S.Meta>
+  );
+};
+
+export default Meta;

+ 112 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/utils.ts

@@ -0,0 +1,112 @@
+import { Partition } from 'generated-sources';
+import { ConsumingMode } from 'lib/hooks/api/topicMessages';
+import { Option } from 'react-multi-select-component';
+
+export const filterOptions = (options: Option[], filter: string) => {
+  if (!filter) {
+    return options;
+  }
+  return options.filter(({ value }) => value && value.toString() === filter);
+};
+
+export const convertPartitionsToOptions = (ids: Array<string>): Option[] =>
+  ids.map((id) => ({
+    label: `Partition #${id}`,
+    value: `${id}`,
+  }));
+
+export const getSelectedPartitions = (
+  allIds: string[],
+  query: string | null
+) => {
+  let selectedIds: string[] = [];
+  switch (query) {
+    case null: // Empty array of partitions in searchParams - means all
+    case 'all':
+      selectedIds = allIds;
+      break;
+    case 'none':
+      selectedIds = [];
+      break;
+    default:
+      selectedIds = query.split('.');
+      break;
+  }
+  return convertPartitionsToOptions(selectedIds);
+};
+
+type PartionOffsetKey = 'offsetMax' | 'offsetMin';
+
+const generateSeekTo = (
+  partitions: Partition[],
+  type: 'property' | 'value',
+  value: PartionOffsetKey | string
+) => {
+  // we iterating over existing partitions to avoid sending wrong partition ids to the backend
+  const seekTo = partitions.map((partition) => {
+    const { partition: id } = partition;
+    switch (type) {
+      case 'property':
+        return `${id}-${partition[value as PartionOffsetKey]}`;
+      case 'value':
+        return `${id}-${value}`;
+      default:
+        return null;
+    }
+  });
+
+  return seekTo.join('.');
+};
+
+export const generateSeekToForSelectedPartitions = (
+  mode: ConsumingMode,
+  partitions: Partition[],
+  offset: string,
+  time: string
+) => {
+  switch (mode) {
+    case 'live':
+    case 'newest':
+      return generateSeekTo(partitions, 'property', 'offsetMax');
+    case 'fromOffset':
+    case 'toOffset':
+      return generateSeekTo(partitions, 'value', offset);
+    case 'sinceTime':
+    case 'untilTime':
+      return generateSeekTo(partitions, 'value', time);
+    default:
+      // case 'oldest';
+      return generateSeekTo(partitions, 'value', '0');
+  }
+};
+
+export const setSeekTo = (
+  searchParams: URLSearchParams,
+  partitions: Partition[]
+) => {
+  const currentSeekTo = searchParams.get('seekTo');
+  const mode = searchParams.get('m') as ConsumingMode;
+  const offset = (searchParams.get('o') as string) || '0';
+  const time =
+    (searchParams.get('t') as string) || new Date().getTime().toString();
+
+  let selectedPartitions: Partition[] = [];
+  // if not `seekTo` property in search params, we set it to all partition
+  if (!currentSeekTo) {
+    selectedPartitions = partitions;
+  } else {
+    const partitionIds = currentSeekTo
+      .split('.')
+      .map((prop) => prop.split('-')[0]);
+
+    selectedPartitions = partitions.filter(({ partition }) =>
+      partitionIds.includes(String(partition))
+    );
+  }
+  searchParams.set(
+    'seekTo',
+    generateSeekToForSelectedPartitions(mode, selectedPartitions, offset, time)
+  );
+
+  return searchParams;
+};

+ 61 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Messages.styled.ts

@@ -0,0 +1,61 @@
+import styled, { css } from 'styled-components';
+
+export const Wrapper = styled.div(
+  ({ theme }) => css`
+    display: grid;
+    grid-template-columns: 300px 1fr;
+    justify-items: center;
+    min-height: calc(
+      100vh - ${theme.layout.navBarHeight} - ${theme.pageHeading.height} -
+        ${theme.primaryTab.height}
+    );
+  `
+);
+
+export const Sidebar = styled.div(
+  ({ theme }) => css`
+    width: 300px;
+    position: sticky;
+    top: ${theme.layout.navBarHeight};
+    align-self: start;
+  `
+);
+
+export const SidebarContent = styled.div`
+  padding: 8px 16px 16px;
+`;
+
+export const TableWrapper = styled.div(
+  ({ theme }) => css`
+    width: 100%;
+    border-left: 1px solid ${theme.layout.stuffBorderColor};
+  `
+);
+
+export const Pagination = styled.div`
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 8px;
+  position: fixed;
+  bottom: 0;
+  padding: 16px;
+  width: 300px;
+`;
+
+export const StatusBarWrapper = styled.div(
+  ({ theme }) => css`
+    padding: 4px 8px;
+    position: sticky;
+    top: ${theme.layout.navBarHeight};
+    background-color: ${theme.layout.backgroundColor};
+    border-bottom: 1px solid ${theme.layout.stuffBorderColor};
+    white-space: nowrap;
+    display: flex;
+    justify-content: space-between;
+  `
+);
+
+export const StatusTags = styled.div`
+  display: flex;
+  gap: 4px;
+`;

+ 137 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Messages.tsx

@@ -0,0 +1,137 @@
+import React from 'react';
+import { ConsumingMode, useTopicMessages } from 'lib/hooks/api/topicMessages';
+import useAppParams from 'lib/hooks/useAppParams';
+import { RouteParamsClusterTopic } from 'lib/paths';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useTopicDetails } from 'lib/hooks/api/topics';
+import { MESSAGES_PER_PAGE } from 'lib/constants';
+import Search from 'components/common/Search/Search';
+import { Button } from 'components/common/Button/Button';
+import PlusIcon from 'components/common/Icons/PlusIcon';
+import SlidingSidebar from 'components/common/SlidingSidebar';
+import useBoolean from 'lib/hooks/useBoolean';
+
+import MessagesTable from './MessagesTable/MessagesTable';
+import * as S from './Messages.styled';
+import Meta from './FiltersBar/Meta';
+import Form from './FiltersBar/Form';
+import { setSeekTo } from './FiltersBar/utils';
+import handleNextPageClick from './utils/handleNextPageClick';
+import StatusBar from './StatusBar';
+import AdvancedFilter from './Advanced Filter/AdvancedFilter';
+
+const Messages = () => {
+  const routerProps = useAppParams<RouteParamsClusterTopic>();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const navigate = useNavigate();
+  const {
+    value: isAdvancedFiltersSidebarVisible,
+    setFalse: closeAdvancedFiltersSidebar,
+    setTrue: openAdvancedFiltersSidebar,
+  } = useBoolean();
+  const { messages, meta, phase, isFetching } = useTopicMessages({
+    ...routerProps,
+    searchParams,
+  });
+  const mode = searchParams.get('m') as ConsumingMode;
+  const isTailing = mode === 'live' && isFetching;
+  const { data: topic = { partitions: [] } } = useTopicDetails(routerProps);
+
+  const partitions = topic.partitions || [];
+
+  /**
+   * Search params:
+   * - `q` - search query
+   * - `m` - way the consumer is going to consume the messages..
+   * - `o` - offset
+   * - `t` - timestamp
+   * - `perPage` - number of messages per page
+   * - `seekTo` - offset or timestamp to seek to.
+   *    Format: `0-101.1-987` - [partition 0, offset 101], [partition 1, offset 987]
+   * - `page` - page number
+   */
+  React.useEffect(() => {
+    if (!mode) {
+      searchParams.set('m', 'newest');
+    }
+    if (!searchParams.get('perPage')) {
+      searchParams.set('perPage', MESSAGES_PER_PAGE);
+    }
+    if (!searchParams.get('seekTo')) {
+      setSeekTo(searchParams, partitions);
+    }
+    setSearchParams(searchParams);
+  }, [topic]);
+
+  // Pagination is disabled in live mode, also we don't want to show the button
+  // if we are fetching the messages or if we are at the end of the topic
+  const isPaginationDisabled =
+    isTailing ||
+    ['newest', 'oldest'].includes(mode) || // TODO: remove after BE is fixed
+    isFetching ||
+    !searchParams.get('seekTo');
+
+  const isNextPageButtonDisabled =
+    isPaginationDisabled ||
+    messages.length < Number(searchParams.get('perPage') || MESSAGES_PER_PAGE);
+  const isPrevPageButtonDisabled =
+    isPaginationDisabled || !searchParams.get('page');
+
+  const handleNextPage = () =>
+    handleNextPageClick(messages, searchParams, setSearchParams);
+
+  return (
+    <>
+      <S.Wrapper>
+        <S.Sidebar>
+          <Meta meta={meta} phase={phase} isFetching={isFetching} />
+          <S.SidebarContent>
+            <Search placeholder="Search" />
+            <Form isFetching={isFetching} partitions={partitions} />
+          </S.SidebarContent>
+          <S.Pagination>
+            <Button
+              buttonType="secondary"
+              buttonSize="L"
+              disabled={isPrevPageButtonDisabled}
+              onClick={() => navigate(-1)}
+            >
+              ← Back
+            </Button>
+            <Button
+              buttonType="secondary"
+              buttonSize="L"
+              disabled={isNextPageButtonDisabled}
+              onClick={handleNextPage}
+            >
+              Next →
+            </Button>
+          </S.Pagination>
+        </S.Sidebar>
+        <S.TableWrapper>
+          <S.StatusBarWrapper>
+            <StatusBar />
+            <Button
+              buttonType="primary"
+              buttonSize="S"
+              onClick={openAdvancedFiltersSidebar}
+            >
+              <PlusIcon />
+              Advanced Filter
+            </Button>
+          </S.StatusBarWrapper>
+          <MessagesTable messages={messages} isLive={isTailing} />
+        </S.TableWrapper>
+      </S.Wrapper>
+      <SlidingSidebar
+        title="Advanced filtering"
+        open={isAdvancedFiltersSidebarVisible}
+        onClose={closeAdvancedFiltersSidebar}
+      >
+        <AdvancedFilter />
+      </SlidingSidebar>
+    </>
+  );
+};
+
+export default Messages;

+ 25 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/ActionsCell.tsx

@@ -0,0 +1,25 @@
+import React from 'react';
+import { TopicMessage } from 'generated-sources';
+import { CellContext } from '@tanstack/react-table';
+import { Dropdown, DropdownItem } from 'components/common/Dropdown';
+import useDataSaver from 'lib/hooks/useDataSaver';
+
+const ActionsCell: React.FC<CellContext<TopicMessage, unknown>> = ({ row }) => {
+  const { content } = row.original;
+
+  const { copyToClipboard, saveFile } = useDataSaver(
+    'topic-message',
+    content || ''
+  );
+
+  return (
+    <Dropdown>
+      <DropdownItem onClick={copyToClipboard}>
+        Copy content to clipboard
+      </DropdownItem>
+      <DropdownItem onClick={saveFile}>Save content as a file</DropdownItem>
+    </Dropdown>
+  );
+};
+
+export default ActionsCell;

+ 55 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessageContent/MessageContent.styled.ts

@@ -0,0 +1,55 @@
+import styled, { css } from 'styled-components';
+import * as SEditorViewer from 'components/common/EditorViewer/EditorViewer.styled';
+
+export const Section = styled.div`
+  display: grid;
+  grid-template-columns: 1fr 400px;
+  align-items: stretch;
+`;
+
+export const ContentBox = styled.div`
+  background-color: white;
+  border-right: 1px solid ${({ theme }) => theme.layout.stuffBorderColor};
+  display: flex;
+  flex-direction: column;
+  padding-right: 16px;
+  & nav {
+    padding-bottom: 16px;
+  }
+
+  ${SEditorViewer.Wrapper} {
+    flex-grow: 1;
+  }
+`;
+
+export const MetadataWrapper = styled.div`
+  padding-left: 16px;
+`;
+
+export const Tab = styled.button<{ $active?: boolean }>(
+  ({ theme, $active }) => css`
+    background-color: ${theme.secondaryTab.backgroundColor[
+      $active ? 'active' : 'normal'
+    ]};
+    color: ${theme.secondaryTab.color[$active ? 'active' : 'normal']};
+    padding: 6px 16px;
+    height: 32px;
+    border: 1px solid ${theme.layout.stuffBorderColor};
+    cursor: pointer;
+    &:hover {
+      background-color: ${theme.secondaryTab.backgroundColor.hover};
+      color: ${theme.secondaryTab.color.hover};
+    }
+    &:first-child {
+      border-radius: 4px 0 0 4px;
+    }
+    &:last-child {
+      border-radius: 0 4px 4px 0;
+    }
+    &:not(:last-child) {
+      border-right: 0px;
+    }
+  `
+);
+
+export const Tabs = styled.nav``;

+ 106 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessageContent/MessageContent.tsx

@@ -0,0 +1,106 @@
+import { SchemaType, TopicMessage } from 'generated-sources';
+import React from 'react';
+import EditorViewer from 'components/common/EditorViewer/EditorViewer';
+import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
+import { formatTimestamp } from 'lib/dateTimeHelpers';
+import { Row } from '@tanstack/react-table';
+import {
+  Label,
+  List,
+  SubText,
+} from 'components/common/PropertiesList/PropertiesList.styled';
+
+import * as S from './MessageContent.styled';
+
+type Tab = 'key' | 'content' | 'headers';
+
+const MessageContent: React.FC<{ row: Row<TopicMessage> }> = ({ row }) => {
+  const {
+    content,
+    valueFormat,
+    key,
+    keyFormat,
+    headers,
+    timestamp,
+    timestampType,
+  } = row.original;
+
+  const [activeTab, setActiveTab] = React.useState<Tab>('content');
+  const activeTabContent = () => {
+    switch (activeTab) {
+      case 'content':
+        return content;
+      case 'key':
+        return key;
+      default:
+        return JSON.stringify(headers || {});
+    }
+  };
+
+  const encoder = new TextEncoder();
+  const keySize = encoder.encode(key).length;
+  const contentSize = encoder.encode(content).length;
+  const contentType =
+    content && content.trim().startsWith('{')
+      ? SchemaType.JSON
+      : SchemaType.PROTOBUF;
+  return (
+    <S.Section>
+      <S.ContentBox>
+        <S.Tabs>
+          <S.Tab
+            type="button"
+            $active={activeTab === 'key'}
+            onClick={() => setActiveTab('key')}
+          >
+            Key
+          </S.Tab>
+          <S.Tab
+            $active={activeTab === 'content'}
+            type="button"
+            onClick={() => setActiveTab('content')}
+          >
+            Content
+          </S.Tab>
+          <S.Tab
+            $active={activeTab === 'headers'}
+            type="button"
+            onClick={() => setActiveTab('headers')}
+          >
+            Headers
+          </S.Tab>
+        </S.Tabs>
+        <EditorViewer
+          data={activeTabContent() || ''}
+          maxLines={28}
+          schemaType={contentType}
+        />
+      </S.ContentBox>
+      <S.MetadataWrapper>
+        <List>
+          <Label>Timestamp</Label>
+          <span>
+            {formatTimestamp(timestamp)}
+            <SubText>Timestamp type: {timestampType}</SubText>
+          </span>
+          <Label>Content</Label>
+          <span>
+            {valueFormat}
+            <SubText>
+              Size: <BytesFormatted value={contentSize} />
+            </SubText>
+          </span>
+          <Label>Key</Label>
+          <span>
+            {keyFormat}
+            <SubText>
+              Size: <BytesFormatted value={keySize} />
+            </SubText>
+          </span>
+        </List>
+      </S.MetadataWrapper>
+    </S.Section>
+  );
+};
+
+export default MessageContent;

+ 41 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessagesTable.tsx

@@ -0,0 +1,41 @@
+import React from 'react';
+import { ColumnDef } from '@tanstack/react-table';
+import Table, { TimestampCell } from 'components/common/NewTable';
+import { TopicMessage } from 'generated-sources';
+import TruncatedTextCell from 'components/common/NewTable/TimestampCell copy';
+
+import MessageContent from './MessageContent/MessageContent';
+import ActionsCell from './ActionsCell';
+
+const MessagesTable: React.FC<{
+  messages: TopicMessage[];
+  isLive: boolean;
+}> = ({ messages, isLive }) => {
+  const columns = React.useMemo<ColumnDef<TopicMessage>[]>(
+    () => [
+      { header: 'Offset', accessorKey: 'offset' },
+      { header: 'Partition', accessorKey: 'partition' },
+      { header: 'Timestamp', accessorKey: 'timestamp', cell: TimestampCell },
+      { header: 'Key', accessorKey: 'key', cell: TruncatedTextCell },
+      { header: 'Content', accessorKey: 'content', cell: TruncatedTextCell },
+      { header: '', id: 'action', cell: ActionsCell },
+    ],
+    []
+  );
+
+  return (
+    <Table
+      columns={columns}
+      data={messages}
+      serverSideProcessing
+      pageCount={1}
+      emptyMessage={isLive ? 'Consuming messages...' : 'No messages to display'}
+      getRowCanExpand={() => true}
+      enableRowSelection={false}
+      enableSorting={false}
+      renderSubComponent={MessageContent}
+    />
+  );
+};
+
+export default MessagesTable;

+ 39 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/StatusBar.tsx

@@ -0,0 +1,39 @@
+import React from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { Tag } from 'components/common/Tag/Tag.styled';
+import { ConsumingMode } from 'lib/hooks/api/topicMessages';
+
+import { StatusTags } from './Messages.styled';
+import { getModeTitle } from './utils/consumingModes';
+
+const StatusBar = () => {
+  const [searchParams] = useSearchParams();
+
+  const mode = getModeTitle(
+    (searchParams.get('m') as ConsumingMode) || undefined
+  );
+  const offset = searchParams.get('o');
+  const timestamp = searchParams.get('t');
+  const query = searchParams.get('q');
+
+  return (
+    <StatusTags>
+      <Tag color="green">
+        {offset || timestamp ? (
+          <>
+            {mode}: <b>{offset || timestamp}</b>
+          </>
+        ) : (
+          mode
+        )}
+      </Tag>
+      {query && (
+        <Tag color="blue">
+          Search: <b>{query}</b>
+        </Tag>
+      )}
+    </StatusTags>
+  );
+};
+
+export default StatusBar;

+ 50 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/consumingModes.ts

@@ -0,0 +1,50 @@
+import { ConsumingMode } from 'lib/hooks/api/topicMessages';
+import { SelectOption } from 'components/common/Select/Select';
+
+interface Mode {
+  key: ConsumingMode;
+  title: string;
+}
+
+interface ModeOption extends SelectOption {
+  value: ConsumingMode;
+}
+
+const config: Mode[] = [
+  {
+    key: 'live',
+    title: 'Live mode',
+  },
+  {
+    key: 'newest',
+    title: 'Newest first',
+  },
+  {
+    key: 'oldest',
+    title: 'Oldest first',
+  },
+  {
+    key: 'fromOffset',
+    title: 'From offset',
+  },
+  {
+    key: 'toOffset',
+    title: 'To offset',
+  },
+  {
+    key: 'sinceTime',
+    title: 'Since time',
+  },
+  {
+    key: 'untilTime',
+    title: 'Until time',
+  },
+];
+
+export const getModeOptions = (): ModeOption[] =>
+  config.map(({ key, title }) => ({ value: key, label: title }));
+
+export const getModeTitle = (mode: ConsumingMode = 'newest') => {
+  const modeConfig = config.find((item) => item.key === mode) as Mode;
+  return modeConfig.title;
+};

+ 65 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/handleNextPageClick.ts

@@ -0,0 +1,65 @@
+import { TopicMessage } from 'generated-sources';
+import { ConsumingMode } from 'lib/hooks/api/topicMessages';
+
+export default (
+  messages: TopicMessage[],
+  searchParams: URLSearchParams,
+  setSearchParams: (params: URLSearchParams) => void
+) => {
+  const seekTo = searchParams.get('seekTo');
+  const mode = searchParams.get('m') as ConsumingMode;
+  const page = searchParams.get('page');
+  if (!seekTo || !mode) return;
+
+  // parse current seekTo query param to array of [partition, offset] tuples
+  const configTuple = seekTo?.split('.').map((item) => {
+    const [partition, offset] = item.split('-');
+    return { partition: Number(partition), offset: Number(offset) };
+  });
+
+  // Reverse messages array for faster last displayed message search.
+  const reversedMessages = [...messages].reverse();
+
+  if (!configTuple) return;
+
+  const newConfigTuple = configTuple.map(({ partition, offset }) => {
+    const message = reversedMessages.find((m) => partition === m.partition);
+    if (!message) {
+      return { partition, offset };
+    }
+
+    switch (mode) {
+      case 'fromOffset':
+      case 'oldest':
+        // First message in the reversed array is the message with max offset.
+        // Replace offset in seekTo query param with the max offset for
+        // each partition from displayed messages array.
+        return { partition, offset: Math.max(message.offset, offset) };
+      case 'toOffset':
+      case 'newest':
+        // First message in the reversed array is the message with min offset.
+        return { partition, offset: Math.min(message.offset, offset) };
+      case 'sinceTime':
+        // First message in the reversed array is the message with max timestamp.
+        return {
+          partition,
+          offset: Math.max(new Date(message.timestamp).getTime(), offset),
+        };
+      case 'untilTime':
+        // First message in the reversed array is the message with min timestamp.
+        return {
+          partition,
+          offset: Math.min(new Date(message.timestamp).getTime(), offset),
+        };
+      default:
+        return { partition, offset };
+    }
+  });
+  searchParams.set('page', String(Number(page || 0) + 1));
+  searchParams.set(
+    'seekTo',
+    newConfigTuple.map((t) => `${t.partition}-${t.offset}`).join('.')
+  );
+
+  setSearchParams(searchParams);
+};

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

@@ -2,7 +2,6 @@ import styled from 'styled-components';
 
 export const Wrapper = styled.div`
   display: block;
-  padding: 1.25rem;
   border-radius: 6px;
 `;
 

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

@@ -1,22 +1,18 @@
 import React, { useEffect } from 'react';
 import { useForm, Controller } from 'react-hook-form';
-import { useNavigate } from 'react-router-dom';
-import {
-  clusterTopicMessagesRelativePath,
-  RouteParamsClusterTopic,
-} from 'lib/paths';
+import { RouteParamsClusterTopic } from 'lib/paths';
 import jsf from 'json-schema-faker';
 import { Button } from 'components/common/Button/Button';
 import Editor from 'components/common/Editor/Editor';
 import Select, { SelectOption } from 'components/common/Select/Select';
 import useAppParams from 'lib/hooks/useAppParams';
-import Heading from 'components/common/heading/Heading.styled';
 import { showAlert } from 'lib/errorHandling';
 import {
   useSendMessage,
   useTopicDetails,
   useTopicMessageSchema,
 } from 'lib/hooks/api/topics';
+import { InputLabel } from 'components/common/Input/InputLabel.styled';
 
 import validateMessage from './validateMessage';
 import * as S from './SendMessage.styled';
@@ -28,9 +24,8 @@ type FieldValues = Partial<{
   partition: number | string;
 }>;
 
-const SendMessage: React.FC = () => {
+const SendMessage: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => {
   const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
-  const navigate = useNavigate();
   const { data: topic } = useTopicDetails({ clusterName, topicName });
   const { data: messageSchema } = useTopicMessageSchema({
     clusterName,
@@ -92,7 +87,7 @@ const SendMessage: React.FC = () => {
     });
   }, [keyDefaultValue, contentDefaultValue, reset]);
 
-  const onSubmit = async (data: {
+  const submit = async (data: {
     key: string;
     content: string;
     headers: string;
@@ -129,16 +124,16 @@ const SendMessage: React.FC = () => {
         headers,
         partition: !partition ? 0 : partition,
       });
-      navigate(`../${clusterTopicMessagesRelativePath}`);
+      onSubmit();
     }
   };
 
   return (
     <S.Wrapper>
-      <form onSubmit={handleSubmit(onSubmit)}>
+      <form onSubmit={handleSubmit(submit)}>
         <S.Columns>
           <S.Column>
-            <Heading level={3}>Partition</Heading>
+            <InputLabel>Partition</InputLabel>
             <Controller
               control={control}
               name="partition"
@@ -160,7 +155,7 @@ const SendMessage: React.FC = () => {
 
         <S.Columns>
           <S.Column>
-            <Heading level={3}>Key</Heading>
+            <InputLabel>Key</InputLabel>
             <Controller
               control={control}
               name="key"
@@ -175,7 +170,7 @@ const SendMessage: React.FC = () => {
             />
           </S.Column>
           <S.Column>
-            <Heading level={3}>Content</Heading>
+            <InputLabel>Content</InputLabel>
             <Controller
               control={control}
               name="content"
@@ -192,7 +187,7 @@ const SendMessage: React.FC = () => {
         </S.Columns>
         <S.Columns>
           <S.Column>
-            <Heading level={3}>Headers</Heading>
+            <InputLabel>Headers</InputLabel>
             <Controller
               control={control}
               name="headers"
@@ -214,7 +209,7 @@ const SendMessage: React.FC = () => {
           type="submit"
           disabled={!isDirty || isSubmitting}
         >
-          Send
+          Produce Message
         </Button>
       </form>
     </S.Wrapper>

+ 9 - 22
kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/SendMessage.spec.tsx

@@ -3,10 +3,7 @@ import SendMessage from 'components/Topics/Topic/SendMessage/SendMessage';
 import { act, screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render, WithRoute } from 'lib/testHelpers';
-import {
-  clusterTopicMessagesRelativePath,
-  clusterTopicSendMessagePath,
-} from 'lib/paths';
+import { clusterTopicPath } from 'lib/paths';
 import validateMessage from 'components/Topics/Topic/SendMessage/validateMessage';
 import { externalTopicPayload, topicMessageSchema } from 'lib/fixtures/topics';
 import {
@@ -35,12 +32,6 @@ jest.mock('lib/errorHandling', () => ({
   showServerError: jest.fn(),
 }));
 
-const mockNavigate = jest.fn();
-jest.mock('react-router-dom', () => ({
-  ...jest.requireActual('react-router-dom'),
-  useNavigate: () => mockNavigate,
-}));
-
 jest.mock('lib/hooks/api/topics', () => ({
   useTopicDetails: jest.fn(),
   useTopicMessageSchema: jest.fn(),
@@ -50,12 +41,14 @@ jest.mock('lib/hooks/api/topics', () => ({
 const clusterName = 'testCluster';
 const topicName = externalTopicPayload.name;
 
+const mockOnSubmit = jest.fn();
+
 const renderComponent = async () => {
-  const path = clusterTopicSendMessagePath(clusterName, topicName);
+  const path = clusterTopicPath(clusterName, topicName);
   await act(() => {
     render(
-      <WithRoute path={clusterTopicSendMessagePath()}>
-        <SendMessage />
+      <WithRoute path={clusterTopicPath()}>
+        <SendMessage onSubmit={mockOnSubmit} />
       </WithRoute>,
       { initialEntries: [path] }
     );
@@ -72,7 +65,7 @@ const renderAndSubmitData = async (error: string[] = []) => {
   });
   await act(() => {
     (validateMessage as Mock).mockImplementation(() => error);
-    userEvent.click(screen.getByText('Send'));
+    userEvent.click(screen.getByText('Produce Message'));
   });
 };
 
@@ -83,10 +76,6 @@ describe('SendMessage', () => {
     }));
   });
 
-  afterEach(() => {
-    mockNavigate.mockClear();
-  });
-
   describe('when schema is fetched', () => {
     beforeEach(() => {
       (useTopicMessageSchema as jest.Mock).mockImplementation(() => ({
@@ -101,9 +90,7 @@ describe('SendMessage', () => {
       }));
       await renderAndSubmitData();
       expect(sendTopicMessageMock).toHaveBeenCalledTimes(1);
-      expect(mockNavigate).toHaveBeenLastCalledWith(
-        `../${clusterTopicMessagesRelativePath}`
-      );
+      expect(mockOnSubmit).toHaveBeenCalledTimes(1);
     });
 
     it('should check and view validation error message when is not valid', async () => {
@@ -113,7 +100,7 @@ describe('SendMessage', () => {
       }));
       await renderAndSubmitData(['error']);
       expect(sendTopicMessageMock).not.toHaveBeenCalled();
-      expect(mockNavigate).not.toHaveBeenCalled();
+      expect(mockOnSubmit).not.toHaveBeenCalled();
     });
   });
 

+ 17 - 7
kafka-ui-react-app/src/components/Topics/Topic/Topic.tsx

@@ -6,10 +6,8 @@ import {
   clusterTopicSettingsRelativePath,
   clusterTopicConsumerGroupsRelativePath,
   clusterTopicEditRelativePath,
-  clusterTopicSendMessageRelativePath,
   clusterTopicStatisticsRelativePath,
   clusterTopicsPath,
-  clusterTopicSendMessagePath,
 } from 'lib/paths';
 import ClusterContext from 'components/contexts/ClusterContext';
 import PageHeading from 'components/common/PageHeading/PageHeading';
@@ -33,8 +31,11 @@ import {
 } from 'redux/reducers/topicMessages/topicMessagesSlice';
 import { CleanUpPolicy } from 'generated-sources';
 import PageLoader from 'components/common/PageLoader/PageLoader';
+import SlidingSidebar from 'components/common/SlidingSidebar';
+import useBoolean from 'lib/hooks/useBoolean';
 
 import Messages from './Messages/Messages';
+import MessagesV2 from './MessagesV2/Messages';
 import Overview from './Overview/Overview';
 import Settings from './Settings/Settings';
 import TopicConsumerGroups from './ConsumerGroups/TopicConsumerGroups';
@@ -44,6 +45,11 @@ import SendMessage from './SendMessage/SendMessage';
 
 const Topic: React.FC = () => {
   const dispatch = useAppDispatch();
+  const {
+    value: isSidebarOpen,
+    setFalse: closeSidebar,
+    setTrue: openSidebar,
+  } = useBoolean(false);
   const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>();
   const navigate = useNavigate();
   const deleteTopic = useDeleteTopic(clusterName);
@@ -76,7 +82,7 @@ const Topic: React.FC = () => {
         <Button
           buttonSize="M"
           buttonType="primary"
-          to={clusterTopicSendMessagePath(clusterName, topicName)}
+          onClick={openSidebar}
           disabled={isReadOnly}
         >
           Produce Message
@@ -179,6 +185,7 @@ const Topic: React.FC = () => {
             path={clusterTopicMessagesRelativePath}
             element={<Messages />}
           />
+          <Route path="v2" element={<MessagesV2 />} />
           <Route
             path={clusterTopicSettingsRelativePath}
             element={<Settings />}
@@ -192,12 +199,15 @@ const Topic: React.FC = () => {
             element={<Statistics />}
           />
           <Route path={clusterTopicEditRelativePath} element={<Edit />} />
-          <Route
-            path={clusterTopicSendMessageRelativePath}
-            element={<SendMessage />}
-          />
         </Routes>
       </Suspense>
+      <SlidingSidebar
+        open={isSidebarOpen}
+        onClose={closeSidebar}
+        title="Produce Message"
+      >
+        <SendMessage onSubmit={closeSidebar} />
+      </SlidingSidebar>
     </>
   );
 };

+ 7 - 2
kafka-ui-react-app/src/components/Topics/Topic/__test__/Topic.spec.tsx

@@ -46,6 +46,9 @@ jest.mock('components/Topics/Topic/Overview/Overview', () => () => (
 jest.mock('components/Topics/Topic/Messages/Messages', () => () => (
   <>MessagesMock</>
 ));
+jest.mock('components/Topics/Topic/SendMessage/SendMessage', () => () => (
+  <>SendMessageMock</>
+));
 jest.mock('components/Topics/Topic/Settings/Settings', () => () => (
   <>SettingsMock</>
 ));
@@ -98,9 +101,11 @@ describe('Details', () => {
   });
   describe('Action Bar', () => {
     describe('when it has readonly flag', () => {
-      it('renders disabled the Action button a Topic', () => {
+      it('renders disabled the Action button', () => {
         renderComponent(true);
-        expect(screen.getByText('Produce Message')).toBeDisabled();
+        expect(
+          screen.getByRole('button', { name: 'Produce Message' })
+        ).toBeDisabled();
       });
     });
 

+ 5 - 5
kafka-ui-react-app/src/components/common/Dropdown/Dropdown.tsx

@@ -1,7 +1,7 @@
 import { MenuProps } from '@szhsin/react-menu';
 import React, { PropsWithChildren, useRef } from 'react';
 import VerticalElipsisIcon from 'components/common/Icons/VerticalElipsisIcon';
-import useModal from 'lib/hooks/useModal';
+import useBoolean from 'lib/hooks/useBoolean';
 
 import * as S from './Dropdown.styled';
 
@@ -12,12 +12,12 @@ interface DropdownProps extends PropsWithChildren<Partial<MenuProps>> {
 
 const Dropdown: React.FC<DropdownProps> = ({ label, disabled, children }) => {
   const ref = useRef(null);
-  const { isOpen, setClose, setOpen } = useModal(false);
+  const { value: isOpen, setFalse, setTrue } = useBoolean(false);
 
   const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
     e.preventDefault();
     e.stopPropagation();
-    setOpen();
+    setTrue();
   };
 
   return (
@@ -33,8 +33,8 @@ const Dropdown: React.FC<DropdownProps> = ({ label, disabled, children }) => {
       <S.Dropdown
         anchorRef={ref}
         state={isOpen ? 'open' : 'closed'}
-        onMouseLeave={setClose}
-        onClose={setClose}
+        onMouseLeave={setFalse}
+        onClose={setFalse}
         align="end"
         direction="bottom"
         offsetY={10}

+ 4 - 4
kafka-ui-react-app/src/components/common/Input/Input.styled.ts

@@ -2,7 +2,7 @@ import styled, { css } from 'styled-components';
 
 export interface InputProps {
   inputSize?: 'S' | 'M' | 'L';
-  hasLeftIcon: boolean;
+  search: boolean;
 }
 
 const INPUT_SIZES = {
@@ -14,7 +14,7 @@ const INPUT_SIZES = {
 export const Wrapper = styled.div`
   position: relative;
 
-  svg {
+  svg:first-child {
     position: absolute;
     top: 8px;
     line-height: 0;
@@ -28,14 +28,14 @@ export const Wrapper = styled.div`
 `;
 
 export const Input = styled.input<InputProps>(
-  ({ theme: { input }, inputSize, hasLeftIcon }) => css`
+  ({ theme: { input }, inputSize, search }) => css`
     border: 1px ${input.borderColor.normal} solid;
     border-radius: 4px;
     height: ${inputSize && INPUT_SIZES[inputSize]
       ? INPUT_SIZES[inputSize]
       : '40px'};
     width: 100%;
-    padding-left: ${hasLeftIcon ? '36px' : '12px'};
+    padding-left: ${search ? '36px' : '12px'};
     font-size: 14px;
 
     &::placeholder {

+ 2 - 2
kafka-ui-react-app/src/components/common/Input/Input.tsx

@@ -6,7 +6,7 @@ import * as S from './Input.styled';
 
 export interface InputProps
   extends React.InputHTMLAttributes<HTMLInputElement>,
-    Omit<S.InputProps, 'hasLeftIcon'> {
+    Omit<S.InputProps, 'search'> {
   name?: string;
   hookFormOptions?: RegisterOptions;
   search?: boolean;
@@ -74,7 +74,7 @@ const Input: React.FC<InputProps> = ({
       {search && <SearchIcon />}
       <S.Input
         inputSize={inputSize}
-        hasLeftIcon={!!search}
+        search={!!search}
         type={type}
         onKeyPress={keyPressEventHandler}
         onPaste={pasteEventHandler}

+ 6 - 0
kafka-ui-react-app/src/components/common/Input/InputLabel.styled.ts

@@ -5,4 +5,10 @@ export const InputLabel = styled.label`
   font-size: 12px;
   line-height: 20px;
   color: ${({ theme }) => theme.input.label.color};
+
+  input[type='checkbox'] {
+    display: inline-block;
+    margin-right: 8px;
+    vertical-align: text-top;
+  }
 `;

+ 1 - 1
kafka-ui-react-app/src/components/common/MultiSelect/MultiSelect.styled.ts

@@ -1,5 +1,5 @@
 import styled from 'styled-components';
-import ReactMultiSelect from 'react-multi-select-component';
+import { MultiSelect as ReactMultiSelect } from 'react-multi-select-component';
 
 const MultiSelect = styled(ReactMultiSelect)<{ minWidth?: string }>`
   min-width: ${({ minWidth }) => minWidth || '200px;'};

+ 1 - 0
kafka-ui-react-app/src/components/common/Navigation/Navbar.styled.ts

@@ -3,6 +3,7 @@ import styled from 'styled-components';
 const Navbar = styled.nav`
   display: flex;
   border-bottom: 1px ${({ theme }) => theme.primaryTab.borderColor.nav} solid;
+  height: ${({ theme }) => theme.primaryTab.height};
   & a {
     height: 40px;
     min-width: 96px;

+ 8 - 0
kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts

@@ -203,3 +203,11 @@ export const PageInfo = styled.div`
   white-space: nowrap;
   margin-left: 16px;
 `;
+
+export const Ellipsis = styled.div`
+  max-width: 300px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: block;
+`;

+ 6 - 8
kafka-ui-react-app/src/components/common/NewTable/Table.tsx

@@ -115,11 +115,11 @@ const Table: React.FC<TableProps<any>> = ({
   pageCount,
   columns,
   getRowCanExpand,
-  renderSubComponent,
+  renderSubComponent: SubComponent,
   serverSideProcessing = false,
   enableSorting = false,
   enableRowSelection = false,
-  batchActionsBar,
+  batchActionsBar: BatchActionsBar,
   emptyMessage,
   onRowClick,
 }) => {
@@ -170,8 +170,6 @@ const Table: React.FC<TableProps<any>> = ({
     enableRowSelection,
   });
 
-  const Bar = batchActionsBar;
-
   const handleRowClick = (row: Row<typeof data>) => (e: React.MouseEvent) => {
     // If row selection is enabled do not handle row click.
     if (enableRowSelection) return undefined;
@@ -192,9 +190,9 @@ const Table: React.FC<TableProps<any>> = ({
 
   return (
     <>
-      {table.getSelectedRowModel().flatRows.length > 0 && Bar && (
+      {table.getSelectedRowModel().flatRows.length > 0 && BatchActionsBar && (
         <S.TableActionsBar>
-          <Bar
+          <BatchActionsBar
             rows={table.getSelectedRowModel().flatRows}
             resetRowSelection={table.resetRowSelection}
           />
@@ -269,11 +267,11 @@ const Table: React.FC<TableProps<any>> = ({
                     </td>
                   ))}
               </S.Row>
-              {row.getIsExpanded() && renderSubComponent && (
+              {row.getIsExpanded() && SubComponent && (
                 <S.Row expanded>
                   <td colSpan={row.getVisibleCells().length + 2}>
                     <S.ExpandedRowInfo>
-                      {renderSubComponent({ row })}
+                      <SubComponent row={row} />
                     </S.ExpandedRowInfo>
                   </td>
                 </S.Row>

+ 11 - 0
kafka-ui-react-app/src/components/common/NewTable/TimestampCell copy.tsx

@@ -0,0 +1,11 @@
+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 TruncatedTextCell: React.FC<CellContext<any, unknown>> = ({
+  getValue,
+}) => <S.Ellipsis>{getValue<string>()}</S.Ellipsis>;
+
+export default TruncatedTextCell;

+ 6 - 1
kafka-ui-react-app/src/components/common/PropertiesList/PropertiesList.styled.tsx

@@ -5,7 +5,7 @@ export const List = styled.div`
   grid-template-columns: repeat(2, max-content);
   gap: 8px;
   column-gap: 24px;
-  margin-top: 16px;
+  margin: 16px 0;
   text-align: left;
 `;
 
@@ -15,3 +15,8 @@ export const Label = styled.div`
   color: ${({ theme }) => theme.list.label.color};
   white-space: nowrap;
 `;
+
+export const SubText = styled.div`
+  color: ${({ theme }) => theme.list.meta.color};
+  font-size: 12px;
+`;

+ 16 - 10
kafka-ui-react-app/src/components/common/Search/Search.tsx

@@ -1,31 +1,37 @@
 import React from 'react';
 import { useDebouncedCallback } from 'use-debounce';
 import Input from 'components/common/Input/Input';
+import { useSearchParams } from 'react-router-dom';
 
 interface SearchProps {
-  handleSearch: (value: string) => void;
   placeholder?: string;
-  value: string;
   disabled?: boolean;
+  onChange?: (value: string) => void;
+  value?: string;
 }
 
 const Search: React.FC<SearchProps> = ({
-  handleSearch,
   placeholder = 'Search',
-  value,
   disabled = false,
+  value,
+  onChange,
 }) => {
-  const onChange = useDebouncedCallback(
-    (e) => handleSearch(e.target.value),
-    300
-  );
+  const [searchParams, setSearchParams] = useSearchParams();
+  const handleChange = useDebouncedCallback((e) => {
+    if (onChange) {
+      onChange(e.target.value);
+    } else {
+      searchParams.set('q', e.target.value);
+      setSearchParams(searchParams);
+    }
+  }, 500);
 
   return (
     <Input
       type="text"
       placeholder={placeholder}
-      onChange={onChange}
-      defaultValue={value}
+      onChange={handleChange}
+      defaultValue={value || searchParams.get('q') || ''}
       inputSize="M"
       disabled={disabled}
       search

+ 21 - 21
kafka-ui-react-app/src/components/common/Search/__tests__/Search.spec.tsx

@@ -3,42 +3,42 @@ import React from 'react';
 import { render } from 'lib/testHelpers';
 import userEvent from '@testing-library/user-event';
 import { screen } from '@testing-library/react';
+import { useSearchParams } from 'react-router-dom';
 
 jest.mock('use-debounce', () => ({
   useDebouncedCallback: (fn: (e: Event) => void) => fn,
 }));
 
+const setSearchParamsMock = jest.fn();
+jest.mock('react-router-dom', () => ({
+  ...(jest.requireActual('react-router-dom') as object),
+  useSearchParams: jest.fn(),
+}));
+
+const placeholder = 'I am a search placeholder';
+
 describe('Search', () => {
-  const handleSearch = jest.fn();
+  beforeEach(() => {
+    (useSearchParams as jest.Mock).mockImplementation(() => [
+      new URLSearchParams(),
+      setSearchParamsMock,
+    ]);
+  });
   it('calls handleSearch on input', () => {
-    render(
-      <Search
-        handleSearch={handleSearch}
-        value=""
-        placeholder="Search bt the Topic name"
-      />
-    );
-    const input = screen.getByPlaceholderText('Search bt the Topic name');
+    render(<Search placeholder={placeholder} />);
+    const input = screen.getByPlaceholderText(placeholder);
     userEvent.click(input);
     userEvent.keyboard('value');
-    expect(handleSearch).toHaveBeenCalledTimes(5);
+    expect(setSearchParamsMock).toHaveBeenCalledTimes(5);
   });
 
   it('when placeholder is provided', () => {
-    render(
-      <Search
-        handleSearch={handleSearch}
-        value=""
-        placeholder="Search bt the Topic name"
-      />
-    );
-    expect(
-      screen.getByPlaceholderText('Search bt the Topic name')
-    ).toBeInTheDocument();
+    render(<Search placeholder={placeholder} />);
+    expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument();
   });
 
   it('when placeholder is not provided', () => {
-    render(<Search handleSearch={handleSearch} value="" />);
+    render(<Search />);
     expect(screen.queryByPlaceholderText('Search')).toBeInTheDocument();
   });
 });

+ 36 - 0
kafka-ui-react-app/src/components/common/SlidingSidebar/SlidingSidebar.styled.ts

@@ -0,0 +1,36 @@
+import styled from 'styled-components';
+
+export const Wrapper = styled.div<{ $open?: boolean }>(
+  ({ theme, $open }) => `
+  background-color: ${theme.layout.backgroundColor};
+  position: fixed;
+  top: ${theme.layout.navBarHeight};
+  bottom: 0;
+  width: 60vw;
+  right: calc(${$open ? '0px' : theme.layout.rightSidebarWidth} * -1);
+  box-shadow: -1px 0px 10px 0px rgba(0, 0, 0, 0.2);
+  transition: right 0.3s linear;
+  z-index: 200;
+
+  h3 {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    border-bottom: 1px solid ${theme.layout.stuffBorderColor};
+    padding: 16px;
+  }
+`
+);
+
+export const Content = styled.div<{ $open?: boolean }>(
+  ({ theme }) => `
+  background-color: ${theme.layout.backgroundColor};
+  overflow-y: auto;
+  position: absolute;
+  top: 65px;
+  bottom: 16px;
+  left: 0;
+  right: 0;
+  padding: 16px;
+`
+);

+ 32 - 0
kafka-ui-react-app/src/components/common/SlidingSidebar/SlidingSidebar.tsx

@@ -0,0 +1,32 @@
+import React, { PropsWithChildren } from 'react';
+import Heading from 'components/common/heading/Heading.styled';
+import { Button } from 'components/common/Button/Button';
+
+import * as S from './SlidingSidebar.styled';
+
+interface SlidingSidebarProps extends PropsWithChildren<unknown> {
+  open?: boolean;
+  title: string;
+  onClose?: () => void;
+}
+
+const SlidingSidebar: React.FC<SlidingSidebarProps> = ({
+  open,
+  title,
+  children,
+  onClose,
+}) => {
+  return (
+    <S.Wrapper $open={open}>
+      <Heading level={3}>
+        <span>{title}</span>
+        <Button buttonSize="M" buttonType="primary" onClick={onClose}>
+          Close
+        </Button>
+      </Heading>
+      <S.Content>{children}</S.Content>
+    </S.Wrapper>
+  );
+};
+
+export default SlidingSidebar;

+ 3 - 0
kafka-ui-react-app/src/components/common/SlidingSidebar/index.ts

@@ -0,0 +1,3 @@
+import SlidingSidebar from './SlidingSidebar';
+
+export default SlidingSidebar;

+ 0 - 11
kafka-ui-react-app/src/lib/__test__/paths.spec.ts

@@ -204,17 +204,6 @@ describe('Paths', () => {
       )
     );
   });
-  it('clusterTopicSendMessagePath', () => {
-    expect(paths.clusterTopicSendMessagePath(clusterName, topicId)).toEqual(
-      `${paths.clusterTopicPath(clusterName, topicId)}/message`
-    );
-    expect(paths.clusterTopicSendMessagePath()).toEqual(
-      paths.clusterTopicSendMessagePath(
-        RouteParams.clusterName,
-        RouteParams.topicName
-      )
-    );
-  });
   it('clusterTopicEditPath', () => {
     expect(paths.clusterTopicEditPath(clusterName, topicId)).toEqual(
       `${paths.clusterTopicPath(clusterName, topicId)}/edit`

+ 3 - 0
kafka-ui-react-app/src/lib/constants.ts

@@ -51,6 +51,7 @@ export const NOT_SET = -1;
 export const BYTES_IN_GB = 1_073_741_824;
 
 export const PER_PAGE = 25;
+export const MESSAGES_PER_PAGE = '100';
 
 export const GIT_REPO_LINK = 'https://github.com/provectus/kafka-ui';
 export const GIT_REPO_LATEST_RELEASE_LINK =
@@ -58,6 +59,8 @@ export const GIT_REPO_LATEST_RELEASE_LINK =
 export const GIT_TAG = process.env.VITE_TAG;
 export const GIT_COMMIT = process.env.VITE_COMMIT;
 
+export const LOCAL_STORAGE_KEY_PREFIX = 'kafka-ui';
+
 export enum AsyncRequestStatus {
   initial = 'initial',
   pending = 'pending',

+ 22 - 0
kafka-ui-react-app/src/lib/dateTimeHelpers.ts

@@ -10,3 +10,25 @@ export const formatTimestamp = (
 
   return dayjs(timestamp).format(format);
 };
+
+export const formatMilliseconds = (input = 0) => {
+  const milliseconds = Math.max(input || 0, 0);
+
+  const seconds = Math.floor(milliseconds / 1000);
+  const minutes = Math.floor(seconds / 60);
+  const hours = Math.floor(minutes / 60);
+
+  if (hours > 0) {
+    return `${hours}h ${minutes % 60}m`;
+  }
+
+  if (minutes > 0) {
+    return `${minutes}m ${seconds % 60}s`;
+  }
+
+  if (seconds > 0) {
+    return `${seconds}s`;
+  }
+
+  return `${milliseconds}ms`;
+};

+ 66 - 0
kafka-ui-react-app/src/lib/hooks/__tests__/useBoolean.spec.ts

@@ -0,0 +1,66 @@
+import { renderHook, act } from '@testing-library/react';
+import useBoolean from 'lib/hooks/useBoolean';
+
+describe('useBoolean CustomHook', () => {
+  it('should check true initial values', () => {
+    let initialValue = true;
+    const { result, rerender } = renderHook(() => useBoolean(initialValue));
+    expect(result.current.value).toBe(initialValue);
+    initialValue = false;
+    rerender();
+    // because state is in useState
+    expect(result.current.value).not.toBe(initialValue);
+  });
+
+  it('should check false initial values', () => {
+    let initialValue = false;
+    const { result, rerender } = renderHook(() => useBoolean(initialValue));
+    expect(result.current.value).toBe(initialValue);
+
+    initialValue = true;
+    rerender();
+    // because state is in useState
+    expect(result.current.value).not.toBe(initialValue);
+  });
+
+  it('should check setTrue function', () => {
+    const { result } = renderHook(() => useBoolean());
+    expect(result.current.value).toBeFalsy();
+    act(() => {
+      result.current.setTrue();
+    });
+    expect(result.current.value).toBeTruthy();
+  });
+
+  it('should check setFalse function', () => {
+    const { result } = renderHook(() => useBoolean());
+
+    expect(result.current.value).toBeFalsy();
+    act(() => {
+      result.current.setTrue();
+    });
+
+    expect(result.current.value).toBeTruthy();
+
+    act(() => {
+      result.current.setFalse();
+    });
+    expect(result.current.value).toBeFalsy();
+  });
+
+  it('should check setToggle function', () => {
+    const { result } = renderHook(() => useBoolean());
+
+    expect(result.current.value).toBeFalsy();
+    act(() => {
+      result.current.toggle();
+    });
+
+    expect(result.current.value).toBeTruthy();
+
+    act(() => {
+      result.current.toggle();
+    });
+    expect(result.current.value).toBeFalsy();
+  });
+});

+ 0 - 66
kafka-ui-react-app/src/lib/hooks/__tests__/useModal.spec.ts

@@ -1,66 +0,0 @@
-import { renderHook, act } from '@testing-library/react';
-import useModal from 'lib/hooks/useModal';
-
-describe('useModal CustomHook', () => {
-  it('should check true initial values', () => {
-    let initialValue = true;
-    const { result, rerender } = renderHook(() => useModal(initialValue));
-    expect(result.current.isOpen).toBe(initialValue);
-    initialValue = false;
-    rerender();
-    // because state is in useState
-    expect(result.current.isOpen).not.toBe(initialValue);
-  });
-
-  it('should check false initial values', () => {
-    let initialValue = false;
-    const { result, rerender } = renderHook(() => useModal(initialValue));
-    expect(result.current.isOpen).toBe(initialValue);
-
-    initialValue = true;
-    rerender();
-    // because state is in useState
-    expect(result.current.isOpen).not.toBe(initialValue);
-  });
-
-  it('should check setOpen function', () => {
-    const { result } = renderHook(() => useModal());
-    expect(result.current.isOpen).toBeFalsy();
-    act(() => {
-      result.current.setOpen();
-    });
-    expect(result.current.isOpen).toBeTruthy();
-  });
-
-  it('should check setClose function', () => {
-    const { result } = renderHook(() => useModal());
-
-    expect(result.current.isOpen).toBeFalsy();
-    act(() => {
-      result.current.setOpen();
-    });
-
-    expect(result.current.isOpen).toBeTruthy();
-
-    act(() => {
-      result.current.setClose();
-    });
-    expect(result.current.isOpen).toBeFalsy();
-  });
-
-  it('should check setToggle function', () => {
-    const { result } = renderHook(() => useModal());
-
-    expect(result.current.isOpen).toBeFalsy();
-    act(() => {
-      result.current.toggle();
-    });
-
-    expect(result.current.isOpen).toBeTruthy();
-
-    act(() => {
-      result.current.toggle();
-    });
-    expect(result.current.isOpen).toBeFalsy();
-  });
-});

+ 177 - 0
kafka-ui-react-app/src/lib/hooks/api/topicMessages.tsx

@@ -0,0 +1,177 @@
+import React from 'react';
+import { fetchEventSource } from '@microsoft/fetch-event-source';
+import { BASE_PARAMS, MESSAGES_PER_PAGE } from 'lib/constants';
+import { ClusterName } from 'redux/interfaces';
+import {
+  SeekDirection,
+  SeekType,
+  TopicMessage,
+  TopicMessageConsuming,
+  TopicMessageEvent,
+  TopicMessageEventTypeEnum,
+} from 'generated-sources';
+import { showServerError } from 'lib/errorHandling';
+import toast from 'react-hot-toast';
+import { StopLoading } from 'components/Topics/Topic/MessagesV2/FiltersBar/FiltersBar.styled';
+
+interface UseTopicMessagesProps {
+  clusterName: ClusterName;
+  topicName: string;
+  searchParams: URLSearchParams;
+}
+
+export type ConsumingMode =
+  | 'live'
+  | 'oldest'
+  | 'newest'
+  | 'fromOffset' // from 900 -> 1000
+  | 'toOffset' // from 900 -> 800
+  | 'sinceTime' // from 10:15 -> 11:15
+  | 'untilTime'; // from 10:15 -> 9:15
+
+export const useTopicMessages = ({
+  clusterName,
+  topicName,
+  searchParams,
+}: UseTopicMessagesProps) => {
+  const [messages, setMessages] = React.useState<TopicMessage[]>([]);
+  const [phase, setPhase] = React.useState<string>();
+  const [meta, setMeta] = React.useState<TopicMessageConsuming>();
+  const [isFetching, setIsFetching] = React.useState<boolean>(false);
+  const abortController = new AbortController();
+
+  // get initial properties
+  const mode = searchParams.get('m') as ConsumingMode;
+  const limit = searchParams.get('perPage') || MESSAGES_PER_PAGE;
+  const seekTo = searchParams.get('seekTo') || '0-0';
+
+  React.useEffect(() => {
+    const fetchData = async () => {
+      setIsFetching(true);
+      const url = `${BASE_PARAMS.basePath}/api/clusters/${clusterName}/topics/${topicName}/messages`;
+      const requestParams = new URLSearchParams({
+        limit,
+        seekTo: seekTo.replaceAll('-', '::').replaceAll('.', ','),
+        q: searchParams.get('q') || '',
+      });
+
+      switch (mode) {
+        case 'live':
+          requestParams.set('seekDirection', SeekDirection.TAILING);
+          requestParams.set('seekType', SeekType.LATEST);
+          break;
+        case 'oldest':
+          requestParams.set('seekType', SeekType.BEGINNING);
+          requestParams.set('seekDirection', SeekDirection.FORWARD);
+          break;
+        case 'newest':
+          requestParams.set('seekType', SeekType.LATEST);
+          requestParams.set('seekDirection', SeekDirection.BACKWARD);
+          break;
+        case 'fromOffset':
+          requestParams.set('seekType', SeekType.OFFSET);
+          requestParams.set('seekDirection', SeekDirection.FORWARD);
+          break;
+        case 'toOffset':
+          requestParams.set('seekType', SeekType.OFFSET);
+          requestParams.set('seekDirection', SeekDirection.BACKWARD);
+          break;
+        case 'sinceTime':
+          requestParams.set('seekType', SeekType.TIMESTAMP);
+          requestParams.set('seekDirection', SeekDirection.FORWARD);
+          break;
+        case 'untilTime':
+          requestParams.set('seekType', SeekType.TIMESTAMP);
+          requestParams.set('seekDirection', SeekDirection.BACKWARD);
+          break;
+        default:
+          break;
+      }
+
+      await fetchEventSource(`${url}?${requestParams.toString()}`, {
+        method: 'GET',
+        signal: abortController.signal,
+        openWhenHidden: true,
+        async onopen(response) {
+          const { ok, status } = response;
+          if (ok && status === 200) {
+            // Reset list of messages.
+            setMessages([]);
+          } else if (status >= 400 && status < 500 && status !== 429) {
+            showServerError(response);
+          }
+        },
+        onmessage(event) {
+          const parsedData: TopicMessageEvent = JSON.parse(event.data);
+          const { message, consuming } = parsedData;
+
+          switch (parsedData.type) {
+            case TopicMessageEventTypeEnum.MESSAGE:
+              if (message) {
+                setMessages((prevMessages) => {
+                  if (mode === 'live') {
+                    return [message, ...prevMessages];
+                  }
+                  return [...prevMessages, message];
+                });
+              }
+              break;
+            case TopicMessageEventTypeEnum.PHASE:
+              if (parsedData.phase?.name) setPhase(parsedData.phase.name);
+              break;
+            case TopicMessageEventTypeEnum.CONSUMING:
+              if (consuming) setMeta(consuming);
+              break;
+            default:
+          }
+        },
+        onclose() {
+          setIsFetching(false);
+        },
+        onerror(err) {
+          setIsFetching(false);
+          showServerError(err);
+        },
+      });
+    };
+    const abortFetchData = () => {
+      setIsFetching(false);
+      abortController.abort();
+    };
+
+    if (mode === 'live') {
+      toast.promise(
+        fetchData(),
+        {
+          loading: (
+            <>
+              <div>Consuming messages...</div>
+              &nbsp;
+              <StopLoading onClick={abortFetchData}>Abort</StopLoading>
+            </>
+          ),
+          success: 'Cancelled',
+          error: 'Something went wrong. Please try again.',
+        },
+        {
+          id: 'messages',
+          position: 'top-center',
+          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+          // @ts-ignore - missing type for icon
+          success: { duration: 10, icon: false },
+        }
+      );
+    } else {
+      fetchData();
+    }
+
+    return abortFetchData;
+  }, [searchParams]);
+
+  return {
+    phase,
+    messages,
+    meta,
+    isFetching,
+  };
+};

+ 21 - 0
kafka-ui-react-app/src/lib/hooks/useBoolean.ts

@@ -0,0 +1,21 @@
+import { useCallback, useState } from 'react';
+
+interface ReturnType {
+  value: boolean;
+  setTrue: () => void;
+  setFalse: () => void;
+  toggle: () => void;
+  setValue: React.Dispatch<React.SetStateAction<boolean>>;
+}
+
+function useBoolean(defaultValue?: boolean): ReturnType {
+  const [value, setValue] = useState(!!defaultValue);
+
+  const setTrue = useCallback(() => setValue(true), []);
+  const setFalse = useCallback(() => setValue(false), []);
+  const toggle = useCallback(() => setValue((x) => !x), []);
+
+  return { value, setValue, setTrue, setFalse, toggle };
+}
+
+export default useBoolean;

+ 20 - 0
kafka-ui-react-app/src/lib/hooks/useLocalStorage.ts

@@ -0,0 +1,20 @@
+import { LOCAL_STORAGE_KEY_PREFIX } from 'lib/constants';
+import { useState, useEffect } from 'react';
+
+export const useLocalStorage = (featureKey: string, defaultValue: string) => {
+  const key = `${LOCAL_STORAGE_KEY_PREFIX}-${featureKey}`;
+  const [value, setValue] = useState(() => {
+    const saved = localStorage.getItem(key);
+
+    if (saved !== null) {
+      return JSON.parse(saved);
+    }
+    return defaultValue;
+  });
+
+  useEffect(() => {
+    localStorage.setItem(key, JSON.stringify(value));
+  }, [key, value]);
+
+  return [value, setValue];
+};

+ 41 - 0
kafka-ui-react-app/src/lib/hooks/useMessageFiltersStore.ts

@@ -0,0 +1,41 @@
+import { LOCAL_STORAGE_KEY_PREFIX } from 'lib/constants';
+import create from 'zustand';
+import { persist } from 'zustand/middleware';
+
+export interface AdvancedFilter {
+  name: string;
+  value: string;
+}
+
+interface MessageFiltersState {
+  filters: AdvancedFilter[];
+  activeFilter?: AdvancedFilter;
+  save: (filter: AdvancedFilter) => void;
+  apply: (filter: AdvancedFilter) => void;
+  remove: (name: string) => void;
+  update: (name: string, filter: AdvancedFilter) => void;
+}
+
+export const useMessageFiltersStore = create<MessageFiltersState>()(
+  persist(
+    (set) => ({
+      filters: [],
+      save: (filter) =>
+        set((state) => ({
+          filters: [...state.filters, filter],
+        })),
+      apply: (filter) => set(() => ({ activeFilter: filter })),
+      remove: (name) =>
+        set((state) => ({
+          filters: state.filters.filter((f) => f.name !== name),
+        })),
+      update: (name, filter) =>
+        set((state) => ({
+          filters: state.filters.map((f) => (f.name === name ? filter : f)),
+        })),
+    }),
+    {
+      name: `${LOCAL_STORAGE_KEY_PREFIX}-message-filters`,
+    }
+  )
+);

+ 0 - 32
kafka-ui-react-app/src/lib/hooks/useModal.ts

@@ -1,32 +0,0 @@
-import { useCallback, useState } from 'react';
-
-interface UseModalReturn {
-  isOpen: boolean;
-  setOpen(): void;
-  setClose(): void;
-  toggle(): void;
-}
-const useModal = (initialModalState?: boolean): UseModalReturn => {
-  const [modalOpen, setModalOpen] = useState<boolean>(!!initialModalState);
-
-  const setOpen = useCallback(() => {
-    setModalOpen(true);
-  }, []);
-
-  const setClose = useCallback(() => {
-    setModalOpen(false);
-  }, []);
-
-  const toggle = useCallback(() => {
-    setModalOpen((prev) => !prev);
-  }, []);
-
-  return {
-    isOpen: modalOpen,
-    setOpen,
-    setClose,
-    toggle,
-  };
-};
-
-export default useModal;

+ 0 - 9
kafka-ui-react-app/src/lib/paths.ts

@@ -148,7 +148,6 @@ export const clusterTopicMessagesRelativePath = 'messages';
 export const clusterTopicConsumerGroupsRelativePath = 'consumer-groups';
 export const clusterTopicStatisticsRelativePath = 'statistics';
 export const clusterTopicEditRelativePath = 'edit';
-export const clusterTopicSendMessageRelativePath = 'message';
 export const clusterTopicPath = (
   clusterName: ClusterName = RouteParams.clusterName,
   topicName: TopicName = RouteParams.topicName
@@ -190,14 +189,6 @@ export const clusterTopicStatisticsPath = (
     clusterName,
     topicName
   )}/${clusterTopicStatisticsRelativePath}`;
-export const clusterTopicSendMessagePath = (
-  clusterName: ClusterName = RouteParams.clusterName,
-  topicName: TopicName = RouteParams.topicName
-) =>
-  `${clusterTopicPath(
-    clusterName,
-    topicName
-  )}/${clusterTopicSendMessageRelativePath}`;
 
 export type RouteParamsClusterTopic = {
   clusterName: ClusterName;

+ 7 - 0
kafka-ui-react-app/src/theme/theme.ts

@@ -75,6 +75,9 @@ const theme = {
     label: {
       color: Colors.neutral[50],
     },
+    meta: {
+      color: Colors.neutral[30],
+    },
   },
   progressBar: {
     backgroundColor: Colors.neutral[3],
@@ -86,6 +89,8 @@ const theme = {
     minWidth: '1200px',
     navBarWidth: '201px',
     navBarHeight: '53px',
+    rightSidebarWidth: '70vw',
+
     stuffColor: Colors.neutral[5],
     stuffBorderColor: Colors.neutral[10],
     overlay: {
@@ -96,6 +101,7 @@ const theme = {
     },
   },
   pageHeading: {
+    height: '64px',
     dividerColor: Colors.neutral[30],
     backLink: {
       color: {
@@ -350,6 +356,7 @@ const theme = {
     },
   },
   primaryTab: {
+    height: '41px',
     color: {
       normal: Colors.neutral[50],
       hover: Colors.neutral[90],