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'); + }); +});