From 4d8704aabc032aa172fa1811c153947e3ea32490 Mon Sep 17 00:00:00 2001 From: cp3hnu Date: Mon, 17 Mar 2025 11:11:22 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E4=BC=98=E5=8C=96=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- react-ui/.storybook/mock/websocket.mock.js | 92 +++++++++++++++++++ react-ui/.storybook/preview.tsx | 5 + .../Experiment/components/LogGroup/index.tsx | 23 +++-- .../Experiment/components/LogList/index.tsx | 36 +++++--- .../pages/HyperParameter/Instance/index.tsx | 2 +- .../components/ExperimentHistory/index.tsx | 2 +- react-ui/src/stories/mockData.ts | 53 +++++++++++ .../src/stories/pages/LogList.stories.tsx | 90 ++++++++++++++++++ react-ui/src/utils/index.ts | 10 +- 9 files changed, 284 insertions(+), 29 deletions(-) create mode 100644 react-ui/.storybook/mock/websocket.mock.js create mode 100644 react-ui/src/stories/pages/LogList.stories.tsx diff --git a/react-ui/.storybook/mock/websocket.mock.js b/react-ui/.storybook/mock/websocket.mock.js new file mode 100644 index 00000000..3661b99b --- /dev/null +++ b/react-ui/.storybook/mock/websocket.mock.js @@ -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', + ], + ], + }, + ], +}; \ No newline at end of file diff --git a/react-ui/.storybook/preview.tsx b/react-ui/.storybook/preview.tsx index 61e82aaa..0ec22de0 100644 --- a/react-ui/.storybook/preview.tsx +++ b/react-ui/.storybook/preview.tsx @@ -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: { diff --git a/react-ui/src/pages/Experiment/components/LogGroup/index.tsx b/react-ui/src/pages/Experiment/components/LogGroup/index.tsx index 5123fae1..11d51aca 100644 --- a/react-ui/src/pages/Experiment/components/LogGroup/index.tsx +++ b/react-ui/src/pages/Experiment/components/LogGroup/index.tsx @@ -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(undefined); const socketRef = useRef(undefined); const retryRef = useRef(2); // 等待 2 秒,重试 3 次 - const elementRef = useRef(null); + const logElementRef = useRef(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 ( -
+
{log_type === 'resource' && (
{pod_name}
diff --git a/react-ui/src/pages/Experiment/components/LogList/index.tsx b/react-ui/src/pages/Experiment/components/LogList/index.tsx index 86c97d15..15ce0def 100644 --- a/react-ui/src/pages/Experiment/components/LogList/index.tsx +++ b/react-ui/src/pages/Experiment/components/LogList/index.tsx @@ -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([]); - const preStatusRef = useRef(undefined); + const [logGroups, setLogGroups] = useState([]); 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 (
- {logList.length > 0 ? ( - logList.map((v) => ) + {logGroups.length > 0 ? ( + logGroups.map((v) => ) ) : (
暂无日志
)} diff --git a/react-ui/src/pages/HyperParameter/Instance/index.tsx b/react-ui/src/pages/HyperParameter/Instance/index.tsx index df25cc18..aa206059 100644 --- a/react-ui/src/pages/HyperParameter/Instance/index.tsx +++ b/react-ui/src/pages/HyperParameter/Instance/index.tsx @@ -192,7 +192,7 @@ function HyperParameterInstance() { key: TabKeys.History, label: '寻优列表', icon: , - children: , + children: , }, ]; diff --git a/react-ui/src/pages/HyperParameter/components/ExperimentHistory/index.tsx b/react-ui/src/pages/HyperParameter/components/ExperimentHistory/index.tsx index c4698464..d9091d26 100644 --- a/react-ui/src/pages/HyperParameter/components/ExperimentHistory/index.tsx +++ b/react-ui/src/pages/HyperParameter/components/ExperimentHistory/index.tsx @@ -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 = first?.config ?? {}; const metricAnalysis: Record = first?.metric_analysis ?? {}; const paramsNames = Object.keys(config); diff --git a/react-ui/src/stories/mockData.ts b/react-ui/src/stories/mockData.ts index 11b5ffa2..e5ea5bd1 100644 --- a/react-ui/src/stories/mockData.ts +++ b/react-ui/src/stories/mockData.ts @@ -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=""\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=""\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="" 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', + }, + }, +}; diff --git a/react-ui/src/stories/pages/LogList.stories.tsx b/react-ui/src/stories/pages/LogList.stories.tsx new file mode 100644 index 00000000..5685b8fa --- /dev/null +++ b/react-ui/src/stories/pages/LogList.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +// 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, + }, +}; diff --git a/react-ui/src/utils/index.ts b/react-ui/src/utils/index.ts index 3deb9832..4df48c1c 100644 --- a/react-ui/src/utils/index.ts +++ b/react-ui/src/utils/index.ts @@ -88,7 +88,10 @@ export function camelCaseToUnderscore(obj: Record) { } // null to undefined -export function nullToUndefined(obj: Record) { +export function nullToUndefined(obj: Record | null) { + if (obj === null) { + return undefined; + } if (!isPlainObject(obj)) { return obj; } @@ -111,7 +114,10 @@ export function nullToUndefined(obj: Record) { } // undefined to null -export function undefinedToNull(obj: Record) { +export function undefinedToNull(obj?: Record) { + if (obj === undefined) { + return null; + } if (!isPlainObject(obj)) { return obj; }