diff --git a/src/locales/en.json b/src/locales/en.json
index ce5974ae..1a17a350 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -339,6 +339,7 @@
"{name} {action} successfully.": "{name} {action} successfully.",
"Image Repository": "Image Repository",
"Image Registry": "Image Registry",
+ "Optionally select a configured Registry resource. If none is selected, offline installation loads packaged images on each node.": "Optionally select a configured Registry resource. If none is selected, offline installation loads packaged images on each node.",
"Select a configured Registry resource. Its address and access settings will be used to pull offline installation images, and components inherit this Registry by default.": "Select a configured Registry resource. Its address and access settings will be used to pull offline installation images, and components inherit this Registry by default.",
"Optionally select a configured Registry resource for image pulls. If none is selected, images are pulled from the official registries.": "Optionally select a configured Registry resource for image pulls. If none is selected, images are pulled from the official registries.",
"Select a configured Registry resource for image pulls. If none is selected, online installation uses the official registries.": "Select a configured Registry resource for image pulls. If none is selected, online installation uses the official registries.",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index e5cc8a0d..0ba15198 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -339,6 +339,7 @@
"{name} {action} successfully.": "{name} {action} 成功。",
"Image Repository": "镜像仓库",
"Image Registry": "镜像仓库(Registry)",
+ "Optionally select a configured Registry resource. If none is selected, offline installation loads packaged images on each node.": "可选:选择已配置的 Registry 资源;未选择时离线安装会在各节点加载安装包中的镜像。",
"Select a configured Registry resource. Its address and access settings will be used to pull offline installation images, and components inherit this Registry by default.": "请选择已配置的 Registry 资源。系统将使用该资源的地址及访问配置拉取离线安装镜像,组件默认继承此 Registry。",
"Optionally select a configured Registry resource for image pulls. If none is selected, images are pulled from the official registries.": "可选:选择已配置的 Registry 资源用于拉取镜像;未选择时使用官方镜像仓库。",
"Select a configured Registry resource for image pulls. If none is selected, online installation uses the official registries.": "请选择已配置的 Registry 资源用于拉取镜像;未选择时在线安装使用官方镜像仓库。",
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 ba33f98c..766aadc6 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
@@ -19,7 +19,6 @@ import classNames from 'classnames';
import {
CaretRightOutlined,
CaretDownOutlined,
- ExclamationCircleFilled,
CheckCircleFilled,
CloseCircleFilled,
} from '@ant-design/icons';
@@ -44,36 +43,32 @@ function LogItemContent(props) {
}
const LogItem = observer((props) => {
- const { nodeStatus, logStore, runtime, nodes, index } = props;
- const { operationStore } = useRootStore();
- const step = operationStore.currentNodesByStep;
+ const { nodeStatus, taskName, logStore, runtime, nodes, index } = props;
const inputEl = useRef();
- const basicStepFinished = !!operationStore.currentNodesByStep?.status;
- logStore.isStepFinished = basicStepFinished;
+ const taskFinished = ['Succeeded', 'Failed', 'TimedOut', 'Canceled'].includes(
+ nodeStatus
+ );
+ logStore.isStepFinished = taskFinished;
const { logdata, isExpand, isLoading, cumulativeSize, isStepFinished } =
logStore;
const stateIcons = (errIgnore, status) => {
- if (errIgnore && status === 'failed') {
- return ;
- } else if (status === 'successful') {
+ if (status === 'Succeeded') {
return ;
- } else if (status === 'failed') {
+ } else if (['Failed', 'TimedOut', 'Canceled'].includes(status)) {
return ;
}
return '';
};
const params = {
- operation: operationStore.currentOperation.name,
- node: nodes.id,
- step: step.id,
+ taskName,
offset: cumulativeSize,
};
useEffect(() => {
- if (index === 0) {
+ if (index === 0 && taskName) {
const fn = async () => {
await logStore.fetchStepLog(params);
inputEl.current?.scrollIntoView({ block: 'end' });
@@ -81,7 +76,7 @@ const LogItem = observer((props) => {
fn();
}
- }, [step.id]);
+ }, [taskName]);
useInterval(
() => {
@@ -91,10 +86,12 @@ const LogItem = observer((props) => {
};
fn();
},
- isExpand && !isStepFinished ? 2000 : null
+ isExpand && !isStepFinished && taskName ? 2000 : null
);
const toggleExpand = async () => {
+ if (!taskName) return;
+
if (!isExpand) {
logStore.getStepLog(params);
} else {
@@ -107,10 +104,10 @@ const LogItem = observer((props) => {
{isExpand ? : }
- {`${nodes.ipv4} (${nodes.id})`}
+ {`${nodes.name || nodes.ipv4 || nodes.id}`}
{runtime || ''}
- {stateIcons(step.errIgnore, nodeStatus)}
+ {stateIcons(false, nodeStatus)}
{
function RightLogContent() {
const { operationStore } = useRootStore();
const { currentNodesByStep, activeStepIndex } = operationStore;
+ const logStores = useRef(new Map());
return useMemo(() => {
- // resolve
- if (currentNodesByStep?.status) {
- return (
-
- {currentNodesByStep?.status?.map(
- ({ node, status, startAt, endAt }, index) => (
-
- )
- )}
-
- );
- }
-
- // pending
+ const nodes = currentNodesByStep?.nodes || [];
+ const statuses = currentNodesByStep?.status || [];
return (
- {currentNodesByStep?.nodes?.map((item, index) => (
-
- ))}
+ {nodes.map((item, index) => {
+ const taskStatus = statuses[index] || {};
+ const taskName = taskStatus.taskName || item.taskName;
+ const storeKey =
+ taskName ||
+ `${item.uid || item.name || item.id || 'node'}-${index}`;
+ if (!logStores.current.has(storeKey)) {
+ logStores.current.set(storeKey, new LogStore());
+ }
+ return (
+
+ );
+ })}
);
}, [currentNodesByStep, activeStepIndex]);
diff --git a/src/pages/cluster/containers/Cluster/Detail/Operation/actions/PipelineLogDialog/index.jsx b/src/pages/cluster/containers/Cluster/Detail/Operation/actions/PipelineLogDialog/index.jsx
index 8b852623..722beac1 100644
--- a/src/pages/cluster/containers/Cluster/Detail/Operation/actions/PipelineLogDialog/index.jsx
+++ b/src/pages/cluster/containers/Cluster/Detail/Operation/actions/PipelineLogDialog/index.jsx
@@ -20,8 +20,8 @@ import { ViewAction } from 'containers/Action';
import { rootStore } from 'stores';
import WebsocketStore from 'stores/websocket';
import { getToken } from 'utils/localStorage';
+import { APIVERSION } from 'utils/constants';
import ObjectMapper from 'utils/object.mapper';
-import { findLastIndex, findLast } from 'lodash';
import RightLogContent from './RightLogContent';
import LeftSteps from './LeftSteps';
@@ -49,22 +49,35 @@ export default class PipelineLog extends ViewAction {
this.store.reset();
this.websocket = new WebsocketStore();
+ this.rawOperation = null;
+ this.taskTimer = null;
+ this.refreshing = false;
+ this.disposer = null;
}
componentDidMount() {
this.store.list.silent = true;
+ this.loadOperation();
this.initWebsocket();
+ this.taskTimer = setInterval(this.refreshTasks, 2000);
}
componentWillUnmount() {
this.store.list.silent = false;
this.websocket.close();
+ this.disposer?.();
+ if (this.taskTimer) {
+ clearInterval(this.taskTimer);
+ this.taskTimer = null;
+ }
}
initWebsocket = () => {
const token = getToken();
this.websocket.watch(
- `api/core.kubeclipper.io/v1/operations?fieldSelector=metadata.name=${this.item.name}&watch=true&token=${token}`
+ `api/operations.kubeclipper.io/v1alpha1/operations?fieldSelector=metadata.name=${encodeURIComponent(
+ this.item.name
+ )}&watch=true&token=${encodeURIComponent(token)}`
);
this.disposer = reaction(
@@ -73,56 +86,72 @@ export default class PipelineLog extends ViewAction {
message = toJS(message);
const types = ['ADDED', 'MODIFIED', 'DELETED'];
if (types.includes(message.type)) {
- const result = ObjectMapper.operations(message.object);
- this.store.status = result.status;
-
- this.generateStatusBySteps(result.operationSteps);
- this.store.currentOperation = result;
- this.store.operationSteps = result.operationSteps;
+ this.updateOperation(message.object);
}
}
);
};
- // 计算 step status
- generateStatusBySteps(operationSteps) {
- operationSteps.forEach((step, index) => {
- if (!step.status) {
- operationSteps[index].stepStatus = 'warning';
- } else {
- operationSteps[index].stepStatus = 'success';
- for (const node of step.status) {
- if (node.status === 'failed') {
- if (step.errignore) {
- operationSteps[index].stepStatus = 'warning';
- } else {
- operationSteps[index].stepStatus = 'failed';
- }
- break;
- }
- }
- }
- });
+ loadOperation = async () => {
+ try {
+ const operation = await request.get(
+ `${APIVERSION.operations}/operations/${encodeURIComponent(
+ this.item.name
+ )}`
+ );
+ await this.updateOperation(operation);
+ } catch (error) {
+ // The operation list already rendered the row. Keep the dialog usable
+ // while the watch connection retries after a transient read failure.
+ // eslint-disable-next-line no-console
+ console.log(error);
+ }
+ };
+
+ refreshTasks = async () => {
+ if (!this.rawOperation || this.refreshing) return;
+
+ this.refreshing = true;
+ try {
+ const tasks = await this.fetchTasks(this.rawOperation);
+ await this.updateOperation(this.rawOperation, tasks);
+ } catch (error) {
+ // Task creation/status updates are eventually consistent with the
+ // operation watch. The next refresh will retry without closing the log.
+ // eslint-disable-next-line no-console
+ console.log(error);
+ } finally {
+ this.refreshing = false;
+ }
+ };
- const lastIndex = findLastIndex(operationSteps, (item) => item.stepID);
- const lastStep = findLast(operationSteps, (item) => item.stepID);
+ fetchTasks = async (operation) => {
+ const uid = operation?.metadata?.uid;
+ if (!uid) return [];
- const msgPending = ['running'];
- // const msgResolve = ['successful', 'failed'];
+ const result = await request.get(
+ `${APIVERSION.operations}/operationtasks`,
+ { fieldSelector: `spec.operationRef.uid=${uid}` }
+ );
+ return result?.items || [];
+ };
- if (
- lastIndex !== operationSteps.length - 1 &&
- msgPending.includes(this.store.status)
- ) {
- const processSteps = operationSteps[lastIndex + 1];
- processSteps.frontStatus = 'pending';
- processSteps.stepStatus = 'processing';
+ updateOperation = async (operation, tasks) => {
+ if (!operation) return;
- this.activeByStep(processSteps, lastIndex + 1);
- } else {
- this.activeByStep(lastStep, lastIndex);
- }
- }
+ this.rawOperation = operation;
+ const operationTasks = tasks || (await this.fetchTasks(operation));
+ const result = ObjectMapper.operations(operation, operationTasks);
+ const { operationSteps } = result;
+
+ this.store.status = result.status;
+ this.store.currentOperation = result;
+ this.store.operationSteps = operationSteps;
+
+ const activeIndex = operationSteps.findIndex((step) => !step.isComplete);
+ const index = activeIndex === -1 ? operationSteps.length - 1 : activeIndex;
+ this.activeByStep(operationSteps[index], index);
+ };
activeByStep = async (step, index) => {
this.store.currentNodesByStep = step;
diff --git a/src/pages/cluster/containers/Cluster/Detail/Operation/actions/Retry.jsx b/src/pages/cluster/containers/Cluster/Detail/Operation/actions/Retry.jsx
index cf2d65f9..c25d93d6 100644
--- a/src/pages/cluster/containers/Cluster/Detail/Operation/actions/Retry.jsx
+++ b/src/pages/cluster/containers/Cluster/Detail/Operation/actions/Retry.jsx
@@ -41,15 +41,17 @@ export default class Index extends ConfirmAction {
isFailedStatus = (item) => {
const firstPage = operationStore.list.page;
const firstData = operationStore.list.data[0];
- const { id, status } = item;
+ const { id, phase } = item;
+ if (!firstData) return false;
+
if (
firstPage === 1 &&
firstData.id === id &&
- firstData.status === 'failed' &&
+ ['Failed', 'TimedOut'].includes(phase) &&
!this.notAllowAction.includes(firstData.operationName)
) {
return true;
- } else if (firstData.id === id && status === 'termination') {
+ } else if (firstData.id === id && phase === 'Canceled') {
return true;
}
@@ -71,6 +73,6 @@ export default class Index extends ConfirmAction {
onSubmit = (data) => {
const { id } = data;
- return operationStore.retry({ id });
+ return operationStore.retry({ id }, data);
};
}
diff --git a/src/pages/cluster/containers/Cluster/Detail/Operation/actions/Stop.jsx b/src/pages/cluster/containers/Cluster/Detail/Operation/actions/Stop.jsx
index a3e011bc..831e68ae 100644
--- a/src/pages/cluster/containers/Cluster/Detail/Operation/actions/Stop.jsx
+++ b/src/pages/cluster/containers/Cluster/Detail/Operation/actions/Stop.jsx
@@ -39,7 +39,7 @@ export default class Index extends ConfirmAction {
}
isDisableStatus(item) {
- return item.status === 'running';
+ return ['Pending', 'Running'].includes(item.phase);
}
allowedCheckFunc = (item) => this.isDisableStatus(item);
@@ -49,6 +49,6 @@ export default class Index extends ConfirmAction {
onSubmit = (data) => {
const { id } = data;
- return operationStore.stop({ id });
+ return operationStore.stop({ id }, data);
};
}
diff --git a/src/pages/cluster/containers/Cluster/Detail/Operation/index.jsx b/src/pages/cluster/containers/Cluster/Detail/Operation/index.jsx
index c6a46f11..60b7c100 100644
--- a/src/pages/cluster/containers/Cluster/Detail/Operation/index.jsx
+++ b/src/pages/cluster/containers/Cluster/Detail/Operation/index.jsx
@@ -58,7 +58,7 @@ function OperationList() {
name: 'operationName',
},
],
- transitionStatusList: ['running'],
+ transitionStatusList: ['Pending', 'Running'],
actionConfigs,
store,
detail: clusterStore.detail,
diff --git a/src/pages/cluster/containers/Cluster/actions/Create/Cluster.jsx b/src/pages/cluster/containers/Cluster/actions/Create/Cluster.jsx
index 08fc526c..4c4d040c 100644
--- a/src/pages/cluster/containers/Cluster/actions/Create/Cluster.jsx
+++ b/src/pages/cluster/containers/Cluster/actions/Create/Cluster.jsx
@@ -337,11 +337,6 @@ export default class Cluster extends BaseForm {
return Promise.reject(t('The selected node and schema are inconsistent'));
};
- checkImageRegistry = (rule, value) =>
- !value && this.isOffLine
- ? Promise.reject(t('Please select a Registry resource'))
- : Promise.resolve(true);
-
checkLabels = (rule, value = []) => {
const checkFunc = (item) => {
if (!item) return true;
@@ -441,11 +436,9 @@ export default class Cluster extends BaseForm {
label: t('Image Registry'),
type: 'select',
options: this.registryOptions,
- validator: this.checkImageRegistry,
- required: this.isOffLine,
extra: this.isOffLine
? t(
- 'Select a configured Registry resource. Its address and access settings will be used to pull offline installation images, and components inherit this Registry by default.'
+ 'Optionally select a configured Registry resource. If none is selected, offline installation loads packaged images on each node.'
)
: t(
'Optionally select a configured Registry resource for image pulls. If none is selected, images are pulled from the official registries.'
diff --git a/src/pages/cluster/containers/Cluster/actions/Upgrade.jsx b/src/pages/cluster/containers/Cluster/actions/Upgrade.jsx
index bf69bb9c..32065324 100644
--- a/src/pages/cluster/containers/Cluster/actions/Upgrade.jsx
+++ b/src/pages/cluster/containers/Cluster/actions/Upgrade.jsx
@@ -199,9 +199,8 @@ export default class Upgrade extends ModalAction {
type: 'select',
options: this.registryOptions,
hidden: !this.isOffLine,
- required: this.isOffLine,
tip: t(
- 'Select a configured Registry resource. Its address and access settings will be used to pull offline installation images, and components inherit this Registry by default.'
+ 'Optionally select a configured Registry resource. If none is selected, offline installation loads packaged images on each node.'
),
},
];
diff --git a/src/pages/cluster/containers/Template/cluster/actions/Create/Cluster.jsx b/src/pages/cluster/containers/Template/cluster/actions/Create/Cluster.jsx
index 862c5451..d1855c7c 100644
--- a/src/pages/cluster/containers/Template/cluster/actions/Create/Cluster.jsx
+++ b/src/pages/cluster/containers/Template/cluster/actions/Create/Cluster.jsx
@@ -345,11 +345,6 @@ export default class Cluster extends BaseForm {
return Promise.resolve(true);
};
- checkImageRegistry = (rule, value) =>
- !value && this.isOffLine
- ? Promise.reject(t('Please select a Registry resource'))
- : Promise.resolve(true);
-
checkLabels = (rule, value = []) => {
const checkFunc = (item) => {
if (!item) return true;
@@ -460,10 +455,8 @@ export default class Cluster extends BaseForm {
label: t('Image Registry'),
type: 'select',
options: this.registryOptions,
- validator: this.checkImageRegistry,
- required: this.isOffLine,
tip: t(
- 'Select a configured Registry resource for image pulls. If none is selected, online installation uses the official registries.'
+ 'Optionally select a configured Registry resource. If none is selected, offline installation loads packaged images on each node.'
),
},
{
diff --git a/src/resources/cluster.jsx b/src/resources/cluster.jsx
index a85e53ba..56b1c993 100644
--- a/src/resources/cluster.jsx
+++ b/src/resources/cluster.jsx
@@ -85,11 +85,12 @@ export const componentStatus = {
};
export const operationStatus = {
- running: t('Ongoing'),
- successful: t('Successful'),
- failed: t('Failed'),
- unknown: t('Unknown'),
- termination: t('Break Off'),
+ Pending: t('Ongoing'),
+ Running: t('Ongoing'),
+ Succeeded: t('Successful'),
+ Failed: t('Failed'),
+ TimedOut: t('Failed'),
+ Canceled: t('Break Off'),
};
export const reclaimPolicyOption = [
diff --git a/src/stores/log.js b/src/stores/log.js
index f130696b..eaf21876 100644
--- a/src/stores/log.js
+++ b/src/stores/log.js
@@ -16,6 +16,7 @@
import BaseStore from './base';
import { makeObservable, observable } from 'mobx';
+import { APIVERSION } from 'utils/constants';
export default class LogStore extends BaseStore {
logdata = '';
@@ -36,13 +37,28 @@ export default class LogStore extends BaseStore {
}
async fetchStepLog(params) {
- const res = await request.get(`${this.apiVersion}/logs`, params);
- this.cumulativeSize += res.deliverySize || 0;
+ if (!params.taskName) {
+ this.isLoading = false;
+ return;
+ }
+
+ const res = await request.get(
+ `${APIVERSION.operations}/operationtasks/${encodeURIComponent(
+ params.taskName
+ )}/logs`,
+ { offset: params.offset || 0 }
+ );
+ const deliverySize = res.deliverySize || 0;
+ this.cumulativeSize += deliverySize;
this.logdata += res.content || '';
- if (this.isStepFinished && this.cumulativeSize < res.logSize) {
+ if (
+ this.isStepFinished &&
+ deliverySize > 0 &&
+ this.cumulativeSize < res.logSize
+ ) {
params = { ...params, offset: this.cumulativeSize };
- this.fetchStepLog(params);
+ await this.fetchStepLog(params);
}
this.isExpand = true;
this.isLoading = false;
diff --git a/src/stores/operation.js b/src/stores/operation.js
index 30c5dd79..10f5ec05 100644
--- a/src/stores/operation.js
+++ b/src/stores/operation.js
@@ -16,6 +16,8 @@
import BaseStore from './base';
import { makeObservable, observable } from 'mobx';
+import { get } from 'lodash';
+import { APIVERSION } from 'utils/constants';
class OperationStore extends BaseStore {
operations = [];
@@ -28,8 +30,20 @@ class OperationStore extends BaseStore {
activeStepIndex = 0;
+ operationQueryKey = '';
+
+ operationContinueTokens = {};
+
module = 'operations';
+ get apiType() {
+ return 'operations';
+ }
+
+ get taskListUrl() {
+ return `${APIVERSION.operations}/operationtasks`;
+ }
+
constructor(props) {
super(props);
@@ -47,18 +61,137 @@ class OperationStore extends BaseStore {
return items;
}
- retry(params, data) {
- return this.submitting(
- request.post(`${this.getDetailUrl(params)}/retry`, data)
- );
+ async fetchTasks(operationUID) {
+ const result = await request.get(this.taskListUrl, {
+ fieldSelector: `spec.operationRef.uid=${operationUID}`,
+ });
+ return get(result, 'items', []);
}
- stop(params) {
+ control(params, action, data) {
+ const operation =
+ data || this.list.data.find((item) => item.id === params.id);
+ const uid = get(operation, 'uid');
+ const resourceVersion = get(operation, 'resourceVersion');
+
+ if (!uid || !resourceVersion) {
+ return Promise.reject(
+ new Error('operation UID and resourceVersion are required')
+ );
+ }
+
return this.submitting(
- request.post(`${this.getDetailUrl(params)}/termination`)
+ request.post(`${this.getDetailUrl(params)}/${action}`, {
+ uid,
+ resourceVersion,
+ })
);
}
+ retry(params, data) {
+ return this.control(params, 'retry', data);
+ }
+
+ stop(params, data) {
+ return this.control(params, 'cancel', data);
+ }
+
+ /*
+ * Operation V2 uses Kubernetes ListOptions. Keep the page-number UI while
+ * translating it to the server-side limit/continue cursor API.
+ */
+ async fetchList({ more, ...params } = {}) {
+ !this.list.silent && this.list.reset();
+
+ const page = Number(params.page) || 1;
+ const limit = Number(params.limit) || 10;
+ const query = { ...params };
+ delete query.page;
+ delete query.limit;
+ delete query.reverse;
+ delete query.silent;
+ const operationName = query.operationName;
+ delete query.operationName;
+ const queryKey = JSON.stringify({ query, operationName, limit });
+ if (page === 1 || queryKey !== this.operationQueryKey) {
+ this.operationQueryKey = queryKey;
+ this.operationContinueTokens = {};
+ }
+
+ // The operation-name search is a client-side contains filter. Fetching a
+ // complete filtered result keeps that behavior until the API exposes a
+ // matching field selector; normal browsing remains cursor-paginated.
+ const paged = !operationName && limit > 0;
+ const requestQuery = { ...query };
+ if (paged) {
+ requestQuery.limit = limit;
+ }
+
+ let result = {};
+ let data = [];
+ if (paged) {
+ let currentPage = 1;
+ let continueToken = '';
+ while (
+ currentPage < page &&
+ this.operationContinueTokens[currentPage]
+ ) {
+ continueToken = this.operationContinueTokens[currentPage];
+ currentPage += 1;
+ }
+ while (currentPage <= page) {
+ const pageQuery = {
+ ...requestQuery,
+ ...(continueToken ? { continue: continueToken } : {}),
+ };
+ // Cursor pages must be fetched in order because each token is issued
+ // by the preceding response.
+ // eslint-disable-next-line no-await-in-loop
+ result = (await request.get(this.getListUrl(), pageQuery)) || {};
+ data = this.getListData(result);
+ this.operationContinueTokens[currentPage] = get(
+ result,
+ 'metadata.continue',
+ ''
+ );
+ continueToken = this.operationContinueTokens[currentPage];
+ if (currentPage < page && !continueToken) {
+ data = [];
+ break;
+ }
+ currentPage += 1;
+ }
+ } else {
+ result = (await request.get(this.getListUrl(), requestQuery)) || {};
+ data = this.getListData(result).filter(
+ (item) =>
+ !operationName ||
+ String(item.operationName || '').includes(String(operationName))
+ );
+ }
+
+ const newData = await this.listDidFetch(data, query);
+ const remaining = get(result, 'metadata.remainingItemCount');
+ const pageOffset = (page - 1) * limit;
+ const total = paged
+ ? Number.isFinite(Number(remaining))
+ ? pageOffset + data.length + Number(remaining)
+ : pageOffset + data.length
+ : data.length;
+
+ this.list.update({
+ data: more ? [...this.list.data, ...newData] : newData,
+ total,
+ limit,
+ page,
+ ...query,
+ ...(operationName ? { operationName } : {}),
+ isLoading: false,
+ ...(this.list.silent ? {} : { selectedRowKeys: [] }),
+ });
+ return newData;
+ }
+
reset() {
this.currentOperation = {};
this.operationSteps = [];
diff --git a/src/utils/constants.js b/src/utils/constants.js
index 009d0499..50becffa 100644
--- a/src/utils/constants.js
+++ b/src/utils/constants.js
@@ -100,6 +100,7 @@ export const statusMap = {
export const APIVERSION = {
core: 'api/core.kubeclipper.io/v1',
+ operations: 'api/operations.kubeclipper.io/v1alpha1',
iam: 'api/iam.kubeclipper.io/v1',
config: 'api/config.kubeclipper.io/v1',
audit: 'api/audit.kubeclipper.io/v1',
diff --git a/src/utils/object.mapper.js b/src/utils/object.mapper.js
index 5f4ea744..5127f931 100644
--- a/src/utils/object.mapper.js
+++ b/src/utils/object.mapper.js
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { get, omit, merge, intersectionBy, has, set } from 'lodash';
+import { get, omit, intersectionBy, has, set } from 'lodash';
import { status as nodeStatus } from 'resources/node';
import { formatRoleRules, safeParseJSON } from 'utils';
import { INTERNAL_ROLE_DES } from 'utils/constants';
@@ -229,22 +229,122 @@ const ClusterTemplateMapper = (item) => {
};
};
+const operationTerminalPhases = ['Succeeded', 'Failed', 'TimedOut', 'Canceled'];
+
+const taskStatusByPhase = (phase) => phase || 'Pending';
+
+const latestTaskForTarget = (tasks, step, target) => {
+ const candidates = tasks.filter((task) => {
+ const taskStepID = get(task, 'spec.stepID');
+ const taskNode = get(task, 'spec.nodeRef', {});
+ return (
+ taskStepID === step.id &&
+ (taskNode.uid === target.uid || taskNode.name === target.name)
+ );
+ });
+
+ return candidates.sort((left, right) => {
+ const retryGeneration =
+ Number(get(left, 'spec.retryGeneration', 0)) -
+ Number(get(right, 'spec.retryGeneration', 0));
+ if (retryGeneration !== 0) return retryGeneration;
+
+ const attempt =
+ Number(get(left, 'spec.attempt', 0)) -
+ Number(get(right, 'spec.attempt', 0));
+ if (attempt !== 0) return attempt;
+
+ return String(get(left, 'metadata.creationTimestamp', '')).localeCompare(
+ String(get(right, 'metadata.creationTimestamp', ''))
+ );
+ })[candidates.length - 1];
+};
+
+const stepName = (step) =>
+ get(step, 'payload.step.name') || get(step, 'payload.name') || step.id;
+
+const mapOperationStep = (step, tasks) => {
+ const nodes = (step.targets || []).map((target) => {
+ const task = latestTaskForTarget(tasks, step, target);
+ const phase = taskStatusByPhase(get(task, 'status.phase'));
+
+ return {
+ id: target.name,
+ name: target.name,
+ uid: target.uid,
+ // Operation V2 exposes the stable node name, not the node IP.
+ ipv4: target.name,
+ taskName: get(task, 'metadata.name'),
+ status: phase,
+ startAt: get(task, 'status.startedAt'),
+ endAt: get(task, 'status.finishedAt'),
+ reason: get(task, 'status.result.reason'),
+ message: get(task, 'status.result.message'),
+ };
+ });
+
+ const phases = nodes.map((node) => node.status);
+ const hasFailure = phases.some((phase) =>
+ ['Failed', 'TimedOut', 'Canceled'].includes(phase)
+ );
+ const isComplete =
+ phases.length > 0 &&
+ phases.every((phase) => operationTerminalPhases.includes(phase));
+ const isSuccessful =
+ isComplete && phases.every((phase) => phase === 'Succeeded');
+ const hasRunning = phases.some((phase) => phase === 'Running');
+ const hasStartedTask = nodes.some((node) => node.taskName);
+ const hasPending = phases.some((phase) => phase === 'Pending');
+
+ return {
+ stepID: step.id,
+ name: stepName(step),
+ nodes,
+ status: nodes.map((node) => ({
+ node: node.id,
+ status: node.status,
+ startAt: node.startAt,
+ endAt: node.endAt,
+ taskName: node.taskName,
+ reason: node.reason,
+ message: node.message,
+ })),
+ errIgnore: get(step, 'payload.step.errIgnore', false),
+ isComplete,
+ stepStatus: hasFailure
+ ? 'failed'
+ : isSuccessful
+ ? 'success'
+ : hasRunning || (hasPending && hasStartedTask)
+ ? 'processing'
+ : 'warning',
+ };
+};
+
/**
- * 操作记录
- * @param {*} item
- * @returns
+ * Operation V2 mapper. The UI keeps the step-oriented view, while the API
+ * stores per-node execution state as OperationTask resources.
*/
-const OperationMapper = (item) => {
- const conditions = get(item, 'status.conditions') || [];
- const steps = get(item, 'steps');
-
- const operationSteps = merge(conditions, steps);
+const OperationMapper = (item, tasks) => {
+ // OperationMapper is also passed directly to Array.prototype.map by the
+ // operation store. In that case the second argument is the item index, not
+ // an OperationTask list.
+ const operationTasks = Array.isArray(tasks) ? tasks : [];
+ const operationSteps = (get(item, 'spec.steps') || []).map((step) =>
+ mapOperationStep(step, operationTasks)
+ );
return {
...getBaseInfo(item),
+ uid: get(item, 'metadata.uid'),
+ phase: get(item, 'status.phase'),
+ status: get(item, 'status.phase'),
+ reason: get(item, 'status.reason'),
+ message: get(item, 'status.message'),
operationSteps,
- status: get(item, 'status.status'),
- operationName: get(item, 'metadata.labels["kubeclipper.io/operation"]'),
+ operationName: get(item, 'spec.action'),
+ targetRef: get(item, 'spec.targetRef'),
+ _originData: item,
};
};
diff --git a/src/utils/object.mapper.test.js b/src/utils/object.mapper.test.js
new file mode 100644
index 00000000..56266fe1
--- /dev/null
+++ b/src/utils/object.mapper.test.js
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2021 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 ObjectMapper from './object.mapper';
+
+jest.mock('resources/node', () => ({ status: {} }), { virtual: true });
+jest.mock(
+ 'utils',
+ () => ({ formatRoleRules: jest.fn(), safeParseJSON: jest.fn() }),
+ { virtual: true }
+);
+jest.mock('utils/constants', () => ({ INTERNAL_ROLE_DES: {} }), {
+ virtual: true,
+});
+jest.mock(
+ 'resources/common',
+ () => ({
+ computeAutoDetectionReversion: jest.fn(),
+ }),
+ { virtual: true }
+);
+
+describe('operation mapper', () => {
+ const operation = {
+ metadata: {
+ name: 'operation-1',
+ uid: 'operation-uid',
+ creationTimestamp: '2026-09-07T00:00:00Z',
+ },
+ spec: {
+ action: 'CreateCluster',
+ steps: [
+ {
+ id: 'step-1',
+ targets: [{ name: 'node-1', uid: 'node-uid' }],
+ payload: { step: { name: 'installRuntime' } },
+ },
+ ],
+ },
+ status: { phase: 'Running' },
+ };
+
+ it('does not treat the Array.map index as operation tasks', () => {
+ const result = [operation].map(ObjectMapper.operations)[0];
+
+ expect(result.operationSteps[0].nodes[0].status).toBe('Pending');
+ expect(result.operationSteps[0].name).toBe('installRuntime');
+ });
+
+ it('uses an explicitly supplied operation task list', () => {
+ const result = ObjectMapper.operations(operation, [
+ {
+ metadata: { name: 'task-1', creationTimestamp: '2026-09-07T00:00:01Z' },
+ spec: {
+ stepID: 'step-1',
+ nodeRef: { name: 'node-1', uid: 'node-uid' },
+ },
+ status: { phase: 'Succeeded' },
+ },
+ ]);
+
+ expect(result.operationSteps[0].nodes[0].status).toBe('Succeeded');
+ expect(result.operationSteps[0].nodes[0].taskName).toBe('task-1');
+ });
+});