| @@ -0,0 +1,92 @@ | |||
| export const createWebSocketMock = () => { | |||
| class WebSocketMock { | |||
| constructor(url) { | |||
| this.url = url; | |||
| this.readyState = WebSocket.OPEN; | |||
| this.listeners = {}; | |||
| this.count = 0; | |||
| console.log("Mock WebSocket connected to:", url); | |||
| // 模拟服务器推送消息 | |||
| this.intervalId = setInterval(() => { | |||
| this.count += 1; | |||
| if (this.count > 5) { | |||
| this.count = 0; | |||
| clearInterval(this.intervalId); | |||
| return; | |||
| } | |||
| this.sendMessage(JSON.stringify(logStreamData)); | |||
| }, 3000); | |||
| } | |||
| sendMessage(data) { | |||
| if (this.listeners["message"]) { | |||
| this.listeners["message"].forEach((callback) => callback({ data })); | |||
| } | |||
| } | |||
| addEventListener(event, callback) { | |||
| if (!this.listeners[event]) { | |||
| this.listeners[event] = []; | |||
| } | |||
| this.listeners[event].push(callback); | |||
| } | |||
| removeEventListener(event, callback) { | |||
| if (this.listeners[event]) { | |||
| this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback); | |||
| } | |||
| } | |||
| close() { | |||
| this.readyState = WebSocket.CLOSED; | |||
| console.log("Mock WebSocket closed"); | |||
| } | |||
| } | |||
| return WebSocketMock; | |||
| }; | |||
| export const logStreamData = { | |||
| streams: [ | |||
| { | |||
| stream: { | |||
| workflows_argoproj_io_completed: 'false', | |||
| workflows_argoproj_io_workflow: 'workflow-p2ddj', | |||
| container: 'init', | |||
| filename: | |||
| '/var/log/pods/argo_workflow-p2ddj-git-clone-f33abcda-3988047653_e31cf6be-e013-4885-9eb6-ec84f83b9ba9/init/0.log', | |||
| job: 'argo/workflow-p2ddj-git-clone-f33abcda-3988047653', | |||
| namespace: 'argo', | |||
| pod: 'workflow-p2ddj-git-clone-f33abcda-3988047653', | |||
| stream: 'stderr', | |||
| }, | |||
| values: [ | |||
| [ | |||
| '1742179591969785990', | |||
| 'time="2025-03-17T02:46:31.969Z" level=info msg="Starting Workflow Executor" version=v3.5.10\n', | |||
| ], | |||
| ], | |||
| }, | |||
| { | |||
| stream: { | |||
| filename: | |||
| '/var/log/pods/argo_workflow-p2ddj-git-clone-f33abcda-3988047653_e31cf6be-e013-4885-9eb6-ec84f83b9ba9/init/0.log', | |||
| job: 'argo/workflow-p2ddj-git-clone-f33abcda-3988047653', | |||
| namespace: 'argo', | |||
| pod: 'workflow-p2ddj-git-clone-f33abcda-3988047653', | |||
| stream: 'stderr', | |||
| workflows_argoproj_io_completed: 'false', | |||
| workflows_argoproj_io_workflow: 'workflow-p2ddj', | |||
| container: 'init', | |||
| }, | |||
| values: [ | |||
| [ | |||
| '1742179591973414064', | |||
| 'time="2025-03-17T02:46:31.973Z" level=info msg="Using executor retry strategy" Duration=1s Factor=1.6 Jitter=0.5 Steps=5\n', | |||
| ], | |||
| ], | |||
| }, | |||
| ], | |||
| }; | |||
| @@ -5,6 +5,7 @@ import type { Preview } from '@storybook/react'; | |||
| import { App, ConfigProvider } from 'antd'; | |||
| import zhCN from 'antd/locale/zh_CN'; | |||
| import { initialize, mswLoader } from 'msw-storybook-addon'; | |||
| import { createWebSocketMock } from './mock/websocket.mock'; | |||
| import './storybook.css'; | |||
| /* | |||
| @@ -14,6 +15,10 @@ import './storybook.css'; | |||
| */ | |||
| initialize(); | |||
| // 替换全局 WebSocket 为 Mock 版本 | |||
| // @ts-ignore | |||
| global.WebSocket = createWebSocketMock(); | |||
| const preview: Preview = { | |||
| parameters: { | |||
| controls: { | |||
| @@ -37,19 +37,20 @@ function LogGroup({ | |||
| const [completed, setCompleted] = useState(false); | |||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | |||
| const [_isMouseDown, setIsMouseDown, isMouseDownRef] = useStateRef(false); | |||
| const preStatusRef = useRef<ExperimentStatus | undefined>(undefined); | |||
| const socketRef = useRef<WebSocket | undefined>(undefined); | |||
| const retryRef = useRef(2); // 等待 2 秒,重试 3 次 | |||
| const elementRef = useRef<HTMLDivElement | null>(null); | |||
| const logElementRef = useRef<HTMLDivElement | null>(null); | |||
| // 如果是【运行中】状态,设置 hasRun 为 true,【运行中】或者从【运行中】切换到别的状态时,不显示【更多】按钮 | |||
| const [hasRun, setHasRun] = useState(false); | |||
| if (status === ExperimentStatus.Running && !hasRun) { | |||
| setHasRun(true); | |||
| } | |||
| useEffect(() => { | |||
| scrollToBottom(false); | |||
| if (status === ExperimentStatus.Running) { | |||
| setupSockect(); | |||
| } else if (preStatusRef.current === ExperimentStatus.Running) { | |||
| setCompleted(true); | |||
| } | |||
| preStatusRef.current = status; | |||
| scrollToBottom(false); | |||
| }, [status]); | |||
| // 鼠标拖到中不滚动到底部 | |||
| @@ -208,15 +209,17 @@ function LogGroup({ | |||
| // }; | |||
| // element.scrollTo(optons); | |||
| // } | |||
| elementRef?.current?.scrollIntoView({ block: 'end', behavior: smooth ? 'smooth' : 'instant' }); | |||
| logElementRef?.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 !== ''; | |||
| const showMoreBtn = !hasRun && !completed && showLog && logText !== ''; | |||
| return ( | |||
| <div className={styles['log-group']} ref={elementRef}> | |||
| <div className={styles['log-group']} ref={logElementRef}> | |||
| {log_type === 'resource' && ( | |||
| <div className={styles['log-group__pod']} onClick={handleCollapse}> | |||
| <div className={styles['log-group__pod__name']}>{pod_name}</div> | |||
| @@ -15,13 +15,21 @@ export type ExperimentLog = { | |||
| }; | |||
| type LogListProps = { | |||
| instanceName: string; // 实验实例 name | |||
| instanceNamespace: string; // 实验实例 namespace | |||
| pipelineNodeId: string; // 流水线节点 id | |||
| workflowId?: string; // 实验实例工作流 id | |||
| instanceNodeStartTime?: string; // 实验实例节点开始运行时间 | |||
| /** 实验实例 name */ | |||
| instanceName: string; | |||
| /** 实验实例 namespace */ | |||
| instanceNamespace: string; | |||
| /** 流水线节点 id */ | |||
| pipelineNodeId: string; | |||
| /** 实验实例工作流 id */ | |||
| workflowId?: string; | |||
| /** 实验实例节点开始运行时间 */ | |||
| instanceNodeStartTime?: string; | |||
| /** 实验实例节点运行状态 */ | |||
| instanceNodeStatus?: ExperimentStatus; | |||
| /** 自定义类名 */ | |||
| className?: string; | |||
| /** 自定义样式 */ | |||
| style?: React.CSSProperties; | |||
| }; | |||
| @@ -35,23 +43,21 @@ function LogList({ | |||
| className, | |||
| style, | |||
| }: LogListProps) { | |||
| const [logList, setLogList] = useState<ExperimentLog[]>([]); | |||
| const preStatusRef = useRef<ExperimentStatus | undefined>(undefined); | |||
| const [logGroups, setLogGroups] = useState<ExperimentLog[]>([]); | |||
| const retryRef = useRef(3); // 等待 2 秒,重试 3 次 | |||
| // 当实例节点运行状态不是 Pending,而上一个运行状态不存在或者是 Pending 时,获取实验日志 | |||
| // 当实例节点运行状态不是 Pending,获取实验日志组 | |||
| useEffect(() => { | |||
| if ( | |||
| instanceNodeStatus && | |||
| instanceNodeStatus !== ExperimentStatus.Pending && | |||
| (!preStatusRef.current || preStatusRef.current === ExperimentStatus.Pending) | |||
| logGroups.length === 0 | |||
| ) { | |||
| getExperimentLog(); | |||
| } | |||
| preStatusRef.current = instanceNodeStatus; | |||
| }, [instanceNodeStatus]); | |||
| // 获取实验日志 | |||
| // 获取实验 Pods 组 | |||
| const getExperimentLog = async () => { | |||
| const start_time = dayjs(instanceNodeStartTime).valueOf() * 1.0e6; | |||
| const params = { | |||
| @@ -71,7 +77,7 @@ function LogList({ | |||
| log_type, | |||
| }, | |||
| ]; | |||
| setLogList(list); | |||
| setLogGroups(list); | |||
| } else if (log_type === 'resource') { | |||
| const list = pods.map((v: string) => ({ | |||
| log_type, | |||
| @@ -79,7 +85,7 @@ function LogList({ | |||
| log_content: '', | |||
| start_time, | |||
| })); | |||
| setLogList(list); | |||
| setLogGroups(list); | |||
| } | |||
| } else { | |||
| if (retryRef.current > 0) { | |||
| @@ -93,8 +99,8 @@ function LogList({ | |||
| return ( | |||
| <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} />) | |||
| {logGroups.length > 0 ? ( | |||
| logGroups.map((v) => <LogGroup key={v.pod_name} {...v} status={instanceNodeStatus} />) | |||
| ) : ( | |||
| <div className={styles['log-list__empty']}>暂无日志</div> | |||
| )} | |||
| @@ -192,7 +192,7 @@ function HyperParameterInstance() { | |||
| key: TabKeys.History, | |||
| label: '寻优列表', | |||
| icon: <KFIcon type="icon-Trialliebiao" />, | |||
| children: <ExperimentHistory trialList={instanceInfo?.trial_list} />, | |||
| children: <ExperimentHistory trialList={instanceInfo?.trial_list ?? []} />, | |||
| }, | |||
| ]; | |||
| @@ -35,7 +35,7 @@ function ExperimentHistory({ trialList = [] }: ExperimentHistoryProps) { | |||
| }, []); | |||
| // 计算 column | |||
| const first: HyperParameterTrial | undefined = trialList[0]; | |||
| const first: HyperParameterTrial | undefined = trialList ? trialList[0] : undefined; | |||
| const config: Record<string, any> = first?.config ?? {}; | |||
| const metricAnalysis: Record<string, any> = first?.metric_analysis ?? {}; | |||
| const paramsNames = Object.keys(config); | |||
| @@ -1,3 +1,4 @@ | |||
| // 数据集列表 | |||
| export const datasetListData = { | |||
| msg: '操作成功', | |||
| code: 200, | |||
| @@ -76,6 +77,7 @@ export const datasetListData = { | |||
| }, | |||
| }; | |||
| // 数据集版本列表 | |||
| export const datasetVersionData = { | |||
| msg: '操作成功', | |||
| code: 200, | |||
| @@ -107,6 +109,7 @@ export const datasetVersionData = { | |||
| ], | |||
| }; | |||
| // 数据集详情 | |||
| export const datasetDetailData = { | |||
| msg: '操作成功', | |||
| code: 200, | |||
| @@ -137,6 +140,7 @@ export const datasetDetailData = { | |||
| }, | |||
| }; | |||
| // 模型列表 | |||
| export const modelListData = { | |||
| msg: '操作成功', | |||
| code: 200, | |||
| @@ -211,6 +215,7 @@ export const modelListData = { | |||
| }, | |||
| }; | |||
| // 模型版本列表 | |||
| export const modelVersionData = { | |||
| msg: '操作成功', | |||
| code: 200, | |||
| @@ -234,6 +239,7 @@ export const modelVersionData = { | |||
| ], | |||
| }; | |||
| // 模型详情 | |||
| export const modelDetailData = { | |||
| msg: '操作成功', | |||
| code: 200, | |||
| @@ -266,6 +272,7 @@ export const modelDetailData = { | |||
| }, | |||
| }; | |||
| // 镜像列表 | |||
| export const mirrorListData = { | |||
| code: 200, | |||
| msg: '操作成功', | |||
| @@ -384,6 +391,7 @@ export const mirrorListData = { | |||
| }, | |||
| }; | |||
| // 镜像版本列表 | |||
| export const mirrorVerionData = { | |||
| code: 200, | |||
| msg: '操作成功', | |||
| @@ -433,6 +441,7 @@ export const mirrorVerionData = { | |||
| }, | |||
| }; | |||
| // 代码配置列表 | |||
| export const codeListData = { | |||
| code: 200, | |||
| msg: '操作成功', | |||
| @@ -547,6 +556,7 @@ export const codeListData = { | |||
| }, | |||
| }; | |||
| // 服务列表 | |||
| export const serviceListData = { | |||
| code: 200, | |||
| msg: '操作成功', | |||
| @@ -620,6 +630,7 @@ export const serviceListData = { | |||
| }, | |||
| }; | |||
| // 计算资源列表 | |||
| export const computeResourceData = { | |||
| code: 200, | |||
| msg: '操作成功', | |||
| @@ -793,3 +804,45 @@ export const computeResourceData = { | |||
| empty: false, | |||
| }, | |||
| }; | |||
| // 日志组 | |||
| export const logGroupData = { | |||
| code: 200, | |||
| msg: '操作成功', | |||
| data: { | |||
| log_type: 'normal', | |||
| log_detail: { | |||
| pod_name: 'workflow-txpb5-git-clone-05955a53-2484323670', | |||
| log_content: | |||
| '[2025-03-06 14:02:23] time="2025-03-06T14:02:23.068Z" level=info msg="capturing logs" argo=true\n[2025-03-06 14:02:23] Cloning into \'/tmp/traincode\'...\n[2025-03-06 14:02:23] Cloning public repository without authentication.\n[2025-03-06 14:02:23] Repository cloned successfully.\n[2025-03-06 14:02:24] time="2025-03-06T14:02:24.069Z" level=info msg="sub-process exited" argo=true error="<nil>"\n', | |||
| start_time: '1741240944069759628', | |||
| }, | |||
| pods: ['workflow-txpb5-git-clone-05955a53-2484323670'], | |||
| }, | |||
| }; | |||
| // 日志 | |||
| export const logData = { | |||
| code: 200, | |||
| msg: '操作成功', | |||
| data: { | |||
| log_detail: { | |||
| pod_name: 'workflow-txpb5-git-clone-05955a53-2484323670', | |||
| log_content: | |||
| '[2025-03-06 14:02:24] time="2025-03-06T06:02:24.315Z" level=info msg="Main container completed" error="<nil>"\n[2025-03-06 14:02:24] time="2025-03-06T06:02:24.315Z" level=info msg="No Script output reference in workflow. Capturing script output ignored"\n[2025-03-06 14:02:24] time="2025-03-06T06:02:24.315Z" level=info msg="No output parameters"\n[2025-03-06 14:02:24] time="2025-03-06T06:02:24.315Z" level=info msg="No output artifacts"\n[2025-03-06 14:02:24] time="2025-03-06T06:02:24.315Z" level=info msg="S3 Save path: /tmp/argo/outputs/logs/main.log, key: workflow-txpb5/workflow-txpb5-git-clone-05955a53-2484323670/main.log"\n[2025-03-06 14:02:24] time="2025-03-06T06:02:24.315Z" level=info msg="Creating minio client using static credentials" endpoint="minio.argo.svc.cluster.local:9000"\n[2025-03-06 14:02:24] time="2025-03-06T06:02:24.315Z" level=info msg="Saving file to s3" bucket=my-bucket endpoint="minio.argo.svc.cluster.local:9000" key=workflow-txpb5/workflow-txpb5-git-clone-05955a53-2484323670/main.log path=/tmp/argo/outputs/logs/main.log\n[2025-03-06 14:02:25] time="2025-03-06T06:02:25.407Z" level=info msg="Save artifact" artifactName=main-logs duration=1.092064185s error="<nil>" key=workflow-txpb5/workflow-txpb5-git-clone-05955a53-2484323670/main.log\n[2025-03-06 14:02:25] time="2025-03-06T06:02:25.407Z" level=info msg="not deleting local artifact" localArtPath=/tmp/argo/outputs/logs/main.log\n[2025-03-06 14:02:25] time="2025-03-06T06:02:25.407Z" level=info msg="Successfully saved file: /tmp/argo/outputs/logs/main.log"\n[2025-03-06 14:02:25] time="2025-03-06T06:02:25.408Z" level=warning msg="failed to patch task result, falling back to legacy/insecure pod patch, see https://argo-workflows.readthedocs.io/en/release-3.5/workflow-rbac/" error="workflowtaskresults.argoproj.io is forbidden: User \\"system:serviceaccount:argo:argo\\" cannot create resource \\"workflowtaskresults\\" in API group \\"argoproj.io\\" in the namespace \\"argo\\""\n[2025-03-06 14:02:25] time="2025-03-06T06:02:25.418Z" level=info msg="Alloc=8553 TotalAlloc=14914 Sys=24421 NumGC=4 Goroutines=10"\n[2025-03-06 14:02:25] time="2025-03-06T06:02:25.419Z" level=warning msg="failed to patch task result, falling back to legacy/insecure pod patch, see https://argo-workflows.readthedocs.io/en/release-3.5/workflow-rbac/" error="workflowtaskresults.argoproj.io \\"workflow-txpb5-2484323670\\" is forbidden: User \\"system:serviceaccount:argo:argo\\" cannot patch resource \\"workflowtaskresults\\" in API group \\"argoproj.io\\" in the namespace \\"argo\\""\n[2025-03-06 14:02:25] time="2025-03-06T06:02:25.427Z" level=info msg="Deadline monitor stopped"\n', | |||
| start_time: '1741240945427542429', | |||
| }, | |||
| }, | |||
| }; | |||
| export const logEmptyData = { | |||
| code: 200, | |||
| msg: '操作成功', | |||
| data: { | |||
| log_detail: { | |||
| pod_name: 'workflow-txpb5-git-clone-05955a53-2484323670', | |||
| log_content: '', | |||
| start_time: '1741240945427542429', | |||
| }, | |||
| }, | |||
| }; | |||
| @@ -0,0 +1,90 @@ | |||
| import { ExperimentStatus } from '@/enums'; | |||
| import LogList from '@/pages/Experiment/components/LogList'; | |||
| import type { Meta, StoryObj } from '@storybook/react'; | |||
| import { http, HttpResponse } from 'msw'; | |||
| import { logData, logEmptyData, logGroupData } from '../mockData'; | |||
| let count = 0; | |||
| // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export | |||
| const meta = { | |||
| title: 'Pages/LogList 日志组', | |||
| component: LogList, | |||
| 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.post('/api/mmp/experimentIns/realTimeLog', () => { | |||
| return HttpResponse.json(logGroupData); | |||
| }), | |||
| http.get('/api/mmp/experimentIns/pods/log', () => { | |||
| if (count > 0) { | |||
| count = 0; | |||
| return HttpResponse.json(logEmptyData); | |||
| } | |||
| count += 1; | |||
| return HttpResponse.json(logData); | |||
| }), | |||
| ], | |||
| }, | |||
| }, | |||
| // 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 | |||
| argTypes: { | |||
| instanceNodeStatus: { control: 'select', options: Object.values(ExperimentStatus) }, | |||
| }, | |||
| // 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() }, | |||
| } satisfies Meta<typeof LogList>; | |||
| export default meta; | |||
| type Story = StoryObj<typeof meta>; | |||
| // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args | |||
| /** 运行完成时 */ | |||
| export const Succeeded: Story = { | |||
| args: { | |||
| instanceName: 'workflow-txpb5', | |||
| instanceNamespace: 'argo', | |||
| pipelineNodeId: 'git-clone-05955a53', | |||
| workflowId: 'workflow-txpb5-2484323670', | |||
| instanceNodeStartTime: '2025-03-06 14:02:14', | |||
| instanceNodeStatus: ExperimentStatus.Succeeded, | |||
| }, | |||
| }; | |||
| /** 无状态,空数据 */ | |||
| export const NoStatus: Story = { | |||
| args: { | |||
| instanceName: 'workflow-txpb5', | |||
| instanceNamespace: 'argo', | |||
| pipelineNodeId: 'git-clone-05955a53', | |||
| workflowId: 'workflow-txpb5-2484323670', | |||
| instanceNodeStartTime: '2025-03-06 14:02:14', | |||
| }, | |||
| }; | |||
| /** Pending */ | |||
| export const Pending: Story = { | |||
| args: { | |||
| instanceName: 'workflow-txpb5', | |||
| instanceNamespace: 'argo', | |||
| pipelineNodeId: 'git-clone-05955a53', | |||
| workflowId: 'workflow-txpb5-2484323670', | |||
| instanceNodeStartTime: '2025-03-06 14:02:14', | |||
| instanceNodeStatus: ExperimentStatus.Pending, | |||
| }, | |||
| }; | |||
| export const Running: Story = { | |||
| args: { | |||
| instanceName: 'workflow-txpb5', | |||
| instanceNamespace: 'argo', | |||
| pipelineNodeId: 'git-clone-05955a53', | |||
| workflowId: 'workflow-txpb5-2484323670', | |||
| instanceNodeStartTime: '2025-03-06 14:02:14', | |||
| instanceNodeStatus: ExperimentStatus.Running, | |||
| }, | |||
| }; | |||
| @@ -88,7 +88,10 @@ export function camelCaseToUnderscore(obj: Record<string, any>) { | |||
| } | |||
| // null to undefined | |||
| export function nullToUndefined(obj: Record<string, any>) { | |||
| export function nullToUndefined(obj: Record<string, any> | null) { | |||
| if (obj === null) { | |||
| return undefined; | |||
| } | |||
| if (!isPlainObject(obj)) { | |||
| return obj; | |||
| } | |||
| @@ -111,7 +114,10 @@ export function nullToUndefined(obj: Record<string, any>) { | |||
| } | |||
| // undefined to null | |||
| export function undefinedToNull(obj: Record<string, any>) { | |||
| export function undefinedToNull(obj?: Record<string, any>) { | |||
| if (obj === undefined) { | |||
| return null; | |||
| } | |||
| if (!isPlainObject(obj)) { | |||
| return obj; | |||
| } | |||