mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Don't show duplicate logs when tree is deeply nested (#13537)
This commit is contained in:
parent
90d09431af
commit
d550382a4a
|
@ -1,28 +1,22 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import type { ITaskDataConnections, NodeConnectionType, Workflow, ITaskData } from 'n8n-workflow';
|
import {
|
||||||
import type { IAiData, IAiDataContent, INodeUi } from '@/Interface';
|
type AIResult,
|
||||||
|
createAiData,
|
||||||
|
getReferencedData,
|
||||||
|
getTreeNodeData,
|
||||||
|
type TreeNode,
|
||||||
|
} from '@/components/RunDataAi/utils';
|
||||||
|
import type { IAiData, INodeUi } 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 NodeIcon from '@/components/NodeIcon.vue';
|
import NodeIcon from '@/components/NodeIcon.vue';
|
||||||
import RunDataAiContent from './RunDataAiContent.vue';
|
import RunDataAiContent from './RunDataAiContent.vue';
|
||||||
import { ElTree } from 'element-plus';
|
import { ElTree } from 'element-plus';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type { Workflow } from 'n8n-workflow';
|
||||||
|
|
||||||
interface AIResult {
|
|
||||||
node: string;
|
|
||||||
runIndex: number;
|
|
||||||
data: IAiDataContent | undefined;
|
|
||||||
}
|
|
||||||
interface TreeNode {
|
|
||||||
node: string;
|
|
||||||
id: string;
|
|
||||||
children: TreeNode[];
|
|
||||||
depth: number;
|
|
||||||
startTime: number;
|
|
||||||
runIndex: number;
|
|
||||||
}
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
node: INodeUi;
|
node: INodeUi;
|
||||||
runIndex?: number;
|
runIndex?: number;
|
||||||
|
@ -40,46 +34,6 @@ function isTreeNodeSelected(node: TreeNode) {
|
||||||
return selectedRun.value.some((run) => run.node === node.node && run.runIndex === node.runIndex);
|
return selectedRun.value.some((run) => run.node === node.node && run.runIndex === node.runIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReferencedData(
|
|
||||||
taskData: ITaskData,
|
|
||||||
withInput: boolean,
|
|
||||||
withOutput: boolean,
|
|
||||||
): IAiDataContent[] {
|
|
||||||
if (!taskData) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const returnData: IAiDataContent[] = [];
|
|
||||||
|
|
||||||
function addFunction(data: ITaskDataConnections | undefined, inOut: 'input' | 'output') {
|
|
||||||
if (!data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.keys(data).map((type) => {
|
|
||||||
returnData.push({
|
|
||||||
data: data[type][0],
|
|
||||||
inOut,
|
|
||||||
type: type as NodeConnectionType,
|
|
||||||
metadata: {
|
|
||||||
executionTime: taskData.executionTime,
|
|
||||||
startTime: taskData.startTime,
|
|
||||||
subExecution: taskData.metadata?.subExecution,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (withInput) {
|
|
||||||
addFunction(taskData.inputOverride, 'input');
|
|
||||||
}
|
|
||||||
if (withOutput) {
|
|
||||||
addFunction(taskData.data, 'output');
|
|
||||||
}
|
|
||||||
|
|
||||||
return returnData;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTreeItem(node: { expanded: boolean }) {
|
function toggleTreeItem(node: { expanded: boolean }) {
|
||||||
node.expanded = !node.expanded;
|
node.expanded = !node.expanded;
|
||||||
}
|
}
|
||||||
|
@ -125,79 +79,13 @@ function selectFirst() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createNode = (
|
const aiData = computed<AIResult[]>(() =>
|
||||||
nodeName: string,
|
createAiData(props.node.name, props.workflow, workflowsStore.getWorkflowResultDataByNodeName),
|
||||||
currentDepth: number,
|
);
|
||||||
r?: AIResult,
|
|
||||||
children: TreeNode[] = [],
|
|
||||||
): TreeNode => ({
|
|
||||||
node: nodeName,
|
|
||||||
id: nodeName,
|
|
||||||
depth: currentDepth,
|
|
||||||
startTime: r?.data?.metadata?.startTime ?? 0,
|
|
||||||
runIndex: r?.runIndex ?? 0,
|
|
||||||
children,
|
|
||||||
});
|
|
||||||
|
|
||||||
function getTreeNodeData(nodeName: string, currentDepth: number): TreeNode[] {
|
const executionTree = computed<TreeNode[]>(() =>
|
||||||
const connections = props.workflow.connectionsByDestinationNode[nodeName];
|
getTreeNodeData(props.node.name, props.workflow, aiData.value),
|
||||||
const resultData = aiData.value?.filter((data) => data.node === nodeName) ?? [];
|
);
|
||||||
|
|
||||||
if (!connections) {
|
|
||||||
return resultData.map((d) => createNode(nodeName, currentDepth, d));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the first level of children
|
|
||||||
const connectedSubNodes = props.workflow.getParentNodes(nodeName, 'ALL_NON_MAIN', 1);
|
|
||||||
|
|
||||||
const children = connectedSubNodes
|
|
||||||
// Only include sub-nodes which have data
|
|
||||||
.filter((name) => aiData.value?.find((data) => data.node === name))
|
|
||||||
.flatMap((name) => getTreeNodeData(name, currentDepth + 1));
|
|
||||||
|
|
||||||
children.sort((a, b) => a.startTime - b.startTime);
|
|
||||||
|
|
||||||
if (resultData.length) {
|
|
||||||
return resultData.map((r) => createNode(nodeName, currentDepth, r, children));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [createNode(nodeName, currentDepth, undefined, children)];
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiData = computed<AIResult[]>(() => {
|
|
||||||
const result: AIResult[] = [];
|
|
||||||
const connectedSubNodes = props.workflow.getParentNodes(props.node.name, 'ALL_NON_MAIN');
|
|
||||||
|
|
||||||
connectedSubNodes.forEach((nodeName) => {
|
|
||||||
const nodeRunData = workflowsStore.getWorkflowResultDataByNodeName(nodeName) ?? [];
|
|
||||||
|
|
||||||
nodeRunData.forEach((run, runIndex) => {
|
|
||||||
const referenceData = {
|
|
||||||
data: getReferencedData(run, false, true)[0],
|
|
||||||
node: nodeName,
|
|
||||||
runIndex,
|
|
||||||
};
|
|
||||||
|
|
||||||
result.push(referenceData);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort the data by start time
|
|
||||||
result.sort((a, b) => {
|
|
||||||
const aTime = a.data?.metadata?.startTime ?? 0;
|
|
||||||
const bTime = b.data?.metadata?.startTime ?? 0;
|
|
||||||
return aTime - bTime;
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
const executionTree = computed<TreeNode[]>(() => {
|
|
||||||
const rootNode = props.node;
|
|
||||||
|
|
||||||
const tree = getTreeNodeData(rootNode.name, 0);
|
|
||||||
return tree || [];
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(() => props.runIndex, selectFirst, { immediate: true });
|
watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
95
packages/editor-ui/src/components/RunDataAi/utils.test.ts
Normal file
95
packages/editor-ui/src/components/RunDataAi/utils.test.ts
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
|
||||||
|
import { createAiData, getTreeNodeData } from '@/components/RunDataAi/utils';
|
||||||
|
import { type ITaskData, NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
|
describe(getTreeNodeData, () => {
|
||||||
|
function createTaskData(partialData: Partial<ITaskData>): ITaskData {
|
||||||
|
return {
|
||||||
|
startTime: 0,
|
||||||
|
executionTime: 1,
|
||||||
|
source: [],
|
||||||
|
executionStatus: 'success',
|
||||||
|
data: { main: [[{ json: {} }]] },
|
||||||
|
...partialData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should generate one node per execution', () => {
|
||||||
|
const workflow = createTestWorkflowObject({
|
||||||
|
nodes: [
|
||||||
|
createTestNode({ name: 'A' }),
|
||||||
|
createTestNode({ name: 'B' }),
|
||||||
|
createTestNode({ name: 'C' }),
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
B: { ai_tool: [[{ node: 'A', type: NodeConnectionType.AiTool, index: 0 }]] },
|
||||||
|
C: {
|
||||||
|
ai_languageModel: [[{ node: 'B', type: NodeConnectionType.AiLanguageModel, index: 0 }]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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') }),
|
||||||
|
],
|
||||||
|
C: [
|
||||||
|
createTaskData({ startTime: +new Date('2025-02-26T00:00:02.000Z') }),
|
||||||
|
createTaskData({ startTime: +new Date('2025-02-26T00:00:04.000Z') }),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getTreeNodeData(
|
||||||
|
'A',
|
||||||
|
workflow,
|
||||||
|
createAiData('A', workflow, (name) => taskDataByNodeName[name] ?? null),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
depth: 0,
|
||||||
|
id: 'A',
|
||||||
|
node: 'A',
|
||||||
|
runIndex: 0,
|
||||||
|
startTime: 0,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
depth: 1,
|
||||||
|
id: 'B',
|
||||||
|
node: 'B',
|
||||||
|
runIndex: 0,
|
||||||
|
startTime: +new Date('2025-02-26T00:00:01.000Z'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
children: [],
|
||||||
|
depth: 2,
|
||||||
|
id: 'C',
|
||||||
|
node: 'C',
|
||||||
|
runIndex: 0,
|
||||||
|
startTime: +new Date('2025-02-26T00:00:02.000Z'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
depth: 1,
|
||||||
|
id: 'B',
|
||||||
|
node: 'B',
|
||||||
|
runIndex: 1,
|
||||||
|
startTime: +new Date('2025-02-26T00:00:03.000Z'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
children: [],
|
||||||
|
depth: 2,
|
||||||
|
id: 'C',
|
||||||
|
node: 'C',
|
||||||
|
runIndex: 1,
|
||||||
|
startTime: +new Date('2025-02-26T00:00:04.000Z'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
160
packages/editor-ui/src/components/RunDataAi/utils.ts
Normal file
160
packages/editor-ui/src/components/RunDataAi/utils.ts
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
import { type IAiDataContent } from '@/Interface';
|
||||||
|
import {
|
||||||
|
type ITaskData,
|
||||||
|
type ITaskDataConnections,
|
||||||
|
type NodeConnectionType,
|
||||||
|
type Workflow,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
export interface AIResult {
|
||||||
|
node: string;
|
||||||
|
runIndex: number;
|
||||||
|
data: IAiDataContent | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeNode {
|
||||||
|
node: string;
|
||||||
|
id: string;
|
||||||
|
children: TreeNode[];
|
||||||
|
depth: number;
|
||||||
|
startTime: number;
|
||||||
|
runIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNode(
|
||||||
|
nodeName: string,
|
||||||
|
currentDepth: number,
|
||||||
|
r?: AIResult,
|
||||||
|
children: TreeNode[] = [],
|
||||||
|
): TreeNode {
|
||||||
|
return {
|
||||||
|
node: nodeName,
|
||||||
|
id: nodeName,
|
||||||
|
depth: currentDepth,
|
||||||
|
startTime: r?.data?.metadata?.startTime ?? 0,
|
||||||
|
runIndex: r?.runIndex ?? 0,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTreeNodeData(
|
||||||
|
nodeName: string,
|
||||||
|
workflow: Workflow,
|
||||||
|
aiData: AIResult[] | undefined,
|
||||||
|
): TreeNode[] {
|
||||||
|
return getTreeNodeDataRec(nodeName, 0, workflow, aiData, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTreeNodeDataRec(
|
||||||
|
nodeName: string,
|
||||||
|
currentDepth: number,
|
||||||
|
workflow: Workflow,
|
||||||
|
aiData: AIResult[] | undefined,
|
||||||
|
runIndex: number | undefined,
|
||||||
|
): TreeNode[] {
|
||||||
|
const connections = workflow.connectionsByDestinationNode[nodeName];
|
||||||
|
const resultData =
|
||||||
|
aiData?.filter(
|
||||||
|
(data) => data.node === nodeName && (runIndex === undefined || runIndex === data.runIndex),
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
if (!connections) {
|
||||||
|
return resultData.map((d) => createNode(nodeName, currentDepth, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first level of children
|
||||||
|
const connectedSubNodes = workflow.getParentNodes(nodeName, 'ALL_NON_MAIN', 1);
|
||||||
|
|
||||||
|
const children = connectedSubNodes.flatMap((name) => {
|
||||||
|
// Only include sub-nodes which have data
|
||||||
|
return (
|
||||||
|
aiData
|
||||||
|
?.filter(
|
||||||
|
(data) => data.node === name && (runIndex === undefined || data.runIndex === runIndex),
|
||||||
|
)
|
||||||
|
.flatMap((data) =>
|
||||||
|
getTreeNodeDataRec(name, currentDepth + 1, workflow, aiData, data.runIndex),
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
children.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
|
if (resultData.length) {
|
||||||
|
return resultData.map((r) => createNode(nodeName, currentDepth, r, children));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [createNode(nodeName, currentDepth, undefined, children)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAiData(
|
||||||
|
nodeName: string,
|
||||||
|
workflow: Workflow,
|
||||||
|
getWorkflowResultDataByNodeName: (nodeName: string) => ITaskData[] | null,
|
||||||
|
): AIResult[] {
|
||||||
|
const result: AIResult[] = [];
|
||||||
|
const connectedSubNodes = workflow.getParentNodes(nodeName, 'ALL_NON_MAIN');
|
||||||
|
|
||||||
|
connectedSubNodes.forEach((node) => {
|
||||||
|
const nodeRunData = getWorkflowResultDataByNodeName(node) ?? [];
|
||||||
|
|
||||||
|
nodeRunData.forEach((run, runIndex) => {
|
||||||
|
const referenceData = {
|
||||||
|
data: getReferencedData(run, false, true)[0],
|
||||||
|
node,
|
||||||
|
runIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
result.push(referenceData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort the data by start time
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const aTime = a.data?.metadata?.startTime ?? 0;
|
||||||
|
const bTime = b.data?.metadata?.startTime ?? 0;
|
||||||
|
return aTime - bTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReferencedData(
|
||||||
|
taskData: ITaskData,
|
||||||
|
withInput: boolean,
|
||||||
|
withOutput: boolean,
|
||||||
|
): IAiDataContent[] {
|
||||||
|
if (!taskData) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnData: IAiDataContent[] = [];
|
||||||
|
|
||||||
|
function addFunction(data: ITaskDataConnections | undefined, inOut: 'input' | 'output') {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(data).map((type) => {
|
||||||
|
returnData.push({
|
||||||
|
data: data[type][0],
|
||||||
|
inOut,
|
||||||
|
type: type as NodeConnectionType,
|
||||||
|
metadata: {
|
||||||
|
executionTime: taskData.executionTime,
|
||||||
|
startTime: taskData.startTime,
|
||||||
|
subExecution: taskData.metadata?.subExecution,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withInput) {
|
||||||
|
addFunction(taskData.inputOverride, 'input');
|
||||||
|
}
|
||||||
|
if (withOutput) {
|
||||||
|
addFunction(taskData.data, 'output');
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnData;
|
||||||
|
}
|
Loading…
Reference in a new issue