Compare commits

...
Sign in to create a new pull request.

12 commits

26 changed files with 1527 additions and 8 deletions

View file

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

View file

@ -5,7 +5,7 @@ import { useSearchParams } from 'react-router-dom';
import { useSerdes } from 'lib/hooks/api/topicMessages';
import useAppParams from 'lib/hooks/useAppParams';
import { RouteParamsClusterTopic } from 'lib/paths';
import { getDefaultSerdeName } from 'components/Topics/Topic/Messages/getDefaultSerdeName';
import { getDefaultSerdeName } from 'components/Topics/Topic/MessagesV2/utils/getDefaultSerdeName';
import { MESSAGES_PER_PAGE } from 'lib/constants';
import MessagesTable from './MessagesTable';

View file

@ -0,0 +1,57 @@
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';
export interface AdvancedFilterProps {
onClose?: () => void;
}
const AdvancedFilter: React.FC<AdvancedFilterProps> = ({ onClose }) => {
const { save, apply, filters, remove } = useMessageFiltersStore();
return (
<div>
<Form save={save} apply={apply} onClose={onClose} />
{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;

View file

@ -0,0 +1,132 @@
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().when('saveFilter', {
is: (value: boolean | undefined) => typeof value === 'undefined' || value,
then: (schema) => schema.required(),
otherwise: (schema) => schema.notRequired(),
}),
});
export interface FormProps {
name?: string;
value?: string;
save(filter: AdvancedFilter): void;
apply(filter: AdvancedFilter): void;
onClose?: () => void;
}
const Form: React.FC<FormProps> = ({
name = '',
value,
save,
apply,
onClose,
}) => {
const methods = useForm<AdvancedFilter>({
mode: 'onChange',
resolver: yupResolver(validationSchema),
});
const {
handleSubmit,
control,
formState: { isDirty, isSubmitting, isValid, errors },
reset,
getValues,
} = methods;
const onApply = React.useCallback(() => {
apply(getValues());
reset({ name: '', value: '' });
if (onClose) {
onClose();
}
}, []);
const onSubmit = React.useCallback(
(values: AdvancedFilter) => {
save(values);
onApply();
},
[reset, save]
);
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}
>
Save & Apply
</Button>
<Button
buttonSize="M"
buttonType="primary"
type="submit"
disabled={isSubmitting || !isDirty}
onClick={onApply}
>
Apply Filter
</Button>
</S.FilterButtonWrapper>
</form>
</FormProvider>
);
};
export default Form;

View file

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

View file

@ -0,0 +1,247 @@
import React from 'react';
import { useForm, Controller, FormProvider } 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 MultiSelect from 'components/common/MultiSelect/MultiSelect.styled';
import * as S from './FiltersBar.styled';
import { getSelectedPartitionsOptionFromSeekToParam, 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 methods = 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,
partitions: getSelectedPartitionsOptionFromSeekToParam(
searchParams,
partitions
),
} as FormValues,
});
const {
handleSubmit,
watch,
control,
getValues,
formState: { isDirty },
reset,
} = methods;
const mode = watch('mode');
const partitionMap = React.useMemo(
() =>
partitions.reduce<Record<string, Partition>>(
(acc, partition) => ({
...acc,
[partition.partition]: partition,
}),
{}
),
[partitions]
);
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()}`);
}
const selectedPartitions = values.partitions.map((partition) => {
return partitionMap[partition.value];
});
setSeekTo(searchParams, selectedPartitions);
setSearchParams(searchParams);
reset(values);
};
const handleRefresh: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
e.preventDefault();
searchParams.set('a', `${Number(searchParams.get('a') || 0) + 1}`);
setSearchParams(searchParams);
};
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)}>
<S.FilterRow>
<InputLabel>Mode</InputLabel>
<Controller
control={control}
name="mode"
defaultValue={getValues('mode')}
render={({ field }) => (
<Select
selectSize="M"
minWidth="100%"
value={field.value}
options={getModeOptions()}
isLive={mode === 'live' && isFetching}
onChange={field.onChange}
/>
)}
/>
</S.FilterRow>
{['sinceTime', 'untilTime'].includes(mode) && (
<S.FilterRow>
<InputLabel>Time</InputLabel>
<Controller
control={control}
name="time"
defaultValue={getValues('time')}
render={({ field }) => (
<S.DatePickerInput
selected={field.value}
onChange={field.onChange}
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"
placeholder="Offset"
name="offset"
/>
</S.FilterRow>
)}
<S.FilterRow>
<InputLabel>Key Serde</InputLabel>
<Controller
control={control}
name="keySerde"
defaultValue={getValues('keySerde')}
render={({ field }) => (
<Select
id="selectKeySerdeOptions"
aria-labelledby="selectKeySerdeOptions"
onChange={field.onChange}
options={getSerdeOptions(serdes.key || [])}
value={field.value}
selectSize="M"
minWidth="100%"
/>
)}
/>
</S.FilterRow>
<S.FilterRow>
<InputLabel>Content Serde</InputLabel>
<Controller
control={control}
name="valueSerde"
defaultValue={getValues('valueSerde')}
render={({ field }) => (
<Select
id="selectValueSerdeOptions"
aria-labelledby="selectValueSerdeOptions"
onChange={field.onChange}
options={getSerdeOptions(serdes.value || [])}
value={field.value}
selectSize="M"
minWidth="100%"
/>
)}
/>
</S.FilterRow>
<S.FilterRow>
<InputLabel>Partitions</InputLabel>
<Controller
control={control}
name="partitions"
render={({ field }) => (
<MultiSelect
options={partitions.map((p) => ({
label: `Partition #${p.partition.toString()}`,
value: p.partition,
}))}
value={field.value}
onChange={field.onChange}
labelledBy="Select partitions"
/>
)}
/>
</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>
</FormProvider>
);
};
export default Form;

View file

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

View file

@ -0,0 +1,140 @@
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're 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;
};
export const getSelectedPartitionsOptionFromSeekToParam = (
params: URLSearchParams,
partitions: Partition[]
) => {
const seekTo = params.get('seekTo');
if (seekTo) {
const selectedPartitionIds = seekTo
.split('.')
.map((item) => Number(item.split('-')[0]));
return partitions.reduce((acc, partition) => {
if (selectedPartitionIds?.includes(partition.partition)) {
acc.push({
value: partition.partition,
label: `Partition #${partition.partition.toString()}`,
});
}
return acc;
}, [] as Option[]);
}
return partitions.map(({ partition }) => ({
value: partition,
label: `Partition #${partition.toString()}`,
}));
};

View file

@ -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;
`;

View file

@ -0,0 +1,111 @@
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 Modal from 'components/common/Modal/Modal';
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 './AdvancedFilter/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 || 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>
<Modal
open={isAdvancedFiltersSidebarVisible}
onClose={closeAdvancedFiltersSidebar}
header="Add new filter"
>
<AdvancedFilter onClose={closeAdvancedFiltersSidebar} />
</Modal>
</>
);
};
export default Messages;

View file

@ -0,0 +1,73 @@
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 { useMessageFiltersStore } from 'lib/hooks/useMessageFiltersStore';
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 || [];
const activeFilterValue = useMessageFiltersStore(
(state) => state.activeFilter?.value
);
/**
* Search params:
* - `q` - search query
* - `m` - way the consumer is going to consume the messages..
* - `o` - offset
* - `t` - timestamp
* - `q` - search query
* - `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 || []));
}
if (activeFilterValue && searchParams.get('q') !== activeFilterValue) {
searchParams.set('q', activeFilterValue);
}
setSearchParams(searchParams);
}, [topic, serdes, activeFilterValue]);
return (
<Suspense>
<Messages />
</Suspense>
);
};
export default MessagesContainer;

View file

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

View file

@ -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``;

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,66 @@
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':
// 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':
// First message in the reversed array is the message with min offset.
return { partition, offset: Math.min(message.offset, offset) };
case 'oldest':
case 'newest':
return { partition, offset: message.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);
};

View file

@ -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 />}

View file

@ -0,0 +1,37 @@
import React, { MouseEventHandler, SyntheticEvent } from 'react';
import styled from 'styled-components';
interface BackdropProps {
onClick?: (e: SyntheticEvent<HTMLElement>) => void;
open?: boolean;
}
const BackdropStyled = styled.div`
display: flex;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(23, 26, 28, 0.6);
`;
const Backdrop: React.FC<BackdropProps> = ({ open, onClick }) => {
const handleClick: MouseEventHandler<HTMLElement> = (e) => {
e.stopPropagation();
if (e.target !== e.currentTarget) {
return;
}
onClick?.(e);
};
return open ? (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<BackdropStyled onClick={handleClick} />
) : null;
};
export default Backdrop;

View file

@ -0,0 +1,43 @@
import React from 'react';
import Overlay, { OverlayProps } from 'components/common/Overlay/Overlay';
import CloseIcon from 'components/common/Icons/CloseIcon';
import {
CloseIconWrapper,
ModalSizes,
Wrapper,
Header,
} from './ModalStyled.styled';
interface ModalProps extends OverlayProps {
size?: ModalSizes;
header?: string;
isCloseIcon?: boolean;
transparent?: boolean;
}
const Modal: React.FC<React.PropsWithChildren<ModalProps>> = ({
children,
open,
portal = true,
header,
isCloseIcon,
onClose,
transparent = false,
}) => {
return (
<Overlay backdrop onClose={onClose} open={open} portal={portal}>
<Wrapper transparent={transparent}>
{isCloseIcon && (
<CloseIconWrapper>
<CloseIcon />
</CloseIconWrapper>
)}
{header && <Header>{header}</Header>}
{children}
</Wrapper>
</Overlay>
);
};
export default Modal;

View file

@ -0,0 +1,35 @@
import styled, { css } from 'styled-components';
export type ModalSizes = 'XS' | 'S' | 'M' | 'L';
export const Wrapper = styled.div<{ transparent: boolean }>(
({ theme, transparent }) => css`
position: relative;
display: flex;
flex-direction: column;
width: 90vw;
max-width: 560px;
max-height: 90vh;
margin: 5vh auto;
background-color: ${transparent
? 'transparent'
: theme.modal.backgroundColor};
padding: 20px;
border-radius: 8px;
overflow: hidden;
`
);
export const CloseIconWrapper = styled.span`
position: absolute;
right: 16px;
top: 16px;
cursor: pointer;
font-size: 24px;
`;
export const Header = styled.span`
font-weight: 700;
font-size: 20px;
text-align: center;
`;

View file

@ -0,0 +1,52 @@
import React, { SyntheticEvent } from 'react';
import styled from 'styled-components';
import Backdrop from 'components/common/Backdrop/Backdrop';
import Portal from 'components/common/Portal/Portal';
export interface OverlayProps {
backdrop?: boolean;
open?: boolean;
portal?: boolean;
onClose?: (e: SyntheticEvent<HTMLElement>) => void;
}
const Wrapper = styled.div`
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
z-index: 1100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
> * {
pointer-events: auto;
}
`;
const Overlay: React.FC<React.PropsWithChildren<OverlayProps>> = ({
children,
backdrop,
open,
portal,
onClose,
}) => {
if (!open) {
return null;
}
return (
<Portal conditional={portal}>
<Wrapper>
{backdrop && <Backdrop onClick={onClose} open={open} />}
{children}
</Wrapper>
</Portal>
);
};
export default Overlay;

View file

@ -0,0 +1,22 @@
import React, { ReactElement } from 'react';
import { createPortal } from 'react-dom';
interface PortalProps {
conditional?: boolean;
target?: HTMLElement;
}
const Portal: React.FC<React.PropsWithChildren<PortalProps>> = ({
children,
conditional = true,
target,
}) => {
if (!conditional) {
return children as ReactElement;
}
const node = target ?? document.body;
return createPortal(children, node);
};
export default Portal;

View file

@ -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;
@ -93,6 +93,12 @@ export const useTopicMessages = ({
break;
}
// 0 case is null
if (searchParams.get('page')) {
// if the pagination is working then seekType is offset
requestParams.set('seekType', SeekType.OFFSET);
}
await fetchEventSource(`${url}?${requestParams.toString()}`, {
method: 'GET',
signal: abortController.signal,