Compare commits
12 commits
master
...
feature/me
Author | SHA1 | Date | |
---|---|---|---|
![]() |
265e295700 | ||
![]() |
cc3f08697e | ||
![]() |
9e716efc06 | ||
![]() |
f57af84863 | ||
![]() |
678d33605d | ||
![]() |
c19a2f3571 | ||
![]() |
7b9bdd24ab | ||
![]() |
ee1bd2ac12 | ||
![]() |
eb873783c7 | ||
![]() |
3d1c92e7d0 | ||
![]() |
a1a390411b | ||
![]() |
93b413ac1b |
26 changed files with 1527 additions and 8 deletions
|
@ -1,6 +0,0 @@
|
|||
import styled from 'styled-components';
|
||||
|
||||
export const StopLoading = styled.div`
|
||||
color: ${({ theme }) => theme.pageLoader.borderColor};
|
||||
cursor: pointer;
|
||||
`;
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
`;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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()}`,
|
||||
}));
|
||||
};
|
|
@ -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;
|
||||
`;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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``;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 +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);
|
||||
};
|
|
@ -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 />}
|
||||
|
|
|
@ -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;
|
43
kafka-ui-react-app/src/components/common/Modal/Modal.tsx
Normal file
43
kafka-ui-react-app/src/components/common/Modal/Modal.tsx
Normal 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;
|
|
@ -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;
|
||||
`;
|
52
kafka-ui-react-app/src/components/common/Overlay/Overlay.tsx
Normal file
52
kafka-ui-react-app/src/components/common/Overlay/Overlay.tsx
Normal 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;
|
22
kafka-ui-react-app/src/components/common/Portal/Portal.tsx
Normal file
22
kafka-ui-react-app/src/components/common/Portal/Portal.tsx
Normal 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;
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Reference in a new issue