Skip to content

Commit

Permalink
Add filtering and pagination for topic messages (provectus#66)
Browse files Browse the repository at this point in the history
* Add filtering and pagination for topic messages

* Add delay to search query, momoize some functions
  • Loading branch information
maksimtereshin committed Jul 2, 2020
1 parent 814a744 commit a001365
Show file tree
Hide file tree
Showing 12 changed files with 349 additions and 133 deletions.
1 change: 1 addition & 0 deletions kafka-ui-react-app/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/ban-ts-ignore": "off",
"import/extensions": [
"error",
"ignorePackages",
Expand Down
137 changes: 86 additions & 51 deletions kafka-ui-react-app/package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion kafka-ui-react-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@types/react-datepicker": "^3.0.2",
"bulma": "^0.8.0",
"bulma-switch": "^2.0.0",
"classnames": "^2.2.6",
"immer": "^6.0.5",
"date-fns": "^2.14.0",
"immer": "^6.0.5",
"lodash": "^4.17.15",
"pretty-ms": "^6.0.1",
"react": "^16.12.0",
"react-datepicker": "^3.0.0",
"react-dom": "^16.12.0",
"react-hook-form": "^4.5.5",
"react-redux": "^7.1.3",
Expand Down
4 changes: 4 additions & 0 deletions kafka-ui-react-app/src/components/App.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ $navbar-width: 250px;
overflow-y: scroll;
}
}

.react-datepicker-wrapper {
display: flex !important;
}
262 changes: 197 additions & 65 deletions kafka-ui-react-app/src/components/Topics/Details/Messages/Messages.tsx
Original file line number Diff line number Diff line change
@@ -1,105 +1,237 @@
import React from 'react';
import { ClusterName, TopicMessage, TopicName } from 'redux/interfaces';
import React, { useCallback, useEffect, useRef } from 'react';
import {
ClusterName,
SeekTypes,
TopicMessage,
TopicMessageQueryParams,
TopicName,
} from 'redux/interfaces';
import PageLoader from 'components/common/PageLoader/PageLoader';
import { format } from 'date-fns';
import DatePicker from 'react-datepicker';

import 'react-datepicker/dist/react-datepicker.css';
import CustomParamButton, {
CustomParamButtonType,
} from 'components/Topics/shared/Form/CustomParams/CustomParamButton';

import { debounce } from 'lodash';

interface Props {
clusterName: ClusterName;
topicName: TopicName;
isFetched: boolean;
fetchTopicMessages: (clusterName: ClusterName, topicName: TopicName) => void;
fetchTopicMessages: (
clusterName: ClusterName,
topicName: TopicName,
queryParams: Partial<TopicMessageQueryParams>
) => void;
messages: TopicMessage[];
}

interface FilterProps {
offset: number;
partition: number;
}

function usePrevious(value: any) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}

const Messages: React.FC<Props> = ({
isFetched,
clusterName,
topicName,
messages,
fetchTopicMessages,
}) => {
const [searchQuery, setSearchQuery] = React.useState<string>('');
const [searchTimestamp, setSearchTimestamp] = React.useState<Date | null>(
null
);
const [filterProps, setFilterProps] = React.useState<FilterProps[]>([]);
const [queryParams, setQueryParams] = React.useState<
Partial<TopicMessageQueryParams>
>({ limit: 100 });

const prevSearchTimestamp = usePrevious(searchTimestamp);

const getUniqueDataForEachPartition: FilterProps[] = React.useMemo(() => {
const map = messages.map((message) => [
message.partition,
{
partition: message.partition,
offset: message.offset,
},
]);
// @ts-ignore
return [...new Map(map).values()];
}, [messages]);

React.useEffect(() => {
fetchTopicMessages(clusterName, topicName, queryParams);
}, [fetchTopicMessages, clusterName, topicName, queryParams]);

React.useEffect(() => {
fetchTopicMessages(clusterName, topicName);
}, [fetchTopicMessages, clusterName, topicName]);
setFilterProps(getUniqueDataForEachPartition);
}, [messages]);

const handleDelayedQuery = useCallback(
debounce(
(query: string) => setQueryParams({ ...queryParams, q: query }),
1000
),
[]
);
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const query = event.target.value;

setSearchQuery(query);
handleDelayedQuery(query);
};

const [searchText, setSearchText] = React.useState<string>('');
const handleDateTimeChange = () => {
if (searchTimestamp !== prevSearchTimestamp) {
if (searchTimestamp) {
const timestamp: number = searchTimestamp.getTime();

const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(event.target.value);
setSearchTimestamp(searchTimestamp);
setQueryParams({
...queryParams,
seekType: SeekTypes.TIMESTAMP,
seekTo: filterProps.map((p) => `${p.partition}::${timestamp}`),
});
} else {
setSearchTimestamp(null);
const { seekTo, seekType, ...queryParamsWithoutSeek } = queryParams;
setQueryParams(queryParamsWithoutSeek);
}
}
};

const getTimestampDate = (timestamp: number) => {
return format(new Date(timestamp * 1000), 'MM.dd.yyyy HH:mm:ss');
};

const getMessageContentHeaders = () => {
const getMessageContentHeaders = React.useMemo(() => {
const message = messages[0];
const headers: JSX.Element[] = [];
const content = JSON.parse(message.content);
Object.keys(content).forEach((k) =>
headers.push(<th>{`content.${k}`}</th>)
);

try {
const content =
typeof message.content !== 'object'
? JSON.parse(message.content)
: message.content;
Object.keys(content).forEach((k) =>
headers.push(<th key={Math.random()}>{`content.${k}`}</th>)
);
} catch (e) {
headers.push(<th>Content</th>);
}
return headers;
};
}, [messages]);

const getMessageContentBody = (content: string) => {
const c = JSON.parse(content);
const getMessageContentBody = (content: any) => {
const columns: JSX.Element[] = [];
Object.values(c).map((v) => columns.push(<td>{JSON.stringify(v)}</td>));
try {
const c = typeof content !== 'object' ? JSON.parse(content) : content;
Object.values(c).map((v) =>
columns.push(<td key={Math.random()}>{JSON.stringify(v)}</td>)
);
} catch (e) {
columns.push(<td>{content}</td>);
}
return columns;
};

return (
// eslint-disable-next-line no-nested-ternary
isFetched ? (
messages.length > 0 ? (
<div>
<div className="columns">
<div className="column is-half is-offset-half">
<input
id="searchText"
type="text"
name="searchText"
className="input"
placeholder="Search"
value={searchText}
onChange={handleInputChange}
/>
</div>
</div>
<table className="table is-striped is-fullwidth">
<thead>
<tr>
<th>Timestamp</th>
<th>Offset</th>
<th>Partition</th>
{getMessageContentHeaders()}
const onNext = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();

const seekTo: string[] = filterProps.map(
(p) => `${p.partition}::${p.offset}`
);
setQueryParams({
...queryParams,
seekType: SeekTypes.OFFSET,
seekTo,
});
};

const getTopicMessagesTable = () => {
return messages.length > 0 ? (
<div>
<table className="table is-striped is-fullwidth">
<thead>
<tr>
<th>Timestamp</th>
<th>Offset</th>
<th>Partition</th>
{getMessageContentHeaders}
</tr>
</thead>
<tbody>
{messages.map((message) => (
<tr key={`${message.timestamp}${Math.random()}`}>
<td>{getTimestampDate(message.timestamp)}</td>
<td>{message.offset}</td>
<td>{message.partition}</td>
{getMessageContentBody(message.content)}
</tr>
</thead>
<tbody>
{messages
.filter(
(message) =>
!searchText || message?.content?.indexOf(searchText) >= 0
)
.map((message) => (
<tr key={message.timestamp}>
<td>{getTimestampDate(message.timestamp)}</td>
<td>{message.offset}</td>
<td>{message.partition}</td>
{getMessageContentBody(message.content)}
</tr>
))}
</tbody>
</table>
))}
</tbody>
</table>
<div className="columns">
<div className="column is-full">
<CustomParamButton
className="is-link is-pulled-right"
type={CustomParamButtonType.chevronRight}
onClick={onNext}
btnText="Next"
/>
</div>
</div>
) : (
<div>No messages at selected topic</div>
)
</div>
) : (
<PageLoader isFullHeight={false} />
)
<div>No messages at selected topic</div>
);
};

return isFetched ? (
<div>
<div className="columns">
<div className="column is-one-quarter">
<label className="label">Timestamp</label>
<DatePicker
selected={searchTimestamp}
onChange={(date) => setSearchTimestamp(date)}
onCalendarClose={handleDateTimeChange}
isClearable
showTimeInput
timeInputLabel="Time:"
dateFormat="MMMM d, yyyy h:mm aa"
className="input"
/>
</div>
<div className="column is-two-quarters is-offset-one-quarter">
<label className="label">Search</label>
<input
id="searchText"
type="text"
name="searchText"
className="input"
placeholder="Search"
value={searchQuery}
onChange={handleQueryChange}
/>
</div>
</div>
<div>{getTopicMessagesTable()}</div>
</div>
) : (
<PageLoader isFullHeight={false} />
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { connect } from 'react-redux';
import { ClusterName, RootState, TopicName } from 'redux/interfaces';
import {
ClusterName,
RootState,
TopicMessageQueryParams,
TopicName,
} from 'redux/interfaces';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { fetchTopicMessages } from 'redux/actions';
import {
Expand Down Expand Up @@ -31,8 +36,11 @@ const mapStateToProps = (
});

const mapDispatchToProps = {
fetchTopicMessages: (clusterName: ClusterName, topicName: TopicName) =>
fetchTopicMessages(clusterName, topicName),
fetchTopicMessages: (
clusterName: ClusterName,
topicName: TopicName,
queryParams: Partial<TopicMessageQueryParams>
) => fetchTopicMessages(clusterName, topicName, queryParams),
};

export default withRouter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react';
export enum CustomParamButtonType {
plus = 'fa-plus',
minus = 'fa-minus',
chevronRight = 'fa-chevron-right',
}

interface Props {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import React from 'react';
import { useFormContext, ErrorMessage } from 'react-hook-form';
import { TopicFormCustomParam } from 'redux/interfaces';
import CustomParamSelect from 'components/Topics/shared/Form/CustomParams/CustomParamSelect';
import CustomParamValue from 'components/Topics/shared/Form/CustomParams/CustomParamValue';
import CustomParamAction from 'components/Topics/shared/Form/CustomParams/CustomParamAction';
import { INDEX_PREFIX } from './CustomParams';
import CustomParamOptions from './CustomParamOptions';

interface Props {
isDisabled: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from 'react';
import { useFormContext, ErrorMessage } from 'react-hook-form';
import { camelCase } from 'lodash';
import CUSTOM_PARAMS_OPTIONS from './customParamsOptions';

interface Props {
Expand Down
Loading

0 comments on commit a001365

Please sign in to comment.