feat(editor): Show logs panel in execution history page (#14477)

This commit is contained in:
Suguru Inoue
2025-04-15 13:26:02 +02:00
committed by GitHub
parent dfc40397c1
commit ed19f0f39b
27 changed files with 596 additions and 165 deletions

View File

@@ -18,7 +18,6 @@ const message: ChatMessage = {
id: 'typing', id: 'typing',
text: '', text: '',
sender: 'bot', sender: 'bot',
createdAt: '',
}; };
const messageContainer = ref<InstanceType<typeof Message>>(); const messageContainer = ref<InstanceType<typeof Message>>();
const classes = computed(() => { const classes = computed(() => {

View File

@@ -21,7 +21,6 @@ export const ChatPlugin: Plugin<ChatOptions> = {
id: uuidv4(), id: uuidv4(),
text, text,
sender: 'bot', sender: 'bot',
createdAt: new Date().toISOString(),
})), })),
); );
@@ -31,7 +30,6 @@ export const ChatPlugin: Plugin<ChatOptions> = {
text, text,
sender: 'user', sender: 'user',
files, files,
createdAt: new Date().toISOString(),
}; };
messages.value.push(sentMessage); messages.value.push(sentMessage);
@@ -62,7 +60,6 @@ export const ChatPlugin: Plugin<ChatOptions> = {
id: uuidv4(), id: uuidv4(),
text: textMessage, text: textMessage,
sender: 'bot', sender: 'bot',
createdAt: new Date().toISOString(),
}; };
messages.value.push(receivedMessage); messages.value.push(receivedMessage);
@@ -80,13 +77,11 @@ export const ChatPlugin: Plugin<ChatOptions> = {
const sessionId = localStorage.getItem(localStorageSessionIdKey) ?? uuidv4(); const sessionId = localStorage.getItem(localStorageSessionIdKey) ?? uuidv4();
const previousMessagesResponse = await api.loadPreviousSession(sessionId, options); const previousMessagesResponse = await api.loadPreviousSession(sessionId, options);
const timestamp = new Date().toISOString();
messages.value = (previousMessagesResponse?.data || []).map((message, index) => ({ messages.value = (previousMessagesResponse?.data || []).map((message, index) => ({
id: `${index}`, id: `${index}`,
text: message.kwargs.content, text: message.kwargs.content,
sender: message.id.includes('HumanMessage') ? 'user' : 'bot', sender: message.id.includes('HumanMessage') ? 'user' : 'bot',
createdAt: timestamp,
})); }));
if (messages.value.length) { if (messages.value.length) {

View File

@@ -13,7 +13,6 @@ export interface ChatMessageText extends ChatMessageBase {
interface ChatMessageBase { interface ChatMessageBase {
id: string; id: string;
createdAt: string;
transparent?: boolean; transparent?: boolean;
sender: 'user' | 'bot'; sender: 'user' | 'bot';
files?: File[]; files?: File[];

View File

@@ -10,6 +10,7 @@ import type {
LoadedClass, LoadedClass,
INodeTypeDescription, INodeTypeDescription,
INodeIssues, INodeIssues,
ITaskData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeConnectionTypes, NodeHelpers, Workflow } from 'n8n-workflow'; import { NodeConnectionTypes, NodeHelpers, Workflow } from 'n8n-workflow';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
@@ -203,3 +204,15 @@ export function createTestNode(node: Partial<INode> = {}): INode {
...node, ...node,
}; };
} }
export function createTestTaskData(partialData: Partial<ITaskData>): ITaskData {
return {
startTime: 0,
executionTime: 1,
executionIndex: 0,
source: [],
executionStatus: 'success',
data: { main: [[{ json: {} }]] },
...partialData,
};
}

View File

@@ -311,7 +311,6 @@ describe('CanvasChat', () => {
id: '1', id: '1',
text: 'Existing message', text: 'Existing message',
sender: 'user', sender: 'user',
createdAt: new Date().toISOString(),
}, },
]; ];
@@ -321,7 +320,6 @@ describe('CanvasChat', () => {
return { return {
sendMessage: vi.fn(), sendMessage: vi.fn(),
extractResponseMessage: vi.fn(),
previousMessageIndex: ref(0), previousMessageIndex: ref(0),
isLoading: computed(() => false), isLoading: computed(() => false),
}; };
@@ -386,7 +384,6 @@ describe('CanvasChat', () => {
beforeEach(() => { beforeEach(() => {
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({ vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
sendMessage: vi.fn(), sendMessage: vi.fn(),
extractResponseMessage: vi.fn(),
previousMessageIndex: ref(0), previousMessageIndex: ref(0),
isLoading: computed(() => false), isLoading: computed(() => false),
}); });
@@ -472,13 +469,11 @@ describe('CanvasChat', () => {
id: '1', id: '1',
text: 'Original message', text: 'Original message',
sender: 'user', sender: 'user',
createdAt: new Date().toISOString(),
}, },
{ {
id: '2', id: '2',
text: 'AI response', text: 'AI response',
sender: 'bot', sender: 'bot',
createdAt: new Date().toISOString(),
}, },
]; ];
vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation(({ messages }) => { vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation(({ messages }) => {
@@ -486,7 +481,6 @@ describe('CanvasChat', () => {
return { return {
sendMessage: sendMessageSpy, sendMessage: sendMessageSpy,
extractResponseMessage: vi.fn(),
previousMessageIndex: ref(0), previousMessageIndex: ref(0),
isLoading: computed(() => false), isLoading: computed(() => false),
}; };

View File

@@ -21,7 +21,6 @@ const workflowsStore = useWorkflowsStore();
const canvasStore = useCanvasStore(); const canvasStore = useCanvasStore();
// Component state // Component state
const isDisabled = ref(false);
const container = ref<HTMLElement>(); const container = ref<HTMLElement>();
const pipContainer = useTemplateRef('pipContainer'); const pipContainer = useTemplateRef('pipContainer');
const pipContent = useTemplateRef('pipContent'); const pipContent = useTemplateRef('pipContent');
@@ -69,13 +68,12 @@ const {
sendMessage, sendMessage,
refreshSession, refreshSession,
displayExecution, displayExecution,
} = useChatState(isDisabled, onWindowResize); } = useChatState(false, onWindowResize);
// Expose internal state for testing // Expose internal state for testing
defineExpose({ defineExpose({
messages, messages,
currentSessionId, currentSessionId,
isDisabled,
workflow, workflow,
}); });

View File

@@ -19,11 +19,13 @@ interface Props {
sessionId: string; sessionId: string;
showCloseButton?: boolean; showCloseButton?: boolean;
isOpen?: boolean; isOpen?: boolean;
isReadOnly?: boolean;
isNewLogsEnabled?: boolean; isNewLogsEnabled?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
isOpen: true, isOpen: true,
isReadOnly: false,
isNewLogsEnabled: false, isNewLogsEnabled: false,
}); });
@@ -145,7 +147,7 @@ async function copySessionId() {
@click="emit('clickHeader')" @click="emit('clickHeader')"
> >
<template #actions> <template #actions>
<N8nTooltip v-if="clipboard.isSupported.value"> <N8nTooltip v-if="clipboard.isSupported.value && !isReadOnly">
<template #content> <template #content>
{{ sessionId }} {{ sessionId }}
<br /> <br />
@@ -160,7 +162,7 @@ async function copySessionId() {
> >
</N8nTooltip> </N8nTooltip>
<N8nTooltip <N8nTooltip
v-if="messages.length > 0" v-if="messages.length > 0 && !isReadOnly"
:content="locale.baseText('chat.window.session.resetSession')" :content="locale.baseText('chat.window.session.resetSession')"
> >
<N8nIconButton <N8nIconButton
@@ -223,7 +225,7 @@ async function copySessionId() {
> >
<template #beforeMessage="{ message }"> <template #beforeMessage="{ message }">
<MessageOptionTooltip <MessageOptionTooltip
v-if="message.sender === 'bot' && !message.id.includes('preload')" v-if="!isReadOnly && message.sender === 'bot' && !message.id.includes('preload')"
placement="right" placement="right"
data-test-id="execution-id-tooltip" data-test-id="execution-id-tooltip"
> >
@@ -232,7 +234,7 @@ async function copySessionId() {
</MessageOptionTooltip> </MessageOptionTooltip>
<MessageOptionAction <MessageOptionAction
v-if="isTextMessage(message) && message.sender === 'user'" v-if="!isReadOnly && isTextMessage(message) && message.sender === 'user'"
data-test-id="repost-message-button" data-test-id="repost-message-button"
icon="redo" icon="redo"
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')" :label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
@@ -241,7 +243,7 @@ async function copySessionId() {
/> />
<MessageOptionAction <MessageOptionAction
v-if="isTextMessage(message) && message.sender === 'user'" v-if="!isReadOnly && isTextMessage(message) && message.sender === 'user'"
data-test-id="reuse-message-button" data-test-id="reuse-message-button"
icon="copy" icon="copy"
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')" :label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"

View File

@@ -2,7 +2,6 @@ import type { ComputedRef, Ref } from 'vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import type { ChatMessage } from '@n8n/chat/types'; import type { ChatMessage } from '@n8n/chat/types';
import { CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import type { import type {
ITaskData, ITaskData,
INodeExecutionData, INodeExecutionData,
@@ -15,10 +14,10 @@ import type {
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage'; import { useMessage } from '@/composables/useMessage';
import { usePinnedData } from '@/composables/usePinnedData'; import { usePinnedData } from '@/composables/usePinnedData';
import { get, isEmpty } from 'lodash-es'; import { MODAL_CONFIRM } from '@/constants';
import { MANUAL_CHAT_TRIGGER_NODE_TYPE, MODAL_CONFIRM } from '@/constants';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import type { IExecutionPushResponse, INodeUi } from '@/Interface'; import type { IExecutionPushResponse, INodeUi } from '@/Interface';
import { extractBotResponse, getInputKey } from '@/components/CanvasChat/utils';
export type RunWorkflowChatPayload = { export type RunWorkflowChatPayload = {
triggerNode: string; triggerNode: string;
@@ -106,13 +105,7 @@ export function useChatMessaging({
return; return;
} }
let inputKey = 'chatInput'; const inputKey = getInputKey(triggerNode);
if (triggerNode.type === MANUAL_CHAT_TRIGGER_NODE_TYPE && triggerNode.typeVersion < 1.1) {
inputKey = 'input';
}
if (triggerNode.type === CHAT_TRIGGER_NODE_TYPE) {
inputKey = 'chatInput';
}
const inputPayload: INodeExecutionData = { const inputPayload: INodeExecutionData = {
json: { json: {
@@ -151,53 +144,17 @@ export function useChatMessaging({
return; return;
} }
processExecutionResultData(response.executionId); const chatMessage = executionResultData.value
} ? extractBotResponse(
executionResultData.value,
response.executionId,
locale.baseText('chat.window.chat.response.empty'),
)
: undefined;
function processExecutionResultData(executionId: string) { if (chatMessage !== undefined) {
const lastNodeExecuted = executionResultData.value?.lastNodeExecuted; messages.value.push(chatMessage);
if (!lastNodeExecuted) return;
const nodeResponseDataArray = get(executionResultData.value.runData, lastNodeExecuted) ?? [];
const nodeResponseData = nodeResponseDataArray[nodeResponseDataArray.length - 1];
let responseMessage: string;
if (get(nodeResponseData, 'error')) {
responseMessage = '[ERROR: ' + get(nodeResponseData, 'error.message') + ']';
} else {
const responseData = get(nodeResponseData, 'data.main[0][0].json');
responseMessage = extractResponseMessage(responseData);
} }
isLoading.value = false;
messages.value.push({
text: responseMessage,
sender: 'bot',
createdAt: new Date().toISOString(),
id: executionId ?? uuid(),
});
}
/** Extracts response message from workflow output */
function extractResponseMessage(responseData?: IDataObject) {
if (!responseData || isEmpty(responseData)) {
return locale.baseText('chat.window.chat.response.empty');
}
// Paths where the response message might be located
const paths = ['output', 'text', 'response.text'];
const matchedPath = paths.find((path) => get(responseData, path));
if (!matchedPath) return JSON.stringify(responseData, null, 2);
const matchedOutput = get(responseData, matchedPath);
if (typeof matchedOutput === 'object') {
return '```json\n' + JSON.stringify(matchedOutput, null, 2) + '\n```';
}
return matchedOutput?.toString() ?? '';
} }
/** Sends a message to the chat */ /** Sends a message to the chat */
@@ -230,7 +187,6 @@ export function useChatMessaging({
const newMessage: ChatMessage & { sessionId: string } = { const newMessage: ChatMessage & { sessionId: string } = {
text: message, text: message,
sender: 'user', sender: 'user',
createdAt: new Date().toISOString(),
sessionId: sessionId.value, sessionId: sessionId.value,
id: uuid(), id: uuid(),
files, files,
@@ -244,6 +200,5 @@ export function useChatMessaging({
previousMessageIndex, previousMessageIndex,
isLoading: computed(() => isLoading.value), isLoading: computed(() => isLoading.value),
sendMessage, sendMessage,
extractResponseMessage,
}; };
} }

View File

@@ -18,6 +18,7 @@ import type { Ref } from 'vue';
import { computed, provide, ref, watch } from 'vue'; import { computed, provide, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { LOGS_PANEL_STATE } from '../types/logs'; import { LOGS_PANEL_STATE } from '../types/logs';
import { restoreChatHistory } from '@/components/CanvasChat/utils';
interface ChatState { interface ChatState {
currentSessionId: Ref<string>; currentSessionId: Ref<string>;
@@ -29,7 +30,8 @@ interface ChatState {
displayExecution: (executionId: string) => void; displayExecution: (executionId: string) => void;
} }
export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => void): ChatState { export function useChatState(isReadOnly: boolean, onWindowResize: () => void): ChatState {
const locale = useI18n();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const canvasStore = useCanvasStore(); const canvasStore = useCanvasStore();
@@ -114,11 +116,18 @@ export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => voi
sendMessage, sendMessage,
currentSessionId, currentSessionId,
isLoading, isLoading,
isDisabled, isDisabled: computed(() => isReadOnly),
allowFileUploads, allowFileUploads,
locale: useI18n(), locale,
}); });
const restoredChatMessages = computed(() =>
restoreChatHistory(
workflowsStore.workflowExecutionData,
locale.baseText('chat.window.chat.response.empty'),
),
);
// Provide chat context // Provide chat context
provide(ChatSymbol, chatConfig); provide(ChatSymbol, chatConfig);
provide(ChatOptionsSymbol, chatOptions); provide(ChatOptionsSymbol, chatOptions);
@@ -210,7 +219,7 @@ export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => voi
return { return {
currentSessionId, currentSessionId,
messages, messages: computed(() => (isReadOnly ? restoredChatMessages.value : messages.value)),
chatTriggerNode, chatTriggerNode,
connectedNode, connectedNode,
sendMessage, sendMessage,

View File

@@ -11,10 +11,9 @@ import {
AI_CATEGORY_CHAINS, AI_CATEGORY_CHAINS,
AI_CODE_NODE_TYPE, AI_CODE_NODE_TYPE,
AI_SUBCATEGORY, AI_SUBCATEGORY,
CHAT_TRIGGER_NODE_TYPE,
MANUAL_CHAT_TRIGGER_NODE_TYPE,
} from '@/constants'; } from '@/constants';
import type { INodeUi } from '@/Interface'; import type { INodeUi } from '@/Interface';
import { isChatNode } from '@/components/CanvasChat/utils';
export interface ChatTriggerDependencies { export interface ChatTriggerDependencies {
getNodeByName: (name: string) => INodeUi | null; getNodeByName: (name: string) => INodeUi | null;
@@ -52,9 +51,7 @@ export function useChatTrigger({
/** Gets the chat trigger node from the workflow */ /** Gets the chat trigger node from the workflow */
function setChatTriggerNode() { function setChatTriggerNode() {
const triggerNode = unref(canvasNodes).find((node) => const triggerNode = unref(canvasNodes).find(isChatNode);
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
);
if (!triggerNode) { if (!triggerNode) {
return; return;

View File

@@ -39,7 +39,11 @@ export function usePiPWindow({
}: UsePiPWindowOptions): UsePiPWindowReturn { }: UsePiPWindowOptions): UsePiPWindowReturn {
const pipWindow = ref<Window>(); const pipWindow = ref<Window>();
const isUnmounting = ref(false); const isUnmounting = ref(false);
const canPopOut = computed(() => !!window.documentPictureInPicture); const canPopOut = computed(
() =>
!!window.documentPictureInPicture /* Browser supports the API */ &&
window.parent === window /* Not in iframe */,
);
const isPoppedOut = computed(() => !!pipWindow.value); const isPoppedOut = computed(() => !!pipWindow.value);
const tooltipContainer = computed(() => const tooltipContainer = computed(() =>
isPoppedOut.value ? (content.value ?? undefined) : undefined, isPoppedOut.value ? (content.value ?? undefined) : undefined,

View File

@@ -46,6 +46,7 @@ describe('LogsPanel', () => {
workflowsStore = mockedStore(useWorkflowsStore); workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.setWorkflowExecutionData(null); workflowsStore.setWorkflowExecutionData(null);
workflowsStore.toggleLogsPanelOpen(false);
nodeTypeStore = mockedStore(useNodeTypesStore); nodeTypeStore = mockedStore(useNodeTypesStore);
nodeTypeStore.setNodeTypes(nodeTypes); nodeTypeStore.setNodeTypes(nodeTypes);

View File

@@ -6,37 +6,66 @@ import { useChatState } from '@/components/CanvasChat/composables/useChatState';
import { useResize } from '@/components/CanvasChat/composables/useResize'; import { useResize } from '@/components/CanvasChat/composables/useResize';
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow'; import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE } from '@/constants';
import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue'; import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue';
import { useCanvasStore } from '@/stores/canvas.store'; import { useCanvasStore } from '@/stores/canvas.store';
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue'; import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue';
import LogsDetailsPanel from '@/components/CanvasChat/future/components/LogDetailsPanel.vue'; import LogsDetailsPanel from '@/components/CanvasChat/future/components/LogDetailsPanel.vue';
import { LOGS_PANEL_STATE, type LogEntryIdentity } from '@/components/CanvasChat/types/logs'; import { LOGS_PANEL_STATE, type LogEntrySelection } from '@/components/CanvasChat/types/logs';
import LogsPanelActions from '@/components/CanvasChat/future/components/LogsPanelActions.vue'; import LogsPanelActions from '@/components/CanvasChat/future/components/LogsPanelActions.vue';
import {
createLogEntries,
findLogEntryToAutoSelect,
type TreeNode,
} from '@/components/RunDataAi/utils';
import { isChatNode } from '@/components/CanvasChat/utils';
const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false });
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const canvasStore = useCanvasStore(); const canvasStore = useCanvasStore();
const panelState = computed(() => workflowsStore.logsPanelState); const panelState = computed(() => workflowsStore.logsPanelState);
const container = ref<HTMLElement>(); const container = ref<HTMLElement>();
const selectedLogEntry = ref<LogEntryIdentity | undefined>(undefined);
const pipContainer = useTemplateRef('pipContainer'); const pipContainer = useTemplateRef('pipContainer');
const pipContent = useTemplateRef('pipContent'); const pipContent = useTemplateRef('pipContent');
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages); const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const hasChat = computed(() =>
workflowsStore.workflowTriggerNodes.some((node) =>
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
),
);
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResizeChatDebounced } = const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResizeChatDebounced } =
useResize(container); useResize(container);
const { currentSessionId, messages, sendMessage, refreshSession, displayExecution } = useChatState( const { currentSessionId, messages, sendMessage, refreshSession, displayExecution } = useChatState(
ref(false), props.isReadOnly,
onWindowResize, onWindowResize,
); );
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,
);
const isLogDetailsOpen = computed(() => selectedLogEntry.value !== undefined); const isLogDetailsOpen = computed(() => selectedLogEntry.value !== undefined);
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({ const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
@@ -76,8 +105,11 @@ function handleClickHeader() {
} }
} }
function handleSelectLogEntry(selected: LogEntryIdentity | undefined) { function handleSelectLogEntry(selected: TreeNode | undefined) {
selectedLogEntry.value = selected; manualLogEntrySelection.value =
selected === undefined
? { type: 'none', workflowId: workflowsStore.workflow.id }
: { type: 'selected', workflowId: workflowsStore.workflow.id, data: selected };
} }
function onPopOut() { function onPopOut() {
@@ -121,6 +153,7 @@ watch([panelState, height], ([state, h]) => {
<ChatMessagesPanel <ChatMessagesPanel
data-test-id="canvas-chat" data-test-id="canvas-chat"
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED" :is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
:is-read-only="isReadOnly"
:messages="messages" :messages="messages"
:session-id="currentSessionId" :session-id="currentSessionId"
:past-chat-messages="previousChatMessages" :past-chat-messages="previousChatMessages"
@@ -136,7 +169,9 @@ watch([panelState, height], ([state, h]) => {
<LogsOverviewPanel <LogsOverviewPanel
:class="$style.logsOverview" :class="$style.logsOverview"
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED" :is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
:is-read-only="isReadOnly"
:selected="selectedLogEntry" :selected="selectedLogEntry"
:execution-tree="executionTree"
@click-header="handleClickHeader" @click-header="handleClickHeader"
@select="handleSelectLogEntry" @select="handleSelectLogEntry"
> >
@@ -145,7 +180,7 @@ watch([panelState, height], ([state, h]) => {
</template> </template>
</LogsOverviewPanel> </LogsOverviewPanel>
<LogsDetailsPanel <LogsDetailsPanel
v-if="selectedLogEntry" v-if="selectedLogEntry !== undefined"
:class="$style.logDetails" :class="$style.logDetails"
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED" :is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
@click-header="handleClickHeader" @click-header="handleClickHeader"

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
import { useSettingsStore } from '@/stores/settings.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed } from 'vue';
const { isNewLogsEnabled } = useSettingsStore();
const workflowsStore = useWorkflowsStore();
const hasExecutionData = computed(() => workflowsStore.workflowExecutionData);
</script>
<template>
<LogsPanel v-if="isNewLogsEnabled && hasExecutionData" :is-read-only="true" />
</template>

View File

@@ -5,10 +5,9 @@ import { createTestingPinia, type TestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { h, type ExtractPropTypes } from 'vue'; import { h } from 'vue';
import { fireEvent, waitFor, within } from '@testing-library/vue'; import { fireEvent, waitFor, within } from '@testing-library/vue';
import { import {
aiAgentNode,
aiChatExecutionResponse, aiChatExecutionResponse,
aiChatWorkflow, aiChatWorkflow,
aiManualExecutionResponse, aiManualExecutionResponse,
@@ -16,6 +15,7 @@ import {
} from '../../__test__/data'; } from '../../__test__/data';
import { usePushConnectionStore } from '@/stores/pushConnection.store'; import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { createLogEntries } from '@/components/RunDataAi/utils';
describe('LogsOverviewPanel', () => { describe('LogsOverviewPanel', () => {
let pinia: TestingPinia; let pinia: TestingPinia;
@@ -23,9 +23,19 @@ describe('LogsOverviewPanel', () => {
let pushConnectionStore: ReturnType<typeof mockedStore<typeof usePushConnectionStore>>; let pushConnectionStore: ReturnType<typeof mockedStore<typeof usePushConnectionStore>>;
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>; let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
function render(props: ExtractPropTypes<typeof LogsOverviewPanel>) { function render(props: Partial<InstanceType<typeof LogsOverviewPanel>['$props']>) {
const mergedProps: InstanceType<typeof LogsOverviewPanel>['$props'] = {
isOpen: false,
isReadOnly: false,
executionTree: createLogEntries(
workflowsStore.getCurrentWorkflow(),
workflowsStore.workflowExecutionData?.data?.resultData.runData ?? {},
),
...props,
};
return renderComponent(LogsOverviewPanel, { return renderComponent(LogsOverviewPanel, {
props, props: mergedProps,
global: { global: {
plugins: [ plugins: [
createRouter({ createRouter({
@@ -54,13 +64,13 @@ describe('LogsOverviewPanel', () => {
}); });
it('should not render body if the panel is not open', () => { it('should not render body if the panel is not open', () => {
const rendered = render({ isOpen: false, node: null }); const rendered = render({ isOpen: false });
expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument(); expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument();
}); });
it('should render empty text if there is no execution', () => { it('should render empty text if there is no execution', () => {
const rendered = render({ isOpen: true, node: null }); const rendered = render({ isOpen: true });
expect(rendered.queryByTestId('logs-overview-empty')).toBeInTheDocument(); expect(rendered.queryByTestId('logs-overview-empty')).toBeInTheDocument();
}); });
@@ -68,7 +78,7 @@ describe('LogsOverviewPanel', () => {
it('should render summary text and executed nodes if there is an execution', async () => { it('should render summary text and executed nodes if there is an execution', async () => {
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render({ isOpen: true, node: aiAgentNode }); const rendered = render({ isOpen: true });
const summary = within(rendered.container.querySelector('.summary')!); const summary = within(rendered.container.querySelector('.summary')!);
expect(summary.queryByText('Success in 1.999s')).toBeInTheDocument(); expect(summary.queryByText('Success in 1.999s')).toBeInTheDocument();
@@ -101,7 +111,7 @@ describe('LogsOverviewPanel', () => {
it('should open NDV if the button is clicked', async () => { it('should open NDV if the button is clicked', async () => {
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render({ isOpen: true, node: aiAgentNode }); const rendered = render({ isOpen: true });
const aiAgentRow = rendered.getAllByRole('treeitem')[0]; const aiAgentRow = rendered.getAllByRole('treeitem')[0];
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Open...')[0]); await fireEvent.click(within(aiAgentRow).getAllByLabelText('Open...')[0]);
@@ -117,7 +127,7 @@ describe('LogsOverviewPanel', () => {
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render({ isOpen: true, node: aiAgentNode }); const rendered = render({ isOpen: true });
const aiAgentRow = rendered.getAllByRole('treeitem')[0]; const aiAgentRow = rendered.getAllByRole('treeitem')[0];
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Test step')[0]); await fireEvent.click(within(aiAgentRow).getAllByLabelText('Test step')[0]);
await waitFor(() => await waitFor(() =>

View File

@@ -8,7 +8,6 @@ import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-sys
import { computed } from 'vue'; import { computed } from 'vue';
import { ElTree, type TreeNode as ElTreeNode } from 'element-plus'; import { ElTree, type TreeNode as ElTreeNode } from 'element-plus';
import { import {
createLogEntries,
getSubtreeTotalConsumedTokens, getSubtreeTotalConsumedTokens,
getTotalConsumedTokens, getTotalConsumedTokens,
type TreeNode, type TreeNode,
@@ -16,18 +15,18 @@ import {
import { upperFirst } from 'lodash-es'; import { upperFirst } from 'lodash-es';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue'; import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
import { type LogEntryIdentity } from '@/components/CanvasChat/types/logs';
import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.vue';
import { useRunWorkflow } from '@/composables/useRunWorkflow'; import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
const { isOpen, selected } = defineProps<{ const { isOpen, isReadOnly, selected, executionTree } = defineProps<{
isOpen: boolean; isOpen: boolean;
selected?: LogEntryIdentity; isReadOnly: boolean;
selected?: TreeNode;
executionTree: TreeNode[];
}>(); }>();
const emit = defineEmits<{ clickHeader: []; select: [LogEntryIdentity | undefined] }>(); const emit = defineEmits<{ clickHeader: []; select: [TreeNode | undefined] }>();
defineSlots<{ actions: {} }>(); defineSlots<{ actions: {} }>();
@@ -40,12 +39,6 @@ const ndvStore = useNDVStore();
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
const isClearExecutionButtonVisible = useClearExecutionButtonVisible(); const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
const workflow = computed(() => workflowsStore.getCurrentWorkflow()); const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const executionTree = computed<TreeNode[]>(() =>
createLogEntries(
workflow.value,
workflowsStore.workflowExecutionData?.data?.resultData.runData ?? {},
),
);
const isEmpty = computed(() => workflowsStore.workflowExecutionData === null); const isEmpty = computed(() => workflowsStore.workflowExecutionData === null);
const switchViewOptions = computed(() => [ const switchViewOptions = computed(() => [
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const }, { label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
@@ -73,7 +66,7 @@ const executionStatusText = computed(() => {
return upperFirst(execution.status); return upperFirst(execution.status);
}); });
const consumedTokens = computed(() => const consumedTokens = computed(() =>
getTotalConsumedTokens(...executionTree.value.map(getSubtreeTotalConsumedTokens)), getTotalConsumedTokens(...executionTree.map(getSubtreeTotalConsumedTokens)),
); );
function onClearExecutionData() { function onClearExecutionData() {
@@ -82,12 +75,12 @@ function onClearExecutionData() {
} }
function handleClickNode(clicked: TreeNode) { function handleClickNode(clicked: TreeNode) {
if (selected?.node === clicked.node && selected.runIndex === clicked.runIndex) { if (selected?.node === clicked.node && selected?.runIndex === clicked.runIndex) {
emit('select', undefined); emit('select', undefined);
return; return;
} }
emit('select', { node: clicked.node, runIndex: clicked.runIndex }); emit('select', clicked);
telemetry.track('User selected node in log view', { telemetry.track('User selected node in log view', {
node_type: workflowsStore.nodesByName[clicked.node].type, node_type: workflowsStore.nodesByName[clicked.node].type,
node_id: workflowsStore.nodesByName[clicked.node].id, node_id: workflowsStore.nodesByName[clicked.node].id,
@@ -97,10 +90,7 @@ function handleClickNode(clicked: TreeNode) {
} }
function handleSwitchView(value: 'overview' | 'details') { function handleSwitchView(value: 'overview' | 'details') {
emit( emit('select', value === 'overview' || executionTree.length === 0 ? undefined : executionTree[0]);
'select',
value === 'overview' || executionTree.value.length === 0 ? undefined : executionTree.value[0],
);
} }
function handleToggleExpanded(treeNode: ElTreeNode) { function handleToggleExpanded(treeNode: ElTreeNode) {
@@ -183,6 +173,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
<LogsOverviewRow <LogsOverviewRow
:data="data" :data="data"
:node="elTreeNode" :node="elTreeNode"
:is-read-only="isReadOnly"
:is-selected="data.node === selected?.node && data.runIndex === selected?.runIndex" :is-selected="data.node === selected?.node && data.runIndex === selected?.runIndex"
:is-compact="selected !== undefined" :is-compact="selected !== undefined"
:should-show-consumed-tokens="consumedTokens.totalTokens > 0" :should-show-consumed-tokens="consumedTokens.totalTokens > 0"

View File

@@ -2,7 +2,7 @@
import { type TreeNode as ElTreeNode } from 'element-plus'; import { type TreeNode as ElTreeNode } from 'element-plus';
import { getSubtreeTotalConsumedTokens, type TreeNode } from '@/components/RunDataAi/utils'; import { getSubtreeTotalConsumedTokens, type TreeNode } from '@/components/RunDataAi/utils';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed } from 'vue'; import { computed, useTemplateRef, watch } from 'vue';
import { type INodeUi } from '@/Interface'; import { type INodeUi } from '@/Interface';
import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system'; import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
import { type ITaskData } from 'n8n-workflow'; import { type ITaskData } from 'n8n-workflow';
@@ -17,6 +17,7 @@ const props = defineProps<{
data: TreeNode; data: TreeNode;
node: ElTreeNode; node: ElTreeNode;
isSelected: boolean; isSelected: boolean;
isReadOnly: boolean;
shouldShowConsumedTokens: boolean; shouldShowConsumedTokens: boolean;
isCompact: boolean; isCompact: boolean;
}>(); }>();
@@ -28,6 +29,7 @@ const emit = defineEmits<{
}>(); }>();
const locale = useI18n(); const locale = useI18n();
const containerRef = useTemplateRef('containerRef');
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const nodeTypeStore = useNodeTypesStore(); const nodeTypeStore = useNodeTypesStore();
const node = computed<INodeUi | undefined>(() => workflowsStore.nodesByName[props.data.node]); const node = computed<INodeUi | undefined>(() => workflowsStore.nodesByName[props.data.node]);
@@ -77,11 +79,23 @@ function isLastChild(level: number) {
(data?.node === lastSibling?.node && data?.runIndex === lastSibling?.runIndex) (data?.node === lastSibling?.node && data?.runIndex === lastSibling?.runIndex)
); );
} }
// When selected, scroll into view
watch(
[() => props.isSelected, containerRef],
([isSelected, ref]) => {
if (isSelected && ref) {
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
},
{ immediate: true },
);
</script> </script>
<template> <template>
<div <div
v-if="node !== undefined" v-if="node !== undefined"
ref="containerRef"
:class="{ :class="{
[$style.container]: true, [$style.container]: true,
[$style.compact]: props.isCompact, [$style.compact]: props.isCompact,
@@ -106,8 +120,8 @@ function isLastChild(level: number) {
size="small" size="small"
:class="$style.name" :class="$style.name"
:color="isError ? 'danger' : undefined" :color="isError ? 'danger' : undefined"
>{{ node.name }}</N8nText >{{ node.name }}
> </N8nText>
<N8nText tag="div" color="text-light" size="small" :class="$style.timeTook"> <N8nText tag="div" color="text-light" size="small" :class="$style.timeTook">
<I18nT v-if="isSettled && runData" keypath="logs.overview.body.summaryText"> <I18nT v-if="isSettled && runData" keypath="logs.overview.body.summaryText">
<template #status> <template #status>
@@ -148,6 +162,7 @@ function isLastChild(level: number) {
:class="$style.compactErrorIcon" :class="$style.compactErrorIcon"
/> />
<N8nIconButton <N8nIconButton
v-if="!props.isReadOnly"
type="secondary" type="secondary"
size="small" size="small"
icon="play" icon="play"

View File

@@ -1,7 +1,9 @@
export interface LogEntryIdentity { import { type TreeNode } from '@/components/RunDataAi/utils';
node: string;
runIndex: number; export type LogEntrySelection =
} | { type: 'initial' }
| { type: 'selected'; workflowId: string; data: TreeNode }
| { type: 'none'; workflowId: string };
export const LOGS_PANEL_STATE = { export const LOGS_PANEL_STATE = {
CLOSED: 'closed', CLOSED: 'closed',

View File

@@ -0,0 +1,48 @@
import { createTestNode, createTestTaskData, createTestWorkflow } from '@/__tests__/mocks';
import { restoreChatHistory } from '@/components/CanvasChat/utils';
import { AGENT_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE } from '@/constants';
import { NodeConnectionTypes } from 'n8n-workflow';
describe(restoreChatHistory, () => {
it('should return extracted chat input and bot message from workflow execution data', () => {
expect(
restoreChatHistory({
id: 'test-exec-id',
workflowData: createTestWorkflow({
nodes: [
createTestNode({ name: 'A', type: CHAT_TRIGGER_NODE_TYPE }),
createTestNode({ name: 'B', type: AGENT_NODE_TYPE }),
],
}),
data: {
resultData: {
lastNodeExecuted: 'B',
runData: {
A: [
createTestTaskData({
startTime: Date.parse('2025-04-20T00:00:01.000Z'),
data: { [NodeConnectionTypes.Main]: [[{ json: { chatInput: 'test input' } }]] },
}),
],
B: [
createTestTaskData({
startTime: Date.parse('2025-04-20T00:00:02.000Z'),
executionTime: 999,
data: { [NodeConnectionTypes.Main]: [[{ json: { output: 'test output' } }]] },
}),
],
},
},
},
finished: true,
mode: 'manual',
status: 'success',
startedAt: '2025-04-20T00:00:00.000Z',
createdAt: '2025-04-20T00:00:00.000Z',
}),
).toEqual([
{ id: expect.any(String), sender: 'user', text: 'test input' },
{ id: 'test-exec-id', sender: 'bot', text: 'test output' },
]);
});
});

View File

@@ -0,0 +1,122 @@
import { CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE } from '@/constants';
import { type IExecutionResponse, type INodeUi, type IWorkflowDb } from '@/Interface';
import { type ChatMessage } from '@n8n/chat/types';
import { get, isEmpty } from 'lodash-es';
import { NodeConnectionTypes, type IDataObject, type IRunExecutionData } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
export function isChatNode(node: INodeUi) {
return [CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type);
}
export function getInputKey(node: INodeUi): string {
if (node.type === MANUAL_CHAT_TRIGGER_NODE_TYPE && node.typeVersion < 1.1) {
return 'input';
}
if (node.type === CHAT_TRIGGER_NODE_TYPE) {
return 'chatInput';
}
return 'chatInput';
}
function extractChatInput(
workflow: IWorkflowDb,
resultData: IRunExecutionData['resultData'],
): ChatMessage | undefined {
const chatTrigger = workflow.nodes.find(isChatNode);
if (chatTrigger === undefined) {
return undefined;
}
const inputKey = getInputKey(chatTrigger);
const runData = (resultData.runData[chatTrigger.name] ?? [])[0];
const message = runData?.data?.[NodeConnectionTypes.Main]?.[0]?.[0]?.json?.[inputKey];
if (runData === undefined || typeof message !== 'string') {
return undefined;
}
return {
text: message,
sender: 'user',
id: uuid(),
};
}
export function extractBotResponse(
resultData: IRunExecutionData['resultData'],
executionId: string,
emptyText?: string,
): ChatMessage | undefined {
const lastNodeExecuted = resultData.lastNodeExecuted;
if (!lastNodeExecuted) return undefined;
const nodeResponseDataArray = get(resultData.runData, lastNodeExecuted) ?? [];
const nodeResponseData = nodeResponseDataArray[nodeResponseDataArray.length - 1];
let responseMessage: string;
if (get(nodeResponseData, 'error')) {
responseMessage = '[ERROR: ' + get(nodeResponseData, 'error.message') + ']';
} else {
const responseData = get(nodeResponseData, 'data.main[0][0].json');
const text = extractResponseText(responseData) ?? emptyText;
if (!text) {
return undefined;
}
responseMessage = text;
}
return {
text: responseMessage,
sender: 'bot',
id: executionId ?? uuid(),
};
}
/** Extracts response message from workflow output */
function extractResponseText(responseData?: IDataObject): string | undefined {
if (!responseData || isEmpty(responseData)) {
return undefined;
}
// Paths where the response message might be located
const paths = ['output', 'text', 'response.text'];
const matchedPath = paths.find((path) => get(responseData, path));
if (!matchedPath) return JSON.stringify(responseData, null, 2);
const matchedOutput = get(responseData, matchedPath);
if (typeof matchedOutput === 'object') {
return '```json\n' + JSON.stringify(matchedOutput, null, 2) + '\n```';
}
return matchedOutput?.toString() ?? '';
}
export function restoreChatHistory(
workflowExecutionData: IExecutionResponse | null,
emptyText?: string,
): ChatMessage[] {
if (!workflowExecutionData?.data) {
return [];
}
const userMessage = extractChatInput(
workflowExecutionData.workflowData,
workflowExecutionData.data.resultData,
);
const botMessage = extractBotResponse(
workflowExecutionData.data.resultData,
workflowExecutionData.id,
emptyText,
);
return [...(userMessage ? [userMessage] : []), ...(botMessage ? [botMessage] : [])];
}

View File

@@ -1,16 +1,28 @@
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks'; import { createTestNode, createTestTaskData, createTestWorkflowObject } from '@/__tests__/mocks';
import { createAiData, createLogEntries, getTreeNodeData } from '@/components/RunDataAi/utils'; import {
import { type ITaskData, NodeConnectionTypes } from 'n8n-workflow'; createAiData,
findLogEntryToAutoSelect,
getTreeNodeData,
createLogEntries,
type TreeNode,
} from '@/components/RunDataAi/utils';
import {
AGENT_LANGCHAIN_NODE_TYPE,
type ExecutionError,
type ITaskData,
NodeConnectionTypes,
} from 'n8n-workflow';
function createTaskData(partialData: Partial<ITaskData>): ITaskData { function createTestLogEntry(data: Partial<TreeNode>): TreeNode {
return { return {
node: 'test node',
runIndex: 0,
id: String(Math.random()),
children: [],
consumedTokens: { completionTokens: 0, totalTokens: 0, promptTokens: 0, isEstimate: false },
depth: 0,
startTime: 0, startTime: 0,
executionIndex: 0, ...data,
executionTime: 1,
source: [],
executionStatus: 'success',
data: { main: [[{ json: {} }]] },
...partialData,
}; };
} }
@@ -30,9 +42,9 @@ describe(getTreeNodeData, () => {
}, },
}); });
const taskDataByNodeName: Record<string, ITaskData[]> = { const taskDataByNodeName: Record<string, ITaskData[]> = {
A: [createTaskData({ startTime: Date.parse('2025-02-26T00:00:00.000Z') })], A: [createTestTaskData({ startTime: Date.parse('2025-02-26T00:00:00.000Z') })],
B: [ B: [
createTaskData({ createTestTaskData({
startTime: Date.parse('2025-02-26T00:00:01.000Z'), startTime: Date.parse('2025-02-26T00:00:01.000Z'),
data: { data: {
main: [ main: [
@@ -50,7 +62,7 @@ describe(getTreeNodeData, () => {
], ],
}, },
}), }),
createTaskData({ createTestTaskData({
startTime: Date.parse('2025-02-26T00:00:03.000Z'), startTime: Date.parse('2025-02-26T00:00:03.000Z'),
data: { data: {
main: [ main: [
@@ -70,7 +82,7 @@ describe(getTreeNodeData, () => {
}), }),
], ],
C: [ C: [
createTaskData({ createTestTaskData({
startTime: Date.parse('2025-02-26T00:00:02.000Z'), startTime: Date.parse('2025-02-26T00:00:02.000Z'),
data: { data: {
main: [ main: [
@@ -88,7 +100,7 @@ describe(getTreeNodeData, () => {
], ],
}, },
}), }),
createTaskData({ startTime: Date.parse('2025-02-26T00:00:04.000Z') }), createTestTaskData({ startTime: Date.parse('2025-02-26T00:00:04.000Z') }),
], ],
}; };
@@ -181,6 +193,143 @@ 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' }),
},
{
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 }));
});
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 }),
],
}),
],
{
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 }));
});
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' }),
},
{
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 }),
],
{
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({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'success' }),
],
},
),
).toEqual(expect.objectContaining({ node: 'A', runIndex: 0 }));
});
});
describe(createLogEntries, () => { describe(createLogEntries, () => {
it('should return root node log entries in ascending order of executionIndex', () => { it('should return root node log entries in ascending order of executionIndex', () => {
const workflow = createTestWorkflowObject({ const workflow = createTestWorkflowObject({
@@ -198,14 +347,26 @@ describe(createLogEntries, () => {
expect( expect(
createLogEntries(workflow, { createLogEntries(workflow, {
A: [ A: [
createTaskData({ startTime: Date.parse('2025-04-04T00:00:00.000Z'), executionIndex: 0 }), createTestTaskData({
startTime: Date.parse('2025-04-04T00:00:00.000Z'),
executionIndex: 0,
}),
], ],
B: [ B: [
createTaskData({ startTime: Date.parse('2025-04-04T00:00:01.000Z'), executionIndex: 1 }), createTestTaskData({
startTime: Date.parse('2025-04-04T00:00:01.000Z'),
executionIndex: 1,
}),
], ],
C: [ C: [
createTaskData({ startTime: Date.parse('2025-04-04T00:00:02.000Z'), executionIndex: 3 }), createTestTaskData({
createTaskData({ startTime: Date.parse('2025-04-04T00:00:03.000Z'), executionIndex: 2 }), startTime: Date.parse('2025-04-04T00:00:02.000Z'),
executionIndex: 3,
}),
createTestTaskData({
startTime: Date.parse('2025-04-04T00:00:03.000Z'),
executionIndex: 2,
}),
], ],
}), }),
).toEqual([ ).toEqual([
@@ -236,14 +397,26 @@ describe(createLogEntries, () => {
expect( expect(
createLogEntries(workflow, { createLogEntries(workflow, {
A: [ A: [
createTaskData({ startTime: Date.parse('2025-04-04T00:00:00.000Z'), executionIndex: 0 }), createTestTaskData({
startTime: Date.parse('2025-04-04T00:00:00.000Z'),
executionIndex: 0,
}),
], ],
B: [ B: [
createTaskData({ startTime: Date.parse('2025-04-04T00:00:01.000Z'), executionIndex: 1 }), createTestTaskData({
startTime: Date.parse('2025-04-04T00:00:01.000Z'),
executionIndex: 1,
}),
], ],
C: [ C: [
createTaskData({ startTime: Date.parse('2025-04-04T00:00:02.000Z'), executionIndex: 3 }), createTestTaskData({
createTaskData({ startTime: Date.parse('2025-04-04T00:00:03.000Z'), executionIndex: 2 }), startTime: Date.parse('2025-04-04T00:00:02.000Z'),
executionIndex: 3,
}),
createTestTaskData({
startTime: Date.parse('2025-04-04T00:00:03.000Z'),
executionIndex: 2,
}),
], ],
}), }),
).toEqual([ ).toEqual([

View File

@@ -1,5 +1,6 @@
import { type LlmTokenUsageData, type IAiDataContent } from '@/Interface'; import { type LlmTokenUsageData, type IAiDataContent, type INodeUi } from '@/Interface';
import { import {
AGENT_LANGCHAIN_NODE_TYPE,
type IRunData, type IRunData,
type INodeExecutionData, type INodeExecutionData,
type ITaskData, type ITaskData,
@@ -226,6 +227,46 @@ export function formatTokenUsageCount(
return usage.isEstimate ? `~${count}` : count.toLocaleString(); 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);
}
function findLogEntryToAutoSelectRec(
tree: TreeNode[],
nodesByName: Record<string, INodeUi>,
runData: IRunData,
depth: number,
): TreeNode | undefined {
for (const entry of tree) {
const taskData = runData[entry.node]?.[entry.runIndex];
if (taskData?.error) {
return entry;
}
const childAutoSelect = findLogEntryToAutoSelectRec(
entry.children,
nodesByName,
runData,
depth + 1,
);
if (childAutoSelect) {
return childAutoSelect;
}
if (nodesByName[entry.node]?.type === AGENT_LANGCHAIN_NODE_TYPE) {
return entry;
}
}
return depth === 0 ? tree[0] : undefined;
}
export function createLogEntries(workflow: Workflow, runData: IRunData) { export function createLogEntries(workflow: Workflow, runData: IRunData) {
const runs = Object.entries(runData) const runs = Object.entries(runData)
.filter(([nodeName]) => workflow.getChildNodes(nodeName, 'ALL_NON_MAIN').length === 0) .filter(([nodeName]) => workflow.getChildNodes(nodeName, 'ALL_NON_MAIN').length === 0)

View File

@@ -232,7 +232,9 @@ async function onSaveWorkflowClick(): Promise<void> {
& > div:nth-child(1) { & > div:nth-child(1) {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
padding: var(--spacing-xs); justify-content: space-between;
padding-block: var(--spacing-2xs);
padding-inline: var(--spacing-s);
width: 100%; width: 100%;
user-select: none; user-select: none;
color: var(--color-text-base) !important; color: var(--color-text-base) !important;
@@ -243,7 +245,7 @@ async function onSaveWorkflowClick(): Promise<void> {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
padding: 0 var(--spacing-l) var(--spacing-s) !important; padding: 0 var(--spacing-s) var(--spacing-2xs) !important;
span { span {
width: 100%; width: 100%;

View File

@@ -23,7 +23,11 @@ import { NodeConnectionTypes, TelemetryHelpers } from 'n8n-workflow';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { CHAT_TRIGGER_NODE_TYPE, SINGLE_WEBHOOK_TRIGGERS } from '@/constants'; import {
CHAT_TRIGGER_NODE_TYPE,
IN_PROGRESS_EXECUTION_ID,
SINGLE_WEBHOOK_TRIGGERS,
} from '@/constants';
import { useRootStore } from '@/stores/root.store'; import { useRootStore } from '@/stores/root.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
@@ -292,7 +296,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
// that data which gets reused is already set and data of newly executed // that data which gets reused is already set and data of newly executed
// nodes can be added as it gets pushed in // nodes can be added as it gets pushed in
const executionData: IExecutionResponse = { const executionData: IExecutionResponse = {
id: '__IN_PROGRESS__', id: IN_PROGRESS_EXECUTION_ID,
finished: false, finished: false,
mode: 'manual', mode: 'manual',
status: 'running', status: 'running',

View File

@@ -21,6 +21,8 @@ export const MAX_DISPLAY_ITEMS_AUTO_ALL = 250;
export const PLACEHOLDER_FILLED_AT_EXECUTION_TIME = '[filled at execution time]'; export const PLACEHOLDER_FILLED_AT_EXECUTION_TIME = '[filled at execution time]';
export const IN_PROGRESS_EXECUTION_ID = '__IN_PROGRESS__';
// parameter input // parameter input
export const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__'; export const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
export const CUSTOM_API_CALL_NAME = 'Custom API Call'; export const CUSTOM_API_CALL_NAME = 'Custom API Call';
@@ -474,6 +476,7 @@ export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE'; export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE'; export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
export const LOCAL_STORAGE_LOGS_2025_SPRING = 'N8N_LOGS_2025_SPRING'; export const LOCAL_STORAGE_LOGS_2025_SPRING = 'N8N_LOGS_2025_SPRING';
export const LOCAL_STORAGE_LOGS_PANEL_OPEN = 'N8N_LOGS_PANEL_OPEN';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename='; export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
export const COMMUNITY_PLUS_DOCS_URL = export const COMMUNITY_PLUS_DOCS_URL =
'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition'; 'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition';

View File

@@ -28,6 +28,8 @@ const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordV
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue'); const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
const MainSidebar = async () => await import('@/components/MainSidebar.vue'); const MainSidebar = async () => await import('@/components/MainSidebar.vue');
const CanvasChatSwitch = async () => await import('@/components/CanvasChat/CanvasChatSwitch.vue'); const CanvasChatSwitch = async () => await import('@/components/CanvasChat/CanvasChatSwitch.vue');
const DemoFooter = async () =>
await import('@/components/CanvasChat/future/components/DemoFooter.vue');
const NodeView = async () => await import('@/views/NodeView.vue'); const NodeView = async () => await import('@/views/NodeView.vue');
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue'); const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
const WorkflowExecutionsLandingPage = async () => const WorkflowExecutionsLandingPage = async () =>
@@ -372,6 +374,7 @@ export const routes: RouteRecordRaw[] = [
name: VIEWS.DEMO, name: VIEWS.DEMO,
components: { components: {
default: NodeView, default: NodeView,
footer: DemoFooter,
}, },
meta: { meta: {
middleware: ['authenticated'], middleware: ['authenticated'],

View File

@@ -6,6 +6,7 @@ import {
DUPLICATE_POSTFFIX, DUPLICATE_POSTFFIX,
ERROR_TRIGGER_NODE_TYPE, ERROR_TRIGGER_NODE_TYPE,
FORM_NODE_TYPE, FORM_NODE_TYPE,
LOCAL_STORAGE_LOGS_PANEL_OPEN,
MAX_WORKFLOW_NAME_LENGTH, MAX_WORKFLOW_NAME_LENGTH,
PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_EMPTY_WORKFLOW_ID,
START_NODE_TYPE, START_NODE_TYPE,
@@ -92,6 +93,7 @@ import { useUsersStore } from '@/stores/users.store';
import { updateCurrentUserSettings } from '@/api/users'; import { updateCurrentUserSettings } from '@/api/users';
import { useExecutingNode } from '@/composables/useExecutingNode'; import { useExecutingNode } from '@/composables/useExecutingNode';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs'; import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
import { useLocalStorage } from '@vueuse/core';
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = { const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
name: '', name: '',
@@ -147,7 +149,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const isInDebugMode = ref(false); const isInDebugMode = ref(false);
const chatMessages = ref<string[]>([]); const chatMessages = ref<string[]>([]);
const chatPartialExecutionDestinationNode = ref<string | null>(null); const chatPartialExecutionDestinationNode = ref<string | null>(null);
const isLogsPanelOpen = ref(false); const isLogsPanelOpen = useLocalStorage(LOCAL_STORAGE_LOGS_PANEL_OPEN, false);
const preferPopOutLogsView = ref(false); const preferPopOutLogsView = ref(false);
const logsPanelState = computed(() => const logsPanelState = computed(() =>
isLogsPanelOpen.value isLogsPanelOpen.value