mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(editor): Show error state in the logs overview (#14248)
This commit is contained in:
@@ -34,7 +34,7 @@ watch(
|
|||||||
v-if="emptyText && initialMessages.length === 0 && messages.length === 0"
|
v-if="emptyText && initialMessages.length === 0 && messages.length === 0"
|
||||||
class="empty-container"
|
class="empty-container"
|
||||||
>
|
>
|
||||||
<div class="empty">
|
<div class="empty" data-test-id="chat-messages-empty">
|
||||||
<N8nIcon icon="comment" size="large" class="emptyIcon" />
|
<N8nIcon icon="comment" size="large" class="emptyIcon" />
|
||||||
<N8nText tag="p" size="medium" color="text-base">
|
<N8nText tag="p" size="medium" color="text-base">
|
||||||
{{ emptyText }}
|
{{ emptyText }}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const mockNodeTypeDescription = ({
|
|||||||
outputs = [NodeConnectionTypes.Main],
|
outputs = [NodeConnectionTypes.Main],
|
||||||
codex = undefined,
|
codex = undefined,
|
||||||
properties = [],
|
properties = [],
|
||||||
|
group,
|
||||||
}: {
|
}: {
|
||||||
name?: INodeTypeDescription['name'];
|
name?: INodeTypeDescription['name'];
|
||||||
icon?: INodeTypeDescription['icon'];
|
icon?: INodeTypeDescription['icon'];
|
||||||
@@ -67,6 +68,7 @@ export const mockNodeTypeDescription = ({
|
|||||||
outputs?: INodeTypeDescription['outputs'];
|
outputs?: INodeTypeDescription['outputs'];
|
||||||
codex?: INodeTypeDescription['codex'];
|
codex?: INodeTypeDescription['codex'];
|
||||||
properties?: INodeTypeDescription['properties'];
|
properties?: INodeTypeDescription['properties'];
|
||||||
|
group?: INodeTypeDescription['group'];
|
||||||
} = {}) =>
|
} = {}) =>
|
||||||
mock<INodeTypeDescription>({
|
mock<INodeTypeDescription>({
|
||||||
name,
|
name,
|
||||||
@@ -80,7 +82,7 @@ export const mockNodeTypeDescription = ({
|
|||||||
defaultVersion: Array.isArray(version) ? version[version.length - 1] : version,
|
defaultVersion: Array.isArray(version) ? version[version.length - 1] : version,
|
||||||
properties: properties as [],
|
properties: properties as [],
|
||||||
maxNodes: Infinity,
|
maxNodes: Infinity,
|
||||||
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
|
group: (group ?? EXECUTABLE_TRIGGER_NODE_TYPES.includes(name)) ? ['trigger'] : [],
|
||||||
inputs,
|
inputs,
|
||||||
outputs,
|
outputs,
|
||||||
codex,
|
codex,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useToast } from '@/composables/useToast';
|
|||||||
import type { IExecutionResponse, INodeUi } from '@/Interface';
|
import type { IExecutionResponse, INodeUi } from '@/Interface';
|
||||||
import type { ChatMessage } from '@n8n/chat/types';
|
import type { ChatMessage } from '@n8n/chat/types';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import { LOGS_PANEL_STATE } from './types/logs';
|
||||||
|
|
||||||
vi.mock('@/composables/useToast', () => {
|
vi.mock('@/composables/useToast', () => {
|
||||||
const showMessage = vi.fn();
|
const showMessage = vi.fn();
|
||||||
@@ -174,7 +175,7 @@ describe('CanvasChat', () => {
|
|||||||
|
|
||||||
return matchedNode;
|
return matchedNode;
|
||||||
});
|
});
|
||||||
workflowsStore.chatPanelState = 'attached';
|
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED;
|
||||||
workflowsStore.isLogsPanelOpen = true;
|
workflowsStore.isLogsPanelOpen = true;
|
||||||
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
|
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
|
||||||
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
|
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
|
||||||
@@ -197,7 +198,7 @@ describe('CanvasChat', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not render chat when panel is closed', async () => {
|
it('should not render chat when panel is closed', async () => {
|
||||||
workflowsStore.chatPanelState = 'closed';
|
workflowsStore.chatPanelState = LOGS_PANEL_STATE.CLOSED;
|
||||||
const { queryByTestId } = renderComponent();
|
const { queryByTestId } = renderComponent();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
|
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
|
||||||
@@ -387,7 +388,7 @@ describe('CanvasChat', () => {
|
|||||||
isLoading: computed(() => false),
|
isLoading: computed(() => false),
|
||||||
});
|
});
|
||||||
|
|
||||||
workflowsStore.chatPanelState = 'attached';
|
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED;
|
||||||
workflowsStore.allowFileUploads = true;
|
workflowsStore.allowFileUploads = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -549,7 +550,7 @@ describe('CanvasChat', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Close chat panel
|
// Close chat panel
|
||||||
workflowsStore.chatPanelState = 'closed';
|
workflowsStore.chatPanelState = LOGS_PANEL_STATE.CLOSED;
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0);
|
expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0);
|
||||||
});
|
});
|
||||||
@@ -559,14 +560,14 @@ describe('CanvasChat', () => {
|
|||||||
const { unmount, rerender } = renderComponent();
|
const { unmount, rerender } = renderComponent();
|
||||||
|
|
||||||
// Set initial state
|
// Set initial state
|
||||||
workflowsStore.chatPanelState = 'attached';
|
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED;
|
||||||
workflowsStore.isLogsPanelOpen = true;
|
workflowsStore.isLogsPanelOpen = true;
|
||||||
|
|
||||||
// Unmount and remount
|
// Unmount and remount
|
||||||
unmount();
|
unmount();
|
||||||
await rerender({});
|
await rerender({});
|
||||||
|
|
||||||
expect(workflowsStore.chatPanelState).toBe('attached');
|
expect(workflowsStore.chatPanelState).toBe(LOGS_PANEL_STATE.ATTACHED);
|
||||||
expect(workflowsStore.isLogsPanelOpen).toBe(true);
|
expect(workflowsStore.isLogsPanelOpen).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -592,10 +593,10 @@ describe('CanvasChat', () => {
|
|||||||
getChatMessages: getChatMessagesSpy,
|
getChatMessages: getChatMessagesSpy,
|
||||||
});
|
});
|
||||||
|
|
||||||
workflowsStore.chatPanelState = 'closed';
|
workflowsStore.chatPanelState = LOGS_PANEL_STATE.CLOSED;
|
||||||
const { rerender } = renderComponent();
|
const { rerender } = renderComponent();
|
||||||
|
|
||||||
workflowsStore.chatPanelState = 'attached';
|
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED;
|
||||||
await rerender({});
|
await rerender({});
|
||||||
|
|
||||||
expect(getChatMessagesSpy).toHaveBeenCalled();
|
expect(getChatMessagesSpy).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
|
|||||||
import { N8nResizeWrapper } from '@n8n/design-system';
|
import { N8nResizeWrapper } from '@n8n/design-system';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
|
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
|
||||||
|
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
||||||
|
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const canvasStore = useCanvasStore();
|
const canvasStore = useCanvasStore();
|
||||||
@@ -49,14 +50,14 @@ const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
|
|||||||
initialWidth: window.document.body.offsetWidth * 0.8,
|
initialWidth: window.document.body.offsetWidth * 0.8,
|
||||||
container: pipContainer,
|
container: pipContainer,
|
||||||
content: pipContent,
|
content: pipContent,
|
||||||
shouldPopOut: computed(() => chatPanelState.value === 'floating'),
|
shouldPopOut: computed(() => chatPanelState.value === LOGS_PANEL_STATE.FLOATING),
|
||||||
onRequestClose: () => {
|
onRequestClose: () => {
|
||||||
if (chatPanelState.value === 'closed') {
|
if (chatPanelState.value === LOGS_PANEL_STATE.CLOSED) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
telemetry.track('User toggled log view', { new_state: 'attached' });
|
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 = () => {
|
const closePanel = () => {
|
||||||
workflowsStore.setPanelState('closed');
|
workflowsStore.setPanelState(LOGS_PANEL_STATE.CLOSED);
|
||||||
};
|
};
|
||||||
|
|
||||||
function onPopOut() {
|
function onPopOut() {
|
||||||
telemetry.track('User toggled log view', { new_state: 'floating' });
|
telemetry.track('User toggled log view', { new_state: 'floating' });
|
||||||
workflowsStore.setPanelState('floating');
|
workflowsStore.setPanelState(LOGS_PANEL_STATE.FLOATING);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watchers
|
// Watchers
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
canvasStore.setPanelHeight(chatPanelState.value === 'attached' ? height.value : 0);
|
canvasStore.setPanelHeight(chatPanelState.value === LOGS_PANEL_STATE.ATTACHED ? height.value : 0);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -98,15 +99,15 @@ watchEffect(() => {
|
|||||||
<div ref="pipContent" :class="$style.pipContent">
|
<div ref="pipContent" :class="$style.pipContent">
|
||||||
<N8nResizeWrapper
|
<N8nResizeWrapper
|
||||||
v-if="chatTriggerNode"
|
v-if="chatTriggerNode"
|
||||||
:is-resizing-enabled="!isPoppedOut && chatPanelState === 'attached'"
|
:is-resizing-enabled="!isPoppedOut && chatPanelState === LOGS_PANEL_STATE.ATTACHED"
|
||||||
:supported-directions="['top']"
|
:supported-directions="['top']"
|
||||||
:class="[$style.resizeWrapper, chatPanelState === 'closed' && $style.empty]"
|
:class="[$style.resizeWrapper, chatPanelState === LOGS_PANEL_STATE.CLOSED && $style.empty]"
|
||||||
:height="height"
|
:height="height"
|
||||||
:style="rootStyles"
|
:style="rootStyles"
|
||||||
@resize="onResizeDebounced"
|
@resize="onResizeDebounced"
|
||||||
>
|
>
|
||||||
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
|
<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
|
<N8nResizeWrapper
|
||||||
:supported-directions="['right']"
|
:supported-directions="['right']"
|
||||||
:width="chatWidth"
|
:width="chatWidth"
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
};
|
||||||
@@ -140,6 +140,7 @@ async function copySessionId() {
|
|||||||
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
|
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
v-if="isNewLogsEnabled"
|
v-if="isNewLogsEnabled"
|
||||||
|
data-test-id="chat-header"
|
||||||
:title="locale.baseText('chat.window.title')"
|
:title="locale.baseText('chat.window.title')"
|
||||||
@click="emit('clickHeader')"
|
@click="emit('clickHeader')"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { v4 as uuid } from 'uuid';
|
|||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
import { computed, provide, ref, watch } from 'vue';
|
import { computed, provide, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import { LOGS_PANEL_STATE } from '../types/logs';
|
||||||
|
|
||||||
interface ChatState {
|
interface ChatState {
|
||||||
currentSessionId: Ref<string>;
|
currentSessionId: Ref<string>;
|
||||||
@@ -129,7 +130,7 @@ export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => voi
|
|||||||
watch(
|
watch(
|
||||||
() => chatPanelState.value,
|
() => chatPanelState.value,
|
||||||
(state) => {
|
(state) => {
|
||||||
if (state !== 'closed') {
|
if (state !== LOGS_PANEL_STATE.CLOSED) {
|
||||||
setChatTriggerNode();
|
setChatTriggerNode();
|
||||||
setConnectedNode();
|
setConnectedNode();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { renderComponent } from '@/__tests__/render';
|
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 { mockedStore } from '@/__tests__/utils';
|
||||||
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
|
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
@@ -7,14 +7,15 @@ import { createTestingPinia, type TestingPinia } from '@pinia/testing';
|
|||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { createTestNode } from '@/__tests__/mocks';
|
|
||||||
import { CHAT_TRIGGER_NODE_TYPE } from '@/constants';
|
|
||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
|
import { executionResponse, aiChatWorkflow, simpleWorkflow, nodeTypes } from '../__test__/data';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
|
||||||
describe('LogsPanel', () => {
|
describe('LogsPanel', () => {
|
||||||
let pinia: TestingPinia;
|
let pinia: TestingPinia;
|
||||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||||
|
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
return renderComponent(LogsPanel, {
|
return renderComponent(LogsPanel, {
|
||||||
@@ -39,50 +40,97 @@ describe('LogsPanel', () => {
|
|||||||
settingsStore.isNewLogsEnabled = true;
|
settingsStore.isNewLogsEnabled = true;
|
||||||
|
|
||||||
workflowsStore = mockedStore(useWorkflowsStore);
|
workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
|
workflowsStore.setWorkflowExecutionData(null);
|
||||||
|
|
||||||
|
nodeTypeStore = mockedStore(useNodeTypesStore);
|
||||||
|
nodeTypeStore.setNodeTypes(nodeTypes);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders collapsed panel by default', async () => {
|
it('should render collapsed panel by default', async () => {
|
||||||
const rendered = render();
|
workflowsStore.setWorkflow(simpleWorkflow);
|
||||||
|
|
||||||
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 })];
|
|
||||||
|
|
||||||
const rendered = render();
|
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 () => {
|
it('opens collapsed panel when clicked', async () => {
|
||||||
|
workflowsStore.setWorkflow(aiChatWorkflow);
|
||||||
|
|
||||||
const rendered = render();
|
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.findByTestId('logs-overview-empty')).toBeInTheDocument();
|
||||||
|
|
||||||
expect(
|
|
||||||
await rendered.findByText('Nothing to display yet', { exact: false }),
|
|
||||||
).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();
|
const rendered = render();
|
||||||
|
|
||||||
await rendered.findByText('Logs');
|
const overviewPanel = await rendered.findByTestId('logs-overview-header');
|
||||||
|
|
||||||
await fireEvent.click(rendered.getAllByRole('button').pop()!);
|
await fireEvent.click(within(overviewPanel).getByLabelText('Open panel'));
|
||||||
expect(rendered.getByText('Nothing to display yet', { exact: false })).toBeInTheDocument();
|
expect(rendered.getByTestId('logs-overview-empty')).toBeInTheDocument();
|
||||||
|
|
||||||
await fireEvent.click(rendered.getAllByRole('button').pop()!);
|
await fireEvent.click(within(overviewPanel).getByLabelText('Collapse panel'));
|
||||||
await waitFor(() =>
|
expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument();
|
||||||
expect(
|
});
|
||||||
rendered.queryByText('Nothing to display yet', { exact: false }),
|
|
||||||
).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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { computed, ref, useTemplateRef, watch } from 'vue';
|
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 { useChatState } from '@/components/CanvasChat/composables/useChatState';
|
||||||
import { useResize } from '@/components/CanvasChat/composables/useResize';
|
import { useResize } from '@/components/CanvasChat/composables/useResize';
|
||||||
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
|
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
|
||||||
@@ -9,14 +9,16 @@ import { useTelemetry } from '@/composables/useTelemetry';
|
|||||||
import { CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE } from '@/constants';
|
import { CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE } from '@/constants';
|
||||||
import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue';
|
import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue';
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
|
||||||
import { useStyles } from '@/composables/useStyles';
|
|
||||||
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue';
|
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue';
|
||||||
|
import LogsDetailsPanel from '@/components/CanvasChat/future/components/LogDetailsPanel.vue';
|
||||||
|
import { LOGS_PANEL_STATE, type LogEntryIdentity } from '@/components/CanvasChat/types/logs';
|
||||||
|
import LogsPanelActions from '@/components/CanvasChat/future/components/LogsPanelActions.vue';
|
||||||
|
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const canvasStore = useCanvasStore();
|
const canvasStore = useCanvasStore();
|
||||||
const panelState = computed(() => workflowsStore.chatPanelState);
|
const panelState = computed(() => workflowsStore.chatPanelState);
|
||||||
const container = ref<HTMLElement>();
|
const container = ref<HTMLElement>();
|
||||||
|
const selectedLogEntry = ref<LogEntryIdentity | undefined>(undefined);
|
||||||
const pipContainer = useTemplateRef('pipContainer');
|
const pipContainer = useTemplateRef('pipContainer');
|
||||||
const pipContent = useTemplateRef('pipContent');
|
const pipContent = useTemplateRef('pipContent');
|
||||||
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
||||||
@@ -25,7 +27,6 @@ const hasChat = computed(() =>
|
|||||||
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
|
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const locales = useI18n();
|
|
||||||
|
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
@@ -34,50 +35,63 @@ const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResi
|
|||||||
|
|
||||||
const { currentSessionId, messages, connectedNode, sendMessage, refreshSession, displayExecution } =
|
const { currentSessionId, messages, connectedNode, sendMessage, refreshSession, displayExecution } =
|
||||||
useChatState(ref(false), onWindowResize);
|
useChatState(ref(false), onWindowResize);
|
||||||
const appStyles = useStyles();
|
const isLogDetailsOpen = computed(() => selectedLogEntry.value !== undefined);
|
||||||
const tooltipZIndex = computed(() => appStyles.APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON + 100);
|
|
||||||
|
|
||||||
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
|
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
|
||||||
initialHeight: 400,
|
initialHeight: 400,
|
||||||
initialWidth: window.document.body.offsetWidth * 0.8,
|
initialWidth: window.document.body.offsetWidth * 0.8,
|
||||||
container: pipContainer,
|
container: pipContainer,
|
||||||
content: pipContent,
|
content: pipContent,
|
||||||
shouldPopOut: computed(() => panelState.value === 'floating'),
|
shouldPopOut: computed(() => panelState.value === LOGS_PANEL_STATE.FLOATING),
|
||||||
onRequestClose: () => {
|
onRequestClose: () => {
|
||||||
if (panelState.value === 'closed') {
|
if (panelState.value === LOGS_PANEL_STATE.CLOSED) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
telemetry.track('User toggled log view', { new_state: 'attached' });
|
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() {
|
function onToggleOpen() {
|
||||||
if (panelState.value === 'closed') {
|
if (panelState.value === LOGS_PANEL_STATE.CLOSED) {
|
||||||
telemetry.track('User toggled log view', { new_state: 'attached' });
|
telemetry.track('User toggled log view', { new_state: 'attached' });
|
||||||
workflowsStore.setPanelState('attached');
|
workflowsStore.setPanelState(LOGS_PANEL_STATE.ATTACHED);
|
||||||
} else {
|
} else {
|
||||||
telemetry.track('User toggled log view', { new_state: 'collapsed' });
|
telemetry.track('User toggled log view', { new_state: 'collapsed' });
|
||||||
workflowsStore.setPanelState('closed');
|
workflowsStore.setPanelState(LOGS_PANEL_STATE.CLOSED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClickHeader() {
|
function handleClickHeader() {
|
||||||
if (panelState.value === 'closed') {
|
if (panelState.value === LOGS_PANEL_STATE.CLOSED) {
|
||||||
telemetry.track('User toggled log view', { new_state: 'attached' });
|
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() {
|
function onPopOut() {
|
||||||
telemetry.track('User toggled log view', { new_state: 'floating' });
|
telemetry.track('User toggled log view', { new_state: 'floating' });
|
||||||
workflowsStore.setPanelState('floating');
|
workflowsStore.setPanelState(LOGS_PANEL_STATE.FLOATING);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([panelState, height], ([state, h]) => {
|
watch([panelState, height], ([state, h]) => {
|
||||||
canvasStore.setPanelHeight(
|
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>
|
</script>
|
||||||
@@ -88,16 +102,16 @@ watch([panelState, height], ([state, h]) => {
|
|||||||
<N8nResizeWrapper
|
<N8nResizeWrapper
|
||||||
:height="height"
|
:height="height"
|
||||||
:supported-directions="['top']"
|
:supported-directions="['top']"
|
||||||
:is-resizing-enabled="panelState === 'attached'"
|
:is-resizing-enabled="panelState === LOGS_PANEL_STATE.ATTACHED"
|
||||||
:style="rootStyles"
|
:style="rootStyles"
|
||||||
:class="[$style.resizeWrapper, panelState === 'closed' ? '' : $style.isOpen]"
|
:class="[$style.resizeWrapper, panelState === LOGS_PANEL_STATE.CLOSED ? '' : $style.isOpen]"
|
||||||
@resize="onResizeDebounced"
|
@resize="onResizeDebounced"
|
||||||
>
|
>
|
||||||
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
|
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
|
||||||
<N8nResizeWrapper
|
<N8nResizeWrapper
|
||||||
v-if="hasChat"
|
v-if="hasChat"
|
||||||
:supported-directions="['right']"
|
:supported-directions="['right']"
|
||||||
:is-resizing-enabled="panelState !== 'closed'"
|
:is-resizing-enabled="panelState !== LOGS_PANEL_STATE.CLOSED"
|
||||||
:width="chatWidth"
|
:width="chatWidth"
|
||||||
:class="$style.chat"
|
:class="$style.chat"
|
||||||
:window="pipWindow"
|
:window="pipWindow"
|
||||||
@@ -105,13 +119,13 @@ watch([panelState, height], ([state, h]) => {
|
|||||||
>
|
>
|
||||||
<ChatMessagesPanel
|
<ChatMessagesPanel
|
||||||
data-test-id="canvas-chat"
|
data-test-id="canvas-chat"
|
||||||
:is-open="panelState !== 'closed'"
|
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:session-id="currentSessionId"
|
:session-id="currentSessionId"
|
||||||
:past-chat-messages="previousChatMessages"
|
:past-chat-messages="previousChatMessages"
|
||||||
:show-close-button="false"
|
:show-close-button="false"
|
||||||
:is-new-logs-enabled="true"
|
:is-new-logs-enabled="true"
|
||||||
@close="handleToggleOpen"
|
@close="onToggleOpen"
|
||||||
@refresh-session="refreshSession"
|
@refresh-session="refreshSession"
|
||||||
@display-execution="displayExecution"
|
@display-execution="displayExecution"
|
||||||
@send-message="sendMessage"
|
@send-message="sendMessage"
|
||||||
@@ -119,46 +133,27 @@ watch([panelState, height], ([state, h]) => {
|
|||||||
/>
|
/>
|
||||||
</N8nResizeWrapper>
|
</N8nResizeWrapper>
|
||||||
<LogsOverviewPanel
|
<LogsOverviewPanel
|
||||||
:is-open="panelState !== 'closed'"
|
:class="$style.logsOverview"
|
||||||
|
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
||||||
:node="connectedNode"
|
: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"
|
@click-header="handleClickHeader"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<N8nTooltip
|
<LogsPanelActions v-if="isLogDetailsOpen" v-bind="logsPanelActionsProps" />
|
||||||
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>
|
|
||||||
</template>
|
</template>
|
||||||
</LogsOverviewPanel>
|
</LogsDetailsPanel>
|
||||||
</div>
|
</div>
|
||||||
</N8nResizeWrapper>
|
</N8nResizeWrapper>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,4 +204,17 @@ watch([panelState, height], ([state, h]) => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
max-width: 100%;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { renderComponent } from '@/__tests__/render';
|
import { renderComponent } from '@/__tests__/render';
|
||||||
import LogsOverviewPanel from './LogsOverviewPanel.vue';
|
import LogsOverviewPanel from './LogsOverviewPanel.vue';
|
||||||
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
|
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
|
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
@@ -8,26 +7,12 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import { h, type ExtractPropTypes } from 'vue';
|
import { h, type ExtractPropTypes } from 'vue';
|
||||||
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
||||||
|
import { aiAgentNode, executionResponse, aiChatWorkflow } from '../../__test__/data';
|
||||||
|
|
||||||
describe('LogsOverviewPanel', () => {
|
describe('LogsOverviewPanel', () => {
|
||||||
let pinia: TestingPinia;
|
let pinia: TestingPinia;
|
||||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
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>) {
|
function render(props: ExtractPropTypes<typeof LogsOverviewPanel>) {
|
||||||
return renderComponent(LogsOverviewPanel, {
|
return renderComponent(LogsOverviewPanel, {
|
||||||
props,
|
props,
|
||||||
@@ -49,73 +34,24 @@ describe('LogsOverviewPanel', () => {
|
|||||||
setActivePinia(pinia);
|
setActivePinia(pinia);
|
||||||
|
|
||||||
workflowsStore = mockedStore(useWorkflowsStore);
|
workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
workflowsStore.setWorkflow(workflow);
|
workflowsStore.setWorkflow(aiChatWorkflow);
|
||||||
workflowsStore.setWorkflowExecutionData(null);
|
workflowsStore.setWorkflowExecutionData(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render body if the panel is not open', () => {
|
it('should not render body if the panel is not open', () => {
|
||||||
const rendered = render({ isOpen: false, node: null });
|
const rendered = render({ isOpen: false, node: null });
|
||||||
|
|
||||||
expect(
|
expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument();
|
||||||
rendered.queryByText('Nothing to display yet', { exact: false }),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render empty text if there is no execution', () => {
|
it('should render empty text if there is no execution', () => {
|
||||||
const rendered = render({ isOpen: true, node: null });
|
const rendered = render({ isOpen: true, 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 () => {
|
it('should render summary text and executed nodes if there is an execution', async () => {
|
||||||
workflowsStore.setWorkflowExecutionData({
|
workflowsStore.setWorkflowExecutionData(executionResponse);
|
||||||
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'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const rendered = render({ isOpen: true, node: aiAgentNode });
|
const rendered = render({ isOpen: true, node: aiAgentNode });
|
||||||
const summary = within(rendered.container.querySelector('.summary')!);
|
const summary = within(rendered.container.querySelector('.summary')!);
|
||||||
@@ -131,14 +67,15 @@ describe('LogsOverviewPanel', () => {
|
|||||||
|
|
||||||
expect(row1.queryByText('AI Agent')).toBeInTheDocument();
|
expect(row1.queryByText('AI Agent')).toBeInTheDocument();
|
||||||
expect(row1.queryByText('Success in 1.778s')).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();
|
expect(row1.queryByText('555 Tokens')).toBeInTheDocument();
|
||||||
|
|
||||||
const row2 = within(tree.queryAllByRole('treeitem')[1]);
|
const row2 = within(tree.queryAllByRole('treeitem')[1]);
|
||||||
|
|
||||||
expect(row2.queryByText('AI Model')).toBeInTheDocument();
|
expect(row2.queryByText('AI Model')).toBeInTheDocument();
|
||||||
expect(row2.queryByText('Success in 1.777s')).toBeInTheDocument();
|
expect(row2.queryByText('Error')).toBeInTheDocument();
|
||||||
expect(row2.queryByText('Started 2025-03-26T00:00:00.003Z')).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();
|
expect(row2.queryByText('555 Tokens')).toBeInTheDocument();
|
||||||
|
|
||||||
// collapse tree
|
// collapse tree
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useI18n } from '@/composables/useI18n';
|
|||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
|
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 { ElTree, type TreeNode as ElTreeNode } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
createAiData,
|
createAiData,
|
||||||
@@ -18,10 +18,16 @@ import { type INodeUi } from '@/Interface';
|
|||||||
import { upperFirst } from 'lodash-es';
|
import { upperFirst } from 'lodash-es';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
|
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
|
||||||
|
import { type LogEntryIdentity } from '@/components/CanvasChat/types/logs';
|
||||||
|
import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.vue';
|
||||||
|
|
||||||
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: {} }>();
|
defineSlots<{ actions: {} }>();
|
||||||
|
|
||||||
@@ -41,9 +47,9 @@ const executionTree = computed<TreeNode[]>(() =>
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
const isEmpty = computed(() => workflowsStore.workflowExecutionData === null);
|
const isEmpty = computed(() => workflowsStore.workflowExecutionData === null);
|
||||||
const switchViewOptions = computed<Array<{ label: string; value: string }>>(() => [
|
const switchViewOptions = computed(() => [
|
||||||
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' },
|
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
|
||||||
{ label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' },
|
{ label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' as const },
|
||||||
]);
|
]);
|
||||||
const executionStatusText = computed(() => {
|
const executionStatusText = computed(() => {
|
||||||
const execution = workflowsStore.workflowExecutionData;
|
const execution = workflowsStore.workflowExecutionData;
|
||||||
@@ -70,20 +76,18 @@ const consumedTokens = computed(() =>
|
|||||||
getTotalConsumedTokens(...executionTree.value.map(getSubtreeTotalConsumedTokens)),
|
getTotalConsumedTokens(...executionTree.value.map(getSubtreeTotalConsumedTokens)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedRun = ref<{ node: string; runIndex: number } | undefined>(undefined);
|
|
||||||
|
|
||||||
function onClearExecutionData() {
|
function onClearExecutionData() {
|
||||||
workflowsStore.setWorkflowExecutionData(null);
|
workflowsStore.setWorkflowExecutionData(null);
|
||||||
nodeHelpers.updateNodesExecutionIssues();
|
nodeHelpers.updateNodesExecutionIssues();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClickNode(clicked: TreeNode) {
|
function handleClickNode(clicked: TreeNode) {
|
||||||
if (selectedRun.value?.node === clicked.node && selectedRun.value.runIndex === clicked.runIndex) {
|
if (selected?.node === clicked.node && selected.runIndex === clicked.runIndex) {
|
||||||
selectedRun.value = undefined;
|
emit('select', undefined);
|
||||||
return;
|
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', {
|
telemetry.track('User selected node in log view', {
|
||||||
node_type: workflowsStore.nodesByName[clicked.node].type,
|
node_type: workflowsStore.nodesByName[clicked.node].type,
|
||||||
node_id: workflowsStore.nodesByName[clicked.node].id,
|
node_id: workflowsStore.nodesByName[clicked.node].id,
|
||||||
@@ -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) {
|
function handleToggleExpanded(treeNode: ElTreeNode) {
|
||||||
treeNode.expanded = !treeNode.expanded;
|
treeNode.expanded = !treeNode.expanded;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.container">
|
<div :class="$style.container" data-test-id="logs-overview">
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
:title="locale.baseText('logs.overview.header.title')"
|
:title="locale.baseText('logs.overview.header.title')"
|
||||||
|
data-test-id="logs-overview-header"
|
||||||
@click="emit('clickHeader')"
|
@click="emit('clickHeader')"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
@@ -120,8 +132,19 @@ function handleToggleExpanded(treeNode: ElTreeNode) {
|
|||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
</template>
|
</template>
|
||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
<div v-if="isOpen" :class="[$style.content, isEmpty ? $style.empty : '']">
|
<div
|
||||||
<N8nText v-if="isEmpty" tag="p" size="medium" color="text-base" :class="$style.emptyText">
|
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') }}
|
{{ locale.baseText('logs.overview.body.empty.message') }}
|
||||||
</N8nText>
|
</N8nText>
|
||||||
<div v-else :class="$style.scrollable">
|
<div v-else :class="$style.scrollable">
|
||||||
@@ -140,6 +163,7 @@ function handleToggleExpanded(treeNode: ElTreeNode) {
|
|||||||
</N8nText>
|
</N8nText>
|
||||||
<ElTree
|
<ElTree
|
||||||
v-if="executionTree.length > 0"
|
v-if="executionTree.length > 0"
|
||||||
|
node-key="id"
|
||||||
:class="$style.tree"
|
:class="$style.tree"
|
||||||
:indent="0"
|
:indent="0"
|
||||||
:data="executionTree"
|
:data="executionTree"
|
||||||
@@ -151,9 +175,8 @@ function handleToggleExpanded(treeNode: ElTreeNode) {
|
|||||||
<LogsOverviewRow
|
<LogsOverviewRow
|
||||||
:data="data"
|
:data="data"
|
||||||
:node="elTreeNode"
|
:node="elTreeNode"
|
||||||
:is-selected="
|
:is-selected="data.node === selected?.node && data.runIndex === selected?.runIndex"
|
||||||
data.node === selectedRun?.node && data.runIndex === selectedRun?.runIndex
|
:is-compact="selected !== undefined"
|
||||||
"
|
|
||||||
:should-show-consumed-tokens="consumedTokens.totalTokens > 0"
|
:should-show-consumed-tokens="consumedTokens.totalTokens > 0"
|
||||||
@toggle-expanded="handleToggleExpanded"
|
@toggle-expanded="handleToggleExpanded"
|
||||||
/>
|
/>
|
||||||
@@ -162,8 +185,9 @@ function handleToggleExpanded(treeNode: ElTreeNode) {
|
|||||||
<N8nRadioButtons
|
<N8nRadioButtons
|
||||||
size="medium"
|
size="medium"
|
||||||
:class="$style.switchViewButtons"
|
:class="$style.switchViewButtons"
|
||||||
:model-value="selectedRun ? 'details' : 'overview'"
|
:model-value="selected ? 'details' : 'overview'"
|
||||||
:options="switchViewOptions"
|
:options="switchViewOptions"
|
||||||
|
@update:model-value="handleSwitchView"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,6 +202,7 @@ function handleToggleExpanded(treeNode: ElTreeNode) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background-color: var(--color-foreground-xlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
|||||||
@@ -4,18 +4,21 @@ import { getSubtreeTotalConsumedTokens, type TreeNode } from '@/components/RunDa
|
|||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { type INodeUi } from '@/Interface';
|
import { type INodeUi } from '@/Interface';
|
||||||
import { type ExecutionStatus, type ITaskData } from 'n8n-workflow';
|
import { N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
|
||||||
import { N8nIconButton, N8nText } from '@n8n/design-system';
|
import { type ITaskData } from 'n8n-workflow';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { upperFirst } from 'lodash-es';
|
import { upperFirst } from 'lodash-es';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
|
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
|
||||||
|
import { I18nT } from 'vue-i18n';
|
||||||
|
import { toDayMonth, toTime } from '@/utils/formatters/dateFormatter';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: TreeNode;
|
data: TreeNode;
|
||||||
node: ElTreeNode;
|
node: ElTreeNode;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
shouldShowConsumedTokens: boolean;
|
shouldShowConsumedTokens: boolean;
|
||||||
|
isCompact: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ toggleExpanded: [node: ElTreeNode] }>();
|
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 type = computed(() => (node.value ? nodeTypeStore.getNodeType(node.value.type) : undefined));
|
||||||
const depth = computed(() => (props.node.level ?? 1) - 1);
|
const depth = computed(() => (props.node.level ?? 1) - 1);
|
||||||
const timeTookText = computed(() => {
|
const isSettled = computed(
|
||||||
const finalStatuses: ExecutionStatus[] = ['crashed', 'error', 'success'];
|
() =>
|
||||||
const status = runData.value?.executionStatus;
|
runData.value?.executionStatus &&
|
||||||
|
['crashed', 'error', 'success'].includes(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 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(() =>
|
const subtreeConsumedTokens = computed(() =>
|
||||||
props.shouldShowConsumedTokens ? getSubtreeTotalConsumedTokens(props.data) : undefined,
|
props.shouldShowConsumedTokens ? getSubtreeTotalConsumedTokens(props.data) : undefined,
|
||||||
@@ -82,7 +74,12 @@ function isLastChild(level: number) {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="node !== undefined"
|
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">
|
<template v-for="level in depth" :key="level">
|
||||||
<div
|
<div
|
||||||
@@ -93,11 +90,30 @@ function isLastChild(level: number) {
|
|||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
<div :class="$style.background" :style="{ '--indent-depth': depth }" />
|
||||||
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
|
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
|
||||||
<N8nText tag="div" :bold="true" size="small" :class="$style.name">{{ node.name }}</N8nText>
|
<N8nText
|
||||||
<N8nText tag="div" color="text-light" size="small" :class="$style.timeTook">{{
|
tag="div"
|
||||||
timeTookText
|
:bold="true"
|
||||||
}}</N8nText>
|
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">{{
|
<N8nText tag="div" color="text-light" size="small" :class="$style.startedAt">{{
|
||||||
startedAtText
|
startedAtText
|
||||||
}}</N8nText>
|
}}</N8nText>
|
||||||
@@ -116,19 +132,25 @@ function isLastChild(level: number) {
|
|||||||
:consumed-tokens="subtreeConsumedTokens"
|
:consumed-tokens="subtreeConsumedTokens"
|
||||||
/>
|
/>
|
||||||
</N8nText>
|
</N8nText>
|
||||||
<div>
|
<N8nIcon
|
||||||
<N8nIconButton
|
v-if="isError && isCompact"
|
||||||
type="secondary"
|
size="medium"
|
||||||
size="medium"
|
color="danger"
|
||||||
:icon="props.node.expanded ? 'chevron-down' : 'chevron-up'"
|
icon="exclamation-triangle"
|
||||||
:style="{
|
:class="$style.compactErrorIcon"
|
||||||
visibility: props.data.children.length === 0 ? 'hidden' : '',
|
/>
|
||||||
color: 'var(--color-text-base)', // give higher specificity than the style from the component itself
|
<N8nIconButton
|
||||||
}"
|
v-if="!isCompact || props.data.children.length > 0"
|
||||||
:class="$style.toggleButton"
|
type="secondary"
|
||||||
@click.stop="emit('toggleExpanded', props.node)"
|
size="medium"
|
||||||
/>
|
:icon="props.node.expanded ? 'chevron-down' : 'chevron-up'"
|
||||||
</div>
|
:style="{
|
||||||
|
visibility: props.data.children.length === 0 ? 'hidden' : '',
|
||||||
|
color: 'var(--color-text-base)', // give higher specificity than the style from the component itself
|
||||||
|
}"
|
||||||
|
:class="$style.toggleButton"
|
||||||
|
@click.stop="emit('toggleExpanded', props.node)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -138,6 +160,8 @@ function isLastChild(level: number) {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -145,28 +169,25 @@ function isLastChild(level: number) {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding: var(--spacing-2xs);
|
padding: var(--spacing-2xs);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
& > :has(.toggleButton) {
|
.background {
|
||||||
flex-shrink: 0;
|
position: absolute;
|
||||||
display: flex;
|
left: calc(var(--indent-depth) * 32px);
|
||||||
align-items: center;
|
top: 0;
|
||||||
padding: 0;
|
width: calc(100% - var(--indent-depth) * 32px);
|
||||||
}
|
height: 100%;
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
z-index: -1;
|
||||||
|
|
||||||
& > .icon {
|
.selected &,
|
||||||
border-top-left-radius: var(--border-radius-base);
|
.container:hover & {
|
||||||
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) {
|
|
||||||
background-color: var(--color-foreground-base);
|
background-color: var(--color-foreground-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selected:not(:hover).error & {
|
||||||
|
background-color: var(--color-danger-tint-2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.indent {
|
.indent {
|
||||||
@@ -212,12 +233,29 @@ function isLastChild(level: number) {
|
|||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 20%;
|
width: 20%;
|
||||||
|
|
||||||
|
.errorIcon {
|
||||||
|
margin-right: var(--spacing-4xs);
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact:hover & {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact:not(:hover) & {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.startedAt {
|
.startedAt {
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 30%;
|
width: 30%;
|
||||||
|
|
||||||
|
.compact & {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.consumedTokens {
|
.consumedTokens {
|
||||||
@@ -225,12 +263,33 @@ function isLastChild(level: number) {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 10%;
|
width: 10%;
|
||||||
text-align: right;
|
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 {
|
.toggleButton {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
margin-inline-end: var(--spacing-5xs);
|
margin-inline-end: var(--spacing-5xs);
|
||||||
|
color: var(--color-text-base);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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];
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
||||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
@@ -39,7 +40,7 @@ const uiStore = useUIStore();
|
|||||||
const { runEntireWorkflow } = useRunWorkflow({ router });
|
const { runEntireWorkflow } = useRunWorkflow({ router });
|
||||||
const { toggleChatOpen } = useCanvasOperations({ 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 isExecuting = computed(() => uiStore.isActionActive.workflowRunning);
|
||||||
const testId = computed(() => `execute-workflow-button-${name}`);
|
const testId = computed(() => `execute-workflow-button-${name}`);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { nextTick } from 'vue';
|
|||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import type { CanvasLayoutEvent } from './useCanvasLayout';
|
import type { CanvasLayoutEvent } from './useCanvasLayout';
|
||||||
import { useTelemetry } from './useTelemetry';
|
import { useTelemetry } from './useTelemetry';
|
||||||
|
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
||||||
|
|
||||||
vi.mock('vue-router', async (importOriginal) => {
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
const actual = await importOriginal<{}>();
|
const actual = await importOriginal<{}>();
|
||||||
@@ -2932,11 +2933,11 @@ describe('useCanvasOperations', () => {
|
|||||||
const { toggleChatOpen } = useCanvasOperations({ router });
|
const { toggleChatOpen } = useCanvasOperations({ router });
|
||||||
|
|
||||||
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
|
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
|
||||||
workflowsStore.chatPanelState = 'closed';
|
workflowsStore.chatPanelState = LOGS_PANEL_STATE.CLOSED;
|
||||||
|
|
||||||
await toggleChatOpen('main');
|
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 () => {
|
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 });
|
const { toggleChatOpen } = useCanvasOperations({ router });
|
||||||
|
|
||||||
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
|
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
|
||||||
workflowsStore.chatPanelState = 'attached';
|
workflowsStore.chatPanelState = LOGS_PANEL_STATE.ATTACHED;
|
||||||
|
|
||||||
await toggleChatOpen('main');
|
await toggleChatOpen('main');
|
||||||
|
|
||||||
expect(workflowsStore.setPanelState).toHaveBeenCalledWith('closed');
|
expect(workflowsStore.setPanelState).toHaveBeenCalledWith(LOGS_PANEL_STATE.CLOSED);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
|
|||||||
import { isPresent } from '../utils/typesUtils';
|
import { isPresent } from '../utils/typesUtils';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import type { CanvasLayoutEvent } from './useCanvasLayout';
|
import type { CanvasLayoutEvent } from './useCanvasLayout';
|
||||||
|
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
||||||
|
|
||||||
type AddNodeData = Partial<INodeUi> & {
|
type AddNodeData = Partial<INodeUi> & {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -1976,7 +1977,9 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
|||||||
const workflow = workflowsStore.getCurrentWorkflow();
|
const workflow = workflowsStore.getCurrentWorkflow();
|
||||||
|
|
||||||
workflowsStore.setPanelState(
|
workflowsStore.setPanelState(
|
||||||
workflowsStore.chatPanelState === 'closed' ? 'attached' : 'closed',
|
workflowsStore.chatPanelState === LOGS_PANEL_STATE.CLOSED
|
||||||
|
? LOGS_PANEL_STATE.ATTACHED
|
||||||
|
: LOGS_PANEL_STATE.CLOSED,
|
||||||
);
|
);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { useTelemetry } from './useTelemetry';
|
|||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
|
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
|
||||||
|
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
||||||
|
|
||||||
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
|
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
|
||||||
const nodeHelpers = useNodeHelpers();
|
const nodeHelpers = useNodeHelpers();
|
||||||
@@ -182,7 +183,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
|||||||
// and halt the execution
|
// and halt the execution
|
||||||
if (!chatHasInputData && !chatHasPinData) {
|
if (!chatHasInputData && !chatHasPinData) {
|
||||||
workflowsStore.chatPartialExecutionDestinationNode = options.destinationNode;
|
workflowsStore.chatPartialExecutionDestinationNode = options.destinationNode;
|
||||||
workflowsStore.setPanelState('attached');
|
workflowsStore.setPanelState(LOGS_PANEL_STATE.ATTACHED);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { ApplicationError } from 'n8n-workflow';
|
|||||||
import { useStyles } from './useStyles';
|
import { useStyles } from './useStyles';
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
||||||
|
|
||||||
export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
|
export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
|
||||||
node: {
|
node: {
|
||||||
@@ -37,7 +38,10 @@ export function useToast() {
|
|||||||
position: 'bottom-right',
|
position: 'bottom-right',
|
||||||
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
|
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
|
||||||
offset:
|
offset:
|
||||||
settingsStore.isAiAssistantEnabled || workflowsStore.chatPanelState === 'attached' ? 64 : 0,
|
settingsStore.isAiAssistantEnabled ||
|
||||||
|
workflowsStore.chatPanelState === LOGS_PANEL_STATE.ATTACHED
|
||||||
|
? 64
|
||||||
|
: 0,
|
||||||
appendTo: '#app-grid',
|
appendTo: '#app-grid',
|
||||||
customClass: 'content-toast',
|
customClass: 'content-toast',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
|||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { updateCurrentUserSettings } from '@/api/users';
|
import { updateCurrentUserSettings } from '@/api/users';
|
||||||
import { useExecutingNode } from '@/composables/useExecutingNode';
|
import { useExecutingNode } from '@/composables/useExecutingNode';
|
||||||
|
import { LOGS_PANEL_STATE, type LogsPanelState } from '@/components/CanvasChat/types/logs';
|
||||||
|
|
||||||
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -116,8 +117,6 @@ const createEmptyWorkflow = (): IWorkflowDb => ({
|
|||||||
let cachedWorkflowKey: string | null = '';
|
let cachedWorkflowKey: string | null = '';
|
||||||
let cachedWorkflow: Workflow | null = null;
|
let cachedWorkflow: Workflow | null = null;
|
||||||
|
|
||||||
type ChatPanelState = 'closed' | 'attached' | 'floating';
|
|
||||||
|
|
||||||
export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
@@ -147,7 +146,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
const isInDebugMode = ref(false);
|
const isInDebugMode = ref(false);
|
||||||
const chatMessages = ref<string[]>([]);
|
const chatMessages = ref<string[]>([]);
|
||||||
const chatPartialExecutionDestinationNode = ref<string | null>(null);
|
const chatPartialExecutionDestinationNode = ref<string | null>(null);
|
||||||
const chatPanelState = ref<ChatPanelState>('closed');
|
const chatPanelState = ref<LogsPanelState>(LOGS_PANEL_STATE.CLOSED);
|
||||||
|
|
||||||
const { executingNode, addExecutingNode, removeExecutingNode, clearNodeExecutionQueue } =
|
const { executingNode, addExecutingNode, removeExecutingNode, clearNodeExecutionQueue } =
|
||||||
useExecutingNode();
|
useExecutingNode();
|
||||||
@@ -1208,7 +1207,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
|
|
||||||
// If chat trigger node is removed, close chat
|
// If chat trigger node is removed, close chat
|
||||||
if (node.type === CHAT_TRIGGER_NODE_TYPE && !settingsStore.isNewLogsEnabled) {
|
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)) {
|
if (workflow.value.pinData && workflow.value.pinData.hasOwnProperty(node.name)) {
|
||||||
@@ -1670,7 +1669,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
// End Canvas V2 Functions
|
// End Canvas V2 Functions
|
||||||
//
|
//
|
||||||
|
|
||||||
function setPanelState(state: ChatPanelState) {
|
function setPanelState(state: LogsPanelState) {
|
||||||
chatPanelState.value = state;
|
chatPanelState.value = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ export function convertToDisplayDate(fullDate: Date | string | number): {
|
|||||||
|
|
||||||
export const toDayMonth = (fullDate: Date | string) => dateformat(fullDate, 'd mmm');
|
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');
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
|||||||
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
||||||
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
|
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
|
||||||
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
|
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
|
||||||
|
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'NodeView',
|
name: 'NodeView',
|
||||||
@@ -273,7 +274,7 @@ const keyBindingsEnabled = computed(() => {
|
|||||||
return !ndvStore.activeNode && uiStore.activeModals.length === 0;
|
return !ndvStore.activeNode && uiStore.activeModals.length === 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isChatOpen = computed(() => workflowsStore.chatPanelState !== 'closed');
|
const isChatOpen = computed(() => workflowsStore.chatPanelState !== LOGS_PANEL_STATE.CLOSED);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialization
|
* Initialization
|
||||||
|
|||||||
Reference in New Issue
Block a user