Browse Source

chore: 优化日志组件

pull/180/head
cp3hnu 11 months ago
parent
commit
4d8704aabc
9 changed files with 284 additions and 29 deletions
  1. +92
    -0
      react-ui/.storybook/mock/websocket.mock.js
  2. +5
    -0
      react-ui/.storybook/preview.tsx
  3. +13
    -10
      react-ui/src/pages/Experiment/components/LogGroup/index.tsx
  4. +21
    -15
      react-ui/src/pages/Experiment/components/LogList/index.tsx
  5. +1
    -1
      react-ui/src/pages/HyperParameter/Instance/index.tsx
  6. +1
    -1
      react-ui/src/pages/HyperParameter/components/ExperimentHistory/index.tsx
  7. +53
    -0
      react-ui/src/stories/mockData.ts
  8. +90
    -0
      react-ui/src/stories/pages/LogList.stories.tsx
  9. +8
    -2
      react-ui/src/utils/index.ts

+ 92
- 0
react-ui/.storybook/mock/websocket.mock.js View File

@@ -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
- 0
react-ui/.storybook/preview.tsx View File

@@ -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: {


+ 13
- 10
react-ui/src/pages/Experiment/components/LogGroup/index.tsx View File

@@ -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>


+ 21
- 15
react-ui/src/pages/Experiment/components/LogList/index.tsx View File

@@ -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>
)}


+ 1
- 1
react-ui/src/pages/HyperParameter/Instance/index.tsx View File

@@ -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 ?? []} />,
},
];



+ 1
- 1
react-ui/src/pages/HyperParameter/components/ExperimentHistory/index.tsx View File

@@ -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);


+ 53
- 0
react-ui/src/stories/mockData.ts View File

@@ -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',
},
},
};

+ 90
- 0
react-ui/src/stories/pages/LogList.stories.tsx View File

@@ -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,
},
};

+ 8
- 2
react-ui/src/utils/index.ts View File

@@ -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;
}


Loading…
Cancel
Save