mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +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">
|
||||
<N8nButton
|
||||
size="mini"
|
||||
type="secondary"
|
||||
:class="content === LOG_DETAILS_CONTENT.OUTPUT ? '' : $style.pressed"
|
||||
@click.stop="handleToggleInput"
|
||||
<KeyboardShortcutTooltip
|
||||
:label="locale.baseText('generic.shortcutHint')"
|
||||
:shortcut="{ keys: ['i'] }"
|
||||
>
|
||||
{{ locale.baseText('logs.details.header.actions.input') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
size="mini"
|
||||
type="secondary"
|
||||
:class="content === LOG_DETAILS_CONTENT.INPUT ? '' : $style.pressed"
|
||||
@click.stop="handleToggleOutput"
|
||||
<N8nButton
|
||||
size="mini"
|
||||
type="secondary"
|
||||
: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'] }"
|
||||
>
|
||||
{{ locale.baseText('logs.details.header.actions.output') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
size="mini"
|
||||
type="secondary"
|
||||
: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,
|
||||
};
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { type ResizeData } from '@n8n/design-system';
|
||||
import { useResizablePanel } from './useResizablePanel';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
describe(useResizablePanel, () => {
|
||||
let localStorageKey = uuid();
|
||||
let container = document.createElement('div');
|
||||
let resizeData: ResizeData;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageKey = uuid();
|
||||
|
||||
container = document.createElement('div');
|
||||
|
||||
Object.defineProperty(container, 'offsetWidth', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 1000;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(container, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 800;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(container, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1000,
|
||||
height: 800,
|
||||
}) as DOMRect;
|
||||
},
|
||||
});
|
||||
|
||||
resizeData = {
|
||||
height: Math.random(),
|
||||
width: Math.random(),
|
||||
dX: Math.random(),
|
||||
dY: Math.random(),
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
direction: 'right',
|
||||
};
|
||||
});
|
||||
|
||||
it('should return defaultSize if value is missing in local storage', () => {
|
||||
const { size } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
|
||||
|
||||
expect(size.value).toBe(444);
|
||||
});
|
||||
|
||||
it('should restore value from local storage if valid number is stored', () => {
|
||||
window.localStorage.setItem(localStorageKey, '0.333');
|
||||
|
||||
const { size } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
|
||||
|
||||
expect(size.value).toBe(333);
|
||||
});
|
||||
|
||||
it('should return defaultSize if invalid value is stored in local storage', () => {
|
||||
window.localStorage.setItem(localStorageKey, '333');
|
||||
|
||||
const { size } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
|
||||
|
||||
expect(size.value).toBe(444);
|
||||
});
|
||||
|
||||
it('should update size when onResize is called', () => {
|
||||
const { size, onResize } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
|
||||
|
||||
onResize({ ...resizeData, x: 555 });
|
||||
expect(size.value).toBe(555);
|
||||
});
|
||||
|
||||
it('should calculate and return height if position is "bottom"', () => {
|
||||
const { size, onResize } = useResizablePanel(localStorageKey, {
|
||||
container,
|
||||
defaultSize: 444,
|
||||
position: 'bottom',
|
||||
});
|
||||
|
||||
onResize({ ...resizeData, y: 222 });
|
||||
expect(size.value).toBe(578); // container height minus y
|
||||
});
|
||||
|
||||
it('should return size bound in the range between minSize and maxSize', () => {
|
||||
const { size, onResize } = useResizablePanel(localStorageKey, {
|
||||
container,
|
||||
defaultSize: 444,
|
||||
minSize: 200,
|
||||
maxSize: (containerSize) => containerSize * 0.9,
|
||||
});
|
||||
|
||||
onResize({ ...resizeData, x: 100 });
|
||||
expect(size.value).toBe(200);
|
||||
|
||||
onResize({ ...resizeData, x: 950 });
|
||||
expect(size.value).toBe(900);
|
||||
});
|
||||
|
||||
it('should update manually updated size so that proportion is maintained when container is resized', async () => {
|
||||
const spyResizeObserver = vi.spyOn(window, 'ResizeObserver');
|
||||
|
||||
const { size, onResize } = useResizablePanel(localStorageKey, {
|
||||
container,
|
||||
defaultSize: 444,
|
||||
minSize: 200,
|
||||
maxSize: (containerSize) => containerSize * 0.9,
|
||||
});
|
||||
|
||||
expect(spyResizeObserver).toHaveBeenCalledTimes(1);
|
||||
|
||||
onResize({ ...resizeData, x: 600 });
|
||||
|
||||
expect(size.value).toBe(600);
|
||||
Object.defineProperty(container, 'offsetWidth', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 500;
|
||||
},
|
||||
});
|
||||
spyResizeObserver.mock.calls[0]?.[0]?.([], {} as ResizeObserver);
|
||||
await nextTick();
|
||||
expect(size.value).toBe(300);
|
||||
});
|
||||
|
||||
it('should return 0 and isCollapsed=true while resizing beyond minSize if allowCollapse is set to true', () => {
|
||||
const { size, isCollapsed, onResize, onResizeEnd } = useResizablePanel(localStorageKey, {
|
||||
container,
|
||||
defaultSize: 444,
|
||||
minSize: 300,
|
||||
allowCollapse: true,
|
||||
});
|
||||
|
||||
expect(size.value).toBe(444);
|
||||
expect(isCollapsed.value).toBe(false);
|
||||
|
||||
onResize({ ...resizeData, x: 200 });
|
||||
expect(size.value).toBe(300);
|
||||
expect(isCollapsed.value).toBe(false);
|
||||
|
||||
onResize({ ...resizeData, x: 10 });
|
||||
expect(size.value).toBe(0);
|
||||
expect(isCollapsed.value).toBe(true);
|
||||
|
||||
onResizeEnd();
|
||||
expect(size.value).toBe(300);
|
||||
expect(isCollapsed.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should return container size and isFullSize=true while resizing close to container size if allowFullSize is set to true', () => {
|
||||
const { size, isFullSize, onResize, onResizeEnd } = useResizablePanel(localStorageKey, {
|
||||
container,
|
||||
defaultSize: 444,
|
||||
maxSize: 800,
|
||||
allowFullSize: true,
|
||||
});
|
||||
|
||||
expect(size.value).toBe(444);
|
||||
expect(isFullSize.value).toBe(false);
|
||||
|
||||
onResize({ ...resizeData, x: 900 });
|
||||
expect(size.value).toBe(800);
|
||||
expect(isFullSize.value).toBe(false);
|
||||
|
||||
onResize({ ...resizeData, x: 999 });
|
||||
expect(size.value).toBe(1000);
|
||||
expect(isFullSize.value).toBe(true);
|
||||
|
||||
onResizeEnd();
|
||||
expect(size.value).toBe(800);
|
||||
expect(isFullSize.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
import { type ResizeData } from '@n8n/design-system';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { computed, type MaybeRef, ref, unref, watch } from 'vue';
|
||||
|
||||
type GetSize = number | ((containerSize: number) => number);
|
||||
|
||||
interface UseResizerV2Options {
|
||||
/**
|
||||
* Container element, to which relative size is calculated (doesn't necessarily have to be DOM parent node)
|
||||
*/
|
||||
container: MaybeRef<HTMLElement | null>;
|
||||
/**
|
||||
* Default size in pixels
|
||||
*/
|
||||
defaultSize: GetSize;
|
||||
/**
|
||||
* Minimum size in pixels
|
||||
*/
|
||||
minSize?: GetSize;
|
||||
/**
|
||||
* Maximum size in pixels
|
||||
*/
|
||||
maxSize?: GetSize;
|
||||
/**
|
||||
* Which end of the container the resizable element itself is located
|
||||
*/
|
||||
position?: 'left' | 'bottom';
|
||||
/**
|
||||
* If set to true, snaps to default size when resizing close to it
|
||||
*/
|
||||
snap?: boolean;
|
||||
/**
|
||||
* If set to true, resizing beyond minSize sets size to 0 and isCollapsed to true
|
||||
* until onResizeEnd is called
|
||||
*/
|
||||
allowCollapse?: boolean;
|
||||
/**
|
||||
* If set to true, resizing beyond maxSize sets size to the container size and
|
||||
* isFullSize to true until onResizeEnd is called
|
||||
*/
|
||||
allowFullSize?: boolean;
|
||||
}
|
||||
|
||||
export function useResizablePanel(
|
||||
localStorageKey: string,
|
||||
{
|
||||
container,
|
||||
defaultSize,
|
||||
snap = true,
|
||||
minSize = 0,
|
||||
maxSize = (size) => size,
|
||||
position = 'left',
|
||||
allowCollapse,
|
||||
allowFullSize,
|
||||
}: UseResizerV2Options,
|
||||
) {
|
||||
const containerSize = ref(0);
|
||||
const persistedSize = useLocalStorage(localStorageKey, -1, { writeDefaults: false });
|
||||
const isResizing = ref(false);
|
||||
const sizeOnResizeStart = ref<number>();
|
||||
const minSizeValue = computed(() => resolveSize(minSize, containerSize.value));
|
||||
const maxSizeValue = computed(() => resolveSize(maxSize, containerSize.value));
|
||||
const constrainedSize = computed(() => {
|
||||
const sizeInPixels =
|
||||
persistedSize.value >= 0 && persistedSize.value <= 1
|
||||
? containerSize.value * persistedSize.value
|
||||
: -1;
|
||||
|
||||
if (isResizing.value && allowCollapse && sizeInPixels < 30) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (isResizing.value && allowFullSize && sizeInPixels > containerSize.value - 30) {
|
||||
return containerSize.value;
|
||||
}
|
||||
|
||||
const defaultSizeValue = resolveSize(defaultSize, containerSize.value);
|
||||
|
||||
if (Number.isNaN(sizeInPixels) || !Number.isFinite(sizeInPixels) || sizeInPixels < 0) {
|
||||
return defaultSizeValue;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
minSizeValue.value,
|
||||
Math.min(
|
||||
snap && Math.abs(defaultSizeValue - sizeInPixels) < 30 ? defaultSizeValue : sizeInPixels,
|
||||
maxSizeValue.value,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
function getSize(el: { width: number; height: number }) {
|
||||
return position === 'bottom' ? el.height : el.width;
|
||||
}
|
||||
|
||||
function getOffsetSize(el: { offsetWidth: number; offsetHeight: number }) {
|
||||
return position === 'bottom' ? el.offsetHeight : el.offsetWidth;
|
||||
}
|
||||
|
||||
function getValue(data: { x: number; y: number }) {
|
||||
return position === 'bottom' ? data.y : data.x;
|
||||
}
|
||||
|
||||
function resolveSize(getter: GetSize, containerSizeValue: number): number {
|
||||
return typeof getter === 'number' ? getter : getter(containerSizeValue);
|
||||
}
|
||||
|
||||
function onResize(data: ResizeData) {
|
||||
const containerRect = unref(container)?.getBoundingClientRect();
|
||||
const newSizeInPixels = Math.max(
|
||||
0,
|
||||
position === 'bottom'
|
||||
? (containerRect ? getSize(containerRect) : 0) - getValue(data)
|
||||
: getValue(data) - (containerRect ? getValue(containerRect) : 0),
|
||||
);
|
||||
|
||||
isResizing.value = true;
|
||||
persistedSize.value = newSizeInPixels / containerSize.value;
|
||||
|
||||
if (sizeOnResizeStart.value === undefined) {
|
||||
sizeOnResizeStart.value = persistedSize.value;
|
||||
}
|
||||
}
|
||||
|
||||
function onResizeEnd() {
|
||||
// If resizing ends with either collapsing or maximizing the panel, restore size at the start of dragging
|
||||
if (
|
||||
(minSizeValue.value > 0 && constrainedSize.value <= 0) ||
|
||||
(maxSizeValue.value < containerSize.value && constrainedSize.value >= containerSize.value)
|
||||
) {
|
||||
persistedSize.value = sizeOnResizeStart.value;
|
||||
}
|
||||
|
||||
sizeOnResizeStart.value = undefined;
|
||||
isResizing.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => unref(container),
|
||||
(el, _, onCleanUp) => {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
containerSize.value = getOffsetSize(el);
|
||||
});
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
containerSize.value = getOffsetSize(el);
|
||||
|
||||
onCleanUp(() => observer.disconnect());
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return {
|
||||
isResizing: computed(() => isResizing.value),
|
||||
isCollapsed: computed(() => isResizing.value && constrainedSize.value <= 0),
|
||||
isFullSize: computed(() => isResizing.value && constrainedSize.value >= containerSize.value),
|
||||
size: constrainedSize,
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
<N8nButton
|
||||
v-if="type === CHAT_TRIGGER_NODE_TYPE"
|
||||
:type="isChatOpen ? 'secondary' : 'primary'"
|
||||
size="large"
|
||||
:disabled="isExecuting"
|
||||
:data-test-id="testId"
|
||||
:label="isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.open')"
|
||||
@click.capture="toggleChatOpen('node')"
|
||||
/>
|
||||
<template v-if="type === CHAT_TRIGGER_NODE_TYPE">
|
||||
<N8nButton
|
||||
v-if="isChatOpen"
|
||||
type="secondary"
|
||||
size="large"
|
||||
:disabled="isExecuting"
|
||||
:data-test-id="testId"
|
||||
: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"
|
||||
|
||||
Reference in New Issue
Block a user