github.com/pingcap/tiflow@v0.0.0-20240520035814-5bf52d54e205/dm/ui/src/pages/migration/task/index.tsx (about)

     1  import React, { useEffect, useMemo, useState } from 'react'
     2  import { useTranslation } from 'react-i18next'
     3  import { useNavigate } from 'react-router-dom'
     4  import { PrismAsyncLight as SyntaxHighlighter } from 'react-syntax-highlighter'
     5  import { ghcolors } from 'react-syntax-highlighter/dist/esm/styles/prism'
     6  import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml'
     7  
     8  import {
     9    Table,
    10    TableColumnsType,
    11    Row,
    12    Col,
    13    Space,
    14    Input,
    15    Button,
    16    Dropdown,
    17    Menu,
    18    Modal,
    19    message,
    20    Drawer,
    21    Spin,
    22    Collapse,
    23    Pagination,
    24    Breadcrumb,
    25    Tabs,
    26    Radio,
    27    Select,
    28    Form,
    29    DatePicker,
    30  } from '~/uikit'
    31  import {
    32    SearchOutlined,
    33    RedoOutlined,
    34    DownOutlined,
    35    PauseCircleOutlined,
    36    PlayCircleOutlined,
    37    CloseCircleOutlined,
    38    ExclamationCircleOutlined,
    39    DatabaseOutlined,
    40    FlagOutlined,
    41    PlusSquareOutlined,
    42    DeploymentUnitOutlined,
    43    ThunderboltOutlined,
    44  } from '~/uikit/icons'
    45  import {
    46    Task,
    47    useDmapiGetTaskListQuery,
    48    useDmapiDeleteTaskMutation,
    49    useDmapiStopTaskMutation,
    50    useDmapiStartTaskMutation,
    51    useDmapiGetTaskStatusQuery,
    52    calculateTaskStatus,
    53    TaskStage,
    54    TaskUnit,
    55    SubTaskStatus,
    56    useDmapiConverterTaskMutation,
    57    DmapiStartTaskApiArg,
    58  } from '~/models/task'
    59  import i18n from '~/i18n'
    60  import { useFuseSearch } from '~/utils/search'
    61  import { actions, useAppDispatch, useAppSelector } from '~/models'
    62  import { Source, useDmapiGetSourceListQuery } from '~/models/source'
    63  import TaskUnitTag from '~/components/SimpleTaskPanel/TaskUnitTag'
    64  
    65  SyntaxHighlighter.registerLanguage('yaml', yaml)
    66  
    67  enum OpenTaskMethod {
    68    ByGuide,
    69    ByConfigFile,
    70  }
    71  
    72  enum StartTaskMethod {
    73    Direct,
    74    WithParams,
    75  }
    76  
    77  const SourceTable: React.FC<{
    78    data: Source[]
    79  }> = ({ data }) => {
    80    const [t] = useTranslation()
    81    const { result, setKeyword } = useFuseSearch(data, { keys: ['source_name'] })
    82    const columns: TableColumnsType<Source> = [
    83      {
    84        title: t('name'),
    85        dataIndex: 'source_name',
    86      },
    87      {
    88        title: t('host'),
    89        render(s: Source) {
    90          return `${s.host}:${s.port}`
    91        },
    92      },
    93      {
    94        title: t('user name'),
    95        dataIndex: 'user',
    96      },
    97    ]
    98    return (
    99      <div>
   100        <Input
   101          className="mb-4"
   102          suffix={<SearchOutlined />}
   103          onChange={e => setKeyword(e.target.value)}
   104          placeholder={t('search placeholder')}
   105        />
   106        <Table dataSource={result} columns={columns} rowKey="source_name" />
   107      </div>
   108    )
   109  }
   110  
   111  const SubTaskTable: React.FC<{ subs: SubTaskStatus[] }> = ({ subs }) => {
   112    const [stage, setStage] = useState<TaskStage | ''>('')
   113    const [unit, setUnit] = useState<TaskUnit | ''>('')
   114    const [page, setPage] = useState(1)
   115    const offset = useMemo(() => {
   116      return {
   117        start: (page - 1) * 10,
   118        end: page * 10,
   119      }
   120    }, [page])
   121    const data = useMemo(() => {
   122      let data = subs
   123      if (stage) {
   124        data = data.filter(s => s.stage === stage)
   125      }
   126      if (unit) {
   127        data = data.filter(s => s.unit === unit)
   128      }
   129      return data
   130    }, [subs, stage, unit, offset])
   131  
   132    const handlePageChange = (page: number) => {
   133      setPage(page)
   134    }
   135  
   136    useEffect(() => {
   137      // reset offset when filters changed
   138      setPage(1)
   139    }, [stage, unit, subs])
   140  
   141    return (
   142      <>
   143        <div className="mb-4">
   144          <Space>
   145            <Select
   146              className="min-w-120px"
   147              placeholder="Stage"
   148              value={stage}
   149              onChange={setStage}
   150            >
   151              {Object.values(TaskStage).map(stage => (
   152                <Select.Option key={stage} value={stage}>
   153                  {stage}
   154                </Select.Option>
   155              ))}
   156              <Select.Option key="all" value="">
   157                All
   158              </Select.Option>
   159            </Select>
   160            <Select
   161              className="min-w-100px"
   162              placeholder="Unit"
   163              value={unit}
   164              onChange={setUnit}
   165            >
   166              {Object.values(TaskUnit).map(i => (
   167                <Select.Option key={i} value={i}>
   168                  {i}
   169                </Select.Option>
   170              ))}
   171              <Select.Option key="all" value="">
   172                All
   173              </Select.Option>
   174            </Select>
   175          </Space>
   176        </div>
   177        <Collapse>
   178          {data.slice(offset.start, offset.end).map(item => {
   179            return (
   180              <Collapse.Panel
   181                key={item.name}
   182                header={
   183                  <div className="flex-1 flex justify-between">
   184                    <span>
   185                      <DatabaseOutlined className="mr-2" />
   186                      {item.source_name}
   187                    </span>
   188                    <span>
   189                      <DeploymentUnitOutlined className="mr-2" />
   190                      {item.unit}
   191                    </span>
   192                    <span>
   193                      <FlagOutlined className="mr-2" />
   194                      {item.stage}
   195                    </span>
   196                    <span>
   197                      <ThunderboltOutlined className="mr-2" />
   198                      {item.sync_status?.seconds_behind_master ?? 0}s
   199                    </span>
   200                  </div>
   201                }
   202              >
   203                <SyntaxHighlighter style={ghcolors} language="json">
   204                  {JSON.stringify(item, null, 2)}
   205                </SyntaxHighlighter>
   206              </Collapse.Panel>
   207            )
   208          })}
   209        </Collapse>
   210        <div className="flex mt-4 justify-end">
   211          <Pagination
   212            current={page}
   213            pageSize={10}
   214            onChange={handlePageChange}
   215            total={data.length}
   216          />
   217        </div>
   218      </>
   219    )
   220  }
   221  
   222  const TaskList: React.FC = () => {
   223    const [t] = useTranslation()
   224    const dispatch = useAppDispatch()
   225    const [detailDrawerVisible, setDetailDrawerVisible] = useState(false)
   226    const [sourceDrawerVisible, setSourceDrawerVisible] = useState(false)
   227    const [currentTaskName, setCurrentTaskName] = useState<string>()
   228    const [selectedSources, setSelectedSources] = useState<string[]>([])
   229    const [isCreateTaskModalVisible, setIsCreateTaskModalVisible] =
   230      useState(false)
   231    const [isStartTaskModalVisible, setIsStartTaskModalVisible] = useState(false)
   232    const [openTaskMethod, setOpenTaskMethod] = useState(OpenTaskMethod.ByGuide)
   233    const [startTaskMethod, setStartTaskMethod] = useState(StartTaskMethod.Direct)
   234    const [currentTaskConfigFile, setCurrentTaskConfigFile] = useState('')
   235    const selectedTask = useAppSelector(state => state.globals.preloadedTask)
   236    const navigate = useNavigate()
   237    const [form] = Form.useForm()
   238  
   239    const { data, isFetching, refetch } = useDmapiGetTaskListQuery({
   240      withStatus: true,
   241    })
   242    const { data: currentTaskStatus, refetch: refetchCurrentTaskStatus } =
   243      useDmapiGetTaskStatusQuery(
   244        { taskName: currentTaskName ?? '' },
   245        { skip: !currentTaskName }
   246      )
   247    const taskMap = useMemo(() => {
   248      return data?.data?.reduce((acc, cur) => {
   249        acc.set(cur.name, cur)
   250        return acc
   251      }, new Map() as Map<string, Task>)
   252    }, [data])
   253    const { data: sources } = useDmapiGetSourceListQuery({ with_status: false })
   254    const sourcesOfCurrentTask: Source[] = useMemo(() => {
   255      if (!currentTaskName || !sources) {
   256        return []
   257      }
   258      const currentTask = taskMap?.get(currentTaskName)
   259      if (currentTask) {
   260        const names = new Set(
   261          currentTask.source_config.source_conf.map(i => i.source_name)
   262        )
   263        return sources.data.filter(i => names.has(i.source_name))
   264      }
   265      return []
   266    }, [currentTaskName, sources, taskMap])
   267  
   268    const [convertTaskDataToConfigFile] = useDmapiConverterTaskMutation()
   269  
   270    const [stopTask, stopTaskResult] = useDmapiStopTaskMutation()
   271    const [startTask, startTaskResult] = useDmapiStartTaskMutation()
   272    const [deleteTask, deleteTaskResult] = useDmapiDeleteTaskMutation()
   273  
   274    const rowSelection = {
   275      selectedRowKeys: selectedSources,
   276      onChange: (selectedRowKeys: React.Key[], selectedRows: Task[]) => {
   277        setSelectedSources(selectedRows.map(i => i.name))
   278      },
   279    }
   280  
   281    const handleRequestWithConfirmModal =
   282      // @ts-ignore
   283      ({ handler, title }) => {
   284        if (!selectedSources.length) {
   285          return
   286        }
   287        Modal.confirm({
   288          title,
   289          icon: <ExclamationCircleOutlined />,
   290          onOk() {
   291            Promise.all(selectedSources.map(name => handler({ taskName: name })))
   292          },
   293        })
   294      }
   295  
   296    const handleStopTask = () => {
   297      handleRequestWithConfirmModal({
   298        title: t('confirm to stop task?'),
   299        handler: stopTask,
   300      })
   301    }
   302    const handleStartTask = () => {
   303      if (!selectedSources.length) {
   304        return
   305      }
   306      setIsStartTaskModalVisible(true)
   307    }
   308    const handleDeleteTask = () => {
   309      handleRequestWithConfirmModal({
   310        title: t('confirm to delete task?'),
   311        handler: deleteTask,
   312      })
   313    }
   314    const handleConfirmOpenTask = () => {
   315      if (openTaskMethod === OpenTaskMethod.ByGuide) {
   316        selectedTask
   317          ? navigate('/migration/task/edit')
   318          : navigate('/migration/task/create')
   319      } else if (openTaskMethod === OpenTaskMethod.ByConfigFile) {
   320        selectedTask
   321          ? navigate('/migration/task/edit#configFile')
   322          : navigate('/migration/task/create#configFile')
   323      }
   324      setIsCreateTaskModalVisible(false)
   325    }
   326    const handleConfirmStartTask = () => {
   327      const extraPayload: Partial<DmapiStartTaskApiArg> = {}
   328  
   329      if (startTaskMethod === StartTaskMethod.WithParams) {
   330        const formValues = form.getFieldsValue()
   331        extraPayload.startTaskRequest = {
   332          start_time: formValues.start_time
   333            .milliseconds(0)
   334            .utc()
   335            .format('YYYY-MM-DDTHH:mm:ss'),
   336          safe_mode_time_duration: formValues.safe_mode_time_duration + 's',
   337        }
   338      }
   339  
   340      Promise.all(
   341        selectedSources.map(name =>
   342          startTask({ taskName: name, ...extraPayload })
   343        )
   344      )
   345    }
   346  
   347    const showMessage = (result: typeof startTaskResult) => {
   348      if (result.isUninitialized) return
   349      const key = result.requestId
   350      if (result.isLoading) {
   351        return message.loading({ content: t('requesting'), key })
   352      }
   353      if (result.isError) {
   354        return message.destroy(key)
   355      }
   356      if (result.isSuccess) {
   357        setIsStartTaskModalVisible(false)
   358        return message.success({ content: t('request success'), key })
   359      }
   360    }
   361  
   362    const dataSource = data?.data
   363  
   364    const { result, setKeyword } = useFuseSearch(dataSource, {
   365      keys: ['name'],
   366    })
   367    const columns: TableColumnsType<Task> = [
   368      {
   369        title: t('task name'),
   370        render(data) {
   371          return (
   372            <Button
   373              type="link"
   374              onClick={() => {
   375                setCurrentTaskName(data.name)
   376                setDetailDrawerVisible(true)
   377              }}
   378            >
   379              {data.name}
   380            </Button>
   381          )
   382        },
   383      },
   384      {
   385        title: t('mode'),
   386        dataIndex: 'task_mode',
   387      },
   388      {
   389        title: t('source info'),
   390        render(data) {
   391          const sourceConfig = data.source_config
   392          const sources =
   393            sourceConfig.source_conf?.length > 0
   394              ? t('{{val}} and {{count}} others', {
   395                  val: `${sourceConfig.source_conf[0].source_name}`,
   396                  count: sourceConfig.source_conf.length,
   397                })
   398              : '-'
   399          if (sources.length > 1) {
   400            return (
   401              <Button
   402                type="link"
   403                onClick={() => {
   404                  setCurrentTaskName(data.name)
   405                  setSourceDrawerVisible(true)
   406                }}
   407              >
   408                {sources}
   409              </Button>
   410            )
   411          }
   412          return sources
   413        },
   414      },
   415      {
   416        title: t('target info'),
   417        dataIndex: 'target_config',
   418        render(targetConfig) {
   419          return `${targetConfig.host}:${targetConfig.port}`
   420        },
   421      },
   422      {
   423        title: t('current stage'),
   424        dataIndex: 'status_list',
   425        render(statusList: Task['status_list']) {
   426          if (!statusList) return '-'
   427          return (
   428            <div>
   429              <TaskUnitTag status={statusList} />
   430            </div>
   431          )
   432        },
   433      },
   434      {
   435        title: t('status'),
   436        dataIndex: 'status_list',
   437        render(subtasks: Task['status_list']) {
   438          return calculateTaskStatus(subtasks)
   439        },
   440        filters: Object.values(TaskStage).map(stage => ({
   441          text: stage,
   442          value: stage,
   443        })),
   444        onFilter: (value, record) =>
   445          calculateTaskStatus(record.status_list) === value,
   446      },
   447      {
   448        title: t('incremental sync delay'),
   449        dataIndex: 'status_list',
   450        render(data: Task['status_list']) {
   451          const syncUnits = data?.filter(i => i.unit === TaskUnit.Sync)
   452          if (syncUnits && syncUnits.length > 0) {
   453            return `${Math.max(
   454              ...syncUnits.map(i => i?.sync_status?.seconds_behind_master ?? 0)
   455            )}s`
   456          }
   457          return '-'
   458        },
   459      },
   460      {
   461        title: t('operations'),
   462        render(data) {
   463          return (
   464            <Space>
   465              <Button
   466                type="link"
   467                onClick={() => {
   468                  dispatch(actions.setPreloadedTask(data))
   469                  setIsCreateTaskModalVisible(true)
   470                }}
   471              >
   472                {t('edit')}
   473              </Button>
   474            </Space>
   475          )
   476        },
   477      },
   478    ]
   479  
   480    useEffect(() => {
   481      dispatch(actions.setPreloadedTask(null))
   482    }, [])
   483  
   484    useEffect(() => {
   485      showMessage(startTaskResult)
   486    }, [startTaskResult.status])
   487  
   488    useEffect(() => {
   489      showMessage(stopTaskResult)
   490    }, [stopTaskResult.status])
   491  
   492    useEffect(() => {
   493      showMessage(deleteTaskResult)
   494    }, [deleteTaskResult.status])
   495  
   496    useEffect(() => {
   497      if (currentTaskName) {
   498        const currentTask = taskMap?.get(currentTaskName)
   499        if (currentTask) {
   500          const { status_list, ...rest } = currentTask
   501          convertTaskDataToConfigFile({ task: rest })
   502            .unwrap()
   503            .then(res => {
   504              setCurrentTaskConfigFile(res.task_config_file)
   505            })
   506        }
   507      }
   508    }, [currentTaskName])
   509  
   510    useEffect(() => {
   511      refetchCurrentTaskStatus()
   512    }, [data])
   513  
   514    useEffect(() => {
   515      if (!isStartTaskModalVisible) {
   516        form.resetFields()
   517      }
   518    }, [isStartTaskModalVisible])
   519  
   520    return (
   521      <div>
   522        <div className="p-4">
   523          <Breadcrumb>
   524            <Breadcrumb.Item>{t('migration')}</Breadcrumb.Item>
   525            <Breadcrumb.Item>{t('task list')}</Breadcrumb.Item>
   526          </Breadcrumb>
   527        </div>
   528  
   529        <div className="mx-4 my-2 p-4 rounded bg-white border-1 border-gray-300 border-dashed whitespace-pre-line">
   530          {t('task list desc')}
   531        </div>
   532  
   533        <Row className="p-4" justify="space-between">
   534          <Col span={22}>
   535            <Space>
   536              <Input
   537                suffix={<SearchOutlined />}
   538                onChange={e => setKeyword(e.target.value)}
   539                placeholder={t('search placeholder')}
   540              />
   541  
   542              <Button icon={<RedoOutlined />} onClick={refetch}>
   543                {t('refresh')}
   544              </Button>
   545  
   546              <Dropdown.Button
   547                icon={<DownOutlined />}
   548                onClick={handleStartTask}
   549                overlay={
   550                  <Menu>
   551                    <Menu.Item
   552                      icon={<PauseCircleOutlined />}
   553                      key="stop"
   554                      onClick={handleStopTask}
   555                    >
   556                      {t('stop', { context: 'task list' })}
   557                    </Menu.Item>
   558                    <Menu.Item
   559                      icon={<CloseCircleOutlined />}
   560                      key="delete"
   561                      onClick={handleDeleteTask}
   562                    >
   563                      {t('delete')}
   564                    </Menu.Item>
   565                  </Menu>
   566                }
   567              >
   568                <PlayCircleOutlined />
   569                {t('start')}
   570              </Dropdown.Button>
   571            </Space>
   572          </Col>
   573          <Col span={2}>
   574            <Button
   575              onClick={() => {
   576                setIsCreateTaskModalVisible(true)
   577              }}
   578              icon={<PlusSquareOutlined />}
   579            >
   580              {t('add')}
   581            </Button>
   582          </Col>
   583        </Row>
   584  
   585        <Table
   586          className="p-4"
   587          dataSource={result}
   588          columns={columns}
   589          loading={isFetching}
   590          rowKey="name"
   591          rowSelection={rowSelection}
   592          pagination={{
   593            total: data?.total,
   594          }}
   595        />
   596  
   597        <Drawer
   598          title={t('task detail')}
   599          placement="right"
   600          size="large"
   601          visible={detailDrawerVisible}
   602          onClose={() => setDetailDrawerVisible(false)}
   603        >
   604          {currentTaskStatus ? (
   605            <Tabs defaultActiveKey="1">
   606              <Tabs.TabPane tab={t('subtask')} key="1">
   607                <SubTaskTable subs={currentTaskStatus.data} />
   608              </Tabs.TabPane>
   609              <Tabs.TabPane tab={t('runtime config')} key="2">
   610                <SyntaxHighlighter style={ghcolors} language="yaml">
   611                  {currentTaskConfigFile}
   612                </SyntaxHighlighter>
   613              </Tabs.TabPane>
   614            </Tabs>
   615          ) : (
   616            <div className="flex items-center justify-center">
   617              <Spin />
   618            </div>
   619          )}
   620        </Drawer>
   621  
   622        <Drawer
   623          title={t('source detail')}
   624          placement="right"
   625          size="large"
   626          visible={sourceDrawerVisible}
   627          onClose={() => setSourceDrawerVisible(false)}
   628        >
   629          <SourceTable data={sourcesOfCurrentTask} />
   630        </Drawer>
   631  
   632        <Modal
   633          onOk={handleConfirmOpenTask}
   634          onCancel={() => setIsCreateTaskModalVisible(false)}
   635          okText={t('confirm')}
   636          cancelText={t('cancel')}
   637          visible={isCreateTaskModalVisible}
   638        >
   639          <div>
   640            <Radio.Group
   641              onChange={e => {
   642                setOpenTaskMethod(e.target.value)
   643              }}
   644              defaultValue={openTaskMethod}
   645            >
   646              <Space direction="vertical">
   647                <Radio value={OpenTaskMethod.ByGuide}>
   648                  <div>
   649                    <div className="font-bold">{t('open task by guide')}</div>
   650                    <div className="text-gray-400">
   651                      {t('open task by guide desc')}
   652                    </div>
   653                  </div>
   654                </Radio>
   655                <Radio value={OpenTaskMethod.ByConfigFile}>
   656                  <div>
   657                    <div className="font-bold">{t('open task by config')}</div>
   658                    <div className="text-gray-400">
   659                      {t('open task by config desc')}
   660                    </div>
   661                  </div>
   662                </Radio>
   663              </Space>
   664            </Radio.Group>
   665          </div>
   666        </Modal>
   667  
   668        <Modal
   669          onOk={handleConfirmStartTask}
   670          onCancel={() => setIsStartTaskModalVisible(false)}
   671          okText={t('confirm')}
   672          cancelText={t('cancel')}
   673          visible={isStartTaskModalVisible}
   674        >
   675          <div>
   676            <Radio.Group
   677              onChange={e => {
   678                setStartTaskMethod(e.target.value)
   679              }}
   680              defaultValue={startTaskMethod}
   681            >
   682              <Space direction="vertical">
   683                <Radio value={StartTaskMethod.Direct}>
   684                  <div>
   685                    <div className="font-bold">
   686                      {t('start task without params')}
   687                    </div>
   688                    <div className="text-gray-400">
   689                      {t('start task without params desc')}
   690                    </div>
   691                  </div>
   692                </Radio>
   693                <Radio value={StartTaskMethod.WithParams}>
   694                  <div>
   695                    <div className="font-bold">{t('start task with params')}</div>
   696                    <div className="text-gray-400">
   697                      {t('start task with params desc')}
   698                    </div>
   699                  </div>
   700                </Radio>
   701  
   702                <Form form={form}>
   703                  <Form.Item
   704                    labelCol={{ span: 10 }}
   705                    wrapperCol={{ span: 14 }}
   706                    className="!mb-2"
   707                    name="start_time"
   708                    tooltip={t('start time on copy tooltip')}
   709                    label={t('start time on copy')}
   710                  >
   711                    <DatePicker
   712                      showTime
   713                      placeholder="Select time"
   714                      className="!w-240px"
   715                    />
   716                  </Form.Item>
   717  
   718                  <Form.Item
   719                    labelCol={{ span: 10 }}
   720                    wrapperCol={{ span: 14 }}
   721                    name="safe_mode_time_duration"
   722                    tooltip={t('safe mode time duration tooltip')}
   723                    label={t('safe mode time duration')}
   724                  >
   725                    <Input
   726                      type="number"
   727                      className="!w-240px"
   728                      addonAfter={t('second')}
   729                    />
   730                  </Form.Item>
   731                </Form>
   732              </Space>
   733            </Radio.Group>
   734          </div>
   735        </Modal>
   736      </div>
   737    )
   738  }
   739  
   740  export const meta = {
   741    title: () => i18n.t('task list'),
   742    index: 0,
   743  }
   744  
   745  export default TaskList