浏览代码

Revert "Remove messages v2 (#2917)"

This reverts commit 8b7c8e3216df2e0686fd7a6b3daa5263fb9cbb11.
Roman Zabaluev 2 年之前
父节点
当前提交
93b413ac1b
共有 20 个文件被更改,包括 1220 次插入7 次删除
  1. 0 6
      kafka-ui-react-app/src/components/Topics/Topic/Messages/Messages.styled.ts
  2. 53 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Advanced Filter/AdvancedFilter.tsx
  3. 118 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Advanced Filter/Form.tsx
  4. 75 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/FiltersBar.styled.ts
  5. 193 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/Form.tsx
  6. 46 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/Meta.tsx
  7. 112 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/FiltersBar/utils.ts
  8. 64 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Messages.styled.ts
  9. 112 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/Messages.tsx
  10. 62 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesContainer.tsx
  11. 25 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/ActionsCell.tsx
  12. 55 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessageContent/MessageContent.styled.ts
  13. 106 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessageContent/MessageContent.tsx
  14. 41 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesTable/MessagesTable.tsx
  15. 39 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/StatusBar.tsx
  16. 50 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/consumingModes.ts
  17. 0 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/getDefaultSerdeName.ts
  18. 65 0
      kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/handleNextPageClick.ts
  19. 3 0
      kafka-ui-react-app/src/components/Topics/Topic/Topic.tsx
  20. 1 1
      kafka-ui-react-app/src/lib/hooks/api/topicMessages.tsx

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

@@ -1,6 +0,0 @@
-import styled from 'styled-components';
-
-export const StopLoading = styled.div`
-  color: ${({ theme }) => theme.pageLoader.borderColor};
-  cursor: pointer;
-`;

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

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

@@ -0,0 +1,193 @@
+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, useSerdes } 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, SerdeUsage } from 'generated-sources';
+import { getModeOptions } from 'components/Topics/Topic/MessagesV2/utils/consumingModes';
+import { getSerdeOptions } from 'components/Topics/Topic/SendMessage/utils';
+import useAppParams from 'lib/hooks/useAppParams';
+import { RouteParamsClusterTopic } from 'lib/paths';
+
+import * as S from './FiltersBar.styled';
+import { setSeekTo } from './utils';
+
+type FormValues = {
+  mode: ConsumingMode;
+  offset: string;
+  time: Date;
+  partitions: Option[];
+  keySerde: string;
+  valueSerde: string;
+};
+
+const Form: React.FC<{ isFetching: boolean; partitions: Partition[] }> = ({
+  isFetching,
+  partitions,
+}) => {
+  const [searchParams, setSearchParams] = useSearchParams();
+  const routerProps = useAppParams<RouteParamsClusterTopic>();
+  const { data: serdes = {} } = useSerdes({
+    ...routerProps,
+    use: SerdeUsage.DESERIALIZE,
+  });
+
+  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(),
+      keySerde: searchParams.get('keySerde') as string,
+      valueSerde: searchParams.get('valueSerde') as string,
+    } as FormValues,
+  });
+  const mode = watch('mode');
+  const offset = watch('offset');
+  const time = watch('time');
+  const keySerde = watch('keySerde');
+  const valueSerde = watch('valueSerde');
+
+  const onSubmit = (values: FormValues) => {
+    searchParams.set('m', values.mode);
+    if (values.keySerde) {
+      searchParams.set('keySerde', values.keySerde);
+    }
+    if (values.valueSerde) {
+      searchParams.set('valueSerde', values.valueSerde);
+    }
+    searchParams.delete('o');
+    searchParams.delete('t');
+    searchParams.delete('a');
+    searchParams.delete('page');
+    if (['fromOffset', 'toOffset'].includes(mode)) {
+      searchParams.set('o', values.offset);
+    } else if (['sinceTime', 'untilTime'].includes(mode)) {
+      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 handleSerdeChange =
+    (type: 'keySerde' | 'valueSerde') => (option: string | number) => {
+      setValue(type, String(option), { 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.FilterRow>
+        <InputLabel>Key Serde</InputLabel>
+        <Select
+          id="selectKeySerdeOptions"
+          aria-labelledby="selectKeySerdeOptions"
+          onChange={handleSerdeChange('keySerde')}
+          options={getSerdeOptions(serdes.key || [])}
+          value={keySerde}
+          selectSize="M"
+          minWidth="100%"
+        />
+      </S.FilterRow>
+      <S.FilterRow>
+        <InputLabel>Content Serde</InputLabel>
+        <Select
+          id="selectValueSerdeOptions"
+          aria-labelledby="selectValueSerdeOptions"
+          onChange={handleSerdeChange('valueSerde')}
+          options={getSerdeOptions(serdes.value || [])}
+          value={valueSerde}
+          selectSize="M"
+          minWidth="100%"
+        />
+      </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;
+};

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

@@ -0,0 +1,64 @@
+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: ${theme.layout.filtersSidebarWidth};
+    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: calc(
+      100vw - ${theme.layout.navBarWidth} - ${theme.layout.filtersSidebarWidth}
+    );
+    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;
+    z-index: 10;
+  `
+);
+
+export const StatusTags = styled.div`
+  display: flex;
+  gap: 4px;
+`;

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

@@ -0,0 +1,112 @@
+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 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 || [];
+
+  // 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;

+ 62 - 0
kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/MessagesContainer.tsx

@@ -0,0 +1,62 @@
+import React, { Suspense } from 'react';
+import { ConsumingMode, useSerdes } from 'lib/hooks/api/topicMessages';
+import useAppParams from 'lib/hooks/useAppParams';
+import { RouteParamsClusterTopic } from 'lib/paths';
+import { useSearchParams } from 'react-router-dom';
+import { useTopicDetails } from 'lib/hooks/api/topics';
+import { MESSAGES_PER_PAGE } from 'lib/constants';
+import { SerdeUsage } from 'generated-sources';
+
+import { setSeekTo } from './FiltersBar/utils';
+import { getDefaultSerdeName } from './utils/getDefaultSerdeName';
+import Messages from './Messages';
+
+const MessagesContainer = () => {
+  const routerProps = useAppParams<RouteParamsClusterTopic>();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const { data: serdes = {} } = useSerdes({
+    ...routerProps,
+    use: SerdeUsage.DESERIALIZE,
+  });
+  const mode = searchParams.get('m') as ConsumingMode;
+  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);
+    }
+    if (!searchParams.get('keySerde')) {
+      searchParams.set('keySerde', getDefaultSerdeName(serdes.key || []));
+    }
+    if (!searchParams.get('valueSerde')) {
+      searchParams.set('valueSerde', getDefaultSerdeName(serdes.value || []));
+    }
+    setSearchParams(searchParams);
+  }, [topic, serdes]);
+
+  return (
+    <Suspense>
+      <Messages />
+    </Suspense>
+  );
+};
+
+export default MessagesContainer;

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

+ 0 - 0
kafka-ui-react-app/src/components/Topics/Topic/Messages/getDefaultSerdeName.ts → kafka-ui-react-app/src/components/Topics/Topic/MessagesV2/utils/getDefaultSerdeName.ts


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

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

@@ -35,6 +35,8 @@ import SlidingSidebar from 'components/common/SlidingSidebar';
 import useBoolean from 'lib/hooks/useBoolean';
 
 import Messages from './Messages/Messages';
+// Messages v2
+import MessagesContainer from './MessagesV2/MessagesContainer';
 import Overview from './Overview/Overview';
 import Settings from './Settings/Settings';
 import TopicConsumerGroups from './ConsumerGroups/TopicConsumerGroups';
@@ -217,6 +219,7 @@ const Topic: React.FC = () => {
             path={clusterTopicMessagesRelativePath}
             element={<Messages />}
           />
+          <Route path="v2" element={<MessagesContainer />} />
           <Route
             path={clusterTopicSettingsRelativePath}
             element={<Settings />}

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

@@ -13,9 +13,9 @@ import {
 } from 'generated-sources';
 import { showServerError } from 'lib/errorHandling';
 import toast from 'react-hot-toast';
+import { StopLoading } from 'components/Topics/Topic/MessagesV2/FiltersBar/FiltersBar.styled';
 import { useQuery } from '@tanstack/react-query';
 import { messagesApiClient } from 'lib/api';
-import { StopLoading } from 'components/Topics/Topic/Messages/Messages.styled';
 
 interface UseTopicMessagesProps {
   clusterName: ClusterName;