fix(editor): Changes to workflow after execution should not affect logs (#14703)

This commit is contained in:
Suguru Inoue
2025-04-24 12:24:26 +02:00
committed by GitHub
parent 92e2a8e61a
commit 84cee1d12d
21 changed files with 1029 additions and 443 deletions

View File

@@ -29,7 +29,6 @@ const pipContent = useTemplateRef('pipContent');
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const chatPanelState = computed(() => workflowsStore.logsPanelState);
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const resultData = computed(() => workflowsStore.getWorkflowRunData);
const telemetry = useTelemetry();
@@ -65,6 +64,7 @@ const {
messages,
chatTriggerNode,
connectedNode,
previousChatMessages,
sendMessage,
refreshSession,
displayExecution,

View File

@@ -23,6 +23,7 @@ import { restoreChatHistory } from '@/components/CanvasChat/utils';
interface ChatState {
currentSessionId: Ref<string>;
messages: Ref<ChatMessage[]>;
previousChatMessages: Ref<string[]>;
chatTriggerNode: Ref<INodeUi | null>;
connectedNode: Ref<INode | null>;
sendMessage: (message: string, files?: File[]) => Promise<void>;
@@ -42,6 +43,7 @@ export function useChatState(isReadOnly: boolean, onWindowResize?: () => void):
const messages = ref<ChatMessage[]>([]);
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const canvasNodes = computed(() => workflowsStore.allNodes);
const allConnections = computed(() => workflowsStore.allConnections);
const logsPanelState = computed(() => workflowsStore.logsPanelState);
@@ -224,6 +226,7 @@ export function useChatState(isReadOnly: boolean, onWindowResize?: () => void):
return {
currentSessionId,
messages: computed(() => (isReadOnly ? restoredChatMessages.value : messages.value)),
previousChatMessages,
chatTriggerNode,
connectedNode,
sendMessage,

View File

@@ -5,10 +5,11 @@ import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
import { useSettingsStore } from '@/stores/settings.store';
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { createRouter, createWebHistory } from 'vue-router';
import { createRouter, createWebHistory, useRouter } from 'vue-router';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { h } from 'vue';
import { h, nextTick } from 'vue';
import {
aiAgentNode,
aiChatExecutionResponse,
aiChatWorkflow,
aiManualWorkflow,
@@ -16,6 +17,8 @@ import {
} from '../__test__/data';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { LOGS_PANEL_STATE } from '../types/logs';
import { IN_PROGRESS_EXECUTION_ID } from '@/constants';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
describe('LogsPanel', () => {
const VIEWPORT_HEIGHT = 800;
@@ -197,4 +200,90 @@ describe('LogsPanel', () => {
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
});
});
it('should reflect changes to execution data in workflow store if execution is in progress', async () => {
workflowsStore.toggleLogsPanelOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData({
...aiChatExecutionResponse,
id: IN_PROGRESS_EXECUTION_ID,
status: 'running',
finished: false,
startedAt: new Date('2025-04-20T12:34:50.000Z'),
stoppedAt: undefined,
data: { resultData: { runData: {} } },
});
const rendered = render();
await fireEvent.click(rendered.getByText('Overview'));
expect(rendered.getByText('Running')).toBeInTheDocument();
expect(rendered.queryByText('AI Agent')).not.toBeInTheDocument();
workflowsStore.setNodeExecuting({
nodeName: 'AI Agent',
executionId: '567',
data: { executionIndex: 0, startTime: Date.parse('2025-04-20T12:34:51.000Z'), source: [] },
});
const treeItem = within(await rendered.findByRole('treeitem'));
expect(treeItem.getByText('AI Agent')).toBeInTheDocument();
expect(treeItem.getByText('Running')).toBeInTheDocument();
workflowsStore.updateNodeExecutionData({
nodeName: 'AI Agent',
executionId: '567',
data: {
executionIndex: 0,
startTime: Date.parse('2025-04-20T12:34:51.000Z'),
source: [],
executionTime: 33,
executionStatus: 'success',
},
});
expect(await treeItem.findByText('AI Agent')).toBeInTheDocument();
expect(treeItem.getByText('Success in 33ms')).toBeInTheDocument();
workflowsStore.setWorkflowExecutionData({
...aiChatExecutionResponse,
id: '1234',
status: 'success',
finished: true,
startedAt: new Date('2025-04-20T12:34:50.000Z'),
stoppedAt: new Date('2025-04-20T12:34:56.000Z'),
});
expect(await rendered.findByText('Success in 6s')).toBeInTheDocument();
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
});
it('should still show logs for a removed node', async () => {
const router = useRouter();
const operations = useCanvasOperations({ router });
workflowsStore.toggleLogsPanelOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData({
...aiChatExecutionResponse,
id: '2345',
status: 'success',
finished: true,
startedAt: new Date('2025-04-20T12:34:50.000Z'),
stoppedAt: new Date('2025-04-20T12:34:56.000Z'),
});
const rendered = render();
expect(rendered.getByText('AI Agent')).toBeInTheDocument();
operations.deleteNode(aiAgentNode.id);
await nextTick();
expect(workflowsStore.nodesByName['AI Agent']).toBeUndefined();
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed, ref, useTemplateRef } from 'vue';
import { N8nResizeWrapper } from '@n8n/design-system';
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
@@ -8,22 +7,16 @@ import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPa
import LogsDetailsPanel from '@/components/CanvasChat/future/components/LogDetailsPanel.vue';
import { type LogEntrySelection } from '@/components/CanvasChat/types/logs';
import LogsPanelActions from '@/components/CanvasChat/future/components/LogsPanelActions.vue';
import {
createLogEntries,
findLogEntryToAutoSelect,
type TreeNode,
} from '@/components/RunDataAi/utils';
import { isChatNode } from '@/components/CanvasChat/utils';
import { useLayout } from '@/components/CanvasChat/future/composables/useLayout';
import { useExecutionData } from '@/components/CanvasChat/future/composables/useExecutionData';
import { findSelectedLogEntry, type LogEntry } from '@/components/RunDataAi/utils';
const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false });
const workflowsStore = useWorkflowsStore();
const container = useTemplateRef('container');
const logsContainer = useTemplateRef('logsContainer');
const pipContainer = useTemplateRef('pipContainer');
const pipContent = useTemplateRef('pipContent');
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const {
height,
@@ -45,37 +38,20 @@ const {
onOverviewPanelResizeEnd,
} = useLayout(pipContainer, pipContent, container, logsContainer);
const { currentSessionId, messages, sendMessage, refreshSession, displayExecution } = useChatState(
props.isReadOnly,
);
const {
currentSessionId,
messages,
previousChatMessages,
sendMessage,
refreshSession,
displayExecution,
} = useChatState(props.isReadOnly);
const { workflow, execution, hasChat, latestNodeNameById, resetExecutionData } = useExecutionData();
const hasChat = computed(
() =>
workflowsStore.workflowTriggerNodes.some(isChatNode) &&
(!props.isReadOnly || messages.value.length > 0),
);
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const executionTree = computed<TreeNode[]>(() =>
createLogEntries(
workflow.value,
workflowsStore.workflowExecutionData?.data?.resultData.runData ?? {},
),
);
const manualLogEntrySelection = ref<LogEntrySelection>({ type: 'initial' });
const autoSelectedLogEntry = computed(() =>
findLogEntryToAutoSelect(
executionTree.value,
workflowsStore.nodesByName,
workflowsStore.workflowExecutionData?.data?.resultData.runData ?? {},
),
);
const selectedLogEntry = computed(() =>
manualLogEntrySelection.value.type === 'initial' ||
manualLogEntrySelection.value.workflowId !== workflowsStore.workflow.id
? autoSelectedLogEntry.value
: manualLogEntrySelection.value.type === 'none'
? undefined
: manualLogEntrySelection.value.data,
findSelectedLogEntry(manualLogEntrySelection.value, execution.value),
);
const isLogDetailsOpen = computed(() => isOpen.value && selectedLogEntry.value !== undefined);
const isLogDetailsVisuallyOpen = computed(
@@ -89,11 +65,17 @@ const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$p
onToggleOpen,
}));
function handleSelectLogEntry(selected: TreeNode | undefined) {
function handleSelectLogEntry(selected: LogEntry | undefined) {
const workflowId = execution.value?.workflowData.id;
if (!workflowId) {
return;
}
manualLogEntrySelection.value =
selected === undefined
? { type: 'none', workflowId: workflowsStore.workflow.id }
: { type: 'selected', workflowId: workflowsStore.workflow.id, data: selected };
? { type: 'none', workflowId }
: { type: 'selected', workflowId, data: selected };
}
function handleResizeOverviewPanelEnd() {
@@ -119,7 +101,7 @@ function handleResizeOverviewPanelEnd() {
>
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
<N8nResizeWrapper
v-if="hasChat"
v-if="hasChat && (!props.isReadOnly || messages.length > 0)"
:supported-directions="['right']"
:is-resizing-enabled="isOpen"
:width="chatPanelWidth"
@@ -162,9 +144,11 @@ function handleResizeOverviewPanelEnd() {
:is-read-only="isReadOnly"
:is-compact="isLogDetailsVisuallyOpen"
:selected="selectedLogEntry"
:execution-tree="executionTree"
:execution="execution"
:latest-node-info="latestNodeNameById"
@click-header="onToggleOpen(true)"
@select="handleSelectLogEntry"
@clear-execution-data="resetExecutionData"
>
<template #actions>
<LogsPanelActions
@@ -175,11 +159,14 @@ function handleResizeOverviewPanelEnd() {
</LogsOverviewPanel>
</N8nResizeWrapper>
<LogsDetailsPanel
v-if="isLogDetailsVisuallyOpen && selectedLogEntry"
v-if="isLogDetailsVisuallyOpen && selectedLogEntry && workflow && execution"
:class="$style.logDetails"
:is-open="isOpen"
:log-entry="selectedLogEntry"
:workflow="workflow"
:execution="execution"
:window="pipWindow"
:latest-info="latestNodeNameById[selectedLogEntry.node.id]"
@click-header="onToggleOpen(true)"
>
<template #actions>

View File

@@ -9,20 +9,53 @@ import {
createTestNode,
createTestTaskData,
createTestWorkflow,
createTestWorkflowExecutionResponse,
createTestWorkflowObject,
} from '@/__tests__/mocks';
import { mockedStore } from '@/__tests__/utils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSettingsStore } from '@/stores/settings.store';
import { type FrontendSettings } from '@n8n/api-types';
describe('LogDetailsPanel', () => {
let pinia: TestingPinia;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
function render(props: InstanceType<typeof LogDetailsPanel>['$props']) {
const aiNode = createTestNode({ name: 'AI Agent' });
const workflowData = createTestWorkflow({
nodes: [createTestNode({ name: 'Chat Trigger' }), aiNode],
connections: { 'Chat Trigger': { main: [[{ node: 'AI Agent', type: 'main', index: 0 }]] } },
});
const chatNodeRunData = createTestTaskData({
executionStatus: 'success',
executionTime: 0,
data: { main: [[{ json: { response: 'hey' } }]] },
});
const aiNodeRunData = createTestTaskData({
executionStatus: 'success',
executionTime: 10,
data: { main: [[{ json: { response: 'Hello!' } }]] },
});
function render(props: Partial<InstanceType<typeof LogDetailsPanel>['$props']>) {
const mergedProps: InstanceType<typeof LogDetailsPanel>['$props'] = {
...props,
logEntry: props.logEntry ?? createTestLogEntry(),
workflow: props.workflow ?? createTestWorkflowObject(workflowData),
execution:
props.execution ??
createTestWorkflowExecutionResponse({
workflowData,
data: {
resultData: {
runData: { 'Chat Trigger': [chatNodeRunData], 'AI Agent': [aiNodeRunData] },
},
},
}),
isOpen: props.isOpen ?? true,
};
const rendered = renderComponent(LogDetailsPanel, {
props,
props: mergedProps,
global: {
plugins: [
createRouter({
@@ -55,44 +88,6 @@ describe('LogDetailsPanel', () => {
settingsStore = mockedStore(useSettingsStore);
settingsStore.isEnterpriseFeatureEnabled = {} as FrontendSettings['enterprise'];
const workflowData = createTestWorkflow({
nodes: [createTestNode({ name: 'Chat Trigger' }), createTestNode({ name: 'AI Agent' })],
connections: { 'Chat Trigger': { main: [[{ node: 'AI Agent', type: 'main', index: 0 }]] } },
});
workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.setNodes(workflowData.nodes);
workflowsStore.setConnections(workflowData.connections);
workflowsStore.setWorkflowExecutionData({
id: 'test-exec-id',
finished: true,
mode: 'manual',
status: 'error',
workflowData,
data: {
resultData: {
runData: {
'Chat Trigger': [
createTestTaskData({
executionStatus: 'success',
executionTime: 0,
data: { main: [[{ json: { chatInput: 'hey' } }]] },
}),
],
'AI Agent': [
createTestTaskData({
executionStatus: 'success',
executionTime: 10,
data: { main: [[{ json: { response: 'Hello!' } }]] },
}),
],
},
},
},
createdAt: '2025-04-16T00:00:00.000Z',
startedAt: '2025-04-16T00:00:01.000Z',
});
localStorage.clear();
});
@@ -101,7 +96,11 @@ describe('LogDetailsPanel', () => {
const rendered = render({
isOpen: true,
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
logEntry: createTestLogEntry({
node: aiNode,
runIndex: 0,
runData: aiNodeRunData,
}),
});
const header = within(rendered.getByTestId('log-details-header'));
@@ -117,7 +116,7 @@ describe('LogDetailsPanel', () => {
it('should toggle input and output panel when the button is clicked', async () => {
const rendered = render({
isOpen: true,
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
logEntry: createTestLogEntry({ node: aiNode, runIndex: 0 }),
});
const header = within(rendered.getByTestId('log-details-header'));
@@ -141,7 +140,7 @@ describe('LogDetailsPanel', () => {
const rendered = render({
isOpen: true,
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
logEntry: createTestLogEntry({ node: aiNode, runIndex: 0 }),
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
@@ -160,7 +159,7 @@ describe('LogDetailsPanel', () => {
const rendered = render({
isOpen: true,
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
logEntry: createTestLogEntry({ node: aiNode, runIndex: 0 }),
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));

View File

@@ -5,23 +5,30 @@ import RunDataView from '@/components/CanvasChat/future/components/RunDataView.v
import { useResizablePanel } from '@/components/CanvasChat/future/composables/useResizablePanel';
import { LOG_DETAILS_CONTENT, type LogDetailsContent } from '@/components/CanvasChat/types/logs';
import NodeIcon from '@/components/NodeIcon.vue';
import { getSubtreeTotalConsumedTokens, type TreeNode } from '@/components/RunDataAi/utils';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { type INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { N8nButton, N8nResizeWrapper, N8nText } from '@n8n/design-system';
import { type Workflow } from 'n8n-workflow';
import { type IExecutionResponse } from '@/Interface';
import NodeName from '@/components/CanvasChat/future/components/NodeName.vue';
import {
getSubtreeTotalConsumedTokens,
type LatestNodeInfo,
type LogEntry,
} from '@/components/RunDataAi/utils';
import { N8nButton, N8nResizeWrapper } from '@n8n/design-system';
import { useLocalStorage } from '@vueuse/core';
import { type ITaskData } from 'n8n-workflow';
import { computed, useTemplateRef } from 'vue';
const MIN_IO_PANEL_WIDTH = 200;
const { isOpen, logEntry, window } = defineProps<{
const { isOpen, logEntry, workflow, execution, window, latestInfo } = defineProps<{
isOpen: boolean;
logEntry: TreeNode;
logEntry: LogEntry;
workflow: Workflow;
execution: IExecutionResponse;
window?: Window;
latestInfo?: LatestNodeInfo;
}>();
const emit = defineEmits<{ clickHeader: [] }>();
@@ -30,7 +37,6 @@ defineSlots<{ actions: {} }>();
const locale = useI18n();
const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore();
const nodeTypeStore = useNodeTypesStore();
const content = useLocalStorage<LogDetailsContent>(
@@ -39,14 +45,7 @@ const content = useLocalStorage<LogDetailsContent>(
{ writeDefaults: false },
);
const node = computed<INodeUi | undefined>(() => workflowsStore.nodesByName[logEntry.node]);
const type = computed(() => (node.value ? nodeTypeStore.getNodeType(node.value.type) : undefined));
const runData = computed<ITaskData | undefined>(
() =>
(workflowsStore.workflowExecutionData?.data?.resultData.runData[logEntry.node] ?? [])[
logEntry.runIndex
],
);
const type = computed(() => nodeTypeStore.getNodeType(logEntry.node.type));
const consumedTokens = computed(() => getSubtreeTotalConsumedTokens(logEntry));
const isTriggerNode = computed(() => type.value?.group.includes('trigger'));
const container = useTemplateRef<HTMLElement>('container');
@@ -113,15 +112,17 @@ function handleResizeEnd() {
<template #title>
<div :class="$style.title">
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
<N8nText tag="div" :bold="true" size="small" :class="$style.name">
{{ node?.name }}
</N8nText>
<NodeName
:latest-name="latestInfo?.name ?? logEntry.node.name"
:name="logEntry.node.name"
:is-deleted="latestInfo?.deleted ?? false"
/>
<ExecutionSummary
v-if="isOpen"
:class="$style.executionSummary"
:status="runData?.executionStatus ?? 'unknown'"
:status="logEntry.runData.executionStatus ?? 'unknown'"
:consumed-tokens="consumedTokens"
:time-took="runData?.executionTime"
:time-took="logEntry.runData.executionTime"
/>
</div>
</template>
@@ -168,6 +169,8 @@ function handleResizeEnd() {
pane-type="input"
:title="locale.baseText('logs.details.header.actions.input')"
:log-entry="logEntry"
:workflow="workflow"
:execution="execution"
/>
</N8nResizeWrapper>
<RunDataView
@@ -177,6 +180,8 @@ function handleResizeEnd() {
:class="$style.outputPanel"
:title="locale.baseText('logs.details.header.actions.output')"
:log-entry="logEntry"
:workflow="workflow"
:execution="execution"
/>
</div>
</div>
@@ -217,12 +222,6 @@ function handleResizeEnd() {
margin-right: var(--spacing-2xs);
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.executionSummary {
flex-shrink: 1;
}

View File

@@ -15,6 +15,7 @@ import {
} from '../../__test__/data';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { useNDVStore } from '@/stores/ndv.store';
import { createTestWorkflowObject } from '@/__tests__/mocks';
import { createLogEntries } from '@/components/RunDataAi/utils';
describe('LogsOverviewPanel', () => {
@@ -28,10 +29,14 @@ describe('LogsOverviewPanel', () => {
isOpen: false,
isReadOnly: false,
isCompact: false,
executionTree: createLogEntries(
workflowsStore.getCurrentWorkflow(),
workflowsStore.workflowExecutionData?.data?.resultData.runData ?? {},
),
execution: {
...aiChatExecutionResponse,
tree: createLogEntries(
createTestWorkflowObject(aiChatWorkflow),
aiChatExecutionResponse.data?.resultData.runData ?? {},
),
},
latestNodeInfo: {},
...props,
};
@@ -55,8 +60,6 @@ describe('LogsOverviewPanel', () => {
setActivePinia(pinia);
workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(null);
pushConnectionStore = mockedStore(usePushConnectionStore);
pushConnectionStore.isConnected = true;
@@ -71,14 +74,12 @@ describe('LogsOverviewPanel', () => {
});
it('should render empty text if there is no execution', () => {
const rendered = render({ isOpen: true });
const rendered = render({ isOpen: true, execution: undefined });
expect(rendered.queryByTestId('logs-overview-empty')).toBeInTheDocument();
});
it('should render summary text and executed nodes if there is an execution', async () => {
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render({ isOpen: true });
const summary = within(rendered.container.querySelector('.summary')!);
@@ -110,9 +111,9 @@ describe('LogsOverviewPanel', () => {
});
it('should open NDV if the button is clicked', async () => {
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render({ isOpen: true });
const rendered = render({
isOpen: true,
});
const aiAgentRow = rendered.getAllByRole('treeitem')[0];
expect(ndvStore.activeNodeName).toBe(null);
@@ -127,14 +128,18 @@ describe('LogsOverviewPanel', () => {
});
it('should trigger partial execution if the button is clicked', async () => {
workflowsStore.setWorkflow(aiManualWorkflow);
workflowsStore.setWorkflowExecutionData(aiManualExecutionResponse);
const spyRun = vi.spyOn(workflowsStore, 'runWorkflow');
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render({ isOpen: true });
const rendered = render({
isOpen: true,
execution: {
...aiManualExecutionResponse,
tree: createLogEntries(
createTestWorkflowObject(aiManualWorkflow),
aiManualExecutionResponse.data?.resultData.runData ?? {},
),
},
});
const aiAgentRow = rendered.getAllByRole('treeitem')[0];
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Test step')[0]);
await waitFor(() =>

View File

@@ -2,60 +2,56 @@
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, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
import { computed, nextTick } from 'vue';
import { ElTree, type TreeNode as ElTreeNode } from 'element-plus';
import {
getSubtreeTotalConsumedTokens,
getTotalConsumedTokens,
type TreeNode,
} from '@/components/RunDataAi/utils';
import { useTelemetry } from '@/composables/useTelemetry';
import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.vue';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { useNDVStore } from '@/stores/ndv.store';
import { useRouter } from 'vue-router';
import ExecutionSummary from '@/components/CanvasChat/future/components/ExecutionSummary.vue';
import {
type ExecutionLogViewData,
getSubtreeTotalConsumedTokens,
getTotalConsumedTokens,
type LatestNodeInfo,
type LogEntry,
} from '@/components/RunDataAi/utils';
const { isOpen, isReadOnly, selected, isCompact, executionTree } = defineProps<{
const { isOpen, isReadOnly, selected, isCompact, execution, latestNodeInfo } = defineProps<{
isOpen: boolean;
selected?: TreeNode;
selected?: LogEntry;
isReadOnly: boolean;
isCompact: boolean;
executionTree: TreeNode[];
execution?: ExecutionLogViewData;
latestNodeInfo: Record<string, LatestNodeInfo>;
}>();
const emit = defineEmits<{ clickHeader: []; select: [TreeNode | undefined] }>();
const emit = defineEmits<{
clickHeader: [];
select: [LogEntry | undefined];
clearExecutionData: [];
}>();
defineSlots<{ actions: {} }>();
const locale = useI18n();
const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore();
const router = useRouter();
const runWorkflow = useRunWorkflow({ router });
const ndvStore = useNDVStore();
const nodeHelpers = useNodeHelpers();
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const isEmpty = computed(() => workflowsStore.workflowExecutionData === null);
const isEmpty = computed(() => execution === undefined);
const switchViewOptions = computed(() => [
{ label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' as const },
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
]);
const execution = computed(() => workflowsStore.workflowExecutionData);
const consumedTokens = computed(() =>
getTotalConsumedTokens(...executionTree.map(getSubtreeTotalConsumedTokens)),
getTotalConsumedTokens(...(execution?.tree ?? []).map(getSubtreeTotalConsumedTokens)),
);
function onClearExecutionData() {
workflowsStore.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
}
function handleClickNode(clicked: TreeNode) {
function handleClickNode(clicked: LogEntry) {
if (selected?.node === clicked.node && selected?.runIndex === clicked.runIndex) {
emit('select', undefined);
return;
@@ -63,29 +59,36 @@ function handleClickNode(clicked: TreeNode) {
emit('select', clicked);
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,
node_type: clicked.node.type,
node_id: clicked.node.id,
execution_id: execution?.id,
workflow_id: execution?.workflowData.id,
});
}
function handleSwitchView(value: 'overview' | 'details') {
emit('select', value === 'overview' || executionTree.length === 0 ? undefined : executionTree[0]);
emit(
'select',
value === 'overview' || (execution?.tree ?? []).length === 0 ? undefined : execution?.tree[0],
);
}
function handleToggleExpanded(treeNode: ElTreeNode) {
treeNode.expanded = !treeNode.expanded;
}
async function handleOpenNdv(treeNode: TreeNode) {
ndvStore.setActiveNodeName(treeNode.node);
async function handleOpenNdv(treeNode: LogEntry) {
ndvStore.setActiveNodeName(treeNode.node.name);
await nextTick(() => ndvStore.setOutputRunIndex(treeNode.runIndex));
}
async function handleTriggerPartialExecution(treeNode: TreeNode) {
await runWorkflow.runWorkflow({ destinationNode: treeNode.node });
async function handleTriggerPartialExecution(treeNode: LogEntry) {
const latestName = latestNodeInfo[treeNode.node.id]?.name ?? treeNode.node.name;
if (latestName) {
await runWorkflow.runWorkflow({ destinationNode: latestName });
}
}
</script>
@@ -107,7 +110,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
icon="trash"
icon-size="medium"
:class="$style.clearButton"
@click.stop="onClearExecutionData"
@click.stop="emit('clearExecutionData')"
>{{ locale.baseText('logs.overview.header.actions.clearExecution') }}</N8nButton
>
</N8nTooltip>
@@ -142,11 +145,11 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
"
/>
<ElTree
v-if="executionTree.length > 0"
v-if="(execution?.tree ?? []).length > 0"
node-key="id"
:class="$style.tree"
:indent="0"
:data="executionTree"
:data="execution?.tree ?? []"
:expand-on-click-node="false"
:default-expand-all="true"
@node-click="handleClickNode"
@@ -156,9 +159,12 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
:data="data"
:node="elTreeNode"
:is-read-only="isReadOnly"
:is-selected="data.node === selected?.node && data.runIndex === selected?.runIndex"
:is-selected="
data.node.name === selected?.node.name && data.runIndex === selected?.runIndex
"
:is-compact="isCompact"
:should-show-consumed-tokens="consumedTokens.totalTokens > 0"
:latest-info="latestNodeInfo[data.node.id]"
@toggle-expanded="handleToggleExpanded"
@open-ndv="handleOpenNdv"
@trigger-partial-execution="handleTriggerPartialExecution"
@@ -220,6 +226,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
flex-grow: 1;
flex-shrink: 1;
overflow: auto;
scroll-padding-block: var(--spacing-2xs);
}
.summary {

View File

@@ -1,55 +1,49 @@
<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, useTemplateRef, watch } from 'vue';
import { type INodeUi } from '@/Interface';
import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
import { type ITaskData } from 'n8n-workflow';
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';
import { I18nT } from 'vue-i18n';
import { toDayMonth, toTime } from '@/utils/formatters/dateFormatter';
import NodeName from '@/components/CanvasChat/future/components/NodeName.vue';
import {
getSubtreeTotalConsumedTokens,
type LatestNodeInfo,
type LogEntry,
} from '@/components/RunDataAi/utils';
const props = defineProps<{
data: TreeNode;
data: LogEntry;
node: ElTreeNode;
isSelected: boolean;
isReadOnly: boolean;
shouldShowConsumedTokens: boolean;
isCompact: boolean;
latestInfo?: LatestNodeInfo;
}>();
const emit = defineEmits<{
toggleExpanded: [node: ElTreeNode];
triggerPartialExecution: [node: TreeNode];
openNdv: [node: TreeNode];
triggerPartialExecution: [node: LogEntry];
openNdv: [node: LogEntry];
}>();
const locale = useI18n();
const containerRef = useTemplateRef('containerRef');
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 type = computed(() => nodeTypeStore.getNodeType(props.data.node.type));
const depth = computed(() => (props.node.level ?? 1) - 1);
const isSettled = computed(
() =>
runData.value?.executionStatus &&
['crashed', 'error', 'success'].includes(runData.value.executionStatus),
props.data.runData.executionStatus &&
['crashed', 'error', 'success'].includes(props.data.runData.executionStatus),
);
const isError = computed(() => !!runData.value?.error);
const isError = computed(() => !!props.data.runData.error);
const startedAtText = computed(() => {
const time = new Date(runData.value?.startTime ?? 0);
const time = new Date(props.data.runData.startTime);
return locale.baseText('logs.overview.body.started', {
interpolate: {
@@ -64,7 +58,7 @@ const subtreeConsumedTokens = computed(() =>
function isLastChild(level: number) {
let parent = props.data.parent;
let data: TreeNode | undefined = props.data;
let data: LogEntry | undefined = props.data;
for (let i = 0; i < depth.value - level; i++) {
data = parent;
@@ -94,7 +88,6 @@ watch(
<template>
<div
v-if="node !== undefined"
ref="containerRef"
:class="{
[$style.container]: true,
@@ -114,31 +107,35 @@ watch(
</template>
<div :class="$style.background" :style="{ '--indent-depth': depth }" />
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
<N8nText
tag="div"
:bold="true"
size="small"
<NodeName
:class="$style.name"
:color="isError ? 'danger' : undefined"
>{{ node.name }}
</N8nText>
:latest-name="latestInfo?.name ?? props.data.node.name"
:name="props.data.node.name"
:is-error="isError"
:is-deleted="latestInfo?.deleted ?? false"
/>
<N8nText v-if="!isCompact" tag="div" color="text-light" size="small" :class="$style.timeTook">
<I18nT v-if="isSettled && runData" keypath="logs.overview.body.summaryText">
<I18nT v-if="isSettled" keypath="logs.overview.body.summaryText">
<template #status>
<N8nText v-if="isError" color="danger" :bold="true" size="small">
<N8nIcon icon="exclamation-triangle" :class="$style.errorIcon" />{{
upperFirst(runData.executionStatus)
upperFirst(props.data.runData.executionStatus)
}}
</N8nText>
<template v-else>{{ upperFirst(runData.executionStatus) }}</template>
<template v-else>{{ upperFirst(props.data.runData.executionStatus) }}</template>
</template>
<template #time>{{ locale.displayTimer(runData.executionTime, true) }}</template>
<template #time>{{ locale.displayTimer(props.data.runData.executionTime, true) }}</template>
</I18nT>
<template v-else>{{ upperFirst(runData?.executionStatus) }}</template></N8nText
<template v-else>{{ upperFirst(props.data.runData.executionStatus) }}</template></N8nText
>
<N8nText
v-if="!isCompact"
tag="div"
color="text-light"
size="small"
:class="$style.startedAt"
>{{ startedAtText }}</N8nText
>
<N8nText tag="div" color="text-light" size="small" :class="$style.startedAt">{{
startedAtText
}}</N8nText>
<N8nText
v-if="!isCompact && subtreeConsumedTokens !== undefined"
tag="div"
@@ -162,20 +159,26 @@ watch(
:class="$style.compactErrorIcon"
/>
<N8nIconButton
v-if="!props.isReadOnly"
v-if="
!isCompact ||
(!props.isReadOnly && !props.latestInfo?.deleted && !props.latestInfo?.disabled)
"
type="secondary"
size="small"
icon="play"
style="color: var(--color-text-base)"
:aria-label="locale.baseText('logs.overview.body.run')"
:class="[$style.partialExecutionButton, depth > 0 ? $style.unavailable : '']"
:disabled="props.latestInfo?.deleted || props.latestInfo?.disabled"
@click.stop="emit('triggerPartialExecution', props.data)"
/>
<N8nIconButton
v-if="!isCompact || !props.latestInfo?.deleted"
type="secondary"
size="small"
icon="external-link-alt"
style="color: var(--color-text-base)"
:disabled="props.latestInfo?.deleted"
:class="$style.openNdvButton"
:aria-label="locale.baseText('logs.overview.body.open')"
@click.stop="emit('openNdv', props.data)"
@@ -294,10 +297,6 @@ watch(
flex-grow: 0;
flex-shrink: 0;
width: 25%;
.compact & {
display: none;
}
}
.consumedTokens {
@@ -349,6 +348,10 @@ watch(
&:hover {
background: transparent;
}
&:disabled {
visibility: hidden !important;
}
}
.toggleButton {

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { N8nText } from '@n8n/design-system';
const { name, latestName, isError, isDeleted } = defineProps<{
name: string;
latestName: string;
isError?: boolean;
isDeleted?: boolean;
}>();
</script>
<template>
<N8nText
tag="div"
:bold="true"
size="small"
:class="$style.name"
:color="isError ? 'danger' : undefined"
>
<del v-if="isDeleted || name !== latestName">
{{ name }}
</del>
<span v-if="!isDeleted">
{{ latestName }}
</span>
</N8nText>
</template>
<style lang="scss" module>
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
& del:not(:last-child) {
margin-right: var(--spacing-4xs);
}
}
</style>

View File

@@ -1,49 +1,39 @@
<script setup lang="ts">
import RunData from '@/components/RunData.vue';
import { type TreeNode } from '@/components/RunDataAi/utils';
import { type LogEntry } from '@/components/RunDataAi/utils';
import { useI18n } from '@/composables/useI18n';
import { type NodePanelType } from '@/Interface';
import { type IExecutionResponse, type NodePanelType } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { N8nLink, N8nText } from '@n8n/design-system';
import { uniqBy } from 'lodash-es';
import { uniq } from 'lodash-es';
import { type Workflow } from 'n8n-workflow';
import { computed } from 'vue';
import { I18nT } from 'vue-i18n';
const { title, logEntry, paneType } = defineProps<{
const { title, logEntry, paneType, workflow, execution } = defineProps<{
title: string;
paneType: NodePanelType;
logEntry: TreeNode;
logEntry: LogEntry;
workflow: Workflow;
execution: IExecutionResponse;
}>();
const locale = useI18n();
const workflowsStore = useWorkflowsStore();
const ndvStore = useNDVStore();
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const parentNodeNames = computed(() =>
uniq(workflow.getParentNodesByDepth(logEntry.node.name, 1)).map((c) => c.name),
);
const node = computed(() => {
if (logEntry.depth > 0 || paneType === 'output') {
return workflowsStore.nodesByName[logEntry.node];
return logEntry.node;
}
const parent = workflow.value.getParentNodesByDepth(logEntry.node)[0];
if (!parent) {
return undefined;
}
return workflowsStore.nodesByName[parent.name];
return parentNodeNames.value.length > 0 ? workflow.getNode(parentNodeNames.value[0]) : undefined;
});
const isMultipleInput = computed(
() =>
paneType === 'input' &&
uniqBy(
workflow.value.getParentNodesByDepth(logEntry.node).filter((n) => n.name !== logEntry.node),
(n) => n.name,
).length > 1,
);
const isMultipleInput = computed(() => paneType === 'input' && parentNodeNames.value.length > 1);
function handleClickOpenNdv() {
ndvStore.setActiveNodeName(logEntry.node);
ndvStore.setActiveNodeName(logEntry.node.name);
}
</script>
@@ -52,6 +42,7 @@ function handleClickOpenNdv() {
v-if="node"
:node="node"
:workflow="workflow"
:workflow-execution="execution"
:run-index="logEntry.runIndex"
:too-much-data-title="locale.baseText('ndv.output.tooMuchData.title')"
:no-data-in-branch-message="locale.baseText('ndv.output.noOutputDataInBranch')"

View File

@@ -0,0 +1,96 @@
import { watch, computed, ref } from 'vue';
import { isChatNode } from '../../utils';
import { type IExecutionResponse } from '@/Interface';
import { Workflow } from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useThrottleFn } from '@vueuse/core';
import { IN_PROGRESS_EXECUTION_ID } from '@/constants';
import {
createLogEntries,
deepToRaw,
type ExecutionLogViewData,
type LatestNodeInfo,
} from '@/components/RunDataAi/utils';
export function useExecutionData() {
const nodeHelpers = useNodeHelpers();
const workflowsStore = useWorkflowsStore();
const execData = ref<IExecutionResponse | undefined>();
const workflow = computed(() =>
execData.value
? new Workflow({
...execData.value?.workflowData,
nodeTypes: workflowsStore.getNodeTypes(),
})
: undefined,
);
const latestNodeNameById = computed(() =>
Object.values(workflow.value?.nodes ?? {}).reduce<Record<string, LatestNodeInfo>>(
(acc, node) => {
const nodeInStore = workflowsStore.getNodeById(node.id);
acc[node.id] = {
deleted: !nodeInStore,
disabled: nodeInStore?.disabled ?? false,
name: nodeInStore?.name ?? node.name,
};
return acc;
},
{},
),
);
const hasChat = computed(() =>
[Object.values(workflow.value?.nodes ?? {}), workflowsStore.workflow.nodes].some((nodes) =>
nodes.some(isChatNode),
),
);
const execution = computed<ExecutionLogViewData | undefined>(() => {
if (!execData.value || !workflow.value) {
return undefined;
}
return {
...execData.value,
tree: createLogEntries(workflow.value, execData.value.data?.resultData.runData ?? {}),
};
});
const updateInterval = computed(() => ((execution.value?.tree.length ?? 0) > 10 ? 300 : 0));
const runStatusList = computed(() =>
workflowsStore.workflowExecutionData?.id === IN_PROGRESS_EXECUTION_ID
? Object.values(workflowsStore.workflowExecutionData?.data?.resultData.runData ?? {})
.flatMap((tasks) => tasks.map((task) => task.executionStatus ?? ''))
.join('|')
: '',
);
function resetExecutionData() {
execData.value = undefined;
workflowsStore.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
}
watch(
// Fields that should trigger update
[
() => workflowsStore.workflowExecutionData?.id,
() => workflowsStore.workflowExecutionData?.workflowData.id,
() => workflowsStore.workflowExecutionData?.status,
runStatusList,
],
useThrottleFn(
() => {
// Create deep copy to disable reactivity
execData.value = deepToRaw(workflowsStore.workflowExecutionData ?? undefined);
},
updateInterval,
true,
true,
),
{ immediate: true },
);
return { execution, workflow, hasChat, latestNodeNameById, resetExecutionData };
}

View File

@@ -1,8 +1,8 @@
import { type TreeNode } from '@/components/RunDataAi/utils';
import { type LogEntry } from '@/components/RunDataAi/utils';
export type LogEntrySelection =
| { type: 'initial' }
| { type: 'selected'; workflowId: string; data: TreeNode }
| { type: 'selected'; workflowId: string; data: LogEntry }
| { type: 'none'; workflowId: string };
export const LOGS_PANEL_STATE = {

View File

@@ -24,6 +24,7 @@ import InlineAskAssistantButton from '@n8n/design-system/components/InlineAskAss
import { useUIStore } from '@/stores/ui.store';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import { useAIAssistantHelpers } from '@/composables/useAIAssistantHelpers';
import { N8nIconButton } from '@n8n/design-system';
type Props = {
// TODO: .node can be undefined
@@ -459,11 +460,11 @@ async function onAskAssistantClick() {
placement="left"
>
<div class="copy-button">
<n8n-icon-button
<N8nIconButton
icon="copy"
type="secondary"
size="mini"
text="true"
:text="true"
transparent-background="transparent"
@click="copyErrorDetails"
/>

View File

@@ -25,6 +25,7 @@ import {
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue';
import type {
IExecutionResponse,
INodeUi,
INodeUpdatePropertiesInformation,
IRunDataDisplayMode,
@@ -118,6 +119,7 @@ export type EnterEditModeArgs = {
type Props = {
workflow: Workflow;
workflowExecution?: IExecutionResponse;
runIndex: number;
tooMuchDataTitle: string;
executingMessage: string;
@@ -163,6 +165,7 @@ const props = withDefaults(defineProps<Props>(), {
disableHoverHighlight: false,
compact: false,
tableHeaderBgColor: 'base',
workflowExecution: undefined,
});
defineSlots<{
@@ -334,12 +337,14 @@ const executionHints = computed(() => {
return [];
});
const workflowExecution = computed(() => workflowsStore.getWorkflowExecution);
const workflowExecution = computed(
() => props.workflowExecution ?? workflowsStore.getWorkflowExecution ?? undefined,
);
const workflowRunData = computed(() => {
if (workflowExecution.value === null) {
if (workflowExecution.value === undefined) {
return null;
}
const executionData: IRunExecutionData | undefined = workflowExecution.value.data;
const executionData: IRunExecutionData | undefined = workflowExecution.value?.data;
if (executionData?.resultData) {
return executionData.resultData.runData;
}
@@ -1104,6 +1109,7 @@ function getRawInputData(
outputIndex,
props.paneType,
connectionType,
workflowExecution.value,
);
}

View File

@@ -2,13 +2,16 @@ import {
createTestLogEntry,
createTestNode,
createTestTaskData,
createTestWorkflow,
createTestWorkflowExecutionResponse,
createTestWorkflowObject,
} from '@/__tests__/mocks';
import {
createAiData,
findLogEntryToAutoSelect,
getTreeNodeData,
createLogEntries,
findSelectedLogEntry,
getTreeNodeData,
getTreeNodeDataV2,
} from '@/components/RunDataAi/utils';
import {
AGENT_LANGCHAIN_NODE_TYPE,
@@ -16,6 +19,8 @@ import {
type ITaskData,
NodeConnectionTypes,
} from 'n8n-workflow';
import { type LogEntrySelection } from '../CanvasChat/types/logs';
import { type IExecutionResponse } from '@/Interface';
describe(getTreeNodeData, () => {
it('should generate one node per execution', () => {
@@ -184,140 +189,320 @@ describe(getTreeNodeData, () => {
});
});
describe(findLogEntryToAutoSelect, () => {
it('should return undefined if no log entry is provided', () => {
expect(
findLogEntryToAutoSelect(
[],
{
A: createTestNode({ name: 'A' }),
B: createTestNode({ name: 'B' }),
C: createTestNode({ name: 'C' }),
describe(getTreeNodeDataV2, () => {
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: NodeConnectionTypes.AiTool, index: 0 }]] },
C: {
ai_languageModel: [[{ node: 'B', type: NodeConnectionTypes.AiLanguageModel, index: 0 }]],
},
{
A: [],
B: [],
C: [],
},
),
).toBe(undefined);
});
},
});
it('should return first log entry with error', () => {
expect(
findLogEntryToAutoSelect(
[
createTestLogEntry({ node: 'A', runIndex: 0 }),
createTestLogEntry({ node: 'B', runIndex: 0 }),
createTestLogEntry({ node: 'C', runIndex: 0 }),
createTestLogEntry({ node: 'C', runIndex: 1 }),
createTestLogEntry({ node: 'C', runIndex: 2 }),
],
{
A: createTestNode({ name: 'A' }),
B: createTestNode({ name: 'B' }),
C: createTestNode({ name: 'C' }),
},
{
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ executionStatus: 'success' })],
C: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
],
},
),
).toEqual(expect.objectContaining({ node: 'C', runIndex: 1 }));
});
const jsonB1 = { tokenUsage: { completionTokens: 1, promptTokens: 2, totalTokens: 3 } };
const jsonB2 = { tokenUsage: { completionTokens: 4, promptTokens: 5, totalTokens: 6 } };
const jsonC1 = { tokenUsageEstimate: { completionTokens: 7, promptTokens: 8, totalTokens: 9 } };
it("should return first log entry with error even if it's on a sub node", () => {
expect(
findLogEntryToAutoSelect(
[
createTestLogEntry({ node: 'A', runIndex: 0 }),
createTestLogEntry({
node: 'B',
runIndex: 0,
children: [
createTestLogEntry({ node: 'C', runIndex: 0 }),
createTestLogEntry({ node: 'C', runIndex: 1 }),
createTestLogEntry({ node: 'C', runIndex: 2 }),
],
getTreeNodeDataV2('A', createTestTaskData({}), workflow, {
A: [createTestTaskData({ startTime: Date.parse('2025-02-26T00:00:00.000Z') })],
B: [
createTestTaskData({
startTime: Date.parse('2025-02-26T00:00:01.000Z'),
data: { main: [[{ json: jsonB1 }]] },
}),
createTestTaskData({
startTime: Date.parse('2025-02-26T00:00:03.000Z'),
data: { main: [[{ json: jsonB2 }]] },
}),
],
{
A: createTestNode({ name: 'A' }),
B: createTestNode({ name: 'B' }),
C: createTestNode({ name: 'C' }),
},
{
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ executionStatus: 'success' })],
C: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
],
C: [
createTestTaskData({
startTime: Date.parse('2025-02-26T00:00:02.000Z'),
data: { main: [[{ json: jsonC1 }]] },
}),
createTestTaskData({ startTime: Date.parse('2025-02-26T00:00:04.000Z') }),
],
}),
).toEqual([
{
depth: 0,
id: 'A:0',
node: expect.objectContaining({ name: 'A' }),
runIndex: 0,
runData: expect.objectContaining({ startTime: 0 }),
parent: undefined,
consumedTokens: {
completionTokens: 0,
promptTokens: 0,
totalTokens: 0,
isEstimate: false,
},
children: [
{
depth: 1,
id: 'B:0',
node: expect.objectContaining({ name: 'B' }),
runIndex: 0,
runData: expect.objectContaining({
startTime: Date.parse('2025-02-26T00:00:01.000Z'),
}),
parent: expect.objectContaining({ node: expect.objectContaining({ name: 'A' }) }),
consumedTokens: {
completionTokens: 1,
promptTokens: 2,
totalTokens: 3,
isEstimate: false,
},
children: [
{
children: [],
depth: 2,
id: 'C:0',
node: expect.objectContaining({ name: 'C' }),
runIndex: 0,
runData: expect.objectContaining({
startTime: Date.parse('2025-02-26T00:00:02.000Z'),
}),
parent: expect.objectContaining({ node: expect.objectContaining({ name: 'B' }) }),
consumedTokens: {
completionTokens: 7,
promptTokens: 8,
totalTokens: 9,
isEstimate: true,
},
},
],
},
{
depth: 1,
id: 'B:1',
node: expect.objectContaining({ name: 'B' }),
runIndex: 1,
runData: expect.objectContaining({
startTime: Date.parse('2025-02-26T00:00:03.000Z'),
}),
parent: expect.objectContaining({ node: expect.objectContaining({ name: 'A' }) }),
consumedTokens: {
completionTokens: 4,
promptTokens: 5,
totalTokens: 6,
isEstimate: false,
},
children: [
{
children: [],
depth: 2,
id: 'C:1',
node: expect.objectContaining({ name: 'C' }),
runIndex: 1,
runData: expect.objectContaining({
startTime: Date.parse('2025-02-26T00:00:04.000Z'),
}),
parent: expect.objectContaining({ node: expect.objectContaining({ name: 'B' }) }),
consumedTokens: {
completionTokens: 0,
promptTokens: 0,
totalTokens: 0,
isEstimate: false,
},
},
],
},
],
},
]);
});
});
describe(findSelectedLogEntry, () => {
function find(state: LogEntrySelection, response: IExecutionResponse) {
return findSelectedLogEntry(state, {
...response,
tree: createLogEntries(
createTestWorkflowObject(response.workflowData),
response.data?.resultData.runData ?? {},
),
).toEqual(expect.objectContaining({ node: 'C', runIndex: 1 }));
});
}
describe('when log is not manually selected', () => {
it('should return undefined if no execution data exists', () => {
const response = createTestWorkflowExecutionResponse({
workflowData: createTestWorkflow({
nodes: [
createTestNode({ name: 'A' }),
createTestNode({ name: 'B' }),
createTestNode({ name: 'C' }),
],
}),
data: { resultData: { runData: {} } },
});
expect(find({ type: 'initial' }, response)).toBe(undefined);
});
it('should return first log entry with error', () => {
const response = createTestWorkflowExecutionResponse({
workflowData: createTestWorkflow({
nodes: [
createTestNode({ name: 'A' }),
createTestNode({ name: 'B' }),
createTestNode({ name: 'C' }),
],
}),
data: {
resultData: {
runData: {
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ executionStatus: 'success' })],
C: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
],
},
},
},
});
expect(find({ type: 'initial' }, response)).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 1 }),
);
});
it("should return first log entry with error even if it's on a sub node", () => {
const response = createTestWorkflowExecutionResponse({
workflowData: createTestWorkflow({
nodes: [
createTestNode({ name: 'A' }),
createTestNode({ name: 'B' }),
createTestNode({ name: 'C' }),
],
connections: {
C: {
[NodeConnectionTypes.AiLanguageModel]: [
[{ node: 'B', type: NodeConnectionTypes.AiLanguageModel, index: 0 }],
],
},
},
}),
data: {
resultData: {
runData: {
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ executionStatus: 'success' })],
C: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
],
},
},
},
});
expect(find({ type: 'initial' }, response)).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 1 }),
);
});
it('should return first log entry for AI agent node if there is no log entry with error', () => {
const response = createTestWorkflowExecutionResponse({
workflowData: createTestWorkflow({
nodes: [
createTestNode({ name: 'A' }),
createTestNode({ name: 'B', type: AGENT_LANGCHAIN_NODE_TYPE }),
createTestNode({ name: 'C' }),
],
}),
data: {
resultData: {
runData: {
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ executionStatus: 'success' })],
C: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
],
},
},
},
});
expect(find({ type: 'initial' }, response)).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'B' }), runIndex: 0 }),
);
});
it('should return first log entry if there is no log entry with error nor executed AI agent node', () => {
const response = createTestWorkflowExecutionResponse({
workflowData: createTestWorkflow({
nodes: [
createTestNode({ name: 'A' }),
createTestNode({ name: 'B' }),
createTestNode({ name: 'C' }),
],
}),
data: {
resultData: {
runData: {
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ executionStatus: 'success' })],
C: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'success' }),
],
},
},
},
});
expect(find({ type: 'initial' }, response)).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'A' }), runIndex: 0 }),
);
});
});
it('should return first log entry for AI agent node if there is no log entry with error', () => {
expect(
findLogEntryToAutoSelect(
[
createTestLogEntry({ node: 'A', runIndex: 0 }),
createTestLogEntry({ node: 'B', runIndex: 0 }),
createTestLogEntry({ node: 'C', runIndex: 0 }),
createTestLogEntry({ node: 'C', runIndex: 1 }),
createTestLogEntry({ node: 'C', runIndex: 2 }),
],
{
A: createTestNode({ name: 'A' }),
B: createTestNode({ name: 'B', type: AGENT_LANGCHAIN_NODE_TYPE }),
C: createTestNode({ name: 'C' }),
describe('when log is manually selected', () => {
it('should return manually selected log', () => {
const nodeA = createTestNode({ name: 'A' });
const response = createTestWorkflowExecutionResponse({
workflowData: createTestWorkflow({
id: 'test-wf-id',
nodes: [nodeA, createTestNode({ name: 'B' })],
}),
data: {
resultData: {
runData: {
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' })],
},
},
},
{
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ executionStatus: 'success' })],
C: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'success' }),
],
},
),
).toEqual(expect.objectContaining({ node: 'B', runIndex: 0 }));
});
});
it('should return first log entry if there is no log entry with error nor executed AI agent node', () => {
expect(
findLogEntryToAutoSelect(
[
createTestLogEntry({ node: 'A', runIndex: 0 }),
createTestLogEntry({ node: 'B', runIndex: 0 }),
createTestLogEntry({ node: 'C', runIndex: 0 }),
createTestLogEntry({ node: 'C', runIndex: 1 }),
createTestLogEntry({ node: 'C', runIndex: 2 }),
],
const result = find(
{
A: createTestNode({ name: 'A' }),
B: createTestNode({ name: 'B' }),
C: createTestNode({ name: 'C' }),
type: 'selected',
workflowId: 'test-wf-id',
data: createTestLogEntry({ node: nodeA, runIndex: 0 }),
},
{
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ executionStatus: 'success' })],
C: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'success' }),
],
},
),
).toEqual(expect.objectContaining({ node: 'A', runIndex: 0 }));
response,
);
expect(result).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'A' }), runIndex: 0 }),
);
});
});
});
@@ -361,10 +546,10 @@ describe(createLogEntries, () => {
],
}),
).toEqual([
expect.objectContaining({ node: 'A', runIndex: 0 }),
expect.objectContaining({ node: 'B', runIndex: 0 }),
expect.objectContaining({ node: 'C', runIndex: 1 }),
expect.objectContaining({ node: 'C', runIndex: 0 }),
expect.objectContaining({ node: expect.objectContaining({ name: 'A' }), runIndex: 0 }),
expect.objectContaining({ node: expect.objectContaining({ name: 'B' }), runIndex: 0 }),
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 1 }),
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 0 }),
]);
});
@@ -411,13 +596,13 @@ describe(createLogEntries, () => {
],
}),
).toEqual([
expect.objectContaining({ node: 'A', runIndex: 0 }),
expect.objectContaining({ node: expect.objectContaining({ name: 'A' }), runIndex: 0 }),
expect.objectContaining({
node: 'B',
node: expect.objectContaining({ name: 'B' }),
runIndex: 0,
children: [
expect.objectContaining({ node: 'C', runIndex: 1 }),
expect.objectContaining({ node: 'C', runIndex: 0 }),
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 1 }),
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 0 }),
],
}),
]);

View File

@@ -1,4 +1,9 @@
import { type LlmTokenUsageData, type IAiDataContent, type INodeUi } from '@/Interface';
import {
type LlmTokenUsageData,
type IAiDataContent,
type INodeUi,
type IExecutionResponse,
} from '@/Interface';
import {
AGENT_LANGCHAIN_NODE_TYPE,
type IRunData,
@@ -8,6 +13,8 @@ import {
type NodeConnectionType,
type Workflow,
} from 'n8n-workflow';
import { type LogEntrySelection } from '../CanvasChat/types/logs';
import { isProxy, isReactive, isRef, toRaw } from 'vue';
export interface AIResult {
node: string;
@@ -202,17 +209,6 @@ export function getConsumedTokens(outputRun: IAiDataContent | undefined): LlmTok
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',
@@ -227,44 +223,162 @@ export function formatTokenUsageCount(
return usage.isEstimate ? `~${count}` : count.toLocaleString();
}
export function findLogEntryToAutoSelect(
tree: TreeNode[],
nodesByName: Record<string, INodeUi>,
runData: IRunData,
): TreeNode | undefined {
return findLogEntryToAutoSelectRec(tree, nodesByName, runData, 0);
export interface ExecutionLogViewData extends IExecutionResponse {
tree: LogEntry[];
}
export interface LogEntry {
parent?: LogEntry;
node: INodeUi;
id: string;
children: LogEntry[];
depth: number;
runIndex: number;
runData: ITaskData;
consumedTokens: LlmTokenUsageData;
}
export interface LatestNodeInfo {
disabled: boolean;
deleted: boolean;
name: string;
}
function getConsumedTokensV2(task: ITaskData): LlmTokenUsageData {
if (!task.data) {
return emptyTokenUsageData;
}
const tokenUsage = Object.values(task.data)
.flat()
.flat()
.reduce<LlmTokenUsageData>((acc, curr) => {
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;
}
function createNodeV2(
parent: LogEntry | undefined,
node: INodeUi,
currentDepth: number,
runIndex: number,
runData: ITaskData,
children: LogEntry[] = [],
): LogEntry {
return {
parent,
node,
id: `${node.name}:${runIndex}`,
depth: currentDepth,
runIndex,
runData,
children,
consumedTokens: getConsumedTokensV2(runData),
};
}
export function getTreeNodeDataV2(
nodeName: string,
runData: ITaskData,
workflow: Workflow,
data: IRunData,
runIndex?: number,
): LogEntry[] {
const node = workflow.getNode(nodeName);
return node ? getTreeNodeDataRecV2(undefined, node, runData, 0, workflow, data, runIndex) : [];
}
function getTreeNodeDataRecV2(
parent: LogEntry | undefined,
node: INodeUi,
runData: ITaskData,
currentDepth: number,
workflow: Workflow,
data: IRunData,
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 children = connectedSubNodes
.flatMap((subNodeName) =>
(data[subNodeName] ?? []).flatMap((t, index) => {
if (runIndex !== undefined && index !== runIndex) {
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;
});
treeNode.children = children;
return [treeNode];
}
export function getTotalConsumedTokens(...usage: LlmTokenUsageData[]): LlmTokenUsageData {
return usage.reduce(addTokenUsageData, emptyTokenUsageData);
}
export function getSubtreeTotalConsumedTokens(treeNode: LogEntry): LlmTokenUsageData {
return getTotalConsumedTokens(
treeNode.consumedTokens,
...treeNode.children.map(getSubtreeTotalConsumedTokens),
);
}
function findLogEntryToAutoSelectRec(
tree: TreeNode[],
nodesByName: Record<string, INodeUi>,
runData: IRunData,
data: ExecutionLogViewData,
subTree: LogEntry[],
depth: number,
): TreeNode | undefined {
for (const entry of tree) {
const taskData = runData[entry.node]?.[entry.runIndex];
): LogEntry | undefined {
for (const entry of subTree) {
const taskData = data.data?.resultData.runData[entry.node.name]?.[entry.runIndex];
if (taskData?.error) {
return entry;
}
const childAutoSelect = findLogEntryToAutoSelectRec(
entry.children,
nodesByName,
runData,
depth + 1,
);
const childAutoSelect = findLogEntryToAutoSelectRec(data, entry.children, depth + 1);
if (childAutoSelect) {
return childAutoSelect;
}
if (nodesByName[entry.node]?.type === AGENT_LANGCHAIN_NODE_TYPE) {
if (
data.workflowData.nodes.find((n) => n.name === entry.node.name)?.type ===
AGENT_LANGCHAIN_NODE_TYPE
) {
return entry;
}
}
return depth === 0 ? tree[0] : undefined;
return depth === 0 ? subTree[0] : undefined;
}
export function createLogEntries(workflow: Workflow, runData: IRunData) {
@@ -285,25 +399,61 @@ export function createLogEntries(workflow: Workflow, runData: IRunData) {
return runs.flatMap(({ nodeName, runIndex, task }) => {
if (workflow.getParentNodes(nodeName, 'ALL_NON_MAIN').length > 0) {
return getTreeNodeData(
nodeName,
workflow,
createAiData(nodeName, workflow, (node) => runData[node] ?? []),
undefined,
);
return getTreeNodeDataV2(nodeName, task, workflow, runData, undefined);
}
return getTreeNodeData(
nodeName,
workflow,
[
{
data: getReferencedData(task, false, true)[0],
node: nodeName,
runIndex,
},
],
runIndex,
);
return getTreeNodeDataV2(nodeName, task, workflow, runData, runIndex);
});
}
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 findSelectedLogEntry(
state: LogEntrySelection,
execution?: ExecutionLogViewData,
): 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;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function deepToRaw<T>(sourceObj: T): T {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const objectIterator = (input: any): any => {
if (Array.isArray(input)) {
return input.map((item) => objectIterator(item));
}
if (isRef(input) || isReactive(input) || isProxy(input)) {
return objectIterator(toRaw(input));
}
if (
input !== null &&
typeof input === 'object' &&
Object.getPrototypeOf(input) === Object.prototype
) {
return Object.keys(input).reduce((acc, key) => {
acc[key as keyof typeof acc] = objectIterator(input[key]);
return acc;
}, {} as T);
}
return input;
};
return objectIterator(sourceObj);
}