diff --git a/src/locales/en.json b/src/locales/en.json
index 16ed9b2..43e19ae 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 452663a..f0b99db 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 35abb3a..eb7788e 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 0000000..d2ec3ca
--- /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('