feat(editor): Show error state in the logs overview (#14248)

This commit is contained in:
Suguru Inoue
2025-04-02 09:21:21 +02:00
committed by GitHub
parent 0e9e28356e
commit 37e5349fe1
23 changed files with 583 additions and 276 deletions

View File

@@ -34,7 +34,7 @@ watch(
v-if="emptyText && initialMessages.length === 0 && messages.length === 0"
class="empty-container"
>
<div class="empty">
<div class="empty" data-test-id="chat-messages-empty">
<N8nIcon icon="comment" size="large" class="emptyIcon" />
<N8nText tag="p" size="medium" color="text-base">
{{ emptyText }}

View File

@@ -58,6 +58,7 @@ export const mockNodeTypeDescription = ({
outputs = [NodeConnectionTypes.Main],
codex = undefined,
properties = [],
group,
}: {
name?: INodeTypeDescription['name'];
icon?: INodeTypeDescription['icon'];
@@ -67,6 +68,7 @@ export const mockNodeTypeDescription = ({
outputs?: INodeTypeDescription['outputs'];
codex?: INodeTypeDescription['codex'];
properties?: INodeTypeDescription['properties'];
group?: INodeTypeDescription['group'];
} = {}) =>
mock<INodeTypeDescription>({
name,
@@ -80,7 +82,7 @@ export const mockNodeTypeDescription = ({
defaultVersion: Array.isArray(version) ? version[version.length - 1] : version,
properties: properties as [],
maxNodes: Infinity,
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
group: (group ?? EXECUTABLE_TRIGGER_NODE_TYPES.includes(name)) ? ['trigger'] : [],
inputs,
outputs,
codex,

View File

@@ -24,6 +24,7 @@ import { useToast } from '@/composables/useToast';
import type { IExecutionResponse, INodeUi } from '@/Interface';
import type { ChatMessage } from '@n8n/chat/types';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { LOGS_PANEL_STATE } from './types/logs';
vi.mock('@/composables/useToast', () => {
const showMessage = vi.fn();
@@ -174,7 +175,7 @@ describe('CanvasChat', () => {
return matchedNode;
});
workflowsStore.chatPanelState = 'attached';
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED;
workflowsStore.isLogsPanelOpen = true;
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
@@ -197,7 +198,7 @@ describe('CanvasChat', () => {
});
it('should not render chat when panel is closed', async () => {
workflowsStore.chatPanelState = 'closed';
workflowsStore.chatPanelState = LOGS_PANEL_STATE.CLOSED;
const { queryByTestId } = renderComponent();
await waitFor(() => {
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
@@ -387,7 +388,7 @@ describe('CanvasChat', () => {
isLoading: computed(() => false),
});
workflowsStore.chatPanelState = 'attached';
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED;
workflowsStore.allowFileUploads = true;
});
@@ -549,7 +550,7 @@ describe('CanvasChat', () => {
});
// Close chat panel
workflowsStore.chatPanelState = 'closed';
workflowsStore.chatPanelState = LOGS_PANEL_STATE.CLOSED;
await waitFor(() => {
expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0);
});
@@ -559,14 +560,14 @@ describe('CanvasChat', () => {
const { unmount, rerender } = renderComponent();
// Set initial state
workflowsStore.chatPanelState = 'attached';
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED;
workflowsStore.isLogsPanelOpen = true;
// Unmount and remount
unmount();
await rerender({});
expect(workflowsStore.chatPanelState).toBe('attached');
expect(workflowsStore.chatPanelState).toBe(LOGS_PANEL_STATE.ATTACHED);
expect(workflowsStore.isLogsPanelOpen).toBe(true);
});
});
@@ -592,10 +593,10 @@ describe('CanvasChat', () => {
getChatMessages: getChatMessagesSpy,
});
workflowsStore.chatPanelState = 'closed';
workflowsStore.chatPanelState = LOGS_PANEL_STATE.CLOSED;
const { rerender } = renderComponent();
workflowsStore.chatPanelState = 'attached';
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED;
await rerender({});
expect(getChatMessagesSpy).toHaveBeenCalled();

View File

@@ -15,6 +15,7 @@ import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
import { N8nResizeWrapper } from '@n8n/design-system';
import { useTelemetry } from '@/composables/useTelemetry';
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
const workflowsStore = useWorkflowsStore();
const canvasStore = useCanvasStore();
@@ -49,14 +50,14 @@ const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
initialWidth: window.document.body.offsetWidth * 0.8,
container: pipContainer,
content: pipContent,
shouldPopOut: computed(() => chatPanelState.value === 'floating'),
shouldPopOut: computed(() => chatPanelState.value === LOGS_PANEL_STATE.FLOATING),
onRequestClose: () => {
if (chatPanelState.value === 'closed') {
if (chatPanelState.value === LOGS_PANEL_STATE.CLOSED) {
return;
}
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPanelState('attached');
workflowsStore.setPanelState(LOGS_PANEL_STATE.ATTACHED);
},
});
@@ -79,17 +80,17 @@ defineExpose({
});
const closePanel = () => {
workflowsStore.setPanelState('closed');
workflowsStore.setPanelState(LOGS_PANEL_STATE.CLOSED);
};
function onPopOut() {
telemetry.track('User toggled log view', { new_state: 'floating' });
workflowsStore.setPanelState('floating');
workflowsStore.setPanelState(LOGS_PANEL_STATE.FLOATING);
}
// Watchers
watchEffect(() => {
canvasStore.setPanelHeight(chatPanelState.value === 'attached' ? height.value : 0);
canvasStore.setPanelHeight(chatPanelState.value === LOGS_PANEL_STATE.ATTACHED ? height.value : 0);
});
</script>
@@ -98,15 +99,15 @@ watchEffect(() => {
<div ref="pipContent" :class="$style.pipContent">
<N8nResizeWrapper
v-if="chatTriggerNode"
:is-resizing-enabled="!isPoppedOut && chatPanelState === 'attached'"
:is-resizing-enabled="!isPoppedOut && chatPanelState === LOGS_PANEL_STATE.ATTACHED"
:supported-directions="['top']"
:class="[$style.resizeWrapper, chatPanelState === 'closed' && $style.empty]"
:class="[$style.resizeWrapper, chatPanelState === LOGS_PANEL_STATE.CLOSED && $style.empty]"
:height="height"
:style="rootStyles"
@resize="onResizeDebounced"
>
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
<div v-if="chatPanelState !== 'closed'" :class="$style.chatResizer">
<div v-if="chatPanelState !== LOGS_PANEL_STATE.CLOSED" :class="$style.chatResizer">
<N8nResizeWrapper
:supported-directions="['right']"
:width="chatWidth"

View File

@@ -0,0 +1,104 @@
import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks';
import {
AGENT_NODE_TYPE,
AI_CATEGORY_AGENTS,
AI_SUBCATEGORY,
CHAT_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
} from '@/constants';
import { type IExecutionResponse } from '@/Interface';
import { WorkflowOperationError } from 'n8n-workflow';
export const nodeTypes = [
mockNodeTypeDescription({
name: CHAT_TRIGGER_NODE_TYPE,
version: 1,
group: ['trigger'],
}),
mockNodeTypeDescription({
name: MANUAL_TRIGGER_NODE_TYPE,
version: 1,
group: ['trigger'],
}),
mockNodeTypeDescription({
name: AGENT_NODE_TYPE,
codex: {
subcategories: {
[AI_SUBCATEGORY]: [AI_CATEGORY_AGENTS],
},
},
version: 1,
}),
];
export const chatTriggerNode = createTestNode({ name: 'Chat', type: CHAT_TRIGGER_NODE_TYPE });
export const manualTriggerNode = createTestNode({ name: 'Manual' });
export const aiAgentNode = createTestNode({ name: 'AI Agent', type: AGENT_NODE_TYPE });
export const aiModelNode = createTestNode({ name: 'AI Model' });
export const simpleWorkflow = createTestWorkflow({
nodes: [manualTriggerNode],
connections: {},
});
export const aiChatWorkflow = createTestWorkflow({
nodes: [chatTriggerNode, aiAgentNode, aiModelNode],
connections: {
Chat: {
main: [[{ node: 'AI Agent', index: 0, type: 'main' }]],
},
'AI Model': {
ai_languageModel: [[{ node: 'AI Agent', index: 0, type: 'ai_languageModel' }]],
},
},
});
export const executionResponse: IExecutionResponse = {
id: 'test-exec-id',
finished: true,
mode: 'manual',
status: 'success',
data: {
resultData: {
runData: {
'AI Agent': [
{
executionStatus: 'success',
startTime: +new Date('2025-03-26T00:00:00.002Z'),
executionTime: 1778,
source: [],
data: {},
},
],
'AI Model': [
{
executionStatus: 'error',
startTime: +new Date('2025-03-26T00:00:00.003Z'),
executionTime: 1777,
source: [],
error: new WorkflowOperationError('Test error', aiModelNode, 'Test error description'),
data: {
ai_languageModel: [
[
{
json: {
tokenUsage: {
completionTokens: 222,
promptTokens: 333,
totalTokens: 555,
},
},
},
],
],
},
},
],
},
},
},
workflowData: aiChatWorkflow,
createdAt: new Date('2025-03-26T00:00:00.000Z'),
startedAt: new Date('2025-03-26T00:00:00.001Z'),
stoppedAt: new Date('2025-03-26T00:00:02.000Z'),
};

View File

@@ -140,6 +140,7 @@ async function copySessionId() {
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
<PanelHeader
v-if="isNewLogsEnabled"
data-test-id="chat-header"
:title="locale.baseText('chat.window.title')"
@click="emit('clickHeader')"
>

View File

@@ -17,6 +17,7 @@ import { v4 as uuid } from 'uuid';
import type { Ref } from 'vue';
import { computed, provide, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { LOGS_PANEL_STATE } from '../types/logs';
interface ChatState {
currentSessionId: Ref<string>;
@@ -129,7 +130,7 @@ export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => voi
watch(
() => chatPanelState.value,
(state) => {
if (state !== 'closed') {
if (state !== LOGS_PANEL_STATE.CLOSED) {
setChatTriggerNode();
setConnectedNode();

View File

@@ -1,5 +1,5 @@
import { renderComponent } from '@/__tests__/render';
import { fireEvent, waitFor } from '@testing-library/vue';
import { fireEvent, within } from '@testing-library/vue';
import { mockedStore } from '@/__tests__/utils';
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
import { useSettingsStore } from '@/stores/settings.store';
@@ -7,14 +7,15 @@ import { createTestingPinia, type TestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { createRouter, createWebHistory } from 'vue-router';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createTestNode } from '@/__tests__/mocks';
import { CHAT_TRIGGER_NODE_TYPE } from '@/constants';
import { h } from 'vue';
import { executionResponse, aiChatWorkflow, simpleWorkflow, nodeTypes } from '../__test__/data';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
describe('LogsPanel', () => {
let pinia: TestingPinia;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
function render() {
return renderComponent(LogsPanel, {
@@ -39,50 +40,97 @@ describe('LogsPanel', () => {
settingsStore.isNewLogsEnabled = true;
workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.setWorkflowExecutionData(null);
nodeTypeStore = mockedStore(useNodeTypesStore);
nodeTypeStore.setNodeTypes(nodeTypes);
});
it('renders collapsed panel by default', async () => {
const rendered = render();
expect(await rendered.findByText('Logs')).toBeInTheDocument();
expect(
rendered.queryByText('Nothing to display yet', { exact: false }),
).not.toBeInTheDocument();
});
it('renders chat panel if the workflow has chat trigger', async () => {
workflowsStore.workflowTriggerNodes = [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE })];
it('should render collapsed panel by default', async () => {
workflowsStore.setWorkflow(simpleWorkflow);
const rendered = render();
expect(await rendered.findByText('Chat')).toBeInTheDocument();
expect(await rendered.findByTestId('logs-overview-header')).toBeInTheDocument();
expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument();
});
it('should render logs panel only if the workflow has no chat trigger', async () => {
workflowsStore.setWorkflow(simpleWorkflow);
const rendered = render();
expect(await rendered.findByTestId('logs-overview-header')).toBeInTheDocument();
expect(rendered.queryByTestId('chat-header')).not.toBeInTheDocument();
});
it('should render chat panel and logs panel if the workflow has chat trigger', async () => {
workflowsStore.setWorkflow(aiChatWorkflow);
const rendered = render();
expect(await rendered.findByTestId('logs-overview-header')).toBeInTheDocument();
expect(await rendered.findByTestId('chat-header')).toBeInTheDocument();
});
it('opens collapsed panel when clicked', async () => {
workflowsStore.setWorkflow(aiChatWorkflow);
const rendered = render();
await rendered.findByText('Logs');
await fireEvent.click(await rendered.findByTestId('logs-overview-header'));
await fireEvent.click(rendered.getByText('Logs'));
expect(
await rendered.findByText('Nothing to display yet', { exact: false }),
).toBeInTheDocument();
expect(await rendered.findByTestId('logs-overview-empty')).toBeInTheDocument();
});
it('toggles panel when chevron icon button is clicked', async () => {
it('should toggle panel when chevron icon button in the overview panel is clicked', async () => {
workflowsStore.setWorkflow(aiChatWorkflow);
const rendered = render();
await rendered.findByText('Logs');
const overviewPanel = await rendered.findByTestId('logs-overview-header');
await fireEvent.click(rendered.getAllByRole('button').pop()!);
expect(rendered.getByText('Nothing to display yet', { exact: false })).toBeInTheDocument();
await fireEvent.click(within(overviewPanel).getByLabelText('Open panel'));
expect(rendered.getByTestId('logs-overview-empty')).toBeInTheDocument();
await fireEvent.click(rendered.getAllByRole('button').pop()!);
await waitFor(() =>
expect(
rendered.queryByText('Nothing to display yet', { exact: false }),
).not.toBeInTheDocument(),
);
await fireEvent.click(within(overviewPanel).getByLabelText('Collapse panel'));
expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument();
});
it('should open log details panel when a log entry is clicked in the logs overview panel', async () => {
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(executionResponse);
const rendered = render();
await fireEvent.click(await rendered.findByTestId('logs-overview-header'));
await fireEvent.click(await rendered.findByText('AI Agent'));
expect(rendered.getByTestId('log-details')).toBeInTheDocument();
// Click again to close the panel
await fireEvent.click(await rendered.findByText('AI Agent'));
expect(rendered.queryByTestId('log-details')).not.toBeInTheDocument();
});
it("should show the button to toggle panel in the header of log details panel when it's opened", async () => {
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(executionResponse);
const rendered = render();
await fireEvent.click(await rendered.findByTestId('logs-overview-header'));
await fireEvent.click(await rendered.findByText('AI Agent'));
const detailsPanel = rendered.getByTestId('log-details');
// Click the toggle button to close the panel
await fireEvent.click(within(detailsPanel).getByLabelText('Collapse panel'));
expect(rendered.queryByTestId('chat-messages-empty')).not.toBeInTheDocument();
expect(rendered.queryByText('AI Agent', { exact: false })).not.toBeInTheDocument();
// Click again to open the panel
await fireEvent.click(within(detailsPanel).getByLabelText('Open panel'));
expect(await rendered.findByTestId('chat-messages-empty')).toBeInTheDocument();
expect(await rendered.findByText('AI Agent', { exact: false })).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed, ref, useTemplateRef, watch } from 'vue';
import { N8nIconButton, N8nResizeWrapper, N8nTooltip } from '@n8n/design-system';
import { N8nResizeWrapper } from '@n8n/design-system';
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
import { useResize } from '@/components/CanvasChat/composables/useResize';
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
@@ -9,14 +9,16 @@ 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 { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
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 LogsPanelActions from '@/components/CanvasChat/future/components/LogsPanelActions.vue';
const workflowsStore = useWorkflowsStore();
const canvasStore = useCanvasStore();
const panelState = computed(() => workflowsStore.chatPanelState);
const container = ref<HTMLElement>();
const selectedLogEntry = ref<LogEntryIdentity | undefined>(undefined);
const pipContainer = useTemplateRef('pipContainer');
const pipContent = useTemplateRef('pipContent');
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
@@ -25,7 +27,6 @@ const hasChat = computed(() =>
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
),
);
const locales = useI18n();
const telemetry = useTelemetry();
@@ -34,50 +35,63 @@ const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResi
const { currentSessionId, messages, connectedNode, sendMessage, refreshSession, displayExecution } =
useChatState(ref(false), onWindowResize);
const appStyles = useStyles();
const tooltipZIndex = computed(() => appStyles.APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON + 100);
const isLogDetailsOpen = computed(() => selectedLogEntry.value !== undefined);
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
initialHeight: 400,
initialWidth: window.document.body.offsetWidth * 0.8,
container: pipContainer,
content: pipContent,
shouldPopOut: computed(() => panelState.value === 'floating'),
shouldPopOut: computed(() => panelState.value === LOGS_PANEL_STATE.FLOATING),
onRequestClose: () => {
if (panelState.value === 'closed') {
if (panelState.value === LOGS_PANEL_STATE.CLOSED) {
return;
}
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPanelState('attached');
workflowsStore.setPanelState(LOGS_PANEL_STATE.ATTACHED);
},
});
const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$props']>(() => ({
panelState: panelState.value,
showPopOutButton: canPopOut.value && !isPoppedOut.value,
onPopOut,
onToggleOpen,
}));
function handleToggleOpen() {
if (panelState.value === 'closed') {
function onToggleOpen() {
if (panelState.value === LOGS_PANEL_STATE.CLOSED) {
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPanelState('attached');
workflowsStore.setPanelState(LOGS_PANEL_STATE.ATTACHED);
} else {
telemetry.track('User toggled log view', { new_state: 'collapsed' });
workflowsStore.setPanelState('closed');
workflowsStore.setPanelState(LOGS_PANEL_STATE.CLOSED);
}
}
function handleClickHeader() {
if (panelState.value === 'closed') {
if (panelState.value === LOGS_PANEL_STATE.CLOSED) {
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPanelState('attached');
workflowsStore.setPanelState(LOGS_PANEL_STATE.ATTACHED);
}
}
function handleSelectLogEntry(selected: LogEntryIdentity | undefined) {
selectedLogEntry.value = selected;
}
function onPopOut() {
telemetry.track('User toggled log view', { new_state: 'floating' });
workflowsStore.setPanelState('floating');
workflowsStore.setPanelState(LOGS_PANEL_STATE.FLOATING);
}
watch([panelState, height], ([state, h]) => {
canvasStore.setPanelHeight(
state === 'floating' ? 0 : state === 'attached' ? h : 32 /* collapsed panel height */,
state === LOGS_PANEL_STATE.FLOATING
? 0
: state === LOGS_PANEL_STATE.ATTACHED
? h
: 32 /* collapsed panel height */,
);
});
</script>
@@ -88,16 +102,16 @@ watch([panelState, height], ([state, h]) => {
<N8nResizeWrapper
:height="height"
:supported-directions="['top']"
:is-resizing-enabled="panelState === 'attached'"
:is-resizing-enabled="panelState === LOGS_PANEL_STATE.ATTACHED"
:style="rootStyles"
:class="[$style.resizeWrapper, panelState === 'closed' ? '' : $style.isOpen]"
:class="[$style.resizeWrapper, panelState === LOGS_PANEL_STATE.CLOSED ? '' : $style.isOpen]"
@resize="onResizeDebounced"
>
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
<N8nResizeWrapper
v-if="hasChat"
:supported-directions="['right']"
:is-resizing-enabled="panelState !== 'closed'"
:is-resizing-enabled="panelState !== LOGS_PANEL_STATE.CLOSED"
:width="chatWidth"
:class="$style.chat"
:window="pipWindow"
@@ -105,13 +119,13 @@ watch([panelState, height], ([state, h]) => {
>
<ChatMessagesPanel
data-test-id="canvas-chat"
:is-open="panelState !== 'closed'"
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
:messages="messages"
:session-id="currentSessionId"
:past-chat-messages="previousChatMessages"
:show-close-button="false"
:is-new-logs-enabled="true"
@close="handleToggleOpen"
@close="onToggleOpen"
@refresh-session="refreshSession"
@display-execution="displayExecution"
@send-message="sendMessage"
@@ -119,46 +133,27 @@ watch([panelState, height], ([state, h]) => {
/>
</N8nResizeWrapper>
<LogsOverviewPanel
:is-open="panelState !== 'closed'"
:class="$style.logsOverview"
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
:node="connectedNode"
:selected="selectedLogEntry"
@click-header="handleClickHeader"
@select="handleSelectLogEntry"
>
<template #actions>
<LogsPanelActions v-if="!isLogDetailsOpen" v-bind="logsPanelActionsProps" />
</template>
</LogsOverviewPanel>
<LogsDetailsPanel
v-if="selectedLogEntry"
:class="$style.logDetails"
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
@click-header="handleClickHeader"
>
<template #actions>
<N8nTooltip
v-if="canPopOut && !isPoppedOut"
:z-index="tooltipZIndex"
:content="locales.baseText('runData.panel.actions.popOut')"
>
<N8nIconButton
icon="pop-out"
type="secondary"
size="small"
icon-size="medium"
@click="onPopOut"
/>
</N8nTooltip>
<N8nTooltip
v-if="panelState !== 'floating'"
:z-index="tooltipZIndex"
:content="
locales.baseText(
panelState === 'attached'
? 'runData.panel.actions.collapse'
: 'runData.panel.actions.open',
)
"
>
<N8nIconButton
type="secondary"
size="small"
icon-size="medium"
:icon="panelState === 'attached' ? 'chevron-down' : 'chevron-up'"
style="color: var(--color-text-base)"
@click.stop="handleToggleOpen"
/>
</N8nTooltip>
<LogsPanelActions v-if="isLogDetailsOpen" v-bind="logsPanelActionsProps" />
</template>
</LogsOverviewPanel>
</LogsDetailsPanel>
</div>
</N8nResizeWrapper>
</div>
@@ -209,4 +204,17 @@ watch([panelState, height], ([state, h]) => {
flex-shrink: 0;
max-width: 100%;
}
.logsOverview {
flex-basis: 20%;
flex-grow: 1;
flex-shrink: 1;
min-width: 360px;
}
.logDetails {
flex-basis: 60%;
flex-grow: 1;
flex-shrink: 1;
}
</style>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
const { isOpen } = defineProps<{ isOpen: boolean }>();
const emit = defineEmits<{ clickHeader: [] }>();
defineSlots<{ actions: {} }>();
</script>
<template>
<div :class="$style.container" data-test-id="log-details">
<PanelHeader
title="Log details"
data-test-id="logs-details-header"
@click="emit('clickHeader')"
>
<template #actions>
<slot name="actions" />
</template>
</PanelHeader>
<div v-if="isOpen" :class="$style.content" data-test-id="logs-details-body" />
</div>
</template>
<style lang="scss" module>
.container {
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: column;
align-items: stretch;
overflow: hidden;
background-color: var(--color-foreground-xlight);
}
.content {
flex-grow: 1;
padding: var(--spacing-s);
}
</style>

View File

@@ -1,6 +1,5 @@
import { renderComponent } from '@/__tests__/render';
import LogsOverviewPanel from './LogsOverviewPanel.vue';
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
import { setActivePinia } from 'pinia';
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
@@ -8,26 +7,12 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { createRouter, createWebHistory } from 'vue-router';
import { h, type ExtractPropTypes } from 'vue';
import { fireEvent, waitFor, within } from '@testing-library/vue';
import { aiAgentNode, executionResponse, aiChatWorkflow } from '../../__test__/data';
describe('LogsOverviewPanel', () => {
let pinia: TestingPinia;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
const triggerNode = createTestNode({ name: 'Chat' });
const aiAgentNode = createTestNode({ name: 'AI Agent' });
const aiModelNode = createTestNode({ name: 'AI Model' });
const workflow = createTestWorkflow({
nodes: [triggerNode, aiAgentNode, aiModelNode],
connections: {
Chat: {
main: [[{ node: 'AI Agent', index: 0, type: 'main' }]],
},
'AI Model': {
ai_languageModel: [[{ node: 'AI Agent', index: 0, type: 'ai_languageModel' }]],
},
},
});
function render(props: ExtractPropTypes<typeof LogsOverviewPanel>) {
return renderComponent(LogsOverviewPanel, {
props,
@@ -49,73 +34,24 @@ describe('LogsOverviewPanel', () => {
setActivePinia(pinia);
workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.setWorkflow(workflow);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(null);
});
it('should not render body if the panel is not open', () => {
const rendered = render({ isOpen: false, node: null });
expect(
rendered.queryByText('Nothing to display yet', { exact: false }),
).not.toBeInTheDocument();
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 });
expect(rendered.queryByText('Nothing to display yet', { exact: false })).toBeInTheDocument();
expect(rendered.queryByTestId('logs-overview-empty')).toBeInTheDocument();
});
it('should render summary text and executed nodes if there is an execution', async () => {
workflowsStore.setWorkflowExecutionData({
id: 'test-exec-id',
finished: true,
mode: 'manual',
status: 'success',
data: {
resultData: {
runData: {
'AI Agent': [
{
executionStatus: 'success',
startTime: +new Date('2025-03-26T00:00:00.002Z'),
executionTime: 1778,
source: [],
data: {},
},
],
'AI Model': [
{
executionStatus: 'success',
startTime: +new Date('2025-03-26T00:00:00.003Z'),
executionTime: 1777,
source: [],
data: {
ai_languageModel: [
[
{
json: {
tokenUsage: {
completionTokens: 222,
promptTokens: 333,
totalTokens: 555,
},
},
},
],
],
},
},
],
},
},
},
workflowData: workflow,
createdAt: new Date('2025-03-26T00:00:00.000Z'),
startedAt: new Date('2025-03-26T00:00:00.001Z'),
stoppedAt: new Date('2025-03-26T00:00:02.000Z'),
});
workflowsStore.setWorkflowExecutionData(executionResponse);
const rendered = render({ isOpen: true, node: aiAgentNode });
const summary = within(rendered.container.querySelector('.summary')!);
@@ -131,14 +67,15 @@ describe('LogsOverviewPanel', () => {
expect(row1.queryByText('AI Agent')).toBeInTheDocument();
expect(row1.queryByText('Success in 1.778s')).toBeInTheDocument();
expect(row1.queryByText('Started 2025-03-26T00:00:00.002Z')).toBeInTheDocument();
expect(row1.queryByText('Started 00:00:00.002, 26 Mar')).toBeInTheDocument();
expect(row1.queryByText('555 Tokens')).toBeInTheDocument();
const row2 = within(tree.queryAllByRole('treeitem')[1]);
expect(row2.queryByText('AI Model')).toBeInTheDocument();
expect(row2.queryByText('Success in 1.777s')).toBeInTheDocument();
expect(row2.queryByText('Started 2025-03-26T00:00:00.003Z')).toBeInTheDocument();
expect(row2.queryByText('Error')).toBeInTheDocument();
expect(row2.queryByText('in 1.777s')).toBeInTheDocument();
expect(row2.queryByText('Started 00:00:00.003, 26 Mar')).toBeInTheDocument();
expect(row2.queryByText('555 Tokens')).toBeInTheDocument();
// collapse tree

View File

@@ -5,7 +5,7 @@ import { useI18n } from '@/composables/useI18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { ElTree, type TreeNode as ElTreeNode } from 'element-plus';
import {
createAiData,
@@ -18,10 +18,16 @@ import { type INodeUi } from '@/Interface';
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';
const { node, isOpen } = defineProps<{ isOpen: boolean; node: INodeUi | null }>();
const { node, isOpen, selected } = defineProps<{
isOpen: boolean;
node: INodeUi | null;
selected?: LogEntryIdentity;
}>();
const emit = defineEmits<{ clickHeader: [] }>();
const emit = defineEmits<{ clickHeader: []; select: [LogEntryIdentity | undefined] }>();
defineSlots<{ actions: {} }>();
@@ -41,9 +47,9 @@ const executionTree = computed<TreeNode[]>(() =>
: [],
);
const isEmpty = computed(() => workflowsStore.workflowExecutionData === null);
const switchViewOptions = computed<Array<{ label: string; value: string }>>(() => [
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' },
{ label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' },
const switchViewOptions = computed(() => [
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
{ label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' as const },
]);
const executionStatusText = computed(() => {
const execution = workflowsStore.workflowExecutionData;
@@ -70,20 +76,18 @@ const consumedTokens = computed(() =>
getTotalConsumedTokens(...executionTree.value.map(getSubtreeTotalConsumedTokens)),
);
const selectedRun = ref<{ node: string; runIndex: number } | undefined>(undefined);
function onClearExecutionData() {
workflowsStore.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
}
function handleClickNode(clicked: TreeNode) {
if (selectedRun.value?.node === clicked.node && selectedRun.value.runIndex === clicked.runIndex) {
selectedRun.value = undefined;
if (selected?.node === clicked.node && selected.runIndex === clicked.runIndex) {
emit('select', undefined);
return;
}
selectedRun.value = { node: clicked.node, runIndex: clicked.runIndex };
emit('select', { node: clicked.node, runIndex: clicked.runIndex });
telemetry.track('User selected node in log view', {
node_type: workflowsStore.nodesByName[clicked.node].type,
node_id: workflowsStore.nodesByName[clicked.node].id,
@@ -92,15 +96,23 @@ function handleClickNode(clicked: TreeNode) {
});
}
function handleSwitchView(value: 'overview' | 'details') {
emit(
'select',
value === 'overview' || executionTree.value.length === 0 ? undefined : executionTree.value[0],
);
}
function handleToggleExpanded(treeNode: ElTreeNode) {
treeNode.expanded = !treeNode.expanded;
}
</script>
<template>
<div :class="$style.container">
<div :class="$style.container" data-test-id="logs-overview">
<PanelHeader
:title="locale.baseText('logs.overview.header.title')"
data-test-id="logs-overview-header"
@click="emit('clickHeader')"
>
<template #actions>
@@ -120,8 +132,19 @@ function handleToggleExpanded(treeNode: ElTreeNode) {
<slot name="actions" />
</template>
</PanelHeader>
<div v-if="isOpen" :class="[$style.content, isEmpty ? $style.empty : '']">
<N8nText v-if="isEmpty" tag="p" size="medium" color="text-base" :class="$style.emptyText">
<div
v-if="isOpen"
:class="[$style.content, isEmpty ? $style.empty : '']"
data-test-id="logs-overview-body"
>
<N8nText
v-if="isEmpty"
tag="p"
size="medium"
color="text-base"
:class="$style.emptyText"
data-test-id="logs-overview-empty"
>
{{ locale.baseText('logs.overview.body.empty.message') }}
</N8nText>
<div v-else :class="$style.scrollable">
@@ -140,6 +163,7 @@ function handleToggleExpanded(treeNode: ElTreeNode) {
</N8nText>
<ElTree
v-if="executionTree.length > 0"
node-key="id"
:class="$style.tree"
:indent="0"
:data="executionTree"
@@ -151,9 +175,8 @@ function handleToggleExpanded(treeNode: ElTreeNode) {
<LogsOverviewRow
:data="data"
:node="elTreeNode"
:is-selected="
data.node === selectedRun?.node && data.runIndex === selectedRun?.runIndex
"
:is-selected="data.node === selected?.node && data.runIndex === selected?.runIndex"
:is-compact="selected !== undefined"
:should-show-consumed-tokens="consumedTokens.totalTokens > 0"
@toggle-expanded="handleToggleExpanded"
/>
@@ -162,8 +185,9 @@ function handleToggleExpanded(treeNode: ElTreeNode) {
<N8nRadioButtons
size="medium"
:class="$style.switchViewButtons"
:model-value="selectedRun ? 'details' : 'overview'"
:model-value="selected ? 'details' : 'overview'"
:options="switchViewOptions"
@update:model-value="handleSwitchView"
/>
</div>
</div>
@@ -178,6 +202,7 @@ function handleToggleExpanded(treeNode: ElTreeNode) {
flex-direction: column;
align-items: stretch;
overflow: hidden;
background-color: var(--color-foreground-xlight);
}
.content {

View File

@@ -4,18 +4,21 @@ import { getSubtreeTotalConsumedTokens, type TreeNode } from '@/components/RunDa
import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed } from 'vue';
import { type INodeUi } from '@/Interface';
import { type ExecutionStatus, type ITaskData } from 'n8n-workflow';
import { N8nIconButton, N8nText } from '@n8n/design-system';
import { N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
import { type ITaskData } from 'n8n-workflow';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { upperFirst } from 'lodash-es';
import { useI18n } from '@/composables/useI18n';
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
import { I18nT } from 'vue-i18n';
import { toDayMonth, toTime } from '@/utils/formatters/dateFormatter';
const props = defineProps<{
data: TreeNode;
node: ElTreeNode;
isSelected: boolean;
shouldShowConsumedTokens: boolean;
isCompact: boolean;
}>();
const emit = defineEmits<{ toggleExpanded: [node: ElTreeNode] }>();
@@ -33,32 +36,21 @@ const runData = computed<ITaskData | undefined>(() =>
);
const type = computed(() => (node.value ? nodeTypeStore.getNodeType(node.value.type) : undefined));
const depth = computed(() => (props.node.level ?? 1) - 1);
const timeTookText = computed(() => {
const finalStatuses: ExecutionStatus[] = ['crashed', 'error', 'success'];
const status = runData.value?.executionStatus;
if (!status) {
return '';
}
const statusText = upperFirst(status);
return finalStatuses.includes(status)
? locale.baseText('logs.overview.body.summaryText', {
interpolate: {
status: statusText,
time: locale.displayTimer(runData.value.executionTime, true),
},
})
: statusText;
});
const startedAtText = computed(() =>
locale.baseText('logs.overview.body.started', {
interpolate: {
time: new Date(runData.value?.startTime ?? 0).toISOString(), // TODO: confirm date format
},
}),
const isSettled = computed(
() =>
runData.value?.executionStatus &&
['crashed', 'error', 'success'].includes(runData.value.executionStatus),
);
const isError = computed(() => !!runData.value?.error);
const startedAtText = computed(() => {
const time = new Date(runData.value?.startTime ?? 0);
return locale.baseText('logs.overview.body.started', {
interpolate: {
time: `${toTime(time, true)}, ${toDayMonth(time)}`,
},
});
});
const subtreeConsumedTokens = computed(() =>
props.shouldShowConsumedTokens ? getSubtreeTotalConsumedTokens(props.data) : undefined,
@@ -82,7 +74,12 @@ function isLastChild(level: number) {
<template>
<div
v-if="node !== undefined"
:class="{ [$style.container]: true, [$style.selected]: props.isSelected }"
:class="{
[$style.container]: true,
[$style.compact]: props.isCompact,
[$style.error]: isError,
[$style.selected]: props.isSelected,
}"
>
<template v-for="level in depth" :key="level">
<div
@@ -93,11 +90,30 @@ function isLastChild(level: number) {
}"
/>
</template>
<div :class="$style.background" :style="{ '--indent-depth': depth }" />
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
<N8nText tag="div" :bold="true" size="small" :class="$style.name">{{ node.name }}</N8nText>
<N8nText tag="div" color="text-light" size="small" :class="$style.timeTook">{{
timeTookText
}}</N8nText>
<N8nText
tag="div"
:bold="true"
size="small"
:class="$style.name"
:color="isError ? 'danger' : undefined"
>{{ 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>
<N8nText v-if="isError" color="danger" :bold="true" size="small">
<N8nIcon icon="exclamation-triangle" :class="$style.errorIcon" />{{
upperFirst(runData.executionStatus)
}}
</N8nText>
<template v-else>{{ upperFirst(runData.executionStatus) }}</template>
</template>
<template #time>{{ locale.displayTimer(runData.executionTime, true) }}</template>
</I18nT>
<template v-else>{{ upperFirst(runData?.executionStatus) }}</template></N8nText
>
<N8nText tag="div" color="text-light" size="small" :class="$style.startedAt">{{
startedAtText
}}</N8nText>
@@ -116,8 +132,15 @@ function isLastChild(level: number) {
:consumed-tokens="subtreeConsumedTokens"
/>
</N8nText>
<div>
<N8nIcon
v-if="isError && isCompact"
size="medium"
color="danger"
icon="exclamation-triangle"
:class="$style.compactErrorIcon"
/>
<N8nIconButton
v-if="!isCompact || props.data.children.length > 0"
type="secondary"
size="medium"
:icon="props.node.expanded ? 'chevron-down' : 'chevron-up'"
@@ -129,7 +152,6 @@ function isLastChild(level: number) {
@click.stop="emit('toggleExpanded', props.node)"
/>
</div>
</div>
</template>
<style lang="scss" module>
@@ -138,6 +160,8 @@ function isLastChild(level: number) {
align-items: stretch;
justify-content: stretch;
overflow: hidden;
position: relative;
z-index: 1;
& > * {
overflow: hidden;
@@ -145,28 +169,25 @@ function isLastChild(level: number) {
white-space: nowrap;
padding: var(--spacing-2xs);
}
}
& > :has(.toggleButton) {
flex-shrink: 0;
display: flex;
align-items: center;
padding: 0;
}
.background {
position: absolute;
left: calc(var(--indent-depth) * 32px);
top: 0;
width: calc(100% - var(--indent-depth) * 32px);
height: 100%;
border-radius: var(--border-radius-base);
z-index: -1;
& > .icon {
border-top-left-radius: var(--border-radius-base);
border-bottom-left-radius: var(--border-radius-base);
}
& > :last-of-type {
border-top-right-radius: var(--border-radius-base);
border-bottom-right-radius: var(--border-radius-base);
}
&.selected > :not(.indent),
&:hover > :not(.indent) {
.selected &,
.container:hover & {
background-color: var(--color-foreground-base);
}
.selected:not(:hover).error & {
background-color: var(--color-danger-tint-2);
}
}
.indent {
@@ -212,12 +233,29 @@ function isLastChild(level: number) {
flex-grow: 0;
flex-shrink: 0;
width: 20%;
.errorIcon {
margin-right: var(--spacing-4xs);
vertical-align: text-bottom;
}
.compact:hover & {
width: auto;
}
.compact:not(:hover) & {
display: none;
}
}
.startedAt {
flex-grow: 0;
flex-shrink: 0;
width: 30%;
.compact & {
display: none;
}
}
.consumedTokens {
@@ -225,12 +263,33 @@ function isLastChild(level: number) {
flex-shrink: 0;
width: 10%;
text-align: right;
.compact:hover & {
width: auto;
}
.compact &:empty,
.compact:not(:hover) & {
display: none;
}
}
.compactErrorIcon {
flex-grow: 0;
flex-shrink: 0;
.container:hover & {
display: none;
}
}
.toggleButton {
flex-grow: 0;
flex-shrink: 0;
border: none;
background: transparent;
margin-inline-end: var(--spacing-5xs);
color: var(--color-text-base);
&:hover {
background: transparent;

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { LOGS_PANEL_STATE, type LogsPanelState } from '@/components/CanvasChat/types/logs';
import { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
import { computed } from 'vue';
const { panelState, showPopOutButton } = defineProps<{
panelState: LogsPanelState;
showPopOutButton: boolean;
}>();
const emit = defineEmits<{ popOut: []; toggleOpen: [] }>();
const appStyles = useStyles();
const locales = useI18n();
const tooltipZIndex = computed(() => appStyles.APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON + 100);
const popOutButtonText = computed(() => locales.baseText('runData.panel.actions.popOut'));
const toggleButtonText = computed(() =>
locales.baseText(
panelState === LOGS_PANEL_STATE.ATTACHED
? 'runData.panel.actions.collapse'
: 'runData.panel.actions.open',
),
);
</script>
<template>
<div>
<N8nTooltip v-if="showPopOutButton" :z-index="tooltipZIndex" :content="popOutButtonText">
<N8nIconButton
icon="pop-out"
type="secondary"
size="small"
icon-size="medium"
:aria-label="popOutButtonText"
@click="emit('popOut')"
/>
</N8nTooltip>
<N8nTooltip
v-if="panelState !== LOGS_PANEL_STATE.FLOATING"
:z-index="tooltipZIndex"
:content="toggleButtonText"
>
<N8nIconButton
type="secondary"
size="small"
icon-size="medium"
:icon="panelState === LOGS_PANEL_STATE.ATTACHED ? 'chevron-down' : 'chevron-up'"
:aria-label="toggleButtonText"
style="color: var(--color-text-base)"
@click.stop="emit('toggleOpen')"
/>
</N8nTooltip>
</div>
</template>

View File

@@ -0,0 +1,12 @@
export interface LogEntryIdentity {
node: string;
runIndex: number;
}
export const LOGS_PANEL_STATE = {
CLOSED: 'closed',
ATTACHED: 'attached',
FLOATING: 'floating',
} as const;
export type LogsPanelState = (typeof LOGS_PANEL_STATE)[keyof typeof LOGS_PANEL_STATE];

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useI18n } from '@/composables/useI18n';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
@@ -39,7 +40,7 @@ const uiStore = useUIStore();
const { runEntireWorkflow } = useRunWorkflow({ router });
const { toggleChatOpen } = useCanvasOperations({ router });
const isChatOpen = computed(() => workflowsStore.chatPanelState !== 'closed');
const isChatOpen = computed(() => workflowsStore.chatPanelState !== LOGS_PANEL_STATE.CLOSED);
const isExecuting = computed(() => uiStore.isActionActive.workflowRunning);
const testId = computed(() => `execute-workflow-button-${name}`);
</script>

View File

@@ -53,6 +53,7 @@ import { nextTick } from 'vue';
import { useProjectsStore } from '@/stores/projects.store';
import type { CanvasLayoutEvent } from './useCanvasLayout';
import { useTelemetry } from './useTelemetry';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<{}>();
@@ -2932,11 +2933,11 @@ describe('useCanvasOperations', () => {
const { toggleChatOpen } = useCanvasOperations({ router });
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
workflowsStore.chatPanelState = 'closed';
workflowsStore.chatPanelState = LOGS_PANEL_STATE.CLOSED;
await toggleChatOpen('main');
expect(workflowsStore.setPanelState).toHaveBeenCalledWith('attached');
expect(workflowsStore.setPanelState).toHaveBeenCalledWith(LOGS_PANEL_STATE.ATTACHED);
});
it('should invoke workflowsStore#setPanelState with 1st argument "collapsed" if the chat panel is open', async () => {
@@ -2944,11 +2945,11 @@ describe('useCanvasOperations', () => {
const { toggleChatOpen } = useCanvasOperations({ router });
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
workflowsStore.chatPanelState = 'attached';
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED;
await toggleChatOpen('main');
expect(workflowsStore.setPanelState).toHaveBeenCalledWith('closed');
expect(workflowsStore.setPanelState).toHaveBeenCalledWith(LOGS_PANEL_STATE.CLOSED);
});
});

View File

@@ -101,6 +101,7 @@ import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
import { isPresent } from '../utils/typesUtils';
import { useProjectsStore } from '@/stores/projects.store';
import type { CanvasLayoutEvent } from './useCanvasLayout';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
type AddNodeData = Partial<INodeUi> & {
type: string;
@@ -1976,7 +1977,9 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
const workflow = workflowsStore.getCurrentWorkflow();
workflowsStore.setPanelState(
workflowsStore.chatPanelState === 'closed' ? 'attached' : 'closed',
workflowsStore.chatPanelState === LOGS_PANEL_STATE.CLOSED
? LOGS_PANEL_STATE.ATTACHED
: LOGS_PANEL_STATE.CLOSED,
);
const payload = {

View File

@@ -40,6 +40,7 @@ import { useTelemetry } from './useTelemetry';
import { useSettingsStore } from '@/stores/settings.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
const nodeHelpers = useNodeHelpers();
@@ -182,7 +183,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
// and halt the execution
if (!chatHasInputData && !chatHasPinData) {
workflowsStore.chatPartialExecutionDestinationNode = options.destinationNode;
workflowsStore.setPanelState('attached');
workflowsStore.setPanelState(LOGS_PANEL_STATE.ATTACHED);
return;
}
}

View File

@@ -12,6 +12,7 @@ import type { ApplicationError } from 'n8n-workflow';
import { useStyles } from './useStyles';
import { useCanvasStore } from '@/stores/canvas.store';
import { useSettingsStore } from '@/stores/settings.store';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
node: {
@@ -37,7 +38,10 @@ export function useToast() {
position: 'bottom-right',
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
offset:
settingsStore.isAiAssistantEnabled || workflowsStore.chatPanelState === 'attached' ? 64 : 0,
settingsStore.isAiAssistantEnabled ||
workflowsStore.chatPanelState === LOGS_PANEL_STATE.ATTACHED
? 64
: 0,
appendTo: '#app-grid',
customClass: 'content-toast',
};

View File

@@ -91,6 +91,7 @@ import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useUsersStore } from '@/stores/users.store';
import { updateCurrentUserSettings } from '@/api/users';
import { useExecutingNode } from '@/composables/useExecutingNode';
import { LOGS_PANEL_STATE, type LogsPanelState } from '@/components/CanvasChat/types/logs';
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
name: '',
@@ -116,8 +117,6 @@ const createEmptyWorkflow = (): IWorkflowDb => ({
let cachedWorkflowKey: string | null = '';
let cachedWorkflow: Workflow | null = null;
type ChatPanelState = 'closed' | 'attached' | 'floating';
export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const uiStore = useUIStore();
const telemetry = useTelemetry();
@@ -147,7 +146,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const isInDebugMode = ref(false);
const chatMessages = ref<string[]>([]);
const chatPartialExecutionDestinationNode = ref<string | null>(null);
const chatPanelState = ref<ChatPanelState>('closed');
const chatPanelState = ref<LogsPanelState>(LOGS_PANEL_STATE.CLOSED);
const { executingNode, addExecutingNode, removeExecutingNode, clearNodeExecutionQueue } =
useExecutingNode();
@@ -1208,7 +1207,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
// If chat trigger node is removed, close chat
if (node.type === CHAT_TRIGGER_NODE_TYPE && !settingsStore.isNewLogsEnabled) {
setPanelState('closed');
setPanelState(LOGS_PANEL_STATE.CLOSED);
}
if (workflow.value.pinData && workflow.value.pinData.hasOwnProperty(node.name)) {
@@ -1670,7 +1669,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
// End Canvas V2 Functions
//
function setPanelState(state: ChatPanelState) {
function setPanelState(state: LogsPanelState) {
chatPanelState.value = state;
}

View File

@@ -25,4 +25,5 @@ export function convertToDisplayDate(fullDate: Date | string | number): {
export const toDayMonth = (fullDate: Date | string) => dateformat(fullDate, 'd mmm');
export const toTime = (fullDate: Date | string) => dateformat(fullDate, 'HH:MM:ss');
export const toTime = (fullDate: Date | string, includeMillis: boolean = false) =>
dateformat(fullDate, includeMillis ? 'HH:MM:ss.l' : 'HH:MM:ss');

View File

@@ -114,6 +114,7 @@ import { isValidNodeConnectionType } from '@/utils/typeGuards';
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
defineOptions({
name: 'NodeView',
@@ -273,7 +274,7 @@ const keyBindingsEnabled = computed(() => {
return !ndvStore.activeNode && uiStore.activeModals.length === 0;
});
const isChatOpen = computed(() => workflowsStore.chatPanelState !== 'closed');
const isChatOpen = computed(() => workflowsStore.chatPanelState !== LOGS_PANEL_STATE.CLOSED);
/**
* Initialization