Messages.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import React, { useCallback, useEffect, useRef } from 'react';
  2. import {
  3. ClusterName,
  4. TopicMessageQueryParams,
  5. TopicName,
  6. } from 'redux/interfaces';
  7. import { TopicMessage, Partition, SeekType } from 'generated-sources';
  8. import PageLoader from 'components/common/PageLoader/PageLoader';
  9. import DatePicker from 'react-datepicker';
  10. import 'react-datepicker/dist/react-datepicker.css';
  11. import CustomParamButton, {
  12. CustomParamButtonType,
  13. } from 'components/Topics/shared/Form/CustomParams/CustomParamButton';
  14. import MultiSelect from 'react-multi-select-component';
  15. import * as _ from 'lodash';
  16. import { useDebouncedCallback } from 'use-debounce';
  17. import { Option } from 'react-multi-select-component/dist/lib/interfaces';
  18. import MessagesTable from './MessagesTable';
  19. export interface Props {
  20. clusterName: ClusterName;
  21. topicName: TopicName;
  22. isFetched: boolean;
  23. fetchTopicMessages: (
  24. clusterName: ClusterName,
  25. topicName: TopicName,
  26. queryParams: Partial<TopicMessageQueryParams>
  27. ) => void;
  28. messages: TopicMessage[];
  29. partitions: Partition[];
  30. }
  31. interface FilterProps {
  32. offset: TopicMessage['offset'];
  33. partition: TopicMessage['partition'];
  34. }
  35. function usePrevious(value: Date | null) {
  36. const ref = useRef<Date | null>();
  37. useEffect(() => {
  38. ref.current = value;
  39. });
  40. return ref.current;
  41. }
  42. const Messages: React.FC<Props> = ({
  43. isFetched,
  44. clusterName,
  45. topicName,
  46. messages,
  47. partitions,
  48. fetchTopicMessages,
  49. }) => {
  50. const [searchQuery, setSearchQuery] = React.useState<string>('');
  51. const [searchTimestamp, setSearchTimestamp] = React.useState<Date | null>(
  52. null
  53. );
  54. const [filterProps, setFilterProps] = React.useState<FilterProps[]>([]);
  55. const [selectedSeekType, setSelectedSeekType] = React.useState<SeekType>(
  56. SeekType.OFFSET
  57. );
  58. const [searchOffset, setSearchOffset] = React.useState<string>();
  59. const [selectedPartitions, setSelectedPartitions] = React.useState<Option[]>(
  60. partitions.map((p) => ({
  61. value: p.partition,
  62. label: p.partition.toString(),
  63. }))
  64. );
  65. const [queryParams, setQueryParams] = React.useState<
  66. Partial<TopicMessageQueryParams>
  67. >({ limit: 100 });
  68. const [debouncedCallback] = useDebouncedCallback(
  69. (query: Partial<TopicMessageQueryParams>) =>
  70. setQueryParams({ ...queryParams, ...query }),
  71. 1000
  72. );
  73. const prevSearchTimestamp = usePrevious(searchTimestamp);
  74. const getUniqueDataForEachPartition: FilterProps[] = React.useMemo(() => {
  75. const partitionUniqs: FilterProps[] = partitions.map((p) => ({
  76. offset: 0,
  77. partition: p.partition,
  78. }));
  79. const messageUniqs: FilterProps[] = _.map(
  80. _.groupBy(messages, 'partition'),
  81. (v) => _.maxBy(v, 'offset')
  82. ).map((v) => ({
  83. offset: v ? v.offset : 0,
  84. partition: v ? v.partition : 0,
  85. }));
  86. return _.map(
  87. _.groupBy(_.concat(partitionUniqs, messageUniqs), 'partition'),
  88. (v) => _.maxBy(v, 'offset') as FilterProps
  89. );
  90. }, [messages, partitions]);
  91. const getSeekToValuesForPartitions = (partition: Option) => {
  92. const foundedValues = filterProps.find(
  93. (prop) => prop.partition === partition.value
  94. );
  95. if (selectedSeekType === SeekType.OFFSET) {
  96. return foundedValues ? foundedValues.offset : 0;
  97. }
  98. return searchTimestamp ? searchTimestamp.getTime() : null;
  99. };
  100. React.useEffect(() => {
  101. fetchTopicMessages(clusterName, topicName, queryParams);
  102. }, []);
  103. React.useEffect(() => {
  104. setFilterProps(getUniqueDataForEachPartition);
  105. }, [messages, partitions]);
  106. const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  107. const query = event.target.value;
  108. setSearchQuery(query);
  109. debouncedCallback({ q: query });
  110. };
  111. const handleDateTimeChange = () => {
  112. if (searchTimestamp !== prevSearchTimestamp) {
  113. if (searchTimestamp) {
  114. const timestamp: number = searchTimestamp.getTime();
  115. setSearchTimestamp(searchTimestamp);
  116. setQueryParams({
  117. ...queryParams,
  118. seekType: SeekType.TIMESTAMP,
  119. seekTo: selectedPartitions.map((p) => `${p.value}::${timestamp}`),
  120. });
  121. } else {
  122. setSearchTimestamp(null);
  123. const { seekTo, seekType, ...queryParamsWithoutSeek } = queryParams;
  124. setQueryParams(queryParamsWithoutSeek);
  125. }
  126. }
  127. };
  128. const handleSeekTypeChange = (
  129. event: React.ChangeEvent<HTMLSelectElement>
  130. ) => {
  131. setSelectedSeekType(event.target.value as SeekType);
  132. };
  133. const handleOffsetChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  134. const offset = event.target.value || '0';
  135. setSearchOffset(offset);
  136. debouncedCallback({
  137. seekType: SeekType.OFFSET,
  138. seekTo: selectedPartitions.map((p) => `${p.value}::${offset}`),
  139. });
  140. };
  141. const handlePartitionsChange = (options: Option[]) => {
  142. setSelectedPartitions(options);
  143. debouncedCallback({
  144. seekType: options.length > 0 ? selectedSeekType : undefined,
  145. seekTo:
  146. options.length > 0
  147. ? options.map((p) => `${p.value}::${getSeekToValuesForPartitions(p)}`)
  148. : undefined,
  149. });
  150. };
  151. const handleFiltersSubmit = useCallback(() => {
  152. fetchTopicMessages(clusterName, topicName, queryParams);
  153. }, [clusterName, topicName, queryParams]);
  154. const onNext = (event: React.MouseEvent<HTMLButtonElement>) => {
  155. event.preventDefault();
  156. const seekTo: string[] = filterProps
  157. .filter(
  158. (value) =>
  159. selectedPartitions.findIndex((p) => p.value === value.partition) > -1
  160. )
  161. .map((p) => `${p.partition}::${p.offset}`);
  162. fetchTopicMessages(clusterName, topicName, {
  163. ...queryParams,
  164. seekType: SeekType.OFFSET,
  165. seekTo,
  166. });
  167. };
  168. const filterOptions = (options: Option[], filter: string) => {
  169. if (!filter) {
  170. return options;
  171. }
  172. return options.filter(
  173. ({ value }) => value.toString() && value.toString() === filter
  174. );
  175. };
  176. const getTopicMessagesTable = () => {
  177. return messages.length > 0 ? (
  178. <div>
  179. <MessagesTable messages={messages} />
  180. <div className="columns">
  181. <div className="column is-full">
  182. <CustomParamButton
  183. className="is-link is-pulled-right"
  184. type={CustomParamButtonType.chevronRight}
  185. onClick={onNext}
  186. btnText="Next"
  187. />
  188. </div>
  189. </div>
  190. </div>
  191. ) : (
  192. <div>No messages at selected topic</div>
  193. );
  194. };
  195. if (!isFetched) {
  196. return <PageLoader isFullHeight={false} />;
  197. }
  198. return (
  199. <div>
  200. <div className="columns">
  201. <div className="column is-one-fifth">
  202. <label className="label">Partitions</label>
  203. <MultiSelect
  204. options={partitions.map((p) => ({
  205. label: `Partition #${p.partition.toString()}`,
  206. value: p.partition,
  207. }))}
  208. filterOptions={filterOptions}
  209. value={selectedPartitions}
  210. onChange={handlePartitionsChange}
  211. labelledBy="Select partitions"
  212. />
  213. </div>
  214. <div className="column is-one-fifth">
  215. <label className="label">Seek Type</label>
  216. <div className="select is-block">
  217. <select
  218. id="selectSeekType"
  219. name="selectSeekType"
  220. onChange={handleSeekTypeChange}
  221. value={selectedSeekType}
  222. >
  223. <option value={SeekType.OFFSET}>Offset</option>
  224. <option value={SeekType.TIMESTAMP}>Timestamp</option>
  225. </select>
  226. </div>
  227. </div>
  228. <div className="column is-one-fifth">
  229. {selectedSeekType === SeekType.OFFSET ? (
  230. <>
  231. <label className="label">Offset</label>
  232. <input
  233. id="searchOffset"
  234. name="searchOffset"
  235. type="text"
  236. className="input"
  237. value={searchOffset}
  238. onChange={handleOffsetChange}
  239. />
  240. </>
  241. ) : (
  242. <>
  243. <label className="label">Timestamp</label>
  244. <DatePicker
  245. selected={searchTimestamp}
  246. onChange={(date: Date | null) => setSearchTimestamp(date)}
  247. onCalendarClose={handleDateTimeChange}
  248. showTimeInput
  249. timeInputLabel="Time:"
  250. dateFormat="MMMM d, yyyy h:mm aa"
  251. className="input"
  252. />
  253. </>
  254. )}
  255. </div>
  256. <div className="column is-two-fifths">
  257. <label className="label">Search</label>
  258. <input
  259. id="searchText"
  260. type="text"
  261. name="searchText"
  262. className="input"
  263. placeholder="Search"
  264. value={searchQuery}
  265. onChange={handleQueryChange}
  266. />
  267. </div>
  268. </div>
  269. <div className="columns">
  270. <div className="column is-full" style={{ textAlign: 'right' }}>
  271. <input
  272. type="submit"
  273. className="button is-primary"
  274. onClick={handleFiltersSubmit}
  275. />
  276. </div>
  277. </div>
  278. {getTopicMessagesTable()}
  279. </div>
  280. );
  281. };
  282. export default Messages;