Messages.tsx 11 KB

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