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"
/>
-
+
{
}
.resizeWrapper {
- height: auto;
+ height: 100%;
min-height: 0;
flex-basis: 0;
- border-top: 1px solid var(--color-foreground-base);
+ border-top: var(--border-base);
background-color: var(--color-background-light);
- display: flex;
- align-items: stretch;
- justify-content: stretch;
&.isOpen {
height: var(--panel-height);
@@ -196,11 +195,12 @@ watch([panelState, height], ([state, h]) => {
}
.container {
+ height: 100%;
display: flex;
flex-grow: 1;
& > *:not(:last-child) {
- border-right: 1px solid var(--color-foreground-base);
+ border-right: var(--border-base);
}
}
diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/future/components/ConsumedTokenCountText.vue b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/ConsumedTokenCountText.vue
new file mode 100644
index 0000000000..7e9155126c
--- /dev/null
+++ b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/ConsumedTokenCountText.vue
@@ -0,0 +1,24 @@
+
+
+
+
+ {{
+ locale.baseText('runData.aiContentBlock.tokens', {
+ interpolate: {
+ count: formatTokenUsageCount(consumedTokens, 'total'),
+ },
+ })
+ }}
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewPanel.test.ts b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewPanel.test.ts
new file mode 100644
index 0000000000..ddf5e9ecfe
--- /dev/null
+++ b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewPanel.test.ts
@@ -0,0 +1,148 @@
+import { renderComponent } from '@/__tests__/render';
+import LogsOverviewPanel from './LogsOverviewPanel.vue';
+import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
+import { setActivePinia } from 'pinia';
+import { createTestingPinia, type TestingPinia } from '@pinia/testing';
+import { mockedStore } from '@/__tests__/utils';
+import { useWorkflowsStore } from '@/stores/workflows.store';
+import { createRouter, createWebHistory } from 'vue-router';
+import { h, type ExtractPropTypes } from 'vue';
+import { fireEvent, waitFor, within } from '@testing-library/vue';
+
+describe('LogsOverviewPanel', () => {
+ let pinia: TestingPinia;
+ let workflowsStore: ReturnType>;
+
+ const triggerNode = createTestNode({ name: 'Chat' });
+ const aiAgentNode = createTestNode({ name: 'AI Agent' });
+ const aiModelNode = createTestNode({ name: 'AI Model' });
+ const workflow = createTestWorkflow({
+ nodes: [triggerNode, aiAgentNode, aiModelNode],
+ connections: {
+ Chat: {
+ main: [[{ node: 'AI Agent', index: 0, type: 'main' }]],
+ },
+ 'AI Model': {
+ ai_languageModel: [[{ node: 'AI Agent', index: 0, type: 'ai_languageModel' }]],
+ },
+ },
+ });
+
+ function render(props: ExtractPropTypes) {
+ return renderComponent(LogsOverviewPanel, {
+ props,
+ global: {
+ plugins: [
+ createRouter({
+ history: createWebHistory(),
+ routes: [{ path: '/', component: () => h('div') }],
+ }),
+ pinia,
+ ],
+ },
+ });
+ }
+
+ beforeEach(() => {
+ pinia = createTestingPinia({ stubActions: false, fakeApp: true });
+
+ setActivePinia(pinia);
+
+ workflowsStore = mockedStore(useWorkflowsStore);
+ workflowsStore.setWorkflow(workflow);
+ workflowsStore.setWorkflowExecutionData(null);
+ });
+
+ it('should not render body if the panel is not open', () => {
+ const rendered = render({ isOpen: false, node: null });
+
+ expect(
+ rendered.queryByText('Nothing to display yet', { exact: false }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should render empty text if there is no execution', () => {
+ const rendered = render({ isOpen: true, node: null });
+
+ expect(rendered.queryByText('Nothing to display yet', { exact: false })).toBeInTheDocument();
+ });
+
+ it('should render summary text and executed nodes if there is an execution', async () => {
+ workflowsStore.setWorkflowExecutionData({
+ id: 'test-exec-id',
+ finished: true,
+ mode: 'manual',
+ status: 'success',
+ data: {
+ resultData: {
+ runData: {
+ 'AI Agent': [
+ {
+ executionStatus: 'success',
+ startTime: +new Date('2025-03-26T00:00:00.002Z'),
+ executionTime: 1778,
+ source: [],
+ data: {},
+ },
+ ],
+ 'AI Model': [
+ {
+ executionStatus: 'success',
+ startTime: +new Date('2025-03-26T00:00:00.003Z'),
+ executionTime: 1777,
+ source: [],
+ data: {
+ ai_languageModel: [
+ [
+ {
+ json: {
+ tokenUsage: {
+ completionTokens: 222,
+ promptTokens: 333,
+ totalTokens: 555,
+ },
+ },
+ },
+ ],
+ ],
+ },
+ },
+ ],
+ },
+ },
+ },
+ workflowData: workflow,
+ createdAt: new Date('2025-03-26T00:00:00.000Z'),
+ startedAt: new Date('2025-03-26T00:00:00.001Z'),
+ stoppedAt: new Date('2025-03-26T00:00:02.000Z'),
+ });
+
+ const rendered = render({ isOpen: true, node: aiAgentNode });
+ const summary = within(rendered.container.querySelector('.summary')!);
+
+ expect(summary.queryByText('Success in 1.999s')).toBeInTheDocument();
+ expect(summary.queryByText('555 Tokens')).toBeInTheDocument();
+
+ const tree = within(rendered.getByRole('tree'));
+
+ expect(tree.queryAllByRole('treeitem')).toHaveLength(2);
+
+ const row1 = within(tree.queryAllByRole('treeitem')[0]);
+
+ expect(row1.queryByText('AI Agent')).toBeInTheDocument();
+ expect(row1.queryByText('Success in 1.778s')).toBeInTheDocument();
+ expect(row1.queryByText('Started 2025-03-26T00:00:00.002Z')).toBeInTheDocument();
+ expect(row1.queryByText('555 Tokens')).toBeInTheDocument();
+
+ const row2 = within(tree.queryAllByRole('treeitem')[1]);
+
+ expect(row2.queryByText('AI Model')).toBeInTheDocument();
+ expect(row2.queryByText('Success in 1.777s')).toBeInTheDocument();
+ expect(row2.queryByText('Started 2025-03-26T00:00:00.003Z')).toBeInTheDocument();
+ expect(row2.queryByText('555 Tokens')).toBeInTheDocument();
+
+ // collapse tree
+ await fireEvent.click(row1.getByRole('button'));
+ await waitFor(() => expect(tree.queryAllByRole('treeitem')).toHaveLength(1));
+ });
+});
diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewPanel.vue b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewPanel.vue
index e6a8a8a676..b6a4d15baa 100644
--- a/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewPanel.vue
+++ b/packages/frontend/editor-ui/src/components/CanvasChat/future/components/LogsOverviewPanel.vue
@@ -1,25 +1,100 @@
@@ -45,10 +120,52 @@ function onClearExecutionData() {
-
-
+
+
{{ locale.baseText('logs.overview.body.empty.message') }}
+
+
+ {{ executionStatusText }}
+
+
+
+
+
+
+
+
+
@@ -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 @@
+
+
+
+
+
+
+
+
+
{{ node.name }}
+
{{
+ timeTookText
+ }}
+
{{
+ startedAtText
+ }}
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ {{ i18n.baseText('runData.aiContentBlock.tokens.prompt') }}
+ {{
+ i18n.baseText('runData.aiContentBlock.tokens', {
+ interpolate: {
+ count: formatTokenUsageCount(consumedTokens, 'prompt'),
+ },
+ })
+ }}
+
+
+
+ {{ i18n.baseText('runData.aiContentBlock.tokens.completion') }}
+ {{
+ i18n.baseText('runData.aiContentBlock.tokens', {
+ interpolate: {
+ count: formatTokenUsageCount(consumedTokens, 'completion'),
+ },
+ })
+ }}
+
+
+
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