[Experimental] New Messages layout (#2462)

This commit is contained in:
Oleg Shur 2022-09-20 13:44:02 +03:00 committed by GitHub
parent 93852b2600
commit d9e74deb28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 1740 additions and 276 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,11 @@
import { CellContext } from '@tanstack/react-table';
import React from 'react';
import * as S from './Table.styled';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TruncatedTextCell: React.FC<CellContext<any, unknown>> = ({
getValue,
}) => <S.Ellipsis>{getValue<string>()}</S.Ellipsis>;
export default TruncatedTextCell;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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