mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
fix(editor): Changes to workflow after execution should not affect logs (#14703)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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')"
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user