mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(editor): Logs overview panel (#14045)
This commit is contained in:
@@ -88,7 +88,7 @@ watch(
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--spacing-xs);
|
gap: var(--spacing-xs);
|
||||||
padding-inline: var(--spacing-m);
|
padding-inline: var(--spacing-m);
|
||||||
padding-bottom: 1.5em;
|
padding-bottom: var(--spacing-l);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
"@fortawesome/fontawesome-svg-core": "^1.2.36",
|
"@fortawesome/fontawesome-svg-core": "^1.2.36",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
"@fortawesome/vue-fontawesome": "^3.0.3",
|
||||||
"element-plus": "2.4.3",
|
"element-plus": "catalog:frontend",
|
||||||
"is-emoji-supported": "^0.0.5",
|
"is-emoji-supported": "^0.0.5",
|
||||||
"markdown-it": "^13.0.2",
|
"markdown-it": "^13.0.2",
|
||||||
"markdown-it-emoji": "^2.0.2",
|
"markdown-it-emoji": "^2.0.2",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"core-js": "^3.40.0",
|
"core-js": "^3.40.0",
|
||||||
"curlconverter": "^4.12.0",
|
"curlconverter": "^4.12.0",
|
||||||
"dateformat": "^3.0.3",
|
"dateformat": "^3.0.3",
|
||||||
|
"element-plus": "catalog:frontend",
|
||||||
"email-providers": "^2.0.1",
|
"email-providers": "^2.0.1",
|
||||||
"esprima-next": "5.8.4",
|
"esprima-next": "5.8.4",
|
||||||
"fast-json-stable-stringify": "^2.1.0",
|
"fast-json-stable-stringify": "^2.1.0",
|
||||||
|
|||||||
@@ -445,9 +445,9 @@ export interface IExecutionBase {
|
|||||||
status: ExecutionStatus;
|
status: ExecutionStatus;
|
||||||
retryOf?: string;
|
retryOf?: string;
|
||||||
retrySuccessId?: string;
|
retrySuccessId?: string;
|
||||||
startedAt: Date;
|
startedAt: Date | string;
|
||||||
createdAt: Date;
|
createdAt: Date | string;
|
||||||
stoppedAt?: Date;
|
stoppedAt?: Date | string;
|
||||||
workflowId?: string; // To be able to filter executions easily //
|
workflowId?: string; // To be able to filter executions easily //
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1583,3 +1583,10 @@ export type MainPanelDimensions = Record<
|
|||||||
relativeWidth: number;
|
relativeWidth: number;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export interface LlmTokenUsageData {
|
||||||
|
completionTokens: number;
|
||||||
|
promptTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
isEstimate: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import ChatInput from '@n8n/chat/components/Input.vue';
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useClipboard } from '@/composables/useClipboard';
|
import { useClipboard } from '@/composables/useClipboard';
|
||||||
import { useToast } from '@/composables/useToast';
|
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';
|
import { N8nButton, N8nIconButton, N8nTooltip } from '@n8n/design-system';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -357,7 +357,7 @@ async function copySessionId() {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-top: 1.5em;
|
padding-top: var(--spacing-l);
|
||||||
|
|
||||||
&:not(:last-child) {
|
&:not(:last-child) {
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
|
|||||||
@@ -32,10 +32,8 @@ const telemetry = useTelemetry();
|
|||||||
const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResizeChatDebounced } =
|
const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResizeChatDebounced } =
|
||||||
useResize(container);
|
useResize(container);
|
||||||
|
|
||||||
const { currentSessionId, messages, sendMessage, refreshSession, displayExecution } = useChatState(
|
const { currentSessionId, messages, connectedNode, sendMessage, refreshSession, displayExecution } =
|
||||||
ref(false),
|
useChatState(ref(false), onWindowResize);
|
||||||
onWindowResize,
|
|
||||||
);
|
|
||||||
const appStyles = useStyles();
|
const appStyles = useStyles();
|
||||||
const tooltipZIndex = computed(() => appStyles.APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON + 100);
|
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]"
|
:class="[$style.resizeWrapper, panelState === 'closed' ? '' : $style.isOpen]"
|
||||||
@resize="onResizeDebounced"
|
@resize="onResizeDebounced"
|
||||||
>
|
>
|
||||||
<div ref="container" :class="$style.container">
|
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
|
||||||
<N8nResizeWrapper
|
<N8nResizeWrapper
|
||||||
v-if="hasChat"
|
v-if="hasChat"
|
||||||
:supported-directions="['right']"
|
:supported-directions="['right']"
|
||||||
@@ -120,7 +118,11 @@ watch([panelState, height], ([state, h]) => {
|
|||||||
@click-header="handleClickHeader"
|
@click-header="handleClickHeader"
|
||||||
/>
|
/>
|
||||||
</N8nResizeWrapper>
|
</N8nResizeWrapper>
|
||||||
<LogsOverviewPanel :is-open="panelState !== 'closed'" @click-header="handleClickHeader">
|
<LogsOverviewPanel
|
||||||
|
:is-open="panelState !== 'closed'"
|
||||||
|
:node="connectedNode"
|
||||||
|
@click-header="handleClickHeader"
|
||||||
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<N8nTooltip
|
<N8nTooltip
|
||||||
v-if="canPopOut && !isPoppedOut"
|
v-if="canPopOut && !isPoppedOut"
|
||||||
@@ -178,14 +180,11 @@ watch([panelState, height], ([state, h]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resizeWrapper {
|
.resizeWrapper {
|
||||||
height: auto;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-basis: 0;
|
flex-basis: 0;
|
||||||
border-top: 1px solid var(--color-foreground-base);
|
border-top: var(--border-base);
|
||||||
background-color: var(--color-background-light);
|
background-color: var(--color-background-light);
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: stretch;
|
|
||||||
|
|
||||||
&.isOpen {
|
&.isOpen {
|
||||||
height: var(--panel-height);
|
height: var(--panel-height);
|
||||||
@@ -196,11 +195,12 @@ watch([panelState, height], ([state, h]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
||||||
& > *:not(:last-child) {
|
& > *:not(:last-child) {
|
||||||
border-right: 1px solid var(--color-foreground-base);
|
border-right: var(--border-base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { formatTokenUsageCount } from '@/components/RunDataAi/utils';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { type LlmTokenUsageData } from '@/Interface';
|
||||||
|
import { N8nTooltip } from '@n8n/design-system';
|
||||||
|
|
||||||
|
const { consumedTokens } = defineProps<{ consumedTokens: LlmTokenUsageData }>();
|
||||||
|
const locale = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<N8nTooltip v-if="consumedTokens !== undefined" :enterable="false">
|
||||||
|
<span>{{
|
||||||
|
locale.baseText('runData.aiContentBlock.tokens', {
|
||||||
|
interpolate: {
|
||||||
|
count: formatTokenUsageCount(consumedTokens, 'total'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}</span>
|
||||||
|
<template #content>
|
||||||
|
<ConsumedTokensDetails :consumed-tokens="consumedTokens" />
|
||||||
|
</template>
|
||||||
|
</N8nTooltip>
|
||||||
|
</template>
|
||||||
@@ -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<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||||
|
|
||||||
|
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<typeof LogsOverviewPanel>) {
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,25 +1,100 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PanelHeader from '@/components/CanvasChat/components/PanelHeader.vue';
|
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
|
||||||
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
|
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { N8nButton, N8nText, N8nTooltip } from '@n8n/design-system';
|
import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { ElTree, type TreeNode as ElTreeNode } from 'element-plus';
|
||||||
|
import {
|
||||||
|
createAiData,
|
||||||
|
getSubtreeTotalConsumedTokens,
|
||||||
|
getTotalConsumedTokens,
|
||||||
|
getTreeNodeData,
|
||||||
|
type TreeNode,
|
||||||
|
} from '@/components/RunDataAi/utils';
|
||||||
|
import { type INodeUi } from '@/Interface';
|
||||||
|
import { upperFirst } from 'lodash-es';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
|
||||||
|
|
||||||
defineProps<{ isOpen: boolean }>();
|
const { node, isOpen } = defineProps<{ isOpen: boolean; node: INodeUi | null }>();
|
||||||
|
|
||||||
const emit = defineEmits<{ clickHeader: [] }>();
|
const emit = defineEmits<{ clickHeader: [] }>();
|
||||||
|
|
||||||
|
defineSlots<{ actions: {} }>();
|
||||||
|
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const nodeHelpers = useNodeHelpers();
|
const nodeHelpers = useNodeHelpers();
|
||||||
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
|
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
|
||||||
|
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||||
|
const executionTree = computed<TreeNode[]>(() =>
|
||||||
|
node
|
||||||
|
? getTreeNodeData(
|
||||||
|
node.name,
|
||||||
|
workflow.value,
|
||||||
|
createAiData(node.name, workflow.value, workflowsStore.getWorkflowResultDataByNodeName),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const isEmpty = computed(() => workflowsStore.workflowExecutionData === null);
|
||||||
|
const switchViewOptions = computed<Array<{ label: string; value: string }>>(() => [
|
||||||
|
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' },
|
||||||
|
{ label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' },
|
||||||
|
]);
|
||||||
|
const executionStatusText = computed(() => {
|
||||||
|
const execution = workflowsStore.workflowExecutionData;
|
||||||
|
|
||||||
defineSlots<{ actions: {} }>();
|
if (!execution) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (execution.startedAt && execution.stoppedAt) {
|
||||||
|
return locale.baseText('logs.overview.body.summaryText', {
|
||||||
|
interpolate: {
|
||||||
|
status: upperFirst(execution.status),
|
||||||
|
time: locale.displayTimer(
|
||||||
|
+new Date(execution.stoppedAt) - +new Date(execution.startedAt),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return upperFirst(execution.status);
|
||||||
|
});
|
||||||
|
const consumedTokens = computed(() =>
|
||||||
|
getTotalConsumedTokens(...executionTree.value.map(getSubtreeTotalConsumedTokens)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedRun = ref<{ node: string; runIndex: number } | undefined>(undefined);
|
||||||
|
|
||||||
function onClearExecutionData() {
|
function onClearExecutionData() {
|
||||||
workflowsStore.setWorkflowExecutionData(null);
|
workflowsStore.setWorkflowExecutionData(null);
|
||||||
nodeHelpers.updateNodesExecutionIssues();
|
nodeHelpers.updateNodesExecutionIssues();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleClickNode(clicked: TreeNode) {
|
||||||
|
if (selectedRun.value?.node === clicked.node && selectedRun.value.runIndex === clicked.runIndex) {
|
||||||
|
selectedRun.value = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedRun.value = { node: clicked.node, runIndex: clicked.runIndex };
|
||||||
|
telemetry.track('User selected node in log view', {
|
||||||
|
node_type: workflowsStore.nodesByName[clicked.node].type,
|
||||||
|
node_id: workflowsStore.nodesByName[clicked.node].id,
|
||||||
|
execution_id: workflowsStore.workflowExecutionData?.id,
|
||||||
|
workflow_id: workflow.value.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleExpanded(treeNode: ElTreeNode) {
|
||||||
|
treeNode.expanded = !treeNode.expanded;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -45,10 +120,52 @@ function onClearExecutionData() {
|
|||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
</template>
|
</template>
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
<div v-if="isOpen" :class="[$style.content, $style.empty]">
|
<div v-if="isOpen" :class="[$style.content, isEmpty ? $style.empty : '']">
|
||||||
<N8nText tag="p" size="medium" color="text-base" :class="$style.emptyText">
|
<N8nText v-if="isEmpty" tag="p" size="medium" color="text-base" :class="$style.emptyText">
|
||||||
{{ locale.baseText('logs.overview.body.empty.message') }}
|
{{ locale.baseText('logs.overview.body.empty.message') }}
|
||||||
</N8nText>
|
</N8nText>
|
||||||
|
<div v-else :class="$style.scrollable">
|
||||||
|
<N8nText
|
||||||
|
v-if="executionStatusText !== undefined"
|
||||||
|
tag="div"
|
||||||
|
color="text-light"
|
||||||
|
size="small"
|
||||||
|
:class="$style.summary"
|
||||||
|
>
|
||||||
|
<span>{{ executionStatusText }}</span>
|
||||||
|
<ConsumedTokenCountText
|
||||||
|
v-if="consumedTokens.totalTokens > 0"
|
||||||
|
:consumed-tokens="consumedTokens"
|
||||||
|
/>
|
||||||
|
</N8nText>
|
||||||
|
<ElTree
|
||||||
|
v-if="executionTree.length > 0"
|
||||||
|
:class="$style.tree"
|
||||||
|
:indent="0"
|
||||||
|
:data="executionTree"
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
:default-expand-all="true"
|
||||||
|
@node-click="handleClickNode"
|
||||||
|
>
|
||||||
|
<template #default="{ node: elTreeNode, data }">
|
||||||
|
<LogsOverviewRow
|
||||||
|
:data="data"
|
||||||
|
:node="elTreeNode"
|
||||||
|
:is-selected="
|
||||||
|
data.node === selectedRun?.node && data.runIndex === selectedRun?.runIndex
|
||||||
|
"
|
||||||
|
:should-show-consumed-tokens="consumedTokens.totalTokens > 0"
|
||||||
|
@toggle-expanded="handleToggleExpanded"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ElTree>
|
||||||
|
<N8nRadioButtons
|
||||||
|
size="medium"
|
||||||
|
:class="$style.switchViewButtons"
|
||||||
|
:model-value="selectedRun ? 'details' : 'overview'"
|
||||||
|
:options="switchViewOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,14 +177,19 @@ function onClearExecutionData() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: var(--spacing-2xs);
|
position: relative;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
&.empty {
|
&.empty {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
@@ -77,4 +199,40 @@ function onClearExecutionData() {
|
|||||||
max-width: 20em;
|
max-width: 20em;
|
||||||
text-align: center;
|
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);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type TreeNode as ElTreeNode } from 'element-plus';
|
||||||
|
import { getSubtreeTotalConsumedTokens, type TreeNode } from '@/components/RunDataAi/utils';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { type INodeUi } from '@/Interface';
|
||||||
|
import { type ExecutionStatus, type ITaskData } from 'n8n-workflow';
|
||||||
|
import { N8nIconButton, N8nText } from '@n8n/design-system';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import { upperFirst } from 'lodash-es';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: TreeNode;
|
||||||
|
node: ElTreeNode;
|
||||||
|
isSelected: boolean;
|
||||||
|
shouldShowConsumedTokens: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{ toggleExpanded: [node: ElTreeNode] }>();
|
||||||
|
|
||||||
|
const locale = useI18n();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const nodeTypeStore = useNodeTypesStore();
|
||||||
|
const node = computed<INodeUi | undefined>(() => workflowsStore.nodesByName[props.data.node]);
|
||||||
|
const runData = computed<ITaskData | undefined>(() =>
|
||||||
|
node.value
|
||||||
|
? workflowsStore.workflowExecutionData?.data?.resultData.runData[node.value.name]?.[
|
||||||
|
props.data.runIndex
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const type = computed(() => (node.value ? nodeTypeStore.getNodeType(node.value.type) : undefined));
|
||||||
|
const depth = computed(() => (props.node.level ?? 1) - 1);
|
||||||
|
const timeTookText = computed(() => {
|
||||||
|
const finalStatuses: ExecutionStatus[] = ['crashed', 'error', 'success'];
|
||||||
|
const status = runData.value?.executionStatus;
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusText = upperFirst(status);
|
||||||
|
|
||||||
|
return finalStatuses.includes(status)
|
||||||
|
? locale.baseText('logs.overview.body.summaryText', {
|
||||||
|
interpolate: {
|
||||||
|
status: statusText,
|
||||||
|
time: locale.displayTimer(runData.value.executionTime, true),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: statusText;
|
||||||
|
});
|
||||||
|
const startedAtText = computed(() =>
|
||||||
|
locale.baseText('logs.overview.body.started', {
|
||||||
|
interpolate: {
|
||||||
|
time: new Date(runData.value?.startTime ?? 0).toISOString(), // TODO: confirm date format
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtreeConsumedTokens = computed(() =>
|
||||||
|
props.shouldShowConsumedTokens ? getSubtreeTotalConsumedTokens(props.data) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
function isLastChild(level: number) {
|
||||||
|
let parent = props.data.parent;
|
||||||
|
let data: TreeNode | undefined = props.data;
|
||||||
|
|
||||||
|
for (let i = 0; i < depth.value - level; i++) {
|
||||||
|
data = parent;
|
||||||
|
parent = parent?.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblings = parent?.children ?? [];
|
||||||
|
|
||||||
|
return data === siblings[siblings.length - 1];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="node !== undefined"
|
||||||
|
:class="{ [$style.container]: true, [$style.selected]: props.isSelected }"
|
||||||
|
>
|
||||||
|
<template v-for="level in depth" :key="level">
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
[$style.indent]: true,
|
||||||
|
[$style.connectorCurved]: level === depth,
|
||||||
|
[$style.connectorStraight]: !isLastChild(level),
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
|
||||||
|
<N8nText tag="div" :bold="true" size="small" :class="$style.name">{{ node.name }}</N8nText>
|
||||||
|
<N8nText tag="div" color="text-light" size="small" :class="$style.timeTook">{{
|
||||||
|
timeTookText
|
||||||
|
}}</N8nText>
|
||||||
|
<N8nText tag="div" color="text-light" size="small" :class="$style.startedAt">{{
|
||||||
|
startedAtText
|
||||||
|
}}</N8nText>
|
||||||
|
<N8nText
|
||||||
|
v-if="subtreeConsumedTokens !== undefined"
|
||||||
|
tag="div"
|
||||||
|
color="text-light"
|
||||||
|
size="small"
|
||||||
|
:class="$style.consumedTokens"
|
||||||
|
>
|
||||||
|
<ConsumedTokenCountText
|
||||||
|
v-if="
|
||||||
|
subtreeConsumedTokens.totalTokens > 0 &&
|
||||||
|
(props.data.children.length === 0 || !props.node.expanded)
|
||||||
|
"
|
||||||
|
:consumed-tokens="subtreeConsumedTokens"
|
||||||
|
/>
|
||||||
|
</N8nText>
|
||||||
|
<div>
|
||||||
|
<N8nIconButton
|
||||||
|
type="secondary"
|
||||||
|
size="medium"
|
||||||
|
:icon="props.node.expanded ? 'chevron-down' : 'chevron-up'"
|
||||||
|
:style="{
|
||||||
|
visibility: props.data.children.length === 0 ? 'hidden' : '',
|
||||||
|
color: 'var(--color-text-base)', // give higher specificity than the style from the component itself
|
||||||
|
}"
|
||||||
|
:class="$style.toggleButton"
|
||||||
|
@click.stop="emit('toggleExpanded', props.node)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :has(.toggleButton) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .icon {
|
||||||
|
border-top-left-radius: var(--border-radius-base);
|
||||||
|
border-bottom-left-radius: var(--border-radius-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :last-of-type {
|
||||||
|
border-top-right-radius: var(--border-radius-base);
|
||||||
|
border-bottom-right-radius: var(--border-radius-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected > :not(.indent),
|
||||||
|
&:hover > :not(.indent) {
|
||||||
|
background-color: var(--color-foreground-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.indent {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: var(--spacing-xl);
|
||||||
|
align-self: stretch;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.connectorCurved:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: var(--spacing-s);
|
||||||
|
bottom: var(--spacing-s);
|
||||||
|
border: 2px solid var(--color-canvas-dot);
|
||||||
|
width: var(--spacing-l);
|
||||||
|
height: var(--spacing-l);
|
||||||
|
border-radius: var(--border-radius-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.connectorStraight:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: var(--spacing-s);
|
||||||
|
top: 0;
|
||||||
|
border-left: 2px solid var(--color-canvas-dot);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeTook {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startedAt {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consumedTokens {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 10%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleButton {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
margin-inline-end: var(--spacing-5xs);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { N8nText } from '@n8n/design-system';
|
||||||
|
|
||||||
defineProps<{ title: string }>();
|
defineProps<{ title: string }>();
|
||||||
|
|
||||||
defineSlots<{ actions: {} }>();
|
defineSlots<{ actions: {} }>();
|
||||||
@@ -8,7 +10,7 @@ const emit = defineEmits<{ click: [] }>();
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header :class="$style.container" @click="emit('click')">
|
<header :class="$style.container" @click="emit('click')">
|
||||||
<span :class="$style.title">{{ title }}</span>
|
<N8nText :class="$style.title" :bold="true" size="small">{{ title }}</N8nText>
|
||||||
<div :class="$style.actions">
|
<div :class="$style.actions">
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
</div>
|
</div>
|
||||||
@@ -18,10 +20,9 @@ const emit = defineEmits<{ click: [] }>();
|
|||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.container {
|
.container {
|
||||||
font-size: var(--font-size-2xs);
|
font-size: var(--font-size-2xs);
|
||||||
font-weight: 400;
|
|
||||||
line-height: 18px;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding-inline: var(--spacing-s);
|
padding-inline-start: var(--spacing-s);
|
||||||
|
padding-inline-end: var(--spacing-xs);
|
||||||
padding-block: var(--spacing-2xs);
|
padding-block: var(--spacing-2xs);
|
||||||
background-color: var(--color-foreground-xlight);
|
background-color: var(--color-foreground-xlight);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -36,12 +37,11 @@ const emit = defineEmits<{ click: [] }>();
|
|||||||
|
|
||||||
&:not(:last-child) {
|
&:not(:last-child) {
|
||||||
/** Panel open */
|
/** Panel open */
|
||||||
border-bottom: 1px solid var(--color-foreground-base);
|
border-bottom: var(--border-base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: 600;
|
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { formatTokenUsageCount } from '@/components/RunDataAi/utils';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { type LlmTokenUsageData } from '@/Interface';
|
||||||
|
import { N8nText } from '@n8n/design-system';
|
||||||
|
|
||||||
|
const { consumedTokens } = defineProps<{ consumedTokens: LlmTokenUsageData }>();
|
||||||
|
const i18n = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<N8nText :bold="true" size="small">
|
||||||
|
{{ i18n.baseText('runData.aiContentBlock.tokens.prompt') }}
|
||||||
|
{{
|
||||||
|
i18n.baseText('runData.aiContentBlock.tokens', {
|
||||||
|
interpolate: {
|
||||||
|
count: formatTokenUsageCount(consumedTokens, 'prompt'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</N8nText>
|
||||||
|
<br />
|
||||||
|
<N8nText :bold="true" size="small">
|
||||||
|
{{ i18n.baseText('runData.aiContentBlock.tokens.completion') }}
|
||||||
|
{{
|
||||||
|
i18n.baseText('runData.aiContentBlock.tokens', {
|
||||||
|
interpolate: {
|
||||||
|
count: formatTokenUsageCount(consumedTokens, 'completion'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</N8nText>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -2,17 +2,14 @@
|
|||||||
import type { IAiData, IAiDataContent } from '@/Interface';
|
import type { IAiData, IAiDataContent } from '@/Interface';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import type {
|
import type { INodeTypeDescription, NodeConnectionType, NodeError } from 'n8n-workflow';
|
||||||
INodeExecutionData,
|
|
||||||
INodeTypeDescription,
|
|
||||||
NodeConnectionType,
|
|
||||||
NodeError,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import NodeIcon from '@/components/NodeIcon.vue';
|
import NodeIcon from '@/components/NodeIcon.vue';
|
||||||
import AiRunContentBlock from './AiRunContentBlock.vue';
|
import AiRunContentBlock from './AiRunContentBlock.vue';
|
||||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { formatTokenUsageCount, getConsumedTokens } from '@/components/RunDataAi/utils';
|
||||||
|
import ConsumedTokensDetails from '@/components/ConsumedTokensDetails.vue';
|
||||||
|
|
||||||
interface RunMeta {
|
interface RunMeta {
|
||||||
startTimeMs: number;
|
startTimeMs: number;
|
||||||
@@ -36,44 +33,11 @@ const workflowsStore = useWorkflowsStore();
|
|||||||
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = useExecutionHelpers();
|
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = useExecutionHelpers();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
type TokenUsageData = {
|
|
||||||
completionTokens: number;
|
|
||||||
promptTokens: number;
|
|
||||||
totalTokens: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const consumedTokensSum = computed(() => {
|
const consumedTokensSum = computed(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||||
const tokenUsage = outputRun.value?.data?.reduce(
|
return getConsumedTokens(outputRun.value);
|
||||||
(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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
function extractRunMeta(run: IAiDataContent) {
|
||||||
const uiNode = workflowsStore.getNodeByName(props.inputData.node);
|
const uiNode = workflowsStore.getNodeByName(props.inputData.node);
|
||||||
const nodeType = nodeTypesStore.getNodeType(uiNode?.type ?? '');
|
const nodeType = nodeTypesStore.getNodeType(uiNode?.type ?? '');
|
||||||
@@ -155,34 +119,12 @@ const outputError = computed(() => {
|
|||||||
{{
|
{{
|
||||||
i18n.baseText('runData.aiContentBlock.tokens', {
|
i18n.baseText('runData.aiContentBlock.tokens', {
|
||||||
interpolate: {
|
interpolate: {
|
||||||
count: formatTokenUsageCount(consumedTokensSum?.totalTokens ?? 0),
|
count: formatTokenUsageCount(consumedTokensSum, 'total'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
<n8n-info-tip type="tooltip" theme="info-light" tooltip-placement="right">
|
<n8n-info-tip type="tooltip" theme="info-light" tooltip-placement="right">
|
||||||
<div>
|
<ConsumedTokensDetails :consumed-tokens="consumedTokensSum" />
|
||||||
<n8n-text :bold="true" size="small">
|
|
||||||
{{ i18n.baseText('runData.aiContentBlock.tokens.prompt') }}
|
|
||||||
{{
|
|
||||||
i18n.baseText('runData.aiContentBlock.tokens', {
|
|
||||||
interpolate: {
|
|
||||||
count: formatTokenUsageCount(consumedTokensSum?.promptTokens ?? 0),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</n8n-text>
|
|
||||||
<br />
|
|
||||||
<n8n-text :bold="true" size="small">
|
|
||||||
{{ i18n.baseText('runData.aiContentBlock.tokens.completion') }}
|
|
||||||
{{
|
|
||||||
i18n.baseText('runData.aiContentBlock.tokens', {
|
|
||||||
interpolate: {
|
|
||||||
count: formatTokenUsageCount(consumedTokensSum?.completionTokens ?? 0),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</n8n-text>
|
|
||||||
</div>
|
|
||||||
</n8n-info-tip>
|
</n8n-info-tip>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -31,11 +31,62 @@ describe(getTreeNodeData, () => {
|
|||||||
const taskDataByNodeName: Record<string, ITaskData[]> = {
|
const taskDataByNodeName: Record<string, ITaskData[]> = {
|
||||||
A: [createTaskData({ startTime: +new Date('2025-02-26T00:00:00.000Z') })],
|
A: [createTaskData({ startTime: +new Date('2025-02-26T00:00:00.000Z') })],
|
||||||
B: [
|
B: [
|
||||||
createTaskData({ startTime: +new Date('2025-02-26T00:00:01.000Z') }),
|
createTaskData({
|
||||||
createTaskData({ startTime: +new Date('2025-02-26T00:00:03.000Z') }),
|
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: [
|
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') }),
|
createTaskData({ startTime: +new Date('2025-02-26T00:00:04.000Z') }),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -53,6 +104,13 @@ describe(getTreeNodeData, () => {
|
|||||||
node: 'A',
|
node: 'A',
|
||||||
runIndex: 0,
|
runIndex: 0,
|
||||||
startTime: 0,
|
startTime: 0,
|
||||||
|
parent: undefined,
|
||||||
|
consumedTokens: {
|
||||||
|
completionTokens: 0,
|
||||||
|
promptTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
isEstimate: false,
|
||||||
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
depth: 1,
|
depth: 1,
|
||||||
@@ -60,6 +118,13 @@ describe(getTreeNodeData, () => {
|
|||||||
node: 'B',
|
node: 'B',
|
||||||
runIndex: 0,
|
runIndex: 0,
|
||||||
startTime: +new Date('2025-02-26T00:00:01.000Z'),
|
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: [
|
||||||
{
|
{
|
||||||
children: [],
|
children: [],
|
||||||
@@ -68,6 +133,13 @@ describe(getTreeNodeData, () => {
|
|||||||
node: 'C',
|
node: 'C',
|
||||||
runIndex: 0,
|
runIndex: 0,
|
||||||
startTime: +new Date('2025-02-26T00:00:02.000Z'),
|
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',
|
node: 'B',
|
||||||
runIndex: 1,
|
runIndex: 1,
|
||||||
startTime: +new Date('2025-02-26T00:00:03.000Z'),
|
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: [
|
||||||
{
|
{
|
||||||
children: [],
|
children: [],
|
||||||
@@ -85,6 +164,13 @@ describe(getTreeNodeData, () => {
|
|||||||
node: 'C',
|
node: 'C',
|
||||||
runIndex: 1,
|
runIndex: 1,
|
||||||
startTime: +new Date('2025-02-26T00:00:04.000Z'),
|
startTime: +new Date('2025-02-26T00:00:04.000Z'),
|
||||||
|
parent: expect.objectContaining({ node: 'B' }),
|
||||||
|
consumedTokens: {
|
||||||
|
completionTokens: 0,
|
||||||
|
promptTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
isEstimate: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type IAiDataContent } from '@/Interface';
|
import { type LlmTokenUsageData, type IAiDataContent } from '@/Interface';
|
||||||
import {
|
import {
|
||||||
|
type INodeExecutionData,
|
||||||
type ITaskData,
|
type ITaskData,
|
||||||
type ITaskDataConnections,
|
type ITaskDataConnections,
|
||||||
type NodeConnectionType,
|
type NodeConnectionType,
|
||||||
@@ -13,27 +14,32 @@ export interface AIResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TreeNode {
|
export interface TreeNode {
|
||||||
|
parent?: TreeNode;
|
||||||
node: string;
|
node: string;
|
||||||
id: string;
|
id: string;
|
||||||
children: TreeNode[];
|
children: TreeNode[];
|
||||||
depth: number;
|
depth: number;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
runIndex: number;
|
runIndex: number;
|
||||||
|
consumedTokens: LlmTokenUsageData;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNode(
|
function createNode(
|
||||||
|
parent: TreeNode | undefined,
|
||||||
nodeName: string,
|
nodeName: string,
|
||||||
currentDepth: number,
|
currentDepth: number,
|
||||||
r?: AIResult,
|
r?: AIResult,
|
||||||
children: TreeNode[] = [],
|
children: TreeNode[] = [],
|
||||||
): TreeNode {
|
): TreeNode {
|
||||||
return {
|
return {
|
||||||
|
parent,
|
||||||
node: nodeName,
|
node: nodeName,
|
||||||
id: nodeName,
|
id: nodeName,
|
||||||
depth: currentDepth,
|
depth: currentDepth,
|
||||||
startTime: r?.data?.metadata?.startTime ?? 0,
|
startTime: r?.data?.metadata?.startTime ?? 0,
|
||||||
runIndex: r?.runIndex ?? 0,
|
runIndex: r?.runIndex ?? 0,
|
||||||
children,
|
children,
|
||||||
|
consumedTokens: getConsumedTokens(r?.data),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,10 +48,11 @@ export function getTreeNodeData(
|
|||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
aiData: AIResult[] | undefined,
|
aiData: AIResult[] | undefined,
|
||||||
): TreeNode[] {
|
): TreeNode[] {
|
||||||
return getTreeNodeDataRec(nodeName, 0, workflow, aiData, undefined);
|
return getTreeNodeDataRec(undefined, nodeName, 0, workflow, aiData, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTreeNodeDataRec(
|
function getTreeNodeDataRec(
|
||||||
|
parent: TreeNode | undefined,
|
||||||
nodeName: string,
|
nodeName: string,
|
||||||
currentDepth: number,
|
currentDepth: number,
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
@@ -59,12 +66,13 @@ function getTreeNodeDataRec(
|
|||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
if (!connections) {
|
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
|
// Get the first level of children
|
||||||
const connectedSubNodes = workflow.getParentNodes(nodeName, 'ALL_NON_MAIN', 1);
|
const connectedSubNodes = workflow.getParentNodes(nodeName, 'ALL_NON_MAIN', 1);
|
||||||
|
|
||||||
|
const treeNode = createNode(parent, nodeName, currentDepth);
|
||||||
const children = connectedSubNodes.flatMap((name) => {
|
const children = connectedSubNodes.flatMap((name) => {
|
||||||
// Only include sub-nodes which have data
|
// Only include sub-nodes which have data
|
||||||
return (
|
return (
|
||||||
@@ -73,18 +81,20 @@ function getTreeNodeDataRec(
|
|||||||
(data) => data.node === name && (runIndex === undefined || data.runIndex === runIndex),
|
(data) => data.node === name && (runIndex === undefined || data.runIndex === runIndex),
|
||||||
)
|
)
|
||||||
.flatMap((data) =>
|
.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);
|
children.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
|
treeNode.children = children;
|
||||||
|
|
||||||
if (resultData.length) {
|
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(
|
export function createAiData(
|
||||||
@@ -158,3 +168,66 @@ export function getReferencedData(
|
|||||||
|
|
||||||
return returnData;
|
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<LlmTokenUsageData>(
|
||||||
|
(acc: LlmTokenUsageData, curr: INodeExecutionData) => {
|
||||||
|
const tokenUsageData = curr.json?.tokenUsage ?? curr.json?.tokenUsageEstimate;
|
||||||
|
|
||||||
|
if (!tokenUsageData) return acc;
|
||||||
|
|
||||||
|
return addTokenUsageData(acc, {
|
||||||
|
...(tokenUsageData as Omit<LlmTokenUsageData, 'isEstimate'>),
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -240,10 +240,19 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
|
|||||||
|
|
||||||
let showedSuccessToast = false;
|
let showedSuccessToast = false;
|
||||||
|
|
||||||
let executionData: Pick<IExecutionResponse, 'workflowId' | 'data' | 'status'>;
|
let executionData: Pick<
|
||||||
|
IExecutionResponse,
|
||||||
|
'workflowId' | 'data' | 'status' | 'startedAt' | 'stoppedAt'
|
||||||
|
>;
|
||||||
if (receivedData.type === 'executionFinished' && receivedData.data.rawData) {
|
if (receivedData.type === 'executionFinished' && receivedData.data.rawData) {
|
||||||
const { workflowId, status, rawData } = receivedData.data;
|
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 {
|
} else {
|
||||||
uiStore.setProcessingExecutionResults(true);
|
uiStore.setProcessingExecutionResults(true);
|
||||||
|
|
||||||
@@ -278,6 +287,8 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
|
|||||||
workflowId: execution.workflowId,
|
workflowId: execution.workflowId,
|
||||||
data: parse(execution.data as unknown as string),
|
data: parse(execution.data as unknown as string),
|
||||||
status: execution.status,
|
status: execution.status,
|
||||||
|
startedAt: workflowsStore.workflowExecutionData?.startedAt as Date,
|
||||||
|
stoppedAt: receivedData.type === 'executionFinished' ? new Date() : undefined,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
uiStore.setProcessingExecutionResults(false);
|
uiStore.setProcessingExecutionResults(false);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
import type { IStartRunData, IWorkflowData } from '@/Interface';
|
import type { IExecutionResponse, IStartRunData, IWorkflowData } from '@/Interface';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
@@ -23,7 +23,8 @@ import { useI18n } from '@/composables/useI18n';
|
|||||||
import { captor, mock } from 'vitest-mock-extended';
|
import { captor, mock } from 'vitest-mock-extended';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
import { createTestNode } from '@/__tests__/mocks';
|
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
|
||||||
vi.mock('@/stores/workflows.store', () => ({
|
vi.mock('@/stores/workflows.store', () => ({
|
||||||
useWorkflowsStore: vi.fn().mockReturnValue({
|
useWorkflowsStore: vi.fn().mockReturnValue({
|
||||||
@@ -45,6 +46,7 @@ vi.mock('@/stores/workflows.store', () => ({
|
|||||||
getPinnedDataLastRemovedAt: vi.fn(),
|
getPinnedDataLastRemovedAt: vi.fn(),
|
||||||
incomingConnectionsByNodeName: vi.fn(),
|
incomingConnectionsByNodeName: vi.fn(),
|
||||||
outgoingConnectionsByNodeName: 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -447,6 +447,15 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
|||||||
toast.showError(error, i18n.baseText('nodeView.showError.stopExecution.title'));
|
toast.showError(error, i18n.baseText('nodeView.showError.stopExecution.title'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
// Wait for websocket event to update the execution status to 'canceled'
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
if (workflowsStore.workflowExecutionData?.status !== 'running') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(requestAnimationFrame);
|
||||||
|
}
|
||||||
|
|
||||||
workflowsStore.markExecutionAsStopped();
|
workflowsStore.markExecutionAsStopped();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ export class I18nClass {
|
|||||||
return `${Math.floor(msPassed / 1000)}${this.baseText('genericHelpers.secShort')}`;
|
return `${Math.floor(msPassed / 1000)}${this.baseText('genericHelpers.secShort')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msPassed > 0 && msPassed < 1000) {
|
||||||
|
return `${msPassed}${this.baseText('genericHelpers.millis')}`;
|
||||||
|
}
|
||||||
|
|
||||||
return `${msPassed / 1000}${this.baseText('genericHelpers.secShort')}`;
|
return `${msPassed / 1000}${this.baseText('genericHelpers.secShort')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -964,6 +964,7 @@
|
|||||||
"genericHelpers.minShort": "m",
|
"genericHelpers.minShort": "m",
|
||||||
"genericHelpers.sec": "sec",
|
"genericHelpers.sec": "sec",
|
||||||
"genericHelpers.secShort": "s",
|
"genericHelpers.secShort": "s",
|
||||||
|
"genericHelpers.millis": "ms",
|
||||||
"readOnly.showMessage.executions.message": "Executions are read-only. Make changes from the <b>Workflow</b> tab.",
|
"readOnly.showMessage.executions.message": "Executions are read-only. Make changes from the <b>Workflow</b> tab.",
|
||||||
"readOnly.showMessage.executions.title": "Cannot edit execution",
|
"readOnly.showMessage.executions.title": "Cannot edit execution",
|
||||||
"readOnlyEnv.showMessage.executions.message": "Executions are read-only.",
|
"readOnlyEnv.showMessage.executions.message": "Executions are read-only.",
|
||||||
@@ -980,8 +981,12 @@
|
|||||||
"logs.overview.header.title": "Logs",
|
"logs.overview.header.title": "Logs",
|
||||||
"logs.overview.header.actions.clearExecution": "Clear execution",
|
"logs.overview.header.actions.clearExecution": "Clear execution",
|
||||||
"logs.overview.header.actions.clearExecution.tooltip": "Clear execution data",
|
"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.message": "Nothing to display yet. Execute the workflow to see execution logs.",
|
||||||
"logs.overview.body.empty.action": "Execute the workflow",
|
"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.aboutN8n": "About n8n",
|
||||||
"mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "",
|
"mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "",
|
||||||
"mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete",
|
"mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete",
|
||||||
|
|||||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -103,6 +103,9 @@ catalogs:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^10.11.0
|
specifier: ^10.11.0
|
||||||
version: 10.11.0
|
version: 10.11.0
|
||||||
|
element-plus:
|
||||||
|
specifier: 2.4.3
|
||||||
|
version: 2.4.3
|
||||||
highlight.js:
|
highlight.js:
|
||||||
specifier: ^11.8.0
|
specifier: ^11.8.0
|
||||||
version: 11.9.0
|
version: 11.9.0
|
||||||
@@ -494,7 +497,7 @@ importers:
|
|||||||
version: 3.666.0(@aws-sdk/client-sts@3.666.0)
|
version: 3.666.0(@aws-sdk/client-sts@3.666.0)
|
||||||
'@getzep/zep-cloud':
|
'@getzep/zep-cloud':
|
||||||
specifier: 1.0.12
|
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':
|
'@getzep/zep-js':
|
||||||
specifier: 0.9.0
|
specifier: 0.9.0
|
||||||
version: 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)
|
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':
|
'@langchain/community':
|
||||||
specifier: 0.3.24
|
specifier: 0.3.24
|
||||||
version: 0.3.24(1725dd003b6ba0539bce135b7f30abed)
|
version: 0.3.24(14647e509198b6d5542cb42df21485e1)
|
||||||
'@langchain/core':
|
'@langchain/core':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
|
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
|
version: 23.0.1
|
||||||
langchain:
|
langchain:
|
||||||
specifier: 0.3.11
|
specifier: 0.3.11
|
||||||
version: 0.3.11(fd386e1130022c8548c06dd951c5cbf0)
|
version: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)
|
||||||
lodash:
|
lodash:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
@@ -1439,7 +1442,7 @@ importers:
|
|||||||
specifier: '*'
|
specifier: '*'
|
||||||
version: 10.11.0(vue@3.5.13(typescript@5.8.2))
|
version: 10.11.0(vue@3.5.13(typescript@5.8.2))
|
||||||
element-plus:
|
element-plus:
|
||||||
specifier: 2.4.3
|
specifier: catalog:frontend
|
||||||
version: 2.4.3(vue@3.5.13(typescript@5.8.2))
|
version: 2.4.3(vue@3.5.13(typescript@5.8.2))
|
||||||
is-emoji-supported:
|
is-emoji-supported:
|
||||||
specifier: ^0.0.5
|
specifier: ^0.0.5
|
||||||
@@ -1682,6 +1685,9 @@ importers:
|
|||||||
dateformat:
|
dateformat:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 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:
|
email-providers:
|
||||||
specifier: ^2.0.1
|
specifier: ^2.0.1
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
@@ -8896,6 +8902,7 @@ packages:
|
|||||||
gm@1.25.0:
|
gm@1.25.0:
|
||||||
resolution: {integrity: sha512-4kKdWXTtgQ4biIo7hZA396HT062nDVVHPjQcurNZ3o/voYN+o5FUC5kOwuORbpExp3XbTJ3SU7iRipiIhQtovw==}
|
resolution: {integrity: sha512-4kKdWXTtgQ4biIo7hZA396HT062nDVVHPjQcurNZ3o/voYN+o5FUC5kOwuORbpExp3XbTJ3SU7iRipiIhQtovw==}
|
||||||
engines: {node: '>=14'}
|
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:
|
google-auth-library@8.9.0:
|
||||||
resolution: {integrity: sha512-f7aQCJODJFmYWN6PeNKzgvy9LI2tYmXnzpNDHEjG5sDNPgGb2FXQyTBnXeSH+PAtpKESFD+LmHw3Ox3mN7e1Fg==}
|
resolution: {integrity: sha512-f7aQCJODJFmYWN6PeNKzgvy9LI2tYmXnzpNDHEjG5sDNPgGb2FXQyTBnXeSH+PAtpKESFD+LmHw3Ox3mN7e1Fg==}
|
||||||
@@ -15973,7 +15980,7 @@ snapshots:
|
|||||||
'@gar/promisify@1.1.3':
|
'@gar/promisify@1.1.3':
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
form-data: 4.0.0
|
form-data: 4.0.0
|
||||||
node-fetch: 2.7.0(encoding@0.1.13)
|
node-fetch: 2.7.0(encoding@0.1.13)
|
||||||
@@ -15982,7 +15989,7 @@ snapshots:
|
|||||||
zod: 3.24.1
|
zod: 3.24.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
|
'@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:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
|
|
||||||
@@ -16490,7 +16497,7 @@ snapshots:
|
|||||||
- aws-crt
|
- aws-crt
|
||||||
- encoding
|
- encoding
|
||||||
|
|
||||||
'@langchain/community@0.3.24(1725dd003b6ba0539bce135b7f30abed)':
|
'@langchain/community@0.3.24(14647e509198b6d5542cb42df21485e1)':
|
||||||
dependencies:
|
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)
|
'@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
|
'@ibm-cloud/watsonx-ai': 1.1.2
|
||||||
@@ -16501,7 +16508,7 @@ snapshots:
|
|||||||
flat: 5.0.2
|
flat: 5.0.2
|
||||||
ibm-cloud-sdk-core: 5.1.0
|
ibm-cloud-sdk-core: 5.1.0
|
||||||
js-yaml: 4.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))
|
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)
|
openai: 4.78.1(encoding@0.1.13)(zod@3.24.1)
|
||||||
uuid: 10.0.0
|
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)
|
'@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)
|
'@azure/storage-blob': 12.18.0(encoding@0.1.13)
|
||||||
'@browserbasehq/sdk': 2.0.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
|
'@getzep/zep-js': 0.9.0
|
||||||
'@google-ai/generativelanguage': 2.6.0(encoding@0.1.13)
|
'@google-ai/generativelanguage': 2.6.0(encoding@0.1.13)
|
||||||
'@google-cloud/storage': 7.12.1(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/debug': 4.1.12
|
||||||
'@types/node': 18.16.16
|
'@types/node': 18.16.16
|
||||||
'@types/tough-cookie': 4.0.2
|
'@types/tough-cookie': 4.0.2
|
||||||
axios: 1.8.2(debug@4.4.0)
|
axios: 1.8.2
|
||||||
camelcase: 6.3.0
|
camelcase: 6.3.0
|
||||||
debug: 4.4.0(supports-color@8.1.1)
|
debug: 4.4.0(supports-color@8.1.1)
|
||||||
dotenv: 16.4.5
|
dotenv: 16.4.5
|
||||||
@@ -22678,7 +22685,7 @@ snapshots:
|
|||||||
isstream: 0.1.2
|
isstream: 0.1.2
|
||||||
jsonwebtoken: 9.0.2
|
jsonwebtoken: 9.0.2
|
||||||
mime-types: 2.1.35
|
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
|
tough-cookie: 4.1.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -23661,7 +23668,7 @@ snapshots:
|
|||||||
|
|
||||||
kuler@2.0.0: {}
|
kuler@2.0.0: {}
|
||||||
|
|
||||||
langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0):
|
langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
|
'@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)
|
'@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: {}
|
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:
|
dependencies:
|
||||||
axios: 1.8.2
|
axios: 1.8.2
|
||||||
|
|
||||||
|
|||||||
@@ -50,3 +50,4 @@ catalogs:
|
|||||||
vue-tsc: ^2.2.8
|
vue-tsc: ^2.2.8
|
||||||
vue-markdown-render: ^2.2.1
|
vue-markdown-render: ^2.2.1
|
||||||
highlight.js: ^11.8.0
|
highlight.js: ^11.8.0
|
||||||
|
'element-plus': 2.4.3
|
||||||
|
|||||||
Reference in New Issue
Block a user