diff --git a/src/locales/en.json b/src/locales/en.json index 1a17a35..16ed9b2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -619,5 +619,7 @@ "Break Off": "Break Off", "Please enter a legal domain": "Please enter a legal domain", "External Access Domain": "External Access Domain", + "Failed to load task logs": "Failed to load task logs", + "Retry loading logs": "Retry loading logs", "WorkLoad": "WorkLoad" } diff --git a/src/locales/zh.json b/src/locales/zh.json index 0ba1519..452663a 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -619,5 +619,7 @@ "Break Off": "中断", "Please enter a legal domain": "请输入合法的域名", "External Access Domain": "外部访问域名", + "Failed to load task logs": "任务日志加载失败", + "Retry loading logs": "重新加载日志", "WorkLoad": "工作负载" } 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 a21d82b..35abb3a 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 { Spin } from 'antd'; +import { Alert, Button, Spin } from 'antd'; import { observer } from 'mobx-react'; import styles from './index.less'; import { useRootStore } from 'stores'; @@ -31,12 +31,29 @@ import { formatSeconds } from 'utils'; import { useInterval } from 'hooks'; function LogItemContent(props) { - const { isExpand, isLoading, logdata } = props; + const { isExpand, isLoading, logdata, logError, onRetry } = props; if (isExpand) { if (isLoading) { return ; } else { - return
{logdata}
; + return ( +
+ {logdata &&
{logdata}
} + {logError && ( + + {t('Retry loading logs')} + + } + /> + )} +
+ ); } } return ''; @@ -50,8 +67,14 @@ const LogItem = observer((props) => { nodeStatus ); logStore.isStepFinished = taskFinished; - const { logdata, isExpand, isLoading, cumulativeSize, isStepFinished } = - logStore; + const { + logdata, + logError, + isExpand, + isLoading, + cumulativeSize, + isStepFinished, + } = logStore; const stateIcons = (errIgnore, status) => { if (status === 'Succeeded') { @@ -93,11 +116,12 @@ const LogItem = observer((props) => { if (!taskName) return; if (!isExpand) { - logStore.getStepLog(params); + logStore.isExpand = true; + await logStore.getStepLog(params); } else { logStore.logReset(); + logStore.isExpand = false; } - logStore.isExpand = !isExpand; }; return ( @@ -113,6 +137,8 @@ const LogItem = observer((props) => { logStore.getStepLog(params)} isLoading={isLoading} /> diff --git a/src/stores/log.js b/src/stores/log.js index eaf2187..6275868 100644 --- a/src/stores/log.js +++ b/src/stores/log.js @@ -21,6 +21,8 @@ import { APIVERSION } from 'utils/constants'; export default class LogStore extends BaseStore { logdata = ''; + logError = ''; + isExpand = false; cumulativeSize = 0; @@ -33,6 +35,7 @@ export default class LogStore extends BaseStore { makeObservable(this, { isExpand: observable, logdata: observable, + logError: observable, }); } @@ -42,36 +45,43 @@ export default class LogStore extends BaseStore { 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 || ''; + this.logError = ''; + try { + 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 && - deliverySize > 0 && - this.cumulativeSize < res.logSize - ) { - params = { ...params, offset: this.cumulativeSize }; - await this.fetchStepLog(params); + if ( + this.isStepFinished && + deliverySize > 0 && + this.cumulativeSize < res.logSize + ) { + params = { ...params, offset: this.cumulativeSize }; + await this.fetchStepLog(params); + } + } catch (error) { + this.logError = + error?.reason || error?.message || t('Failed to load task logs'); + } finally { + this.isExpand = true; + this.isLoading = false; } - this.isExpand = true; - this.isLoading = false; } async getStepLog(params) { this.isLoading = true; await this.fetchStepLog(params); - this.isLoading = false; } logReset() { this.cumulativeSize = 0; this.logdata = ''; + this.logError = ''; } } diff --git a/src/stores/log.test.js b/src/stores/log.test.js new file mode 100644 index 0000000..5317f91 --- /dev/null +++ b/src/stores/log.test.js @@ -0,0 +1,82 @@ +/* + * 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 LogStore from './log'; + +jest.mock('utils/object.mapper', () => ({})); + +beforeEach(() => { + global.request = { get: jest.fn() }; +}); + +afterEach(() => { + delete global.request; +}); + +it('ends loading and exposes an error when the initial log request fails', async () => { + const store = new LogStore(); + request.get.mockRejectedValue({ + reason: 'agent Task log endpoint is unavailable', + }); + + await store.fetchStepLog({ taskName: 'task-1' }); + + expect(store.isLoading).toBe(false); + expect(store.isExpand).toBe(true); + expect(store.logError).toBe('agent Task log endpoint is unavailable'); + expect(store.logdata).toBe(''); + expect(store.cumulativeSize).toBe(0); +}); + +it('retries from the last received offset without losing or duplicating logs', async () => { + const store = new LogStore(); + store.isStepFinished = true; + request.get + .mockResolvedValueOnce({ content: 'first', deliverySize: 5, logSize: 11 }) + .mockRejectedValueOnce(new Error('connection lost')); + + await store.getStepLog({ taskName: 'task-1' }); + expect(store.isLoading).toBe(false); + expect(store.logError).toBe('connection lost'); + expect(store.logdata).toBe('first'); + expect(store.cumulativeSize).toBe(5); + + request.get.mockResolvedValueOnce({ + content: 'second', + deliverySize: 6, + logSize: 11, + }); + await store.getStepLog({ taskName: 'task-1', offset: store.cumulativeSize }); + + expect(request.get).toHaveBeenLastCalledWith( + 'api/operations.kubeclipper.io/v1alpha1/operationtasks/task-1/logs', + { offset: 5 } + ); + expect(store.logError).toBe(''); + expect(store.isLoading).toBe(false); + expect(store.logdata).toBe('firstsecond'); + expect(store.cumulativeSize).toBe(11); +}); + +it('ends loading and allows retry when expanding a failed log request', async () => { + const store = new LogStore(); + request.get.mockRejectedValue(new Error('request failed')); + + await store.getStepLog({ taskName: 'task-1' }); + + expect(store.isLoading).toBe(false); + expect(store.logError).toBe('request failed'); +});