ResetOffsets.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. import React from 'react';
  2. import { useNavigate } from 'react-router-dom';
  3. import { ConsumerGroupOffsetsResetType } from 'generated-sources';
  4. import { ClusterGroupParam } from 'lib/paths';
  5. import {
  6. Controller,
  7. FormProvider,
  8. useFieldArray,
  9. useForm,
  10. } from 'react-hook-form';
  11. import MultiSelect from 'react-multi-select-component';
  12. import { Option } from 'react-multi-select-component/dist/lib/interfaces';
  13. import DatePicker from 'react-datepicker';
  14. import 'react-datepicker/dist/react-datepicker.css';
  15. import groupBy from 'lodash/groupBy';
  16. import PageLoader from 'components/common/PageLoader/PageLoader';
  17. import { ErrorMessage } from '@hookform/error-message';
  18. import Select from 'components/common/Select/Select';
  19. import { InputLabel } from 'components/common/Input/InputLabel.styled';
  20. import { Button } from 'components/common/Button/Button';
  21. import Input from 'components/common/Input/Input';
  22. import { FormError } from 'components/common/Input/Input.styled';
  23. import PageHeading from 'components/common/PageHeading/PageHeading';
  24. import {
  25. fetchConsumerGroupDetails,
  26. selectById,
  27. getAreConsumerGroupDetailsFulfilled,
  28. getIsOffsetReseted,
  29. resetConsumerGroupOffsets,
  30. } from 'redux/reducers/consumerGroups/consumerGroupsSlice';
  31. import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
  32. import useAppParams from 'lib/hooks/useAppParams';
  33. import { resetLoaderById } from 'redux/reducers/loader/loaderSlice';
  34. import * as S from './ResetOffsets.styled';
  35. interface FormType {
  36. topic: string;
  37. resetType: ConsumerGroupOffsetsResetType;
  38. partitionsOffsets: { offset: string | undefined; partition: number }[];
  39. resetToTimestamp: Date;
  40. }
  41. const ResetOffsets: React.FC = () => {
  42. const dispatch = useAppDispatch();
  43. const { consumerGroupID, clusterName } = useAppParams<ClusterGroupParam>();
  44. const consumerGroup = useAppSelector((state) =>
  45. selectById(state, consumerGroupID)
  46. );
  47. const isFetched = useAppSelector(getAreConsumerGroupDetailsFulfilled);
  48. const isOffsetReseted = useAppSelector(getIsOffsetReseted);
  49. React.useEffect(() => {
  50. dispatch(fetchConsumerGroupDetails({ clusterName, consumerGroupID }));
  51. }, [clusterName, consumerGroupID, dispatch]);
  52. const [uniqueTopics, setUniqueTopics] = React.useState<string[]>([]);
  53. const [selectedPartitions, setSelectedPartitions] = React.useState<Option[]>(
  54. []
  55. );
  56. const methods = useForm<FormType>({
  57. mode: 'onChange',
  58. defaultValues: {
  59. resetType: ConsumerGroupOffsetsResetType.EARLIEST,
  60. topic: '',
  61. partitionsOffsets: [],
  62. },
  63. });
  64. const {
  65. handleSubmit,
  66. setValue,
  67. watch,
  68. control,
  69. setError,
  70. clearErrors,
  71. formState: { errors, isValid },
  72. } = methods;
  73. const { fields } = useFieldArray({
  74. control,
  75. name: 'partitionsOffsets',
  76. });
  77. const resetTypeValue = watch('resetType');
  78. const topicValue = watch('topic');
  79. const offsetsValue = watch('partitionsOffsets');
  80. React.useEffect(() => {
  81. if (isFetched && consumerGroup?.partitions) {
  82. setValue('topic', consumerGroup.partitions[0].topic);
  83. setUniqueTopics(Object.keys(groupBy(consumerGroup.partitions, 'topic')));
  84. }
  85. }, [consumerGroup?.partitions, isFetched, setValue]);
  86. const onSelectedPartitionsChange = (value: Option[]) => {
  87. clearErrors();
  88. setValue(
  89. 'partitionsOffsets',
  90. value.map((partition) => {
  91. const currentOffset = offsetsValue.find(
  92. (offset) => offset.partition === partition.value
  93. );
  94. return {
  95. offset: currentOffset ? currentOffset?.offset : undefined,
  96. partition: partition.value,
  97. };
  98. })
  99. );
  100. setSelectedPartitions(value);
  101. };
  102. React.useEffect(() => {
  103. onSelectedPartitionsChange([]);
  104. // eslint-disable-next-line react-hooks/exhaustive-deps
  105. }, [topicValue]);
  106. const onSubmit = (data: FormType) => {
  107. const augmentedData = {
  108. ...data,
  109. partitions: selectedPartitions.map((partition) => partition.value),
  110. partitionsOffsets: data.partitionsOffsets as {
  111. offset: string;
  112. partition: number;
  113. }[],
  114. };
  115. let isValidAugmentedData = true;
  116. if (augmentedData.resetType === ConsumerGroupOffsetsResetType.OFFSET) {
  117. augmentedData.partitionsOffsets.forEach((offset, index) => {
  118. if (!offset.offset) {
  119. setError(`partitionsOffsets.${index}.offset`, {
  120. type: 'manual',
  121. message: "This field shouldn't be empty!",
  122. });
  123. isValidAugmentedData = false;
  124. }
  125. });
  126. } else if (
  127. augmentedData.resetType === ConsumerGroupOffsetsResetType.TIMESTAMP
  128. ) {
  129. if (!augmentedData.resetToTimestamp) {
  130. setError(`resetToTimestamp`, {
  131. type: 'manual',
  132. message: "This field shouldn't be empty!",
  133. });
  134. isValidAugmentedData = false;
  135. }
  136. }
  137. if (isValidAugmentedData) {
  138. dispatch(
  139. resetConsumerGroupOffsets({
  140. clusterName,
  141. consumerGroupID,
  142. requestBody: augmentedData,
  143. })
  144. );
  145. }
  146. };
  147. const navigate = useNavigate();
  148. React.useEffect(() => {
  149. if (isOffsetReseted) {
  150. dispatch(resetLoaderById('consumerGroups/resetConsumerGroupOffsets'));
  151. navigate('../');
  152. }
  153. }, [clusterName, consumerGroupID, dispatch, navigate, isOffsetReseted]);
  154. if (!isFetched || !consumerGroup) {
  155. return <PageLoader />;
  156. }
  157. return (
  158. <FormProvider {...methods}>
  159. <PageHeading text="Reset offsets" />
  160. <S.Wrapper>
  161. <form onSubmit={handleSubmit(onSubmit)}>
  162. <S.MainSelectors>
  163. <div>
  164. <InputLabel id="topicLabel">Topic</InputLabel>
  165. <Controller
  166. control={control}
  167. name="topic"
  168. render={({ field: { name, onChange, value } }) => (
  169. <Select
  170. id="topic"
  171. selectSize="M"
  172. aria-labelledby="topicLabel"
  173. minWidth="100%"
  174. name={name}
  175. onChange={onChange}
  176. defaultValue={value}
  177. value={value}
  178. options={uniqueTopics.map((topic) => ({
  179. value: topic,
  180. label: topic,
  181. }))}
  182. />
  183. )}
  184. />
  185. </div>
  186. <div>
  187. <InputLabel id="resetTypeLabel">Reset Type</InputLabel>
  188. <Controller
  189. control={control}
  190. name="resetType"
  191. render={({ field: { name, onChange, value } }) => (
  192. <Select
  193. id="resetType"
  194. selectSize="M"
  195. aria-labelledby="resetTypeLabel"
  196. minWidth="100%"
  197. name={name}
  198. onChange={onChange}
  199. value={value}
  200. options={Object.values(ConsumerGroupOffsetsResetType).map(
  201. (type) => ({ value: type, label: type })
  202. )}
  203. />
  204. )}
  205. />
  206. </div>
  207. <div>
  208. <InputLabel>Partitions</InputLabel>
  209. <MultiSelect
  210. options={
  211. consumerGroup.partitions
  212. ?.filter((p) => p.topic === topicValue)
  213. .map((p) => ({
  214. label: `Partition #${p.partition.toString()}`,
  215. value: p.partition,
  216. })) || []
  217. }
  218. value={selectedPartitions}
  219. onChange={onSelectedPartitionsChange}
  220. labelledBy="Select partitions"
  221. />
  222. </div>
  223. </S.MainSelectors>
  224. {resetTypeValue === ConsumerGroupOffsetsResetType.TIMESTAMP &&
  225. selectedPartitions.length > 0 && (
  226. <div>
  227. <InputLabel>Timestamp</InputLabel>
  228. <Controller
  229. control={control}
  230. name="resetToTimestamp"
  231. render={({ field: { onChange, onBlur, value, ref } }) => (
  232. <DatePicker
  233. ref={ref}
  234. selected={value}
  235. onChange={onChange}
  236. onBlur={onBlur}
  237. showTimeInput
  238. timeInputLabel="Time:"
  239. dateFormat="MMMM d, yyyy h:mm aa"
  240. />
  241. )}
  242. />
  243. <ErrorMessage
  244. errors={errors}
  245. name="resetToTimestamp"
  246. render={({ message }) => <FormError>{message}</FormError>}
  247. />
  248. </div>
  249. )}
  250. {resetTypeValue === ConsumerGroupOffsetsResetType.OFFSET &&
  251. selectedPartitions.length > 0 && (
  252. <div>
  253. <S.OffsetsTitle>Offsets</S.OffsetsTitle>
  254. <S.OffsetsWrapper>
  255. {fields.map((field, index) => (
  256. <div key={field.id}>
  257. <InputLabel htmlFor={`partitionsOffsets.${index}.offset`}>
  258. Partition #{field.partition}
  259. </InputLabel>
  260. <Input
  261. id={`partitionsOffsets.${index}.offset`}
  262. type="number"
  263. name={`partitionsOffsets.${index}.offset` as const}
  264. hookFormOptions={{
  265. shouldUnregister: true,
  266. min: {
  267. value: 0,
  268. message: 'must be greater than or equal to 0',
  269. },
  270. }}
  271. defaultValue={field.offset}
  272. />
  273. <ErrorMessage
  274. errors={errors}
  275. name={`partitionsOffsets.${index}.offset`}
  276. render={({ message }) => (
  277. <FormError>{message}</FormError>
  278. )}
  279. />
  280. </div>
  281. ))}
  282. </S.OffsetsWrapper>
  283. </div>
  284. )}
  285. <Button
  286. buttonSize="M"
  287. buttonType="primary"
  288. type="submit"
  289. disabled={!isValid || selectedPartitions.length === 0}
  290. >
  291. Submit
  292. </Button>
  293. </form>
  294. </S.Wrapper>
  295. </FormProvider>
  296. );
  297. };
  298. export default ResetOffsets;