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