From d9e74deb287e7b81172c6988dd0f99b1a97d9036 Mon Sep 17 00:00:00 2001 From: Oleg Shur Date: Tue, 20 Sep 2022 13:44:02 +0300 Subject: [PATCH] [Experimental] New Messages layout (#2462) --- kafka-ui-react-app/package.json | 6 +- kafka-ui-react-app/pnpm-lock.yaml | 36 +++- .../Brokers/Broker/Configs/Configs.tsx | 2 +- .../src/components/Connect/List/ListPage.tsx | 8 +- .../Details/ResetOffsets/ResetOffsets.tsx | 3 +- .../components/ConsumerGroups/List/List.tsx | 12 +- .../src/components/Schemas/List/List.tsx | 8 +- .../src/components/Topics/List/ListPage.tsx | 8 +- .../DangerZone/__test__/DangerZone.spec.tsx | 6 +- .../Topic/Messages/Filters/AddFilter.tsx | 4 +- .../Topics/Topic/Messages/Filters/Filters.tsx | 13 +- .../Topics/Topic/Messages/Filters/utils.ts | 2 +- .../MessageContent/MessageContent.styled.ts | 2 - .../Topic/Messages/__test__/utils.spec.ts | 2 +- .../Advanced Filter/AdvancedFilter.tsx | 53 ++++++ .../Topic/MessagesV2/Advanced Filter/Form.tsx | 118 ++++++++++++ .../FiltersBar/FiltersBar.styled.ts | 75 ++++++++ .../Topic/MessagesV2/FiltersBar/Form.tsx | 144 ++++++++++++++ .../Topic/MessagesV2/FiltersBar/Meta.tsx | 46 +++++ .../Topic/MessagesV2/FiltersBar/utils.ts | 112 +++++++++++ .../Topic/MessagesV2/Messages.styled.ts | 61 ++++++ .../Topics/Topic/MessagesV2/Messages.tsx | 137 ++++++++++++++ .../MessagesV2/MessagesTable/ActionsCell.tsx | 25 +++ .../MessageContent/MessageContent.styled.ts | 55 ++++++ .../MessageContent/MessageContent.tsx | 106 +++++++++++ .../MessagesTable/MessagesTable.tsx | 41 ++++ .../Topics/Topic/MessagesV2/StatusBar.tsx | 39 ++++ .../Topic/MessagesV2/utils/consumingModes.ts | 50 +++++ .../MessagesV2/utils/handleNextPageClick.ts | 65 +++++++ .../Topic/SendMessage/SendMessage.styled.tsx | 1 - .../Topics/Topic/SendMessage/SendMessage.tsx | 27 ++- .../SendMessage/__test__/SendMessage.spec.tsx | 31 +-- .../src/components/Topics/Topic/Topic.tsx | 24 ++- .../Topics/Topic/__test__/Topic.spec.tsx | 9 +- .../components/common/Dropdown/Dropdown.tsx | 10 +- .../components/common/Input/Input.styled.ts | 8 +- .../src/components/common/Input/Input.tsx | 4 +- .../common/Input/InputLabel.styled.ts | 6 + .../common/MultiSelect/MultiSelect.styled.ts | 2 +- .../common/Navigation/Navbar.styled.ts | 1 + .../common/NewTable/Table.styled.ts | 8 + .../src/components/common/NewTable/Table.tsx | 14 +- .../common/NewTable/TimestampCell copy.tsx | 11 ++ .../PropertiesList/PropertiesList.styled.tsx | 7 +- .../src/components/common/Search/Search.tsx | 26 ++- .../common/Search/__tests__/Search.spec.tsx | 42 ++--- .../SlidingSidebar/SlidingSidebar.styled.ts | 36 ++++ .../common/SlidingSidebar/SlidingSidebar.tsx | 32 ++++ .../components/common/SlidingSidebar/index.ts | 3 + .../src/lib/__test__/paths.spec.ts | 11 -- kafka-ui-react-app/src/lib/constants.ts | 3 + kafka-ui-react-app/src/lib/dateTimeHelpers.ts | 22 +++ .../lib/hooks/__tests__/useBoolean.spec.ts | 66 +++++++ .../src/lib/hooks/__tests__/useModal.spec.ts | 66 ------- .../src/lib/hooks/api/topicMessages.tsx | 177 ++++++++++++++++++ .../src/lib/hooks/useBoolean.ts | 21 +++ .../src/lib/hooks/useLocalStorage.ts | 20 ++ .../src/lib/hooks/useMessageFiltersStore.ts | 41 ++++ kafka-ui-react-app/src/lib/hooks/useModal.ts | 32 ---- kafka-ui-react-app/src/lib/paths.ts | 9 - kafka-ui-react-app/src/theme/theme.ts | 7 + 61 files changed, 1740 insertions(+), 276 deletions(-) create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Advanced Filter/AdvancedFilter.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Advanced Filter/Form.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/FiltersBar.styled.ts create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/Form.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/Meta.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/utils.ts create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Messages.styled.ts create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Messages.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/ActionsCell.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessageContent/MessageContent.styled.ts create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessageContent/MessageContent.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessagesTable.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/StatusBar.tsx create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/consumingModes.ts create mode 100644 kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/handleNextPageClick.ts create mode 100644 kafka-ui-react-app/src/components/common/NewTable/TimestampCell copy.tsx create mode 100644 kafka-ui-react-app/src/components/common/SlidingSidebar/SlidingSidebar.styled.ts create mode 100644 kafka-ui-react-app/src/components/common/SlidingSidebar/SlidingSidebar.tsx create mode 100644 kafka-ui-react-app/src/components/common/SlidingSidebar/index.ts create mode 100644 kafka-ui-react-app/src/lib/hooks/__tests__/useBoolean.spec.ts delete mode 100644 kafka-ui-react-app/src/lib/hooks/__tests__/useModal.spec.ts create mode 100644 kafka-ui-react-app/src/lib/hooks/api/topicMessages.tsx create mode 100644 kafka-ui-react-app/src/lib/hooks/useBoolean.ts create mode 100644 kafka-ui-react-app/src/lib/hooks/useLocalStorage.ts create mode 100644 kafka-ui-react-app/src/lib/hooks/useMessageFiltersStore.ts delete mode 100644 kafka-ui-react-app/src/lib/hooks/useModal.ts diff --git a/kafka-ui-react-app/package.json b/kafka-ui-react-app/package.json index 80e620c452..22c9047db4 100644 --- a/kafka-ui-react-app/package.json +++ b/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}": [ diff --git a/kafka-ui-react-app/pnpm-lock.yaml b/kafka-ui-react-app/pnpm-lock.yaml index d5a7161b18..c789e1b048 100644 --- a/kafka-ui-react-app/pnpm-lock.yaml +++ b/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 diff --git a/kafka-ui-react-app/src/components/Brokers/Broker/Configs/Configs.tsx b/kafka-ui-react-app/src/components/Brokers/Broker/Configs/Configs.tsx index 33ed6c25d6..31a42f60df 100644 --- a/kafka-ui-react-app/src/components/Brokers/Broker/Configs/Configs.tsx +++ b/kafka-ui-react-app/src/components/Brokers/Broker/Configs/Configs.tsx @@ -68,7 +68,7 @@ const Configs: React.FC = () => { <> diff --git a/kafka-ui-react-app/src/components/Connect/List/ListPage.tsx b/kafka-ui-react-app/src/components/Connect/List/ListPage.tsx index 4834c85425..904be0d7b6 100644 --- a/kafka-ui-react-app/src/components/Connect/List/ListPage.tsx +++ b/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(); - 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 = () => { - + }> diff --git a/kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/ResetOffsets.tsx b/kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/ResetOffsets.tsx index b8df3b56b8..cb4b602cd8 100644 --- a/kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/ResetOffsets.tsx +++ b/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'; diff --git a/kafka-ui-react-app/src/components/ConsumerGroups/List/List.tsx b/kafka-ui-react-app/src/components/ConsumerGroups/List/List.tsx index 3939f111ff..89be57d639 100644 --- a/kafka-ui-react-app/src/components/ConsumerGroups/List/List.tsx +++ b/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 = ({ consumerGroups, totalPages }) => { - const [searchText, handleSearchText] = useSearch(); const dispatch = useAppDispatch(); const { clusterName } = useAppParams(); const [searchParams] = useSearchParams(); @@ -40,10 +38,10 @@ const List: React.FC = ({ 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[]>( () => [ @@ -87,11 +85,7 @@ const List: React.FC = ({ consumerGroups, totalPages }) => { <> - + { 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 = () => { )} - + {isFetched ? (
{ 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 = () => { )} - + + + Name + Value + + + + + {filters.map((filter) => ( + + + + + + ))} + + + + )} + + ); +}; + +export default AdvancedFilter; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Advanced Filter/Form.tsx b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Advanced Filter/Form.tsx new file mode 100644 index 0000000000..71f11911f1 --- /dev/null +++ b/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 = ({ name, value, save, apply }) => { + const methods = useForm({ + 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 ( + + +
+ Filter code + ( + + )} + /> +
+
+ + + +
+
+ Display name + +
+
+ + + +
+ + + + + +
+ ); +}; + +export default Form; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/FiltersBar.styled.ts b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/FiltersBar.styled.ts new file mode 100644 index 0000000000..bd36e913e6 --- /dev/null +++ b/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; + } +`; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/Form.tsx b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/Form.tsx new file mode 100644 index 0000000000..0deb6266df --- /dev/null +++ b/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({ + 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) => { + setValue('offset', e.target.value, { shouldDirty: true }); + }; + const handleRefresh: React.MouseEventHandler = (e) => { + e.stopPropagation(); + e.preventDefault(); + searchParams.set('a', `${Number(searchParams.get('a') || 0) + 1}`); + setSearchParams(searchParams); + }; + + return ( +
+ + Mode + + + )} + + + + + + + ); +}; + +export default Form; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/Meta.tsx b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/Meta.tsx new file mode 100644 index 0000000000..9057b2514b --- /dev/null +++ b/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 = ({ meta = {} }) => { + const { bytesConsumed, messagesConsumed, elapsedMs } = meta; + + return ( + + + + + + + {messagesConsumed || 0} msg. + + + + + + + + + + + + {formatMilliseconds(elapsedMs)} + + + + ); +}; + +export default Meta; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/utils.ts b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/utils.ts new file mode 100644 index 0000000000..99528b270d --- /dev/null +++ b/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): 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; +}; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Messages.styled.ts b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Messages.styled.ts new file mode 100644 index 0000000000..ca256684c2 --- /dev/null +++ b/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; +`; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Messages.tsx b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Messages.tsx new file mode 100644 index 0000000000..07d65d3c37 --- /dev/null +++ b/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(); + 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 ( + <> + + + + + +
+
+ + + + +
+ + + + + + + +
+ + + + + ); +}; + +export default Messages; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/ActionsCell.tsx b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/ActionsCell.tsx new file mode 100644 index 0000000000..3769cdf95f --- /dev/null +++ b/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> = ({ row }) => { + const { content } = row.original; + + const { copyToClipboard, saveFile } = useDataSaver( + 'topic-message', + content || '' + ); + + return ( + + + Copy content to clipboard + + Save content as a file + + ); +}; + +export default ActionsCell; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessageContent/MessageContent.styled.ts b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessageContent/MessageContent.styled.ts new file mode 100644 index 0000000000..9ca2bfbf4f --- /dev/null +++ b/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``; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessageContent/MessageContent.tsx b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessageContent/MessageContent.tsx new file mode 100644 index 0000000000..e0eb83a372 --- /dev/null +++ b/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 }> = ({ row }) => { + const { + content, + valueFormat, + key, + keyFormat, + headers, + timestamp, + timestampType, + } = row.original; + + const [activeTab, setActiveTab] = React.useState('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 ( + + + + setActiveTab('key')} + > + Key + + setActiveTab('content')} + > + Content + + setActiveTab('headers')} + > + Headers + + + + + + + + + {formatTimestamp(timestamp)} + Timestamp type: {timestampType} + + + + {valueFormat} + + Size: + + + + + {keyFormat} + + Size: + + + + + + ); +}; + +export default MessageContent; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessagesTable.tsx b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessagesTable.tsx new file mode 100644 index 0000000000..995594d4c7 --- /dev/null +++ b/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[]>( + () => [ + { 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 ( +
{filter.name} +
{filter.value}
+
+ + apply(filter)}> + Apply Filter + + remove(filter.name)}> + Delete filter + + +
true} + enableRowSelection={false} + enableSorting={false} + renderSubComponent={MessageContent} + /> + ); +}; + +export default MessagesTable; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/StatusBar.tsx b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/StatusBar.tsx new file mode 100644 index 0000000000..7f707cf481 --- /dev/null +++ b/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 ( + + + {offset || timestamp ? ( + <> + {mode}: {offset || timestamp} + + ) : ( + mode + )} + + {query && ( + + Search: {query} + + )} + + ); +}; + +export default StatusBar; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/consumingModes.ts b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/consumingModes.ts new file mode 100644 index 0000000000..1f3671999a --- /dev/null +++ b/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; +}; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/handleNextPageClick.ts b/kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/handleNextPageClick.ts new file mode 100644 index 0000000000..2d92d962bd --- /dev/null +++ b/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); +}; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.styled.tsx b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.styled.tsx index 55613b1694..483c41d053 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.styled.tsx +++ b/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; `; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx index 781a33aed6..50278a3501 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx @@ -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(); - 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 ( - + - Partition + Partition { - Key + Key { /> - Content + Content { - Headers + Headers { type="submit" disabled={!isDirty || isSubmitting} > - Send + Produce Message diff --git a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/SendMessage.spec.tsx b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/SendMessage.spec.tsx index 803ffbd791..b72e0fe6ca 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/SendMessage.spec.tsx +++ b/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( - - + + , { 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(); }); }); diff --git a/kafka-ui-react-app/src/components/Topics/Topic/Topic.tsx b/kafka-ui-react-app/src/components/Topics/Topic/Topic.tsx index 723a6e7aef..4bb8aa28bc 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/Topic.tsx +++ b/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(); const navigate = useNavigate(); const deleteTopic = useDeleteTopic(clusterName); @@ -76,7 +82,7 @@ const Topic: React.FC = () => { diff --git a/kafka-ui-react-app/src/components/common/NewTable/TimestampCell copy.tsx b/kafka-ui-react-app/src/components/common/NewTable/TimestampCell copy.tsx new file mode 100644 index 0000000000..c5ad01c7c9 --- /dev/null +++ b/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> = ({ + getValue, +}) => {getValue()}; + +export default TruncatedTextCell; diff --git a/kafka-ui-react-app/src/components/common/PropertiesList/PropertiesList.styled.tsx b/kafka-ui-react-app/src/components/common/PropertiesList/PropertiesList.styled.tsx index d3f986bb2c..6257040940 100644 --- a/kafka-ui-react-app/src/components/common/PropertiesList/PropertiesList.styled.tsx +++ b/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; +`; diff --git a/kafka-ui-react-app/src/components/common/Search/Search.tsx b/kafka-ui-react-app/src/components/common/Search/Search.tsx index 78dd758f99..9baf1ed3b2 100644 --- a/kafka-ui-react-app/src/components/common/Search/Search.tsx +++ b/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 = ({ - 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 ( ({ 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( - - ); - const input = screen.getByPlaceholderText('Search bt the Topic name'); + render(); + 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( - - ); - expect( - screen.getByPlaceholderText('Search bt the Topic name') - ).toBeInTheDocument(); + render(); + expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument(); }); it('when placeholder is not provided', () => { - render(); + render(); expect(screen.queryByPlaceholderText('Search')).toBeInTheDocument(); }); }); diff --git a/kafka-ui-react-app/src/components/common/SlidingSidebar/SlidingSidebar.styled.ts b/kafka-ui-react-app/src/components/common/SlidingSidebar/SlidingSidebar.styled.ts new file mode 100644 index 0000000000..1b1a7ac0d4 --- /dev/null +++ b/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; +` +); diff --git a/kafka-ui-react-app/src/components/common/SlidingSidebar/SlidingSidebar.tsx b/kafka-ui-react-app/src/components/common/SlidingSidebar/SlidingSidebar.tsx new file mode 100644 index 0000000000..40ef062d51 --- /dev/null +++ b/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 { + open?: boolean; + title: string; + onClose?: () => void; +} + +const SlidingSidebar: React.FC = ({ + open, + title, + children, + onClose, +}) => { + return ( + + + {title} + + + {children} + + ); +}; + +export default SlidingSidebar; diff --git a/kafka-ui-react-app/src/components/common/SlidingSidebar/index.ts b/kafka-ui-react-app/src/components/common/SlidingSidebar/index.ts new file mode 100644 index 0000000000..9406bfa417 --- /dev/null +++ b/kafka-ui-react-app/src/components/common/SlidingSidebar/index.ts @@ -0,0 +1,3 @@ +import SlidingSidebar from './SlidingSidebar'; + +export default SlidingSidebar; diff --git a/kafka-ui-react-app/src/lib/__test__/paths.spec.ts b/kafka-ui-react-app/src/lib/__test__/paths.spec.ts index 4fb4a3360c..c6abb77074 100644 --- a/kafka-ui-react-app/src/lib/__test__/paths.spec.ts +++ b/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` diff --git a/kafka-ui-react-app/src/lib/constants.ts b/kafka-ui-react-app/src/lib/constants.ts index 49ca968ba4..5d4a40e724 100644 --- a/kafka-ui-react-app/src/lib/constants.ts +++ b/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', diff --git a/kafka-ui-react-app/src/lib/dateTimeHelpers.ts b/kafka-ui-react-app/src/lib/dateTimeHelpers.ts index b32a3c2799..0a9306464c 100644 --- a/kafka-ui-react-app/src/lib/dateTimeHelpers.ts +++ b/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`; +}; diff --git a/kafka-ui-react-app/src/lib/hooks/__tests__/useBoolean.spec.ts b/kafka-ui-react-app/src/lib/hooks/__tests__/useBoolean.spec.ts new file mode 100644 index 0000000000..12ec120752 --- /dev/null +++ b/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(); + }); +}); diff --git a/kafka-ui-react-app/src/lib/hooks/__tests__/useModal.spec.ts b/kafka-ui-react-app/src/lib/hooks/__tests__/useModal.spec.ts deleted file mode 100644 index 85064e4ea8..0000000000 --- a/kafka-ui-react-app/src/lib/hooks/__tests__/useModal.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/kafka-ui-react-app/src/lib/hooks/api/topicMessages.tsx b/kafka-ui-react-app/src/lib/hooks/api/topicMessages.tsx new file mode 100644 index 0000000000..80c9ea02ab --- /dev/null +++ b/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([]); + const [phase, setPhase] = React.useState(); + const [meta, setMeta] = React.useState(); + const [isFetching, setIsFetching] = React.useState(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: ( + <> +
Consuming messages...
+   + Abort + + ), + 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, + }; +}; diff --git a/kafka-ui-react-app/src/lib/hooks/useBoolean.ts b/kafka-ui-react-app/src/lib/hooks/useBoolean.ts new file mode 100644 index 0000000000..ac016ef1a0 --- /dev/null +++ b/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>; +} + +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; diff --git a/kafka-ui-react-app/src/lib/hooks/useLocalStorage.ts b/kafka-ui-react-app/src/lib/hooks/useLocalStorage.ts new file mode 100644 index 0000000000..d8945620db --- /dev/null +++ b/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]; +}; diff --git a/kafka-ui-react-app/src/lib/hooks/useMessageFiltersStore.ts b/kafka-ui-react-app/src/lib/hooks/useMessageFiltersStore.ts new file mode 100644 index 0000000000..9aa59b00e0 --- /dev/null +++ b/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()( + 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`, + } + ) +); diff --git a/kafka-ui-react-app/src/lib/hooks/useModal.ts b/kafka-ui-react-app/src/lib/hooks/useModal.ts deleted file mode 100644 index 88a417aab4..0000000000 --- a/kafka-ui-react-app/src/lib/hooks/useModal.ts +++ /dev/null @@ -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(!!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; diff --git a/kafka-ui-react-app/src/lib/paths.ts b/kafka-ui-react-app/src/lib/paths.ts index 8fb14ad088..94609de491 100644 --- a/kafka-ui-react-app/src/lib/paths.ts +++ b/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; diff --git a/kafka-ui-react-app/src/theme/theme.ts b/kafka-ui-react-app/src/theme/theme.ts index a962b2a535..d05ad54cb9 100644 --- a/kafka-ui-react-app/src/theme/theme.ts +++ b/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],
- {renderSubComponent({ row })} +