ResetOffsets.tsx 11 KB

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