From d1710a1da375d13a687add4f48400faab62169d7 Mon Sep 17 00:00:00 2001 From: Suguru Inoue Date: Mon, 31 Mar 2025 13:19:54 +0200 Subject: [PATCH] feat(editor): Logs overview panel (#14045) --- .../@n8n/chat/src/components/MessagesList.vue | 2 +- .../frontend/@n8n/design-system/package.json | 2 +- packages/frontend/editor-ui/package.json | 1 + packages/frontend/editor-ui/src/Interface.ts | 13 +- .../components/ChatMessagesPanel.vue | 4 +- .../CanvasChat/future/LogsPanel.vue | 24 +- .../components/ConsumedTokenCountText.vue | 24 ++ .../components/LogsOverviewPanel.test.ts | 148 +++++++++++ .../future/components/LogsOverviewPanel.vue | 174 ++++++++++++- .../future/components/LogsOverviewRow.vue | 239 ++++++++++++++++++ .../{ => future}/components/PanelHeader.vue | 12 +- .../src/components/ConsumedTokensDetails.vue | 35 +++ .../components/RunDataAi/RunDataAiContent.vue | 70 +---- .../src/components/RunDataAi/utils.test.ts | 92 ++++++- .../src/components/RunDataAi/utils.ts | 85 ++++++- .../src/composables/usePushConnection.ts | 15 +- .../src/composables/useRunWorkflow.test.ts | 43 +++- .../src/composables/useRunWorkflow.ts | 9 + .../editor-ui/src/plugins/i18n/index.ts | 4 + .../src/plugins/i18n/locales/en.json | 5 + pnpm-lock.yaml | 33 ++- pnpm-workspace.yaml | 1 + 22 files changed, 912 insertions(+), 123 deletions(-) create mode 100644 packages/frontend/editor-ui/src/components/CanvasChat/future/components/ConsumedTokenCountText.vue create mode 100644 packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewPanel.test.ts create mode 100644 packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewRow.vue rename packages/frontend/editor-ui/src/components/CanvasChat/{ => future}/components/PanelHeader.vue (80%) create mode 100644 packages/frontend/editor-ui/src/components/ConsumedTokensDetails.vue diff --git a/packages/frontend/@n8n/chat/src/components/MessagesList.vue b/packages/frontend/@n8n/chat/src/components/MessagesList.vue index a7c8a87119..a0ca0becdf 100644 --- a/packages/frontend/@n8n/chat/src/components/MessagesList.vue +++ b/packages/frontend/@n8n/chat/src/components/MessagesList.vue @@ -88,7 +88,7 @@ watch( justify-content: center; gap: var(--spacing-xs); padding-inline: var(--spacing-m); - padding-bottom: 1.5em; + padding-bottom: var(--spacing-l); overflow: hidden; } diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index aabcc2b90b..4d46ea8a7d 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -49,7 +49,7 @@ "@fortawesome/fontawesome-svg-core": "^1.2.36", "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/vue-fontawesome": "^3.0.3", - "element-plus": "2.4.3", + "element-plus": "catalog:frontend", "is-emoji-supported": "^0.0.5", "markdown-it": "^13.0.2", "markdown-it-emoji": "^2.0.2", diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 4ee373c549..0a97069c13 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -59,6 +59,7 @@ "core-js": "^3.40.0", "curlconverter": "^4.12.0", "dateformat": "^3.0.3", + "element-plus": "catalog:frontend", "email-providers": "^2.0.1", "esprima-next": "5.8.4", "fast-json-stable-stringify": "^2.1.0", diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index c44867141d..2016b7db0a 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -445,9 +445,9 @@ export interface IExecutionBase { status: ExecutionStatus; retryOf?: string; retrySuccessId?: string; - startedAt: Date; - createdAt: Date; - stoppedAt?: Date; + startedAt: Date | string; + createdAt: Date | string; + stoppedAt?: Date | string; workflowId?: string; // To be able to filter executions easily // } @@ -1583,3 +1583,10 @@ export type MainPanelDimensions = Record< relativeWidth: number; } >; + +export interface LlmTokenUsageData { + completionTokens: number; + promptTokens: number; + totalTokens: number; + isEstimate: boolean; +} diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/components/ChatMessagesPanel.vue b/packages/frontend/editor-ui/src/components/CanvasChat/components/ChatMessagesPanel.vue index eea3e96b97..f4a70d3598 100644 --- a/packages/frontend/editor-ui/src/components/CanvasChat/components/ChatMessagesPanel.vue +++ b/packages/frontend/editor-ui/src/components/CanvasChat/components/ChatMessagesPanel.vue @@ -10,7 +10,7 @@ import ChatInput from '@n8n/chat/components/Input.vue'; import { computed, ref } from 'vue'; import { useClipboard } from '@/composables/useClipboard'; import { useToast } from '@/composables/useToast'; -import PanelHeader from '@/components/CanvasChat/components/PanelHeader.vue'; +import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue'; import { N8nButton, N8nIconButton, N8nTooltip } from '@n8n/design-system'; interface Props { @@ -357,7 +357,7 @@ async function copySessionId() { height: 100%; width: 100%; overflow: auto; - padding-top: 1.5em; + padding-top: var(--spacing-l); &:not(:last-child) { margin-right: 1em; diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.vue b/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.vue index 396e578d35..d6ec7c6c1d 100644 --- a/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.vue +++ b/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.vue @@ -32,10 +32,8 @@ const telemetry = useTelemetry(); const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResizeChatDebounced } = useResize(container); -const { currentSessionId, messages, sendMessage, refreshSession, displayExecution } = useChatState( - ref(false), - onWindowResize, -); +const { currentSessionId, messages, connectedNode, sendMessage, refreshSession, displayExecution } = + useChatState(ref(false), onWindowResize); const appStyles = useStyles(); const tooltipZIndex = computed(() => appStyles.APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON + 100); @@ -95,7 +93,7 @@ watch([panelState, height], ([state, h]) => { :class="[$style.resizeWrapper, panelState === 'closed' ? '' : $style.isOpen]" @resize="onResizeDebounced" > -
+
{ @click-header="handleClickHeader" /> - + @@ -60,14 +177,19 @@ function onClearExecutionData() { display: flex; flex-direction: column; align-items: stretch; + overflow: hidden; } .content { - padding: var(--spacing-2xs); + position: relative; flex-grow: 1; + overflow: auto; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: stretch; &.empty { - display: flex; align-items: center; justify-content: center; } @@ -77,4 +199,40 @@ function onClearExecutionData() { max-width: 20em; text-align: center; } + +.scrollable { + padding: var(--spacing-2xs); + flex-grow: 1; + flex-shrink: 1; + overflow: auto; +} + +.summary { + display: flex; + align-items: center; + padding-block: var(--spacing-2xs); + + & > * { + padding-inline: var(--spacing-2xs); + } + + & > *:not(:last-child) { + border-right: var(--border-base); + } +} + +.tree { + margin-top: var(--spacing-2xs); + + & :global(.el-icon) { + display: none; + } +} + +.switchViewButtons { + position: absolute; + right: 0; + top: 0; + margin: var(--spacing-2xs); +} diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewRow.vue b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewRow.vue new file mode 100644 index 0000000000..edf3006edd --- /dev/null +++ b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewRow.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/components/PanelHeader.vue b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/PanelHeader.vue similarity index 80% rename from packages/frontend/editor-ui/src/components/CanvasChat/components/PanelHeader.vue rename to packages/frontend/editor-ui/src/components/CanvasChat/future/components/PanelHeader.vue index e5c77edf4f..fbad423a2f 100644 --- a/packages/frontend/editor-ui/src/components/CanvasChat/components/PanelHeader.vue +++ b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/PanelHeader.vue @@ -1,4 +1,6 @@ + + diff --git a/packages/frontend/editor-ui/src/components/RunDataAi/RunDataAiContent.vue b/packages/frontend/editor-ui/src/components/RunDataAi/RunDataAiContent.vue index adacd5f30e..cd6cd416cf 100644 --- a/packages/frontend/editor-ui/src/components/RunDataAi/RunDataAiContent.vue +++ b/packages/frontend/editor-ui/src/components/RunDataAi/RunDataAiContent.vue @@ -2,17 +2,14 @@ import type { IAiData, IAiDataContent } from '@/Interface'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; -import type { - INodeExecutionData, - INodeTypeDescription, - NodeConnectionType, - NodeError, -} from 'n8n-workflow'; +import type { INodeTypeDescription, NodeConnectionType, NodeError } from 'n8n-workflow'; import { computed } from 'vue'; import NodeIcon from '@/components/NodeIcon.vue'; import AiRunContentBlock from './AiRunContentBlock.vue'; import { useExecutionHelpers } from '@/composables/useExecutionHelpers'; import { useI18n } from '@/composables/useI18n'; +import { formatTokenUsageCount, getConsumedTokens } from '@/components/RunDataAi/utils'; +import ConsumedTokensDetails from '@/components/ConsumedTokensDetails.vue'; interface RunMeta { startTimeMs: number; @@ -36,44 +33,11 @@ const workflowsStore = useWorkflowsStore(); const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = useExecutionHelpers(); const i18n = useI18n(); -type TokenUsageData = { - completionTokens: number; - promptTokens: number; - totalTokens: number; -}; - const consumedTokensSum = computed(() => { // eslint-disable-next-line @typescript-eslint/no-use-before-define - const tokenUsage = outputRun.value?.data?.reduce( - (acc: TokenUsageData, curr: INodeExecutionData) => { - const tokenUsageData = (curr.json?.tokenUsage ?? - curr.json?.tokenUsageEstimate) as TokenUsageData; - - if (!tokenUsageData) return acc; - - return { - completionTokens: acc.completionTokens + tokenUsageData.completionTokens, - promptTokens: acc.promptTokens + tokenUsageData.promptTokens, - totalTokens: acc.totalTokens + tokenUsageData.totalTokens, - }; - }, - { - completionTokens: 0, - promptTokens: 0, - totalTokens: 0, - }, - ); - - return tokenUsage; + return getConsumedTokens(outputRun.value); }); -const usingTokensEstimates = computed(() => { - return outputRun.value?.data?.some((d) => d.json?.tokenUsageEstimate); -}); - -function formatTokenUsageCount(count: number) { - return usingTokensEstimates.value ? `~${count}` : count.toString(); -} function extractRunMeta(run: IAiDataContent) { const uiNode = workflowsStore.getNodeByName(props.inputData.node); const nodeType = nodeTypesStore.getNodeType(uiNode?.type ?? ''); @@ -155,34 +119,12 @@ const outputError = computed(() => { {{ i18n.baseText('runData.aiContentBlock.tokens', { interpolate: { - count: formatTokenUsageCount(consumedTokensSum?.totalTokens ?? 0), + count: formatTokenUsageCount(consumedTokensSum, 'total'), }, }) }} -
- - {{ i18n.baseText('runData.aiContentBlock.tokens.prompt') }} - {{ - i18n.baseText('runData.aiContentBlock.tokens', { - interpolate: { - count: formatTokenUsageCount(consumedTokensSum?.promptTokens ?? 0), - }, - }) - }} - -
- - {{ i18n.baseText('runData.aiContentBlock.tokens.completion') }} - {{ - i18n.baseText('runData.aiContentBlock.tokens', { - interpolate: { - count: formatTokenUsageCount(consumedTokensSum?.completionTokens ?? 0), - }, - }) - }} - -
+
diff --git a/packages/frontend/editor-ui/src/components/RunDataAi/utils.test.ts b/packages/frontend/editor-ui/src/components/RunDataAi/utils.test.ts index 9140c853e4..a85f801d0f 100644 --- a/packages/frontend/editor-ui/src/components/RunDataAi/utils.test.ts +++ b/packages/frontend/editor-ui/src/components/RunDataAi/utils.test.ts @@ -31,11 +31,62 @@ describe(getTreeNodeData, () => { const taskDataByNodeName: Record = { A: [createTaskData({ startTime: +new Date('2025-02-26T00:00:00.000Z') })], B: [ - createTaskData({ startTime: +new Date('2025-02-26T00:00:01.000Z') }), - createTaskData({ startTime: +new Date('2025-02-26T00:00:03.000Z') }), + createTaskData({ + startTime: +new Date('2025-02-26T00:00:01.000Z'), + data: { + main: [ + [ + { + json: { + tokenUsage: { + completionTokens: 1, + promptTokens: 2, + totalTokens: 3, + }, + }, + }, + ], + ], + }, + }), + createTaskData({ + startTime: +new Date('2025-02-26T00:00:03.000Z'), + data: { + main: [ + [ + { + json: { + tokenUsage: { + completionTokens: 4, + promptTokens: 5, + totalTokens: 6, + }, + }, + }, + ], + ], + }, + }), ], C: [ - createTaskData({ startTime: +new Date('2025-02-26T00:00:02.000Z') }), + createTaskData({ + startTime: +new Date('2025-02-26T00:00:02.000Z'), + data: { + main: [ + [ + { + json: { + tokenUsageEstimate: { + completionTokens: 7, + promptTokens: 8, + totalTokens: 9, + }, + }, + }, + ], + ], + }, + }), createTaskData({ startTime: +new Date('2025-02-26T00:00:04.000Z') }), ], }; @@ -53,6 +104,13 @@ describe(getTreeNodeData, () => { node: 'A', runIndex: 0, startTime: 0, + parent: undefined, + consumedTokens: { + completionTokens: 0, + promptTokens: 0, + totalTokens: 0, + isEstimate: false, + }, children: [ { depth: 1, @@ -60,6 +118,13 @@ describe(getTreeNodeData, () => { node: 'B', runIndex: 0, startTime: +new Date('2025-02-26T00:00:01.000Z'), + parent: expect.objectContaining({ node: 'A' }), + consumedTokens: { + completionTokens: 1, + promptTokens: 2, + totalTokens: 3, + isEstimate: false, + }, children: [ { children: [], @@ -68,6 +133,13 @@ describe(getTreeNodeData, () => { node: 'C', runIndex: 0, startTime: +new Date('2025-02-26T00:00:02.000Z'), + parent: expect.objectContaining({ node: 'B' }), + consumedTokens: { + completionTokens: 7, + promptTokens: 8, + totalTokens: 9, + isEstimate: true, + }, }, ], }, @@ -77,6 +149,13 @@ describe(getTreeNodeData, () => { node: 'B', runIndex: 1, startTime: +new Date('2025-02-26T00:00:03.000Z'), + parent: expect.objectContaining({ node: 'A' }), + consumedTokens: { + completionTokens: 4, + promptTokens: 5, + totalTokens: 6, + isEstimate: false, + }, children: [ { children: [], @@ -85,6 +164,13 @@ describe(getTreeNodeData, () => { node: 'C', runIndex: 1, startTime: +new Date('2025-02-26T00:00:04.000Z'), + parent: expect.objectContaining({ node: 'B' }), + consumedTokens: { + completionTokens: 0, + promptTokens: 0, + totalTokens: 0, + isEstimate: false, + }, }, ], }, diff --git a/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts b/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts index 9c8dbb43db..ba080dfc51 100644 --- a/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts +++ b/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts @@ -1,5 +1,6 @@ -import { type IAiDataContent } from '@/Interface'; +import { type LlmTokenUsageData, type IAiDataContent } from '@/Interface'; import { + type INodeExecutionData, type ITaskData, type ITaskDataConnections, type NodeConnectionType, @@ -13,27 +14,32 @@ export interface AIResult { } export interface TreeNode { + parent?: TreeNode; node: string; id: string; children: TreeNode[]; depth: number; startTime: number; runIndex: number; + consumedTokens: LlmTokenUsageData; } function createNode( + parent: TreeNode | undefined, nodeName: string, currentDepth: number, r?: AIResult, children: TreeNode[] = [], ): TreeNode { return { + parent, node: nodeName, id: nodeName, depth: currentDepth, startTime: r?.data?.metadata?.startTime ?? 0, runIndex: r?.runIndex ?? 0, children, + consumedTokens: getConsumedTokens(r?.data), }; } @@ -42,10 +48,11 @@ export function getTreeNodeData( workflow: Workflow, aiData: AIResult[] | undefined, ): TreeNode[] { - return getTreeNodeDataRec(nodeName, 0, workflow, aiData, undefined); + return getTreeNodeDataRec(undefined, nodeName, 0, workflow, aiData, undefined); } function getTreeNodeDataRec( + parent: TreeNode | undefined, nodeName: string, currentDepth: number, workflow: Workflow, @@ -59,12 +66,13 @@ function getTreeNodeDataRec( ) ?? []; if (!connections) { - return resultData.map((d) => createNode(nodeName, currentDepth, d)); + return resultData.map((d) => createNode(parent, nodeName, currentDepth, d)); } // Get the first level of children const connectedSubNodes = workflow.getParentNodes(nodeName, 'ALL_NON_MAIN', 1); + const treeNode = createNode(parent, nodeName, currentDepth); const children = connectedSubNodes.flatMap((name) => { // Only include sub-nodes which have data return ( @@ -73,18 +81,20 @@ function getTreeNodeDataRec( (data) => data.node === name && (runIndex === undefined || data.runIndex === runIndex), ) .flatMap((data) => - getTreeNodeDataRec(name, currentDepth + 1, workflow, aiData, data.runIndex), + getTreeNodeDataRec(treeNode, name, currentDepth + 1, workflow, aiData, data.runIndex), ) ?? [] ); }); children.sort((a, b) => a.startTime - b.startTime); + treeNode.children = children; + if (resultData.length) { - return resultData.map((r) => createNode(nodeName, currentDepth, r, children)); + return resultData.map((r) => createNode(parent, nodeName, currentDepth, r, children)); } - return [createNode(nodeName, currentDepth, undefined, children)]; + return [treeNode]; } export function createAiData( @@ -158,3 +168,66 @@ export function getReferencedData( return returnData; } + +const emptyTokenUsageData: LlmTokenUsageData = { + completionTokens: 0, + promptTokens: 0, + totalTokens: 0, + isEstimate: false, +}; + +function addTokenUsageData(one: LlmTokenUsageData, another: LlmTokenUsageData): LlmTokenUsageData { + return { + completionTokens: one.completionTokens + another.completionTokens, + promptTokens: one.promptTokens + another.promptTokens, + totalTokens: one.totalTokens + another.totalTokens, + isEstimate: one.isEstimate || another.isEstimate, + }; +} + +export function getConsumedTokens(outputRun: IAiDataContent | undefined): LlmTokenUsageData { + if (!outputRun?.data) { + return emptyTokenUsageData; + } + + const tokenUsage = outputRun.data.reduce( + (acc: LlmTokenUsageData, curr: INodeExecutionData) => { + const tokenUsageData = curr.json?.tokenUsage ?? curr.json?.tokenUsageEstimate; + + if (!tokenUsageData) return acc; + + return addTokenUsageData(acc, { + ...(tokenUsageData as Omit), + isEstimate: !!curr.json.tokenUsageEstimate, + }); + }, + emptyTokenUsageData, + ); + + return tokenUsage; +} + +export function getTotalConsumedTokens(...usage: LlmTokenUsageData[]): LlmTokenUsageData { + return usage.reduce(addTokenUsageData, emptyTokenUsageData); +} + +export function getSubtreeTotalConsumedTokens(treeNode: TreeNode): LlmTokenUsageData { + return getTotalConsumedTokens( + treeNode.consumedTokens, + ...treeNode.children.map(getSubtreeTotalConsumedTokens), + ); +} + +export function formatTokenUsageCount( + usage: LlmTokenUsageData, + field: 'total' | 'prompt' | 'completion', +) { + const count = + field === 'total' + ? usage.totalTokens + : field === 'completion' + ? usage.completionTokens + : usage.promptTokens; + + return usage.isEstimate ? `~${count}` : count.toLocaleString(); +} diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection.ts b/packages/frontend/editor-ui/src/composables/usePushConnection.ts index 1be7a39aac..86ef569500 100644 --- a/packages/frontend/editor-ui/src/composables/usePushConnection.ts +++ b/packages/frontend/editor-ui/src/composables/usePushConnection.ts @@ -240,10 +240,19 @@ export function usePushConnection({ router }: { router: ReturnType; + let executionData: Pick< + IExecutionResponse, + 'workflowId' | 'data' | 'status' | 'startedAt' | 'stoppedAt' + >; if (receivedData.type === 'executionFinished' && receivedData.data.rawData) { const { workflowId, status, rawData } = receivedData.data; - executionData = { workflowId, data: parse(rawData), status }; + executionData = { + workflowId, + data: parse(rawData), + status, + startedAt: workflowsStore.workflowExecutionData?.startedAt ?? new Date(), + stoppedAt: new Date(), + }; } else { uiStore.setProcessingExecutionResults(true); @@ -278,6 +287,8 @@ export function usePushConnection({ router }: { router: ReturnType ({ useWorkflowsStore: vi.fn().mockReturnValue({ @@ -45,6 +46,7 @@ vi.mock('@/stores/workflows.store', () => ({ getPinnedDataLastRemovedAt: vi.fn(), incomingConnectionsByNodeName: vi.fn(), outgoingConnectionsByNodeName: vi.fn(), + markExecutionAsStopped: vi.fn(), }), })); @@ -671,4 +673,41 @@ describe('useRunWorkflow({ router })', () => { }); }); }); + + describe('stopCurrentExecution()', () => { + it('should not prematurely call markExecutionAsStopped() while execution status is still "running"', async () => { + const runWorkflowComposable = useRunWorkflow({ router }); + const executionData: IExecutionResponse = { + id: 'test-exec-id', + workflowData: createTestWorkflow({ id: 'test-wf-id' }), + finished: false, + mode: 'manual', + status: 'running', + startedAt: new Date('2025-04-01T00:00:00.000Z'), + createdAt: new Date('2025-04-01T00:00:00.000Z'), + }; + const markStoppedSpy = vi.spyOn(workflowsStore, 'markExecutionAsStopped'); + + workflowsStore.workflowExecutionData = executionData; + workflowsStore.activeWorkflows = ['test-wf-id']; + workflowsStore.activeExecutionId = 'test-exec-id'; + + // Exercise - don't wait for returned promise to resolve + void runWorkflowComposable.stopCurrentExecution(); + + // Assert that markExecutionAsStopped() isn't called yet after a simulated delay + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(markStoppedSpy).not.toHaveBeenCalled(); + + // Simulated executionFinished event + workflowsStore.workflowExecutionData = { + ...executionData, + status: 'canceled', + stoppedAt: new Date('2025-04-01T00:00:99.000Z'), + }; + + // Assert that markExecutionAsStopped() is called eventually + await waitFor(() => expect(markStoppedSpy).toHaveBeenCalled()); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts b/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts index 30b499c8e4..f45b43dfc0 100644 --- a/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts @@ -447,6 +447,15 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType 0 && msPassed < 1000) { + return `${msPassed}${this.baseText('genericHelpers.millis')}`; + } + return `${msPassed / 1000}${this.baseText('genericHelpers.secShort')}`; } diff --git a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json index 7ae9f8eefc..074dde5705 100644 --- a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json @@ -964,6 +964,7 @@ "genericHelpers.minShort": "m", "genericHelpers.sec": "sec", "genericHelpers.secShort": "s", + "genericHelpers.millis": "ms", "readOnly.showMessage.executions.message": "Executions are read-only. Make changes from the Workflow tab.", "readOnly.showMessage.executions.title": "Cannot edit execution", "readOnlyEnv.showMessage.executions.message": "Executions are read-only.", @@ -980,8 +981,12 @@ "logs.overview.header.title": "Logs", "logs.overview.header.actions.clearExecution": "Clear execution", "logs.overview.header.actions.clearExecution.tooltip": "Clear execution data", + "logs.overview.header.switch.details": "Details", + "logs.overview.header.switch.overview": "Overview", "logs.overview.body.empty.message": "Nothing to display yet. Execute the workflow to see execution logs.", "logs.overview.body.empty.action": "Execute the workflow", + "logs.overview.body.summaryText": "{status} in {time}", + "logs.overview.body.started": "Started {time}", "mainSidebar.aboutN8n": "About n8n", "mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "", "mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7dd6960ffd..a62cd485cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ catalogs: '@vueuse/core': specifier: ^10.11.0 version: 10.11.0 + element-plus: + specifier: 2.4.3 + version: 2.4.3 highlight.js: specifier: ^11.8.0 version: 11.9.0 @@ -494,7 +497,7 @@ importers: version: 3.666.0(@aws-sdk/client-sts@3.666.0) '@getzep/zep-cloud': specifier: 1.0.12 - version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0)) + version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)) '@getzep/zep-js': specifier: 0.9.0 version: 0.9.0 @@ -521,7 +524,7 @@ importers: version: 0.3.2(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13) '@langchain/community': specifier: 0.3.24 - version: 0.3.24(1725dd003b6ba0539bce135b7f30abed) + version: 0.3.24(14647e509198b6d5542cb42df21485e1) '@langchain/core': specifier: 'catalog:' version: 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)) @@ -614,7 +617,7 @@ importers: version: 23.0.1 langchain: specifier: 0.3.11 - version: 0.3.11(fd386e1130022c8548c06dd951c5cbf0) + version: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a) lodash: specifier: 'catalog:' version: 4.17.21 @@ -1439,7 +1442,7 @@ importers: specifier: '*' version: 10.11.0(vue@3.5.13(typescript@5.8.2)) element-plus: - specifier: 2.4.3 + specifier: catalog:frontend version: 2.4.3(vue@3.5.13(typescript@5.8.2)) is-emoji-supported: specifier: ^0.0.5 @@ -1682,6 +1685,9 @@ importers: dateformat: specifier: ^3.0.3 version: 3.0.3 + element-plus: + specifier: catalog:frontend + version: 2.4.3(vue@3.5.13(typescript@5.8.2)) email-providers: specifier: ^2.0.1 version: 2.0.1 @@ -8896,6 +8902,7 @@ packages: gm@1.25.0: resolution: {integrity: sha512-4kKdWXTtgQ4biIo7hZA396HT062nDVVHPjQcurNZ3o/voYN+o5FUC5kOwuORbpExp3XbTJ3SU7iRipiIhQtovw==} engines: {node: '>=14'} + deprecated: The gm module has been sunset. Please migrate to an alternative. https://github.com/aheckmann/gm?tab=readme-ov-file#2025-02-24-this-project-is-not-maintained google-auth-library@8.9.0: resolution: {integrity: sha512-f7aQCJODJFmYWN6PeNKzgvy9LI2tYmXnzpNDHEjG5sDNPgGb2FXQyTBnXeSH+PAtpKESFD+LmHw3Ox3mN7e1Fg==} @@ -15973,7 +15980,7 @@ snapshots: '@gar/promisify@1.1.3': optional: true - '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0))': + '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a))': dependencies: form-data: 4.0.0 node-fetch: 2.7.0(encoding@0.1.13) @@ -15982,7 +15989,7 @@ snapshots: zod: 3.24.1 optionalDependencies: '@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)) - langchain: 0.3.11(fd386e1130022c8548c06dd951c5cbf0) + langchain: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a) transitivePeerDependencies: - encoding @@ -16490,7 +16497,7 @@ snapshots: - aws-crt - encoding - '@langchain/community@0.3.24(1725dd003b6ba0539bce135b7f30abed)': + '@langchain/community@0.3.24(14647e509198b6d5542cb42df21485e1)': dependencies: '@browserbasehq/stagehand': 1.9.0(@playwright/test@1.49.1)(deepmerge@4.3.1)(dotenv@16.4.5)(encoding@0.1.13)(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))(zod@3.24.1) '@ibm-cloud/watsonx-ai': 1.1.2 @@ -16501,7 +16508,7 @@ snapshots: flat: 5.0.2 ibm-cloud-sdk-core: 5.1.0 js-yaml: 4.1.0 - langchain: 0.3.11(fd386e1130022c8548c06dd951c5cbf0) + langchain: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a) langsmith: 0.2.15(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)) openai: 4.78.1(encoding@0.1.13)(zod@3.24.1) uuid: 10.0.0 @@ -16516,7 +16523,7 @@ snapshots: '@aws-sdk/credential-provider-node': 3.666.0(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@aws-sdk/client-sts@3.666.0) '@azure/storage-blob': 12.18.0(encoding@0.1.13) '@browserbasehq/sdk': 2.0.0(encoding@0.1.13) - '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0)) + '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)) '@getzep/zep-js': 0.9.0 '@google-ai/generativelanguage': 2.6.0(encoding@0.1.13) '@google-cloud/storage': 7.12.1(encoding@0.1.13) @@ -22668,7 +22675,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 18.16.16 '@types/tough-cookie': 4.0.2 - axios: 1.8.2(debug@4.4.0) + axios: 1.8.2 camelcase: 6.3.0 debug: 4.4.0(supports-color@8.1.1) dotenv: 16.4.5 @@ -22678,7 +22685,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.2 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.8.2) + retry-axios: 2.6.0(axios@1.8.2(debug@4.4.0)) tough-cookie: 4.1.3 transitivePeerDependencies: - supports-color @@ -23661,7 +23668,7 @@ snapshots: kuler@2.0.0: {} - langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0): + langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a): dependencies: '@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)) '@langchain/openai': 0.3.17(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13) @@ -26066,7 +26073,7 @@ snapshots: ret@0.1.15: {} - retry-axios@2.6.0(axios@1.8.2): + retry-axios@2.6.0(axios@1.8.2(debug@4.4.0)): dependencies: axios: 1.8.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c46586e86d..ab6741d366 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -50,3 +50,4 @@ catalogs: vue-tsc: ^2.2.8 vue-markdown-render: ^2.2.1 highlight.js: ^11.8.0 + 'element-plus': 2.4.3