mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(editor): Keyboard shortcuts for the log view (#15378)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useStyles } from '@/composables/useStyles';
|
||||
import { useAssistantStore } from '@/stores/assistant.store';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
import AssistantAvatar from '@n8n/design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
|
||||
import AskAssistantButton from '@n8n/design-system/components/AskAssistantButton/AskAssistantButton.vue';
|
||||
import { computed } from 'vue';
|
||||
@@ -10,7 +10,7 @@ import { computed } from 'vue';
|
||||
const assistantStore = useAssistantStore();
|
||||
const i18n = useI18n();
|
||||
const { APP_Z_INDEXES } = useStyles();
|
||||
const canvasStore = useCanvasStore();
|
||||
const logsStore = useLogsStore();
|
||||
|
||||
const lastUnread = computed(() => {
|
||||
const msg = assistantStore.lastUnread;
|
||||
@@ -41,7 +41,7 @@ const onClick = () => {
|
||||
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
|
||||
:class="$style.container"
|
||||
data-test-id="ask-assistant-floating-button"
|
||||
:style="{ '--canvas-panel-height-offset': `${canvasStore.panelHeight}px` }"
|
||||
:style="{ '--canvas-panel-height-offset': `${logsStore.height}px` }"
|
||||
>
|
||||
<n8n-tooltip
|
||||
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
|
||||
|
||||
@@ -16,7 +16,6 @@ import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import * as useChatMessaging from './composables/useChatMessaging';
|
||||
import * as useChatTrigger from './composables/useChatTrigger';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
@@ -25,6 +24,7 @@ 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';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
|
||||
vi.mock('@/composables/useToast', () => {
|
||||
const showMessage = vi.fn();
|
||||
@@ -139,7 +139,7 @@ describe('CanvasChat', () => {
|
||||
});
|
||||
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
let canvasStore: ReturnType<typeof mockedStore<typeof useCanvasStore>>;
|
||||
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
|
||||
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -160,7 +160,7 @@ describe('CanvasChat', () => {
|
||||
setActivePinia(pinia);
|
||||
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
canvasStore = mockedStore(useCanvasStore);
|
||||
logsStore = mockedStore(useLogsStore);
|
||||
nodeTypeStore = mockedStore(useNodeTypesStore);
|
||||
|
||||
// Setup default mocks
|
||||
@@ -175,11 +175,12 @@ describe('CanvasChat', () => {
|
||||
|
||||
return matchedNode;
|
||||
});
|
||||
workflowsStore.logsPanelState = LOGS_PANEL_STATE.ATTACHED;
|
||||
workflowsStore.isLogsPanelOpen = true;
|
||||
logsStore.isOpen = true;
|
||||
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
|
||||
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
|
||||
|
||||
logsStore.state = LOGS_PANEL_STATE.ATTACHED;
|
||||
|
||||
nodeTypeStore.getNodeType = vi.fn().mockImplementation((nodeTypeName) => {
|
||||
return mockNodeTypes.find((node) => node.name === nodeTypeName) ?? null;
|
||||
});
|
||||
@@ -198,7 +199,7 @@ describe('CanvasChat', () => {
|
||||
});
|
||||
|
||||
it('should not render chat when panel is closed', async () => {
|
||||
workflowsStore.logsPanelState = LOGS_PANEL_STATE.CLOSED;
|
||||
logsStore.state = LOGS_PANEL_STATE.CLOSED;
|
||||
const { queryByTestId } = renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
|
||||
@@ -363,7 +364,7 @@ describe('CanvasChat', () => {
|
||||
{ coords: { clientX: 0, clientY: 100 } },
|
||||
]);
|
||||
|
||||
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
|
||||
expect(logsStore.setHeight).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should persist resize dimensions', () => {
|
||||
@@ -388,7 +389,7 @@ describe('CanvasChat', () => {
|
||||
isLoading: computed(() => false),
|
||||
});
|
||||
|
||||
workflowsStore.logsPanelState = LOGS_PANEL_STATE.ATTACHED;
|
||||
logsStore.state = LOGS_PANEL_STATE.ATTACHED;
|
||||
workflowsStore.allowFileUploads = true;
|
||||
});
|
||||
|
||||
@@ -544,15 +545,15 @@ describe('CanvasChat', () => {
|
||||
renderComponent();
|
||||
|
||||
// Toggle logs panel
|
||||
workflowsStore.isLogsPanelOpen = true;
|
||||
logsStore.isOpen = true;
|
||||
await waitFor(() => {
|
||||
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
|
||||
expect(logsStore.setHeight).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Close chat panel
|
||||
workflowsStore.logsPanelState = LOGS_PANEL_STATE.CLOSED;
|
||||
logsStore.state = LOGS_PANEL_STATE.CLOSED;
|
||||
await waitFor(() => {
|
||||
expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0);
|
||||
expect(logsStore.setHeight).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -560,15 +561,15 @@ describe('CanvasChat', () => {
|
||||
const { unmount, rerender } = renderComponent();
|
||||
|
||||
// Set initial state
|
||||
workflowsStore.logsPanelState = LOGS_PANEL_STATE.ATTACHED;
|
||||
workflowsStore.isLogsPanelOpen = true;
|
||||
logsStore.state = LOGS_PANEL_STATE.ATTACHED;
|
||||
logsStore.isOpen = true;
|
||||
|
||||
// Unmount and remount
|
||||
unmount();
|
||||
await rerender({});
|
||||
|
||||
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.ATTACHED);
|
||||
expect(workflowsStore.isLogsPanelOpen).toBe(true);
|
||||
expect(logsStore.state).toBe(LOGS_PANEL_STATE.ATTACHED);
|
||||
expect(logsStore.isOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,16 +9,16 @@ import ChatLogsPanel from './components/ChatLogsPanel.vue';
|
||||
import { useResize } from './composables/useResize';
|
||||
|
||||
// Types
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
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';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const canvasStore = useCanvasStore();
|
||||
const logsStore = useLogsStore();
|
||||
|
||||
// Component state
|
||||
const container = ref<HTMLElement>();
|
||||
@@ -28,7 +28,7 @@ const pipContent = useTemplateRef('pipContent');
|
||||
// Computed properties
|
||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||
|
||||
const chatPanelState = computed(() => workflowsStore.logsPanelState);
|
||||
const chatPanelState = computed(() => logsStore.state);
|
||||
const resultData = computed(() => workflowsStore.getWorkflowRunData);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
@@ -55,7 +55,7 @@ const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
|
||||
}
|
||||
|
||||
telemetry.track('User toggled log view', { new_state: 'attached' });
|
||||
workflowsStore.setPreferPoppedOutLogsView(false);
|
||||
logsStore.setPreferPoppedOut(false);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,22 +78,22 @@ defineExpose({
|
||||
});
|
||||
|
||||
const closePanel = () => {
|
||||
workflowsStore.toggleLogsPanelOpen(false);
|
||||
logsStore.toggleOpen(false);
|
||||
};
|
||||
|
||||
function onPopOut() {
|
||||
telemetry.track('User toggled log view', { new_state: 'floating' });
|
||||
workflowsStore.toggleLogsPanelOpen(true);
|
||||
workflowsStore.setPreferPoppedOutLogsView(true);
|
||||
logsStore.toggleOpen(true);
|
||||
logsStore.setPreferPoppedOut(true);
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watchEffect(() => {
|
||||
canvasStore.setPanelHeight(chatPanelState.value === LOGS_PANEL_STATE.ATTACHED ? height.value : 0);
|
||||
logsStore.setHeight(chatPanelState.value === LOGS_PANEL_STATE.ATTACHED ? height.value : 0);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => workflowsStore.logsPanelState,
|
||||
chatPanelState,
|
||||
(state) => {
|
||||
if (state !== LOGS_PANEL_STATE.CLOSED) {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -10,8 +10,9 @@ import ChatInput from '@n8n/chat/components/Input.vue';
|
||||
import { watch, computed, ref } from 'vue';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
|
||||
import LogsPanelHeader from '@/components/CanvasChat/future/components/LogsPanelHeader.vue';
|
||||
import { N8nButton, N8nIconButton, N8nTooltip } from '@n8n/design-system';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
interface Props {
|
||||
pastChatMessages: string[];
|
||||
@@ -40,6 +41,7 @@ const emit = defineEmits<{
|
||||
const clipboard = useClipboard();
|
||||
const locale = useI18n();
|
||||
const toast = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const previousMessageIndex = ref(0);
|
||||
|
||||
@@ -140,7 +142,7 @@ async function copySessionId() {
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
if (isOpen && !settingsStore.isNewLogsEnabled) {
|
||||
setTimeout(() => {
|
||||
chatEventBus.emit('focusInput');
|
||||
}, 0);
|
||||
@@ -151,8 +153,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
|
||||
<PanelHeader
|
||||
<div
|
||||
:class="$style.chat"
|
||||
data-test-id="workflow-lm-chat-dialog"
|
||||
class="ignore-key-press-canvas"
|
||||
tabindex="0"
|
||||
>
|
||||
<LogsPanelHeader
|
||||
v-if="isNewLogsEnabled"
|
||||
data-test-id="chat-header"
|
||||
:title="locale.baseText('chat.window.title')"
|
||||
@@ -191,7 +198,7 @@ watch(
|
||||
/>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
</PanelHeader>
|
||||
</LogsPanelHeader>
|
||||
<header v-else :class="$style.chatHeader">
|
||||
<span :class="$style.chatTitle">{{ locale.baseText('chat.window.title') }}</span>
|
||||
<div :class="$style.session">
|
||||
|
||||
@@ -16,8 +16,8 @@ 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';
|
||||
import { restoreChatHistory } from '@/components/CanvasChat/utils';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
|
||||
interface ChatState {
|
||||
currentSessionId: Ref<string>;
|
||||
@@ -34,6 +34,7 @@ export function useChatState(isReadOnly: boolean): ChatState {
|
||||
const locale = useI18n();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const logsStore = useLogsStore();
|
||||
const router = useRouter();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const { runWorkflow } = useRunWorkflow({ router });
|
||||
@@ -42,7 +43,6 @@ export function useChatState(isReadOnly: boolean): ChatState {
|
||||
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
|
||||
|
||||
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
||||
const logsPanelState = computed(() => workflowsStore.logsPanelState);
|
||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||
|
||||
// Initialize features with injected dependencies
|
||||
@@ -168,7 +168,7 @@ export function useChatState(isReadOnly: boolean): ChatState {
|
||||
messages.value = [];
|
||||
currentSessionId.value = uuid().replace(/-/g, '');
|
||||
|
||||
if (logsPanelState.value !== LOGS_PANEL_STATE.CLOSED) {
|
||||
if (logsStore.isOpen) {
|
||||
chatEventBus.emit('focusInput');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { LOGS_PANEL_STATE } from '../types/logs';
|
||||
import { IN_PROGRESS_EXECUTION_ID } from '@/constants';
|
||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
import { createTestTaskData } from '@/__tests__/mocks';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
|
||||
describe('LogsPanel', () => {
|
||||
const VIEWPORT_HEIGHT = 800;
|
||||
@@ -28,6 +31,8 @@ describe('LogsPanel', () => {
|
||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
|
||||
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
|
||||
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
|
||||
|
||||
function render() {
|
||||
return renderComponent(LogsPanel, {
|
||||
@@ -53,11 +58,15 @@ describe('LogsPanel', () => {
|
||||
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
workflowsStore.setWorkflowExecutionData(null);
|
||||
workflowsStore.toggleLogsPanelOpen(false);
|
||||
|
||||
logsStore = mockedStore(useLogsStore);
|
||||
logsStore.toggleOpen(false);
|
||||
|
||||
nodeTypeStore = mockedStore(useNodeTypesStore);
|
||||
nodeTypeStore.setNodeTypes(nodeTypes);
|
||||
|
||||
ndvStore = mockedStore(useNDVStore);
|
||||
|
||||
Object.defineProperty(document.body, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get() {
|
||||
@@ -161,11 +170,11 @@ describe('LogsPanel', () => {
|
||||
});
|
||||
|
||||
it('should open itself by pulling up the resizer', async () => {
|
||||
workflowsStore.toggleLogsPanelOpen(false);
|
||||
logsStore.toggleOpen(false);
|
||||
|
||||
const rendered = render();
|
||||
|
||||
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.CLOSED);
|
||||
expect(logsStore.state).toBe(LOGS_PANEL_STATE.CLOSED);
|
||||
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
|
||||
@@ -174,17 +183,17 @@ describe('LogsPanel', () => {
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 0, clientY: 0 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.ATTACHED);
|
||||
expect(logsStore.state).toBe(LOGS_PANEL_STATE.ATTACHED);
|
||||
expect(rendered.queryByTestId('logs-overview-body')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should close itself by pulling down the resizer', async () => {
|
||||
workflowsStore.toggleLogsPanelOpen(true);
|
||||
logsStore.toggleOpen(true);
|
||||
|
||||
const rendered = render();
|
||||
|
||||
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.ATTACHED);
|
||||
expect(logsStore.state).toBe(LOGS_PANEL_STATE.ATTACHED);
|
||||
expect(rendered.queryByTestId('logs-overview-body')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
|
||||
@@ -197,13 +206,13 @@ describe('LogsPanel', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.CLOSED);
|
||||
expect(logsStore.state).toBe(LOGS_PANEL_STATE.CLOSED);
|
||||
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should reflect changes to execution data in workflow store if execution is in progress', async () => {
|
||||
workflowsStore.toggleLogsPanelOpen(true);
|
||||
logsStore.toggleOpen(true);
|
||||
workflowsStore.setWorkflow(aiChatWorkflow);
|
||||
workflowsStore.setWorkflowExecutionData({
|
||||
...aiChatExecutionResponse,
|
||||
@@ -271,8 +280,8 @@ describe('LogsPanel', () => {
|
||||
const router = useRouter();
|
||||
const operations = useCanvasOperations({ router });
|
||||
|
||||
workflowsStore.toggleLogsPanelOpen(true);
|
||||
workflowsStore.setWorkflow(aiChatWorkflow);
|
||||
logsStore.toggleOpen(true);
|
||||
workflowsStore.setWorkflow(deepCopy(aiChatWorkflow));
|
||||
workflowsStore.setWorkflowExecutionData({
|
||||
...aiChatExecutionResponse,
|
||||
id: '2345',
|
||||
@@ -293,4 +302,90 @@ describe('LogsPanel', () => {
|
||||
expect(workflowsStore.nodesByName['AI Agent']).toBeUndefined();
|
||||
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open NDV if the button is clicked', async () => {
|
||||
logsStore.toggleOpen(true);
|
||||
workflowsStore.setWorkflow(aiChatWorkflow);
|
||||
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
||||
|
||||
const rendered = render();
|
||||
const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0];
|
||||
|
||||
expect(ndvStore.activeNodeName).toBe(null);
|
||||
expect(ndvStore.output.run).toBe(undefined);
|
||||
|
||||
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Open...')[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ndvStore.activeNodeName).toBe('AI Agent');
|
||||
expect(ndvStore.output.run).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should toggle subtree when chevron icon button is pressed', async () => {
|
||||
logsStore.toggleOpen(true);
|
||||
workflowsStore.setWorkflow(aiChatWorkflow);
|
||||
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
||||
|
||||
const rendered = render();
|
||||
const overview = within(rendered.getByTestId('logs-overview'));
|
||||
|
||||
await waitFor(() => expect(overview.queryAllByRole('treeitem')).toHaveLength(2));
|
||||
expect(overview.queryByText('AI Agent')).toBeInTheDocument();
|
||||
expect(overview.queryByText('AI Model')).toBeInTheDocument();
|
||||
|
||||
// Close subtree of AI Agent
|
||||
await fireEvent.click(overview.getAllByLabelText('Toggle row')[0]);
|
||||
|
||||
await waitFor(() => expect(overview.queryAllByRole('treeitem')).toHaveLength(1));
|
||||
expect(overview.queryByText('AI Agent')).toBeInTheDocument();
|
||||
expect(overview.queryByText('AI Model')).not.toBeInTheDocument();
|
||||
|
||||
// Re-open subtree of AI Agent
|
||||
await fireEvent.click(overview.getAllByLabelText('Toggle row')[0]);
|
||||
|
||||
await waitFor(() => expect(overview.queryAllByRole('treeitem')).toHaveLength(2));
|
||||
expect(overview.queryByText('AI Agent')).toBeInTheDocument();
|
||||
expect(overview.queryByText('AI Model')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle input and output panel when the button is clicked', async () => {
|
||||
logsStore.toggleOpen(true);
|
||||
logsStore.toggleInputOpen(false);
|
||||
logsStore.toggleOutputOpen(true);
|
||||
workflowsStore.setWorkflow(aiChatWorkflow);
|
||||
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
||||
|
||||
const rendered = render();
|
||||
|
||||
const header = within(rendered.getByTestId('log-details-header'));
|
||||
|
||||
expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(header.getByText('Input'));
|
||||
|
||||
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(header.getByText('Output'));
|
||||
|
||||
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow to select previous and next row via keyboard shortcut', async () => {
|
||||
logsStore.toggleOpen(true);
|
||||
workflowsStore.setWorkflow(aiChatWorkflow);
|
||||
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
||||
|
||||
const rendered = render();
|
||||
const overview = rendered.getByTestId('logs-overview');
|
||||
|
||||
expect(await rendered.findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
|
||||
await fireEvent.keyDown(overview, { key: 'K' });
|
||||
expect(await rendered.findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
|
||||
await fireEvent.keyDown(overview, { key: 'J' });
|
||||
expect(await rendered.findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
import { nextTick, computed, useTemplateRef } from 'vue';
|
||||
import { N8nResizeWrapper } from '@n8n/design-system';
|
||||
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
|
||||
import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue';
|
||||
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue';
|
||||
import LogsDetailsPanel from '@/components/CanvasChat/future/components/LogDetailsPanel.vue';
|
||||
import { type LogEntrySelection } from '@/components/CanvasChat/types/logs';
|
||||
import LogsPanelActions from '@/components/CanvasChat/future/components/LogsPanelActions.vue';
|
||||
import { useLayout } from '@/components/CanvasChat/future/composables/useLayout';
|
||||
import { useExecutionData } from '@/components/CanvasChat/future/composables/useExecutionData';
|
||||
import { findSelectedLogEntry, type LogEntry } from '@/components/RunDataAi/utils';
|
||||
import { useLogsPanelLayout } from '@/components/CanvasChat/future/composables/useLogsPanelLayout';
|
||||
import { useLogsExecutionData } from '@/components/CanvasChat/future/composables/useLogsExecutionData';
|
||||
import { type LogEntry } from '@/components/RunDataAi/utils';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { ndvEventBus } from '@/event-bus';
|
||||
import { useLogsSelection } from '@/components/CanvasChat/future/composables/useLogsSelection';
|
||||
import { useLogsTreeExpand } from '@/components/CanvasChat/future/composables/useLogsTreeExpand';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
|
||||
const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false });
|
||||
|
||||
@@ -18,6 +22,9 @@ const logsContainer = useTemplateRef('logsContainer');
|
||||
const pipContainer = useTemplateRef('pipContainer');
|
||||
const pipContent = useTemplateRef('pipContent');
|
||||
|
||||
const logsStore = useLogsStore();
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
const {
|
||||
height,
|
||||
chatPanelWidth,
|
||||
@@ -36,7 +43,7 @@ const {
|
||||
onChatPanelResizeEnd,
|
||||
onOverviewPanelResize,
|
||||
onOverviewPanelResizeEnd,
|
||||
} = useLayout(pipContainer, pipContent, container, logsContainer);
|
||||
} = useLogsPanelLayout(pipContainer, pipContent, container, logsContainer);
|
||||
|
||||
const {
|
||||
currentSessionId,
|
||||
@@ -48,13 +55,15 @@ const {
|
||||
} = useChatState(props.isReadOnly);
|
||||
|
||||
const { entries, execution, hasChat, latestNodeNameById, resetExecutionData, loadSubExecution } =
|
||||
useExecutionData();
|
||||
|
||||
const manualLogEntrySelection = ref<LogEntrySelection>({ type: 'initial' });
|
||||
const selectedLogEntry = computed(() =>
|
||||
findSelectedLogEntry(manualLogEntrySelection.value as LogEntrySelection, entries.value),
|
||||
useLogsExecutionData();
|
||||
const { flatLogEntries, toggleExpanded } = useLogsTreeExpand(entries);
|
||||
const { selected, select, selectNext, selectPrev } = useLogsSelection(
|
||||
execution,
|
||||
entries,
|
||||
flatLogEntries,
|
||||
);
|
||||
const isLogDetailsOpen = computed(() => isOpen.value && selectedLogEntry.value !== undefined);
|
||||
|
||||
const isLogDetailsOpen = computed(() => isOpen.value && selected.value !== undefined);
|
||||
const isLogDetailsVisuallyOpen = computed(
|
||||
() => isLogDetailsOpen.value && !isCollapsingDetailsPanel.value,
|
||||
);
|
||||
@@ -66,18 +75,26 @@ const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$p
|
||||
onToggleOpen,
|
||||
}));
|
||||
|
||||
function handleSelectLogEntry(selected: LogEntry | undefined) {
|
||||
manualLogEntrySelection.value =
|
||||
selected === undefined ? { type: 'none' } : { type: 'selected', id: selected.id };
|
||||
}
|
||||
|
||||
function handleResizeOverviewPanelEnd() {
|
||||
if (isOverviewPanelFullWidth.value) {
|
||||
handleSelectLogEntry(undefined);
|
||||
select(undefined);
|
||||
}
|
||||
|
||||
onOverviewPanelResizeEnd();
|
||||
}
|
||||
|
||||
async function handleOpenNdv(treeNode: LogEntry) {
|
||||
ndvStore.setActiveNodeName(treeNode.node.name);
|
||||
|
||||
await nextTick(() => {
|
||||
const source = treeNode.runData.source[0];
|
||||
const inputBranch = source?.previousNodeOutput ?? 0;
|
||||
|
||||
ndvEventBus.emit('updateInputNodeName', source?.previousNode);
|
||||
ndvEventBus.emit('setInputBranchIndex', inputBranch);
|
||||
ndvStore.setOutputRunIndex(treeNode.runIndex);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -92,7 +109,18 @@ function handleResizeOverviewPanelEnd() {
|
||||
@resize="onResize"
|
||||
@resizeend="onResizeEnd"
|
||||
>
|
||||
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
|
||||
<div
|
||||
ref="container"
|
||||
:class="$style.container"
|
||||
tabindex="-1"
|
||||
@keydown.esc.stop="select(undefined)"
|
||||
@keydown.j.stop="selectNext"
|
||||
@keydown.down.stop.prevent="selectNext"
|
||||
@keydown.k.stop="selectPrev"
|
||||
@keydown.up.stop.prevent="selectPrev"
|
||||
@keydown.space.stop="selected && toggleExpanded(selected)"
|
||||
@keydown.enter.stop="selected && handleOpenNdv(selected)"
|
||||
>
|
||||
<N8nResizeWrapper
|
||||
v-if="hasChat && (!props.isReadOnly || messages.length > 0)"
|
||||
:supported-directions="['right']"
|
||||
@@ -137,17 +165,16 @@ function handleResizeOverviewPanelEnd() {
|
||||
:is-open="isOpen"
|
||||
:is-read-only="isReadOnly"
|
||||
:is-compact="isLogDetailsVisuallyOpen"
|
||||
:selected="selectedLogEntry"
|
||||
:entries="entries"
|
||||
:selected="selected"
|
||||
:execution="execution"
|
||||
:scroll-to-selection="
|
||||
manualLogEntrySelection.type !== 'selected' ||
|
||||
manualLogEntrySelection.id !== selectedLogEntry?.id
|
||||
"
|
||||
:entries="entries"
|
||||
:latest-node-info="latestNodeNameById"
|
||||
:flat-log-entries="flatLogEntries"
|
||||
@click-header="onToggleOpen(true)"
|
||||
@select="handleSelectLogEntry"
|
||||
@select="select"
|
||||
@clear-execution-data="resetExecutionData"
|
||||
@toggle-expanded="toggleExpanded"
|
||||
@open-ndv="handleOpenNdv"
|
||||
@load-sub-execution="loadSubExecution"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -159,13 +186,16 @@ function handleResizeOverviewPanelEnd() {
|
||||
</LogsOverviewPanel>
|
||||
</N8nResizeWrapper>
|
||||
<LogsDetailsPanel
|
||||
v-if="isLogDetailsVisuallyOpen && selectedLogEntry"
|
||||
v-if="isLogDetailsVisuallyOpen && selected"
|
||||
:class="$style.logDetails"
|
||||
:is-open="isOpen"
|
||||
:log-entry="selectedLogEntry"
|
||||
:log-entry="selected"
|
||||
:window="pipWindow"
|
||||
:latest-info="latestNodeNameById[selectedLogEntry.id]"
|
||||
:latest-info="latestNodeNameById[selected.id]"
|
||||
:panels="logsStore.detailsState"
|
||||
@click-header="onToggleOpen(true)"
|
||||
@toggle-input-open="logsStore.toggleInputOpen"
|
||||
@toggle-output-open="logsStore.toggleOutputOpen"
|
||||
>
|
||||
<template #actions>
|
||||
<LogsPanelActions v-if="isLogDetailsVisuallyOpen" v-bind="logsPanelActionsProps" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
||||
import { fireEvent, within } from '@testing-library/vue';
|
||||
import { renderComponent } from '@/__tests__/render';
|
||||
import LogDetailsPanel from './LogDetailsPanel.vue';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { type FrontendSettings } from '@n8n/api-types';
|
||||
import { LOG_DETAILS_PANEL_STATE } from '../../types/logs';
|
||||
import type { LogEntry } from '@/components/RunDataAi/utils';
|
||||
|
||||
describe('LogDetailsPanel', () => {
|
||||
@@ -91,11 +92,10 @@ describe('LogDetailsPanel', () => {
|
||||
});
|
||||
|
||||
it('should show name, run status, input, and output of the node', async () => {
|
||||
localStorage.setItem('N8N_LOGS_DETAIL_PANEL_CONTENT', 'both');
|
||||
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
|
||||
panels: LOG_DETAILS_PANEL_STATE.BOTH,
|
||||
});
|
||||
|
||||
const header = within(rendered.getByTestId('log-details-header'));
|
||||
@@ -108,34 +108,11 @@ describe('LogDetailsPanel', () => {
|
||||
expect(await outputPanel.findByText('Hello!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle input and output panel when the button is clicked', async () => {
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
|
||||
});
|
||||
|
||||
const header = within(rendered.getByTestId('log-details-header'));
|
||||
|
||||
expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(header.getByText('Input'));
|
||||
|
||||
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(header.getByText('Output'));
|
||||
|
||||
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close input panel by dragging the divider to the left end', async () => {
|
||||
localStorage.setItem('N8N_LOGS_DETAIL_PANEL_CONTENT', 'both');
|
||||
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
|
||||
panels: LOG_DETAILS_PANEL_STATE.BOTH,
|
||||
});
|
||||
|
||||
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
|
||||
@@ -143,18 +120,14 @@ describe('LogDetailsPanel', () => {
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 0, clientY: 0 }));
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 0, clientY: 0 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
|
||||
});
|
||||
expect(rendered.emitted()).toEqual({ toggleInputOpen: [[false]] });
|
||||
});
|
||||
|
||||
it('should close output panel by dragging the divider to the right end', async () => {
|
||||
localStorage.setItem('N8N_LOGS_DETAIL_PANEL_CONTENT', 'both');
|
||||
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
|
||||
panels: LOG_DETAILS_PANEL_STATE.BOTH,
|
||||
});
|
||||
|
||||
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
|
||||
@@ -162,9 +135,6 @@ describe('LogDetailsPanel', () => {
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 1000, clientY: 0 }));
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 1000, clientY: 0 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
|
||||
expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(rendered.emitted()).toEqual({ toggleOutputOpen: [[false]] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import ExecutionSummary from '@/components/CanvasChat/future/components/ExecutionSummary.vue';
|
||||
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
|
||||
import RunDataView from '@/components/CanvasChat/future/components/RunDataView.vue';
|
||||
import { useResizablePanel } from '@/components/CanvasChat/future/composables/useResizablePanel';
|
||||
import { LOG_DETAILS_CONTENT, type LogDetailsContent } from '@/components/CanvasChat/types/logs';
|
||||
import LogsViewExecutionSummary from '@/components/CanvasChat/future/components/LogsViewExecutionSummary.vue';
|
||||
import LogsPanelHeader from '@/components/CanvasChat/future/components/LogsPanelHeader.vue';
|
||||
import LogsViewRunData from '@/components/CanvasChat/future/components/LogsViewRunData.vue';
|
||||
import { useResizablePanel } from '@/composables/useResizablePanel';
|
||||
import {
|
||||
LOG_DETAILS_PANEL_STATE,
|
||||
type LogDetailsPanelState,
|
||||
} from '@/components/CanvasChat/types/logs';
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import NodeName from '@/components/CanvasChat/future/components/NodeName.vue';
|
||||
import LogsViewNodeName from '@/components/CanvasChat/future/components/LogsViewNodeName.vue';
|
||||
import {
|
||||
getSubtreeTotalConsumedTokens,
|
||||
type LogEntry,
|
||||
type LatestNodeInfo,
|
||||
} from '@/components/RunDataAi/utils';
|
||||
import { N8nButton, N8nResizeWrapper } from '@n8n/design-system';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
|
||||
const MIN_IO_PANEL_WIDTH = 200;
|
||||
|
||||
const { isOpen, logEntry, window, latestInfo } = defineProps<{
|
||||
const { isOpen, logEntry, window, latestInfo, panels } = defineProps<{
|
||||
isOpen: boolean;
|
||||
logEntry: LogEntry;
|
||||
window?: Window;
|
||||
latestInfo?: LatestNodeInfo;
|
||||
panels: LogDetailsPanelState;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ clickHeader: [] }>();
|
||||
const emit = defineEmits<{
|
||||
clickHeader: [];
|
||||
toggleInputOpen: [] | [boolean];
|
||||
toggleOutputOpen: [] | [boolean];
|
||||
}>();
|
||||
|
||||
defineSlots<{ actions: {} }>();
|
||||
|
||||
const locale = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const nodeTypeStore = useNodeTypesStore();
|
||||
|
||||
const content = useLocalStorage<LogDetailsContent>(
|
||||
'N8N_LOGS_DETAIL_PANEL_CONTENT',
|
||||
LOG_DETAILS_CONTENT.OUTPUT,
|
||||
{ writeDefaults: false },
|
||||
);
|
||||
|
||||
const type = computed(() => nodeTypeStore.getNodeType(logEntry.node.type));
|
||||
const consumedTokens = computed(() => getSubtreeTotalConsumedTokens(logEntry, false));
|
||||
const isTriggerNode = computed(() => type.value?.group.includes('trigger'));
|
||||
@@ -53,45 +53,15 @@ const resizer = useResizablePanel('N8N_LOGS_INPUT_PANEL_WIDTH', {
|
||||
allowCollapse: true,
|
||||
allowFullSize: true,
|
||||
});
|
||||
const shouldResize = computed(() => content.value === LOG_DETAILS_CONTENT.BOTH);
|
||||
|
||||
function handleToggleInput(open?: boolean) {
|
||||
const wasOpen = [LOG_DETAILS_CONTENT.INPUT, LOG_DETAILS_CONTENT.BOTH].includes(content.value);
|
||||
|
||||
if (open === wasOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
content.value = wasOpen ? LOG_DETAILS_CONTENT.OUTPUT : LOG_DETAILS_CONTENT.BOTH;
|
||||
|
||||
telemetry.track('User toggled log view sub pane', {
|
||||
pane: 'input',
|
||||
newState: wasOpen ? 'hidden' : 'visible',
|
||||
});
|
||||
}
|
||||
|
||||
function handleToggleOutput(open?: boolean) {
|
||||
const wasOpen = [LOG_DETAILS_CONTENT.OUTPUT, LOG_DETAILS_CONTENT.BOTH].includes(content.value);
|
||||
|
||||
if (open === wasOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
content.value = wasOpen ? LOG_DETAILS_CONTENT.INPUT : LOG_DETAILS_CONTENT.BOTH;
|
||||
|
||||
telemetry.track('User toggled log view sub pane', {
|
||||
pane: 'output',
|
||||
newState: wasOpen ? 'hidden' : 'visible',
|
||||
});
|
||||
}
|
||||
const shouldResize = computed(() => panels === LOG_DETAILS_PANEL_STATE.BOTH);
|
||||
|
||||
function handleResizeEnd() {
|
||||
if (resizer.isCollapsed.value) {
|
||||
handleToggleInput(false);
|
||||
emit('toggleInputOpen', false);
|
||||
}
|
||||
|
||||
if (resizer.isFullSize.value) {
|
||||
handleToggleOutput(false);
|
||||
emit('toggleOutputOpen', false);
|
||||
}
|
||||
|
||||
resizer.onResizeEnd();
|
||||
@@ -100,7 +70,7 @@ function handleResizeEnd() {
|
||||
|
||||
<template>
|
||||
<div ref="container" :class="$style.container" data-test-id="log-details">
|
||||
<PanelHeader
|
||||
<LogsPanelHeader
|
||||
data-test-id="log-details-header"
|
||||
:class="$style.header"
|
||||
@click="emit('clickHeader')"
|
||||
@@ -108,12 +78,12 @@ function handleResizeEnd() {
|
||||
<template #title>
|
||||
<div :class="$style.title">
|
||||
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
|
||||
<NodeName
|
||||
<LogsViewNodeName
|
||||
:latest-name="latestInfo?.name ?? logEntry.node.name"
|
||||
:name="logEntry.node.name"
|
||||
:is-deleted="latestInfo?.deleted ?? false"
|
||||
/>
|
||||
<ExecutionSummary
|
||||
<LogsViewExecutionSummary
|
||||
v-if="isOpen"
|
||||
:class="$style.executionSummary"
|
||||
:status="logEntry.runData.executionStatus ?? 'unknown'"
|
||||
@@ -124,29 +94,39 @@ function handleResizeEnd() {
|
||||
</template>
|
||||
<template #actions>
|
||||
<div v-if="isOpen && !isTriggerNode" :class="$style.actions">
|
||||
<KeyboardShortcutTooltip
|
||||
:label="locale.baseText('generic.shortcutHint')"
|
||||
:shortcut="{ keys: ['i'] }"
|
||||
>
|
||||
<N8nButton
|
||||
size="mini"
|
||||
type="secondary"
|
||||
:class="content === LOG_DETAILS_CONTENT.OUTPUT ? '' : $style.pressed"
|
||||
@click.stop="handleToggleInput"
|
||||
:class="panels === LOG_DETAILS_PANEL_STATE.OUTPUT ? '' : $style.pressed"
|
||||
@click.stop="emit('toggleInputOpen')"
|
||||
>
|
||||
{{ locale.baseText('logs.details.header.actions.input') }}
|
||||
</N8nButton>
|
||||
</KeyboardShortcutTooltip>
|
||||
<KeyboardShortcutTooltip
|
||||
:label="locale.baseText('generic.shortcutHint')"
|
||||
:shortcut="{ keys: ['o'] }"
|
||||
>
|
||||
<N8nButton
|
||||
size="mini"
|
||||
type="secondary"
|
||||
:class="content === LOG_DETAILS_CONTENT.INPUT ? '' : $style.pressed"
|
||||
@click.stop="handleToggleOutput"
|
||||
:class="panels === LOG_DETAILS_PANEL_STATE.INPUT ? '' : $style.pressed"
|
||||
@click.stop="emit('toggleOutputOpen')"
|
||||
>
|
||||
{{ locale.baseText('logs.details.header.actions.output') }}
|
||||
</N8nButton>
|
||||
</KeyboardShortcutTooltip>
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</PanelHeader>
|
||||
</LogsPanelHeader>
|
||||
<div v-if="isOpen" :class="$style.content" data-test-id="logs-details-body">
|
||||
<N8nResizeWrapper
|
||||
v-if="!isTriggerNode && content !== LOG_DETAILS_CONTENT.OUTPUT"
|
||||
v-if="!isTriggerNode && panels !== LOG_DETAILS_PANEL_STATE.OUTPUT"
|
||||
:class="{
|
||||
[$style.inputResizer]: true,
|
||||
[$style.collapsed]: resizer.isCollapsed.value,
|
||||
@@ -160,15 +140,15 @@ function handleResizeEnd() {
|
||||
@resize="resizer.onResize"
|
||||
@resizeend="handleResizeEnd"
|
||||
>
|
||||
<RunDataView
|
||||
<LogsViewRunData
|
||||
data-test-id="log-details-input"
|
||||
pane-type="input"
|
||||
:title="locale.baseText('logs.details.header.actions.input')"
|
||||
:log-entry="logEntry"
|
||||
/>
|
||||
</N8nResizeWrapper>
|
||||
<RunDataView
|
||||
v-if="isTriggerNode || content !== LOG_DETAILS_CONTENT.INPUT"
|
||||
<LogsViewRunData
|
||||
v-if="isTriggerNode || panels !== LOG_DETAILS_PANEL_STATE.INPUT"
|
||||
data-test-id="log-details-output"
|
||||
pane-type="output"
|
||||
:class="$style.outputPanel"
|
||||
|
||||
@@ -14,23 +14,22 @@ import {
|
||||
aiManualWorkflow,
|
||||
} from '../../__test__/data';
|
||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { createTestWorkflowObject } from '@/__tests__/mocks';
|
||||
import { createLogTree } from '@/components/RunDataAi/utils';
|
||||
import { createLogTree, flattenLogEntries } from '@/components/RunDataAi/utils';
|
||||
|
||||
describe('LogsOverviewPanel', () => {
|
||||
let pinia: TestingPinia;
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
let pushConnectionStore: ReturnType<typeof mockedStore<typeof usePushConnectionStore>>;
|
||||
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
|
||||
|
||||
function render(props: Partial<InstanceType<typeof LogsOverviewPanel>['$props']>) {
|
||||
const logs = createLogTree(createTestWorkflowObject(aiChatWorkflow), aiChatExecutionResponse);
|
||||
const mergedProps: InstanceType<typeof LogsOverviewPanel>['$props'] = {
|
||||
isOpen: false,
|
||||
isReadOnly: false,
|
||||
isCompact: false,
|
||||
scrollToSelection: false,
|
||||
entries: createLogTree(createTestWorkflowObject(aiChatWorkflow), aiChatExecutionResponse),
|
||||
flatLogEntries: flattenLogEntries(logs, {}),
|
||||
entries: logs,
|
||||
latestNodeInfo: {},
|
||||
execution: aiChatExecutionResponse,
|
||||
...props,
|
||||
@@ -59,8 +58,6 @@ describe('LogsOverviewPanel', () => {
|
||||
|
||||
pushConnectionStore = mockedStore(usePushConnectionStore);
|
||||
pushConnectionStore.isConnected = true;
|
||||
|
||||
ndvStore = mockedStore(useNDVStore);
|
||||
});
|
||||
|
||||
it('should not render body if the panel is not open', () => {
|
||||
@@ -70,7 +67,12 @@ describe('LogsOverviewPanel', () => {
|
||||
});
|
||||
|
||||
it('should render empty text if there is no execution', () => {
|
||||
const rendered = render({ isOpen: true, entries: [], execution: undefined });
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
flatLogEntries: [],
|
||||
entries: [],
|
||||
execution: undefined,
|
||||
});
|
||||
|
||||
expect(rendered.queryByTestId('logs-overview-empty')).toBeInTheDocument();
|
||||
});
|
||||
@@ -101,35 +103,20 @@ describe('LogsOverviewPanel', () => {
|
||||
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
|
||||
await fireEvent.click(row1.getAllByLabelText('Toggle row')[0]);
|
||||
await waitFor(() => expect(tree.queryAllByRole('treeitem')).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('should open NDV if the button is clicked', async () => {
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
});
|
||||
const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0];
|
||||
|
||||
expect(ndvStore.activeNodeName).toBe(null);
|
||||
expect(ndvStore.output.run).toBe(undefined);
|
||||
|
||||
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Open...')[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ndvStore.activeNodeName).toBe('AI Agent');
|
||||
expect(ndvStore.output.run).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should trigger partial execution if the button is clicked', async () => {
|
||||
const spyRun = vi.spyOn(workflowsStore, 'runWorkflow');
|
||||
|
||||
const logs = createLogTree(
|
||||
createTestWorkflowObject(aiManualWorkflow),
|
||||
aiManualExecutionResponse,
|
||||
);
|
||||
const rendered = render({
|
||||
isOpen: true,
|
||||
entries: createLogTree(createTestWorkflowObject(aiManualWorkflow), aiManualExecutionResponse),
|
||||
execution: aiManualExecutionResponse,
|
||||
entries: logs,
|
||||
flatLogEntries: flattenLogEntries(logs, {}),
|
||||
});
|
||||
const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0];
|
||||
|
||||
@@ -138,26 +125,4 @@ describe('LogsOverviewPanel', () => {
|
||||
expect(spyRun).toHaveBeenCalledWith(expect.objectContaining({ destinationNode: 'AI Agent' })),
|
||||
);
|
||||
});
|
||||
|
||||
it('should toggle subtree when chevron icon button is pressed', async () => {
|
||||
const rendered = render({ isOpen: true });
|
||||
|
||||
await waitFor(() => expect(rendered.queryAllByRole('treeitem')).toHaveLength(2));
|
||||
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
|
||||
expect(rendered.queryByText('AI Model')).toBeInTheDocument();
|
||||
|
||||
// Close subtree of AI Agent
|
||||
await fireEvent.click(rendered.getAllByLabelText('Toggle row')[0]);
|
||||
|
||||
await waitFor(() => expect(rendered.queryAllByRole('treeitem')).toHaveLength(1));
|
||||
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
|
||||
expect(rendered.queryByText('AI Model')).not.toBeInTheDocument();
|
||||
|
||||
// Re-open subtree of AI Agent
|
||||
await fireEvent.click(rendered.getAllByLabelText('Toggle row')[0]);
|
||||
|
||||
await waitFor(() => expect(rendered.queryAllByRole('treeitem')).toHaveLength(2));
|
||||
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
|
||||
expect(rendered.queryByText('AI Model')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
|
||||
import LogsPanelHeader from '@/components/CanvasChat/future/components/LogsPanelHeader.vue';
|
||||
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import { ref, computed, nextTick, watch } from 'vue';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { computed, nextTick, toRef, watch } from 'vue';
|
||||
import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.vue';
|
||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useRouter } from 'vue-router';
|
||||
import ExecutionSummary from '@/components/CanvasChat/future/components/ExecutionSummary.vue';
|
||||
import LogsViewExecutionSummary from '@/components/CanvasChat/future/components/LogsViewExecutionSummary.vue';
|
||||
import {
|
||||
getDefaultCollapsedEntries,
|
||||
flattenLogEntries,
|
||||
getSubtreeTotalConsumedTokens,
|
||||
getTotalConsumedTokens,
|
||||
hasSubExecution,
|
||||
type LatestNodeInfo,
|
||||
type LogEntry,
|
||||
getDepth,
|
||||
} from '@/components/RunDataAi/utils';
|
||||
import { useVirtualList } from '@vueuse/core';
|
||||
import { ndvEventBus } from '@/event-bus';
|
||||
import { type IExecutionResponse } from '@/Interface';
|
||||
|
||||
const {
|
||||
@@ -31,35 +25,35 @@ const {
|
||||
isCompact,
|
||||
execution,
|
||||
entries,
|
||||
flatLogEntries,
|
||||
latestNodeInfo,
|
||||
scrollToSelection,
|
||||
} = defineProps<{
|
||||
isOpen: boolean;
|
||||
selected?: LogEntry;
|
||||
isReadOnly: boolean;
|
||||
isCompact: boolean;
|
||||
entries: LogEntry[];
|
||||
execution?: IExecutionResponse;
|
||||
entries: LogEntry[];
|
||||
flatLogEntries: LogEntry[];
|
||||
latestNodeInfo: Record<string, LatestNodeInfo>;
|
||||
scrollToSelection: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
clickHeader: [];
|
||||
select: [LogEntry | undefined];
|
||||
clearExecutionData: [];
|
||||
openNdv: [LogEntry];
|
||||
toggleExpanded: [LogEntry];
|
||||
loadSubExecution: [LogEntry];
|
||||
}>();
|
||||
|
||||
defineSlots<{ actions: {} }>();
|
||||
|
||||
const locale = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const router = useRouter();
|
||||
const runWorkflow = useRunWorkflow({ router });
|
||||
const ndvStore = useNDVStore();
|
||||
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
|
||||
const isEmpty = computed(() => entries.length === 0 || execution === undefined);
|
||||
const isEmpty = computed(() => flatLogEntries.length === 0 || execution === undefined);
|
||||
const switchViewOptions = computed(() => [
|
||||
{ label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' as const },
|
||||
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
|
||||
@@ -74,60 +68,27 @@ const consumedTokens = computed(() =>
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const shouldShowTokenCountColumn = computed(
|
||||
() =>
|
||||
consumedTokens.value.totalTokens > 0 ||
|
||||
entries.some((entry) => getSubtreeTotalConsumedTokens(entry, true).totalTokens > 0),
|
||||
);
|
||||
const manuallyCollapsedEntries = ref<Record<string, boolean>>({});
|
||||
const collapsedEntries = computed(() => ({
|
||||
...getDefaultCollapsedEntries(entries),
|
||||
...manuallyCollapsedEntries.value,
|
||||
}));
|
||||
const flatLogEntries = computed(() => flattenLogEntries(entries, collapsedEntries.value));
|
||||
const virtualList = useVirtualList(flatLogEntries, { itemHeight: 32 });
|
||||
|
||||
function handleClickNode(clicked: LogEntry) {
|
||||
if (selected?.id === clicked.id) {
|
||||
emit('select', undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
emit('select', clicked);
|
||||
|
||||
telemetry.track('User selected node in log view', {
|
||||
node_type: clicked.node.type,
|
||||
node_id: clicked.node.id,
|
||||
execution_id: execution?.id,
|
||||
workflow_id: execution?.workflowData.id,
|
||||
subworkflow_depth: getDepth(clicked),
|
||||
});
|
||||
}
|
||||
const virtualList = useVirtualList(
|
||||
toRef(() => flatLogEntries),
|
||||
{ itemHeight: 32 },
|
||||
);
|
||||
|
||||
function handleSwitchView(value: 'overview' | 'details') {
|
||||
emit('select', value === 'overview' || entries.length === 0 ? undefined : entries[0]);
|
||||
emit('select', value === 'overview' ? undefined : flatLogEntries[0]);
|
||||
}
|
||||
|
||||
async function handleToggleExpanded(treeNode: LogEntry) {
|
||||
function handleToggleExpanded(treeNode: LogEntry) {
|
||||
if (hasSubExecution(treeNode) && treeNode.children.length === 0) {
|
||||
emit('loadSubExecution', treeNode);
|
||||
return;
|
||||
}
|
||||
|
||||
manuallyCollapsedEntries.value[treeNode.id] = !collapsedEntries.value[treeNode.id];
|
||||
}
|
||||
|
||||
async function handleOpenNdv(treeNode: LogEntry) {
|
||||
ndvStore.setActiveNodeName(treeNode.node.name);
|
||||
|
||||
await nextTick(() => {
|
||||
const source = treeNode.runData.source[0];
|
||||
const inputBranch = source?.previousNodeOutput ?? 0;
|
||||
|
||||
ndvEventBus.emit('updateInputNodeName', source?.previousNode);
|
||||
ndvEventBus.emit('setInputBranchIndex', inputBranch);
|
||||
ndvStore.setOutputRunIndex(treeNode.runIndex);
|
||||
});
|
||||
emit('toggleExpanded', treeNode);
|
||||
}
|
||||
|
||||
async function handleTriggerPartialExecution(treeNode: LogEntry) {
|
||||
@@ -140,10 +101,10 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
|
||||
|
||||
// Scroll selected row into view
|
||||
watch(
|
||||
() => (scrollToSelection ? selected?.id : undefined),
|
||||
async (selectedId) => {
|
||||
if (selectedId) {
|
||||
const index = flatLogEntries.value.findIndex((e) => e.id === selectedId);
|
||||
() => selected,
|
||||
async (selection) => {
|
||||
if (selection && virtualList.list.value.every((e) => e.data.id !== selection.id)) {
|
||||
const index = flatLogEntries.findIndex((e) => e.id === selection?.id);
|
||||
|
||||
if (index >= 0) {
|
||||
// Wait for the node to be added to the list, and then scroll
|
||||
@@ -157,7 +118,7 @@ watch(
|
||||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="logs-overview">
|
||||
<PanelHeader
|
||||
<LogsPanelHeader
|
||||
:title="locale.baseText('logs.overview.header.title')"
|
||||
data-test-id="logs-overview-header"
|
||||
@click="emit('clickHeader')"
|
||||
@@ -180,7 +141,7 @@ watch(
|
||||
</N8nTooltip>
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</PanelHeader>
|
||||
</LogsPanelHeader>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
:class="[$style.content, isEmpty ? $style.empty : '']"
|
||||
@@ -197,7 +158,7 @@ watch(
|
||||
{{ locale.baseText('logs.overview.body.empty.message') }}
|
||||
</N8nText>
|
||||
<template v-else>
|
||||
<ExecutionSummary
|
||||
<LogsViewExecutionSummary
|
||||
data-test-id="logs-overview-status"
|
||||
:class="$style.summary"
|
||||
:status="execution.status"
|
||||
@@ -219,12 +180,12 @@ watch(
|
||||
:is-compact="isCompact"
|
||||
:should-show-token-count-column="shouldShowTokenCountColumn"
|
||||
:latest-info="latestNodeInfo[data.node.id]"
|
||||
:expanded="!collapsedEntries[data.id]"
|
||||
:expanded="virtualList.list.value[index + 1]?.data.parent?.id === data.id"
|
||||
:can-open-ndv="data.executionId === execution?.id"
|
||||
@click.stop="handleClickNode(data)"
|
||||
@toggle-expanded="handleToggleExpanded"
|
||||
@open-ndv="handleOpenNdv"
|
||||
@trigger-partial-execution="handleTriggerPartialExecution"
|
||||
@toggle-expanded="handleToggleExpanded(data)"
|
||||
@open-ndv="emit('openNdv', data)"
|
||||
@trigger-partial-execution="handleTriggerPartialExecution(data)"
|
||||
@toggle-selected="emit('select', selected?.id === data.id ? undefined : data)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, nextTick, useTemplateRef, watch } from 'vue';
|
||||
import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
|
||||
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 LogsViewConsumedTokenCountText from '@/components/CanvasChat/future/components/LogsViewConsumedTokenCountText.vue';
|
||||
import { I18nT } from 'vue-i18n';
|
||||
import { toDayMonth, toTime } from '@/utils/formatters/dateFormatter';
|
||||
import NodeName from '@/components/CanvasChat/future/components/NodeName.vue';
|
||||
import LogsViewNodeName from '@/components/CanvasChat/future/components/LogsViewNodeName.vue';
|
||||
import {
|
||||
getSubtreeTotalConsumedTokens,
|
||||
type LatestNodeInfo,
|
||||
@@ -26,11 +26,13 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleExpanded: [node: LogEntry];
|
||||
triggerPartialExecution: [node: LogEntry];
|
||||
openNdv: [node: LogEntry];
|
||||
toggleExpanded: [];
|
||||
toggleSelected: [];
|
||||
triggerPartialExecution: [];
|
||||
openNdv: [];
|
||||
}>();
|
||||
|
||||
const container = useTemplateRef('containerRef');
|
||||
const locale = useI18n();
|
||||
const nodeTypeStore = useNodeTypesStore();
|
||||
const type = computed(() => nodeTypeStore.getNodeType(props.data.node.type));
|
||||
@@ -75,12 +77,26 @@ function isLastChild(level: number) {
|
||||
(data?.node === lastSibling?.node && data?.runIndex === lastSibling?.runIndex)
|
||||
);
|
||||
}
|
||||
|
||||
// Focus when selected: For scrolling into view and for keyboard navigation to work
|
||||
watch(
|
||||
() => props.isSelected,
|
||||
(isSelected) => {
|
||||
void nextTick(() => {
|
||||
if (isSelected) {
|
||||
container.value?.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
role="treeitem"
|
||||
tabindex="0"
|
||||
tabindex="-1"
|
||||
:aria-expanded="props.data.children.length > 0 && props.expanded"
|
||||
:aria-selected="props.isSelected"
|
||||
:class="{
|
||||
@@ -89,6 +105,7 @@ function isLastChild(level: number) {
|
||||
[$style.error]: isError,
|
||||
[$style.selected]: props.isSelected,
|
||||
}"
|
||||
@click.stop="emit('toggleSelected')"
|
||||
>
|
||||
<template v-for="level in props.data.depth" :key="level">
|
||||
<div
|
||||
@@ -101,7 +118,7 @@ function isLastChild(level: number) {
|
||||
</template>
|
||||
<div :class="$style.background" :style="{ '--indent-depth': props.data.depth }" />
|
||||
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
|
||||
<NodeName
|
||||
<LogsViewNodeName
|
||||
:class="$style.name"
|
||||
:latest-name="latestInfo?.name ?? props.data.node.name"
|
||||
:name="props.data.node.name"
|
||||
@@ -137,7 +154,7 @@ function isLastChild(level: number) {
|
||||
size="small"
|
||||
:class="$style.consumedTokens"
|
||||
>
|
||||
<ConsumedTokenCountText
|
||||
<LogsViewConsumedTokenCountText
|
||||
v-if="
|
||||
subtreeConsumedTokens.totalTokens > 0 &&
|
||||
(props.data.children.length === 0 || !props.expanded)
|
||||
@@ -165,7 +182,7 @@ function isLastChild(level: number) {
|
||||
:disabled="props.latestInfo?.deleted"
|
||||
:class="$style.openNdvButton"
|
||||
:aria-label="locale.baseText('logs.overview.body.open')"
|
||||
@click.stop="emit('openNdv', props.data)"
|
||||
@click.stop="emit('openNdv')"
|
||||
/>
|
||||
<N8nIconButton
|
||||
v-if="
|
||||
@@ -179,7 +196,7 @@ function isLastChild(level: number) {
|
||||
:aria-label="locale.baseText('logs.overview.body.run')"
|
||||
:class="[$style.partialExecutionButton, props.data.depth > 0 ? $style.unavailable : '']"
|
||||
:disabled="props.latestInfo?.deleted || props.latestInfo?.disabled"
|
||||
@click.stop="emit('triggerPartialExecution', props.data)"
|
||||
@click.stop="emit('triggerPartialExecution')"
|
||||
/>
|
||||
<N8nButton
|
||||
v-if="!isCompact || hasChildren"
|
||||
@@ -192,7 +209,7 @@ function isLastChild(level: number) {
|
||||
}"
|
||||
:class="$style.toggleButton"
|
||||
:aria-label="locale.baseText('logs.overview.body.toggleRow')"
|
||||
@click.stop="emit('toggleExpanded', props.data)"
|
||||
@click.stop="emit('toggleExpanded')"
|
||||
>
|
||||
<N8nIcon size="medium" :icon="props.expanded ? 'chevron-down' : 'chevron-up'" />
|
||||
</N8nButton>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useStyles } from '@/composables/useStyles';
|
||||
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
|
||||
@@ -33,7 +34,12 @@ const toggleButtonText = computed(() =>
|
||||
@click.stop="emit('popOut')"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
<N8nTooltip v-if="showToggleButton" :z-index="tooltipZIndex" :content="toggleButtonText">
|
||||
<KeyboardShortcutTooltip
|
||||
v-if="showToggleButton"
|
||||
:label="locales.baseText('generic.shortcutHint')"
|
||||
:shortcut="{ keys: ['l'] }"
|
||||
:z-index="tooltipZIndex"
|
||||
>
|
||||
<N8nIconButton
|
||||
type="secondary"
|
||||
size="small"
|
||||
@@ -43,7 +49,7 @@ const toggleButtonText = computed(() =>
|
||||
style="color: var(--color-text-base)"
|
||||
@click.stop="emit('toggleOpen')"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
</KeyboardShortcutTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
|
||||
import LogsViewConsumedTokenCountText from '@/components/CanvasChat/future/components/LogsViewConsumedTokenCountText.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { type LlmTokenUsageData } from '@/Interface';
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
@@ -29,7 +29,7 @@ const executionStatusText = computed(() =>
|
||||
<template>
|
||||
<N8nText tag="div" color="text-light" size="small" :class="$style.container">
|
||||
<span>{{ executionStatusText }}</span>
|
||||
<ConsumedTokenCountText
|
||||
<LogsViewConsumedTokenCountText
|
||||
v-if="consumedTokens.totalTokens > 0"
|
||||
:consumed-tokens="consumedTokens"
|
||||
/>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { useExecutionData } from './useExecutionData';
|
||||
import { useLogsExecutionData } from './useLogsExecutionData';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
@@ -18,7 +18,7 @@ import { useToast } from '@/composables/useToast';
|
||||
|
||||
vi.mock('@/composables/useToast');
|
||||
|
||||
describe(useExecutionData, () => {
|
||||
describe(useLogsExecutionData, () => {
|
||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
|
||||
|
||||
@@ -72,7 +72,7 @@ describe(useExecutionData, () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { loadSubExecution, entries } = useExecutionData();
|
||||
const { loadSubExecution, entries } = useLogsExecutionData();
|
||||
|
||||
expect(entries.value).toHaveLength(2);
|
||||
expect(entries.value[1].children).toHaveLength(0);
|
||||
@@ -101,7 +101,7 @@ describe(useExecutionData, () => {
|
||||
new Error('test execution fetch fail'),
|
||||
);
|
||||
|
||||
const { loadSubExecution, entries } = useExecutionData();
|
||||
const { loadSubExecution, entries } = useLogsExecutionData();
|
||||
|
||||
await loadSubExecution(entries.value[1]);
|
||||
await waitFor(() => expect(showErrorSpy).toHaveBeenCalled());
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { parse } from 'flatted';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
||||
export function useExecutionData() {
|
||||
export function useLogsExecutionData() {
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const toast = useToast();
|
||||
@@ -135,7 +135,7 @@ export function useExecutionData() {
|
||||
);
|
||||
|
||||
return {
|
||||
execution: execData,
|
||||
execution: computed(() => execData.value),
|
||||
entries,
|
||||
hasChat,
|
||||
latestNodeNameById,
|
||||
@@ -6,22 +6,19 @@ import {
|
||||
} from '../../composables/useResize';
|
||||
import { LOGS_PANEL_STATE } from '../../types/logs';
|
||||
import { usePiPWindow } from '../../composables/usePiPWindow';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { watch } from 'vue';
|
||||
import { useResizablePanel } from './useResizablePanel';
|
||||
import { useResizablePanel } from '../../../../composables/useResizablePanel';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
|
||||
export function useLayout(
|
||||
export function useLogsPanelLayout(
|
||||
pipContainer: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
pipContent: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
container: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
logsContainer: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
) {
|
||||
const canvasStore = useCanvasStore();
|
||||
const logsStore = useLogsStore();
|
||||
const telemetry = useTelemetry();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const panelState = computed(() => workflowsStore.logsPanelState);
|
||||
|
||||
const resizer = useResizablePanel(LOCAL_STORAGE_PANEL_HEIGHT, {
|
||||
container: document.body,
|
||||
@@ -49,9 +46,9 @@ export function useLayout(
|
||||
});
|
||||
|
||||
const isOpen = computed(() =>
|
||||
panelState.value === LOGS_PANEL_STATE.CLOSED
|
||||
? resizer.isResizing.value && resizer.size.value > 0
|
||||
: !resizer.isCollapsed.value,
|
||||
logsStore.isOpen
|
||||
? !resizer.isCollapsed.value
|
||||
: resizer.isResizing.value && resizer.size.value > 0,
|
||||
);
|
||||
const isCollapsingDetailsPanel = computed(() => overviewPanelResizer.isFullSize.value);
|
||||
|
||||
@@ -60,25 +57,25 @@ export function useLayout(
|
||||
initialWidth: window.document.body.offsetWidth * 0.8,
|
||||
container: pipContainer,
|
||||
content: pipContent,
|
||||
shouldPopOut: computed(() => panelState.value === LOGS_PANEL_STATE.FLOATING),
|
||||
shouldPopOut: computed(() => logsStore.state === LOGS_PANEL_STATE.FLOATING),
|
||||
onRequestClose: () => {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
telemetry.track('User toggled log view', { new_state: 'attached' });
|
||||
workflowsStore.setPreferPoppedOutLogsView(false);
|
||||
logsStore.setPreferPoppedOut(false);
|
||||
},
|
||||
});
|
||||
|
||||
function handleToggleOpen(open?: boolean) {
|
||||
const wasOpen = panelState.value !== LOGS_PANEL_STATE.CLOSED;
|
||||
const wasOpen = logsStore.isOpen;
|
||||
|
||||
if (open === wasOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
workflowsStore.toggleLogsPanelOpen(open);
|
||||
logsStore.toggleOpen(open);
|
||||
|
||||
telemetry.track('User toggled log view', {
|
||||
new_state: wasOpen ? 'collapsed' : 'attached',
|
||||
@@ -87,12 +84,12 @@ export function useLayout(
|
||||
|
||||
function handlePopOut() {
|
||||
telemetry.track('User toggled log view', { new_state: 'floating' });
|
||||
workflowsStore.toggleLogsPanelOpen(true);
|
||||
workflowsStore.setPreferPoppedOutLogsView(true);
|
||||
logsStore.toggleOpen(true);
|
||||
logsStore.setPreferPoppedOut(true);
|
||||
}
|
||||
|
||||
function handleResizeEnd() {
|
||||
if (panelState.value === LOGS_PANEL_STATE.CLOSED && !resizer.isCollapsed.value) {
|
||||
if (!logsStore.isOpen && !resizer.isCollapsed.value) {
|
||||
handleToggleOpen(true);
|
||||
}
|
||||
|
||||
@@ -104,9 +101,9 @@ export function useLayout(
|
||||
}
|
||||
|
||||
watch(
|
||||
[panelState, resizer.size],
|
||||
[() => logsStore.state, resizer.size],
|
||||
([state, height]) => {
|
||||
canvasStore.setPanelHeight(
|
||||
logsStore.setHeight(
|
||||
state === LOGS_PANEL_STATE.FLOATING
|
||||
? 0
|
||||
: state === LOGS_PANEL_STATE.ATTACHED
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { LogEntrySelection } from '@/components/CanvasChat/types/logs';
|
||||
import {
|
||||
findSelectedLogEntry,
|
||||
getDepth,
|
||||
getEntryAtRelativeIndex,
|
||||
type LogEntry,
|
||||
} from '@/components/RunDataAi/utils';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import type { IExecutionResponse } from '@/Interface';
|
||||
import { computed, ref, type ComputedRef } from 'vue';
|
||||
|
||||
export function useLogsSelection(
|
||||
execution: ComputedRef<IExecutionResponse | undefined>,
|
||||
tree: ComputedRef<LogEntry[]>,
|
||||
flatLogEntries: ComputedRef<LogEntry[]>,
|
||||
) {
|
||||
const telemetry = useTelemetry();
|
||||
const manualLogEntrySelection = ref<LogEntrySelection>({ type: 'initial' });
|
||||
const selected = computed(() => findSelectedLogEntry(manualLogEntrySelection.value, tree.value));
|
||||
|
||||
function select(value: LogEntry | undefined) {
|
||||
manualLogEntrySelection.value =
|
||||
value === undefined ? { type: 'none' } : { type: 'selected', id: value.id };
|
||||
|
||||
if (value) {
|
||||
telemetry.track('User selected node in log view', {
|
||||
node_type: value.node.type,
|
||||
node_id: value.node.id,
|
||||
execution_id: execution.value?.id,
|
||||
workflow_id: execution.value?.workflowData.id,
|
||||
subworkflow_depth: getDepth(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function selectPrev() {
|
||||
const entries = flatLogEntries.value;
|
||||
const prevEntry = selected.value
|
||||
? (getEntryAtRelativeIndex(entries, selected.value.id, -1) ?? entries[0])
|
||||
: entries[entries.length - 1];
|
||||
|
||||
select(prevEntry);
|
||||
}
|
||||
|
||||
function selectNext() {
|
||||
const entries = flatLogEntries.value;
|
||||
const nextEntry = selected.value
|
||||
? (getEntryAtRelativeIndex(entries, selected.value.id, 1) ?? entries[entries.length - 1])
|
||||
: entries[0];
|
||||
|
||||
select(nextEntry);
|
||||
}
|
||||
|
||||
return { selected, select, selectPrev, selectNext };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { flattenLogEntries, type LogEntry } from '@/components/RunDataAi/utils';
|
||||
import { computed, ref, type ComputedRef } from 'vue';
|
||||
|
||||
export function useLogsTreeExpand(entries: ComputedRef<LogEntry[]>) {
|
||||
const collapsedEntries = ref<Record<string, boolean>>({});
|
||||
const flatLogEntries = computed(() => flattenLogEntries(entries.value, collapsedEntries.value));
|
||||
|
||||
function toggleExpanded(treeNode: LogEntry) {
|
||||
collapsedEntries.value[treeNode.id] = !collapsedEntries.value[treeNode.id];
|
||||
}
|
||||
|
||||
return {
|
||||
flatLogEntries,
|
||||
toggleExpanded,
|
||||
};
|
||||
}
|
||||
@@ -11,10 +11,11 @@ export const LOGS_PANEL_STATE = {
|
||||
|
||||
export type LogsPanelState = (typeof LOGS_PANEL_STATE)[keyof typeof LOGS_PANEL_STATE];
|
||||
|
||||
export const LOG_DETAILS_CONTENT = {
|
||||
export const LOG_DETAILS_PANEL_STATE = {
|
||||
INPUT: 'input',
|
||||
OUTPUT: 'output',
|
||||
BOTH: 'both',
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type LogDetailsContent = (typeof LOG_DETAILS_CONTENT)[keyof typeof LOG_DETAILS_CONTENT];
|
||||
export type LogDetailsPanelState =
|
||||
(typeof LOG_DETAILS_PANEL_STATE)[keyof typeof LOG_DETAILS_PANEL_STATE];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { KeyboardShortcut } from '@/Interface';
|
||||
import { N8nKeyboardShortcut, N8nTooltip } from '@n8n/design-system';
|
||||
import type { Placement } from 'element-plus';
|
||||
|
||||
interface Props {
|
||||
@@ -11,15 +12,15 @@ withDefaults(defineProps<Props>(), { placement: 'top', shortcut: undefined });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-tooltip :placement="placement" :show-after="500">
|
||||
<N8nTooltip :placement="placement" :show-after="500">
|
||||
<template #content>
|
||||
<div :class="$style.shortcut">
|
||||
<div :class="$style.label">{{ label }}</div>
|
||||
<n8n-keyboard-shortcut v-if="shortcut" v-bind="shortcut" />
|
||||
<N8nKeyboardShortcut v-if="shortcut" v-bind="shortcut" />
|
||||
</div>
|
||||
</template>
|
||||
<slot />
|
||||
</n8n-tooltip>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
||||
@@ -580,6 +580,16 @@ export function flattenLogEntries(
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function getEntryAtRelativeIndex(
|
||||
entries: LogEntry[],
|
||||
id: string,
|
||||
relativeIndex: number,
|
||||
): LogEntry | undefined {
|
||||
const offset = entries.findIndex((e) => e.id === id);
|
||||
|
||||
return offset === -1 ? undefined : entries[offset + relativeIndex];
|
||||
}
|
||||
|
||||
function sortLogEntries<T extends { runData: ITaskData }>(a: T, b: T) {
|
||||
// We rely on execution index only when startTime is different
|
||||
// Because it is reset to 0 when execution is waited, and therefore not necessarily unique
|
||||
|
||||
@@ -69,6 +69,9 @@ const emit = defineEmits<{
|
||||
'update:node:parameters': [id: string, parameters: Record<string, unknown>];
|
||||
'update:node:inputs': [id: string];
|
||||
'update:node:outputs': [id: string];
|
||||
'update:logs-open': [open?: boolean];
|
||||
'update:logs:input-open': [open?: boolean];
|
||||
'update:logs:output-open': [open?: boolean];
|
||||
'click:node': [id: string, position: XYPosition];
|
||||
'click:node:add': [id: string, handle: string];
|
||||
'run:node': [id: string];
|
||||
@@ -100,6 +103,7 @@ const emit = defineEmits<{
|
||||
'viewport:change': [viewport: ViewportTransform, dimensions: Dimensions];
|
||||
'selection:end': [position: XYPosition];
|
||||
'open:sub-workflow': [nodeId: string];
|
||||
'start-chat': [];
|
||||
}>();
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -287,6 +291,9 @@ const keyMap = computed(() => {
|
||||
ArrowRight: emitWithLastSelectedNode(selectRightNode),
|
||||
shift_ArrowLeft: emitWithLastSelectedNode(selectUpstreamNodes),
|
||||
shift_ArrowRight: emitWithLastSelectedNode(selectDownstreamNodes),
|
||||
l: () => emit('update:logs-open'),
|
||||
i: () => emit('update:logs:input-open'),
|
||||
o: () => emit('update:logs:output-open'),
|
||||
};
|
||||
|
||||
if (props.readOnly) return readOnlyKeymap;
|
||||
@@ -305,6 +312,7 @@ const keyMap = computed(() => {
|
||||
ctrl_enter: () => emit('run:workflow'),
|
||||
ctrl_s: () => emit('save:workflow'),
|
||||
shift_alt_t: async () => await onTidyUp({ source: 'keyboard-shortcut' }),
|
||||
c: () => emit('start-chat'),
|
||||
};
|
||||
return fullKeymap;
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||
import { CHAT_TRIGGER_NODE_TYPE } from '@/constants';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { N8nButton } from '@n8n/design-system';
|
||||
import { computed, useCssModule } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
@@ -35,10 +37,11 @@ const containerClass = computed(() => ({
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const logsStore = useLogsStore();
|
||||
const { runEntireWorkflow } = useRunWorkflow({ router });
|
||||
const { toggleChatOpen } = useCanvasOperations({ router });
|
||||
const { startChat } = useCanvasOperations({ router });
|
||||
|
||||
const isChatOpen = computed(() => workflowsStore.logsPanelState !== LOGS_PANEL_STATE.CLOSED);
|
||||
const isChatOpen = computed(() => logsStore.isOpen);
|
||||
const isExecuting = computed(() => workflowsStore.isWorkflowRunning);
|
||||
const testId = computed(() => `execute-workflow-button-${name}`);
|
||||
</script>
|
||||
@@ -52,15 +55,31 @@ const testId = computed(() => `execute-workflow-button-${name}`);
|
||||
</div>
|
||||
|
||||
<template v-if="!readOnly">
|
||||
<template v-if="type === CHAT_TRIGGER_NODE_TYPE">
|
||||
<N8nButton
|
||||
v-if="type === CHAT_TRIGGER_NODE_TYPE"
|
||||
:type="isChatOpen ? 'secondary' : 'primary'"
|
||||
v-if="isChatOpen"
|
||||
type="secondary"
|
||||
size="large"
|
||||
:disabled="isExecuting"
|
||||
:data-test-id="testId"
|
||||
:label="isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')"
|
||||
@click.capture="toggleChatOpen('node')"
|
||||
:label="i18n.baseText('chat.hide')"
|
||||
@click.capture="logsStore.toggleOpen(false)"
|
||||
/>
|
||||
<KeyboardShortcutTooltip
|
||||
v-else
|
||||
:label="i18n.baseText('chat.open')"
|
||||
:shortcut="{ keys: ['c'] }"
|
||||
>
|
||||
<N8nButton
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="isExecuting"
|
||||
:data-test-id="testId"
|
||||
:label="i18n.baseText('chat.open')"
|
||||
@click.capture="startChat('node')"
|
||||
/>
|
||||
</KeyboardShortcutTooltip>
|
||||
</template>
|
||||
<N8nButton
|
||||
v-else
|
||||
type="primary"
|
||||
|
||||
@@ -2975,24 +2975,6 @@ describe('useCanvasOperations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleChatOpen', () => {
|
||||
it('should invoke workflowsStore#toggleLogsPanelOpen with 2nd argument passed through as 1st argument', async () => {
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
const { toggleChatOpen } = useCanvasOperations({ router });
|
||||
|
||||
workflowsStore.getCurrentWorkflow.mockReturnValue(createTestWorkflowObject());
|
||||
|
||||
await toggleChatOpen('main');
|
||||
expect(workflowsStore.toggleLogsPanelOpen).toHaveBeenCalledWith(undefined);
|
||||
|
||||
await toggleChatOpen('main', true);
|
||||
expect(workflowsStore.toggleLogsPanelOpen).toHaveBeenCalledWith(true);
|
||||
|
||||
await toggleChatOpen('main', false);
|
||||
expect(workflowsStore.toggleLogsPanelOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importTemplate', () => {
|
||||
it('should import template to canvas', async () => {
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
|
||||
@@ -106,6 +106,9 @@ import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
|
||||
import { isPresent } from '../utils/typesUtils';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import type { CanvasLayoutEvent } from './useCanvasLayout';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
import { isChatNode } from '@/components/CanvasChat/utils';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
|
||||
type AddNodeData = Partial<INodeUi> & {
|
||||
type: string;
|
||||
@@ -147,6 +150,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||
const nodeCreatorStore = useNodeCreatorStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const logsStore = useLogsStore();
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
@@ -2031,10 +2035,14 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||
return data;
|
||||
}
|
||||
|
||||
async function toggleChatOpen(source: 'node' | 'main', isOpen?: boolean) {
|
||||
function startChat(source?: 'node' | 'main') {
|
||||
if (!workflowsStore.allNodes.some(isChatNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workflow = workflowsStore.getCurrentWorkflow();
|
||||
|
||||
workflowsStore.toggleLogsPanelOpen(isOpen);
|
||||
logsStore.toggleOpen(true);
|
||||
|
||||
const payload = {
|
||||
workflow_id: workflow.id,
|
||||
@@ -2043,6 +2051,10 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||
|
||||
void externalHooks.run('nodeView.onOpenChat', payload);
|
||||
telemetry.track('User clicked chat open button', payload);
|
||||
|
||||
setTimeout(() => {
|
||||
chatEventBus.emit('focusInput');
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function importTemplate({
|
||||
@@ -2121,7 +2133,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||
initializeWorkspace,
|
||||
resolveNodeWebhook,
|
||||
openExecution,
|
||||
toggleChatOpen,
|
||||
startChat,
|
||||
importTemplate,
|
||||
tryToOpenSubworkflowInNewTab,
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ import { useTelemetry } from './useTelemetry';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
|
||||
import { useCanvasOperations } from './useCanvasOperations';
|
||||
import { useAgentRequestStore } from '@/stores/agentRequest.store';
|
||||
|
||||
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
|
||||
@@ -59,6 +60,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
const { dirtinessByName } = useNodeDirtiness();
|
||||
const { startChat } = useCanvasOperations({ router: useRunWorkflowOpts.router });
|
||||
|
||||
function sortNodesByYPosition(nodes: string[]) {
|
||||
return [...nodes].sort((a, b) => {
|
||||
@@ -204,7 +206,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||
// and halt the execution
|
||||
if (!chatHasInputData && !chatHasPinData) {
|
||||
workflowsStore.chatPartialExecutionDestinationNode = options.destinationNode;
|
||||
workflowsStore.toggleLogsPanelOpen(true);
|
||||
startChat();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import { useExternalHooks } from './useExternalHooks';
|
||||
import { VIEWS } from '@/constants';
|
||||
import type { ApplicationError } from 'n8n-workflow';
|
||||
import { useStyles } from './useStyles';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
|
||||
export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
|
||||
node: {
|
||||
@@ -31,7 +31,7 @@ export function useToast() {
|
||||
const i18n = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { APP_Z_INDEXES } = useStyles();
|
||||
const canvasStore = useCanvasStore();
|
||||
const logsStore = useLogsStore();
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
function showMessage(messageData: Partial<NotificationOptions>, track = true) {
|
||||
@@ -41,7 +41,7 @@ export function useToast() {
|
||||
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
|
||||
offset:
|
||||
(settingsStore.isAiAssistantEnabled ? 64 : 0) +
|
||||
(ndvStore.activeNode === null ? canvasStore.panelHeight : 0),
|
||||
(ndvStore.activeNode === null ? logsStore.height : 0),
|
||||
appendTo: '#app-grid',
|
||||
customClass: 'content-toast',
|
||||
};
|
||||
|
||||
@@ -484,6 +484,7 @@ export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_
|
||||
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
|
||||
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
|
||||
export const LOCAL_STORAGE_LOGS_PANEL_OPEN = 'N8N_LOGS_PANEL_OPEN';
|
||||
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL = 'N8N_LOGS_DETAILS_PANEL';
|
||||
export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';
|
||||
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||
export const COMMUNITY_PLUS_DOCS_URL =
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"generic.workflows": "Workflows",
|
||||
"generic.rename": "Rename",
|
||||
"generic.missing.permissions": "Missing permissions to perform this action",
|
||||
"generic.shortcutHint": "Or press",
|
||||
"about.aboutN8n": "About n8n",
|
||||
"about.close": "Close",
|
||||
"about.license": "License",
|
||||
|
||||
@@ -9,23 +9,15 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
const loadingService = useLoadingService();
|
||||
|
||||
const newNodeInsertPosition = ref<XYPosition | null>(null);
|
||||
const panelHeight = ref(0);
|
||||
|
||||
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
|
||||
const aiNodes = computed<INodeUi[]>(() =>
|
||||
nodes.value.filter((node) => node.type.includes('langchain')),
|
||||
);
|
||||
|
||||
function setPanelHeight(height: number) {
|
||||
panelHeight.value = height;
|
||||
}
|
||||
|
||||
return {
|
||||
newNodeInsertPosition,
|
||||
isLoading: loadingService.isLoading,
|
||||
aiNodes,
|
||||
panelHeight: computed(() => panelHeight.value),
|
||||
setPanelHeight,
|
||||
startLoading: loadingService.startLoading,
|
||||
setLoadingText: loadingService.setLoadingText,
|
||||
stopLoading: loadingService.stopLoading,
|
||||
|
||||
91
packages/frontend/editor-ui/src/stores/logs.store.ts
Normal file
91
packages/frontend/editor-ui/src/stores/logs.store.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
LOG_DETAILS_PANEL_STATE,
|
||||
LOGS_PANEL_STATE,
|
||||
type LogDetailsPanelState,
|
||||
} from '@/components/CanvasChat/types/logs';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL, LOCAL_STORAGE_LOGS_PANEL_OPEN } from '@/constants';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export const useLogsStore = defineStore('logs', () => {
|
||||
const isOpen = useLocalStorage(LOCAL_STORAGE_LOGS_PANEL_OPEN, false);
|
||||
const preferPoppedOut = ref(false);
|
||||
const state = computed(() =>
|
||||
isOpen.value
|
||||
? preferPoppedOut.value
|
||||
? LOGS_PANEL_STATE.FLOATING
|
||||
: LOGS_PANEL_STATE.ATTACHED
|
||||
: LOGS_PANEL_STATE.CLOSED,
|
||||
);
|
||||
const height = ref(0);
|
||||
const detailsState = useLocalStorage<LogDetailsPanelState>(
|
||||
LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL,
|
||||
LOG_DETAILS_PANEL_STATE.OUTPUT,
|
||||
{ writeDefaults: false },
|
||||
);
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
function setHeight(value: number) {
|
||||
height.value = value;
|
||||
}
|
||||
|
||||
function toggleOpen(value?: boolean) {
|
||||
isOpen.value = value ?? !isOpen.value;
|
||||
}
|
||||
|
||||
function setPreferPoppedOut(value: boolean) {
|
||||
preferPoppedOut.value = value;
|
||||
}
|
||||
|
||||
function toggleInputOpen(open?: boolean) {
|
||||
const statesWithInput: LogDetailsPanelState[] = [
|
||||
LOG_DETAILS_PANEL_STATE.INPUT,
|
||||
LOG_DETAILS_PANEL_STATE.BOTH,
|
||||
];
|
||||
const wasOpen = statesWithInput.includes(detailsState.value);
|
||||
|
||||
if (open === wasOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
detailsState.value = wasOpen ? LOG_DETAILS_PANEL_STATE.OUTPUT : LOG_DETAILS_PANEL_STATE.BOTH;
|
||||
|
||||
telemetry.track('User toggled log view sub pane', {
|
||||
pane: 'input',
|
||||
newState: wasOpen ? 'hidden' : 'visible',
|
||||
});
|
||||
}
|
||||
|
||||
function toggleOutputOpen(open?: boolean) {
|
||||
const statesWithOutput: LogDetailsPanelState[] = [
|
||||
LOG_DETAILS_PANEL_STATE.OUTPUT,
|
||||
LOG_DETAILS_PANEL_STATE.BOTH,
|
||||
];
|
||||
const wasOpen = statesWithOutput.includes(detailsState.value);
|
||||
|
||||
if (open === wasOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
detailsState.value = wasOpen ? LOG_DETAILS_PANEL_STATE.INPUT : LOG_DETAILS_PANEL_STATE.BOTH;
|
||||
|
||||
telemetry.track('User toggled log view sub pane', {
|
||||
pane: 'output',
|
||||
newState: wasOpen ? 'hidden' : 'visible',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
isOpen: computed(() => state.value !== LOGS_PANEL_STATE.CLOSED),
|
||||
detailsState: computed(() => detailsState.value),
|
||||
height: computed(() => height.value),
|
||||
setHeight,
|
||||
toggleOpen,
|
||||
setPreferPoppedOut,
|
||||
toggleInputOpen,
|
||||
toggleOutputOpen,
|
||||
};
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
DUPLICATE_POSTFFIX,
|
||||
ERROR_TRIGGER_NODE_TYPE,
|
||||
FORM_NODE_TYPE,
|
||||
LOCAL_STORAGE_LOGS_PANEL_OPEN,
|
||||
MAX_WORKFLOW_NAME_LENGTH,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
START_NODE_TYPE,
|
||||
@@ -93,9 +92,8 @@ 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 } from '@/components/CanvasChat/types/logs';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
|
||||
import { useLogsStore } from './logs.store';
|
||||
|
||||
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
||||
name: '',
|
||||
@@ -131,6 +129,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
const rootStore = useRootStore();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const usersStore = useUsersStore();
|
||||
const logsStore = useLogsStore();
|
||||
|
||||
const version = computed(() => settingsStore.partialExecutionVersion);
|
||||
const workflow = ref<IWorkflowDb>(createEmptyWorkflow());
|
||||
@@ -153,15 +152,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
const isInDebugMode = ref(false);
|
||||
const chatMessages = ref<string[]>([]);
|
||||
const chatPartialExecutionDestinationNode = ref<string | null>(null);
|
||||
const isLogsPanelOpen = useLocalStorage(LOCAL_STORAGE_LOGS_PANEL_OPEN, false);
|
||||
const preferPopOutLogsView = ref(false);
|
||||
const logsPanelState = computed(() =>
|
||||
isLogsPanelOpen.value
|
||||
? preferPopOutLogsView.value
|
||||
? LOGS_PANEL_STATE.FLOATING
|
||||
: LOGS_PANEL_STATE.ATTACHED
|
||||
: LOGS_PANEL_STATE.CLOSED,
|
||||
);
|
||||
|
||||
const {
|
||||
executingNode,
|
||||
@@ -1323,7 +1313,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
|
||||
// If chat trigger node is removed, close chat
|
||||
if (node.type === CHAT_TRIGGER_NODE_TYPE && !settingsStore.isNewLogsEnabled) {
|
||||
toggleLogsPanelOpen(false);
|
||||
logsStore.toggleOpen(false);
|
||||
}
|
||||
|
||||
if (workflow.value.pinData && workflow.value.pinData.hasOwnProperty(node.name)) {
|
||||
@@ -1812,14 +1802,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
// End Canvas V2 Functions
|
||||
//
|
||||
|
||||
function toggleLogsPanelOpen(isOpen?: boolean) {
|
||||
isLogsPanelOpen.value = isOpen ?? !isLogsPanelOpen.value;
|
||||
}
|
||||
|
||||
function setPreferPoppedOutLogsView(value: boolean) {
|
||||
preferPopOutLogsView.value = value;
|
||||
}
|
||||
|
||||
function markExecutionAsStopped() {
|
||||
setActiveExecutionId(undefined);
|
||||
clearNodeExecutionQueue();
|
||||
@@ -1882,9 +1864,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
getAllLoadedFinishedExecutions,
|
||||
getWorkflowExecution,
|
||||
getPastChatMessages,
|
||||
logsPanelState: computed(() => logsPanelState.value),
|
||||
toggleLogsPanelOpen,
|
||||
setPreferPoppedOutLogsView,
|
||||
outgoingConnectionsByNodeName,
|
||||
incomingConnectionsByNodeName,
|
||||
nodeHasOutputConnection,
|
||||
|
||||
@@ -116,12 +116,13 @@ 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';
|
||||
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
|
||||
import { useBuilderStore } from '@/stores/builder.store';
|
||||
import { useFoldersStore } from '@/stores/folders.store';
|
||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
import { useAgentRequestStore } from '@/stores/agentRequest.store';
|
||||
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
|
||||
defineOptions({
|
||||
name: 'NodeView',
|
||||
@@ -175,6 +176,7 @@ const templatesStore = useTemplatesStore();
|
||||
const builderStore = useBuilderStore();
|
||||
const foldersStore = useFoldersStore();
|
||||
const agentRequestStore = useAgentRequestStore();
|
||||
const logsStore = useLogsStore();
|
||||
|
||||
const canvasEventBus = createEventBus<CanvasEventBusEvents>();
|
||||
|
||||
@@ -224,7 +226,7 @@ const {
|
||||
editableWorkflow,
|
||||
editableWorkflowObject,
|
||||
lastClickPosition,
|
||||
toggleChatOpen,
|
||||
startChat,
|
||||
} = useCanvasOperations({ router });
|
||||
const { applyExecutionData } = useExecutionDebugging();
|
||||
useClipboard({ onPaste: onClipboardPaste });
|
||||
@@ -272,7 +274,7 @@ const keyBindingsEnabled = computed(() => {
|
||||
return !ndvStore.activeNode && uiStore.activeModals.length === 0;
|
||||
});
|
||||
|
||||
const isLogsPanelOpen = computed(() => workflowsStore.logsPanelState !== LOGS_PANEL_STATE.CLOSED);
|
||||
const isLogsPanelOpen = computed(() => logsStore.isOpen);
|
||||
|
||||
/**
|
||||
* Initialization
|
||||
@@ -1358,12 +1360,8 @@ const chatTriggerNodePinnedData = computed(() => {
|
||||
return workflowsStore.pinDataByNodeName(chatTriggerNode.value.name);
|
||||
});
|
||||
|
||||
async function onToggleChat() {
|
||||
await toggleChatOpen('main');
|
||||
}
|
||||
|
||||
async function onOpenChat() {
|
||||
await toggleChatOpen('main', true);
|
||||
function onOpenChat() {
|
||||
startChat('main');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1933,6 +1931,9 @@ onBeforeUnmount(() => {
|
||||
@update:node:parameters="onUpdateNodeParameters"
|
||||
@update:node:inputs="onUpdateNodeInputs"
|
||||
@update:node:outputs="onUpdateNodeOutputs"
|
||||
@update:logs-open="logsStore.toggleOpen($event)"
|
||||
@update:logs:input-open="logsStore.toggleInputOpen"
|
||||
@update:logs:output-open="logsStore.toggleOutputOpen"
|
||||
@open:sub-workflow="onOpenSubWorkflow"
|
||||
@click:node="onClickNode"
|
||||
@click:node:add="onClickNodeAdd"
|
||||
@@ -1958,6 +1959,7 @@ onBeforeUnmount(() => {
|
||||
@selection:end="onSelectionEnd"
|
||||
@drag-and-drop="onDragAndDrop"
|
||||
@tidy-up="onTidyUp"
|
||||
@start-chat="startChat()"
|
||||
>
|
||||
<Suspense>
|
||||
<LazySetupWorkflowCredentialsButton :class="$style.setupCredentialsButtonWrapper" />
|
||||
@@ -1972,12 +1974,25 @@ onBeforeUnmount(() => {
|
||||
@mouseleave="onRunWorkflowButtonMouseLeave"
|
||||
@click="runEntireWorkflow('main')"
|
||||
/>
|
||||
<template v-if="containsChatTriggerNodes">
|
||||
<CanvasChatButton
|
||||
v-if="containsChatTriggerNodes"
|
||||
:type="isLogsPanelOpen ? 'tertiary' : 'primary'"
|
||||
:label="isLogsPanelOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')"
|
||||
@click="onToggleChat"
|
||||
v-if="isLogsPanelOpen"
|
||||
type="tertiary"
|
||||
:label="i18n.baseText('chat.hide')"
|
||||
@click="logsStore.toggleOpen(false)"
|
||||
/>
|
||||
<KeyboardShortcutTooltip
|
||||
v-else
|
||||
:label="i18n.baseText('chat.open')"
|
||||
:shortcut="{ keys: ['c'] }"
|
||||
>
|
||||
<CanvasChatButton
|
||||
type="primary"
|
||||
:label="i18n.baseText('chat.open')"
|
||||
@click="onOpenChat"
|
||||
/>
|
||||
</KeyboardShortcutTooltip>
|
||||
</template>
|
||||
<CanvasStopCurrentExecutionButton
|
||||
v-if="isStopExecutionButtonVisible"
|
||||
:stopping="isStoppingExecution"
|
||||
|
||||
Reference in New Issue
Block a user