* fix: perform load action on boot main app * feat: add dataType option in collection duplicator * chore: reset optional dumpable config * chore: dump command * chore: dump & restore command * chore: delay restore * fix: dump test * chore: restore command * chore: dump command action * chore: dumpable collection api * chore: client collection option * feat: backup& restore client * chore: content disposition header in dump response * chore: download backup field * feat: collection origin option * fix: test * chore: collection manager collection origin * chore: upload backup field * chore: upload restore file * chore: upload restore file * fix: test * chore: backup and restore support learn more * refactor: upload restore file * refactor: upload restore file * fix: test * fix: test * chore: dumpable collection with title * chore: pg only test * chore: test * fix: test * chore: test sleep * style: locale improve * refactor: download backup file * refactor: start restore * fix: restore key name * refactor: start restore * refactor: start restore * refactor: start restore * refactor: start restore * refactor: start restore * refactor: start restore * chore: unify duplicator option * fix: dump empty collection * chore: test * chore: test * style: style improve * refactor: locale improve * chore: dumpalbe collection orders * style: style improve * style: style improve * style: icon adjust * chore: nginx body size * chore: get file status * feat: run dump task * feat: download api * chore: backup files resourcer * feat: restore destroy api * chore: backup files resoucer * feat: list backup files action * chore: get collection meta from dumped file * fix: dump file name * fix: test * chore: backup and restore ui * chore: swagger api for backup & restore * chore: api doc * chore: api doc * chore: api doc * chore: backup and restore ui * chore: backup and restore ui * chore: backup and restore ui * chore: backup and restore ui * chore: backup and restore ui * fix: restore values * style: style improve * fix: download field respontype * fix: restore form local file * refactor: local improve * refactor: delete backup file * fix: in progress status * refactor: locale improve * refactor: locale improve * refactor: style improve * refactor: style improve * refactor: style improve * test: dump collection table attribute * chore: dump collection with table attributes * chore: test * chore: create new table in restore * fix: import error * chore: restore table from backup file * chore: sync collection after restore collections * fix: restore json data * style: style improve * chore: restore with fields * chore: test * fix: test * fix: test with underscored * style: style improve * fix: lock file state * chore: add test file * refactor: backup & restore plugin * fix: mysql test * chore: skip import view collection * chore: restore collection with inherits topo order * fix: import * style: style improve * fix: restore sequence fields * fix: themeConfig collection duplicator option * fix: restore with dialectOnly meta * fix: throw error * fix: restore * fix: import backup file created in postgres into mysql * fix: repeated items in inherits * chore: upgrade after restore * feat: check database env before restore * feat: handle autoincr val in postgres * chore: sqlite & mysql queryInterface * chore: test * fix: test * chore: test * fix: build * fix: pg test * fix: restore with date field * chore: theme-config collection * chore: chage import collections method to support collection origin * chore: fallback get autoincr value in mysql * fix: dataType normalize * chore: delay restore * chore: test * fix: build * feat: collectin onDump * feat: collection onDump interface * chore: dump with view collection * chore: sync in restore * refactor: locale improve * refactor: code improve * fix: test * fix: data sync * chore: rename backup & restore plugin * chore: skip test * style: style improve * style: style improve * style: style improve * style: style improve * chore: import version check * chore: backup file dir * chore: build * fix: bugs * fix: error * fix: pageSize * fix: import origin * fix: improve code * fix: remove namespace * chore: dump rules config * fix: dump custom collection * chore: version * fix: test * fix: test * fix: test * fix: test * chore: test * fix: load custom collection * fix: client * fix: translation * chore: code * fix: bug * fix: support shared option * fix: roles collection dumpRules * chore: test * fix: define collections * chore: collection group * fix: translation * fix: translation * fix: restore options * chore: restore command * refactor: optimize the command line * chore: dump error * fix: test error * fix: test error * fix: test error * fix: test error * fix: test error * fix: skip cli test cases * fix: test error * fix: too many open files * fix: update migration version * fix: migrations * fix: upgrade * fix: error * fix: migration error * fix: upgrade * fix: test error * fix: timeout * fix: width * feat: auto load collections * fix: test error * fix: test error * fix: test error * fix: test error * fix: test error * fix: test error * fix: test error * fix: ipc error * fix: test error --------- Co-authored-by: Chareice <chareice@live.com> Co-authored-by: katherinehhh <katherine_15995@163.com>
		
			
				
	
	
		
			469 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			469 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { InboxOutlined, PlusOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons';
 | |
| import { FormItem } from '@formily/antd-v5';
 | |
| import { Checkbox, DatePicker, useAPIClient, useCompile } from '@nocobase/client';
 | |
| import { Alert, App, Button, Card, Divider, Modal, Space, Spin, Table, Tabs, Upload, UploadProps, message } from 'antd';
 | |
| import { saveAs } from 'file-saver';
 | |
| import React, { useEffect, useMemo, useState } from 'react';
 | |
| import { useDuplicatorTranslation } from './locale';
 | |
| 
 | |
| const { Dragger } = Upload;
 | |
| 
 | |
| function useUploadProps(props: UploadProps): any {
 | |
|   const onChange = (param) => {
 | |
|     props.onChange?.(param);
 | |
|   };
 | |
| 
 | |
|   const api = useAPIClient();
 | |
| 
 | |
|   return {
 | |
|     ...props,
 | |
|     customRequest({ action, data, file, filename, headers, onError, onProgress, onSuccess, withCredentials }) {
 | |
|       const formData = new FormData();
 | |
|       if (data) {
 | |
|         Object.keys(data).forEach((key) => {
 | |
|           formData.append(key, data[key]);
 | |
|         });
 | |
|       }
 | |
|       formData.append(filename, file);
 | |
|       // eslint-disable-next-line promise/catch-or-return
 | |
|       api.axios
 | |
|         .post(action, formData, {
 | |
|           withCredentials,
 | |
|           headers,
 | |
|           onUploadProgress: ({ total, loaded }) => {
 | |
|             onProgress({ percent: Math.round((loaded / total) * 100).toFixed(2) }, file);
 | |
|           },
 | |
|         })
 | |
|         .then(({ data }) => {
 | |
|           onSuccess(data, file);
 | |
|         })
 | |
|         .catch(onError)
 | |
|         .finally(() => {});
 | |
| 
 | |
|       return {
 | |
|         abort() {
 | |
|           console.log('upload progress is aborted.');
 | |
|         },
 | |
|       };
 | |
|     },
 | |
|     onChange,
 | |
|   };
 | |
| }
 | |
| 
 | |
| const LearnMore: any = (props: { collectionsData?: any; isBackup?: boolean }) => {
 | |
|   const { collectionsData } = props;
 | |
|   const { t } = useDuplicatorTranslation();
 | |
|   const [isModalOpen, setIsModalOpen] = useState(false);
 | |
|   const [dataSource, setDataSource] = useState<any>(collectionsData);
 | |
|   useEffect(() => {
 | |
|     setDataSource(collectionsData);
 | |
|   }, [collectionsData]);
 | |
|   const apiClient = useAPIClient();
 | |
|   const compile = useCompile();
 | |
|   const resource = useMemo(() => {
 | |
|     return apiClient.resource('backupFiles');
 | |
|   }, [apiClient]);
 | |
|   const showModal = async () => {
 | |
|     if (props.isBackup) {
 | |
|       const data = await resource.dumpableCollections();
 | |
|       setDataSource(data?.data);
 | |
|       setIsModalOpen(true);
 | |
|     }
 | |
|     setIsModalOpen(true);
 | |
|   };
 | |
| 
 | |
|   const handleOk = () => {
 | |
|     setIsModalOpen(false);
 | |
|   };
 | |
| 
 | |
|   const handleCancel = () => {
 | |
|     setIsModalOpen(false);
 | |
|   };
 | |
|   const columns = [
 | |
|     {
 | |
|       title: t('Collection'),
 | |
|       dataIndex: 'collection',
 | |
|       key: 'collection',
 | |
|       render: (_, data) => {
 | |
|         const title = compile(data.title);
 | |
|         const name = data.name
 | |
|         return name === title ? title : (
 | |
|           <div>
 | |
|             {data.name}
 | |
|             {' '}
 | |
|             <span style={{ color: 'rgba(0, 0, 0, 0.3)', fontSize: '0.9em' }}>({compile(data.title)})</span>
 | |
|           </div>
 | |
|         );
 | |
|       },
 | |
|     },
 | |
|     {
 | |
|       title: t('Origin'),
 | |
|       dataIndex: 'origin',
 | |
|       key: 'origin',
 | |
|       width: '50%',
 | |
|     },
 | |
|   ];
 | |
|   const items = Object.keys(dataSource || {}).map((item) => {
 | |
|     return {
 | |
|       key: item,
 | |
|       label: t(`${item}.title`),
 | |
|       children: (
 | |
|         <>
 | |
|           <Alert style={{ marginBottom: 16 }} message={t(`${item}.description`)} />
 | |
|           <Table
 | |
|             pagination={{ pageSize: 100 }}
 | |
|             bordered
 | |
|             size={'small'}
 | |
|             dataSource={dataSource[item]}
 | |
|             columns={columns}
 | |
|             scroll={{ y: 400 }}
 | |
|           />
 | |
|         </>
 | |
|       ),
 | |
|     };
 | |
|   });
 | |
| 
 | |
|   return (
 | |
|     <>
 | |
|       <a onClick={showModal}>{t('Learn more')}</a>
 | |
|       <Modal
 | |
|         title={t('Backup instructions')}
 | |
|         width={'80vw'}
 | |
|         open={isModalOpen}
 | |
|         footer={null}
 | |
|         onOk={handleOk}
 | |
|         onCancel={handleCancel}
 | |
|       >
 | |
|         <Tabs defaultActiveKey="required" items={items} />
 | |
|       </Modal>
 | |
|     </>
 | |
|   );
 | |
| };
 | |
| 
 | |
| const Restore: React.FC<any> = ({ ButtonComponent = Button, title, upload = false, fileData }) => {
 | |
|   const { t } = useDuplicatorTranslation();
 | |
|   const [dataTypes, setDataTypes] = useState<any[]>(['required']);
 | |
|   const [isModalOpen, setIsModalOpen] = useState(false);
 | |
|   const [restoreData, setRestoreData] = useState<any>(null);
 | |
|   const [loading, setLoading] = useState(false);
 | |
|   const apiClient = useAPIClient();
 | |
|   const resource = useMemo(() => {
 | |
|     return apiClient.resource('backupFiles');
 | |
|   }, [apiClient]);
 | |
|   const [dataSource, setDataSource] = useState([]);
 | |
| 
 | |
|   useEffect(() => {
 | |
|     setDataSource(
 | |
|       Object.keys(restoreData?.dumpableCollectionsGroupByGroup || []).map((key) => ({
 | |
|         value: key,
 | |
|         label: t(`${key}.title`),
 | |
|         disabled: ['required', 'skipped'].includes(key),
 | |
|       })),
 | |
|     );
 | |
|   }, [restoreData]);
 | |
| 
 | |
|   const showModal = async () => {
 | |
|     setIsModalOpen(true);
 | |
|     if (!upload) {
 | |
|       setLoading(true);
 | |
|       const { data } = await resource.get({ filterByTk: fileData.name });
 | |
|       setDataSource(
 | |
|         Object.keys(data?.data?.meta?.dumpableCollectionsGroupByGroup || []).map((key) => ({
 | |
|           value: key,
 | |
|           label: t(`${key}.title`),
 | |
|           disabled: ['required', 'skipped'].includes(key),
 | |
|         })),
 | |
|       );
 | |
|       setRestoreData(data?.data?.meta);
 | |
|       setLoading(false);
 | |
|     }
 | |
|   };
 | |
|   const handleOk = () => {
 | |
|     resource.restore({
 | |
|       values: {
 | |
|         dataTypes,
 | |
|         filterByTk: fileData?.name,
 | |
|         key: restoreData?.key,
 | |
|       },
 | |
|     });
 | |
|     setIsModalOpen(false);
 | |
|   };
 | |
| 
 | |
|   const handleCancel = () => {
 | |
|     setIsModalOpen(false);
 | |
|     setRestoreData(null);
 | |
|     setDataTypes(['required']);
 | |
|   };
 | |
|   return (
 | |
|     <>
 | |
|       <ButtonComponent onClick={showModal}>{title}</ButtonComponent>
 | |
|       <Modal
 | |
|         title={t('Restore')}
 | |
|         width={800}
 | |
|         footer={upload && !restoreData ? null : undefined}
 | |
|         open={isModalOpen}
 | |
|         onOk={handleOk}
 | |
|         onCancel={handleCancel}
 | |
|       >
 | |
|         <Spin spinning={loading}>
 | |
|           {upload && !restoreData && <RestoreUpload setRestoreData={setRestoreData} />}
 | |
|           {(!upload || restoreData) && [
 | |
|             <strong style={{ fontWeight: 600, display: 'block', margin: '16px 0 8px' }} key="info">
 | |
|               {t('Select the data to be restored')} (
 | |
|               <LearnMore collectionsData={restoreData?.dumpableCollectionsGroupByGroup} />
 | |
|               ):
 | |
|             </strong>,
 | |
|             <div style={{ lineHeight: 2, marginBottom: 8 }} key="dataType">
 | |
|               <FormItem>
 | |
|                 <Checkbox.Group
 | |
|                   options={dataSource}
 | |
|                   style={{ flexDirection: 'column' }}
 | |
|                   value={dataTypes}
 | |
|                   onChange={(checkValue) => setDataTypes(checkValue)}
 | |
|                 />
 | |
|               </FormItem>
 | |
|             </div>,
 | |
|           ]}
 | |
|         </Spin>
 | |
|       </Modal>
 | |
|     </>
 | |
|   );
 | |
| };
 | |
| 
 | |
| const NewBackup: React.FC<any> = ({ ButtonComponent = Button, refresh }) => {
 | |
|   const { t } = useDuplicatorTranslation();
 | |
|   const [isModalOpen, setIsModalOpen] = useState(false);
 | |
|   const [dataTypes, setBackupData] = useState<any[]>(['required']);
 | |
|   const apiClient = useAPIClient();
 | |
|   const [dataSource, setDataSource] = useState([]);
 | |
| 
 | |
|   const showModal = async () => {
 | |
|     const { data } = await apiClient.resource('backupFiles').dumpableCollections();
 | |
|     setDataSource(
 | |
|       Object.keys(data || []).map((key) => ({
 | |
|         value: key,
 | |
|         label: t(`${key}.title`),
 | |
|         disabled: ['required', 'skipped'].includes(key),
 | |
|       })),
 | |
|     );
 | |
|     setIsModalOpen(true);
 | |
|   };
 | |
| 
 | |
|   const handleOk = () => {
 | |
|     apiClient.request({
 | |
|       url: 'backupFiles:create',
 | |
|       method: 'post',
 | |
|       data: {
 | |
|         dataTypes,
 | |
|       },
 | |
|     });
 | |
|     setIsModalOpen(false);
 | |
|     setBackupData(['required']);
 | |
|     setTimeout(() => {
 | |
|       refresh();
 | |
|     }, 500);
 | |
|   };
 | |
| 
 | |
|   const handleCancel = () => {
 | |
|     setIsModalOpen(false);
 | |
|     setBackupData(['required']);
 | |
|   };
 | |
| 
 | |
|   return (
 | |
|     <>
 | |
|       <ButtonComponent icon={<PlusOutlined />} type="primary" onClick={showModal}>
 | |
|         {t('New backup')}
 | |
|       </ButtonComponent>
 | |
|       <Modal title={t('New backup')} width={800} open={isModalOpen} onOk={handleOk} onCancel={handleCancel}>
 | |
|         <strong style={{ fontWeight: 600, display: 'block', margin: '16px 0 8px' }}>
 | |
|           {t('Select the data to be backed up')} (
 | |
|           <LearnMore isBackup={true} />
 | |
|           ):
 | |
|         </strong>
 | |
|         <div style={{ lineHeight: 2, marginBottom: 8 }}>
 | |
|           <Checkbox.Group
 | |
|             options={dataSource}
 | |
|             style={{ flexDirection: 'column' }}
 | |
|             onChange={(checkValue) => setBackupData(checkValue)}
 | |
|             value={dataTypes}
 | |
|           />
 | |
|         </div>
 | |
|       </Modal>
 | |
|     </>
 | |
|   );
 | |
| };
 | |
| 
 | |
| const RestoreUpload: React.FC<any> = (props: any) => {
 | |
|   const { t } = useDuplicatorTranslation();
 | |
|   const uploadProps: UploadProps = {
 | |
|     multiple: false,
 | |
|     action: '/backupFiles:upload',
 | |
|     onChange(info) {
 | |
|       if (info.fileList.length > 1) {
 | |
|         info.fileList.splice(0, info.fileList.length - 1); // 只保留一个文件
 | |
|       }
 | |
|       const { status } = info.file;
 | |
|       if (status === 'done') {
 | |
|         message.success(`${info.file.name} ` + t('file uploaded successfully'));
 | |
|         props.setRestoreData({ ...info.file.response?.data?.meta, key: info.file.response?.data.key });
 | |
|       } else if (status === 'error') {
 | |
|         message.error(`${info.file.name} ` + t('file upload failed'));
 | |
|       }
 | |
|     },
 | |
|     onDrop(e) {
 | |
|       console.log('Dropped files', e.dataTransfer.files);
 | |
|     },
 | |
|   };
 | |
| 
 | |
|   return (
 | |
|     <Dragger {...useUploadProps(uploadProps)}>
 | |
|       <p className="ant-upload-drag-icon">
 | |
|         <InboxOutlined />
 | |
|       </p>
 | |
|       <p className="ant-upload-text"> {t('Click or drag file to this area to upload')}</p>
 | |
|     </Dragger>
 | |
|   );
 | |
| };
 | |
| 
 | |
| export const BackupAndRestoreList = () => {
 | |
|   const { t } = useDuplicatorTranslation();
 | |
|   const apiClient = useAPIClient();
 | |
|   const [dataSource, setDataSource] = useState([]);
 | |
|   const [loading, setLoading] = useState(false);
 | |
|   const [downloadTarget, setDownloadTarget] = useState(false);
 | |
|   const { modal } = App.useApp();
 | |
|   const resource = useMemo(() => {
 | |
|     return apiClient.resource('backupFiles');
 | |
|   }, [apiClient]);
 | |
| 
 | |
|   useEffect(() => {
 | |
|     queryFieldList();
 | |
|   }, []);
 | |
|   const queryFieldList = async () => {
 | |
|     setLoading(true);
 | |
|     const { data } = await resource.list();
 | |
|     setDataSource(data.data);
 | |
|     setLoading(false);
 | |
|   };
 | |
|   const handleDownload = async (fileData) => {
 | |
|     setDownloadTarget(fileData.name);
 | |
|     const data = await apiClient.request({
 | |
|       url: 'backupFiles:download',
 | |
|       method: 'get',
 | |
|       params: {
 | |
|         filterByTk: fileData.name,
 | |
|       },
 | |
|       responseType: 'blob',
 | |
|     });
 | |
|     setDownloadTarget(false);
 | |
|     const blob = new Blob([data.data]);
 | |
|     saveAs(blob, fileData.name);
 | |
|   };
 | |
|   const handleRefresh = async () => {
 | |
|     await queryFieldList();
 | |
|   };
 | |
|   const handleDestory = (fileData) => {
 | |
|     modal.confirm({
 | |
|       title: t('Delete record', { ns: 'client' }),
 | |
|       content: t('Are you sure you want to delete it?', { ns: 'client' }),
 | |
|       onOk: async () => {
 | |
|         await resource.destroy({ filterByTk: fileData.name });
 | |
|         await queryFieldList();
 | |
|         message.success(t('Deleted successfully'));
 | |
|       },
 | |
|     });
 | |
|   };
 | |
| 
 | |
|   return (
 | |
|     <div>
 | |
|       <Card bordered={false}>
 | |
|         <Space style={{ float: 'right', marginBottom: 16 }}>
 | |
|           <Button onClick={handleRefresh} icon={<ReloadOutlined />}>
 | |
|             {t('Refresh')}
 | |
|           </Button>
 | |
|           <Restore
 | |
|             upload
 | |
|             title={
 | |
|               <>
 | |
|                 <UploadOutlined /> {t('Restore backup from local')}
 | |
|               </>
 | |
|             }
 | |
|           />
 | |
|           <NewBackup refresh={handleRefresh} />
 | |
|         </Space>
 | |
|         <Table
 | |
|           dataSource={dataSource}
 | |
|           loading={loading}
 | |
|           columns={[
 | |
|             {
 | |
|               title: t('Backup file'),
 | |
|               dataIndex: 'name',
 | |
|               width: 400,
 | |
|               onCell: (data) => {
 | |
|                 return data.inProgress
 | |
|                   ? {
 | |
|                       colSpan: 4,
 | |
|                     }
 | |
|                   : {};
 | |
|               },
 | |
|               render: (name, data) =>
 | |
|                 data.inProgress ? (
 | |
|                   <div style={{ color: 'rgba(0, 0, 0, 0.88)' }}>
 | |
|                     {name}({t('Backing up')}...)
 | |
|                   </div>
 | |
|                 ) : (
 | |
|                   <div>{name}</div>
 | |
|                 ),
 | |
|             },
 | |
|             {
 | |
|               title: t('File size'),
 | |
|               dataIndex: 'fileSize',
 | |
|               onCell: (data) => {
 | |
|                 return data.inProgress
 | |
|                   ? {
 | |
|                       colSpan: 0,
 | |
|                     }
 | |
|                   : {};
 | |
|               },
 | |
|             },
 | |
|             {
 | |
|               title: t('Created at', { ns: 'client' }),
 | |
|               dataIndex: 'createdAt',
 | |
|               onCell: (data) => {
 | |
|                 return data.inProgress
 | |
|                   ? {
 | |
|                       colSpan: 0,
 | |
|                     }
 | |
|                   : {};
 | |
|               },
 | |
|               render: (value) => {
 | |
|                 return <DatePicker.ReadPretty value={value} showTime />;
 | |
|               },
 | |
|             },
 | |
|             {
 | |
|               title: t('Actions', { ns: 'client' }),
 | |
|               dataIndex: 'actions',
 | |
|               onCell: (data) => {
 | |
|                 return data.inProgress
 | |
|                   ? {
 | |
|                       colSpan: 0,
 | |
|                     }
 | |
|                   : {};
 | |
|               },
 | |
|               render: (_, record) => (
 | |
|                 <Space split={<Divider type="vertical" />}>
 | |
|                   <Restore ButtonComponent={'a'} title={t('Restore')} fileData={record} />
 | |
|                   <a type="link" onClick={() => handleDownload(record)}>
 | |
|                     {t('Download')}
 | |
|                   </a>
 | |
|                   <a onClick={() => handleDestory(record)}>{t('Delete')}</a>
 | |
|                 </Space>
 | |
|               ),
 | |
|             },
 | |
|           ]}
 | |
|         />
 | |
|       </Card>
 | |
|     </div>
 | |
|   );
 | |
| };
 |