mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(editor): Show logs panel in execution history page (#14477)
This commit is contained in:
@@ -18,7 +18,6 @@ const message: ChatMessage = {
|
||||
id: 'typing',
|
||||
text: '',
|
||||
sender: 'bot',
|
||||
createdAt: '',
|
||||
};
|
||||
const messageContainer = ref<InstanceType<typeof Message>>();
|
||||
const classes = computed(() => {
|
||||
|
||||
@@ -21,7 +21,6 @@ export const ChatPlugin: Plugin<ChatOptions> = {
|
||||
id: uuidv4(),
|
||||
text,
|
||||
sender: 'bot',
|
||||
createdAt: new Date().toISOString(),
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -31,7 +30,6 @@ export const ChatPlugin: Plugin<ChatOptions> = {
|
||||
text,
|
||||
sender: 'user',
|
||||
files,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
messages.value.push(sentMessage);
|
||||
@@ -62,7 +60,6 @@ export const ChatPlugin: Plugin<ChatOptions> = {
|
||||
id: uuidv4(),
|
||||
text: textMessage,
|
||||
sender: 'bot',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
messages.value.push(receivedMessage);
|
||||
|
||||
@@ -80,13 +77,11 @@ export const ChatPlugin: Plugin<ChatOptions> = {
|
||||
|
||||
const sessionId = localStorage.getItem(localStorageSessionIdKey) ?? uuidv4();
|
||||
const previousMessagesResponse = await api.loadPreviousSession(sessionId, options);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
messages.value = (previousMessagesResponse?.data || []).map((message, index) => ({
|
||||
id: `${index}`,
|
||||
text: message.kwargs.content,
|
||||
sender: message.id.includes('HumanMessage') ? 'user' : 'bot',
|
||||
createdAt: timestamp,
|
||||
}));
|
||||
|
||||
if (messages.value.length) {
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface ChatMessageText extends ChatMessageBase {
|
||||
|
||||
interface ChatMessageBase {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
transparent?: boolean;
|
||||
sender: 'user' | 'bot';
|
||||
files?: File[];
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
LoadedClass,
|
||||
INodeTypeDescription,
|
||||
INodeIssues,
|
||||
ITaskData,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeHelpers, Workflow } from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -203,3 +204,15 @@ export function createTestNode(node: Partial<INode> = {}): INode {
|
||||
...node,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestTaskData(partialData: Partial<ITaskData>): ITaskData {
|
||||
return {
|
||||
startTime: 0,
|
||||
executionTime: 1,
|
||||
executionIndex: 0,
|
||||
source: [],
|
||||
executionStatus: 'success',
|
||||
data: { main: [[{ json: {} }]] },
|
||||
...partialData,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,7 +311,6 @@ describe('CanvasChat', () => {
|
||||
id: '1',
|
||||
text: 'Existing message',
|
||||
sender: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -321,7 +320,6 @@ describe('CanvasChat', () => {
|
||||
|
||||
return {
|
||||
sendMessage: vi.fn(),
|
||||
extractResponseMessage: vi.fn(),
|
||||
previousMessageIndex: ref(0),
|
||||
isLoading: computed(() => false),
|
||||
};
|
||||
@@ -386,7 +384,6 @@ describe('CanvasChat', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||
sendMessage: vi.fn(),
|
||||
extractResponseMessage: vi.fn(),
|
||||
previousMessageIndex: ref(0),
|
||||
isLoading: computed(() => false),
|
||||
});
|
||||
@@ -472,13 +469,11 @@ describe('CanvasChat', () => {
|
||||
id: '1',
|
||||
text: 'Original message',
|
||||
sender: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
text: 'AI response',
|
||||
sender: 'bot',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation(({ messages }) => {
|
||||
@@ -486,7 +481,6 @@ describe('CanvasChat', () => {
|
||||
|
||||
return {
|
||||
sendMessage: sendMessageSpy,
|
||||
extractResponseMessage: vi.fn(),
|
||||
previousMessageIndex: ref(0),
|
||||
isLoading: computed(() => false),
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ const workflowsStore = useWorkflowsStore();
|
||||
const canvasStore = useCanvasStore();
|
||||
|
||||
// Component state
|
||||
const isDisabled = ref(false);
|
||||
const container = ref<HTMLElement>();
|
||||
const pipContainer = useTemplateRef('pipContainer');
|
||||
const pipContent = useTemplateRef('pipContent');
|
||||
@@ -69,13 +68,12 @@ const {
|
||||
sendMessage,
|
||||
refreshSession,
|
||||
displayExecution,
|
||||
} = useChatState(isDisabled, onWindowResize);
|
||||
} = useChatState(false, onWindowResize);
|
||||
|
||||
// Expose internal state for testing
|
||||
defineExpose({
|
||||
messages,
|
||||
currentSessionId,
|
||||
isDisabled,
|
||||
workflow,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@ interface Props {
|
||||
sessionId: string;
|
||||
showCloseButton?: boolean;
|
||||
isOpen?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isNewLogsEnabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isOpen: true,
|
||||
isReadOnly: false,
|
||||
isNewLogsEnabled: false,
|
||||
});
|
||||
|
||||
@@ -145,7 +147,7 @@ async function copySessionId() {
|
||||
@click="emit('clickHeader')"
|
||||
>
|
||||
<template #actions>
|
||||
<N8nTooltip v-if="clipboard.isSupported.value">
|
||||
<N8nTooltip v-if="clipboard.isSupported.value && !isReadOnly">
|
||||
<template #content>
|
||||
{{ sessionId }}
|
||||
<br />
|
||||
@@ -160,7 +162,7 @@ async function copySessionId() {
|
||||
>
|
||||
</N8nTooltip>
|
||||
<N8nTooltip
|
||||
v-if="messages.length > 0"
|
||||
v-if="messages.length > 0 && !isReadOnly"
|
||||
:content="locale.baseText('chat.window.session.resetSession')"
|
||||
>
|
||||
<N8nIconButton
|
||||
@@ -223,7 +225,7 @@ async function copySessionId() {
|
||||
>
|
||||
<template #beforeMessage="{ message }">
|
||||
<MessageOptionTooltip
|
||||
v-if="message.sender === 'bot' && !message.id.includes('preload')"
|
||||
v-if="!isReadOnly && message.sender === 'bot' && !message.id.includes('preload')"
|
||||
placement="right"
|
||||
data-test-id="execution-id-tooltip"
|
||||
>
|
||||
@@ -232,7 +234,7 @@ async function copySessionId() {
|
||||
</MessageOptionTooltip>
|
||||
|
||||
<MessageOptionAction
|
||||
v-if="isTextMessage(message) && message.sender === 'user'"
|
||||
v-if="!isReadOnly && isTextMessage(message) && message.sender === 'user'"
|
||||
data-test-id="repost-message-button"
|
||||
icon="redo"
|
||||
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
|
||||
@@ -241,7 +243,7 @@ async function copySessionId() {
|
||||
/>
|
||||
|
||||
<MessageOptionAction
|
||||
v-if="isTextMessage(message) && message.sender === 'user'"
|
||||
v-if="!isReadOnly && isTextMessage(message) && message.sender === 'user'"
|
||||
data-test-id="reuse-message-button"
|
||||
icon="copy"
|
||||
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ComputedRef, Ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { ChatMessage } from '@n8n/chat/types';
|
||||
import { CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||
import type {
|
||||
ITaskData,
|
||||
INodeExecutionData,
|
||||
@@ -15,10 +14,10 @@ import type {
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { usePinnedData } from '@/composables/usePinnedData';
|
||||
import { get, isEmpty } from 'lodash-es';
|
||||
import { MANUAL_CHAT_TRIGGER_NODE_TYPE, MODAL_CONFIRM } from '@/constants';
|
||||
import { MODAL_CONFIRM } from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { IExecutionPushResponse, INodeUi } from '@/Interface';
|
||||
import { extractBotResponse, getInputKey } from '@/components/CanvasChat/utils';
|
||||
|
||||
export type RunWorkflowChatPayload = {
|
||||
triggerNode: string;
|
||||
@@ -106,13 +105,7 @@ export function useChatMessaging({
|
||||
return;
|
||||
}
|
||||
|
||||
let inputKey = 'chatInput';
|
||||
if (triggerNode.type === MANUAL_CHAT_TRIGGER_NODE_TYPE && triggerNode.typeVersion < 1.1) {
|
||||
inputKey = 'input';
|
||||
}
|
||||
if (triggerNode.type === CHAT_TRIGGER_NODE_TYPE) {
|
||||
inputKey = 'chatInput';
|
||||
}
|
||||
const inputKey = getInputKey(triggerNode);
|
||||
|
||||
const inputPayload: INodeExecutionData = {
|
||||
json: {
|
||||
@@ -151,53 +144,17 @@ export function useChatMessaging({
|
||||
return;
|
||||
}
|
||||
|
||||
processExecutionResultData(response.executionId);
|
||||
const chatMessage = executionResultData.value
|
||||
? extractBotResponse(
|
||||
executionResultData.value,
|
||||
response.executionId,
|
||||
locale.baseText('chat.window.chat.response.empty'),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (chatMessage !== undefined) {
|
||||
messages.value.push(chatMessage);
|
||||
}
|
||||
|
||||
function processExecutionResultData(executionId: string) {
|
||||
const lastNodeExecuted = executionResultData.value?.lastNodeExecuted;
|
||||
|
||||
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 */
|
||||
@@ -230,7 +187,6 @@ export function useChatMessaging({
|
||||
const newMessage: ChatMessage & { sessionId: string } = {
|
||||
text: message,
|
||||
sender: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
sessionId: sessionId.value,
|
||||
id: uuid(),
|
||||
files,
|
||||
@@ -244,6 +200,5 @@ export function useChatMessaging({
|
||||
previousMessageIndex,
|
||||
isLoading: computed(() => isLoading.value),
|
||||
sendMessage,
|
||||
extractResponseMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { Ref } from 'vue';
|
||||
import { computed, provide, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { LOGS_PANEL_STATE } from '../types/logs';
|
||||
import { restoreChatHistory } from '@/components/CanvasChat/utils';
|
||||
|
||||
interface ChatState {
|
||||
currentSessionId: Ref<string>;
|
||||
@@ -29,7 +30,8 @@ interface ChatState {
|
||||
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 nodeTypesStore = useNodeTypesStore();
|
||||
const canvasStore = useCanvasStore();
|
||||
@@ -114,11 +116,18 @@ export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => voi
|
||||
sendMessage,
|
||||
currentSessionId,
|
||||
isLoading,
|
||||
isDisabled,
|
||||
isDisabled: computed(() => isReadOnly),
|
||||
allowFileUploads,
|
||||
locale: useI18n(),
|
||||
locale,
|
||||
});
|
||||
|
||||
const restoredChatMessages = computed(() =>
|
||||
restoreChatHistory(
|
||||
workflowsStore.workflowExecutionData,
|
||||
locale.baseText('chat.window.chat.response.empty'),
|
||||
),
|
||||
);
|
||||
|
||||
// Provide chat context
|
||||
provide(ChatSymbol, chatConfig);
|
||||
provide(ChatOptionsSymbol, chatOptions);
|
||||
@@ -210,7 +219,7 @@ export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => voi
|
||||
|
||||
return {
|
||||
currentSessionId,
|
||||
messages,
|
||||
messages: computed(() => (isReadOnly ? restoredChatMessages.value : messages.value)),
|
||||
chatTriggerNode,
|
||||
connectedNode,
|
||||
sendMessage,
|
||||
|
||||
@@ -11,10 +11,9 @@ import {
|
||||
AI_CATEGORY_CHAINS,
|
||||
AI_CODE_NODE_TYPE,
|
||||
AI_SUBCATEGORY,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { isChatNode } from '@/components/CanvasChat/utils';
|
||||
|
||||
export interface ChatTriggerDependencies {
|
||||
getNodeByName: (name: string) => INodeUi | null;
|
||||
@@ -52,9 +51,7 @@ export function useChatTrigger({
|
||||
|
||||
/** Gets the chat trigger node from the workflow */
|
||||
function setChatTriggerNode() {
|
||||
const triggerNode = unref(canvasNodes).find((node) =>
|
||||
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
|
||||
);
|
||||
const triggerNode = unref(canvasNodes).find(isChatNode);
|
||||
|
||||
if (!triggerNode) {
|
||||
return;
|
||||
|
||||
@@ -39,7 +39,11 @@ export function usePiPWindow({
|
||||
}: UsePiPWindowOptions): UsePiPWindowReturn {
|
||||
const pipWindow = ref<Window>();
|
||||
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 tooltipContainer = computed(() =>
|
||||
isPoppedOut.value ? (content.value ?? undefined) : undefined,
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('LogsPanel', () => {
|
||||
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
workflowsStore.setWorkflowExecutionData(null);
|
||||
workflowsStore.toggleLogsPanelOpen(false);
|
||||
|
||||
nodeTypeStore = mockedStore(useNodeTypesStore);
|
||||
nodeTypeStore.setNodeTypes(nodeTypes);
|
||||
|
||||
@@ -6,37 +6,66 @@ import { useChatState } from '@/components/CanvasChat/composables/useChatState';
|
||||
import { useResize } from '@/components/CanvasChat/composables/useResize';
|
||||
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
|
||||
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 { useCanvasStore } from '@/stores/canvas.store';
|
||||
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.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 {
|
||||
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 canvasStore = useCanvasStore();
|
||||
const panelState = computed(() => workflowsStore.logsPanelState);
|
||||
const container = ref<HTMLElement>();
|
||||
const selectedLogEntry = ref<LogEntryIdentity | undefined>(undefined);
|
||||
const pipContainer = useTemplateRef('pipContainer');
|
||||
const pipContent = useTemplateRef('pipContent');
|
||||
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 { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResizeChatDebounced } =
|
||||
useResize(container);
|
||||
|
||||
const { currentSessionId, messages, sendMessage, refreshSession, displayExecution } = useChatState(
|
||||
ref(false),
|
||||
props.isReadOnly,
|
||||
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 { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
|
||||
@@ -76,8 +105,11 @@ function handleClickHeader() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectLogEntry(selected: LogEntryIdentity | undefined) {
|
||||
selectedLogEntry.value = selected;
|
||||
function handleSelectLogEntry(selected: TreeNode | undefined) {
|
||||
manualLogEntrySelection.value =
|
||||
selected === undefined
|
||||
? { type: 'none', workflowId: workflowsStore.workflow.id }
|
||||
: { type: 'selected', workflowId: workflowsStore.workflow.id, data: selected };
|
||||
}
|
||||
|
||||
function onPopOut() {
|
||||
@@ -121,6 +153,7 @@ watch([panelState, height], ([state, h]) => {
|
||||
<ChatMessagesPanel
|
||||
data-test-id="canvas-chat"
|
||||
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
||||
:is-read-only="isReadOnly"
|
||||
:messages="messages"
|
||||
:session-id="currentSessionId"
|
||||
:past-chat-messages="previousChatMessages"
|
||||
@@ -136,7 +169,9 @@ watch([panelState, height], ([state, h]) => {
|
||||
<LogsOverviewPanel
|
||||
:class="$style.logsOverview"
|
||||
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
||||
:is-read-only="isReadOnly"
|
||||
:selected="selectedLogEntry"
|
||||
:execution-tree="executionTree"
|
||||
@click-header="handleClickHeader"
|
||||
@select="handleSelectLogEntry"
|
||||
>
|
||||
@@ -145,7 +180,7 @@ watch([panelState, height], ([state, h]) => {
|
||||
</template>
|
||||
</LogsOverviewPanel>
|
||||
<LogsDetailsPanel
|
||||
v-if="selectedLogEntry"
|
||||
v-if="selectedLogEntry !== undefined"
|
||||
:class="$style.logDetails"
|
||||
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
||||
@click-header="handleClickHeader"
|
||||
|
||||
@@ -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>
|
||||
@@ -5,10 +5,9 @@ import { createTestingPinia, type TestingPinia } from '@pinia/testing';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
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 {
|
||||
aiAgentNode,
|
||||
aiChatExecutionResponse,
|
||||
aiChatWorkflow,
|
||||
aiManualExecutionResponse,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
} from '../../__test__/data';
|
||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { createLogEntries } from '@/components/RunDataAi/utils';
|
||||
|
||||
describe('LogsOverviewPanel', () => {
|
||||
let pinia: TestingPinia;
|
||||
@@ -23,9 +23,19 @@ describe('LogsOverviewPanel', () => {
|
||||
let pushConnectionStore: ReturnType<typeof mockedStore<typeof usePushConnectionStore>>;
|
||||
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, {
|
||||
props,
|
||||
props: mergedProps,
|
||||
global: {
|
||||
plugins: [
|
||||
createRouter({
|
||||
@@ -54,13 +64,13 @@ describe('LogsOverviewPanel', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -68,7 +78,7 @@ describe('LogsOverviewPanel', () => {
|
||||
it('should render summary text and executed nodes if there is an execution', async () => {
|
||||
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
||||
|
||||
const rendered = render({ isOpen: true, node: aiAgentNode });
|
||||
const rendered = render({ isOpen: true });
|
||||
const summary = within(rendered.container.querySelector('.summary')!);
|
||||
|
||||
expect(summary.queryByText('Success in 1.999s')).toBeInTheDocument();
|
||||
@@ -101,7 +111,7 @@ describe('LogsOverviewPanel', () => {
|
||||
it('should open NDV if the button is clicked', async () => {
|
||||
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
||||
|
||||
const rendered = render({ isOpen: true, node: aiAgentNode });
|
||||
const rendered = render({ isOpen: true });
|
||||
const aiAgentRow = rendered.getAllByRole('treeitem')[0];
|
||||
|
||||
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Open...')[0]);
|
||||
@@ -117,7 +127,7 @@ describe('LogsOverviewPanel', () => {
|
||||
|
||||
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
||||
|
||||
const rendered = render({ isOpen: true, node: aiAgentNode });
|
||||
const rendered = render({ isOpen: true });
|
||||
const aiAgentRow = rendered.getAllByRole('treeitem')[0];
|
||||
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Test step')[0]);
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-sys
|
||||
import { computed } from 'vue';
|
||||
import { ElTree, type TreeNode as ElTreeNode } from 'element-plus';
|
||||
import {
|
||||
createLogEntries,
|
||||
getSubtreeTotalConsumedTokens,
|
||||
getTotalConsumedTokens,
|
||||
type TreeNode,
|
||||
@@ -16,18 +15,18 @@ import {
|
||||
import { upperFirst } from 'lodash-es';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
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 { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const { isOpen, selected } = defineProps<{
|
||||
const { isOpen, isReadOnly, selected, executionTree } = defineProps<{
|
||||
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: {} }>();
|
||||
|
||||
@@ -40,12 +39,6 @@ const ndvStore = useNDVStore();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
|
||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||
const executionTree = computed<TreeNode[]>(() =>
|
||||
createLogEntries(
|
||||
workflow.value,
|
||||
workflowsStore.workflowExecutionData?.data?.resultData.runData ?? {},
|
||||
),
|
||||
);
|
||||
const isEmpty = computed(() => workflowsStore.workflowExecutionData === null);
|
||||
const switchViewOptions = computed(() => [
|
||||
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
|
||||
@@ -73,7 +66,7 @@ const executionStatusText = computed(() => {
|
||||
return upperFirst(execution.status);
|
||||
});
|
||||
const consumedTokens = computed(() =>
|
||||
getTotalConsumedTokens(...executionTree.value.map(getSubtreeTotalConsumedTokens)),
|
||||
getTotalConsumedTokens(...executionTree.map(getSubtreeTotalConsumedTokens)),
|
||||
);
|
||||
|
||||
function onClearExecutionData() {
|
||||
@@ -82,12 +75,12 @@ function onClearExecutionData() {
|
||||
}
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
emit('select', { node: clicked.node, runIndex: clicked.runIndex });
|
||||
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,
|
||||
@@ -97,10 +90,7 @@ function handleClickNode(clicked: TreeNode) {
|
||||
}
|
||||
|
||||
function handleSwitchView(value: 'overview' | 'details') {
|
||||
emit(
|
||||
'select',
|
||||
value === 'overview' || executionTree.value.length === 0 ? undefined : executionTree.value[0],
|
||||
);
|
||||
emit('select', value === 'overview' || executionTree.length === 0 ? undefined : executionTree[0]);
|
||||
}
|
||||
|
||||
function handleToggleExpanded(treeNode: ElTreeNode) {
|
||||
@@ -183,6 +173,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
|
||||
<LogsOverviewRow
|
||||
:data="data"
|
||||
:node="elTreeNode"
|
||||
:is-read-only="isReadOnly"
|
||||
:is-selected="data.node === selected?.node && data.runIndex === selected?.runIndex"
|
||||
:is-compact="selected !== undefined"
|
||||
:should-show-consumed-tokens="consumedTokens.totalTokens > 0"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { type TreeNode as ElTreeNode } from 'element-plus';
|
||||
import { getSubtreeTotalConsumedTokens, type TreeNode } from '@/components/RunDataAi/utils';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { computed } from 'vue';
|
||||
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';
|
||||
@@ -17,6 +17,7 @@ const props = defineProps<{
|
||||
data: TreeNode;
|
||||
node: ElTreeNode;
|
||||
isSelected: boolean;
|
||||
isReadOnly: boolean;
|
||||
shouldShowConsumedTokens: boolean;
|
||||
isCompact: boolean;
|
||||
}>();
|
||||
@@ -28,6 +29,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypeStore = useNodeTypesStore();
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
// When selected, scroll into view
|
||||
watch(
|
||||
[() => props.isSelected, containerRef],
|
||||
([isSelected, ref]) => {
|
||||
if (isSelected && ref) {
|
||||
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="node !== undefined"
|
||||
ref="containerRef"
|
||||
:class="{
|
||||
[$style.container]: true,
|
||||
[$style.compact]: props.isCompact,
|
||||
@@ -106,8 +120,8 @@ function isLastChild(level: number) {
|
||||
size="small"
|
||||
:class="$style.name"
|
||||
:color="isError ? 'danger' : undefined"
|
||||
>{{ node.name }}</N8nText
|
||||
>
|
||||
>{{ node.name }}
|
||||
</N8nText>
|
||||
<N8nText tag="div" color="text-light" size="small" :class="$style.timeTook">
|
||||
<I18nT v-if="isSettled && runData" keypath="logs.overview.body.summaryText">
|
||||
<template #status>
|
||||
@@ -148,6 +162,7 @@ function isLastChild(level: number) {
|
||||
:class="$style.compactErrorIcon"
|
||||
/>
|
||||
<N8nIconButton
|
||||
v-if="!props.isReadOnly"
|
||||
type="secondary"
|
||||
size="small"
|
||||
icon="play"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export interface LogEntryIdentity {
|
||||
node: string;
|
||||
runIndex: number;
|
||||
}
|
||||
import { type TreeNode } from '@/components/RunDataAi/utils';
|
||||
|
||||
export type LogEntrySelection =
|
||||
| { type: 'initial' }
|
||||
| { type: 'selected'; workflowId: string; data: TreeNode }
|
||||
| { type: 'none'; workflowId: string };
|
||||
|
||||
export const LOGS_PANEL_STATE = {
|
||||
CLOSED: 'closed',
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
122
packages/frontend/editor-ui/src/components/CanvasChat/utils.ts
Normal file
122
packages/frontend/editor-ui/src/components/CanvasChat/utils.ts
Normal 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] : [])];
|
||||
}
|
||||
@@ -1,16 +1,28 @@
|
||||
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
|
||||
import { createAiData, createLogEntries, getTreeNodeData } from '@/components/RunDataAi/utils';
|
||||
import { type ITaskData, NodeConnectionTypes } from 'n8n-workflow';
|
||||
import { createTestNode, createTestTaskData, createTestWorkflowObject } from '@/__tests__/mocks';
|
||||
import {
|
||||
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 {
|
||||
node: 'test node',
|
||||
runIndex: 0,
|
||||
id: String(Math.random()),
|
||||
children: [],
|
||||
consumedTokens: { completionTokens: 0, totalTokens: 0, promptTokens: 0, isEstimate: false },
|
||||
depth: 0,
|
||||
startTime: 0,
|
||||
executionIndex: 0,
|
||||
executionTime: 1,
|
||||
source: [],
|
||||
executionStatus: 'success',
|
||||
data: { main: [[{ json: {} }]] },
|
||||
...partialData,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,9 +42,9 @@ describe(getTreeNodeData, () => {
|
||||
},
|
||||
});
|
||||
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: [
|
||||
createTaskData({
|
||||
createTestTaskData({
|
||||
startTime: Date.parse('2025-02-26T00:00:01.000Z'),
|
||||
data: {
|
||||
main: [
|
||||
@@ -50,7 +62,7 @@ describe(getTreeNodeData, () => {
|
||||
],
|
||||
},
|
||||
}),
|
||||
createTaskData({
|
||||
createTestTaskData({
|
||||
startTime: Date.parse('2025-02-26T00:00:03.000Z'),
|
||||
data: {
|
||||
main: [
|
||||
@@ -70,7 +82,7 @@ describe(getTreeNodeData, () => {
|
||||
}),
|
||||
],
|
||||
C: [
|
||||
createTaskData({
|
||||
createTestTaskData({
|
||||
startTime: Date.parse('2025-02-26T00:00:02.000Z'),
|
||||
data: {
|
||||
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, () => {
|
||||
it('should return root node log entries in ascending order of executionIndex', () => {
|
||||
const workflow = createTestWorkflowObject({
|
||||
@@ -198,14 +347,26 @@ describe(createLogEntries, () => {
|
||||
expect(
|
||||
createLogEntries(workflow, {
|
||||
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: [
|
||||
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: [
|
||||
createTaskData({ startTime: Date.parse('2025-04-04T00:00:02.000Z'), executionIndex: 3 }),
|
||||
createTaskData({ startTime: Date.parse('2025-04-04T00:00:03.000Z'), executionIndex: 2 }),
|
||||
createTestTaskData({
|
||||
startTime: Date.parse('2025-04-04T00:00:02.000Z'),
|
||||
executionIndex: 3,
|
||||
}),
|
||||
createTestTaskData({
|
||||
startTime: Date.parse('2025-04-04T00:00:03.000Z'),
|
||||
executionIndex: 2,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
@@ -236,14 +397,26 @@ describe(createLogEntries, () => {
|
||||
expect(
|
||||
createLogEntries(workflow, {
|
||||
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: [
|
||||
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: [
|
||||
createTaskData({ startTime: Date.parse('2025-04-04T00:00:02.000Z'), executionIndex: 3 }),
|
||||
createTaskData({ startTime: Date.parse('2025-04-04T00:00:03.000Z'), executionIndex: 2 }),
|
||||
createTestTaskData({
|
||||
startTime: Date.parse('2025-04-04T00:00:02.000Z'),
|
||||
executionIndex: 3,
|
||||
}),
|
||||
createTestTaskData({
|
||||
startTime: Date.parse('2025-04-04T00:00:03.000Z'),
|
||||
executionIndex: 2,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type LlmTokenUsageData, type IAiDataContent } from '@/Interface';
|
||||
import { type LlmTokenUsageData, type IAiDataContent, type INodeUi } from '@/Interface';
|
||||
import {
|
||||
AGENT_LANGCHAIN_NODE_TYPE,
|
||||
type IRunData,
|
||||
type INodeExecutionData,
|
||||
type ITaskData,
|
||||
@@ -226,6 +227,46 @@ 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);
|
||||
}
|
||||
|
||||
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) {
|
||||
const runs = Object.entries(runData)
|
||||
.filter(([nodeName]) => workflow.getChildNodes(nodeName, 'ALL_NON_MAIN').length === 0)
|
||||
|
||||
@@ -232,7 +232,9 @@ async function onSaveWorkflowClick(): Promise<void> {
|
||||
& > div:nth-child(1) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: var(--spacing-xs);
|
||||
justify-content: space-between;
|
||||
padding-block: var(--spacing-2xs);
|
||||
padding-inline: var(--spacing-s);
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
color: var(--color-text-base) !important;
|
||||
@@ -243,7 +245,7 @@ async function onSaveWorkflowClick(): Promise<void> {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 0 var(--spacing-l) var(--spacing-s) !important;
|
||||
padding: 0 var(--spacing-s) var(--spacing-2xs) !important;
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
|
||||
@@ -23,7 +23,11 @@ import { NodeConnectionTypes, TelemetryHelpers } from 'n8n-workflow';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
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 { 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
|
||||
// nodes can be added as it gets pushed in
|
||||
const executionData: IExecutionResponse = {
|
||||
id: '__IN_PROGRESS__',
|
||||
id: IN_PROGRESS_EXECUTION_ID,
|
||||
finished: false,
|
||||
mode: 'manual',
|
||||
status: 'running',
|
||||
|
||||
@@ -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 IN_PROGRESS_EXECUTION_ID = '__IN_PROGRESS__';
|
||||
|
||||
// parameter input
|
||||
export const CUSTOM_API_CALL_KEY = '__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_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_PANEL_OPEN = 'N8N_LOGS_PANEL_OPEN';
|
||||
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||
export const COMMUNITY_PLUS_DOCS_URL =
|
||||
'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition';
|
||||
|
||||
@@ -28,6 +28,8 @@ const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordV
|
||||
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
||||
const MainSidebar = async () => await import('@/components/MainSidebar.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 WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
||||
const WorkflowExecutionsLandingPage = async () =>
|
||||
@@ -372,6 +374,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
name: VIEWS.DEMO,
|
||||
components: {
|
||||
default: NodeView,
|
||||
footer: DemoFooter,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated'],
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DUPLICATE_POSTFFIX,
|
||||
ERROR_TRIGGER_NODE_TYPE,
|
||||
FORM_NODE_TYPE,
|
||||
LOCAL_STORAGE_LOGS_PANEL_OPEN,
|
||||
MAX_WORKFLOW_NAME_LENGTH,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
START_NODE_TYPE,
|
||||
@@ -92,6 +93,7 @@ import { useUsersStore } from '@/stores/users.store';
|
||||
import { updateCurrentUserSettings } from '@/api/users';
|
||||
import { useExecutingNode } from '@/composables/useExecutingNode';
|
||||
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
|
||||
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
||||
name: '',
|
||||
@@ -147,7 +149,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
const isInDebugMode = ref(false);
|
||||
const chatMessages = ref<string[]>([]);
|
||||
const chatPartialExecutionDestinationNode = ref<string | null>(null);
|
||||
const isLogsPanelOpen = ref(false);
|
||||
const isLogsPanelOpen = useLocalStorage(LOCAL_STORAGE_LOGS_PANEL_OPEN, false);
|
||||
const preferPopOutLogsView = ref(false);
|
||||
const logsPanelState = computed(() =>
|
||||
isLogsPanelOpen.value
|
||||
|
||||
Reference in New Issue
Block a user