mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
feat(editor): Logs overview panel (#14045)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div ref="container" :class="$style.container">
|
||||
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
|
||||
<N8nResizeWrapper
|
||||
v-if="hasChat"
|
||||
:supported-directions="['right']"
|
||||
@@ -120,7 +118,11 @@ watch([panelState, height], ([state, h]) => {
|
||||
@click-header="handleClickHeader"
|
||||
/>
|
||||
</N8nResizeWrapper>
|
||||
<LogsOverviewPanel :is-open="panelState !== 'closed'" @click-header="handleClickHeader">
|
||||
<LogsOverviewPanel
|
||||
:is-open="panelState !== 'closed'"
|
||||
:node="connectedNode"
|
||||
@click-header="handleClickHeader"
|
||||
>
|
||||
<template #actions>
|
||||
<N8nTooltip
|
||||
v-if="canPopOut && !isPoppedOut"
|
||||
@@ -178,14 +180,11 @@ watch([panelState, height], ([state, h]) => {
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
import PanelHeader from '@/components/CanvasChat/components/PanelHeader.vue';
|
||||
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
|
||||
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
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: [] }>();
|
||||
|
||||
defineSlots<{ actions: {} }>();
|
||||
|
||||
const locale = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
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() {
|
||||
workflowsStore.setWorkflowExecutionData(null);
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -45,10 +120,52 @@ function onClearExecutionData() {
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</PanelHeader>
|
||||
<div v-if="isOpen" :class="[$style.content, $style.empty]">
|
||||
<N8nText tag="p" size="medium" color="text-base" :class="$style.emptyText">
|
||||
<div v-if="isOpen" :class="[$style.content, isEmpty ? $style.empty : '']">
|
||||
<N8nText v-if="isEmpty" tag="p" size="medium" color="text-base" :class="$style.emptyText">
|
||||
{{ locale.baseText('logs.overview.body.empty.message') }}
|
||||
</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>
|
||||
</template>
|
||||
@@ -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);
|
||||
}
|
||||
</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">
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
|
||||
defineProps<{ title: string }>();
|
||||
|
||||
defineSlots<{ actions: {} }>();
|
||||
@@ -8,7 +10,7 @@ const emit = defineEmits<{ click: [] }>();
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
@@ -18,10 +20,9 @@ const emit = defineEmits<{ click: [] }>();
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
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);
|
||||
background-color: var(--color-foreground-xlight);
|
||||
display: flex;
|
||||
@@ -36,12 +37,11 @@ const emit = defineEmits<{ click: [] }>();
|
||||
|
||||
&:not(:last-child) {
|
||||
/** Panel open */
|
||||
border-bottom: 1px solid var(--color-foreground-base);
|
||||
border-bottom: var(--border-base);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
flex-grow: 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 { 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'),
|
||||
},
|
||||
})
|
||||
}}
|
||||
<n8n-info-tip type="tooltip" theme="info-light" tooltip-placement="right">
|
||||
<div>
|
||||
<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>
|
||||
<ConsumedTokensDetails :consumed-tokens="consumedTokensSum" />
|
||||
</n8n-info-tip>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -31,11 +31,62 @@ describe(getTreeNodeData, () => {
|
||||
const taskDataByNodeName: Record<string, ITaskData[]> = {
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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<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 executionData: Pick<IExecutionResponse, 'workflowId' | 'data' | 'status'>;
|
||||
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<typeof useRou
|
||||
workflowId: execution.workflowId,
|
||||
data: parse(execution.data as unknown as string),
|
||||
status: execution.status,
|
||||
startedAt: workflowsStore.workflowExecutionData?.startedAt as Date,
|
||||
stoppedAt: receivedData.type === 'executionFinished' ? new Date() : undefined,
|
||||
};
|
||||
} catch {
|
||||
uiStore.setProcessingExecutionResults(false);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'n8n-workflow';
|
||||
|
||||
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 { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
@@ -23,7 +23,8 @@ import { useI18n } from '@/composables/useI18n';
|
||||
import { captor, mock } from 'vitest-mock-extended';
|
||||
import { useSettingsStore } from '@/stores/settings.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', () => ({
|
||||
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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -447,6 +447,15 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||
toast.showError(error, i18n.baseText('nodeView.showError.stopExecution.title'));
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ export class I18nClass {
|
||||
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')}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <b>Workflow</b> 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",
|
||||
|
||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user