| @@ -245,6 +245,9 @@ export const antd: RuntimeAntdConfig = (memo) => { | |||
| linkColor: 'rgba(29, 29, 32, 0.7)', | |||
| separatorColor: 'rgba(29, 29, 32, 0.7)', | |||
| }; | |||
| memo.theme.components.Tree = { | |||
| directoryNodeSelectedBg: 'rgba(22, 100, 255, 0.7)', | |||
| }; | |||
| memo.theme.cssVar = true; | |||
| // memo.theme.hashed = false; | |||
| @@ -24,21 +24,15 @@ export type BasicInfoProps = { | |||
| /** | |||
| * 基础信息展示组件,用于展示基础信息,支持一行两列或一行三列,支持数据格式化 | |||
| * | |||
| * ### usage | |||
| * ```tsx | |||
| * import { BasicInfo } from '@/components/BasicInfo'; | |||
| * <BasicInfo datas={datas} labelWidth={80} /> | |||
| * ``` | |||
| */ | |||
| export default function BasicInfo({ | |||
| datas, | |||
| className, | |||
| style, | |||
| labelWidth, | |||
| labelEllipsis = true, | |||
| threeColumns = false, | |||
| labelAlign = 'start', | |||
| threeColumns = false, | |||
| className, | |||
| style, | |||
| }: BasicInfoProps) { | |||
| return ( | |||
| <div | |||
| @@ -5,16 +5,18 @@ import { type BasicInfoData, type BasicInfoLink } from '../BasicInfo/types'; | |||
| import './index.less'; | |||
| export type { BasicInfoData, BasicInfoLink }; | |||
| export type BasicTableInfoProps = Omit<BasicInfoProps, 'labelAlign' | 'threeColumns'>; | |||
| /** | |||
| * 表格基础信息展示组件,用于展示基础信息,一行四列,支持数据格式化 | |||
| */ | |||
| export default function BasicTableInfo({ | |||
| datas, | |||
| className, | |||
| style, | |||
| labelWidth, | |||
| labelEllipsis, | |||
| }: BasicInfoProps) { | |||
| className, | |||
| style, | |||
| }: BasicTableInfoProps) { | |||
| const remainder = datas.length % 4; | |||
| const array = []; | |||
| if (remainder > 0) { | |||
| @@ -1,19 +1,21 @@ | |||
| import { formatEnum } from '@/utils/format'; | |||
| import { Typography } from 'antd'; | |||
| import { Typography, type SelectProps } from 'antd'; | |||
| import classNames from 'classnames'; | |||
| import './index.less'; | |||
| type FormInfoProps = { | |||
| /** 自定义类名 */ | |||
| /** 值 */ | |||
| value?: any; | |||
| /** 如果 `value` 是对象时,取对象的哪个属性作为值 */ | |||
| /** 如果 `value` 是对象,取对象的哪个属性作为值 */ | |||
| valuePropName?: string; | |||
| /** 是否是多行文本 */ | |||
| textArea?: boolean; | |||
| /** 是否是下拉框 */ | |||
| select?: boolean; | |||
| /** 下拉框数据 */ | |||
| options?: { label: string; value: any }[]; | |||
| options?: SelectProps['options']; | |||
| /** 自定义节点 label、value 的字段 */ | |||
| fieldNames?: SelectProps['fieldNames']; | |||
| /** 自定义类名 */ | |||
| className?: string; | |||
| /** 自定义样式 */ | |||
| @@ -26,17 +28,29 @@ type FormInfoProps = { | |||
| function FormInfo({ | |||
| value, | |||
| valuePropName, | |||
| className, | |||
| select, | |||
| textArea = false, | |||
| select = false, | |||
| options, | |||
| fieldNames, | |||
| className, | |||
| style, | |||
| textArea = false, | |||
| }: FormInfoProps) { | |||
| let data = value; | |||
| let showValue = value; | |||
| if (value && typeof value === 'object' && valuePropName) { | |||
| data = value[valuePropName]; | |||
| showValue = value[valuePropName]; | |||
| } else if (select === true && options) { | |||
| data = formatEnum(options)(value); | |||
| let _options: SelectProps['options'] = options; | |||
| if (fieldNames) { | |||
| _options = options.map((v) => { | |||
| return { | |||
| ...v, | |||
| label: fieldNames.label && v[fieldNames.label], | |||
| value: fieldNames.value && v[fieldNames.value], | |||
| options: fieldNames.options && v[fieldNames.options], | |||
| }; | |||
| }); | |||
| } | |||
| showValue = formatEnum(_options)(value); | |||
| } | |||
| return ( | |||
| @@ -50,8 +64,8 @@ function FormInfo({ | |||
| )} | |||
| style={style} | |||
| > | |||
| <Typography.Paragraph ellipsis={textArea ? false : { tooltip: data }}> | |||
| {data} | |||
| <Typography.Paragraph ellipsis={textArea ? false : { tooltip: showValue }}> | |||
| {showValue} | |||
| </Typography.Paragraph> | |||
| </div> | |||
| ); | |||
| @@ -4,33 +4,53 @@ | |||
| * @Description: 参数下拉选择组件,支持资源规格、数据集、模型、服务 | |||
| */ | |||
| import { PipelineNodeModelParameter } from '@/types'; | |||
| import { to } from '@/utils/promise'; | |||
| import { Select } from 'antd'; | |||
| import { Select, type SelectProps } from 'antd'; | |||
| import { useEffect, useState } from 'react'; | |||
| import FormInfo from '../FormInfo'; | |||
| import { paramSelectConfig } from './config'; | |||
| type ParameterSelectProps = { | |||
| value?: PipelineNodeModelParameter; | |||
| onChange?: (value: PipelineNodeModelParameter) => void; | |||
| disabled?: boolean; | |||
| export type ParameterSelectObject = { | |||
| value: any; | |||
| [key: string]: any; | |||
| }; | |||
| function ParameterSelect({ value, onChange, disabled = false }: ParameterSelectProps) { | |||
| export interface ParameterSelectProps extends SelectProps { | |||
| /** 类型 */ | |||
| dataType: 'dataset' | 'model' | 'service' | 'resource'; | |||
| /** 是否只是展示信息 */ | |||
| display?: boolean; | |||
| /** 值 */ | |||
| value?: string | ParameterSelectObject; | |||
| /** 修改后回调 */ | |||
| onChange?: (value: string | ParameterSelectObject) => void; | |||
| } | |||
| /** 参数选择器,支持资源规格、数据集、模型、服务 */ | |||
| function ParameterSelect({ | |||
| dataType, | |||
| display = false, | |||
| value, | |||
| onChange, | |||
| ...rest | |||
| }: ParameterSelectProps) { | |||
| const [options, setOptions] = useState([]); | |||
| const valueNonNullable = value ?? ({} as PipelineNodeModelParameter); | |||
| const { item_type } = valueNonNullable; | |||
| const propsConfig = paramSelectConfig[item_type]; | |||
| const propsConfig = paramSelectConfig[dataType]; | |||
| const valueText = typeof value === 'object' && value !== null ? value.value : value; | |||
| useEffect(() => { | |||
| getSelectOptions(); | |||
| }, []); | |||
| const hangleChange = (e: string) => { | |||
| onChange?.({ | |||
| ...valueNonNullable, | |||
| value: e, | |||
| }); | |||
| const handleChange = (text: string) => { | |||
| if (typeof value === 'object' && value !== null) { | |||
| onChange?.({ | |||
| ...value, | |||
| value: text, | |||
| }); | |||
| } else { | |||
| onChange?.(text); | |||
| } | |||
| }; | |||
| // 获取下拉数据 | |||
| @@ -45,16 +65,26 @@ function ParameterSelect({ value, onChange, disabled = false }: ParameterSelectP | |||
| } | |||
| }; | |||
| if (display) { | |||
| return ( | |||
| <FormInfo | |||
| select | |||
| value={valueText} | |||
| options={options} | |||
| fieldNames={propsConfig?.fieldNames} | |||
| ></FormInfo> | |||
| ); | |||
| } | |||
| return ( | |||
| <Select | |||
| placeholder={valueNonNullable.placeholder} | |||
| {...rest} | |||
| filterOption={propsConfig?.filterOption} | |||
| options={options} | |||
| fieldNames={propsConfig?.fieldNames} | |||
| value={valueNonNullable.value} | |||
| optionFilterProp={propsConfig.optionFilterProp} | |||
| onChange={hangleChange} | |||
| disabled={disabled} | |||
| optionFilterProp={propsConfig?.optionFilterProp} | |||
| value={valueText} | |||
| onChange={handleChange} | |||
| showSearch | |||
| allowClear | |||
| /> | |||
| @@ -129,3 +129,23 @@ export const hyperParameterOptimizedModeOptions = [ | |||
| { label: '越大越好', value: hyperParameterOptimizedMode.Max }, | |||
| { label: '越小越好', value: hyperParameterOptimizedMode.Min }, | |||
| ]; | |||
| // 超参数 Trail 运行状态 | |||
| export enum HyperParameterTrailStatus { | |||
| PENDING = 'PENDING', // 挂起 | |||
| RUNNING = 'RUNNING', // 运行中 | |||
| TERMINATED = 'TERMINATED', // 成功 | |||
| ERROR = 'ERROR', // 错误 | |||
| PAUSED = 'PAUSED', // 暂停 | |||
| RESTORING = 'RESTORING', // 恢复中 | |||
| } | |||
| // 自动 Trail 运行状态 | |||
| export enum AutoMLTrailStatus { | |||
| TIMEOUT = 'TIMEOUT', // 超时 | |||
| SUCCESS = 'SUCCESS', // 成功 | |||
| FAILURE = 'FAILURE', // 失败 | |||
| CRASHED = 'CRASHED', // 崩溃 | |||
| STOP = 'STOP', // 停止 | |||
| CANCELLED = 'CANCELLED', // 取消 | |||
| } | |||
| @@ -22,6 +22,8 @@ enum TabKeys { | |||
| History = 'history', | |||
| } | |||
| const NodePrefix = 'auto-ml'; | |||
| function AutoMLInstance() { | |||
| const [activeTab, setActiveTab] = useState<string>(TabKeys.Params); | |||
| const [autoMLInfo, setAutoMLInfo] = useState<AutoMLData | undefined>(undefined); | |||
| @@ -66,7 +68,7 @@ function AutoMLInstance() { | |||
| const nodeStatusJson = parseJsonText(node_status); | |||
| if (nodeStatusJson) { | |||
| Object.keys(nodeStatusJson).forEach((key) => { | |||
| if (key.startsWith('auto-ml')) { | |||
| if (key.startsWith(NodePrefix)) { | |||
| const value = nodeStatusJson[key]; | |||
| info.nodeStatus = value; | |||
| } | |||
| @@ -100,7 +102,7 @@ function AutoMLInstance() { | |||
| const nodes = dataJson?.result?.object?.status?.nodes; | |||
| if (nodes) { | |||
| const statusData = Object.values(nodes).find((node: any) => | |||
| node.displayName.startsWith('auto-ml'), | |||
| node.displayName.startsWith(NodePrefix), | |||
| ) as NodeStatus; | |||
| if (statusData) { | |||
| setInstanceInfo((prev) => ({ | |||
| @@ -184,7 +186,7 @@ function AutoMLInstance() { | |||
| }, | |||
| { | |||
| key: TabKeys.History, | |||
| label: 'Trial 列表', | |||
| label: '试验列表', | |||
| icon: <KFIcon type="icon-Trialliebiao" />, | |||
| children: ( | |||
| <ExperimentHistory | |||
| @@ -4,6 +4,7 @@ import tableCellRender from '@/utils/table'; | |||
| import { Table, type TableProps } from 'antd'; | |||
| import classNames from 'classnames'; | |||
| import { useEffect, useState } from 'react'; | |||
| import TrialStatusCell from '../TrialStatusCell'; | |||
| import styles from './index.less'; | |||
| type ExperimentHistoryProps = { | |||
| @@ -103,7 +104,7 @@ function ExperimentHistory({ fileUrl, isClassification }: ExperimentHistoryProps | |||
| dataIndex: 'status', | |||
| key: 'status', | |||
| width: 120, | |||
| render: tableCellRender(false), | |||
| render: TrialStatusCell, | |||
| }, | |||
| ]; | |||
| @@ -25,7 +25,8 @@ | |||
| } | |||
| &__text { | |||
| white-space: pre-wrap; | |||
| font-family: 'Roboto Mono', 'Menlo', 'Consolas', 'Monaco', monospace; | |||
| white-space: pre; | |||
| } | |||
| &__images { | |||
| @@ -0,0 +1,3 @@ | |||
| .trial-status-cell { | |||
| height: 100%; | |||
| } | |||
| @@ -0,0 +1,67 @@ | |||
| /* | |||
| * @Author: 赵伟 | |||
| * @Date: 2024-04-18 18:35:41 | |||
| * @Description: 实验状态 | |||
| */ | |||
| import { AutoMLTrailStatus } from '@/enums'; | |||
| import { ExperimentStatusInfo } from '@/pages/Experiment/status'; | |||
| import themes from '@/styles/theme.less'; | |||
| import styles from './index.less'; | |||
| export const statusInfo: Record<AutoMLTrailStatus, ExperimentStatusInfo> = { | |||
| [AutoMLTrailStatus.SUCCESS]: { | |||
| label: '成功', | |||
| color: themes.successColor, | |||
| icon: '/assets/images/experiment-status/success-icon.png', | |||
| }, | |||
| [AutoMLTrailStatus.TIMEOUT]: { | |||
| label: '超时', | |||
| color: themes.pendingColor, | |||
| icon: '/assets/images/experiment-status/pending-icon.png', | |||
| }, | |||
| [AutoMLTrailStatus.FAILURE]: { | |||
| label: '失败', | |||
| color: themes.errorColor, | |||
| icon: '/assets/images/experiment-status/fail-icon.png', | |||
| }, | |||
| [AutoMLTrailStatus.CRASHED]: { | |||
| label: '崩溃', | |||
| color: themes.errorColor, | |||
| icon: '/assets/images/experiment-status/fail-icon.png', | |||
| }, | |||
| [AutoMLTrailStatus.CANCELLED]: { | |||
| label: '取消', | |||
| color: themes.abortColor, | |||
| icon: '/assets/images/experiment-status/omitted-icon.png', | |||
| }, | |||
| [AutoMLTrailStatus.STOP]: { | |||
| label: '停止', | |||
| color: themes.textColor, | |||
| icon: '/assets/images/experiment-status/omitted-icon.png', | |||
| }, | |||
| }; | |||
| function TrialStatusCell(status?: AutoMLTrailStatus | null) { | |||
| if (status === null || status === undefined) { | |||
| return <span>--</span>; | |||
| } | |||
| return ( | |||
| <div className={styles['trial-status-cell']}> | |||
| {/* <img | |||
| style={{ width: '17px', marginRight: '7px' }} | |||
| src={statusInfo[status]?.icon} | |||
| draggable={false} | |||
| alt="" | |||
| /> */} | |||
| <span | |||
| style={{ color: statusInfo[status] ? statusInfo[status].color : themes.textColor }} | |||
| className={styles['trial-status-cell__label']} | |||
| > | |||
| {statusInfo[status] ? statusInfo[status].label : status} | |||
| </span> | |||
| </div> | |||
| ); | |||
| } | |||
| export default TrialStatusCell; | |||
| @@ -18,26 +18,6 @@ | |||
| background-color: white; | |||
| border-radius: 10px; | |||
| &__footer { | |||
| display: flex; | |||
| align-items: center; | |||
| padding-top: 20px; | |||
| color: @text-color-secondary; | |||
| font-size: 12px; | |||
| background-color: white; | |||
| div { | |||
| flex: 1; | |||
| height: 1px; | |||
| background-color: @border-color; | |||
| } | |||
| p { | |||
| flex: none; | |||
| margin: 0 8px; | |||
| } | |||
| } | |||
| :global { | |||
| .ant-table-container { | |||
| border: none !important; | |||
| @@ -77,7 +77,7 @@ function ExperimentComparison() { | |||
| }; | |||
| // 对比按钮 click | |||
| const hanldeComparisonClick = () => { | |||
| const handleComparisonClick = () => { | |||
| if (selectedRowKeys.length < 2) { | |||
| message.error('请至少选择两项进行对比'); | |||
| return; | |||
| @@ -202,7 +202,7 @@ function ExperimentComparison() { | |||
| return ( | |||
| <div className={styles['experiment-comparison']}> | |||
| <div className={styles['experiment-comparison__header']}> | |||
| <Button type="default" onClick={hanldeComparisonClick}> | |||
| <Button type="default" onClick={handleComparisonClick}> | |||
| 可视化对比 | |||
| </Button> | |||
| </div> | |||
| @@ -3,7 +3,7 @@ import ParameterSelect from '@/components/ParameterSelect'; | |||
| import SubAreaTitle from '@/components/SubAreaTitle'; | |||
| import { useComputingResource } from '@/hooks/resource'; | |||
| import { PipelineNodeModelSerialize } from '@/types'; | |||
| import { Form, Select } from 'antd'; | |||
| import { Form } from 'antd'; | |||
| import styles from './index.less'; | |||
| type ExperimentParameterProps = { | |||
| @@ -100,7 +100,7 @@ function ExperimentParameter({ nodeData }: ExperimentParameterProps) { | |||
| </Form.Item> | |||
| <Form.Item label="启动命令" name="command"> | |||
| <FormInfo multiline /> | |||
| <FormInfo textArea /> | |||
| </Form.Item> | |||
| <Form.Item | |||
| label="资源规格" | |||
| @@ -112,9 +112,9 @@ function ExperimentParameter({ nodeData }: ExperimentParameterProps) { | |||
| }, | |||
| ]} | |||
| > | |||
| <Select | |||
| <FormInfo | |||
| select | |||
| options={resourceStandardList} | |||
| disabled | |||
| fieldNames={{ | |||
| label: 'description', | |||
| value: 'standard', | |||
| @@ -125,7 +125,7 @@ function ExperimentParameter({ nodeData }: ExperimentParameterProps) { | |||
| <FormInfo /> | |||
| </Form.Item> | |||
| <Form.Item label="环境变量" name="env_variables"> | |||
| <FormInfo multiline /> | |||
| <FormInfo textArea /> | |||
| </Form.Item> | |||
| {controlStrategyList.map((item) => ( | |||
| <Form.Item key={item.key} name={['control_strategy', item.key]} label={item.value.label}> | |||
| @@ -146,7 +146,9 @@ function ExperimentParameter({ nodeData }: ExperimentParameterProps) { | |||
| rules={[{ required: item.value.require ? true : false }]} | |||
| > | |||
| {item.value.type === 'select' ? ( | |||
| <ParameterSelect disabled /> | |||
| ['dataset', 'model', 'service', 'resource'].includes(item.value.item_type) ? ( | |||
| <ParameterSelect dataType={item.value.item_type as any} display /> | |||
| ) : null | |||
| ) : ( | |||
| <FormInfo valuePropName="showValue" /> | |||
| )} | |||
| @@ -20,7 +20,8 @@ | |||
| padding: 15px; | |||
| color: white; | |||
| font-size: 14px; | |||
| white-space: pre-line; | |||
| font-family: 'Roboto Mono', 'Menlo', 'Consolas', 'Monaco', monospace; | |||
| white-space: pre-wrap; | |||
| text-align: left; | |||
| word-break: break-all; | |||
| background: #19253b; | |||
| @@ -25,19 +25,6 @@ type Log = { | |||
| pod_name: string; // pod名称 | |||
| }; | |||
| // 滚动到底部 | |||
| const scrollToBottom = (smooth: boolean = true) => { | |||
| const element = document.getElementById('log-list'); | |||
| if (element) { | |||
| const optons: ScrollToOptions = { | |||
| top: element.scrollHeight, | |||
| behavior: smooth ? 'smooth' : 'instant', | |||
| }; | |||
| element.scrollTo(optons); | |||
| } | |||
| }; | |||
| function LogGroup({ | |||
| log_type = 'normal', | |||
| pod_name = '', | |||
| @@ -53,6 +40,7 @@ function LogGroup({ | |||
| const preStatusRef = useRef<ExperimentStatus | undefined>(undefined); | |||
| const socketRef = useRef<WebSocket | undefined>(undefined); | |||
| const retryRef = useRef(2); // 等待 2 秒,重试 3 次 | |||
| const elementRef = useRef<HTMLDivElement | null>(null); | |||
| useEffect(() => { | |||
| scrollToBottom(false); | |||
| @@ -161,7 +149,7 @@ function LogGroup({ | |||
| }); | |||
| socket.addEventListener('message', (event) => { | |||
| console.log('message received.', event); | |||
| // console.log('message received.', event); | |||
| if (!event.data) { | |||
| return; | |||
| } | |||
| @@ -210,12 +198,25 @@ function LogGroup({ | |||
| } | |||
| }; | |||
| // 滚动到底部 | |||
| const scrollToBottom = (smooth: boolean = true) => { | |||
| // const element = document.getElementById(listId); | |||
| // if (element) { | |||
| // const optons: ScrollToOptions = { | |||
| // top: element.scrollHeight, | |||
| // behavior: smooth ? 'smooth' : 'instant', | |||
| // }; | |||
| // element.scrollTo(optons); | |||
| // } | |||
| elementRef?.current?.scrollIntoView({ block: 'end', behavior: smooth ? 'smooth' : 'instant' }); | |||
| }; | |||
| const showLog = (log_type === 'resource' && !collapse) || log_type === 'normal'; | |||
| const logText = log_content + logList.map((v) => v.log_content).join(''); | |||
| const showMoreBtn = | |||
| status !== ExperimentStatus.Running && showLog && !completed && logText !== ''; | |||
| return ( | |||
| <div className={styles['log-group']}> | |||
| <div className={styles['log-group']} ref={elementRef}> | |||
| {log_type === 'resource' && ( | |||
| <div className={styles['log-group__pod']} onClick={handleCollapse}> | |||
| <div className={styles['log-group__pod__name']}>{pod_name}</div> | |||
| @@ -1,8 +1,9 @@ | |||
| import { ExperimentStatus } from '@/enums'; | |||
| import { getQueryByExperimentLog } from '@/services/experiment/index.js'; | |||
| import { to } from '@/utils/promise'; | |||
| import classNames from 'classnames'; | |||
| import dayjs from 'dayjs'; | |||
| import { useEffect, useRef, useState } from 'react'; | |||
| import React, { useEffect, useRef, useState } from 'react'; | |||
| import LogGroup from '../LogGroup'; | |||
| import styles from './index.less'; | |||
| @@ -20,6 +21,8 @@ type LogListProps = { | |||
| workflowId?: string; // 实验实例工作流 id | |||
| instanceNodeStartTime?: string; // 实验实例节点开始运行时间 | |||
| instanceNodeStatus?: ExperimentStatus; | |||
| className?: string; | |||
| style?: React.CSSProperties; | |||
| }; | |||
| function LogList({ | |||
| @@ -29,6 +32,8 @@ function LogList({ | |||
| workflowId, | |||
| instanceNodeStartTime, | |||
| instanceNodeStatus, | |||
| className, | |||
| style, | |||
| }: LogListProps) { | |||
| const [logList, setLogList] = useState<ExperimentLog[]>([]); | |||
| const preStatusRef = useRef<ExperimentStatus | undefined>(undefined); | |||
| @@ -87,7 +92,7 @@ function LogList({ | |||
| }; | |||
| return ( | |||
| <div className={styles['log-list']} id="log-list"> | |||
| <div className={classNames(styles['log-list'], className)} id="log-list" style={style}> | |||
| {logList.length > 0 ? ( | |||
| logList.map((v) => <LogGroup key={v.pod_name} {...v} status={instanceNodeStatus} />) | |||
| ) : ( | |||
| @@ -1,4 +1,4 @@ | |||
| .create-hyperparameter { | |||
| .create-hyper-parameter { | |||
| height: 100%; | |||
| &__content { | |||
| @@ -11,11 +11,6 @@ | |||
| background-color: white; | |||
| border-radius: 10px; | |||
| &__type { | |||
| color: @text-color; | |||
| font-size: @font-size-input-lg; | |||
| } | |||
| :global { | |||
| .ant-input-number { | |||
| width: 100%; | |||
| @@ -118,9 +118,9 @@ function CreateHyperParameter() { | |||
| } | |||
| return ( | |||
| <div className={styles['create-hyperparameter']}> | |||
| <div className={styles['create-hyper-parameter']}> | |||
| <PageTitle title={title}></PageTitle> | |||
| <div className={styles['create-hyperparameter__content']}> | |||
| <div className={styles['create-hyper-parameter__content']}> | |||
| <div> | |||
| <Form | |||
| name="create-hyperparameter" | |||
| @@ -1,4 +1,4 @@ | |||
| .auto-ml-info { | |||
| .hyper-parameter-info { | |||
| position: relative; | |||
| height: 100%; | |||
| &__tabs { | |||
| @@ -35,9 +35,9 @@ function HyperparameterInfo() { | |||
| }; | |||
| return ( | |||
| <div className={styles['auto-ml-info']}> | |||
| <div className={styles['hyper-parameter-info']}> | |||
| <PageTitle title="实验详情"></PageTitle> | |||
| <div className={styles['auto-ml-info__content']}> | |||
| <div className={styles['hyper-parameter-info__content']}> | |||
| <HyperParameterBasic info={hyperparameterInfo} /> | |||
| </div> | |||
| </div> | |||
| @@ -1,4 +1,4 @@ | |||
| .auto-ml-instance { | |||
| .hyper-parameter-instance { | |||
| height: 100%; | |||
| &__tabs { | |||
| @@ -34,7 +34,7 @@ | |||
| &__log { | |||
| height: calc(100% - 10px); | |||
| margin-top: 10px; | |||
| padding: 20px calc(@content-padding - 8px); | |||
| padding: 8px calc(@content-padding - 8px) 20px; | |||
| overflow-y: visible; | |||
| background-color: white; | |||
| border-radius: 10px; | |||
| @@ -1,6 +1,5 @@ | |||
| import KFIcon from '@/components/KFIcon'; | |||
| import { AutoMLTaskType, ExperimentStatus } from '@/enums'; | |||
| import LogList from '@/pages/Experiment/components/LogList'; | |||
| import { ExperimentStatus } from '@/enums'; | |||
| import { getRayInsReq } from '@/services/hyperParameter'; | |||
| import { NodeStatus } from '@/types'; | |||
| import { parseJsonText } from '@/utils'; | |||
| @@ -10,9 +9,10 @@ import { useParams } from '@umijs/max'; | |||
| import { Tabs } from 'antd'; | |||
| import { useEffect, useRef, useState } from 'react'; | |||
| import ExperimentHistory from '../components/ExperimentHistory'; | |||
| import ExperimentLog from '../components/ExperimentLog'; | |||
| import ExperimentResult from '../components/ExperimentResult'; | |||
| import HyperParameterBasic from '../components/HyperParameterBasic'; | |||
| import { AutoMLInstanceData, HyperParameterData } from '../types'; | |||
| import { HyperParameterData, HyperParameterInstanceData } from '../types'; | |||
| import styles from './index.less'; | |||
| enum TabKeys { | |||
| @@ -25,7 +25,12 @@ enum TabKeys { | |||
| function HyperParameterInstance() { | |||
| const [activeTab, setActiveTab] = useState<string>(TabKeys.Params); | |||
| const [experimentInfo, setExperimentInfo] = useState<HyperParameterData | undefined>(undefined); | |||
| const [instanceInfo, setInstanceInfo] = useState<AutoMLInstanceData | undefined>(undefined); | |||
| const [instanceInfo, setInstanceInfo] = useState<HyperParameterInstanceData | undefined>( | |||
| undefined, | |||
| ); | |||
| // 超参数寻优运行有3个节点,运行状态取工作流状态,而不是 auto-hpo 节点状态 | |||
| const [workflowStatus, setWorkflowStatus] = useState<NodeStatus | undefined>(undefined); | |||
| const [nodes, setNodes] = useState<Record<string, NodeStatus> | undefined>(undefined); | |||
| const params = useParams(); | |||
| const instanceId = safeInvoke(Number)(params.id); | |||
| const evtSourceRef = useRef<EventSource | null>(null); | |||
| @@ -43,35 +48,51 @@ function HyperParameterInstance() { | |||
| const getExperimentInsInfo = async (isStatusDetermined: boolean) => { | |||
| const [res] = await to(getRayInsReq(instanceId)); | |||
| if (res && res.data) { | |||
| const info = res.data as AutoMLInstanceData; | |||
| const info = res.data as HyperParameterInstanceData; | |||
| const { param, node_status, argo_ins_name, argo_ins_ns, status } = info; | |||
| // 解析配置参数 | |||
| const paramJson = parseJsonText(param); | |||
| if (paramJson) { | |||
| // 实例详情返回的参数是字符串,需要转换 | |||
| if (typeof paramJson.parameters === 'string') { | |||
| paramJson.parameters = parseJsonText(paramJson.parameters); | |||
| } | |||
| if (!Array.isArray(paramJson.parameters)) { | |||
| paramJson.parameters = []; | |||
| } | |||
| // 实例详情返回的运行参数是字符串,需要转换 | |||
| if (typeof paramJson.points_to_evaluate === 'string') { | |||
| paramJson.points_to_evaluate = parseJsonText(paramJson.points_to_evaluate); | |||
| } | |||
| if (!Array.isArray(paramJson.points_to_evaluate)) { | |||
| paramJson.points_to_evaluate = []; | |||
| } | |||
| setExperimentInfo(paramJson); | |||
| } | |||
| setInstanceInfo(info); | |||
| // 这个接口返回的状态有延时,SSE 返回的状态是最新的 | |||
| // SSE 调用时,不需要解析 node_status, 也不要重新建立 SSE | |||
| // SSE 调用时,不需要解析 node_status,也不要重新建立 SSE | |||
| if (isStatusDetermined) { | |||
| setInstanceInfo((prev) => ({ | |||
| ...info, | |||
| nodeStatus: prev!.nodeStatus, | |||
| })); | |||
| return; | |||
| } | |||
| // 进行节点状态 | |||
| const nodeStatusJson = parseJsonText(node_status); | |||
| if (nodeStatusJson) { | |||
| Object.keys(nodeStatusJson).forEach((key) => { | |||
| if (key.startsWith('auto-ml')) { | |||
| const value = nodeStatusJson[key]; | |||
| info.nodeStatus = value; | |||
| setNodes(nodeStatusJson); | |||
| Object.keys(nodeStatusJson).some((key) => { | |||
| if (key.startsWith('workflow')) { | |||
| const workflowStatus = nodeStatusJson[key]; | |||
| setWorkflowStatus(workflowStatus); | |||
| return true; | |||
| } | |||
| return false; | |||
| }); | |||
| } | |||
| setInstanceInfo(info); | |||
| // 运行中或者等待中,开启 SSE | |||
| if (status === ExperimentStatus.Pending || status === ExperimentStatus.Running) { | |||
| setupSSE(argo_ins_name, argo_ins_ns); | |||
| @@ -81,9 +102,9 @@ function HyperParameterInstance() { | |||
| const setupSSE = (name: string, namespace: string) => { | |||
| let { origin } = location; | |||
| if (process.env.NODE_ENV === 'development') { | |||
| origin = 'http://172.20.32.197:31213'; | |||
| } | |||
| // if (process.env.NODE_ENV === 'development') { | |||
| // origin = 'http://172.20.32.197:31213'; | |||
| // } | |||
| const params = encodeURIComponent(`metadata.namespace=${namespace},metadata.name=${name}`); | |||
| const evtSource = new EventSource( | |||
| `${origin}/api/v1/realtimeStatus?listOptions.fieldSelector=${params}`, | |||
| @@ -98,19 +119,21 @@ function HyperParameterInstance() { | |||
| if (dataJson) { | |||
| const nodes = dataJson?.result?.object?.status?.nodes; | |||
| if (nodes) { | |||
| const statusData = Object.values(nodes).find((node: any) => | |||
| node.displayName.startsWith('auto-ml'), | |||
| const workflowStatus = Object.values(nodes).find((node: any) => | |||
| node.displayName.startsWith('workflow'), | |||
| ) as NodeStatus; | |||
| if (statusData) { | |||
| setInstanceInfo((prev) => ({ | |||
| ...prev!, | |||
| nodeStatus: statusData, | |||
| })); | |||
| // 节点 | |||
| setNodes(nodes); | |||
| // 设置工作流状态 | |||
| if (workflowStatus) { | |||
| setWorkflowStatus(workflowStatus); | |||
| // 实验结束,关闭 SSE | |||
| if ( | |||
| statusData.phase !== ExperimentStatus.Pending && | |||
| statusData.phase !== ExperimentStatus.Running | |||
| workflowStatus.phase !== ExperimentStatus.Pending && | |||
| workflowStatus.phase !== ExperimentStatus.Running | |||
| ) { | |||
| closeSSE(); | |||
| getExperimentInsInfo(true); | |||
| @@ -140,9 +163,9 @@ function HyperParameterInstance() { | |||
| icon: <KFIcon type="icon-jibenxinxi" />, | |||
| children: ( | |||
| <HyperParameterBasic | |||
| className={styles['auto-ml-instance__basic']} | |||
| className={styles['hyper-parameter-instance__basic']} | |||
| info={experimentInfo} | |||
| runStatus={instanceInfo?.nodeStatus} | |||
| runStatus={workflowStatus} | |||
| isInstance | |||
| /> | |||
| ), | |||
| @@ -152,17 +175,8 @@ function HyperParameterInstance() { | |||
| label: '日志', | |||
| icon: <KFIcon type="icon-rizhi1" />, | |||
| children: ( | |||
| <div className={styles['auto-ml-instance__log']}> | |||
| {instanceInfo && instanceInfo.nodeStatus && ( | |||
| <LogList | |||
| instanceName={instanceInfo.argo_ins_name} | |||
| instanceNamespace={instanceInfo.argo_ins_ns} | |||
| pipelineNodeId={instanceInfo.nodeStatus.displayName} | |||
| workflowId={instanceInfo.nodeStatus.id} | |||
| instanceNodeStartTime={instanceInfo.nodeStatus.startedAt} | |||
| instanceNodeStatus={instanceInfo.nodeStatus.phase as ExperimentStatus} | |||
| ></LogList> | |||
| )} | |||
| <div className={styles['hyper-parameter-instance__log']}> | |||
| {instanceInfo && nodes && <ExperimentLog instanceInfo={instanceInfo} nodes={nodes} />} | |||
| </div> | |||
| ), | |||
| }, | |||
| @@ -173,24 +187,13 @@ function HyperParameterInstance() { | |||
| key: TabKeys.Result, | |||
| label: '实验结果', | |||
| icon: <KFIcon type="icon-shiyanjieguo1" />, | |||
| children: ( | |||
| <ExperimentResult | |||
| fileUrl={instanceInfo?.result_path} | |||
| imageUrl={instanceInfo?.img_path} | |||
| modelPath={instanceInfo?.model_path} | |||
| /> | |||
| ), | |||
| children: <ExperimentResult fileUrl={instanceInfo?.result_txt} />, | |||
| }, | |||
| { | |||
| key: TabKeys.History, | |||
| label: 'Trial 列表', | |||
| label: '寻优列表', | |||
| icon: <KFIcon type="icon-Trialliebiao" />, | |||
| children: ( | |||
| <ExperimentHistory | |||
| fileUrl={instanceInfo?.run_history_path} | |||
| isClassification={experimentInfo?.task_type === AutoMLTaskType.Classification} | |||
| /> | |||
| ), | |||
| children: <ExperimentHistory trialList={instanceInfo?.trial_list} />, | |||
| }, | |||
| ]; | |||
| @@ -200,9 +203,9 @@ function HyperParameterInstance() { | |||
| : basicTabItems; | |||
| return ( | |||
| <div className={styles['auto-ml-instance']}> | |||
| <div className={styles['hyper-parameter-instance']}> | |||
| <Tabs | |||
| className={styles['auto-ml-instance__tabs']} | |||
| className={styles['hyper-parameter-instance__tabs']} | |||
| items={tabItems} | |||
| activeKey={activeTab} | |||
| onChange={setActiveTab} | |||
| @@ -8,7 +8,66 @@ | |||
| border-radius: 10px; | |||
| &__table { | |||
| height: 100%; | |||
| height: calc(100% - 52px); | |||
| margin-top: 20px; | |||
| } | |||
| :global { | |||
| .ant-table-container { | |||
| border: none !important; | |||
| } | |||
| .ant-table-thead { | |||
| .ant-table-cell { | |||
| background-color: rgb(247, 247, 247); | |||
| border-color: @border-color !important; | |||
| } | |||
| } | |||
| .ant-table-tbody { | |||
| .ant-table-cell { | |||
| border-right: none !important; | |||
| border-left: none !important; | |||
| } | |||
| } | |||
| .ant-table-tbody-virtual::after { | |||
| border-bottom: none !important; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| .cell-index { | |||
| position: relative; | |||
| width: 100%; | |||
| white-space: nowrap; | |||
| &__best-tag { | |||
| margin-left: 8px; | |||
| padding: 1px 10px; | |||
| color: @success-color; | |||
| font-weight: normal; | |||
| font-size: 13px; | |||
| white-space: nowrap; | |||
| background-color: .addAlpha(@success-color, 0.1) []; | |||
| border-radius: 2px; | |||
| } | |||
| } | |||
| .table-best-row { | |||
| color: @success-color; | |||
| font-weight: bold; | |||
| } | |||
| .trail-result { | |||
| :global { | |||
| .ant-tree-node-selected { | |||
| .trail-result__icon { | |||
| color: white; | |||
| } | |||
| } | |||
| .trail-result__icon { | |||
| margin-left: 8px; | |||
| color: @primary-color; | |||
| } | |||
| } | |||
| } | |||
| @@ -1,115 +1,246 @@ | |||
| import { getFileReq } from '@/services/file'; | |||
| import InfoGroup from '@/components/InfoGroup'; | |||
| import KFIcon from '@/components/KFIcon'; | |||
| import TrialStatusCell from '@/pages/HyperParameter/components/TrialStatusCell'; | |||
| import { HyperParameterFile, HyperParameterTrial } from '@/pages/HyperParameter/types'; | |||
| import { getExpMetricsReq } from '@/services/hyperParameter'; | |||
| import { downLoadZip } from '@/utils/downloadfile'; | |||
| import { to } from '@/utils/promise'; | |||
| import tableCellRender from '@/utils/table'; | |||
| import { Table, type TableProps } from 'antd'; | |||
| import tableCellRender, { TableCellValueType } from '@/utils/table'; | |||
| import { App, Button, Table, Tooltip, Tree, type TableProps, type TreeDataNode } from 'antd'; | |||
| import classNames from 'classnames'; | |||
| import { useEffect, useState } from 'react'; | |||
| import { useState } from 'react'; | |||
| import styles from './index.less'; | |||
| const { DirectoryTree } = Tree; | |||
| type ExperimentHistoryProps = { | |||
| fileUrl?: string; | |||
| isClassification: boolean; | |||
| trialList?: HyperParameterTrial[]; | |||
| }; | |||
| type TableData = { | |||
| id?: string; | |||
| accuracy?: number; | |||
| duration?: number; | |||
| train_loss?: number; | |||
| status?: string; | |||
| feature?: string; | |||
| althorithm?: string; | |||
| }; | |||
| function ExperimentHistory({ trialList = [] }: ExperimentHistoryProps) { | |||
| const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]); | |||
| const { message } = App.useApp(); | |||
| function ExperimentHistory({ fileUrl, isClassification }: ExperimentHistoryProps) { | |||
| const [tableData, setTableData] = useState<TableData[]>([]); | |||
| useEffect(() => { | |||
| if (fileUrl) { | |||
| getHistoryFile(); | |||
| } | |||
| }, [fileUrl]); | |||
| // 获取实验运行历史记录 | |||
| const getHistoryFile = async () => { | |||
| const [res] = await to(getFileReq(fileUrl)); | |||
| if (res) { | |||
| const data: any[] = res.data; | |||
| const list: TableData[] = data.map((item) => { | |||
| return { | |||
| id: item[0]?.[0], | |||
| accuracy: item[1]?.[5]?.accuracy, | |||
| duration: item[1]?.[5]?.duration, | |||
| train_loss: item[1]?.[5]?.train_loss, | |||
| status: item[1]?.[2]?.['__enum__']?.split('.')?.[1], | |||
| }; | |||
| }); | |||
| list.forEach((item) => { | |||
| if (!item.id) return; | |||
| const config = (res as any).configs?.[item.id]; | |||
| item.feature = config?.['feature_preprocessor:__choice__']; | |||
| item.althorithm = isClassification | |||
| ? config?.['classifier:__choice__'] | |||
| : config?.['regressor:__choice__']; | |||
| }); | |||
| setTableData(list); | |||
| } | |||
| }; | |||
| const first: HyperParameterTrial | undefined = trialList[0]; | |||
| const config: Record<string, any> = first?.config ?? {}; | |||
| const metricAnalysis: Record<string, any> = first?.metric_analysis ?? {}; | |||
| const paramsNames = Object.keys(config); | |||
| const metricNames = Object.keys(metricAnalysis); | |||
| const columns: TableProps<TableData>['columns'] = [ | |||
| const trialColumns: TableProps<HyperParameterTrial>['columns'] = [ | |||
| { | |||
| title: 'ID', | |||
| dataIndex: 'id', | |||
| key: 'id', | |||
| width: 80, | |||
| render: tableCellRender(false), | |||
| title: '序号', | |||
| dataIndex: 'index', | |||
| key: 'index', | |||
| width: 110, | |||
| render: (_text, record, index: number) => { | |||
| return ( | |||
| <div className={styles['cell-index']}> | |||
| <span className={styles['cell-index__text']}>{index + 1}</span> | |||
| {record.is_best && <span className={styles['cell-index__best-tag']}>最佳</span>} | |||
| </div> | |||
| ); | |||
| }, | |||
| }, | |||
| { | |||
| title: '准确率', | |||
| dataIndex: 'accuracy', | |||
| key: 'accuracy', | |||
| render: tableCellRender(true), | |||
| ellipsis: { showTitle: false }, | |||
| title: '运行次数', | |||
| dataIndex: 'training_iteration', | |||
| key: 'training_iteration', | |||
| width: 120, | |||
| render: tableCellRender(false), | |||
| }, | |||
| { | |||
| title: '耗时', | |||
| dataIndex: 'duration', | |||
| key: 'duration', | |||
| render: tableCellRender(true), | |||
| title: '平均时长(秒)', | |||
| dataIndex: 'time_avg', | |||
| key: 'time_avg', | |||
| width: 150, | |||
| render: tableCellRender(false, TableCellValueType.Custom, { | |||
| format: (value = 0) => Number(value).toFixed(2), | |||
| }), | |||
| ellipsis: { showTitle: false }, | |||
| }, | |||
| { | |||
| title: '训练损失', | |||
| dataIndex: 'train_loss', | |||
| key: 'train_loss', | |||
| render: tableCellRender(true), | |||
| ellipsis: { showTitle: false }, | |||
| title: '状态', | |||
| dataIndex: 'status', | |||
| key: 'status', | |||
| width: 120, | |||
| render: TrialStatusCell, | |||
| }, | |||
| ]; | |||
| if (paramsNames.length) { | |||
| trialColumns.push({ | |||
| title: '运行参数', | |||
| dataIndex: 'config', | |||
| key: 'config', | |||
| align: 'center', | |||
| children: paramsNames.map((name) => ({ | |||
| title: ( | |||
| <Tooltip title={name}> | |||
| <span>{name}</span> | |||
| </Tooltip> | |||
| ), | |||
| dataIndex: ['config', name], | |||
| key: name, | |||
| width: 120, | |||
| align: 'center', | |||
| render: tableCellRender(true), | |||
| ellipsis: { showTitle: false }, | |||
| showSorterTooltip: false, | |||
| })), | |||
| }); | |||
| } | |||
| if (metricNames.length) { | |||
| trialColumns.push({ | |||
| title: `指标分析(${first.metric ?? ''})`, | |||
| dataIndex: 'metrics', | |||
| key: 'metrics', | |||
| align: 'center', | |||
| children: metricNames.map((name) => ({ | |||
| title: ( | |||
| <Tooltip title={name}> | |||
| <span>{name}</span> | |||
| </Tooltip> | |||
| ), | |||
| dataIndex: ['metric_analysis', name], | |||
| key: name, | |||
| width: 120, | |||
| align: 'center', | |||
| render: tableCellRender(true), | |||
| ellipsis: { showTitle: false }, | |||
| showSorterTooltip: false, | |||
| })), | |||
| }); | |||
| } | |||
| const fileColumns: TableProps<HyperParameterFile>['columns'] = [ | |||
| { | |||
| title: '特征处理', | |||
| dataIndex: 'feature', | |||
| key: 'feature', | |||
| render: tableCellRender(true), | |||
| ellipsis: { showTitle: false }, | |||
| title: '文件名称', | |||
| dataIndex: 'name', | |||
| key: 'name', | |||
| render: tableCellRender(false), | |||
| }, | |||
| { | |||
| title: '算法', | |||
| dataIndex: 'althorithm', | |||
| key: 'althorithm', | |||
| render: tableCellRender(true), | |||
| ellipsis: { showTitle: false }, | |||
| title: '文件大小', | |||
| dataIndex: 'size', | |||
| key: 'size', | |||
| width: 200, | |||
| render: tableCellRender(false), | |||
| }, | |||
| { | |||
| title: '状态', | |||
| dataIndex: 'status', | |||
| key: 'status', | |||
| width: 120, | |||
| render: tableCellRender(false), | |||
| title: '操作', | |||
| dataIndex: 'option', | |||
| width: 160, | |||
| key: 'option', | |||
| render: (_: any, record: HyperParameterFile) => { | |||
| return ( | |||
| <Button | |||
| type="link" | |||
| size="small" | |||
| key="download" | |||
| icon={<KFIcon type="icon-xiazai" />} | |||
| onClick={() => { | |||
| if (record.isFile) { | |||
| downLoadZip(`/api/mmp/minioStorage/downloadFile`, { path: record.url }); | |||
| } else { | |||
| downLoadZip(`/api/mmp/minioStorage/download`, { path: record.url }); | |||
| } | |||
| }} | |||
| > | |||
| 下载 | |||
| </Button> | |||
| ); | |||
| }, | |||
| }, | |||
| ]; | |||
| const expandedRowRender = (record: HyperParameterTrial) => ( | |||
| <Table columns={fileColumns} dataSource={[record.file]} pagination={false} rowKey="name" /> | |||
| ); | |||
| const expandedRowRender2 = (record: HyperParameterTrial) => { | |||
| const filesToTreeData = ( | |||
| files: HyperParameterFile[], | |||
| parent?: HyperParameterFile, | |||
| ): TreeDataNode[] => | |||
| files.map((file) => { | |||
| const key = parent ? `${parent.name}/${file.name}` : file.name; | |||
| return { | |||
| ...file, | |||
| key, | |||
| title: file.name, | |||
| children: file.children ? filesToTreeData(file.children, file) : undefined, | |||
| }; | |||
| }); | |||
| const treeData: TreeDataNode[] = filesToTreeData([record.file]); | |||
| return ( | |||
| <InfoGroup title="寻优结果" className={styles['trail-result']}> | |||
| <DirectoryTree | |||
| // @ts-ignore | |||
| treeData={treeData} | |||
| defaultExpandAll | |||
| titleRender={(record: TreeDataNode & HyperParameterFile) => { | |||
| const label = record.title + (record.isFile ? `(${record.size})` : ''); | |||
| return ( | |||
| <> | |||
| <span style={{ fontSize: 14 }}>{label}</span> | |||
| <KFIcon | |||
| type="icon-xiazai" | |||
| className="trail-result__icon" | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| downLoadZip( | |||
| record.isFile | |||
| ? `/api/mmp/minioStorage/downloadFile` | |||
| : `/api/mmp/minioStorage/download`, | |||
| { path: record.url }, | |||
| ); | |||
| }} | |||
| /> | |||
| </> | |||
| ); | |||
| }} | |||
| /> | |||
| </InfoGroup> | |||
| ); | |||
| }; | |||
| // 选择行 | |||
| const rowSelection: TableProps<HyperParameterTrial>['rowSelection'] = { | |||
| type: 'checkbox', | |||
| columnWidth: 48, | |||
| fixed: 'left', | |||
| selectedRowKeys, | |||
| onChange: (selectedRowKeys: React.Key[]) => { | |||
| setSelectedRowKeys(selectedRowKeys); | |||
| }, | |||
| }; | |||
| const handleComparisonClick = () => { | |||
| if (selectedRowKeys.length < 1) { | |||
| message.error('请至少选择一项'); | |||
| return; | |||
| } | |||
| getExpMetrics(); | |||
| }; | |||
| // 获取对比 url | |||
| const getExpMetrics = async () => { | |||
| const [res] = await to(getExpMetricsReq(selectedRowKeys)); | |||
| if (res && res.data) { | |||
| const url = res.data; | |||
| window.open(url, '_blank'); | |||
| } | |||
| }; | |||
| return ( | |||
| <div className={styles['experiment-history']}> | |||
| <div className={styles['experiment-history__content']}> | |||
| <Button type="default" onClick={handleComparisonClick}> | |||
| 可视化对比 | |||
| </Button> | |||
| <div | |||
| className={classNames( | |||
| 'vertical-scroll-table-no-page', | |||
| @@ -117,11 +248,15 @@ function ExperimentHistory({ fileUrl, isClassification }: ExperimentHistoryProps | |||
| )} | |||
| > | |||
| <Table | |||
| dataSource={tableData} | |||
| columns={columns} | |||
| rowClassName={(record) => (record.is_best ? styles['table-best-row'] : '')} | |||
| dataSource={trialList} | |||
| columns={trialColumns} | |||
| pagination={false} | |||
| scroll={{ y: 'calc(100% - 55px)' }} | |||
| rowKey="id" | |||
| bordered={true} | |||
| scroll={{ y: 'calc(100% - 110px)', x: '100%' }} | |||
| rowKey="trial_id" | |||
| expandable={{ expandedRowRender: expandedRowRender2 }} | |||
| rowSelection={rowSelection} | |||
| /> | |||
| </div> | |||
| </div> | |||
| @@ -0,0 +1,16 @@ | |||
| .experiment-log { | |||
| height: 100%; | |||
| &__tabs { | |||
| height: 100%; | |||
| :global { | |||
| .ant-tabs-nav-list { | |||
| padding-left: 0 !important; | |||
| background: none !important; | |||
| } | |||
| } | |||
| &__log { | |||
| height: 100%; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,109 @@ | |||
| import { ExperimentStatus } from '@/enums'; | |||
| import LogList from '@/pages/Experiment/components/LogList'; | |||
| import { HyperParameterInstanceData } from '@/pages/HyperParameter/types'; | |||
| import { NodeStatus } from '@/types'; | |||
| import { Tabs } from 'antd'; | |||
| import { useEffect } from 'react'; | |||
| import styles from './index.less'; | |||
| type ExperimentLogProps = { | |||
| instanceInfo: HyperParameterInstanceData; | |||
| nodes: Record<string, NodeStatus>; | |||
| }; | |||
| function ExperimentLog({ instanceInfo, nodes }: ExperimentLogProps) { | |||
| let hpoNodeStatus: NodeStatus | undefined; | |||
| let frameworkCloneNodeStatus: NodeStatus | undefined; | |||
| let trainCloneNodeStatus: NodeStatus | undefined; | |||
| Object.keys(nodes) | |||
| .sort((key1, key2) => { | |||
| const node1 = nodes[key1]; | |||
| const node2 = nodes[key2]; | |||
| return new Date(node1.startedAt).getTime() - new Date(node2.startedAt).getTime(); | |||
| }) | |||
| .forEach((key) => { | |||
| const node = nodes[key]; | |||
| if (node.displayName.startsWith('auto-hpo')) { | |||
| hpoNodeStatus = node; | |||
| } else if (node.displayName.startsWith('git-clone') && !frameworkCloneNodeStatus) { | |||
| frameworkCloneNodeStatus = node; | |||
| } else if ( | |||
| node.displayName.startsWith('git-clone') && | |||
| frameworkCloneNodeStatus && | |||
| node.displayName !== frameworkCloneNodeStatus?.displayName | |||
| ) { | |||
| trainCloneNodeStatus = node; | |||
| } | |||
| }); | |||
| const tabItems = [ | |||
| { | |||
| key: 'git-clone-framework', | |||
| label: '框架代码日志', | |||
| // icon: <KFIcon type="icon-rizhi1" />, | |||
| children: ( | |||
| <div className={styles['experiment-log__tabs__log']}> | |||
| {frameworkCloneNodeStatus && ( | |||
| <LogList | |||
| instanceName={instanceInfo.argo_ins_name} | |||
| instanceNamespace={instanceInfo.argo_ins_ns} | |||
| pipelineNodeId={frameworkCloneNodeStatus.displayName} | |||
| workflowId={frameworkCloneNodeStatus.id} | |||
| instanceNodeStartTime={frameworkCloneNodeStatus.startedAt} | |||
| instanceNodeStatus={frameworkCloneNodeStatus.phase as ExperimentStatus} | |||
| ></LogList> | |||
| )} | |||
| </div> | |||
| ), | |||
| }, | |||
| { | |||
| key: 'git-clone-train', | |||
| label: '训练代码日志', | |||
| // icon: <KFIcon type="icon-rizhi1" />, | |||
| children: ( | |||
| <div className={styles['experiment-log__tabs__log']}> | |||
| {trainCloneNodeStatus && ( | |||
| <LogList | |||
| instanceName={instanceInfo.argo_ins_name} | |||
| instanceNamespace={instanceInfo.argo_ins_ns} | |||
| pipelineNodeId={trainCloneNodeStatus.displayName} | |||
| workflowId={trainCloneNodeStatus.id} | |||
| instanceNodeStartTime={trainCloneNodeStatus.startedAt} | |||
| instanceNodeStatus={trainCloneNodeStatus.phase as ExperimentStatus} | |||
| ></LogList> | |||
| )} | |||
| </div> | |||
| ), | |||
| }, | |||
| { | |||
| key: 'auto-hpo', | |||
| label: '超参寻优日志', | |||
| // icon: <KFIcon type="icon-rizhi1" />, | |||
| children: ( | |||
| <div className={styles['experiment-log__tabs__log']}> | |||
| {hpoNodeStatus && ( | |||
| <LogList | |||
| instanceName={instanceInfo.argo_ins_name} | |||
| instanceNamespace={instanceInfo.argo_ins_ns} | |||
| pipelineNodeId={hpoNodeStatus.displayName} | |||
| workflowId={hpoNodeStatus.id} | |||
| instanceNodeStartTime={hpoNodeStatus.startedAt} | |||
| instanceNodeStatus={hpoNodeStatus.phase as ExperimentStatus} | |||
| ></LogList> | |||
| )} | |||
| </div> | |||
| ), | |||
| }, | |||
| ]; | |||
| useEffect(() => {}, []); | |||
| return ( | |||
| <div className={styles['experiment-log']}> | |||
| <Tabs className={styles['experiment-log__tabs']} items={tabItems} /> | |||
| </div> | |||
| ); | |||
| } | |||
| export default ExperimentLog; | |||
| @@ -6,47 +6,12 @@ | |||
| background-color: white; | |||
| border-radius: 10px; | |||
| &__download { | |||
| padding-top: 16px; | |||
| padding-bottom: 16px; | |||
| padding-left: @content-padding; | |||
| color: @text-color; | |||
| font-size: 13px; | |||
| background-color: #f8f8f9; | |||
| border-radius: 4px; | |||
| &__btn { | |||
| display: block; | |||
| height: 36px; | |||
| margin-top: 15px; | |||
| font-size: 14px; | |||
| } | |||
| &__table { | |||
| height: 400px; | |||
| } | |||
| &__text { | |||
| white-space: pre-wrap; | |||
| } | |||
| &__images { | |||
| display: flex; | |||
| align-items: flex-start; | |||
| width: 100%; | |||
| overflow-x: auto; | |||
| :global { | |||
| .ant-image { | |||
| margin-right: 20px; | |||
| &:last-child { | |||
| margin-right: 0; | |||
| } | |||
| } | |||
| } | |||
| &__item { | |||
| height: 248px; | |||
| border: 1px solid rgba(96, 107, 122, 0.3); | |||
| } | |||
| font-family: 'Roboto Mono', 'Menlo', 'Consolas', 'Monaco', monospace; | |||
| white-space: pre; | |||
| } | |||
| } | |||
| @@ -1,26 +1,16 @@ | |||
| import InfoGroup from '@/components/InfoGroup'; | |||
| import { getFileReq } from '@/services/file'; | |||
| import { to } from '@/utils/promise'; | |||
| import { Button, Image } from 'antd'; | |||
| import { useEffect, useMemo, useState } from 'react'; | |||
| import { useEffect, useState } from 'react'; | |||
| import styles from './index.less'; | |||
| type ExperimentResultProps = { | |||
| fileUrl?: string; | |||
| imageUrl?: string; | |||
| modelPath?: string; | |||
| }; | |||
| function ExperimentResult({ fileUrl, imageUrl, modelPath }: ExperimentResultProps) { | |||
| function ExperimentResult({ fileUrl }: ExperimentResultProps) { | |||
| const [result, setResult] = useState<string | undefined>(''); | |||
| const images = useMemo(() => { | |||
| if (imageUrl) { | |||
| return imageUrl.split(',').map((item) => item.trim()); | |||
| } | |||
| return []; | |||
| }, [imageUrl]); | |||
| useEffect(() => { | |||
| if (fileUrl) { | |||
| getResultFile(); | |||
| @@ -37,45 +27,9 @@ function ExperimentResult({ fileUrl, imageUrl, modelPath }: ExperimentResultProp | |||
| return ( | |||
| <div className={styles['experiment-result']}> | |||
| <InfoGroup title="实验结果" height={420} width="100%"> | |||
| <InfoGroup title="最佳实验结果" width="100%"> | |||
| <div className={styles['experiment-result__text']}>{result}</div> | |||
| </InfoGroup> | |||
| <InfoGroup title="可视化结果" style={{ margin: '16px 0' }}> | |||
| <div className={styles['experiment-result__images']}> | |||
| <Image.PreviewGroup | |||
| preview={{ | |||
| onChange: (current, prev) => | |||
| console.log(`current index: ${current}, prev index: ${prev}`), | |||
| }} | |||
| > | |||
| {images.map((item) => ( | |||
| <Image | |||
| key={item} | |||
| className={styles['experiment-result__images__item']} | |||
| src={item} | |||
| height={248} | |||
| draggable={false} | |||
| alt="" | |||
| /> | |||
| ))} | |||
| </Image.PreviewGroup> | |||
| </div> | |||
| </InfoGroup> | |||
| {modelPath && ( | |||
| <div className={styles['experiment-result__download']}> | |||
| <span style={{ marginRight: '12px', color: '#606b7a' }}>文件名</span> | |||
| <span>save_model.joblib</span> | |||
| <Button | |||
| type="primary" | |||
| className={styles['experiment-result__download__btn']} | |||
| onClick={() => { | |||
| window.location.href = modelPath; | |||
| }} | |||
| > | |||
| 模型下载 | |||
| </Button> | |||
| </div> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -90,7 +90,7 @@ function HyperParameterBasic({ | |||
| return [ | |||
| { | |||
| label: '代码', | |||
| value: info.code, | |||
| value: info.code_config, | |||
| format: formatCodeConfig, | |||
| }, | |||
| { | |||
| @@ -0,0 +1,3 @@ | |||
| .trial-status-cell { | |||
| height: 100%; | |||
| } | |||
| @@ -0,0 +1,67 @@ | |||
| /* | |||
| * @Author: 赵伟 | |||
| * @Date: 2024-04-18 18:35:41 | |||
| * @Description: 实验状态 | |||
| */ | |||
| import { HyperParameterTrailStatus } from '@/enums'; | |||
| import { ExperimentStatusInfo } from '@/pages/Experiment/status'; | |||
| import themes from '@/styles/theme.less'; | |||
| import styles from './index.less'; | |||
| export const statusInfo: Record<HyperParameterTrailStatus, ExperimentStatusInfo> = { | |||
| [HyperParameterTrailStatus.RUNNING]: { | |||
| label: '运行中', | |||
| color: themes.primaryColor, | |||
| icon: '/assets/images/experiment-status/running-icon.png', | |||
| }, | |||
| [HyperParameterTrailStatus.TERMINATED]: { | |||
| label: '成功', | |||
| color: themes.successColor, | |||
| icon: '/assets/images/experiment-status/success-icon.png', | |||
| }, | |||
| [HyperParameterTrailStatus.PENDING]: { | |||
| label: '挂起', | |||
| color: themes.pendingColor, | |||
| icon: '/assets/images/experiment-status/pending-icon.png', | |||
| }, | |||
| [HyperParameterTrailStatus.ERROR]: { | |||
| label: '失败', | |||
| color: themes.errorColor, | |||
| icon: '/assets/images/experiment-status/fail-icon.png', | |||
| }, | |||
| [HyperParameterTrailStatus.PAUSED]: { | |||
| label: '暂停', | |||
| color: themes.abortColor, | |||
| icon: '/assets/images/experiment-status/omitted-icon.png', | |||
| }, | |||
| [HyperParameterTrailStatus.RESTORING]: { | |||
| label: '恢复中', | |||
| color: themes.textColor, | |||
| icon: '/assets/images/experiment-status/omitted-icon.png', | |||
| }, | |||
| }; | |||
| function TrialStatusCell(status?: HyperParameterTrailStatus | null) { | |||
| if (status === null || status === undefined) { | |||
| return <span>--</span>; | |||
| } | |||
| return ( | |||
| <div className={styles['trial-status-cell']}> | |||
| {/* <img | |||
| style={{ width: '17px', marginRight: '7px' }} | |||
| src={statusInfo[status]?.icon} | |||
| draggable={false} | |||
| alt="" | |||
| /> */} | |||
| <span | |||
| style={{ color: statusInfo[status] ? statusInfo[status].color : themes.textColor }} | |||
| className={styles['trial-status-cell__label']} | |||
| > | |||
| {statusInfo[status] ? statusInfo[status].label : status} | |||
| </span> | |||
| </div> | |||
| ); | |||
| } | |||
| export default TrialStatusCell; | |||
| @@ -42,13 +42,11 @@ export type HyperParameterData = { | |||
| } & FormData; | |||
| // 自动机器学习实验实例 | |||
| export type AutoMLInstanceData = { | |||
| export type HyperParameterInstanceData = { | |||
| id: number; | |||
| auto_ml_id: number; | |||
| ray_id: number; | |||
| result_path: string; | |||
| model_path: string; | |||
| img_path: string; | |||
| run_history_path: string; | |||
| result_txt: string; | |||
| state: number; | |||
| status: string; | |||
| node_status: string; | |||
| @@ -60,5 +58,27 @@ export type AutoMLInstanceData = { | |||
| create_time: string; | |||
| update_time: string; | |||
| finish_time: string; | |||
| nodeStatus?: NodeStatus; | |||
| nodeStatus?: NodeStatus; // json之后的节点状态 | |||
| trial_list?: HyperParameterTrial[]; | |||
| file_list?: HyperParameterFile[]; | |||
| }; | |||
| export type HyperParameterTrial = { | |||
| trial_id?: string; | |||
| training_iteration?: number; | |||
| time?: number; | |||
| status?: string; | |||
| config?: Record<string, any>; | |||
| metric_analysis?: Record<string, any>; | |||
| metric: string; | |||
| file: HyperParameterFile; | |||
| is_best?: boolean; | |||
| }; | |||
| export type HyperParameterFile = { | |||
| name: string; | |||
| size: string; | |||
| url: string; | |||
| isFile: boolean; | |||
| children: HyperParameterFile[]; | |||
| }; | |||
| @@ -502,7 +502,7 @@ const PipelineNodeParameter = forwardRef(({ onFormChange }: PipelineNodeParamete | |||
| label={getLabel(item, 'control_strategy')} | |||
| rules={getFormRules(item)} | |||
| > | |||
| <ParameterInput allowClear></ParameterInput> | |||
| <ParameterInput placeholder={item.value.placeholder} allowClear></ParameterInput> | |||
| </Form.Item> | |||
| ))} | |||
| {/* 输入参数 */} | |||
| @@ -523,9 +523,18 @@ const PipelineNodeParameter = forwardRef(({ onFormChange }: PipelineNodeParamete | |||
| <div className={styles['pipeline-drawer__ref-row']}> | |||
| <Form.Item name={['in_parameters', item.key]} rules={getFormRules(item)} noStyle> | |||
| {item.value.type === 'select' ? ( | |||
| <ParameterSelect /> | |||
| ['dataset', 'model', 'service', 'resource'].includes(item.value.item_type) ? ( | |||
| <ParameterSelect | |||
| dataType={item.value.item_type as any} | |||
| placeholder={item.value.placeholder} | |||
| /> | |||
| ) : null | |||
| ) : ( | |||
| <ParameterInput canInput={canInput(item.value)} allowClear></ParameterInput> | |||
| <ParameterInput | |||
| canInput={canInput(item.value)} | |||
| placeholder={item.value.placeholder} | |||
| allowClear | |||
| ></ParameterInput> | |||
| )} | |||
| </Form.Item> | |||
| {item.value.type === 'ref' && ( | |||
| @@ -563,7 +572,7 @@ const PipelineNodeParameter = forwardRef(({ onFormChange }: PipelineNodeParamete | |||
| label={getLabel(item, 'out_parameters')} | |||
| rules={getFormRules(item)} | |||
| > | |||
| <ParameterInput allowClear></ParameterInput> | |||
| <ParameterInput placeholder={item.value.placeholder} allowClear></ParameterInput> | |||
| </Form.Item> | |||
| ))} | |||
| </> | |||
| @@ -91,3 +91,10 @@ export function batchDeleteRayInsReq(data) { | |||
| }); | |||
| } | |||
| // 获取当前实验的指标对比地址 | |||
| export function getExpMetricsReq(data) { | |||
| return request(`/api/mmp/rayIns/getExpMetrics`, { | |||
| method: 'POST', | |||
| data | |||
| }); | |||
| } | |||
| @@ -89,6 +89,8 @@ export const Primary: Story = { | |||
| ], | |||
| labelWidth: 80, | |||
| labelAlign: 'justify', | |||
| threeColumns: false, | |||
| labelEllipsis: true, | |||
| }, | |||
| }; | |||
| @@ -14,7 +14,49 @@ const meta = { | |||
| tags: ['autodocs'], | |||
| // More on argTypes: https://storybook.js.org/docs/api/argtypes | |||
| argTypes: { | |||
| // backgroundColor: { control: 'color' }, | |||
| datas: { | |||
| description: '基础信息', | |||
| table: { | |||
| type: { summary: 'BasicInfoData[]' }, | |||
| }, | |||
| type: { | |||
| required: true, | |||
| name: 'array', | |||
| value: { | |||
| name: 'object', | |||
| value: {}, | |||
| }, | |||
| }, | |||
| }, | |||
| labelWidth: { | |||
| description: '标题宽度', | |||
| type: { | |||
| required: true, | |||
| name: 'number', | |||
| }, | |||
| }, | |||
| labelEllipsis: { | |||
| description: '标题是否显示省略号', | |||
| table: { | |||
| type: { summary: 'boolean' }, | |||
| defaultValue: { summary: 'true' }, | |||
| }, | |||
| control: 'boolean', | |||
| }, | |||
| className: { | |||
| description: '自定义类名', | |||
| table: { | |||
| type: { summary: 'string' }, | |||
| }, | |||
| control: 'text', | |||
| }, | |||
| style: { | |||
| description: '自定义样式', | |||
| table: { | |||
| type: { summary: 'ReactCSSProperties' }, | |||
| }, | |||
| control: 'object', | |||
| }, | |||
| }, | |||
| // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args | |||
| // args: { onClick: fn() }, | |||
| @@ -26,7 +68,8 @@ type Story = StoryObj<typeof meta>; | |||
| // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args | |||
| export const Primary: Story = { | |||
| args: { | |||
| ...BasicInfoStories.Primary.args, | |||
| datas: BasicInfoStories.Primary.args.datas, | |||
| labelWidth: 100, | |||
| labelEllipsis: true, | |||
| }, | |||
| }; | |||
| @@ -1,8 +1,9 @@ | |||
| import CodeSelect, { type ParameterInputValue } from '@/components/CodeSelect'; | |||
| import { action } from '@storybook/addon-actions'; | |||
| import { useArgs } from '@storybook/preview-api'; | |||
| import type { Meta, StoryObj } from '@storybook/react'; | |||
| import { fn } from '@storybook/test'; | |||
| import { Col, Form, Row } from 'antd'; | |||
| import { Button, Col, Form, Row } from 'antd'; | |||
| import { http, HttpResponse } from 'msw'; | |||
| import { codeListData } from './mockData'; | |||
| @@ -56,7 +57,14 @@ export const Primary: Story = { | |||
| export const InForm: Story = { | |||
| render: ({ onChange }) => { | |||
| return ( | |||
| <Form name="code-select-form" size="large"> | |||
| <Form | |||
| name="code-select-form" | |||
| labelCol={{ flex: '80px' }} | |||
| labelAlign="left" | |||
| size="large" | |||
| autoComplete="off" | |||
| onFinish={action('onFinish')} | |||
| > | |||
| <Row gutter={8}> | |||
| <Col span={10}> | |||
| <Form.Item label="代码配置" name="code_config"> | |||
| @@ -69,6 +77,11 @@ export const InForm: Story = { | |||
| </Form.Item> | |||
| </Col> | |||
| </Row> | |||
| <Row gutter={8}> | |||
| <Button htmlType="submit" type="primary"> | |||
| 提交 | |||
| </Button> | |||
| </Row> | |||
| </Form> | |||
| ); | |||
| }, | |||
| @@ -1,6 +1,6 @@ | |||
| import FormInfo from '@/components/FormInfo'; | |||
| import type { Meta, StoryObj } from '@storybook/react'; | |||
| import { Form, Input, Select } from 'antd'; | |||
| import { Form, Input, Select, Typography } from 'antd'; | |||
| // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export | |||
| const meta = { | |||
| @@ -38,7 +38,7 @@ export const InForm: Story = { | |||
| <Form | |||
| name="form" | |||
| style={{ width: 300 }} | |||
| labelCol={{ flex: '100px' }} | |||
| labelCol={{ flex: '150px' }} | |||
| initialValues={{ | |||
| text: '文本', | |||
| large_text: | |||
| @@ -49,9 +49,11 @@ export const InForm: Story = { | |||
| showValue: '对象文本', | |||
| }, | |||
| select_text: 1, | |||
| select_map_text: 1, | |||
| ant_input_text: | |||
| '超长文本超长文本超长文本超长文本超长文本超长文本超长文本超长文本超长文本超长文本超长文本', | |||
| ant_select_text: 1, | |||
| ant_select_ellipsis_text: 1, | |||
| }} | |||
| > | |||
| <Form.Item label="文本" name="text"> | |||
| @@ -69,7 +71,7 @@ export const InForm: Story = { | |||
| <Form.Item label="无内容" name="empty_text"> | |||
| <FormInfo /> | |||
| </Form.Item> | |||
| <Form.Item label="Select" name="select_text"> | |||
| <Form.Item label="模拟 Select" name="select_text"> | |||
| <FormInfo | |||
| select | |||
| options={[ | |||
| @@ -81,6 +83,22 @@ export const InForm: Story = { | |||
| ]} | |||
| /> | |||
| </Form.Item> | |||
| <Form.Item label="模拟 Select Map" name="select_map_text"> | |||
| <FormInfo | |||
| select | |||
| options={[ | |||
| { | |||
| otherLabel: | |||
| '超长文本超长文本超长文本超长文本超长文本超长文本超长文本超长文本超长文本超长文本超长文本', | |||
| otherValue: 1, | |||
| }, | |||
| ]} | |||
| fieldNames={{ | |||
| label: 'otherLabel', | |||
| value: 'otherValue', | |||
| }} | |||
| /> | |||
| </Form.Item> | |||
| <Form.Item label="Input" name="ant_input_text"> | |||
| <Input disabled /> | |||
| </Form.Item> | |||
| @@ -96,6 +114,27 @@ export const InForm: Story = { | |||
| ]} | |||
| /> | |||
| </Form.Item> | |||
| <Form.Item label="Select Ellipsis" name="ant_select_ellipsis_text"> | |||
| <Select | |||
| labelRender={(props) => { | |||
| return ( | |||
| <div style={{ width: '100%', lineHeight: 'normal' }}> | |||
| <Typography.Text ellipsis={{ tooltip: props.label }} style={{ margin: 0 }}> | |||
| {props.label} | |||
| </Typography.Text> | |||
| </div> | |||
| ); | |||
| }} | |||
| disabled | |||
| options={[ | |||
| { | |||
| label: | |||
| '超长文本超长文本超长文本超长文本超长文本超长文本超长文本超长文本超长文本超长文本超长文本', | |||
| value: 1, | |||
| }, | |||
| ]} | |||
| /> | |||
| </Form.Item> | |||
| </Form> | |||
| ); | |||
| }, | |||
| @@ -0,0 +1,166 @@ | |||
| import ParameterSelect, { type ParameterSelectObject } from '@/components/ParameterSelect'; | |||
| import { action } from '@storybook/addon-actions'; | |||
| import { useArgs } from '@storybook/preview-api'; | |||
| import type { Meta, StoryObj } from '@storybook/react'; | |||
| import { fn } from '@storybook/test'; | |||
| import { Button, Col, Form, Row } from 'antd'; | |||
| import { http, HttpResponse } from 'msw'; | |||
| import { computeResourceData, datasetListData, modelListData, serviceListData } from './mockData'; | |||
| // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export | |||
| const meta = { | |||
| title: 'Components/ParameterSelect 参数选择器', | |||
| component: ParameterSelect, | |||
| parameters: { | |||
| // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout | |||
| // layout: 'centered', | |||
| msw: { | |||
| handlers: [ | |||
| http.get('/api/mmp/newdataset/queryDatasets', () => { | |||
| return HttpResponse.json(datasetListData); | |||
| }), | |||
| http.get('/api/mmp/newmodel/queryModels', () => { | |||
| return HttpResponse.json(modelListData); | |||
| }), | |||
| http.get('/api/mmp/service', () => { | |||
| return HttpResponse.json(serviceListData); | |||
| }), | |||
| http.get('/api/mmp/computingResource', () => { | |||
| return HttpResponse.json(computeResourceData); | |||
| }), | |||
| ], | |||
| }, | |||
| }, | |||
| // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs | |||
| tags: ['autodocs'], | |||
| // More on argTypes: https://storybook.js.org/docs/api/argtypes | |||
| // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args | |||
| args: { onChange: fn() }, | |||
| } satisfies Meta<typeof ParameterSelect>; | |||
| export default meta; | |||
| type Story = StoryObj<typeof meta>; | |||
| // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args | |||
| export const Primary: Story = { | |||
| args: { | |||
| placeholder: '请选择', | |||
| dataType: 'dataset', | |||
| style: { width: 400 }, | |||
| size: 'large', | |||
| }, | |||
| render: function Render(args) { | |||
| const [{ value }, updateArgs] = useArgs(); | |||
| function handleChange(value?: string | ParameterSelectObject) { | |||
| updateArgs({ value: value }); | |||
| args.onChange?.(value); | |||
| } | |||
| return <ParameterSelect {...args} value={value} onChange={handleChange}></ParameterSelect>; | |||
| }, | |||
| }; | |||
| /** 值可以是一个对象,典型的是流水线节点对象 **PipelineNodeModelParameter** */ | |||
| export const Object: Story = { | |||
| args: { | |||
| placeholder: '请选择', | |||
| dataType: 'dataset', | |||
| style: { width: 400 }, | |||
| size: 'large', | |||
| value: { | |||
| value: undefined, | |||
| }, | |||
| }, | |||
| render: function Render(args) { | |||
| const [{ value }, updateArgs] = useArgs(); | |||
| function handleChange(value?: string | ParameterSelectObject) { | |||
| updateArgs({ value: value }); | |||
| args.onChange?.(value); | |||
| } | |||
| return <ParameterSelect {...args} value={value} onChange={handleChange}></ParameterSelect>; | |||
| }, | |||
| }; | |||
| export const InForm: Story = { | |||
| args: { | |||
| dataType: 'dataset', | |||
| }, | |||
| render: ({ onChange }) => { | |||
| return ( | |||
| <Form | |||
| name="parameter-select-form" | |||
| labelCol={{ flex: '80px' }} | |||
| labelAlign="left" | |||
| size="large" | |||
| onFinish={action('onFinish')} | |||
| autoComplete="off" | |||
| initialValues={{ | |||
| dataset: { | |||
| type: 'select', | |||
| item_type: 'dataset', | |||
| placeholder: '请选择数据集', | |||
| label: '数据集', | |||
| }, | |||
| model: { | |||
| type: 'select', | |||
| item_type: 'model', | |||
| placeholder: '请选择模型', | |||
| label: '模型', | |||
| }, | |||
| service: { | |||
| type: 'select', | |||
| item_type: 'service', | |||
| placeholder: '请选择服务', | |||
| label: '服务', | |||
| }, | |||
| resource: { | |||
| type: 'select', | |||
| item_type: 'resource', | |||
| placeholder: '请选择计算资源', | |||
| label: '计算资源', | |||
| }, | |||
| test: '1234', | |||
| }} | |||
| > | |||
| <Row gutter={8}> | |||
| <Col span={10}> | |||
| <Form.Item label="数据集" name="dataset"> | |||
| <ParameterSelect dataType="dataset" placeholder="请选择数据集" onChange={onChange} /> | |||
| </Form.Item> | |||
| </Col> | |||
| </Row> | |||
| <Row gutter={8}> | |||
| <Col span={10}> | |||
| <Form.Item label="模型" name="model"> | |||
| <ParameterSelect dataType="model" placeholder="请选择模型" onChange={onChange} /> | |||
| </Form.Item> | |||
| </Col> | |||
| </Row> | |||
| <Row gutter={8}> | |||
| <Col span={10}> | |||
| <Form.Item label="服务" name="service"> | |||
| <ParameterSelect dataType="service" placeholder="请选择服务" onChange={onChange} /> | |||
| </Form.Item> | |||
| </Col> | |||
| </Row> | |||
| <Row gutter={8}> | |||
| <Col span={10}> | |||
| <Form.Item label="计算资源" name="resource"> | |||
| <ParameterSelect | |||
| dataType="resource" | |||
| placeholder="请选择计算资源" | |||
| onChange={onChange} | |||
| /> | |||
| </Form.Item> | |||
| </Col> | |||
| </Row> | |||
| <Row gutter={8}> | |||
| <Button htmlType="submit" type="primary"> | |||
| 提交 | |||
| </Button> | |||
| </Row> | |||
| </Form> | |||
| ); | |||
| }, | |||
| }; | |||
| @@ -3,10 +3,11 @@ import ResourceSelect, { | |||
| requiredValidator, | |||
| ResourceSelectorType, | |||
| } from '@/components/ResourceSelect'; | |||
| import { action } from '@storybook/addon-actions'; | |||
| import { useArgs } from '@storybook/preview-api'; | |||
| import type { Meta, StoryObj } from '@storybook/react'; | |||
| import { fn } from '@storybook/test'; | |||
| import { Col, Form, Row } from 'antd'; | |||
| import { Button, Col, Form, Row } from 'antd'; | |||
| import { http, HttpResponse } from 'msw'; | |||
| import { | |||
| datasetDetailData, | |||
| @@ -100,6 +101,7 @@ export const InForm: Story = { | |||
| labelAlign="left" | |||
| size="large" | |||
| autoComplete="off" | |||
| onFinish={action('onFinish')} | |||
| > | |||
| <Row gutter={8}> | |||
| <Col span={10}> | |||
| @@ -150,6 +152,11 @@ export const InForm: Story = { | |||
| </Form.Item> | |||
| </Col> | |||
| </Row> | |||
| <Row gutter={8}> | |||
| <Button htmlType="submit" type="primary"> | |||
| 提交 | |||
| </Button> | |||
| </Row> | |||
| </Form> | |||
| ); | |||
| }, | |||
| @@ -8,7 +8,45 @@ import { Meta } from '@storybook/blocks'; | |||
| ### 自定义主题 | |||
| `src/styles/theme.less` 定义了 UI 主题颜色变量、Less 函数、Less 混合。在开发过程中使用这个文件的定义的变量、函数以及混合,通过 UmiJS 的配置,我们在 Less 文件不需要收到导入这个文件。 | |||
| `src/styles/theme.less` 定义了 UI 主题颜色变量、Less 函数、Less 混合。 | |||
| 在开发过程中使用这个文件的定义的变量、函数以及混合。通过 UmiJS 的配置,我们在 Less 文件不需要收到导入这个文件。 | |||
| ```css | |||
| // 颜色 | |||
| @primary-color: #1664ff; // 主色调 | |||
| @primary-color-secondary: #4e89ff; | |||
| @primary-color-hover: #69b1ff; | |||
| @sider-background-color: #f2f5f7; // 侧边栏背景颜色 | |||
| @background-color: #f9fafb; // 页面背景颜色 | |||
| @text-color: #1d1d20; | |||
| @text-color-secondary: #575757; | |||
| @text-color-tertiary: #8a8a8a; | |||
| @text-placeholder-color: rgba(0, 0, 0, 0.25); | |||
| @text-disabled-color: rgba(0, 0, 0, 0.25); | |||
| @success-color: #6ac21d; | |||
| @error-color: #c73131; | |||
| @warning-color: #f98e1b; | |||
| @abort-color: #8a8a8a; | |||
| @pending-color: #ecb934; | |||
| @underline-color: #5d93ff; | |||
| @border-color: #eaeaea; | |||
| @link-hover-color: #69b1ff; | |||
| @heading-color: rgba(0, 0, 0, 0.85); | |||
| @input-icon-hover-color: rgba(0, 0, 0, 0.85); | |||
| // 字体大小 | |||
| @font-size-title: 18px; | |||
| @font-size-content: 16px; | |||
| @font-size: 15px; | |||
| @font-size-input: 14px; | |||
| @font-size-input-lg: @font-size-content; | |||
| // padding | |||
| @content-padding: 25px; | |||
| ``` | |||
| 颜色变量还可以在 `js/ts/jsx/tsx` 里使用 | |||
| @@ -173,27 +211,27 @@ function Component() { | |||
| 说明你需要拆分组件了 | |||
| ```tsx | |||
| function Component1() { | |||
| function Component() { | |||
| return ( | |||
| <div className="component1"> | |||
| <div className="component1__element1"> | |||
| <div className="component"> | |||
| <div className="component__element1"> | |||
| <Component1></Component1> | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| function Component() { | |||
| function SubComponent() { | |||
| return ( | |||
| <div className="component"> | |||
| <div className="component__element1"> | |||
| <Component1></Component1> | |||
| <div className="sub-component"> | |||
| <div className="sub-component__element1"> | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| ``` | |||
| 既减少了类名的嵌套,又减少了HTML的嵌套,使代码逻辑更加清晰,易于理解与维护 | |||
| 既减少了类名的嵌套,又减少了 HTML 的嵌套,使代码逻辑更加清晰,易于理解与维护,同时实现模块化和组件化 | |||
| @@ -546,3 +546,250 @@ export const codeListData = { | |||
| empty: false, | |||
| }, | |||
| }; | |||
| export const serviceListData = { | |||
| code: 200, | |||
| msg: '操作成功', | |||
| data: { | |||
| content: [ | |||
| { | |||
| id: 25, | |||
| service_name: '测试1224', | |||
| service_type: 'video', | |||
| service_type_name: '视频', | |||
| description: '测试', | |||
| create_by: 'admin', | |||
| update_by: 'admin', | |||
| create_time: '2024-12-24T16:01:02.000+08:00', | |||
| update_time: '2024-12-24T16:01:02.000+08:00', | |||
| state: 1, | |||
| version_count: 2, | |||
| }, | |||
| { | |||
| id: 12, | |||
| service_name: '介电材料', | |||
| service_type: 'text', | |||
| service_type_name: '文本', | |||
| description: 'test', | |||
| create_by: 'admin', | |||
| update_by: 'admin', | |||
| create_time: '2024-11-27T09:30:23.000+08:00', | |||
| update_time: '2024-11-27T09:30:23.000+08:00', | |||
| state: 1, | |||
| version_count: 0, | |||
| }, | |||
| { | |||
| id: 7, | |||
| service_name: '手写体识别', | |||
| service_type: 'image', | |||
| service_type_name: '图片', | |||
| description: '手写体识别服务', | |||
| create_by: 'admin', | |||
| update_by: 'admin', | |||
| create_time: '2024-10-10T10:14:00.000+08:00', | |||
| update_time: '2024-10-10T10:14:00.000+08:00', | |||
| state: 1, | |||
| version_count: 5, | |||
| }, | |||
| ], | |||
| pageable: { | |||
| sort: { | |||
| unsorted: true, | |||
| sorted: false, | |||
| empty: true, | |||
| }, | |||
| pageNumber: 0, | |||
| pageSize: 10, | |||
| offset: 0, | |||
| paged: true, | |||
| unpaged: false, | |||
| }, | |||
| last: true, | |||
| totalPages: 1, | |||
| totalElements: 3, | |||
| sort: { | |||
| unsorted: true, | |||
| sorted: false, | |||
| empty: true, | |||
| }, | |||
| first: true, | |||
| number: 0, | |||
| numberOfElements: 3, | |||
| size: 10, | |||
| empty: false, | |||
| }, | |||
| }; | |||
| export const computeResourceData = { | |||
| code: 200, | |||
| msg: '操作成功', | |||
| data: { | |||
| content: [ | |||
| { | |||
| id: 15, | |||
| computing_resource: 'GPU', | |||
| standard: | |||
| '{"name":"CPU-GPU","value":{"detail_type":"3060","gpu":0,"cpu":1,"memory":"2GB"}}', | |||
| description: 'GPU: 0, CPU:1, 内存: 2GB', | |||
| create_by: 'admin', | |||
| create_time: '2024-04-19T00:00:00.000+08:00', | |||
| update_by: 'admin', | |||
| update_time: '2024-04-19T00:00:00.000+08:00', | |||
| state: 1, | |||
| used_state: null, | |||
| node: null, | |||
| }, | |||
| { | |||
| id: 16, | |||
| computing_resource: 'GPU', | |||
| standard: | |||
| '{"name":"CPU-GPU","value":{"detail_type":"3060","gpu":0,"cpu":2,"memory":"4GB"}}', | |||
| description: 'GPU: 0, CPU:2, 内存: 4GB', | |||
| create_by: 'admin', | |||
| create_time: '2024-04-19T00:00:00.000+08:00', | |||
| update_by: 'admin', | |||
| update_time: '2024-04-19T00:00:00.000+08:00', | |||
| state: 1, | |||
| used_state: null, | |||
| node: null, | |||
| }, | |||
| { | |||
| id: 17, | |||
| computing_resource: 'GPU', | |||
| standard: | |||
| '{"name":"CPU-GPU","value":{"detail_type":"3060","gpu":0,"cpu":4,"memory":"8GB"}}', | |||
| description: 'GPU: 0, CPU:4, 内存: 8GB', | |||
| create_by: 'admin', | |||
| create_time: '2024-04-19T00:00:00.000+08:00', | |||
| update_by: 'admin', | |||
| update_time: '2024-04-19T00:00:00.000+08:00', | |||
| state: 1, | |||
| used_state: null, | |||
| node: null, | |||
| }, | |||
| { | |||
| id: 18, | |||
| computing_resource: 'GPU', | |||
| standard: | |||
| '{"name":"CPU-GPU","value":{"detail_type":"3060","gpu":1,"cpu":1,"memory":"2GB"}}', | |||
| description: 'GPU: 1, CPU:1, 内存: 2GB', | |||
| create_by: 'admin', | |||
| create_time: '2024-04-19T00:00:00.000+08:00', | |||
| update_by: 'admin', | |||
| update_time: '2024-04-19T00:00:00.000+08:00', | |||
| state: 1, | |||
| used_state: null, | |||
| node: null, | |||
| }, | |||
| { | |||
| id: 19, | |||
| computing_resource: 'GPU', | |||
| standard: | |||
| '{"name":"CPU-GPU","value":{"detail_type":"3060","gpu":1,"cpu":2,"memory":"4GB"}}', | |||
| description: 'GPU: 1, CPU:2, 内存: 4GB', | |||
| create_by: 'admin', | |||
| create_time: '2024-04-19T00:00:00.000+08:00', | |||
| update_by: 'admin', | |||
| update_time: '2024-04-19T00:00:00.000+08:00', | |||
| state: 1, | |||
| used_state: null, | |||
| node: null, | |||
| }, | |||
| { | |||
| id: 20, | |||
| computing_resource: 'GPU', | |||
| standard: | |||
| '{"name":"CPU-GPU","value":{"detail_type":"3060","gpu":1,"cpu":4,"memory":"8GB"}}', | |||
| description: 'GPU: 1, CPU:4, 内存: 8GB', | |||
| create_by: 'admin', | |||
| create_time: '2024-04-19T00:00:00.000+08:00', | |||
| update_by: 'admin', | |||
| update_time: '2024-04-19T00:00:00.000+08:00', | |||
| state: 1, | |||
| used_state: null, | |||
| node: null, | |||
| }, | |||
| { | |||
| id: 21, | |||
| computing_resource: 'GPU', | |||
| standard: | |||
| '{"name":"CPU-GPU","value":{"detail_type":"3060","gpu":2,"cpu":2,"memory":"4GB"}}', | |||
| description: 'GPU: 2, CPU:2, 内存: 4GB', | |||
| create_by: 'admin', | |||
| create_time: '2024-04-19T00:00:00.000+08:00', | |||
| update_by: 'admin', | |||
| update_time: '2024-04-19T00:00:00.000+08:00', | |||
| state: 1, | |||
| used_state: null, | |||
| node: null, | |||
| }, | |||
| { | |||
| id: 22, | |||
| computing_resource: 'GPU', | |||
| standard: | |||
| '{"name":"CPU-GPU","value":{"detail_type":"3060","gpu":2,"cpu":4,"memory":"8GB"}}', | |||
| description: 'GPU: 2, CPU:4, 内存: 8GB', | |||
| create_by: 'admin', | |||
| create_time: '2024-04-19T00:00:00.000+08:00', | |||
| update_by: 'admin', | |||
| update_time: '2024-04-19T00:00:00.000+08:00', | |||
| state: 1, | |||
| used_state: null, | |||
| node: null, | |||
| }, | |||
| { | |||
| id: 23, | |||
| computing_resource: 'GPU', | |||
| standard: | |||
| '{"name":"CPU-GPU","value":{"detail_type":"RTX 3080 Ti","gpu":1,"cpu":1,"memory":"2GB"}}', | |||
| description: 'GPU: 1, CPU:1, 内存: 2GB, 显存: 12GB', | |||
| create_by: 'admin', | |||
| create_time: '2024-04-19T11:38:07.000+08:00', | |||
| update_by: 'admin', | |||
| update_time: '2024-04-19T11:38:07.000+08:00', | |||
| state: 1, | |||
| used_state: null, | |||
| node: null, | |||
| }, | |||
| { | |||
| id: 24, | |||
| computing_resource: 'GPU', | |||
| standard: | |||
| '{"name":"CPU-GPU","value":{"detail_type":"RTX 3080","gpu":1,"cpu":2,"memory":"4GB"}}', | |||
| description: 'GPU: 1, CPU:2, 内存: 4GB, 显存: 10GB', | |||
| create_by: 'admin', | |||
| create_time: '2024-04-19T11:39:40.000+08:00', | |||
| update_by: 'admin', | |||
| update_time: '2024-04-19T11:39:40.000+08:00', | |||
| state: 1, | |||
| used_state: null, | |||
| node: null, | |||
| }, | |||
| ], | |||
| pageable: { | |||
| sort: { | |||
| unsorted: true, | |||
| sorted: false, | |||
| empty: true, | |||
| }, | |||
| pageNumber: 0, | |||
| pageSize: 1000, | |||
| offset: 0, | |||
| paged: true, | |||
| unpaged: false, | |||
| }, | |||
| last: true, | |||
| totalPages: 1, | |||
| totalElements: 10, | |||
| sort: { | |||
| unsorted: true, | |||
| sorted: false, | |||
| empty: true, | |||
| }, | |||
| first: true, | |||
| number: 0, | |||
| numberOfElements: 10, | |||
| size: 1000, | |||
| empty: false, | |||
| }, | |||
| }; | |||
| @@ -122,14 +122,14 @@ export const formatBoolean = (value: boolean): string => { | |||
| return value ? '是' : '否'; | |||
| }; | |||
| type FormatEnumFunc = (value: string | number) => string; | |||
| type FormatEnumFunc = (value: string | number) => React.ReactNode; | |||
| // 格式化枚举 | |||
| export const formatEnum = ( | |||
| options: { value: string | number; label: string }[], | |||
| options: { value?: string | number | null; label?: React.ReactNode }[], | |||
| ): FormatEnumFunc => { | |||
| return (value: string | number) => { | |||
| const option = options.find((item) => item.value === value); | |||
| return option ? option.label : '--'; | |||
| return option && option.label ? option.label : '--'; | |||
| }; | |||
| }; | |||