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"
|
||||
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 }}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
<PanelHeader
|
||||
v-if="isNewLogsEnabled"
|
||||
data-test-id="chat-header"
|
||||
:title="locale.baseText('chat.window.title')"
|
||||
@click="emit('clickHeader')"
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,19 +132,25 @@ function isLastChild(level: number) {
|
||||
:consumed-tokens="subtreeConsumedTokens"
|
||||
/>
|
||||
</N8nText>
|
||||
<div>
|
||||
<N8nIconButton
|
||||
type="secondary"
|
||||
size="medium"
|
||||
:icon="props.node.expanded ? 'chevron-down' : 'chevron-up'"
|
||||
: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>
|
||||
<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'"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user