Messages.tsx 10 KB

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