Skip to content

Commit f71c601

Browse files
Add filtering and pagination for topic messages (#66)
* Add filtering and pagination for topic messages * Add delay to search query, momoize some functions
1 parent 8480740 commit f71c601

File tree

12 files changed

+349
-133
lines changed

12 files changed

+349
-133
lines changed

kafka-ui-react-app/.eslintrc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"plugin:@typescript-eslint/recommended"
2525
],
2626
"rules": {
27+
"@typescript-eslint/ban-ts-ignore": "off",
2728
"import/extensions": [
2829
"error",
2930
"ignorePackages",

kafka-ui-react-app/package-lock.json

Lines changed: 86 additions & 51 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

kafka-ui-react-app/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
"version": "0.1.0",
44
"private": true,
55
"dependencies": {
6+
"@types/react-datepicker": "^3.0.2",
67
"bulma": "^0.8.0",
78
"bulma-switch": "^2.0.0",
89
"classnames": "^2.2.6",
9-
"immer": "^6.0.5",
1010
"date-fns": "^2.14.0",
11+
"immer": "^6.0.5",
1112
"lodash": "^4.17.15",
1213
"pretty-ms": "^6.0.1",
1314
"react": "^16.12.0",
15+
"react-datepicker": "^3.0.0",
1416
"react-dom": "^16.12.0",
1517
"react-hook-form": "^4.5.5",
1618
"react-redux": "^7.1.3",

kafka-ui-react-app/src/components/App.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,7 @@ $navbar-width: 250px;
2828
overflow-y: scroll;
2929
}
3030
}
31+
32+
.react-datepicker-wrapper {
33+
display: flex !important;
34+
}

kafka-ui-react-app/src/components/Topics/Details/Messages/Messages.tsx

Lines changed: 197 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,105 +1,237 @@
1-
import React from 'react';
2-
import { ClusterName, TopicMessage, TopicName } from 'redux/interfaces';
1+
import React, { useCallback, useEffect, useRef } from 'react';
2+
import {
3+
ClusterName,
4+
SeekTypes,
5+
TopicMessage,
6+
TopicMessageQueryParams,
7+
TopicName,
8+
} from 'redux/interfaces';
39
import PageLoader from 'components/common/PageLoader/PageLoader';
410
import { format } from 'date-fns';
11+
import DatePicker from 'react-datepicker';
12+
13+
import 'react-datepicker/dist/react-datepicker.css';
14+
import CustomParamButton, {
15+
CustomParamButtonType,
16+
} from 'components/Topics/shared/Form/CustomParams/CustomParamButton';
17+
18+
import { debounce } from 'lodash';
519

620
interface Props {
721
clusterName: ClusterName;
822
topicName: TopicName;
923
isFetched: boolean;
10-
fetchTopicMessages: (clusterName: ClusterName, topicName: TopicName) => void;
24+
fetchTopicMessages: (
25+
clusterName: ClusterName,
26+
topicName: TopicName,
27+
queryParams: Partial<TopicMessageQueryParams>
28+
) => void;
1129
messages: TopicMessage[];
1230
}
1331

32+
interface FilterProps {
33+
offset: number;
34+
partition: number;
35+
}
36+
37+
function usePrevious(value: any) {
38+
const ref = useRef();
39+
useEffect(() => {
40+
ref.current = value;
41+
});
42+
return ref.current;
43+
}
44+
1445
const Messages: React.FC<Props> = ({
1546
isFetched,
1647
clusterName,
1748
topicName,
1849
messages,
1950
fetchTopicMessages,
2051
}) => {
52+
const [searchQuery, setSearchQuery] = React.useState<string>('');
53+
const [searchTimestamp, setSearchTimestamp] = React.useState<Date | null>(
54+
null
55+
);
56+
const [filterProps, setFilterProps] = React.useState<FilterProps[]>([]);
57+
const [queryParams, setQueryParams] = React.useState<
58+
Partial<TopicMessageQueryParams>
59+
>({ limit: 100 });
60+
61+
const prevSearchTimestamp = usePrevious(searchTimestamp);
62+
63+
const getUniqueDataForEachPartition: FilterProps[] = React.useMemo(() => {
64+
const map = messages.map((message) => [
65+
message.partition,
66+
{
67+
partition: message.partition,
68+
offset: message.offset,
69+
},
70+
]);
71+
// @ts-ignore
72+
return [...new Map(map).values()];
73+
}, [messages]);
74+
75+
React.useEffect(() => {
76+
fetchTopicMessages(clusterName, topicName, queryParams);
77+
}, [fetchTopicMessages, clusterName, topicName, queryParams]);
78+
2179
React.useEffect(() => {
22-
fetchTopicMessages(clusterName, topicName);
23-
}, [fetchTopicMessages, clusterName, topicName]);
80+
setFilterProps(getUniqueDataForEachPartition);
81+
}, [messages]);
82+
83+
const handleDelayedQuery = useCallback(
84+
debounce(
85+
(query: string) => setQueryParams({ ...queryParams, q: query }),
86+
1000
87+
),
88+
[]
89+
);
90+
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
91+
const query = event.target.value;
92+
93+
setSearchQuery(query);
94+
handleDelayedQuery(query);
95+
};
2496

25-
const [searchText, setSearchText] = React.useState<string>('');
97+
const handleDateTimeChange = () => {
98+
if (searchTimestamp !== prevSearchTimestamp) {
99+
if (searchTimestamp) {
100+
const timestamp: number = searchTimestamp.getTime();
26101

27-
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
28-
setSearchText(event.target.value);
102+
setSearchTimestamp(searchTimestamp);
103+
setQueryParams({
104+
...queryParams,
105+
seekType: SeekTypes.TIMESTAMP,
106+
seekTo: filterProps.map((p) => `${p.partition}::${timestamp}`),
107+
});
108+
} else {
109+
setSearchTimestamp(null);
110+
const { seekTo, seekType, ...queryParamsWithoutSeek } = queryParams;
111+
setQueryParams(queryParamsWithoutSeek);
112+
}
113+
}
29114
};
30115

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

35-
const getMessageContentHeaders = () => {
120+
const getMessageContentHeaders = React.useMemo(() => {
36121
const message = messages[0];
37122
const headers: JSX.Element[] = [];
38-
const content = JSON.parse(message.content);
39-
Object.keys(content).forEach((k) =>
40-
headers.push(<th>{`content.${k}`}</th>)
41-
);
42-
123+
try {
124+
const content =
125+
typeof message.content !== 'object'
126+
? JSON.parse(message.content)
127+
: message.content;
128+
Object.keys(content).forEach((k) =>
129+
headers.push(<th key={Math.random()}>{`content.${k}`}</th>)
130+
);
131+
} catch (e) {
132+
headers.push(<th>Content</th>);
133+
}
43134
return headers;
44-
};
135+
}, [messages]);
45136

46-
const getMessageContentBody = (content: string) => {
47-
const c = JSON.parse(content);
137+
const getMessageContentBody = (content: any) => {
48138
const columns: JSX.Element[] = [];
49-
Object.values(c).map((v) => columns.push(<td>{JSON.stringify(v)}</td>));
139+
try {
140+
const c = typeof content !== 'object' ? JSON.parse(content) : content;
141+
Object.values(c).map((v) =>
142+
columns.push(<td key={Math.random()}>{JSON.stringify(v)}</td>)
143+
);
144+
} catch (e) {
145+
columns.push(<td>{content}</td>);
146+
}
50147
return columns;
51148
};
52149

53-
return (
54-
// eslint-disable-next-line no-nested-ternary
55-
isFetched ? (
56-
messages.length > 0 ? (
57-
<div>
58-
<div className="columns">
59-
<div className="column is-half is-offset-half">
60-
<input
61-
id="searchText"
62-
type="text"
63-
name="searchText"
64-
className="input"
65-
placeholder="Search"
66-
value={searchText}
67-
onChange={handleInputChange}
68-
/>
69-
</div>
70-
</div>
71-
<table className="table is-striped is-fullwidth">
72-
<thead>
73-
<tr>
74-
<th>Timestamp</th>
75-
<th>Offset</th>
76-
<th>Partition</th>
77-
{getMessageContentHeaders()}
150+
const onNext = (event: React.MouseEvent<HTMLButtonElement>) => {
151+
event.preventDefault();
152+
153+
const seekTo: string[] = filterProps.map(
154+
(p) => `${p.partition}::${p.offset}`
155+
);
156+
setQueryParams({
157+
...queryParams,
158+
seekType: SeekTypes.OFFSET,
159+
seekTo,
160+
});
161+
};
162+
163+
const getTopicMessagesTable = () => {
164+
return messages.length > 0 ? (
165+
<div>
166+
<table className="table is-striped is-fullwidth">
167+
<thead>
168+
<tr>
169+
<th>Timestamp</th>
170+
<th>Offset</th>
171+
<th>Partition</th>
172+
{getMessageContentHeaders}
173+
</tr>
174+
</thead>
175+
<tbody>
176+
{messages.map((message) => (
177+
<tr key={`${message.timestamp}${Math.random()}`}>
178+
<td>{getTimestampDate(message.timestamp)}</td>
179+
<td>{message.offset}</td>
180+
<td>{message.partition}</td>
181+
{getMessageContentBody(message.content)}
78182
</tr>
79-
</thead>
80-
<tbody>
81-
{messages
82-
.filter(
83-
(message) =>
84-
!searchText || message?.content?.indexOf(searchText) >= 0
85-
)
86-
.map((message) => (
87-
<tr key={message.timestamp}>
88-
<td>{getTimestampDate(message.timestamp)}</td>
89-
<td>{message.offset}</td>
90-
<td>{message.partition}</td>
91-
{getMessageContentBody(message.content)}
92-
</tr>
93-
))}
94-
</tbody>
95-
</table>
183+
))}
184+
</tbody>
185+
</table>
186+
<div className="columns">
187+
<div className="column is-full">
188+
<CustomParamButton
189+
className="is-link is-pulled-right"
190+
type={CustomParamButtonType.chevronRight}
191+
onClick={onNext}
192+
btnText="Next"
193+
/>
194+
</div>
96195
</div>
97-
) : (
98-
<div>No messages at selected topic</div>
99-
)
196+
</div>
100197
) : (
101-
<PageLoader isFullHeight={false} />
102-
)
198+
<div>No messages at selected topic</div>
199+
);
200+
};
201+
202+
return isFetched ? (
203+
<div>
204+
<div className="columns">
205+
<div className="column is-one-quarter">
206+
<label className="label">Timestamp</label>
207+
<DatePicker
208+
selected={searchTimestamp}
209+
onChange={(date) => setSearchTimestamp(date)}
210+
onCalendarClose={handleDateTimeChange}
211+
isClearable
212+
showTimeInput
213+
timeInputLabel="Time:"
214+
dateFormat="MMMM d, yyyy h:mm aa"
215+
className="input"
216+
/>
217+
</div>
218+
<div className="column is-two-quarters is-offset-one-quarter">
219+
<label className="label">Search</label>
220+
<input
221+
id="searchText"
222+
type="text"
223+
name="searchText"
224+
className="input"
225+
placeholder="Search"
226+
value={searchQuery}
227+
onChange={handleQueryChange}
228+
/>
229+
</div>
230+
</div>
231+
<div>{getTopicMessagesTable()}</div>
232+
</div>
233+
) : (
234+
<PageLoader isFullHeight={false} />
103235
);
104236
};
105237

kafka-ui-react-app/src/components/Topics/Details/Messages/MessagesContainer.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { connect } from 'react-redux';
2-
import { ClusterName, RootState, TopicName } from 'redux/interfaces';
2+
import {
3+
ClusterName,
4+
RootState,
5+
TopicMessageQueryParams,
6+
TopicName,
7+
} from 'redux/interfaces';
38
import { RouteComponentProps, withRouter } from 'react-router-dom';
49
import { fetchTopicMessages } from 'redux/actions';
510
import {
@@ -31,8 +36,11 @@ const mapStateToProps = (
3136
});
3237

3338
const mapDispatchToProps = {
34-
fetchTopicMessages: (clusterName: ClusterName, topicName: TopicName) =>
35-
fetchTopicMessages(clusterName, topicName),
39+
fetchTopicMessages: (
40+
clusterName: ClusterName,
41+
topicName: TopicName,
42+
queryParams: Partial<TopicMessageQueryParams>
43+
) => fetchTopicMessages(clusterName, topicName, queryParams),
3644
};
3745

3846
export default withRouter(

kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamButton.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React from 'react';
33
export enum CustomParamButtonType {
44
plus = 'fa-plus',
55
minus = 'fa-minus',
6+
chevronRight = 'fa-chevron-right',
67
}
78

89
interface Props {

kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamField.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
import React from 'react';
2-
import { useFormContext, ErrorMessage } from 'react-hook-form';
3-
import { TopicFormCustomParam } from 'redux/interfaces';
42
import CustomParamSelect from 'components/Topics/shared/Form/CustomParams/CustomParamSelect';
53
import CustomParamValue from 'components/Topics/shared/Form/CustomParams/CustomParamValue';
64
import CustomParamAction from 'components/Topics/shared/Form/CustomParams/CustomParamAction';
7-
import { INDEX_PREFIX } from './CustomParams';
8-
import CustomParamOptions from './CustomParamOptions';
95

106
interface Props {
117
isDisabled: boolean;

kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamValue.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React from 'react';
22
import { useFormContext, ErrorMessage } from 'react-hook-form';
3-
import { camelCase } from 'lodash';
43
import CUSTOM_PARAMS_OPTIONS from './customParamsOptions';
54

65
interface Props {

0 commit comments

Comments
 (0)