mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-22 12:19:09 +00:00
feat(editor): Show sub workflow runs in the log view (#15163)
This commit is contained in:
@@ -6,12 +6,12 @@ import {
|
||||
} from '@/Interface';
|
||||
import {
|
||||
AGENT_LANGCHAIN_NODE_TYPE,
|
||||
type IRunData,
|
||||
type INodeExecutionData,
|
||||
type ITaskData,
|
||||
type ITaskDataConnections,
|
||||
type NodeConnectionType,
|
||||
type Workflow,
|
||||
type IRunExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import { type LogEntrySelection } from '../CanvasChat/types/logs';
|
||||
import { isProxy, isReactive, isRef, toRaw } from 'vue';
|
||||
@@ -244,10 +244,6 @@ export function formatTokenUsageCount(
|
||||
return usage.isEstimate ? `~${count}` : count.toLocaleString();
|
||||
}
|
||||
|
||||
export interface ExecutionLogViewData extends IExecutionResponse {
|
||||
tree: LogEntry[];
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
parent?: LogEntry;
|
||||
node: INodeUi;
|
||||
@@ -257,6 +253,19 @@ export interface LogEntry {
|
||||
runIndex: number;
|
||||
runData: ITaskData;
|
||||
consumedTokens: LlmTokenUsageData;
|
||||
workflow: Workflow;
|
||||
executionId: string;
|
||||
execution: IRunExecutionData;
|
||||
}
|
||||
|
||||
export interface LogTreeCreationContext {
|
||||
parent: LogEntry | undefined;
|
||||
depth: number;
|
||||
workflow: Workflow;
|
||||
executionId: string;
|
||||
data: IRunExecutionData;
|
||||
workflows: Record<string, Workflow>;
|
||||
subWorkflowData: Record<string, IRunExecutionData>;
|
||||
}
|
||||
|
||||
export interface LatestNodeInfo {
|
||||
@@ -288,87 +297,117 @@ function getConsumedTokensV2(task: ITaskData): LlmTokenUsageData {
|
||||
}
|
||||
|
||||
function createNodeV2(
|
||||
parent: LogEntry | undefined,
|
||||
node: INodeUi,
|
||||
currentDepth: number,
|
||||
context: LogTreeCreationContext,
|
||||
runIndex: number,
|
||||
runData: ITaskData,
|
||||
children: LogEntry[] = [],
|
||||
): LogEntry {
|
||||
return {
|
||||
parent,
|
||||
parent: context.parent,
|
||||
node,
|
||||
id: `${node.name}:${runIndex}`,
|
||||
depth: currentDepth,
|
||||
id: `${context.workflow.id}:${node.name}:${context.executionId}:${runIndex}`,
|
||||
depth: context.depth,
|
||||
runIndex,
|
||||
runData,
|
||||
children,
|
||||
consumedTokens: getConsumedTokensV2(runData),
|
||||
workflow: context.workflow,
|
||||
executionId: context.executionId,
|
||||
execution: context.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTreeNodeDataV2(
|
||||
nodeName: string,
|
||||
runData: ITaskData,
|
||||
workflow: Workflow,
|
||||
data: IRunData,
|
||||
runIndex?: number,
|
||||
runIndex: number | undefined,
|
||||
context: LogTreeCreationContext,
|
||||
): LogEntry[] {
|
||||
const node = workflow.getNode(nodeName);
|
||||
const node = context.workflow.getNode(nodeName);
|
||||
|
||||
return node ? getTreeNodeDataRecV2(undefined, node, runData, 0, workflow, data, runIndex) : [];
|
||||
return node ? getTreeNodeDataRecV2(node, runData, context, runIndex) : [];
|
||||
}
|
||||
|
||||
function getChildNodes(
|
||||
treeNode: LogEntry,
|
||||
node: INodeUi,
|
||||
runIndex: number | undefined,
|
||||
context: LogTreeCreationContext,
|
||||
) {
|
||||
if (hasSubExecution(treeNode)) {
|
||||
const workflowId = treeNode.runData.metadata?.subExecution?.workflowId;
|
||||
const executionId = treeNode.runData.metadata?.subExecution?.executionId;
|
||||
const workflow = workflowId ? context.workflows[workflowId] : undefined;
|
||||
const subWorkflowRunData = executionId ? context.subWorkflowData[executionId] : undefined;
|
||||
|
||||
if (!workflow || !subWorkflowRunData || !executionId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return createLogTreeRec({
|
||||
...context,
|
||||
parent: treeNode,
|
||||
depth: context.depth + 1,
|
||||
workflow,
|
||||
executionId,
|
||||
data: subWorkflowRunData,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the first level of children
|
||||
const connectedSubNodes = context.workflow.getParentNodes(node.name, 'ALL_NON_MAIN', 1);
|
||||
|
||||
return connectedSubNodes.flatMap((subNodeName) =>
|
||||
(context.data.resultData.runData[subNodeName] ?? []).flatMap((t, index) => {
|
||||
// At root depth, filter out node executions that weren't triggered by this node
|
||||
// This prevents showing duplicate executions when a sub-node is connected to multiple parents
|
||||
// Only filter nodes that have source information with valid previousNode references
|
||||
const isMatched =
|
||||
context.depth === 0 && t.source.some((source) => source !== null)
|
||||
? t.source.some(
|
||||
(source) =>
|
||||
source?.previousNode === node.name &&
|
||||
(runIndex === undefined || source.previousNodeRun === runIndex),
|
||||
)
|
||||
: runIndex === undefined || index === runIndex;
|
||||
|
||||
if (!isMatched) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const subNode = context.workflow.getNode(subNodeName);
|
||||
|
||||
return subNode
|
||||
? getTreeNodeDataRecV2(
|
||||
subNode,
|
||||
t,
|
||||
{ ...context, depth: context.depth + 1, parent: treeNode },
|
||||
index,
|
||||
)
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getTreeNodeDataRecV2(
|
||||
parent: LogEntry | undefined,
|
||||
node: INodeUi,
|
||||
runData: ITaskData,
|
||||
currentDepth: number,
|
||||
workflow: Workflow,
|
||||
data: IRunData,
|
||||
context: LogTreeCreationContext,
|
||||
runIndex: number | undefined,
|
||||
): LogEntry[] {
|
||||
// Get the first level of children
|
||||
const connectedSubNodes = workflow.getParentNodes(node.name, 'ALL_NON_MAIN', 1);
|
||||
const treeNode = createNodeV2(parent, node, currentDepth, runIndex ?? 0, runData);
|
||||
const treeNode = createNodeV2(node, context, runIndex ?? 0, runData);
|
||||
const children = getChildNodes(treeNode, node, runIndex, context).sort((a, b) => {
|
||||
// Sort the data by execution index or start time
|
||||
if (a.runData.executionIndex !== undefined && b.runData.executionIndex !== undefined) {
|
||||
return a.runData.executionIndex - b.runData.executionIndex;
|
||||
}
|
||||
|
||||
const children = connectedSubNodes
|
||||
.flatMap((subNodeName) =>
|
||||
(data[subNodeName] ?? []).flatMap((t, index) => {
|
||||
// At root depth, filter out node executions that weren't triggered by this node
|
||||
// This prevents showing duplicate executions when a sub-node is connected to multiple parents
|
||||
// Only filter nodes that have source information with valid previousNode references
|
||||
const isMatched =
|
||||
currentDepth === 0 && t.source.some((source) => source !== null)
|
||||
? t.source.some(
|
||||
(source) =>
|
||||
source?.previousNode === node.name &&
|
||||
(runIndex === undefined || source.previousNodeRun === runIndex),
|
||||
)
|
||||
: runIndex === undefined || index === runIndex;
|
||||
const aTime = a.runData.startTime ?? 0;
|
||||
const bTime = b.runData.startTime ?? 0;
|
||||
|
||||
if (!isMatched) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const subNode = workflow.getNode(subNodeName);
|
||||
|
||||
return subNode
|
||||
? getTreeNodeDataRecV2(treeNode, subNode, t, currentDepth + 1, workflow, data, index)
|
||||
: [];
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
// Sort the data by execution index or start time
|
||||
if (a.runData.executionIndex !== undefined && b.runData.executionIndex !== undefined) {
|
||||
return a.runData.executionIndex - b.runData.executionIndex;
|
||||
}
|
||||
|
||||
const aTime = a.runData.startTime ?? 0;
|
||||
const bTime = b.runData.startTime ?? 0;
|
||||
|
||||
return aTime - bTime;
|
||||
});
|
||||
return aTime - bTime;
|
||||
});
|
||||
|
||||
treeNode.children = children;
|
||||
|
||||
@@ -379,35 +418,39 @@ export function getTotalConsumedTokens(...usage: LlmTokenUsageData[]): LlmTokenU
|
||||
return usage.reduce(addTokenUsageData, emptyTokenUsageData);
|
||||
}
|
||||
|
||||
export function getSubtreeTotalConsumedTokens(treeNode: LogEntry): LlmTokenUsageData {
|
||||
return getTotalConsumedTokens(
|
||||
treeNode.consumedTokens,
|
||||
...treeNode.children.map(getSubtreeTotalConsumedTokens),
|
||||
);
|
||||
export function getSubtreeTotalConsumedTokens(
|
||||
treeNode: LogEntry,
|
||||
includeSubWorkflow: boolean,
|
||||
): LlmTokenUsageData {
|
||||
const executionId = treeNode.executionId;
|
||||
|
||||
function calculate(currentNode: LogEntry): LlmTokenUsageData {
|
||||
if (!includeSubWorkflow && currentNode.executionId !== executionId) {
|
||||
return emptyTokenUsageData;
|
||||
}
|
||||
|
||||
return getTotalConsumedTokens(
|
||||
currentNode.consumedTokens,
|
||||
...currentNode.children.map(calculate),
|
||||
);
|
||||
}
|
||||
|
||||
return calculate(treeNode);
|
||||
}
|
||||
|
||||
function findLogEntryToAutoSelectRec(
|
||||
data: ExecutionLogViewData,
|
||||
subTree: LogEntry[],
|
||||
depth: number,
|
||||
): LogEntry | undefined {
|
||||
function findLogEntryToAutoSelectRec(subTree: LogEntry[], depth: number): LogEntry | undefined {
|
||||
for (const entry of subTree) {
|
||||
const taskData = data.data?.resultData.runData[entry.node.name]?.[entry.runIndex];
|
||||
|
||||
if (taskData?.error) {
|
||||
if (entry.runData?.error) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const childAutoSelect = findLogEntryToAutoSelectRec(data, entry.children, depth + 1);
|
||||
const childAutoSelect = findLogEntryToAutoSelectRec(entry.children, depth + 1);
|
||||
|
||||
if (childAutoSelect) {
|
||||
return childAutoSelect;
|
||||
}
|
||||
|
||||
if (
|
||||
data.workflowData.nodes.find((n) => n.name === entry.node.name)?.type ===
|
||||
AGENT_LANGCHAIN_NODE_TYPE
|
||||
) {
|
||||
if (entry.node.type === AGENT_LANGCHAIN_NODE_TYPE) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
@@ -415,11 +458,28 @@ function findLogEntryToAutoSelectRec(
|
||||
return depth === 0 ? subTree[0] : undefined;
|
||||
}
|
||||
|
||||
export function createLogEntries(workflow: Workflow, runData: IRunData) {
|
||||
const runs = Object.entries(runData)
|
||||
export function createLogTree(
|
||||
workflow: Workflow,
|
||||
response: IExecutionResponse,
|
||||
workflows: Record<string, Workflow> = {},
|
||||
subWorkflowData: Record<string, IRunExecutionData> = {},
|
||||
) {
|
||||
return createLogTreeRec({
|
||||
parent: undefined,
|
||||
depth: 0,
|
||||
executionId: response.id,
|
||||
workflow,
|
||||
workflows,
|
||||
data: response.data ?? { resultData: { runData: {} } },
|
||||
subWorkflowData,
|
||||
});
|
||||
}
|
||||
|
||||
function createLogTreeRec(context: LogTreeCreationContext) {
|
||||
const runs = Object.entries(context.data.resultData.runData)
|
||||
.flatMap(([nodeName, taskData]) =>
|
||||
workflow.getChildNodes(nodeName, 'ALL_NON_MAIN').length > 0 ||
|
||||
workflow.getNode(nodeName)?.disabled
|
||||
context.workflow.getChildNodes(nodeName, 'ALL_NON_MAIN').length > 0 ||
|
||||
context.workflow.getNode(nodeName)?.disabled
|
||||
? [] // skip sub nodes and disabled nodes
|
||||
: taskData.map((task, runIndex) => ({
|
||||
nodeName,
|
||||
@@ -439,37 +499,45 @@ export function createLogEntries(workflow: Workflow, runData: IRunData) {
|
||||
});
|
||||
|
||||
return runs.flatMap(({ nodeName, runIndex, task, nodeHasMultipleRuns }) =>
|
||||
getTreeNodeDataV2(
|
||||
nodeName,
|
||||
task,
|
||||
workflow,
|
||||
runData,
|
||||
nodeHasMultipleRuns ? runIndex : undefined,
|
||||
),
|
||||
getTreeNodeDataV2(nodeName, task, nodeHasMultipleRuns ? runIndex : undefined, context),
|
||||
);
|
||||
}
|
||||
|
||||
export function includesLogEntry(log: LogEntry, logs: LogEntry[]): boolean {
|
||||
return logs.some(
|
||||
(l) =>
|
||||
(l.node.name === log.node.name && log.runIndex === l.runIndex) ||
|
||||
includesLogEntry(log, l.children),
|
||||
);
|
||||
export function findLogEntryRec(id: string, entries: LogEntry[]): LogEntry | undefined {
|
||||
for (const entry of entries) {
|
||||
if (entry.id === id) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const child = findLogEntryRec(id, entry.children);
|
||||
|
||||
if (child) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findSelectedLogEntry(
|
||||
state: LogEntrySelection,
|
||||
execution?: ExecutionLogViewData,
|
||||
selection: LogEntrySelection,
|
||||
entries: LogEntry[],
|
||||
): LogEntry | undefined {
|
||||
return state.type === 'initial' ||
|
||||
state.workflowId !== execution?.workflowData.id ||
|
||||
(state.type === 'selected' && !includesLogEntry(state.data, execution.tree))
|
||||
? execution
|
||||
? findLogEntryToAutoSelectRec(execution, execution.tree, 0)
|
||||
: undefined
|
||||
: state.type === 'none'
|
||||
? undefined
|
||||
: state.data;
|
||||
switch (selection.type) {
|
||||
case 'initial':
|
||||
return findLogEntryToAutoSelectRec(entries, 0);
|
||||
case 'none':
|
||||
return undefined;
|
||||
case 'selected': {
|
||||
const entry = findLogEntryRec(selection.id, entries);
|
||||
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
return findLogEntryToAutoSelectRec(entries, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -526,3 +594,37 @@ export function flattenLogEntries(
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function hasSubExecution(entry: LogEntry): boolean {
|
||||
return !!entry.runData.metadata?.subExecution;
|
||||
}
|
||||
|
||||
export function getDefaultCollapsedEntries(entries: LogEntry[]): Record<string, boolean> {
|
||||
const ret: Record<string, boolean> = {};
|
||||
|
||||
function collect(children: LogEntry[]) {
|
||||
for (const entry of children) {
|
||||
if (hasSubExecution(entry) && entry.children.length === 0) {
|
||||
ret[entry.id] = true;
|
||||
}
|
||||
|
||||
collect(entry.children);
|
||||
}
|
||||
}
|
||||
|
||||
collect(entries);
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function getDepth(entry: LogEntry): number {
|
||||
let depth = 0;
|
||||
let currentEntry = entry;
|
||||
|
||||
while (currentEntry.parent !== undefined) {
|
||||
currentEntry = currentEntry.parent;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return depth;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user