From f1eec38b29da4a1cd85cb5d8fd1d788325d3131a Mon Sep 17 00:00:00 2001 From: lixd Date: Fri, 23 Oct 2026 16:55:15 +0800 Subject: [PATCH 1/2] fix(operation): show task failures and preserve readable logs Signed-off-by: lixd --- src/locales/en.json | 14 +- src/locales/zh.json | 14 +- .../PipelineLogDialog/RightLogContent.jsx | 143 ++++++---- .../RightLogContent.test.jsx | 174 ++++++++++++ .../actions/PipelineLogDialog/index.jsx | 67 +++-- .../actions/PipelineLogDialog/index.less | 173 ++++++++--- .../actions/PipelineLogDialog/index.test.jsx | 160 +++++++++++ src/stores/log.js | 71 +++-- src/stores/log.test.js | 268 ++++++++++++++---- 9 files changed, 900 insertions(+), 184 deletions(-) create mode 100644 src/pages/cluster/containers/Cluster/Detail/Operation/actions/PipelineLogDialog/RightLogContent.test.jsx create mode 100644 src/pages/cluster/containers/Cluster/Detail/Operation/actions/PipelineLogDialog/index.test.jsx diff --git a/src/locales/en.json b/src/locales/en.json index 16ed9b21..43e19aef 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -621,5 +621,17 @@ "External Access Domain": "External Access Domain", "Failed to load task logs": "Failed to load task logs", "Retry loading logs": "Retry loading logs", - "WorkLoad": "WorkLoad" + "WorkLoad": "WorkLoad", + "Log read failed; current content is preserved": "Log read failed; current content is preserved", + "Operation read failed": "Operation read failed", + "Task read failed; current content is preserved": "Task read failed; current content is preserved", + "Execution logs": "Execution logs", + "No log output yet": "No log output yet", + "Execution failed": "Execution failed", + "Task message": "Task message", + "Succeeded": "Succeeded", + "Running": "Running", + "Pending": "Pending", + "TimedOut": "TimedOut", + "Canceled": "Canceled" } diff --git a/src/locales/zh.json b/src/locales/zh.json index 452663a6..f0b99db9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -621,5 +621,17 @@ "External Access Domain": "外部访问域名", "Failed to load task logs": "任务日志加载失败", "Retry loading logs": "重新加载日志", - "WorkLoad": "工作负载" + "WorkLoad": "工作负载", + "Log read failed; current content is preserved": "日志读取失败,已保留当前内容", + "Operation read failed": "操作信息读取失败", + "Task read failed; current content is preserved": "任务状态读取失败,已保留当前内容", + "Execution logs": "执行日志", + "No log output yet": "暂无日志输出", + "Execution failed": "执行失败", + "Task message": "任务消息", + "Succeeded": "成功", + "Running": "执行中", + "Pending": "等待执行", + "TimedOut": "已超时", + "Canceled": "已取消" } diff --git a/src/pages/cluster/containers/Cluster/Detail/Operation/actions/PipelineLogDialog/RightLogContent.jsx b/src/pages/cluster/containers/Cluster/Detail/Operation/actions/PipelineLogDialog/RightLogContent.jsx index 35abb3af..eb7788e8 100644 --- a/src/pages/cluster/containers/Cluster/Detail/Operation/actions/PipelineLogDialog/RightLogContent.jsx +++ b/src/pages/cluster/containers/Cluster/Detail/Operation/actions/PipelineLogDialog/RightLogContent.jsx @@ -22,7 +22,7 @@ import { CheckCircleFilled, CloseCircleFilled, } from '@ant-design/icons'; -import { Alert, Button, Spin } from 'antd'; +import { Spin, Button } from 'antd'; import { observer } from 'mobx-react'; import styles from './index.less'; import { useRootStore } from 'stores'; @@ -30,38 +30,34 @@ import LogStore from 'stores/log'; import { formatSeconds } from 'utils'; import { useInterval } from 'hooks'; -function LogItemContent(props) { - const { isExpand, isLoading, logdata, logError, onRetry } = props; - if (isExpand) { - if (isLoading) { - return ; - } else { - return ( -
- {logdata &&
{logdata}
} - {logError && ( - - {t('Retry loading logs')} - - } - /> - )} -
- ); - } - } - return ''; +function LogItemContent({ isExpand, isLoading, logdata }) { + if (!isExpand) return null; + return ( +
+
{t('Execution logs')}
+ {isLoading && !logdata ? ( + + ) : ( +
+          {logdata || t('No log output yet')}
+        
+ )} +
+ ); } const LogItem = observer((props) => { - const { nodeStatus, taskName, logStore, runtime, nodes, index } = props; - const inputEl = useRef(); + const { + nodeStatus, + taskName, + logStore, + runtime, + nodes, + index, + message, + reason, + } = props; + const initiallyExpanded = useRef(index === 0); const taskFinished = ['Succeeded', 'Failed', 'TimedOut', 'Canceled'].includes( nodeStatus @@ -69,11 +65,11 @@ const LogItem = observer((props) => { logStore.isStepFinished = taskFinished; const { logdata, - logError, isExpand, isLoading, cumulativeSize, isStepFinished, + error, } = logStore; const stateIcons = (errIgnore, status) => { @@ -91,21 +87,27 @@ const LogItem = observer((props) => { }; useEffect(() => { - if (index === 0 && taskName) { + if (initiallyExpanded.current) logStore.isExpand = true; + return () => logStore.logReset(); + }, [taskName, logStore]); + + useEffect(() => { + if (logStore.isExpand && taskName) { const fn = async () => { - await logStore.fetchStepLog(params); - inputEl.current?.scrollIntoView({ block: 'end' }); + await logStore.fetchStepLog({ + taskName, + offset: logStore.cumulativeSize, + }); }; fn(); } - }, [taskName]); + }, [taskName, nodeStatus, index, logStore]); useInterval( () => { const fn = async () => { await logStore.fetchStepLog(params); - inputEl.current?.scrollIntoView({ block: 'end' }); }; fn(); }, @@ -115,30 +117,73 @@ const LogItem = observer((props) => { const toggleExpand = async () => { if (!taskName) return; + logStore.isExpand = !isExpand; if (!isExpand) { - logStore.isExpand = true; - await logStore.getStepLog(params); + logStore.getStepLog(params); } else { logStore.logReset(); - logStore.isExpand = false; } }; + const failed = ['Failed', 'TimedOut', 'Canceled'].includes(nodeStatus); + const statusClass = failed + ? 'status-failed' + : nodeStatus === 'Succeeded' + ? 'status-succeeded' + : 'status-running'; + return ( -
-
- {isExpand ? : } - {nodes.ipv4 ? `${nodes.id}(${nodes.ipv4})` : nodes.id} +
+
+ + {(message || reason) && ( +
+
+ {' '} + {t(failed ? 'Execution failed' : 'Task message')} +
+
{message || reason}
+
+ )} + {error && ( +
+ + {t('Log read failed; current content is preserved')}: {error} + + +
+ )} logStore.getStepLog(params)} isLoading={isLoading} />
@@ -169,6 +214,8 @@ function RightLogContent() { key={index} logStore={logStores.current.get(storeKey)} nodeStatus={taskStatus.status || 'Pending'} + message={taskStatus.message} + reason={taskStatus.reason} taskName={taskName} runtime={ taskStatus.startAt diff --git a/src/pages/cluster/containers/Cluster/Detail/Operation/actions/PipelineLogDialog/RightLogContent.test.jsx b/src/pages/cluster/containers/Cluster/Detail/Operation/actions/PipelineLogDialog/RightLogContent.test.jsx new file mode 100644 index 00000000..d2ec3caa --- /dev/null +++ b/src/pages/cluster/containers/Cluster/Detail/Operation/actions/PipelineLogDialog/RightLogContent.test.jsx @@ -0,0 +1,174 @@ +/* + * Copyright 2026 KubeClipper Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; +import RightLogContent from './RightLogContent'; + +let mockNodeStatus; +const mockLogStores = []; + +jest.mock( + 'stores', + () => ({ + useRootStore: () => ({ + operationStore: { + currentNodesByStep: { + nodes: [{ id: 'node-1', ipv4: '192.168.1.20' }], + status: [mockNodeStatus], + }, + activeStepIndex: 0, + }, + }), + }), + { virtual: true } +); +jest.mock('stores/log', () => ({ + __esModule: true, + default: class LogStore { + logdata = ''; + + isExpand = false; + + isLoading = false; + + cumulativeSize = 0; + + fetchStepLog = jest.fn(() => Promise.resolve()); + + logReset = jest.fn(); + + getStepLog = jest.fn(() => Promise.resolve()); + + constructor() { + mockLogStores.push(this); + } + }, +})); +jest.mock('hooks', () => ({ useInterval: () => {} }), { virtual: true }); +jest.mock('utils', () => ({ formatSeconds: () => '' }), { virtual: true }); +jest.mock('mobx-react', () => ({ observer: (component) => component })); +jest.mock('antd', () => ({ + Spin: () => null, + Button: ({ children, onClick }) => ( + + ), +})); +jest.mock('@ant-design/icons', () => ({ + CaretRightOutlined: () => null, + CaretDownOutlined: () => null, + CheckCircleFilled: () => null, + CloseCircleFilled: () => null, +})); + +describe('operation task failure', () => { + beforeEach(() => { + global.t = (value) => value; + }); + + it('shows the task error even when the log is empty and collapsed', () => { + mockNodeStatus = { + status: 'Failed', + taskName: 'task-1', + message: 'failed to restart unit containerd.service: failed', + }; + + const html = renderToStaticMarkup(); + + expect(html).toContain('role="alert"'); + expect(html).toContain(mockNodeStatus.message); + }); + + it('shows the reason when a failed task has no message', () => { + mockNodeStatus = { status: 'TimedOut', reason: 'DeadlineExceeded' }; + + expect(renderToStaticMarkup()).toContain( + 'DeadlineExceeded' + ); + }); + + it('renders an error as text', () => { + mockNodeStatus = { + status: 'Failed', + message: '', + }; + + const html = renderToStaticMarkup(); + + expect(html).toContain('<script>'); + expect(html).not.toContain('