From 3c109ffab133f42f8ae0aa2cf41c5612b70efefc Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Wed, 11 Jan 2023 15:08:00 +0100 Subject: [PATCH] refactor(editor): Overhaul workflow level executions list (#5089) * fix(editor): update texts and styles * fix(editor): update texts and styles * fix(editor): move 'No execution found' to sidebar * fix(editor): change empty state title in executions * fix(editor): workflow execution list delete item * fix(editor): workflow execution always show sidebar * fix(editor): workflow execution unify date display mode * fix(editor): workflow execution empty list --- .../src/components/ExecutionsList.vue | 1150 +++++++++-------- .../ExecutionsView/ExecutionCard.vue | 10 +- .../ExecutionsInfoAccordion.vue | 24 +- .../ExecutionsView/ExecutionsLandingPage.vue | 3 - .../ExecutionsView/ExecutionsList.vue | 45 +- .../ExecutionsView/ExecutionsSidebar.vue | 19 +- .../src/components/MainHeader/MainHeader.vue | 2 +- .../editor-ui/src/mixins/executionsHelpers.ts | 12 +- .../editor-ui/src/mixins/genericHelpers.ts | 7 +- .../src/plugins/i18n/locales/en.json | 6 +- 10 files changed, 641 insertions(+), 637 deletions(-) diff --git a/packages/editor-ui/src/components/ExecutionsList.vue b/packages/editor-ui/src/components/ExecutionsList.vue index 27f84f39f8..8845678e74 100644 --- a/packages/editor-ui/src/components/ExecutionsList.vue +++ b/packages/editor-ui/src/components/ExecutionsList.vue @@ -238,6 +238,7 @@ import { externalHooks } from '@/mixins/externalHooks'; import { VIEWS } from '@/constants'; import { restApi } from '@/mixins/restApi'; import { genericHelpers } from '@/mixins/genericHelpers'; +import { executionHelpers } from '@/mixins/executionsHelpers'; import { showMessage } from '@/mixins/showMessage'; import { IExecutionsCurrentSummaryExtended, @@ -253,618 +254,623 @@ import { mapStores } from 'pinia'; import { useUIStore } from '@/stores/ui'; import { useWorkflowsStore } from '@/stores/workflows'; -export default mixins(externalHooks, genericHelpers, restApi, showMessage).extend({ - name: 'ExecutionsList', - components: { - ExecutionTime, - WorkflowActivator, - }, - data() { - return { - finishedExecutions: [] as IExecutionsSummary[], - finishedExecutionsCount: 0, - finishedExecutionsCountEstimated: false, +export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, showMessage).extend( + { + name: 'ExecutionsList', + components: { + ExecutionTime, + WorkflowActivator, + }, + data() { + return { + finishedExecutions: [] as IExecutionsSummary[], + finishedExecutionsCount: 0, + finishedExecutionsCountEstimated: false, - checkAll: false, - autoRefresh: true, - autoRefreshInterval: undefined as undefined | NodeJS.Timer, + checkAll: false, + autoRefresh: true, + autoRefreshInterval: undefined as undefined | NodeJS.Timer, - filter: { - status: 'ALL', - workflowId: 'ALL', - }, - - isDataLoading: false, - - requestItemsPerRequest: 10, - - selectedItems: {} as { [key: string]: boolean }, - - stoppingExecutions: [] as string[], - workflows: [] as IWorkflowShortResponse[], - }; - }, - async created() { - await this.loadWorkflows(); - await this.refreshData(); - this.handleAutoRefreshToggle(); - - this.$externalHooks().run('executionsList.openDialog'); - this.$telemetry.track('User opened Executions log', { - workflow_id: this.workflowsStore.workflowId, - }); - }, - beforeDestroy() { - if (this.autoRefreshInterval) { - clearInterval(this.autoRefreshInterval); - this.autoRefreshInterval = undefined; - } - }, - computed: { - ...mapStores(useUIStore, useWorkflowsStore), - statuses() { - return [ - { - id: 'ALL', - name: this.$locale.baseText('executionsList.anyStatus'), + filter: { + status: 'ALL', + workflowId: 'ALL', }, - { - id: 'error', - name: this.$locale.baseText('executionsList.error'), - }, - { - id: 'running', - name: this.$locale.baseText('executionsList.running'), - }, - { - id: 'success', - name: this.$locale.baseText('executionsList.success'), - }, - { - id: 'waiting', - name: this.$locale.baseText('executionsList.waiting'), - }, - ]; - }, - activeExecutions(): IExecutionsCurrentSummaryExtended[] { - return this.workflowsStore.activeExecutions; - }, - combinedExecutions(): IExecutionsSummary[] { - const returnData: IExecutionsSummary[] = []; - if (['ALL', 'running'].includes(this.filter.status)) { - returnData.push(...this.activeExecutions); - } - if (['ALL', 'error', 'success', 'waiting'].includes(this.filter.status)) { - returnData.push(...this.finishedExecutions); - } + isDataLoading: false, - return returnData; - }, - combinedExecutionsCount(): number { - return 0 + this.activeExecutions.length + this.finishedExecutionsCount; - }, - numSelected(): number { - if (this.checkAll) { - return this.finishedExecutionsCount; - } + requestItemsPerRequest: 10, - return Object.keys(this.selectedItems).length; - }, - isIndeterminate(): boolean { - if (this.checkAll) { - return false; - } + selectedItems: {} as { [key: string]: boolean }, - return this.numSelected > 0; + stoppingExecutions: [] as string[], + workflows: [] as IWorkflowShortResponse[], + }; }, - workflowFilterCurrent(): IDataObject { - const filter: IDataObject = {}; - if (this.filter.workflowId !== 'ALL') { - filter.workflowId = this.filter.workflowId; - } - return filter; - }, - workflowFilterPast(): IDataObject { - const filter: IDataObject = {}; - if (this.filter.workflowId !== 'ALL') { - filter.workflowId = this.filter.workflowId; - } - if (this.filter.status === 'waiting') { - filter.waitTill = true; - } else if (['error', 'success'].includes(this.filter.status)) { - filter.finished = this.filter.status === 'success'; - } - return filter; - }, - }, - methods: { - closeDialog() { - this.$emit('closeModal'); - }, - formatDate(epochTime: number) { - const { date, time } = this.convertToDisplayDate(epochTime); - return this.$locale.baseText('executionsList.started', { interpolate: { time, date } }); - }, - displayExecution(execution: IExecutionsSummary) { - const route = this.$router.resolve({ - name: VIEWS.EXECUTION_PREVIEW, - params: { name: execution.workflowId, executionId: execution.id }, + async created() { + await this.loadWorkflows(); + await this.refreshData(); + this.handleAutoRefreshToggle(); + + this.$externalHooks().run('executionsList.openDialog'); + this.$telemetry.track('User opened Executions log', { + workflow_id: this.workflowsStore.workflowId, }); - window.open(route.href, '_blank'); }, - handleAutoRefreshToggle() { + beforeDestroy() { if (this.autoRefreshInterval) { - // Clear any previously existing intervals (if any - there shouldn't) clearInterval(this.autoRefreshInterval); this.autoRefreshInterval = undefined; } - - if (this.autoRefresh) { - this.autoRefreshInterval = setInterval(() => this.loadAutoRefresh(), 4 * 1000); // refresh data every 4 secs - } }, - handleCheckAllChange() { - if (!this.checkAll) { - Vue.set(this, 'selectedItems', {}); - } - }, - handleCheckboxChanged(executionId: string) { - if (this.selectedItems[executionId]) { - Vue.delete(this.selectedItems, executionId); - } else { - Vue.set(this.selectedItems, executionId, true); - } - }, - async handleDeleteSelected() { - const deleteExecutions = await this.confirmMessage( - this.$locale.baseText('executionsList.confirmMessage.message', { - interpolate: { numSelected: this.numSelected.toString() }, - }), - this.$locale.baseText('executionsList.confirmMessage.headline'), - 'warning', - this.$locale.baseText('executionsList.confirmMessage.confirmButtonText'), - this.$locale.baseText('executionsList.confirmMessage.cancelButtonText'), - ); + computed: { + ...mapStores(useUIStore, useWorkflowsStore), + statuses() { + return [ + { + id: 'ALL', + name: this.$locale.baseText('executionsList.anyStatus'), + }, + { + id: 'error', + name: this.$locale.baseText('executionsList.error'), + }, + { + id: 'running', + name: this.$locale.baseText('executionsList.running'), + }, + { + id: 'success', + name: this.$locale.baseText('executionsList.success'), + }, + { + id: 'waiting', + name: this.$locale.baseText('executionsList.waiting'), + }, + ]; + }, + activeExecutions(): IExecutionsCurrentSummaryExtended[] { + return this.workflowsStore.activeExecutions; + }, + combinedExecutions(): IExecutionsSummary[] { + const returnData: IExecutionsSummary[] = []; - if (!deleteExecutions) { - return; - } - - this.isDataLoading = true; - - const sendData: IExecutionDeleteFilter = {}; - if (this.checkAll) { - sendData.deleteBefore = this.finishedExecutions[0].startedAt as Date; - } else { - sendData.ids = Object.keys(this.selectedItems); - } - - sendData.filters = this.workflowFilterPast; - - try { - await this.restApi().deleteExecutions(sendData); - let removedCurrentlyLoadedExecution = false; - let removedActiveExecution = false; - const currentWorkflow: string = this.workflowsStore.workflowId; - const activeExecution: IExecutionsSummary | null = - this.workflowsStore.activeWorkflowExecution; - // Also update current workflow executions view if needed - for (const selectedId of Object.keys(this.selectedItems)) { - const execution: IExecutionsSummary | undefined = - this.workflowsStore.getExecutionDataById(selectedId); - if (execution && execution.workflowId === currentWorkflow) { - this.workflowsStore.deleteExecution(execution); - removedCurrentlyLoadedExecution = true; - } - if ( - execution !== undefined && - activeExecution !== null && - execution.id === activeExecution.id - ) { - removedActiveExecution = true; - } + if (['ALL', 'running'].includes(this.filter.status)) { + returnData.push(...this.activeExecutions); } - // Also update route if needed - if (removedCurrentlyLoadedExecution) { - const currentWorkflowExecutions: IExecutionsSummary[] = - this.workflowsStore.currentWorkflowExecutions; - if (currentWorkflowExecutions.length === 0) { - this.workflowsStore.activeWorkflowExecution = null; - - this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: currentWorkflow } }); - } else if (removedActiveExecution) { - this.workflowsStore.activeWorkflowExecution = currentWorkflowExecutions[0]; - this.$router - .push({ - name: VIEWS.EXECUTION_PREVIEW, - params: { name: currentWorkflow, executionId: currentWorkflowExecutions[0].id }, - }) - .catch(() => {}); - } - } - } catch (error) { - this.isDataLoading = false; - this.$showError( - error, - this.$locale.baseText('executionsList.showError.handleDeleteSelected.title'), - ); - - return; - } - this.isDataLoading = false; - - this.$showMessage({ - title: this.$locale.baseText('executionsList.showMessage.handleDeleteSelected.title'), - type: 'success', - }); - - Vue.set(this, 'selectedItems', {}); - this.checkAll = false; - - this.refreshData(); - }, - handleClearSelection() { - this.checkAll = false; - this.handleCheckAllChange(); - }, - handleFilterChanged() { - this.refreshData(); - }, - handleActionItemClick(commandData: { command: string; execution: IExecutionsSummary }) { - if (['currentlySaved', 'original'].includes(commandData.command)) { - let loadWorkflow = false; - if (commandData.command === 'currentlySaved') { - loadWorkflow = true; + if (['ALL', 'error', 'success', 'waiting'].includes(this.filter.status)) { + returnData.push(...this.finishedExecutions); } - this.retryExecution(commandData.execution, loadWorkflow); + return returnData; + }, + combinedExecutionsCount(): number { + return 0 + this.activeExecutions.length + this.finishedExecutionsCount; + }, + numSelected(): number { + if (this.checkAll) { + return this.finishedExecutionsCount; + } - this.$telemetry.track('User clicked retry execution button', { - workflow_id: this.workflowsStore.workflowId, - execution_id: commandData.execution.id, - retry_type: loadWorkflow ? 'current' : 'original', + return Object.keys(this.selectedItems).length; + }, + isIndeterminate(): boolean { + if (this.checkAll) { + return false; + } + + return this.numSelected > 0; + }, + workflowFilterCurrent(): IDataObject { + const filter: IDataObject = {}; + if (this.filter.workflowId !== 'ALL') { + filter.workflowId = this.filter.workflowId; + } + return filter; + }, + workflowFilterPast(): IDataObject { + const filter: IDataObject = {}; + if (this.filter.workflowId !== 'ALL') { + filter.workflowId = this.filter.workflowId; + } + if (this.filter.status === 'waiting') { + filter.waitTill = true; + } else if (['error', 'success'].includes(this.filter.status)) { + filter.finished = this.filter.status === 'success'; + } + return filter; + }, + }, + methods: { + closeDialog() { + this.$emit('closeModal'); + }, + displayExecution(execution: IExecutionsSummary) { + const route = this.$router.resolve({ + name: VIEWS.EXECUTION_PREVIEW, + params: { name: execution.workflowId, executionId: execution.id }, }); - } - if (commandData.command === 'delete') { - this.deleteExecution(commandData.execution); - } - }, - getRowClass(execution: IExecutionsSummary): string { - const classes: string[] = [this.$style.execRow]; - if (execution.waitTill) { - classes.push(this.$style.waiting); - } else if (execution.stoppedAt === undefined) { - classes.push(this.$style.running); - } else if (execution.finished) { - classes.push(this.$style.success); - } else if (execution.stoppedAt !== null) { - classes.push(this.$style.failed); - } else { - classes.push(this.$style.unknown); - } - - return classes.join(' '); - }, - getWorkflowName(workflowId: string): string | undefined { - const workflow = this.workflows.find((data) => data.id === workflowId); - if (workflow === undefined) { - return undefined; - } - - return workflow.name; - }, - async loadActiveExecutions(): Promise { - const activeExecutions = await this.restApi().getCurrentExecutions( - this.workflowFilterCurrent, - ); - for (const activeExecution of activeExecutions) { - if ( - activeExecution.workflowId !== undefined && - activeExecution.workflowName === undefined - ) { - activeExecution.workflowName = this.getWorkflowName(activeExecution.workflowId); - } - } - - this.workflowsStore.activeExecutions = activeExecutions; - this.workflowsStore.addToCurrentExecutions(activeExecutions); - }, - async loadAutoRefresh(): Promise { - const filter = this.workflowFilterPast; - // We cannot use firstId here as some executions finish out of order. Let's say - // You have execution ids 500 to 505 running. - // Suppose 504 finishes before 500, 501, 502 and 503. - // iF you use firstId, filtering id >= 504 you won't - // ever get ids 500, 501, 502 and 503 when they finish - const pastExecutionsPromise: Promise = - this.restApi().getPastExecutions(filter, 30); - const currentExecutionsPromise: Promise = - this.restApi().getCurrentExecutions({}); - - const results = await Promise.all([pastExecutionsPromise, currentExecutionsPromise]); - - for (const activeExecution of results[1]) { - if ( - activeExecution.workflowId !== undefined && - activeExecution.workflowName === undefined - ) { - activeExecution.workflowName = this.getWorkflowName(activeExecution.workflowId); - } - } - - this.workflowsStore.activeExecutions = results[1]; - - // execution IDs are typed as string, int conversion is necessary so we can order. - const alreadyPresentExecutionIds = this.finishedExecutions.map((exec) => - parseInt(exec.id, 10), - ); - let lastId = 0; - const gaps = [] as number[]; - for (let i = results[0].results.length - 1; i >= 0; i--) { - const currentItem = results[0].results[i]; - const currentId = parseInt(currentItem.id, 10); - if (lastId !== 0 && isNaN(currentId) === false) { - // We are doing this iteration to detect possible gaps. - // The gaps are used to remove executions that finished - // and were deleted from database but were displaying - // in this list while running. - if (currentId - lastId > 1) { - // We have some gaps. - const range = _range(lastId + 1, currentId); - gaps.push(...range); - } - } - lastId = parseInt(currentItem.id, 10) || 0; - - // Check new results from end to start - // Add new items accordingly. - const executionIndex = alreadyPresentExecutionIds.indexOf(currentId); - if (executionIndex !== -1) { - // Execution that we received is already present. - - if ( - this.finishedExecutions[executionIndex].finished === false && - currentItem.finished === true - ) { - // Concurrency stuff. This might happen if the execution finishes - // prior to saving all information to database. Somewhat rare but - // With auto refresh and several executions, it happens sometimes. - // So we replace the execution data so it displays correctly. - this.finishedExecutions[executionIndex] = currentItem; - } - - continue; + window.open(route.href, '_blank'); + }, + handleAutoRefreshToggle() { + if (this.autoRefreshInterval) { + // Clear any previously existing intervals (if any - there shouldn't) + clearInterval(this.autoRefreshInterval); + this.autoRefreshInterval = undefined; } - // Find the correct position to place this newcomer - let j; - for (j = this.finishedExecutions.length - 1; j >= 0; j--) { - if (currentId < parseInt(this.finishedExecutions[j].id, 10)) { - this.finishedExecutions.splice(j + 1, 0, currentItem); - break; - } + if (this.autoRefresh) { + this.autoRefreshInterval = setInterval(() => this.loadAutoRefresh(), 4 * 1000); // refresh data every 4 secs } - if (j === -1) { - this.finishedExecutions.unshift(currentItem); + }, + handleCheckAllChange() { + if (!this.checkAll) { + Vue.set(this, 'selectedItems', {}); } - } - this.finishedExecutions = this.finishedExecutions.filter( - (execution) => - !gaps.includes(parseInt(execution.id, 10)) && lastId >= parseInt(execution.id, 10), - ); - this.finishedExecutionsCount = results[0].count; - this.finishedExecutionsCountEstimated = results[0].estimated; - this.workflowsStore.addToCurrentExecutions(this.finishedExecutions); - }, - async loadFinishedExecutions(): Promise { - if (this.filter.status === 'running') { - this.finishedExecutions = []; - this.finishedExecutionsCount = 0; - this.finishedExecutionsCountEstimated = false; - return; - } - const data = await this.restApi().getPastExecutions( - this.workflowFilterPast, - this.requestItemsPerRequest, - ); - this.finishedExecutions = data.results; - this.finishedExecutionsCount = data.count; - this.finishedExecutionsCountEstimated = data.estimated; - - this.workflowsStore.addToCurrentExecutions(data.results); - }, - async loadMore() { - if (this.filter.status === 'running') { - return; - } - - this.isDataLoading = true; - - const filter = this.workflowFilterPast; - let lastId: string | undefined; - - if (this.finishedExecutions.length !== 0) { - const lastItem = this.finishedExecutions.slice(-1)[0]; - lastId = lastItem.id; - } - - let data: IExecutionsListResponse; - try { - data = await this.restApi().getPastExecutions(filter, this.requestItemsPerRequest, lastId); - } catch (error) { - this.isDataLoading = false; - this.$showError(error, this.$locale.baseText('executionsList.showError.loadMore.title')); - return; - } - - data.results = data.results.map((execution) => { - // @ts-ignore - return { ...execution, mode: execution.mode }; - }); - - this.finishedExecutions.push(...data.results); - this.finishedExecutionsCount = data.count; - this.finishedExecutionsCountEstimated = data.estimated; - - this.isDataLoading = false; - - this.workflowsStore.addToCurrentExecutions(data.results); - }, - async loadWorkflows() { - try { - const workflows = await this.restApi().getWorkflows(); - workflows.sort((a, b) => { - if (a.name.toLowerCase() < b.name.toLowerCase()) { - return -1; - } - if (a.name.toLowerCase() > b.name.toLowerCase()) { - return 1; - } - return 0; - }); - - // @ts-ignore - workflows.unshift({ - id: 'ALL', - name: this.$locale.baseText('executionsList.allWorkflows'), - }); - - Vue.set(this, 'workflows', workflows); - } catch (error) { - this.$showError( - error, - this.$locale.baseText('executionsList.showError.loadWorkflows.title'), - ); - } - }, - async retryExecution(execution: IExecutionsSummary, loadWorkflow?: boolean) { - this.isDataLoading = true; - - try { - const retrySuccessful = await this.restApi().retryExecution(execution.id, loadWorkflow); - - if (retrySuccessful) { - this.$showMessage({ - title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulTrue.title'), - type: 'success', - }); + }, + handleCheckboxChanged(executionId: string) { + if (this.selectedItems[executionId]) { + Vue.delete(this.selectedItems, executionId); } else { - this.$showMessage({ - title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulFalse.title'), - type: 'error', - }); + Vue.set(this.selectedItems, executionId, true); } - - this.isDataLoading = false; - } catch (error) { - this.$showError( - error, - this.$locale.baseText('executionsList.showError.retryExecution.title'), + }, + async handleDeleteSelected() { + const deleteExecutions = await this.confirmMessage( + this.$locale.baseText('executionsList.confirmMessage.message', { + interpolate: { numSelected: this.numSelected.toString() }, + }), + this.$locale.baseText('executionsList.confirmMessage.headline'), + 'warning', + this.$locale.baseText('executionsList.confirmMessage.confirmButtonText'), + this.$locale.baseText('executionsList.confirmMessage.cancelButtonText'), ); + if (!deleteExecutions) { + return; + } + + this.isDataLoading = true; + + const sendData: IExecutionDeleteFilter = {}; + if (this.checkAll) { + sendData.deleteBefore = this.finishedExecutions[0].startedAt as Date; + } else { + sendData.ids = Object.keys(this.selectedItems); + } + + sendData.filters = this.workflowFilterPast; + + try { + await this.restApi().deleteExecutions(sendData); + let removedCurrentlyLoadedExecution = false; + let removedActiveExecution = false; + const currentWorkflow: string = this.workflowsStore.workflowId; + const activeExecution: IExecutionsSummary | null = + this.workflowsStore.activeWorkflowExecution; + // Also update current workflow executions view if needed + for (const selectedId of Object.keys(this.selectedItems)) { + const execution: IExecutionsSummary | undefined = + this.workflowsStore.getExecutionDataById(selectedId); + if (execution && execution.workflowId === currentWorkflow) { + this.workflowsStore.deleteExecution(execution); + removedCurrentlyLoadedExecution = true; + } + if ( + execution !== undefined && + activeExecution !== null && + execution.id === activeExecution.id + ) { + removedActiveExecution = true; + } + } + // Also update route if needed + if (removedCurrentlyLoadedExecution) { + const currentWorkflowExecutions: IExecutionsSummary[] = + this.workflowsStore.currentWorkflowExecutions; + if (currentWorkflowExecutions.length === 0) { + this.workflowsStore.activeWorkflowExecution = null; + + this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: currentWorkflow } }); + } else if (removedActiveExecution) { + this.workflowsStore.activeWorkflowExecution = currentWorkflowExecutions[0]; + this.$router + .push({ + name: VIEWS.EXECUTION_PREVIEW, + params: { name: currentWorkflow, executionId: currentWorkflowExecutions[0].id }, + }) + .catch(() => {}); + } + } + } catch (error) { + this.isDataLoading = false; + this.$showError( + error, + this.$locale.baseText('executionsList.showError.handleDeleteSelected.title'), + ); + + return; + } this.isDataLoading = false; - } - }, - async refreshData() { - this.isDataLoading = true; - - try { - const activeExecutionsPromise = this.loadActiveExecutions(); - const finishedExecutionsPromise = this.loadFinishedExecutions(); - await Promise.all([activeExecutionsPromise, finishedExecutionsPromise]); - } catch (error) { - this.$showError(error, this.$locale.baseText('executionsList.showError.refreshData.title')); - } - - this.isDataLoading = false; - }, - getStatusText(entry: IExecutionsSummary): string { - let text = ''; - - if (entry.waitTill) { - text = this.$locale.baseText('executionsList.waiting'); - } else if (entry.stoppedAt === undefined) { - text = this.$locale.baseText('executionsList.running'); - } else if (entry.finished) { - text = this.$locale.baseText('executionsList.succeeded'); - } else if (entry.stoppedAt !== null) { - text = this.$locale.baseText('executionsList.error'); - } else { - text = this.$locale.baseText('executionsList.unknown'); - } - - return text; - }, - getStatusTextTranslationPath(entry: IExecutionsSummary): string { - let path = ''; - - if (entry.waitTill) { - path = 'executionsList.statusWaiting'; - } else if (entry.stoppedAt === undefined) { - path = 'executionsList.statusRunning'; - } else if (entry.finished) { - path = 'executionsList.statusText'; - } else if (entry.stoppedAt !== null) { - path = 'executionsList.statusText'; - } else { - path = 'executionsList.statusUnknown'; - } - - return path; - }, - async stopExecution(activeExecutionId: string) { - try { - // Add it to the list of currently stopping executions that we - // can show the user in the UI that it is in progress - this.stoppingExecutions.push(activeExecutionId); - - await this.restApi().stopCurrentExecution(activeExecutionId); - - // Remove it from the list of currently stopping executions - const index = this.stoppingExecutions.indexOf(activeExecutionId); - this.stoppingExecutions.splice(index, 1); this.$showMessage({ - title: this.$locale.baseText('executionsList.showMessage.stopExecution.title'), - message: this.$locale.baseText('executionsList.showMessage.stopExecution.message', { - interpolate: { activeExecutionId }, - }), + title: this.$locale.baseText('executionsList.showMessage.handleDeleteSelected.title'), type: 'success', }); + Vue.set(this, 'selectedItems', {}); + this.checkAll = false; + this.refreshData(); - } catch (error) { - this.$showError( - error, - this.$locale.baseText('executionsList.showError.stopExecution.title'), + }, + handleClearSelection() { + this.checkAll = false; + this.handleCheckAllChange(); + }, + handleFilterChanged() { + this.refreshData(); + }, + handleActionItemClick(commandData: { command: string; execution: IExecutionsSummary }) { + if (['currentlySaved', 'original'].includes(commandData.command)) { + let loadWorkflow = false; + if (commandData.command === 'currentlySaved') { + loadWorkflow = true; + } + + this.retryExecution(commandData.execution, loadWorkflow); + + this.$telemetry.track('User clicked retry execution button', { + workflow_id: this.workflowsStore.workflowId, + execution_id: commandData.execution.id, + retry_type: loadWorkflow ? 'current' : 'original', + }); + } + if (commandData.command === 'delete') { + this.deleteExecution(commandData.execution); + } + }, + getRowClass(execution: IExecutionsSummary): string { + const classes: string[] = [this.$style.execRow]; + if (execution.waitTill) { + classes.push(this.$style.waiting); + } else if (execution.stoppedAt === undefined) { + classes.push(this.$style.running); + } else if (execution.finished) { + classes.push(this.$style.success); + } else if (execution.stoppedAt !== null) { + classes.push(this.$style.failed); + } else { + classes.push(this.$style.unknown); + } + + return classes.join(' '); + }, + getWorkflowName(workflowId: string): string | undefined { + const workflow = this.workflows.find((data) => data.id === workflowId); + if (workflow === undefined) { + return undefined; + } + + return workflow.name; + }, + async loadActiveExecutions(): Promise { + const activeExecutions = await this.restApi().getCurrentExecutions( + this.workflowFilterCurrent, ); - } - }, - isExecutionRetriable(execution: IExecutionsSummary): boolean { - return ( - execution.stoppedAt !== undefined && - !execution.finished && - execution.retryOf === undefined && - execution.retrySuccessId === undefined && - !execution.waitTill - ); - }, - async deleteExecution(execution: IExecutionsSummary) { - this.isDataLoading = true; - try { - await this.restApi().deleteExecutions({ ids: [execution.id] }); - await this.refreshData(); - } catch (error) { - this.$showError( - error, - this.$locale.baseText('executionsList.showError.handleDeleteSelected.title'), + for (const activeExecution of activeExecutions) { + if ( + activeExecution.workflowId !== undefined && + activeExecution.workflowName === undefined + ) { + activeExecution.workflowName = this.getWorkflowName(activeExecution.workflowId); + } + } + + this.workflowsStore.activeExecutions = activeExecutions; + this.workflowsStore.addToCurrentExecutions(activeExecutions); + }, + async loadAutoRefresh(): Promise { + const filter = this.workflowFilterPast; + // We cannot use firstId here as some executions finish out of order. Let's say + // You have execution ids 500 to 505 running. + // Suppose 504 finishes before 500, 501, 502 and 503. + // iF you use firstId, filtering id >= 504 you won't + // ever get ids 500, 501, 502 and 503 when they finish + const pastExecutionsPromise: Promise = + this.restApi().getPastExecutions(filter, 30); + const currentExecutionsPromise: Promise = + this.restApi().getCurrentExecutions({}); + + const results = await Promise.all([pastExecutionsPromise, currentExecutionsPromise]); + + for (const activeExecution of results[1]) { + if ( + activeExecution.workflowId !== undefined && + activeExecution.workflowName === undefined + ) { + activeExecution.workflowName = this.getWorkflowName(activeExecution.workflowId); + } + } + + this.workflowsStore.activeExecutions = results[1]; + + // execution IDs are typed as string, int conversion is necessary so we can order. + const alreadyPresentExecutionIds = this.finishedExecutions.map((exec) => + parseInt(exec.id, 10), ); - } - this.isDataLoading = true; + let lastId = 0; + const gaps = [] as number[]; + for (let i = results[0].results.length - 1; i >= 0; i--) { + const currentItem = results[0].results[i]; + const currentId = parseInt(currentItem.id, 10); + if (lastId !== 0 && isNaN(currentId) === false) { + // We are doing this iteration to detect possible gaps. + // The gaps are used to remove executions that finished + // and were deleted from database but were displaying + // in this list while running. + if (currentId - lastId > 1) { + // We have some gaps. + const range = _range(lastId + 1, currentId); + gaps.push(...range); + } + } + lastId = parseInt(currentItem.id, 10) || 0; + + // Check new results from end to start + // Add new items accordingly. + const executionIndex = alreadyPresentExecutionIds.indexOf(currentId); + if (executionIndex !== -1) { + // Execution that we received is already present. + + if ( + this.finishedExecutions[executionIndex].finished === false && + currentItem.finished === true + ) { + // Concurrency stuff. This might happen if the execution finishes + // prior to saving all information to database. Somewhat rare but + // With auto refresh and several executions, it happens sometimes. + // So we replace the execution data so it displays correctly. + this.finishedExecutions[executionIndex] = currentItem; + } + + continue; + } + + // Find the correct position to place this newcomer + let j; + for (j = this.finishedExecutions.length - 1; j >= 0; j--) { + if (currentId < parseInt(this.finishedExecutions[j].id, 10)) { + this.finishedExecutions.splice(j + 1, 0, currentItem); + break; + } + } + if (j === -1) { + this.finishedExecutions.unshift(currentItem); + } + } + this.finishedExecutions = this.finishedExecutions.filter( + (execution) => + !gaps.includes(parseInt(execution.id, 10)) && lastId >= parseInt(execution.id, 10), + ); + this.finishedExecutionsCount = results[0].count; + this.finishedExecutionsCountEstimated = results[0].estimated; + this.workflowsStore.addToCurrentExecutions(this.finishedExecutions); + }, + async loadFinishedExecutions(): Promise { + if (this.filter.status === 'running') { + this.finishedExecutions = []; + this.finishedExecutionsCount = 0; + this.finishedExecutionsCountEstimated = false; + return; + } + const data = await this.restApi().getPastExecutions( + this.workflowFilterPast, + this.requestItemsPerRequest, + ); + this.finishedExecutions = data.results; + this.finishedExecutionsCount = data.count; + this.finishedExecutionsCountEstimated = data.estimated; + + this.workflowsStore.addToCurrentExecutions(data.results); + }, + async loadMore() { + if (this.filter.status === 'running') { + return; + } + + this.isDataLoading = true; + + const filter = this.workflowFilterPast; + let lastId: string | undefined; + + if (this.finishedExecutions.length !== 0) { + const lastItem = this.finishedExecutions.slice(-1)[0]; + lastId = lastItem.id; + } + + let data: IExecutionsListResponse; + try { + data = await this.restApi().getPastExecutions( + filter, + this.requestItemsPerRequest, + lastId, + ); + } catch (error) { + this.isDataLoading = false; + this.$showError(error, this.$locale.baseText('executionsList.showError.loadMore.title')); + return; + } + + data.results = data.results.map((execution) => { + // @ts-ignore + return { ...execution, mode: execution.mode }; + }); + + this.finishedExecutions.push(...data.results); + this.finishedExecutionsCount = data.count; + this.finishedExecutionsCountEstimated = data.estimated; + + this.isDataLoading = false; + + this.workflowsStore.addToCurrentExecutions(data.results); + }, + async loadWorkflows() { + try { + const workflows = await this.restApi().getWorkflows(); + workflows.sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) { + return -1; + } + if (a.name.toLowerCase() > b.name.toLowerCase()) { + return 1; + } + return 0; + }); + + // @ts-ignore + workflows.unshift({ + id: 'ALL', + name: this.$locale.baseText('executionsList.allWorkflows'), + }); + + Vue.set(this, 'workflows', workflows); + } catch (error) { + this.$showError( + error, + this.$locale.baseText('executionsList.showError.loadWorkflows.title'), + ); + } + }, + async retryExecution(execution: IExecutionsSummary, loadWorkflow?: boolean) { + this.isDataLoading = true; + + try { + const retrySuccessful = await this.restApi().retryExecution(execution.id, loadWorkflow); + + if (retrySuccessful) { + this.$showMessage({ + title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulTrue.title'), + type: 'success', + }); + } else { + this.$showMessage({ + title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulFalse.title'), + type: 'error', + }); + } + + this.isDataLoading = false; + } catch (error) { + this.$showError( + error, + this.$locale.baseText('executionsList.showError.retryExecution.title'), + ); + + this.isDataLoading = false; + } + }, + async refreshData() { + this.isDataLoading = true; + + try { + const activeExecutionsPromise = this.loadActiveExecutions(); + const finishedExecutionsPromise = this.loadFinishedExecutions(); + await Promise.all([activeExecutionsPromise, finishedExecutionsPromise]); + } catch (error) { + this.$showError( + error, + this.$locale.baseText('executionsList.showError.refreshData.title'), + ); + } + + this.isDataLoading = false; + }, + getStatusText(entry: IExecutionsSummary): string { + let text = ''; + + if (entry.waitTill) { + text = this.$locale.baseText('executionsList.waiting'); + } else if (entry.stoppedAt === undefined) { + text = this.$locale.baseText('executionsList.running'); + } else if (entry.finished) { + text = this.$locale.baseText('executionsList.succeeded'); + } else if (entry.stoppedAt !== null) { + text = this.$locale.baseText('executionsList.error'); + } else { + text = this.$locale.baseText('executionsList.unknown'); + } + + return text; + }, + getStatusTextTranslationPath(entry: IExecutionsSummary): string { + let path = ''; + + if (entry.waitTill) { + path = 'executionsList.statusWaiting'; + } else if (entry.stoppedAt === undefined) { + path = 'executionsList.statusRunning'; + } else if (entry.finished) { + path = 'executionsList.statusText'; + } else if (entry.stoppedAt !== null) { + path = 'executionsList.statusText'; + } else { + path = 'executionsList.statusUnknown'; + } + + return path; + }, + async stopExecution(activeExecutionId: string) { + try { + // Add it to the list of currently stopping executions that we + // can show the user in the UI that it is in progress + this.stoppingExecutions.push(activeExecutionId); + + await this.restApi().stopCurrentExecution(activeExecutionId); + + // Remove it from the list of currently stopping executions + const index = this.stoppingExecutions.indexOf(activeExecutionId); + this.stoppingExecutions.splice(index, 1); + + this.$showMessage({ + title: this.$locale.baseText('executionsList.showMessage.stopExecution.title'), + message: this.$locale.baseText('executionsList.showMessage.stopExecution.message', { + interpolate: { activeExecutionId }, + }), + type: 'success', + }); + + this.refreshData(); + } catch (error) { + this.$showError( + error, + this.$locale.baseText('executionsList.showError.stopExecution.title'), + ); + } + }, + isExecutionRetriable(execution: IExecutionsSummary): boolean { + return ( + execution.stoppedAt !== undefined && + !execution.finished && + execution.retryOf === undefined && + execution.retrySuccessId === undefined && + !execution.waitTill + ); + }, + async deleteExecution(execution: IExecutionsSummary) { + this.isDataLoading = true; + try { + await this.restApi().deleteExecutions({ ids: [execution.id] }); + await this.refreshData(); + } catch (error) { + this.$showError( + error, + this.$locale.baseText('executionsList.showError.handleDeleteSelected.title'), + ); + } + this.isDataLoading = true; + }, }, }, -}); +); diff --git a/packages/editor-ui/src/components/ExecutionsView/ExecutionsSidebar.vue b/packages/editor-ui/src/components/ExecutionsView/ExecutionsSidebar.vue index 2e5ab97b8d..e1664e0026 100644 --- a/packages/editor-ui/src/components/ExecutionsView/ExecutionsSidebar.vue +++ b/packages/editor-ui/src/components/ExecutionsView/ExecutionsSidebar.vue @@ -66,6 +66,11 @@ +
+ + {{ $locale.baseText('executionsLandingPage.noResults') }} + +
diff --git a/packages/editor-ui/src/components/MainHeader/MainHeader.vue b/packages/editor-ui/src/components/MainHeader/MainHeader.vue index 1ff487cc8e..e19c4650af 100644 --- a/packages/editor-ui/src/components/MainHeader/MainHeader.vue +++ b/packages/editor-ui/src/components/MainHeader/MainHeader.vue @@ -52,7 +52,7 @@ export default mixins(pushConnection, workflowHelpers).extend({ ...mapStores(useNDVStore, useUIStore), tabBarItems(): ITabBarItem[] { return [ - { value: MAIN_HEADER_TABS.WORKFLOW, label: this.$locale.baseText('generic.workflow') }, + { value: MAIN_HEADER_TABS.WORKFLOW, label: this.$locale.baseText('generic.editor') }, { value: MAIN_HEADER_TABS.EXECUTIONS, label: this.$locale.baseText('generic.executions') }, ]; }, diff --git a/packages/editor-ui/src/mixins/executionsHelpers.ts b/packages/editor-ui/src/mixins/executionsHelpers.ts index 46f7c97d65..6ba4e9d52d 100644 --- a/packages/editor-ui/src/mixins/executionsHelpers.ts +++ b/packages/editor-ui/src/mixins/executionsHelpers.ts @@ -1,6 +1,6 @@ import { IExecutionsSummary } from '@/Interface'; import { useWorkflowsStore } from '@/stores/workflows'; -import dateFormat from 'dateformat'; +import { i18n as locale } from '@/plugins/i18n'; import { mapStores } from 'pinia'; import mixins from 'vue-typed-mixins'; import { genericHelpers } from './genericHelpers'; @@ -35,7 +35,7 @@ export const executionHelpers = mixins(genericHelpers).extend({ getExecutionUIDetails(execution: IExecutionsSummary): IExecutionUIData { const status = { name: 'unknown', - startTime: this.formatDate(new Date(execution.startedAt)), + startTime: this.formatDate(execution.startedAt), label: 'Status unknown', runningTime: '', }; @@ -72,11 +72,9 @@ export const executionHelpers = mixins(genericHelpers).extend({ return status; }, - formatDate(date: Date) { - if (date.getFullYear() === new Date().getFullYear()) { - return dateFormat(date.getTime(), 'HH:MM:ss "on" d mmm'); - } - return dateFormat(date.getTime(), 'HH:MM:ss "on" d mmm yyyy'); + formatDate(fullDate: Date | string | number) { + const { date, time } = this.convertToDisplayDate(fullDate); + return locale.baseText('executionsList.started', { interpolate: { time, date } }); }, }, }); diff --git a/packages/editor-ui/src/mixins/genericHelpers.ts b/packages/editor-ui/src/mixins/genericHelpers.ts index 5e01b56321..c47f4f7190 100644 --- a/packages/editor-ui/src/mixins/genericHelpers.ts +++ b/packages/editor-ui/src/mixins/genericHelpers.ts @@ -36,8 +36,11 @@ export const genericHelpers = mixins(showMessage).extend({ return `${minutesPassed}:${secondsLeft}${this.$locale.baseText('genericHelpers.minShort')}`; }, - convertToDisplayDate(epochTime: number): { date: string; time: string } { - const formattedDate = dateformat(epochTime, 'd mmm, yyyy#HH:MM:ss'); + convertToDisplayDate(fullDate: Date | string | number): { date: string; time: string } { + const mask = `d mmm${ + new Date(fullDate).getFullYear() === new Date().getFullYear() ? '' : ', yyyy' + }#HH:MM:ss`; + const formattedDate = dateformat(fullDate, mask); const [date, time] = formattedDate.split('#'); return { date, time }; }, diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index ceb65c478e..a34e315bad 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -40,6 +40,7 @@ "generic.unsavedWork.confirmMessage.confirmButtonText": "Save", "generic.unsavedWork.confirmMessage.cancelButtonText": "Leave without saving", "generic.workflow": "Workflow", + "generic.editor": "Editor", "about.aboutN8n": "About n8n", "about.close": "Close", "about.license": "License", @@ -412,11 +413,12 @@ "executionsLandingPage.emptyState.noTrigger.heading": "Set up the first step. Then execute your workflow", "executionsLandingPage.emptyState.noTrigger.buttonText": "Add first step...", "executionsLandingPage.clickExecutionMessage": "Click on an execution from the list to view it", - "executionsLandingPage.emptyState.heading": " No executions yet", + "executionsLandingPage.emptyState.heading": "Nothing here yet", "executionsLandingPage.emptyState.message": "New workflow executions will show here", "executionsLandingPage.emptyState.accordion.title": "Which executions is this workflow saving?", + "executionsLandingPage.emptyState.accordion.titleWarning": "Some executions won’t be saved", "executionsLandingPage.emptyState.accordion.productionExecutions": "Production executions", - "executionsLandingPage.emptyState.accordion.manualExecutions": "Manual executions", + "executionsLandingPage.emptyState.accordion.testExecutions": "Test executions", "executionsLandingPage.emptyState.accordion.productionExecutionsWarningTooltip": "Not all production executions are being saved. Change this in the workflow's settings", "executionsLandingPage.emptyState.accordion.footer": "You can change this in", "executionsLandingPage.emptyState.accordion.footer.settingsLink": "Workflow settings",