| @@ -62,3 +62,7 @@ | |||||
| font-size: 12px; | font-size: 12px; | ||||
| } | } | ||||
| } | } | ||||
| .parameter-input.parameter-input--error { | |||||
| border-color: @error-color; | |||||
| } | |||||
| @@ -1,5 +1,5 @@ | |||||
| import { CloseOutlined } from '@ant-design/icons'; | import { CloseOutlined } from '@ant-design/icons'; | ||||
| import { Input } from 'antd'; | |||||
| import { Form, Input } from 'antd'; | |||||
| import { RuleObject } from 'antd/es/form'; | import { RuleObject } from 'antd/es/form'; | ||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||
| import './index.less'; | import './index.less'; | ||||
| @@ -31,6 +31,7 @@ export interface ParameterInputProps { | |||||
| style?: React.CSSProperties; | style?: React.CSSProperties; | ||||
| size?: 'middle' | 'small' | 'large'; | size?: 'middle' | 'small' | 'large'; | ||||
| disabled?: boolean; | disabled?: boolean; | ||||
| id?: string; | |||||
| } | } | ||||
| function ParameterInput({ | function ParameterInput({ | ||||
| @@ -45,6 +46,7 @@ function ParameterInput({ | |||||
| style, | style, | ||||
| size = 'middle', | size = 'middle', | ||||
| disabled = false, | disabled = false, | ||||
| id, | |||||
| ...rest | ...rest | ||||
| }: ParameterInputProps) { | }: ParameterInputProps) { | ||||
| const valueObj = | const valueObj = | ||||
| @@ -55,6 +57,7 @@ function ParameterInput({ | |||||
| const isSelect = valueObj?.fromSelect; | const isSelect = valueObj?.fromSelect; | ||||
| const placeholder = valueObj?.placeholder || rest?.placeholder; | const placeholder = valueObj?.placeholder || rest?.placeholder; | ||||
| const InputComponent = textArea ? Input.TextArea : Input; | const InputComponent = textArea ? Input.TextArea : Input; | ||||
| const { status } = Form.Item.useStatus(); | |||||
| // 删除 | // 删除 | ||||
| const handleRemove = (e: React.MouseEvent<HTMLSpanElement, MouseEvent>) => { | const handleRemove = (e: React.MouseEvent<HTMLSpanElement, MouseEvent>) => { | ||||
| @@ -75,9 +78,11 @@ function ParameterInput({ | |||||
| <> | <> | ||||
| {(isSelect || !canInput) && !disabled ? ( | {(isSelect || !canInput) && !disabled ? ( | ||||
| <div | <div | ||||
| id={id} | |||||
| className={classNames( | className={classNames( | ||||
| 'parameter-input', | 'parameter-input', | ||||
| { 'parameter-input--large': size === 'large' }, | { 'parameter-input--large': size === 'large' }, | ||||
| { [`parameter-input--${status}`]: status }, | |||||
| className, | className, | ||||
| )} | )} | ||||
| style={style} | style={style} | ||||
| @@ -98,6 +103,7 @@ function ParameterInput({ | |||||
| ) : ( | ) : ( | ||||
| <InputComponent | <InputComponent | ||||
| {...rest} | {...rest} | ||||
| id={id} | |||||
| size={size} | size={size} | ||||
| className={className} | className={className} | ||||
| style={style} | style={style} | ||||
| @@ -1,4 +1,4 @@ | |||||
| .resource-select { | |||||
| .kf-resource-select { | |||||
| position: relative; | position: relative; | ||||
| display: flex; | display: flex; | ||||
| align-items: center; | align-items: center; | ||||
| @@ -8,7 +8,7 @@ import { openAntdModal } from '@/utils/modal'; | |||||
| import { Button } from 'antd'; | import { Button } from 'antd'; | ||||
| import { useState } from 'react'; | import { useState } from 'react'; | ||||
| import ParameterInput, { type ParameterInputProps } from '../ParameterInput'; | import ParameterInput, { type ParameterInputProps } from '../ParameterInput'; | ||||
| import styles from './index.less'; | |||||
| import './index.less'; | |||||
| export { requiredValidator, type ParameterInputObject } from '../ParameterInput'; | export { requiredValidator, type ParameterInputObject } from '../ParameterInput'; | ||||
| @@ -80,7 +80,7 @@ function ResourceSelect({ type, value, onChange, ...rest }: ResourceSelectProps) | |||||
| }; | }; | ||||
| return ( | return ( | ||||
| <div className={styles['resource-select']}> | |||||
| <div className="kf-resource-select"> | |||||
| <ParameterInput | <ParameterInput | ||||
| {...rest} | {...rest} | ||||
| value={value} | value={value} | ||||
| @@ -89,7 +89,7 @@ function ResourceSelect({ type, value, onChange, ...rest }: ResourceSelectProps) | |||||
| onClick={selectResource} | onClick={selectResource} | ||||
| ></ParameterInput> | ></ParameterInput> | ||||
| <Button | <Button | ||||
| className={styles['resource-select__button']} | |||||
| className="kf-resource-select__button" | |||||
| size="large" | size="large" | ||||
| type="link" | type="link" | ||||
| icon={getSelectBtnIcon(type)} | icon={getSelectBtnIcon(type)} | ||||
| @@ -0,0 +1,69 @@ | |||||
| .tableExpandBox { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| width: 100%; | |||||
| padding: 0 0 0 33px; | |||||
| color: @text-color; | |||||
| font-size: 15px; | |||||
| & > div { | |||||
| padding: 0 16px; | |||||
| } | |||||
| .index { | |||||
| width: calc((100% + 32px + 33px) / 6.25); | |||||
| } | |||||
| .tensorBoard { | |||||
| width: calc((100% + 32px + 33px) / 6.25); | |||||
| } | |||||
| .description { | |||||
| display: flex; | |||||
| flex: 1; | |||||
| align-items: center; | |||||
| .startTime { | |||||
| .singleLine(); | |||||
| } | |||||
| } | |||||
| .status { | |||||
| width: 200px; | |||||
| } | |||||
| .operation { | |||||
| width: 334px; | |||||
| } | |||||
| } | |||||
| .tableExpandBoxContent { | |||||
| height: 45px; | |||||
| background-color: #fff; | |||||
| border: 1px solid #eaeaea; | |||||
| & + & { | |||||
| border-top: none; | |||||
| } | |||||
| .statusBox { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| width: 200px; | |||||
| .statusIcon { | |||||
| visibility: hidden; | |||||
| transition: all 0.2s; | |||||
| } | |||||
| } | |||||
| .statusBox:hover .statusIcon { | |||||
| visibility: visible; | |||||
| } | |||||
| } | |||||
| .loadMoreBox { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| margin: 10px auto 0; | |||||
| } | |||||
| @@ -0,0 +1,178 @@ | |||||
| import KFIcon from '@/components/KFIcon'; | |||||
| import { ExperimentStatus } from '@/enums'; | |||||
| import { experimentStatusInfo } from '@/pages/Experiment/status'; | |||||
| import { | |||||
| deleteQueryByExperimentInsId, | |||||
| putQueryByExperimentInsId, | |||||
| } from '@/services/experiment/index.js'; | |||||
| import themes from '@/styles/theme.less'; | |||||
| import { type ExperimentInstance } from '@/types'; | |||||
| import { elapsedTime, formatDate } from '@/utils/date'; | |||||
| import { to } from '@/utils/promise'; | |||||
| import { modalConfirm } from '@/utils/ui'; | |||||
| import { DoubleRightOutlined } from '@ant-design/icons'; | |||||
| import { App, Button, ConfigProvider, Tooltip } from 'antd'; | |||||
| import classNames from 'classnames'; | |||||
| import TensorBoardStatusCell from '../TensorBoardStatus'; | |||||
| import styles from './index.less'; | |||||
| type ExperimentInstanceProps = { | |||||
| experimentInList?: ExperimentInstance[]; | |||||
| experimentInsTotal: number; | |||||
| onClickInstance?: (instance: ExperimentInstance) => void; | |||||
| onClickTensorBoard?: (instance: ExperimentInstance) => void; | |||||
| onRemove?: () => void; | |||||
| onTerminate?: (instance: ExperimentInstance) => void; | |||||
| onLoadMore?: () => void; | |||||
| }; | |||||
| function ExperimentInstanceComponent({ | |||||
| experimentInList, | |||||
| experimentInsTotal, | |||||
| onClickInstance, | |||||
| onClickTensorBoard, | |||||
| onRemove, | |||||
| onTerminate, | |||||
| onLoadMore, | |||||
| }: ExperimentInstanceProps) { | |||||
| const { message } = App.useApp(); | |||||
| // 删除实验实例确认 | |||||
| const handleRemove = (instance: ExperimentInstance) => { | |||||
| modalConfirm({ | |||||
| title: '确定删除该条实例吗?', | |||||
| onOk: () => { | |||||
| deleteExperimentInstance(instance.id); | |||||
| }, | |||||
| }); | |||||
| }; | |||||
| // 删除实验实例 | |||||
| const deleteExperimentInstance = async (id: number) => { | |||||
| const [res] = await to(deleteQueryByExperimentInsId(id)); | |||||
| if (res) { | |||||
| message.success('删除成功'); | |||||
| onRemove?.(); | |||||
| } | |||||
| }; | |||||
| // 终止实验实例 | |||||
| const terminateExperimentInstance = async (instance: ExperimentInstance) => { | |||||
| const [res] = await to(putQueryByExperimentInsId(instance.id)); | |||||
| if (res) { | |||||
| message.success('终止成功'); | |||||
| onTerminate?.(instance); | |||||
| } | |||||
| }; | |||||
| if (!experimentInList || experimentInList.length === 0) { | |||||
| return null; | |||||
| } | |||||
| return ( | |||||
| <div> | |||||
| <div className={styles.tableExpandBox} style={{ paddingBottom: '16px' }}> | |||||
| <div className={styles.index}>序号</div> | |||||
| <div className={styles.tensorBoard}>可视化</div> | |||||
| <div className={styles.description}> | |||||
| <div style={{ width: '50%' }}>运行时长</div> | |||||
| <div style={{ width: '50%' }}>开始时间</div> | |||||
| </div> | |||||
| <div className={styles.status}>状态</div> | |||||
| <div className={styles.operation}>操作</div> | |||||
| </div> | |||||
| {experimentInList.map((item, index) => ( | |||||
| <div | |||||
| key={item.id} | |||||
| className={classNames(styles.tableExpandBox, styles.tableExpandBoxContent)} | |||||
| > | |||||
| <a | |||||
| className={styles.index} | |||||
| style={{ padding: '0 16px' }} | |||||
| onClick={() => onClickInstance?.(item)} | |||||
| > | |||||
| {index + 1} | |||||
| </a> | |||||
| <div className={styles.tensorBoard}> | |||||
| {item.nodes_result?.tensorboard_log ? ( | |||||
| <TensorBoardStatusCell | |||||
| status={item.tensorBoardStatus} | |||||
| onClick={() => onClickTensorBoard?.(item)} | |||||
| ></TensorBoardStatusCell> | |||||
| ) : ( | |||||
| '--' | |||||
| )} | |||||
| </div> | |||||
| <div className={styles.description}> | |||||
| <div style={{ width: '50%' }}>{elapsedTime(item.create_time, item.finish_time)}</div> | |||||
| <div style={{ width: '50%' }} className={styles.startTime}> | |||||
| <Tooltip title={formatDate(item.create_time)}> | |||||
| <span>{formatDate(item.create_time)}</span> | |||||
| </Tooltip> | |||||
| </div> | |||||
| </div> | |||||
| <div className={styles.statusBox}> | |||||
| <img | |||||
| style={{ width: '17px', marginRight: '7px' }} | |||||
| src={experimentStatusInfo[item.status as ExperimentStatus]?.icon} | |||||
| /> | |||||
| <span | |||||
| style={{ color: experimentStatusInfo[item.status as ExperimentStatus]?.color }} | |||||
| className={styles.statusIcon} | |||||
| > | |||||
| {experimentStatusInfo[item.status as ExperimentStatus]?.label} | |||||
| </span> | |||||
| </div> | |||||
| <div className={styles.operation}> | |||||
| <Button | |||||
| type="link" | |||||
| size="small" | |||||
| key="stop" | |||||
| disabled={ | |||||
| item.status === ExperimentStatus.Succeeded || | |||||
| item.status === ExperimentStatus.Failed || | |||||
| item.status === ExperimentStatus.Terminated | |||||
| } | |||||
| icon={<KFIcon type="icon-zhongzhi" />} | |||||
| onClick={() => terminateExperimentInstance(item)} | |||||
| > | |||||
| 终止 | |||||
| </Button> | |||||
| <ConfigProvider | |||||
| theme={{ | |||||
| token: { | |||||
| colorLink: themes['warningColor'], | |||||
| }, | |||||
| }} | |||||
| > | |||||
| <Button | |||||
| type="link" | |||||
| size="small" | |||||
| key="batchRemove" | |||||
| disabled={ | |||||
| item.status === ExperimentStatus.Running || | |||||
| item.status === ExperimentStatus.Pending | |||||
| } | |||||
| icon={<KFIcon type="icon-shanchu" />} | |||||
| onClick={() => handleRemove(item)} | |||||
| > | |||||
| 删除 | |||||
| </Button> | |||||
| </ConfigProvider> | |||||
| </div> | |||||
| </div> | |||||
| ))} | |||||
| {experimentInsTotal > experimentInList.length ? ( | |||||
| <div className={styles.loadMoreBox}> | |||||
| <Button type="link" onClick={onLoadMore}> | |||||
| 更多 | |||||
| <DoubleRightOutlined rotate={90} /> | |||||
| </Button> | |||||
| </div> | |||||
| ) : null} | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| export default ExperimentInstanceComponent; | |||||
| @@ -42,7 +42,7 @@ const statusConfig: Record<TensorBoardStatus, TensorBoardStatusInfo> = { | |||||
| }; | }; | ||||
| type TensorBoardStatusProps = { | type TensorBoardStatusProps = { | ||||
| status: TensorBoardStatus; | |||||
| status?: TensorBoardStatus; | |||||
| onClick: () => void; | onClick: () => void; | ||||
| }; | }; | ||||
| @@ -1,31 +1,28 @@ | |||||
| import CommonTableCell from '@/components/CommonTableCell'; | import CommonTableCell from '@/components/CommonTableCell'; | ||||
| import KFIcon from '@/components/KFIcon'; | import KFIcon from '@/components/KFIcon'; | ||||
| import { TensorBoardStatus } from '@/enums'; | |||||
| import { ExperimentStatus, TensorBoardStatus } from '@/enums'; | |||||
| import { | import { | ||||
| deleteExperimentById, | deleteExperimentById, | ||||
| deleteQueryByExperimentInsId, | |||||
| getExperiment, | getExperiment, | ||||
| getExperimentById, | getExperimentById, | ||||
| getQueryByExperimentId, | getQueryByExperimentId, | ||||
| getTensorBoardStatusReq, | getTensorBoardStatusReq, | ||||
| postExperiment, | postExperiment, | ||||
| putExperiment, | putExperiment, | ||||
| putQueryByExperimentInsId, | |||||
| runExperiments, | runExperiments, | ||||
| runTensorBoardReq, | runTensorBoardReq, | ||||
| } from '@/services/experiment/index.js'; | } from '@/services/experiment/index.js'; | ||||
| import { getWorkflow } from '@/services/pipeline/index.js'; | import { getWorkflow } from '@/services/pipeline/index.js'; | ||||
| import themes from '@/styles/theme.less'; | import themes from '@/styles/theme.less'; | ||||
| import { elapsedTime, formatDate } from '@/utils/date'; | |||||
| import { to } from '@/utils/promise'; | import { to } from '@/utils/promise'; | ||||
| import { modalConfirm } from '@/utils/ui'; | import { modalConfirm } from '@/utils/ui'; | ||||
| import { App, Button, ConfigProvider, Dropdown, Space, Table, Tooltip } from 'antd'; | |||||
| import { App, Button, ConfigProvider, Dropdown, Space, Table } from 'antd'; | |||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||
| import { useEffect, useRef, useState } from 'react'; | import { useEffect, useRef, useState } from 'react'; | ||||
| import { useNavigate } from 'react-router-dom'; | import { useNavigate } from 'react-router-dom'; | ||||
| import { ComparisonType } from './Comparison/config'; | import { ComparisonType } from './Comparison/config'; | ||||
| import AddExperimentModal from './components/AddExperimentModal'; | import AddExperimentModal from './components/AddExperimentModal'; | ||||
| import TensorBoardStatusCell from './components/TensorBoardStatus'; | |||||
| import ExperimentInstance from './components/ExperimentInstance'; | |||||
| import Styles from './index.less'; | import Styles from './index.less'; | ||||
| import { experimentStatusInfo } from './status'; | import { experimentStatusInfo } from './status'; | ||||
| @@ -49,7 +46,18 @@ function Experiment() { | |||||
| const [isAdd, setIsAdd] = useState(true); | const [isAdd, setIsAdd] = useState(true); | ||||
| const [isModalOpen, setIsModalOpen] = useState(false); | const [isModalOpen, setIsModalOpen] = useState(false); | ||||
| const [addFormData, setAddFormData] = useState({}); | const [addFormData, setAddFormData] = useState({}); | ||||
| const [experimentInsTotal, setExperimentInsTotal] = useState(0); | |||||
| const { message } = App.useApp(); | const { message } = App.useApp(); | ||||
| const pageOption = useRef({ page: 1, size: 10 }); | |||||
| const paginationProps = { | |||||
| showSizeChanger: true, | |||||
| showQuickJumper: true, | |||||
| showTotal: () => `共${total}条`, | |||||
| total: total, | |||||
| page: pageOption.current.page, | |||||
| size: pageOption.current.size, | |||||
| onChange: (current, size) => paginationChange(current, size), | |||||
| }; | |||||
| useEffect(() => { | useEffect(() => { | ||||
| getList(); | getList(); | ||||
| @@ -83,38 +91,42 @@ function Experiment() { | |||||
| setWorkflowList(res.data.content); | setWorkflowList(res.data.content); | ||||
| } | } | ||||
| }; | }; | ||||
| // 获取实验实例 | |||||
| const getQueryByExperiment = (val) => { | |||||
| getQueryByExperimentId(val).then((ret) => { | |||||
| setExpandedRowKeys(val); | |||||
| if (ret && ret.data && ret.data.length > 0) { | |||||
| try { | |||||
| const list = ret.data.map((v) => { | |||||
| const nodes_result = v.nodes_result ? JSON.parse(v.nodes_result) : {}; | |||||
| return { | |||||
| ...v, | |||||
| nodes_result, | |||||
| }; | |||||
| }); | |||||
| // 获取实验实例列表 | |||||
| const getQueryByExperiment = async (experimentId, page) => { | |||||
| const params = { | |||||
| experimentId: experimentId, | |||||
| page: page, | |||||
| size: 5, | |||||
| }; | |||||
| const [res, error] = await to(getQueryByExperimentId(params)); | |||||
| if (res && res.data) { | |||||
| const { content = [], totalElements = 0 } = res.data; | |||||
| setExpandedRowKeys(experimentId); | |||||
| try { | |||||
| const list = content.map((v) => { | |||||
| const nodes_result = v.nodes_result ? JSON.parse(v.nodes_result) : {}; | |||||
| return { | |||||
| ...v, | |||||
| nodes_result, | |||||
| }; | |||||
| }); | |||||
| if (page === 0) { | |||||
| setExperimentInList(list); | setExperimentInList(list); | ||||
| // 获取 TensorBoard 状态 | |||||
| list.forEach((item) => { | |||||
| if (item.nodes_result?.tensorboard_log) { | |||||
| const timerId = setTimeout(() => { | |||||
| getTensorBoardStatus(item); | |||||
| }, 0); | |||||
| timerIds.set(item.id, timerId); | |||||
| } | |||||
| }); | |||||
| } catch (error) { | |||||
| setExperimentInList([]); | |||||
| clearExperimentInTimers(); | |||||
| } else { | |||||
| setExperimentInList((prev) => [...prev, ...list]); | |||||
| } | } | ||||
| getList(); | |||||
| } else { | |||||
| setExperimentInList([]); | |||||
| getList(); | |||||
| setExperimentInsTotal(totalElements); | |||||
| // 获取 TensorBoard 状态 | |||||
| list.forEach((item) => { | |||||
| if (item.nodes_result?.tensorboard_log) { | |||||
| getTensorBoardStatus(item); | |||||
| } | |||||
| }); | |||||
| } catch (error) { | |||||
| console.log(error); | |||||
| } | } | ||||
| }); | |||||
| } | |||||
| }; | }; | ||||
| // 运行 TensorBoard | // 运行 TensorBoard | ||||
| const runTensorBoard = async (experimentIn) => { | const runTensorBoard = async (experimentIn) => { | ||||
| @@ -155,18 +167,26 @@ function Experiment() { | |||||
| return item; | return item; | ||||
| }); | }); | ||||
| }); | }); | ||||
| const timerId = setTimeout(() => { | |||||
| let timerId = timerIds.get(experimentIn.id); | |||||
| if (timerId) { | |||||
| clearTimeout(timerId); | |||||
| timerIds.delete(experimentIn.id); | |||||
| } | |||||
| timerId = setTimeout(() => { | |||||
| getTensorBoardStatus(experimentIn); | getTensorBoardStatus(experimentIn); | ||||
| }, 10000); | |||||
| }, 1000 * 1000); | |||||
| timerIds.set(experimentIn.id, timerId); | timerIds.set(experimentIn.id, timerId); | ||||
| } | } | ||||
| }; | }; | ||||
| // 展开实例 | |||||
| const expandChange = (e, record) => { | const expandChange = (e, record) => { | ||||
| clearExperimentInTimers(); | clearExperimentInTimers(); | ||||
| setExperimentInList([]); | |||||
| if (record.id === expandedRowKeys) { | if (record.id === expandedRowKeys) { | ||||
| setExpandedRowKeys(null); | setExpandedRowKeys(null); | ||||
| } else { | } else { | ||||
| getQueryByExperiment(record.id); | |||||
| getQueryByExperiment(record.id, 0); | |||||
| } | } | ||||
| }; | }; | ||||
| // 终止实验实例获取 TensorBoard 状态的定时器 | // 终止实验实例获取 TensorBoard 状态的定时器 | ||||
| @@ -198,6 +218,7 @@ function Experiment() { | |||||
| const handleCancel = () => { | const handleCancel = () => { | ||||
| setIsModalOpen(false); | setIsModalOpen(false); | ||||
| }; | }; | ||||
| // 跳转到流水线 | |||||
| const routeToEdit = (e, record) => { | const routeToEdit = (e, record) => { | ||||
| e.stopPropagation(); | e.stopPropagation(); | ||||
| navgite({ pathname: `/pipeline/template/${record.workflow_id}` }); | navgite({ pathname: `/pipeline/template/${record.workflow_id}` }); | ||||
| @@ -226,16 +247,7 @@ function Experiment() { | |||||
| } | } | ||||
| } | } | ||||
| }; | }; | ||||
| const pageOption = useRef({ page: 1, size: 10 }); | |||||
| const paginationProps = { | |||||
| showSizeChanger: true, | |||||
| showQuickJumper: true, | |||||
| showTotal: () => `共${total}条`, | |||||
| total: total, | |||||
| page: pageOption.current.page, | |||||
| size: pageOption.current.size, | |||||
| onChange: (current, size) => paginationChange(current, size), | |||||
| }; | |||||
| // 当前页面切换 | // 当前页面切换 | ||||
| const paginationChange = async (current, size) => { | const paginationChange = async (current, size) => { | ||||
| pageOption.current = { | pageOption.current = { | ||||
| @@ -244,21 +256,22 @@ function Experiment() { | |||||
| }; | }; | ||||
| getList(); | getList(); | ||||
| }; | }; | ||||
| const runExperiment = (id) => { | |||||
| runExperiments(id).then((ret) => { | |||||
| if (ret.code === 200) { | |||||
| message.success('运行成功'); | |||||
| getQueryByExperiment(id); | |||||
| } else { | |||||
| message.error('运行失败'); | |||||
| } | |||||
| }); | |||||
| // 运行实验 | |||||
| const runExperiment = async (id) => { | |||||
| const [res] = await to(runExperiments(id)); | |||||
| if (res) { | |||||
| message.success('运行成功'); | |||||
| refreshExperimentIns(id); | |||||
| } else { | |||||
| message.error('运行失败'); | |||||
| } | |||||
| }; | }; | ||||
| const routerToText = (e, item, record) => { | |||||
| e.stopPropagation(); | |||||
| // 跳转到实验实例详情 | |||||
| const gotoInstanceInfo = (item, record) => { | |||||
| navgite({ pathname: `/pipeline/experiment/${record.workflow_id}/${item.id}` }); | navgite({ pathname: `/pipeline/experiment/${record.workflow_id}/${item.id}` }); | ||||
| }; | }; | ||||
| // 处理 TensorBoard 操作 | |||||
| const handleTensorboard = async (experimentIn) => { | const handleTensorboard = async (experimentIn) => { | ||||
| if ( | if ( | ||||
| experimentIn.tensorBoardStatus === TensorBoardStatus.Terminated || | experimentIn.tensorBoardStatus === TensorBoardStatus.Terminated || | ||||
| @@ -273,6 +286,21 @@ function Experiment() { | |||||
| } | } | ||||
| }; | }; | ||||
| // 实验实例终止 | |||||
| const handleInstanceTerminate = async (experimentIn) => { | |||||
| setExperimentInList((prevList) => { | |||||
| return prevList.map((item) => { | |||||
| if (item.id === experimentIn.id) { | |||||
| return { | |||||
| ...item, | |||||
| status: ExperimentStatus.Terminated, | |||||
| }; | |||||
| } | |||||
| return item; | |||||
| }); | |||||
| }); | |||||
| }; | |||||
| // 实验对比菜单 | // 实验对比菜单 | ||||
| const getComparisonMenu = (experimentId) => { | const getComparisonMenu = (experimentId) => { | ||||
| return { | return { | ||||
| @@ -292,6 +320,17 @@ function Experiment() { | |||||
| }; | }; | ||||
| }; | }; | ||||
| // 刷新实验实例列表 | |||||
| const refreshExperimentIns = (experimentId) => { | |||||
| getQueryByExperiment(experimentId, 0); | |||||
| }; | |||||
| // 加载更多实验实例 | |||||
| const loadMoreExperimentIns = () => { | |||||
| const page = Math.round(experimentInList.length / 5); | |||||
| getQueryByExperiment(expandedRowKeys, page); | |||||
| }; | |||||
| const columns = [ | const columns = [ | ||||
| { | { | ||||
| title: '实验名称', | title: '实验名称', | ||||
| @@ -413,7 +452,7 @@ function Experiment() { | |||||
| ]; | ]; | ||||
| return ( | return ( | ||||
| <div className={Styles.experimentBox}> | <div className={Styles.experimentBox}> | ||||
| <div className={Styles.pipelineTopBox}> | |||||
| <div className={Styles.experimentTopBox}> | |||||
| <Button type="default" onClick={createExperiment} icon={<KFIcon type="icon-xinjian2" />}> | <Button type="default" onClick={createExperiment} icon={<KFIcon type="icon-xinjian2" />}> | ||||
| 新建实验 | 新建实验 | ||||
| </Button> | </Button> | ||||
| @@ -427,130 +466,16 @@ function Experiment() { | |||||
| scroll={{ y: 'calc(100% - 55px)' }} | scroll={{ y: 'calc(100% - 55px)' }} | ||||
| expandable={{ | expandable={{ | ||||
| expandedRowRender: (record) => ( | expandedRowRender: (record) => ( | ||||
| <div> | |||||
| {experimentInList && experimentInList.length > 0 ? ( | |||||
| <div className={Styles.tableExpandBox} style={{ paddingBottom: '16px' }}> | |||||
| <div className={Styles.index}>序号</div> | |||||
| <div className={Styles.tensorBoard}>可视化</div> | |||||
| <div className={Styles.description}> | |||||
| <div style={{ width: '50%' }}>运行时长</div> | |||||
| <div style={{ width: '50%' }}>开始时间</div> | |||||
| </div> | |||||
| <div className={Styles.status}>状态</div> | |||||
| <div className={Styles.operation}>操作</div> | |||||
| </div> | |||||
| ) : ( | |||||
| '' | |||||
| )} | |||||
| {experimentInList && experimentInList.length > 0 | |||||
| ? experimentInList.map((item, index) => ( | |||||
| <div | |||||
| key={item.id} | |||||
| className={classNames(Styles.tableExpandBox, Styles.tableExpandBoxContent)} | |||||
| > | |||||
| <a | |||||
| className={Styles.index} | |||||
| style={{ padding: '0 16px' }} | |||||
| onClick={(e) => routerToText(e, item, record)} | |||||
| > | |||||
| {index + 1} | |||||
| </a> | |||||
| <div className={Styles.tensorBoard}> | |||||
| {item.nodes_result?.tensorboard_log ? ( | |||||
| <TensorBoardStatusCell | |||||
| status={item.tensorBoardStatus} | |||||
| onClick={() => handleTensorboard(item)} | |||||
| ></TensorBoardStatusCell> | |||||
| ) : ( | |||||
| '--' | |||||
| )} | |||||
| </div> | |||||
| <div className={Styles.description}> | |||||
| <div style={{ width: '50%' }}> | |||||
| {elapsedTime(item.create_time, item.finish_time)} | |||||
| </div> | |||||
| <div style={{ width: '50%' }} className={Styles.startTime}> | |||||
| <Tooltip title={formatDate(item.create_time)}> | |||||
| <span>{formatDate(item.create_time)}</span> | |||||
| </Tooltip> | |||||
| </div> | |||||
| </div> | |||||
| <div className={Styles.statusBox}> | |||||
| <img | |||||
| style={{ width: '17px', marginRight: '7px' }} | |||||
| src={experimentStatusInfo[item.status]?.icon} | |||||
| /> | |||||
| <span | |||||
| style={{ color: experimentStatusInfo[item.status]?.color }} | |||||
| className={Styles.statusIcon} | |||||
| > | |||||
| {experimentStatusInfo[item.status]?.label} | |||||
| </span> | |||||
| </div> | |||||
| <div className={Styles.operation}> | |||||
| <Button | |||||
| type="link" | |||||
| size="small" | |||||
| key="stop" | |||||
| disabled={ | |||||
| item.status === 'Succeeded' || | |||||
| item.status === 'Failed' || | |||||
| item.status === 'Terminated' | |||||
| } | |||||
| icon={<KFIcon type="icon-zhongzhi" />} | |||||
| onClick={async () => { | |||||
| putQueryByExperimentInsId(item.id).then((ret) => { | |||||
| if (ret.code === 200) { | |||||
| message.success('终止成功'); | |||||
| getQueryByExperiment(record.id); | |||||
| } else { | |||||
| message.error(ret.msg); | |||||
| } | |||||
| }); | |||||
| }} | |||||
| > | |||||
| 终止 | |||||
| </Button> | |||||
| <ConfigProvider | |||||
| theme={{ | |||||
| token: { | |||||
| colorLink: themes['warningColor'], | |||||
| }, | |||||
| }} | |||||
| > | |||||
| <Button | |||||
| type="link" | |||||
| size="small" | |||||
| key="batchRemove" | |||||
| disabled={item.status === 'Running' || item.status === 'Pending'} | |||||
| icon={<KFIcon type="icon-shanchu" />} | |||||
| onClick={() => { | |||||
| modalConfirm({ | |||||
| title: '确定删除该条实例吗?', | |||||
| onOk: () => { | |||||
| deleteQueryByExperimentInsId(item.id).then((ret) => { | |||||
| if (ret.code === 200) { | |||||
| message.success('删除成功'); | |||||
| getQueryByExperiment(record.id); | |||||
| } else { | |||||
| message.error(ret.msg); | |||||
| } | |||||
| }); | |||||
| }, | |||||
| }); | |||||
| }} | |||||
| > | |||||
| 删除 | |||||
| </Button> | |||||
| </ConfigProvider> | |||||
| </div> | |||||
| </div> | |||||
| )) | |||||
| : ''} | |||||
| </div> | |||||
| <ExperimentInstance | |||||
| experimentInList={experimentInList} | |||||
| experimentInsTotal={experimentInsTotal} | |||||
| onClickInstance={(item) => gotoInstanceInfo(item, record)} | |||||
| onClickTensorBoard={handleTensorboard} | |||||
| onRemove={() => refreshExperimentIns(record.id)} | |||||
| onTerminate={handleInstanceTerminate} | |||||
| onLoadMore={() => loadMoreExperimentIns()} | |||||
| ></ExperimentInstance> | |||||
| ), | ), | ||||
| onExpand: (e, a) => { | onExpand: (e, a) => { | ||||
| expandChange(e, a); | expandChange(e, a); | ||||
| }, | }, | ||||
| @@ -1,92 +1,18 @@ | |||||
| .experimentTopBox { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: flex-end; | |||||
| width: 100%; | |||||
| height: 49px; | |||||
| padding-right: 30px; | |||||
| background-image: url(/assets/images/pipeline-back.png); | |||||
| background-repeat: no-repeat; | |||||
| background-position: top center; | |||||
| background-size: 100% 100%; | |||||
| } | |||||
| .pipelineTopBox { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: flex-end; | |||||
| width: 100%; | |||||
| height: 49px; | |||||
| margin-bottom: 10px; | |||||
| padding-right: 30px; | |||||
| background-image: url(/assets/images/pipeline-back.png); | |||||
| background-repeat: no-repeat; | |||||
| background-position: top center; | |||||
| background-size: 100% 100%; | |||||
| } | |||||
| .tableExpandBox { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| width: 100%; | |||||
| padding: 0 0 0 33px; | |||||
| color: @text-color; | |||||
| font-size: 15px; | |||||
| & > div { | |||||
| padding: 0 16px; | |||||
| } | |||||
| .index { | |||||
| width: calc((100% + 32px + 33px) / 6.25); | |||||
| } | |||||
| .tensorBoard { | |||||
| width: calc((100% + 32px + 33px) / 6.25); | |||||
| } | |||||
| .description { | |||||
| .experimentBox { | |||||
| height: 100%; | |||||
| .experimentTopBox { | |||||
| display: flex; | display: flex; | ||||
| flex: 1; | |||||
| align-items: center; | align-items: center; | ||||
| .startTime { | |||||
| .singleLine(); | |||||
| } | |||||
| } | |||||
| .status { | |||||
| width: 200px; | |||||
| justify-content: flex-end; | |||||
| width: 100%; | |||||
| height: 49px; | |||||
| margin-bottom: 10px; | |||||
| padding-right: 30px; | |||||
| background-image: url(/assets/images/pipeline-back.png); | |||||
| background-repeat: no-repeat; | |||||
| background-position: top center; | |||||
| background-size: 100% 100%; | |||||
| } | } | ||||
| .operation { | |||||
| width: 334px; | |||||
| } | |||||
| } | |||||
| .tableExpandBoxContent { | |||||
| height: 45px; | |||||
| background-color: #fff; | |||||
| border: 1px solid #eaeaea; | |||||
| & + & { | |||||
| border-top: none; | |||||
| } | |||||
| } | |||||
| .statusBox { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| width: 200px; | |||||
| .statusIcon { | |||||
| visibility: hidden; | |||||
| transition: all 0.2s; | |||||
| } | |||||
| } | |||||
| .statusBox:hover .statusIcon { | |||||
| visibility: visible; | |||||
| } | |||||
| .experimentBox { | |||||
| height: 100%; | |||||
| .experimentTable { | .experimentTable { | ||||
| height: calc(100% - 60px); | height: calc(100% - 60px); | ||||
| :global { | :global { | ||||
| @@ -288,7 +288,13 @@ function MirrorInfo() { | |||||
| dataSource={tableData} | dataSource={tableData} | ||||
| columns={columns} | columns={columns} | ||||
| scroll={{ y: 'calc(100% - 55px)' }} | scroll={{ y: 'calc(100% - 55px)' }} | ||||
| pagination={{ ...pagination, total, showSizeChanger: true, showQuickJumper: true }} | |||||
| pagination={{ | |||||
| ...pagination, | |||||
| total, | |||||
| showSizeChanger: true, | |||||
| showQuickJumper: true, | |||||
| showTotal: () => `共${total}条`, | |||||
| }} | |||||
| onChange={handleTableChange} | onChange={handleTableChange} | ||||
| rowKey="id" | rowKey="id" | ||||
| /> | /> | ||||
| @@ -241,7 +241,7 @@ function MirrorList() { | |||||
| <div className={styles['mirror-list__content']}> | <div className={styles['mirror-list__content']}> | ||||
| <div className={styles['mirror-list__content__filter']}> | <div className={styles['mirror-list__content__filter']}> | ||||
| <Input.Search | <Input.Search | ||||
| placeholder="按数据集名称筛选" | |||||
| placeholder="按镜像名称筛选" | |||||
| allowClear | allowClear | ||||
| onSearch={onSearch} | onSearch={onSearch} | ||||
| onChange={(e) => setInputText(e.target.value)} | onChange={(e) => setInputText(e.target.value)} | ||||
| @@ -277,6 +277,7 @@ function MirrorList() { | |||||
| total: total, | total: total, | ||||
| showSizeChanger: true, | showSizeChanger: true, | ||||
| showQuickJumper: true, | showQuickJumper: true, | ||||
| showTotal: () => `共${total}条`, | |||||
| }} | }} | ||||
| onChange={handleTableChange} | onChange={handleTableChange} | ||||
| rowKey="id" | rowKey="id" | ||||
| @@ -336,6 +336,7 @@ function ModelDeployment() { | |||||
| total: total, | total: total, | ||||
| showSizeChanger: true, | showSizeChanger: true, | ||||
| showQuickJumper: true, | showQuickJumper: true, | ||||
| showTotal: () => `共${total}条`, | |||||
| }} | }} | ||||
| onChange={handleTableChange} | onChange={handleTableChange} | ||||
| rowKey="service_id" | rowKey="service_id" | ||||
| @@ -86,14 +86,21 @@ const EditPipeline = () => { | |||||
| // 保存 | // 保存 | ||||
| const savePipeline = async (val) => { | const savePipeline = async (val) => { | ||||
| const [res, error] = await to(paramsDrawerRef.current.validateFields()); | |||||
| if (error) { | |||||
| const [globalParamRes, globalParamError] = await to(paramsDrawerRef.current.validateFields()); | |||||
| if (globalParamError) { | |||||
| message.error('全局参数配置有误'); | message.error('全局参数配置有误'); | ||||
| openParamsDrawer(); | openParamsDrawer(); | ||||
| return; | return; | ||||
| } | } | ||||
| closeParamsDrawer(); | |||||
| const [propsRes, propsError] = await to(propsRef.current.validateFields()); | |||||
| if (propsError) { | |||||
| message.error('节点必填项必须配置'); | |||||
| return; | |||||
| } | |||||
| propsRef.current.close(); | |||||
| propsRef.current.propClose(); | |||||
| setTimeout(() => { | setTimeout(() => { | ||||
| const data = graph.save(); | const data = graph.save(); | ||||
| console.log(data); | console.log(data); | ||||
| @@ -102,16 +109,19 @@ const EditPipeline = () => { | |||||
| }); | }); | ||||
| if (errorNode) { | if (errorNode) { | ||||
| message.error(`【${errorNode.label}】节点必填项必须配置`); | message.error(`【${errorNode.label}】节点必填项必须配置`); | ||||
| const graphNode = graph.findById(errorNode.id); | |||||
| if (graphNode) { | |||||
| openNodeDrawer(graphNode, true); | |||||
| } | |||||
| return; | return; | ||||
| } | } | ||||
| const params = { | const params = { | ||||
| ...locationParams, | ...locationParams, | ||||
| dag: JSON.stringify(data), | dag: JSON.stringify(data), | ||||
| global_param: JSON.stringify(res.global_param), | |||||
| global_param: JSON.stringify(globalParamRes.global_param), | |||||
| }; | }; | ||||
| saveWorkflow(params).then((ret) => { | saveWorkflow(params).then((ret) => { | ||||
| message.success('保存成功'); | message.success('保存成功'); | ||||
| closeParamsDrawer(); | |||||
| setTimeout(() => { | setTimeout(() => { | ||||
| if (val) { | if (val) { | ||||
| navgite({ pathname: `/pipeline/template` }); | navgite({ pathname: `/pipeline/template` }); | ||||
| @@ -281,6 +291,17 @@ const EditPipeline = () => { | |||||
| } | } | ||||
| }; | }; | ||||
| // 打开节点抽屉 | |||||
| const openNodeDrawer = (node, validate = false) => { | |||||
| // 获取所有的上游节点 | |||||
| const parentNodes = findAllParentNodes(graph, node); | |||||
| // 如果没有打开过全局参数抽屉,获取不到全局参数 | |||||
| const globalParams = | |||||
| paramsDrawerRef.current.getFieldsValue().global_param || globalParamRef.current; | |||||
| // 打开节点编辑抽屉 | |||||
| propsRef.current.showDrawer(node.getModel(), globalParams, parentNodes, validate); | |||||
| }; | |||||
| // 初始化图 | // 初始化图 | ||||
| const initGraph = () => { | const initGraph = () => { | ||||
| const contextMenu = initMenu(); | const contextMenu = initMenu(); | ||||
| @@ -531,13 +552,7 @@ const EditPipeline = () => { | |||||
| const bindEvents = () => { | const bindEvents = () => { | ||||
| graph.on('node:click', (e) => { | graph.on('node:click', (e) => { | ||||
| if (e.target.get('name') !== 'anchor-point' && e.item) { | if (e.target.get('name') !== 'anchor-point' && e.item) { | ||||
| // 获取所有的上游节点 | |||||
| const parentNodes = findAllParentNodes(graph, e.item); | |||||
| // 如果没有打开过全局参数抽屉,获取不到全局参数 | |||||
| const globalParams = | |||||
| paramsDrawerRef.current.getFieldsValue().global_param || globalParamRef.current; | |||||
| // 打开节点编辑抽屉 | |||||
| propsRef.current.showDrawer(e, globalParams, parentNodes); | |||||
| openNodeDrawer(e.item); | |||||
| } | } | ||||
| }); | }); | ||||
| graph.on('aftercreateedge', (e) => { | graph.on('aftercreateedge', (e) => { | ||||
| @@ -6,6 +6,7 @@ import { CommonTabKeys } from '@/enums'; | |||||
| import { useComputingResource } from '@/hooks/resource'; | import { useComputingResource } from '@/hooks/resource'; | ||||
| import { | import { | ||||
| PipelineGlobalParam, | PipelineGlobalParam, | ||||
| PipelineNodeModel, | |||||
| PipelineNodeModelParameter, | PipelineNodeModelParameter, | ||||
| PipelineNodeModelSerialize, | PipelineNodeModelSerialize, | ||||
| } from '@/types'; | } from '@/types'; | ||||
| @@ -57,7 +58,6 @@ const PipelineNodeParameter = forwardRef(({ onFormChange }: PipelineNodeParamete | |||||
| }; | }; | ||||
| console.log('res', res); | console.log('res', res); | ||||
| onFormChange(res); | onFormChange(res); | ||||
| } | } | ||||
| }; | }; | ||||
| @@ -66,36 +66,53 @@ const PipelineNodeParameter = forwardRef(({ onFormChange }: PipelineNodeParamete | |||||
| }; | }; | ||||
| useImperativeHandle(ref, () => ({ | useImperativeHandle(ref, () => ({ | ||||
| showDrawer(e: any, params: PipelineGlobalParam[], parentNodes: INode[]) { | |||||
| if (e.item && e.item.getModel()) { | |||||
| showDrawer( | |||||
| model: PipelineNodeModel, | |||||
| params: PipelineGlobalParam[], | |||||
| parentNodes: INode[], | |||||
| validate: boolean = false, | |||||
| ) { | |||||
| try { | |||||
| const nodeData: PipelineNodeModelSerialize = { | |||||
| ...model, | |||||
| in_parameters: JSON.parse(model.in_parameters), | |||||
| out_parameters: JSON.parse(model.out_parameters), | |||||
| control_strategy: JSON.parse(model.control_strategy), | |||||
| }; | |||||
| console.log('model', nodeData); | |||||
| setStagingItem({ | |||||
| ...nodeData, | |||||
| }); | |||||
| form.resetFields(); | form.resetFields(); | ||||
| const model = e.item.getModel(); | |||||
| try { | |||||
| const nodeData = { | |||||
| ...model, | |||||
| in_parameters: JSON.parse(model.in_parameters), | |||||
| out_parameters: JSON.parse(model.out_parameters), | |||||
| control_strategy: JSON.parse(model.control_strategy), | |||||
| }; | |||||
| console.log('model', nodeData); | |||||
| setStagingItem({ | |||||
| ...nodeData, | |||||
| }); | |||||
| form.setFieldsValue({ | |||||
| ...nodeData, | |||||
| }); | |||||
| } catch (error) { | |||||
| console.log(error); | |||||
| form.setFieldsValue({ | |||||
| ...nodeData, | |||||
| }); | |||||
| if (validate) { | |||||
| form.validateFields(); | |||||
| } | } | ||||
| setOpen(true); | |||||
| // 参数下拉菜单 | |||||
| setMenuItems(createMenuItems(params, parentNodes)); | |||||
| } catch (error) { | |||||
| console.log(error); | |||||
| } | } | ||||
| setOpen(true); | |||||
| // 参数下拉菜单 | |||||
| setMenuItems(createMenuItems(params, parentNodes)); | |||||
| }, | }, | ||||
| propClose: () => { | |||||
| close: () => { | |||||
| onClose(); | onClose(); | ||||
| }, | }, | ||||
| validateFields: async () => { | |||||
| if (!open) { | |||||
| return; | |||||
| } | |||||
| const [values, error] = await to(form.validateFields()); | |||||
| if (!error && values) { | |||||
| return values; | |||||
| } else { | |||||
| form.scrollToField((error as any)?.errorFields?.[0]?.name, { block: 'center' }); | |||||
| return Promise.reject(error); | |||||
| } | |||||
| }, | |||||
| })); | })); | ||||
| // 选择数据集、模型、镜像 | // 选择数据集、模型、镜像 | ||||
| @@ -279,6 +296,7 @@ const PipelineNodeParameter = forwardRef(({ onFormChange }: PipelineNodeParamete | |||||
| maxWidth: 600, | maxWidth: 600, | ||||
| }} | }} | ||||
| autoComplete="off" | autoComplete="off" | ||||
| scrollToFirstError | |||||
| > | > | ||||
| <div className={styles['pipeline-drawer__title']}> | <div className={styles['pipeline-drawer__title']}> | ||||
| <SubAreaTitle image="/assets/images/static-message.png" title="基本信息"></SubAreaTitle> | <SubAreaTitle image="/assets/images/static-message.png" title="基本信息"></SubAreaTitle> | ||||
| @@ -28,9 +28,10 @@ export function deleteExperimentById(id) { | |||||
| }); | }); | ||||
| } | } | ||||
| // 根据id查询实验实例 | // 根据id查询实验实例 | ||||
| export function getQueryByExperimentId(id) { | |||||
| return request(`/api/mmp/experimentIns/queryByExperimentId/${id}`, { | |||||
| export function getQueryByExperimentId(params) { | |||||
| return request(`/api/mmp/experimentIns`, { | |||||
| method: 'GET', | method: 'GET', | ||||
| params, | |||||
| }); | }); | ||||
| } | } | ||||
| // 根据id删除实验实例 | // 根据id删除实验实例 | ||||
| @@ -4,7 +4,7 @@ | |||||
| * @Description: 定义全局类型,比如无关联的页面都需要要的类型 | * @Description: 定义全局类型,比如无关联的页面都需要要的类型 | ||||
| */ | */ | ||||
| import { ExperimentStatus } from '@/enums'; | |||||
| import { ExperimentStatus, TensorBoardStatus } from '@/enums'; | |||||
| // 流水线全局参数 | // 流水线全局参数 | ||||
| export type PipelineGlobalParam = { | export type PipelineGlobalParam = { | ||||
| @@ -26,9 +26,13 @@ export type ExperimentInstance = { | |||||
| status: string; | status: string; | ||||
| argo_ins_name: string; | argo_ins_name: string; | ||||
| argo_ins_ns: string; | argo_ins_ns: string; | ||||
| nodes_result: string; | |||||
| nodes_result: { | |||||
| [key: string]: any; | |||||
| }; | |||||
| nodes_status: string; | nodes_status: string; | ||||
| global_param: PipelineGlobalParam[]; | global_param: PipelineGlobalParam[]; | ||||
| tensorBoardStatus?: TensorBoardStatus; | |||||
| tensorboardUrl?: string; | |||||
| }; | }; | ||||
| // 流水线节点 | // 流水线节点 | ||||
| @@ -2,12 +2,12 @@ | |||||
| * @param { Promise } promise | * @param { Promise } promise | ||||
| * @return { Promise } | * @return { Promise } | ||||
| */ | */ | ||||
| export async function to<T>(promise: Promise<T>): Promise<[T, null] | [null, Error]> { | |||||
| export async function to<T, U = Error>(promise: Promise<T>): Promise<[T, null] | [null, U]> { | |||||
| try { | try { | ||||
| const data = await promise; | const data = await promise; | ||||
| return [data, null]; | return [data, null]; | ||||
| } catch (error) { | } catch (error) { | ||||
| return [null, error as Error]; | |||||
| return [null, error as U]; | |||||
| } | } | ||||
| } | } | ||||