fix(editor): Don't show duplicate logs when tree is deeply nested (#13537)

This commit is contained in:
autologie 2025-02-28 09:21:44 +01:00 committed by GitHub
parent 90d09431af
commit d550382a4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 270 additions and 127 deletions

View file

@ -1,28 +1,22 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import { computed, ref, watch } from 'vue';
import type { ITaskDataConnections, NodeConnectionType, Workflow, ITaskData } from 'n8n-workflow';
import type { IAiData, IAiDataContent, INodeUi } from '@/Interface';
import {
type AIResult,
createAiData,
getReferencedData,
getTreeNodeData,
type TreeNode,
} from '@/components/RunDataAi/utils';
import type { IAiData, INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import NodeIcon from '@/components/NodeIcon.vue';
import RunDataAiContent from './RunDataAiContent.vue';
import { ElTree } from 'element-plus';
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 {
node: INodeUi;
runIndex?: number;
@ -40,46 +34,6 @@ function isTreeNodeSelected(node: TreeNode) {
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 }) {
node.expanded = !node.expanded;
}
@ -125,79 +79,13 @@ function selectFirst() {
}
}
const createNode = (
nodeName: string,
currentDepth: number,
r?: AIResult,
children: TreeNode[] = [],
): TreeNode => ({
node: nodeName,
id: nodeName,
depth: currentDepth,
startTime: r?.data?.metadata?.startTime ?? 0,
runIndex: r?.runIndex ?? 0,
children,
});
const aiData = computed<AIResult[]>(() =>
createAiData(props.node.name, props.workflow, workflowsStore.getWorkflowResultDataByNodeName),
);
function getTreeNodeData(nodeName: string, currentDepth: number): TreeNode[] {
const connections = props.workflow.connectionsByDestinationNode[nodeName];
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 || [];
});
const executionTree = computed<TreeNode[]>(() =>
getTreeNodeData(props.node.name, props.workflow, aiData.value),
);
watch(() => props.runIndex, selectFirst, { immediate: true });
</script>

View 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'),
},
],
},
],
},
]);
});
});

View 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;
}