feat(editor): Log details panel (#14409)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Suguru Inoue
2025-04-17 13:44:40 +02:00
committed by GitHub
parent 3cdc8b41be
commit 1e0853b24a
36 changed files with 1633 additions and 303 deletions

View File

@@ -157,6 +157,7 @@ async function copySessionId() {
data-test-id="chat-session-id"
type="secondary"
size="mini"
:class="$style.newHeaderButton"
@click.stop="copySessionId"
>{{ sessionIdText }}</N8nButton
>
@@ -166,7 +167,7 @@ async function copySessionId() {
:content="locale.baseText('chat.window.session.resetSession')"
>
<N8nIconButton
:class="$style.headerButton"
:class="$style.newHeaderButton"
data-test-id="refresh-session-button"
outline
type="secondary"
@@ -310,6 +311,7 @@ async function copySessionId() {
overflow: hidden;
background-color: var(--color-background-light);
}
.chatHeader {
font-size: var(--font-size-s);
font-weight: var(--font-weight-regular);
@@ -322,9 +324,11 @@ async function copySessionId() {
justify-content: space-between;
align-items: center;
}
.chatTitle {
font-weight: var(--font-weight-medium);
}
.session {
display: flex;
align-items: center;
@@ -332,6 +336,7 @@ async function copySessionId() {
color: var(--color-text-base);
max-width: 70%;
}
.sessionId {
display: inline-block;
white-space: nowrap;
@@ -342,10 +347,17 @@ async function copySessionId() {
cursor: pointer;
}
}
.headerButton {
max-height: 1.1rem;
border: none;
}
.newHeaderButton {
border: none;
color: var(--color-text-light);
}
.chatBody {
display: flex;
height: 100%;

View File

@@ -30,7 +30,7 @@ interface ChatState {
displayExecution: (executionId: string) => void;
}
export function useChatState(isReadOnly: boolean, onWindowResize: () => void): ChatState {
export function useChatState(isReadOnly: boolean, onWindowResize?: () => void): ChatState {
const locale = useI18n();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
@@ -141,7 +141,7 @@ export function useChatState(isReadOnly: boolean, onWindowResize: () => void): C
setConnectedNode();
setTimeout(() => {
onWindowResize();
onWindowResize?.();
chatEventBus.emit('focusInput');
}, 0);
}

View File

@@ -14,8 +14,8 @@ import {
interface UsePiPWindowOptions {
initialWidth?: number;
initialHeight?: number;
container: Readonly<ShallowRef<HTMLDivElement | null>>;
content: Readonly<ShallowRef<HTMLDivElement | null>>;
container: Readonly<ShallowRef<HTMLElement | null>>;
content: Readonly<ShallowRef<HTMLElement | null>>;
shouldPopOut: ComputedRef<boolean>;
onRequestClose: () => void;
}

View File

@@ -5,8 +5,9 @@ import type { IChatResizeStyles } from '../types/chat';
import { useStorage } from '@/composables/useStorage';
import { type ResizeData } from '@n8n/design-system';
const LOCAL_STORAGE_PANEL_HEIGHT = 'N8N_CANVAS_CHAT_HEIGHT';
const LOCAL_STORAGE_PANEL_WIDTH = 'N8N_CANVAS_CHAT_WIDTH';
export const LOCAL_STORAGE_PANEL_HEIGHT = 'N8N_CANVAS_CHAT_HEIGHT';
export const LOCAL_STORAGE_PANEL_WIDTH = 'N8N_CANVAS_CHAT_WIDTH';
export const LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH = 'N8N_LOGS_OVERVIEW_PANEL_WIDTH';
// Percentage of container width for chat panel constraints
const MAX_WIDTH_PERCENTAGE = 0.8;

View File

@@ -1,5 +1,5 @@
import { renderComponent } from '@/__tests__/render';
import { fireEvent, within } from '@testing-library/vue';
import { fireEvent, waitFor, within } from '@testing-library/vue';
import { mockedStore } from '@/__tests__/utils';
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
import { useSettingsStore } from '@/stores/settings.store';
@@ -15,8 +15,11 @@ import {
nodeTypes,
} from '../__test__/data';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { LOGS_PANEL_STATE } from '../types/logs';
describe('LogsPanel', () => {
const VIEWPORT_HEIGHT = 800;
let pinia: TestingPinia;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
@@ -50,6 +53,17 @@ describe('LogsPanel', () => {
nodeTypeStore = mockedStore(useNodeTypesStore);
nodeTypeStore.setNodeTypes(nodeTypes);
Object.defineProperty(document.body, 'offsetHeight', {
configurable: true,
get() {
return VIEWPORT_HEIGHT;
},
});
vi.spyOn(document.body, 'getBoundingClientRect').mockReturnValue({
y: 0,
height: VIEWPORT_HEIGHT,
} as DOMRect);
});
it('should render collapsed panel by default', async () => {
@@ -112,7 +126,9 @@ describe('LogsPanel', () => {
expect(rendered.getByTestId('log-details')).toBeInTheDocument();
// Click again to close the panel
await fireEvent.click(await rendered.findByText('AI Agent'));
await fireEvent.click(
await within(rendered.getByTestId('logs-overview-body')).findByText('AI Agent'),
);
expect(rendered.queryByTestId('log-details')).not.toBeInTheDocument();
});
@@ -130,11 +146,53 @@ describe('LogsPanel', () => {
// Click the toggle button to close the panel
await fireEvent.click(within(detailsPanel).getByLabelText('Collapse panel'));
expect(rendered.queryByTestId('chat-messages-empty')).not.toBeInTheDocument();
expect(rendered.queryByText('AI Agent', { exact: false })).not.toBeInTheDocument();
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
// Click again to open the panel
await fireEvent.click(within(detailsPanel).getByLabelText('Open panel'));
expect(await rendered.findByTestId('chat-messages-empty')).toBeInTheDocument();
expect(await rendered.findByText('AI Agent', { exact: false })).toBeInTheDocument();
expect(await rendered.findByTestId('logs-overview-body')).toBeInTheDocument();
});
it('should open itself by pulling up the resizer', async () => {
workflowsStore.toggleLogsPanelOpen(false);
const rendered = render();
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.CLOSED);
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
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(workflowsStore.logsPanelState).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);
const rendered = render();
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.ATTACHED);
expect(rendered.queryByTestId('logs-overview-body')).toBeInTheDocument();
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 0, clientY: VIEWPORT_HEIGHT }),
);
window.dispatchEvent(
new MouseEvent('mouseup', { bubbles: true, clientX: 0, clientY: VIEWPORT_HEIGHT }),
);
await waitFor(() => {
expect(workflowsStore.logsPanelState).toBe(LOGS_PANEL_STATE.CLOSED);
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
});
});
});

View File

@@ -1,16 +1,12 @@
<script setup lang="ts">
import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed, ref, useTemplateRef, watch } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
import { N8nResizeWrapper } from '@n8n/design-system';
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
import { useResize } from '@/components/CanvasChat/composables/useResize';
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
import { useTelemetry } from '@/composables/useTelemetry';
import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue';
import { useCanvasStore } from '@/stores/canvas.store';
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue';
import LogsDetailsPanel from '@/components/CanvasChat/future/components/LogDetailsPanel.vue';
import { LOGS_PANEL_STATE, type LogEntrySelection } from '@/components/CanvasChat/types/logs';
import { type LogEntrySelection } from '@/components/CanvasChat/types/logs';
import LogsPanelActions from '@/components/CanvasChat/future/components/LogsPanelActions.vue';
import {
createLogEntries,
@@ -18,24 +14,39 @@ import {
type TreeNode,
} from '@/components/RunDataAi/utils';
import { isChatNode } from '@/components/CanvasChat/utils';
import { useLayout } from '@/components/CanvasChat/future/composables/useLayout';
const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false });
const workflowsStore = useWorkflowsStore();
const canvasStore = useCanvasStore();
const panelState = computed(() => workflowsStore.logsPanelState);
const container = ref<HTMLElement>();
const container = useTemplateRef('container');
const logsContainer = useTemplateRef('logsContainer');
const pipContainer = useTemplateRef('pipContainer');
const pipContent = useTemplateRef('pipContent');
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const telemetry = useTelemetry();
const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResizeChatDebounced } =
useResize(container);
const {
height,
chatPanelWidth,
overviewPanelWidth,
canPopOut,
isOpen,
isPoppedOut,
isCollapsingDetailsPanel,
isOverviewPanelFullWidth,
pipWindow,
onResize,
onResizeEnd,
onToggleOpen,
onPopOut,
onChatPanelResize,
onChatPanelResizeEnd,
onOverviewPanelResize,
onOverviewPanelResizeEnd,
} = useLayout(pipContainer, pipContent, container, logsContainer);
const { currentSessionId, messages, sendMessage, refreshSession, displayExecution } = useChatState(
props.isReadOnly,
onWindowResize,
);
const hasChat = computed(
@@ -66,45 +77,18 @@ const selectedLogEntry = computed(() =>
? undefined
: manualLogEntrySelection.value.data,
);
const isLogDetailsOpen = computed(() => selectedLogEntry.value !== undefined);
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
initialHeight: 400,
initialWidth: window.document.body.offsetWidth * 0.8,
container: pipContainer,
content: pipContent,
shouldPopOut: computed(() => panelState.value === LOGS_PANEL_STATE.FLOATING),
onRequestClose: () => {
if (panelState.value === LOGS_PANEL_STATE.CLOSED) {
return;
}
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPreferPoppedOutLogsView(false);
},
});
const isLogDetailsOpen = computed(
() => selectedLogEntry.value !== undefined && !isCollapsingDetailsPanel.value,
);
const isLogDetailsOpenOrCollapsing = computed(() => selectedLogEntry.value !== undefined);
const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$props']>(() => ({
panelState: panelState.value,
isOpen: isOpen.value,
showToggleButton: !isPoppedOut.value,
showPopOutButton: canPopOut.value && !isPoppedOut.value,
onPopOut,
onToggleOpen,
}));
function onToggleOpen() {
workflowsStore.toggleLogsPanelOpen();
telemetry.track('User toggled log view', {
new_state: panelState.value === LOGS_PANEL_STATE.CLOSED ? 'attached' : 'collapsed',
});
}
function handleClickHeader() {
if (panelState.value === LOGS_PANEL_STATE.CLOSED) {
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.toggleLogsPanelOpen(true);
}
}
function handleSelectLogEntry(selected: TreeNode | undefined) {
manualLogEntrySelection.value =
selected === undefined
@@ -112,21 +96,13 @@ function handleSelectLogEntry(selected: TreeNode | undefined) {
: { type: 'selected', workflowId: workflowsStore.workflow.id, data: selected };
}
function onPopOut() {
telemetry.track('User toggled log view', { new_state: 'floating' });
workflowsStore.toggleLogsPanelOpen(true);
workflowsStore.setPreferPoppedOutLogsView(true);
}
function handleResizeOverviewPanelEnd() {
if (isOverviewPanelFullWidth.value) {
handleSelectLogEntry(undefined);
}
watch([panelState, height], ([state, h]) => {
canvasStore.setPanelHeight(
state === LOGS_PANEL_STATE.FLOATING
? 0
: state === LOGS_PANEL_STATE.ATTACHED
? h
: 32 /* collapsed panel height */,
);
});
onOverviewPanelResizeEnd();
}
</script>
<template>
@@ -135,24 +111,27 @@ watch([panelState, height], ([state, h]) => {
<N8nResizeWrapper
:height="height"
:supported-directions="['top']"
:is-resizing-enabled="panelState === LOGS_PANEL_STATE.ATTACHED"
:style="rootStyles"
:class="[$style.resizeWrapper, panelState === LOGS_PANEL_STATE.CLOSED ? '' : $style.isOpen]"
@resize="onResizeDebounced"
:is-resizing-enabled="!isPoppedOut"
:class="$style.resizeWrapper"
:style="{ height: isOpen ? `${height}px` : 'auto' }"
@resize="onResize"
@resizeend="onResizeEnd"
>
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
<N8nResizeWrapper
v-if="hasChat"
:supported-directions="['right']"
:is-resizing-enabled="panelState !== LOGS_PANEL_STATE.CLOSED"
:width="chatWidth"
:is-resizing-enabled="isOpen"
:width="chatPanelWidth"
:style="{ width: `${chatPanelWidth}px` }"
:class="$style.chat"
:window="pipWindow"
@resize="onResizeChatDebounced"
@resize="onChatPanelResize"
@resizeend="onChatPanelResizeEnd"
>
<ChatMessagesPanel
data-test-id="canvas-chat"
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
:is-open="isOpen"
:is-read-only="isReadOnly"
:messages="messages"
:session-id="currentSessionId"
@@ -163,32 +142,48 @@ watch([panelState, height], ([state, h]) => {
@refresh-session="refreshSession"
@display-execution="displayExecution"
@send-message="sendMessage"
@click-header="handleClickHeader"
@click-header="onToggleOpen(true)"
/>
</N8nResizeWrapper>
<LogsOverviewPanel
:class="$style.logsOverview"
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
:is-read-only="isReadOnly"
:selected="selectedLogEntry"
:execution-tree="executionTree"
@click-header="handleClickHeader"
@select="handleSelectLogEntry"
>
<template #actions>
<LogsPanelActions v-if="!isLogDetailsOpen" v-bind="logsPanelActionsProps" />
</template>
</LogsOverviewPanel>
<LogsDetailsPanel
v-if="selectedLogEntry !== undefined"
:class="$style.logDetails"
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
@click-header="handleClickHeader"
>
<template #actions>
<LogsPanelActions v-if="isLogDetailsOpen" v-bind="logsPanelActionsProps" />
</template>
</LogsDetailsPanel>
<div ref="logsContainer" :class="$style.logsContainer">
<N8nResizeWrapper
:class="$style.overviewResizer"
:width="overviewPanelWidth"
:style="{ width: isLogDetailsOpen ? `${overviewPanelWidth}px` : '' }"
:supported-directions="['right']"
:is-resizing-enabled="isLogDetailsOpenOrCollapsing"
:window="pipWindow"
@resize="onOverviewPanelResize"
@resizeend="handleResizeOverviewPanelEnd"
>
<LogsOverviewPanel
:class="$style.logsOverview"
:is-open="isOpen"
:is-read-only="isReadOnly"
:is-compact="isLogDetailsOpen"
:selected="selectedLogEntry"
:execution-tree="executionTree"
@click-header="onToggleOpen(true)"
@select="handleSelectLogEntry"
>
<template #actions>
<LogsPanelActions v-if="!isLogDetailsOpen" v-bind="logsPanelActionsProps" />
</template>
</LogsOverviewPanel>
</N8nResizeWrapper>
<LogsDetailsPanel
v-if="isLogDetailsOpenOrCollapsing && selectedLogEntry"
:class="$style.logDetails"
:is-open="isOpen"
:log-entry="selectedLogEntry"
:window="pipWindow"
@click-header="onToggleOpen(true)"
>
<template #actions>
<LogsPanelActions v-if="isLogDetailsOpen" v-bind="logsPanelActionsProps" />
</template>
</LogsDetailsPanel>
</div>
</div>
</N8nResizeWrapper>
</div>
@@ -215,13 +210,6 @@ watch([panelState, height], ([state, h]) => {
flex-basis: 0;
border-top: var(--border-base);
background-color: var(--color-background-light);
&.isOpen {
height: var(--panel-height);
min-height: 4rem;
max-height: 90vh;
flex-basis: content;
}
}
.container {
@@ -235,21 +223,35 @@ watch([panelState, height], ([state, h]) => {
}
.chat {
width: var(--chat-width);
flex-shrink: 0;
max-width: 100%;
}
.logsContainer {
width: 0;
flex-grow: 1;
display: flex;
align-items: stretch;
& > *:not(:last-child) {
border-right: var(--border-base);
}
}
.overviewResizer {
flex-grow: 0;
flex-shrink: 0;
&:last-child {
flex-grow: 1;
}
}
.logsOverview {
flex-basis: 20%;
flex-grow: 1;
flex-shrink: 1;
min-width: 360px;
height: 100%;
}
.logDetails {
flex-basis: 60%;
.logsDetails {
width: 0;
flex-grow: 1;
flex-shrink: 1;
}
</style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
import { useI18n } from '@/composables/useI18n';
import { type LlmTokenUsageData } from '@/Interface';
import { N8nText } from '@n8n/design-system';
import { upperFirst } from 'lodash-es';
import { type ExecutionStatus } from 'n8n-workflow';
import { computed } from 'vue';
const { status, consumedTokens, timeTook } = defineProps<{
status: ExecutionStatus;
consumedTokens: LlmTokenUsageData;
timeTook?: number;
}>();
const locale = useI18n();
const executionStatusText = computed(() =>
timeTook === undefined
? upperFirst(status)
: locale.baseText('logs.overview.body.summaryText', {
interpolate: {
status: upperFirst(status),
time: locale.displayTimer(timeTook, true),
},
}),
);
</script>
<template>
<N8nText tag="div" color="text-light" size="small" :class="$style.container">
<span>{{ executionStatusText }}</span>
<ConsumedTokenCountText
v-if="consumedTokens.totalTokens > 0"
:consumed-tokens="consumedTokens"
/>
</N8nText>
</template>
<style lang="scss" module>
.container {
display: flex;
align-items: center;
& > * {
padding-inline: var(--spacing-2xs);
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
& > *:not(:last-child) {
border-right: var(--border-base);
}
}
</style>

View File

@@ -0,0 +1,168 @@
import { fireEvent, waitFor, within } from '@testing-library/vue';
import { renderComponent } from '@/__tests__/render';
import LogDetailsPanel from './LogDetailsPanel.vue';
import { createRouter, createWebHistory } from 'vue-router';
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
import { h } from 'vue';
import {
createTestLogEntry,
createTestNode,
createTestTaskData,
createTestWorkflow,
} from '@/__tests__/mocks';
import { mockedStore } from '@/__tests__/utils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSettingsStore } from '@/stores/settings.store';
import { type FrontendSettings } from '@n8n/api-types';
describe('LogDetailsPanel', () => {
let pinia: TestingPinia;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
function render(props: InstanceType<typeof LogDetailsPanel>['$props']) {
const rendered = renderComponent(LogDetailsPanel, {
props,
global: {
plugins: [
createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: () => h('div') }],
}),
pinia,
],
},
});
const container = rendered.getByTestId('log-details');
Object.defineProperty(container, 'offsetWidth', {
configurable: true,
get() {
return 1000;
},
});
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
x: 0,
width: 1000,
} as DOMRect);
return rendered;
}
beforeEach(() => {
pinia = createTestingPinia({ stubActions: false, fakeApp: true });
settingsStore = mockedStore(useSettingsStore);
settingsStore.isEnterpriseFeatureEnabled = {} as FrontendSettings['enterprise'];
const workflowData = createTestWorkflow({
nodes: [createTestNode({ name: 'Chat Trigger' }), createTestNode({ name: 'AI Agent' })],
connections: { 'Chat Trigger': { main: [[{ node: 'AI Agent', type: 'main', index: 0 }]] } },
});
workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.setNodes(workflowData.nodes);
workflowsStore.setConnections(workflowData.connections);
workflowsStore.setWorkflowExecutionData({
id: 'test-exec-id',
finished: true,
mode: 'manual',
status: 'error',
workflowData,
data: {
resultData: {
runData: {
'Chat Trigger': [
createTestTaskData({
executionStatus: 'success',
executionTime: 0,
data: { main: [[{ json: { chatInput: 'hey' } }]] },
}),
],
'AI Agent': [
createTestTaskData({
executionStatus: 'success',
executionTime: 10,
data: { main: [[{ json: { response: 'Hello!' } }]] },
}),
],
},
},
},
createdAt: '2025-04-16T00:00:00.000Z',
startedAt: '2025-04-16T00:00:01.000Z',
});
});
it('should show name, run status, input, and output of the node', async () => {
const rendered = render({
isOpen: true,
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
});
const header = within(rendered.getByTestId('log-details-header'));
const inputPanel = within(rendered.getByTestId('log-details-input'));
const outputPanel = within(rendered.getByTestId('log-details-output'));
expect(header.getByText('AI Agent')).toBeInTheDocument();
expect(header.getByText('Success in 10ms')).toBeInTheDocument();
expect(await inputPanel.findByText('hey')).toBeInTheDocument();
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: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
});
const header = within(rendered.getByTestId('log-details-header'));
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
await fireEvent.click(header.getByText('Input'));
expect(rendered.queryByTestId('log-details-input')).not.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 () => {
const rendered = render({
isOpen: true,
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
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();
});
});
it('should close output panel by dragging the divider to the right end', async () => {
const rendered = render({
isOpen: true,
logEntry: createTestLogEntry({ node: 'AI Agent', runIndex: 0 }),
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
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();
});
});
});

View File

@@ -1,25 +1,175 @@
<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 NodeIcon from '@/components/NodeIcon.vue';
import { getSubtreeTotalConsumedTokens, type TreeNode } from '@/components/RunDataAi/utils';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { type INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { N8nButton, N8nResizeWrapper, N8nText } from '@n8n/design-system';
import { type ITaskData } from 'n8n-workflow';
import { computed, ref, useTemplateRef } from 'vue';
const { isOpen } = defineProps<{ isOpen: boolean }>();
const MIN_IO_PANEL_WIDTH = 200;
const { isOpen, logEntry, window } = defineProps<{
isOpen: boolean;
logEntry: TreeNode;
window?: Window;
}>();
const emit = defineEmits<{ clickHeader: [] }>();
defineSlots<{ actions: {} }>();
const locale = useI18n();
const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore();
const nodeTypeStore = useNodeTypesStore();
const content = ref<LogDetailsContent>(LOG_DETAILS_CONTENT.BOTH);
const node = computed<INodeUi | undefined>(() => workflowsStore.nodesByName[logEntry.node]);
const type = computed(() => (node.value ? nodeTypeStore.getNodeType(node.value.type) : undefined));
const runData = computed<ITaskData | undefined>(
() =>
(workflowsStore.workflowExecutionData?.data?.resultData.runData[logEntry.node] ?? [])[
logEntry.runIndex
],
);
const consumedTokens = computed(() => getSubtreeTotalConsumedTokens(logEntry));
const isTriggerNode = computed(() => type.value?.group.includes('trigger'));
const container = useTemplateRef<HTMLElement>('container');
const resizer = useResizablePanel('N8N_LOGS_INPUT_PANEL_WIDTH', {
container,
defaultSize: (size) => size / 2,
minSize: MIN_IO_PANEL_WIDTH,
maxSize: (size) => size - MIN_IO_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',
});
}
function handleResizeEnd() {
if (resizer.isCollapsed.value) {
handleToggleInput(false);
}
if (resizer.isFullSize.value) {
handleToggleOutput(false);
}
resizer.onResizeEnd();
}
</script>
<template>
<div :class="$style.container" data-test-id="log-details">
<PanelHeader
title="Log details"
data-test-id="logs-details-header"
@click="emit('clickHeader')"
>
<div ref="container" :class="$style.container" data-test-id="log-details">
<PanelHeader data-test-id="log-details-header" @click="emit('clickHeader')">
<template #title>
<div :class="$style.title">
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
<N8nText tag="div" :bold="true" size="small" :class="$style.name">
{{ node?.name }}
</N8nText>
<ExecutionSummary
v-if="isOpen"
:class="$style.executionSummary"
:status="runData?.executionStatus ?? 'unknown'"
:consumed-tokens="consumedTokens"
:time-took="runData?.executionTime"
/>
</div>
</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"
>
{{ locale.baseText('logs.details.header.actions.input') }}
</N8nButton>
<N8nButton
size="mini"
type="secondary"
:class="content === LOG_DETAILS_CONTENT.INPUT ? '' : $style.pressed"
@click.stop="handleToggleOutput"
>
{{ locale.baseText('logs.details.header.actions.output') }}
</N8nButton>
</div>
<slot name="actions" />
</template>
</PanelHeader>
<div v-if="isOpen" :class="$style.content" data-test-id="logs-details-body" />
<div v-if="isOpen" :class="$style.content" data-test-id="logs-details-body">
<N8nResizeWrapper
v-if="!isTriggerNode && content !== LOG_DETAILS_CONTENT.OUTPUT"
:class="{
[$style.inputResizer]: true,
[$style.collapsed]: resizer.isCollapsed.value,
[$style.full]: resizer.isFullSize.value,
}"
:width="resizer.size.value"
:style="shouldResize ? { width: `${resizer.size.value ?? 0}px` } : undefined"
:supported-directions="['right']"
:is-resizing-enabled="shouldResize"
:window="window"
@resize="resizer.onResize"
@resizeend="handleResizeEnd"
>
<RunDataView
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"
data-test-id="log-details-output"
pane-type="output"
:class="$style.outputPanel"
:title="locale.baseText('logs.details.header.actions.output')"
:log-entry="logEntry"
/>
</div>
</div>
</template>
@@ -31,11 +181,58 @@ defineSlots<{ actions: {} }>();
flex-direction: column;
align-items: stretch;
overflow: hidden;
background-color: var(--color-foreground-xlight);
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
padding-inline-end: var(--spacing-2xs);
.pressed {
background-color: var(--color-button-secondary-focus-outline);
}
}
.title {
display: flex;
align-items: center;
flex-shrink: 1;
}
.icon {
margin-right: var(--spacing-2xs);
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.executionSummary {
flex-shrink: 1;
}
.content {
flex-shrink: 1;
flex-grow: 1;
padding: var(--spacing-s);
display: flex;
align-items: stretch;
overflow: hidden;
}
.outputPanel {
width: 0;
flex-grow: 1;
}
.inputResizer {
overflow: hidden;
flex-shrink: 0;
&:not(:is(:last-child, .collapsed, .full)) {
border-right: var(--border-base);
}
}
</style>

View File

@@ -27,6 +27,7 @@ describe('LogsOverviewPanel', () => {
const mergedProps: InstanceType<typeof LogsOverviewPanel>['$props'] = {
isOpen: false,
isReadOnly: false,
isCompact: false,
executionTree: createLogEntries(
workflowsStore.getCurrentWorkflow(),
workflowsStore.workflowExecutionData?.data?.resultData.runData ?? {},

View File

@@ -12,17 +12,18 @@ import {
getTotalConsumedTokens,
type TreeNode,
} from '@/components/RunDataAi/utils';
import { upperFirst } from 'lodash-es';
import { useTelemetry } from '@/composables/useTelemetry';
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.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';
const { isOpen, isReadOnly, selected, executionTree } = defineProps<{
const { isOpen, isReadOnly, selected, isCompact, executionTree } = defineProps<{
isOpen: boolean;
isReadOnly: boolean;
selected?: TreeNode;
isReadOnly: boolean;
isCompact: boolean;
executionTree: TreeNode[];
}>();
@@ -44,27 +45,7 @@ const switchViewOptions = computed(() => [
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
{ label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' as const },
]);
const executionStatusText = computed(() => {
const execution = workflowsStore.workflowExecutionData;
if (!execution) {
return undefined;
}
if (execution.startedAt && execution.stoppedAt) {
return locale.baseText('logs.overview.body.summaryText', {
interpolate: {
status: upperFirst(execution.status),
time: locale.displayTimer(
+new Date(execution.stoppedAt) - +new Date(execution.startedAt),
true,
),
},
});
}
return upperFirst(execution.status);
});
const execution = computed(() => workflowsStore.workflowExecutionData);
const consumedTokens = computed(() =>
getTotalConsumedTokens(...executionTree.map(getSubtreeTotalConsumedTokens)),
);
@@ -123,6 +104,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
type="secondary"
icon="trash"
icon-size="medium"
:class="$style.clearButton"
@click.stop="onClearExecutionData"
>{{ locale.baseText('logs.overview.header.actions.clearExecution') }}</N8nButton
>
@@ -146,19 +128,17 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
{{ locale.baseText('logs.overview.body.empty.message') }}
</N8nText>
<div v-else :class="$style.scrollable">
<N8nText
v-if="executionStatusText !== undefined"
tag="div"
color="text-light"
size="small"
<ExecutionSummary
v-if="execution"
:class="$style.summary"
>
<span>{{ executionStatusText }}</span>
<ConsumedTokenCountText
v-if="consumedTokens.totalTokens > 0"
:consumed-tokens="consumedTokens"
/>
</N8nText>
:status="execution.status"
:consumed-tokens="consumedTokens"
:time-took="
execution.startedAt && execution.stoppedAt
? +new Date(execution.stoppedAt) - +new Date(execution.startedAt)
: undefined
"
/>
<ElTree
v-if="executionTree.length > 0"
node-key="id"
@@ -175,7 +155,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
:node="elTreeNode"
:is-read-only="isReadOnly"
:is-selected="data.node === selected?.node && data.runIndex === selected?.runIndex"
:is-compact="selected !== undefined"
:is-compact="isCompact"
:should-show-consumed-tokens="consumedTokens.totalTokens > 0"
@toggle-expanded="handleToggleExpanded"
@open-ndv="handleOpenNdv"
@@ -196,6 +176,8 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
</template>
<style lang="scss" module>
@import '@/styles/variables';
.container {
flex-grow: 1;
flex-shrink: 1;
@@ -206,6 +188,11 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
background-color: var(--color-foreground-xlight);
}
.clearButton {
border: none;
color: var(--color-text-light);
}
.content {
position: relative;
flex-grow: 1;
@@ -227,28 +214,19 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
}
.scrollable {
padding: var(--spacing-2xs);
flex-grow: 1;
flex-shrink: 1;
overflow: auto;
}
.summary {
display: flex;
align-items: center;
padding-block: var(--spacing-2xs);
& > * {
padding-inline: var(--spacing-2xs);
}
& > *:not(:last-child) {
border-right: var(--border-base);
}
margin-bottom: var(--spacing-4xs);
padding: var(--spacing-4xs) var(--spacing-2xs) 0 var(--spacing-2xs);
min-height: calc(30px + var(--spacing-s));
}
.tree {
margin-top: var(--spacing-2xs);
padding: 0 var(--spacing-2xs) var(--spacing-2xs) var(--spacing-2xs);
& :global(.el-icon) {
display: none;
@@ -261,5 +239,13 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
right: 0;
top: 0;
margin: var(--spacing-2xs);
visibility: hidden;
opacity: 0;
transition: opacity 0.3s $ease-out-expo;
.content:hover & {
visibility: visible;
opacity: 1;
}
}
</style>

View File

@@ -274,6 +274,7 @@ watch(
}
.name {
flex-basis: 0;
flex-grow: 1;
padding-inline-start: 0;
}
@@ -288,6 +289,10 @@ watch(
vertical-align: text-bottom;
}
.compact & {
flex-shrink: 1;
}
.compact:hover & {
width: auto;
}
@@ -313,6 +318,10 @@ watch(
width: 10%;
text-align: right;
.compact & {
flex-shrink: 1;
}
.compact:hover & {
width: auto;
}

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { LOGS_PANEL_STATE, type LogsPanelState } from '@/components/CanvasChat/types/logs';
import { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
import { computed } from 'vue';
const { panelState, showPopOutButton } = defineProps<{
panelState: LogsPanelState;
const { isOpen, showToggleButton, showPopOutButton } = defineProps<{
isOpen: boolean;
showToggleButton: boolean;
showPopOutButton: boolean;
}>();
@@ -17,16 +17,12 @@ const locales = useI18n();
const tooltipZIndex = computed(() => appStyles.APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON + 100);
const popOutButtonText = computed(() => locales.baseText('runData.panel.actions.popOut'));
const toggleButtonText = computed(() =>
locales.baseText(
panelState === LOGS_PANEL_STATE.ATTACHED
? 'runData.panel.actions.collapse'
: 'runData.panel.actions.open',
),
locales.baseText(isOpen ? 'runData.panel.actions.collapse' : 'runData.panel.actions.open'),
);
</script>
<template>
<div>
<div :class="$style.container">
<N8nTooltip v-if="showPopOutButton" :z-index="tooltipZIndex" :content="popOutButtonText">
<N8nIconButton
icon="pop-out"
@@ -37,16 +33,12 @@ const toggleButtonText = computed(() =>
@click.stop="emit('popOut')"
/>
</N8nTooltip>
<N8nTooltip
v-if="panelState !== LOGS_PANEL_STATE.FLOATING"
:z-index="tooltipZIndex"
:content="toggleButtonText"
>
<N8nTooltip v-if="showToggleButton" :z-index="tooltipZIndex" :content="toggleButtonText">
<N8nIconButton
type="secondary"
size="small"
icon-size="medium"
:icon="panelState === LOGS_PANEL_STATE.ATTACHED ? 'chevron-down' : 'chevron-up'"
:icon="isOpen ? 'chevron-down' : 'chevron-up'"
:aria-label="toggleButtonText"
style="color: var(--color-text-base)"
@click.stop="emit('toggleOpen')"
@@ -54,3 +46,14 @@ const toggleButtonText = computed(() =>
</N8nTooltip>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
}
.container button {
border: none;
color: var(--color-text-light);
}
</style>

View File

@@ -1,16 +1,18 @@
<script setup lang="ts">
import { N8nText } from '@n8n/design-system';
defineProps<{ title: string }>();
const { title } = defineProps<{ title?: string }>();
defineSlots<{ actions: {} }>();
defineSlots<{ actions: {}; title?: {} }>();
const emit = defineEmits<{ click: [] }>();
</script>
<template>
<header :class="$style.container" @click="emit('click')">
<N8nText :class="$style.title" :bold="true" size="small">{{ title }}</N8nText>
<N8nText :class="$style.title" :bold="true" size="small">
<slot name="title">{{ title }}</slot>
</N8nText>
<div :class="$style.actions">
<slot name="actions" />
</div>
@@ -44,9 +46,13 @@ const emit = defineEmits<{ click: [] }>();
.title {
flex-grow: 1;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
flex-shrink: 0;
display: flex;
align-items: center;
color: var(--color-text-base);
@@ -54,9 +60,4 @@ const emit = defineEmits<{ click: [] }>();
/* Let button heights not affect the header height */
margin-block: calc(-1 * var(--spacing-s));
}
.actions button {
border: none;
color: var(--color-text-light);
}
</style>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import RunData from '@/components/RunData.vue';
import { type TreeNode } from '@/components/RunDataAi/utils';
import { useI18n } from '@/composables/useI18n';
import { type NodePanelType } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { N8nLink, N8nText } from '@n8n/design-system';
import { uniqBy } from 'lodash-es';
import { computed } from 'vue';
import { I18nT } from 'vue-i18n';
const { title, logEntry, paneType } = defineProps<{
title: string;
paneType: NodePanelType;
logEntry: TreeNode;
}>();
const locale = useI18n();
const workflowsStore = useWorkflowsStore();
const ndvStore = useNDVStore();
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const node = computed(() => {
if (logEntry.depth > 0 || paneType === 'output') {
return workflowsStore.nodesByName[logEntry.node];
}
const parent = workflow.value.getParentNodesByDepth(logEntry.node)[0];
if (!parent) {
return undefined;
}
return workflowsStore.nodesByName[parent.name];
});
const isMultipleInput = computed(
() =>
paneType === 'input' &&
uniqBy(
workflow.value.getParentNodesByDepth(logEntry.node).filter((n) => n.name !== logEntry.node),
(n) => n.name,
).length > 1,
);
function handleClickOpenNdv() {
ndvStore.setActiveNodeName(logEntry.node);
}
</script>
<template>
<RunData
v-if="node"
:node="node"
:workflow="workflow"
:run-index="logEntry.runIndex"
:too-much-data-title="locale.baseText('ndv.output.tooMuchData.title')"
:no-data-in-branch-message="locale.baseText('ndv.output.noOutputDataInBranch')"
:executing-message="locale.baseText('ndv.output.executing')"
:pane-type="paneType"
:disable-run-index-selection="true"
:compact="true"
:disable-pin="true"
:disable-edit="true"
table-header-bg-color="light"
>
<template #header>
<N8nText :class="$style.title" :bold="true" color="text-light" size="small">
{{ title }}
</N8nText>
</template>
<template #no-output-data>
<N8nText :bold="true" color="text-dark" size="large">
{{ locale.baseText('ndv.output.noOutputData.title') }}
</N8nText>
</template>
<template v-if="isMultipleInput" #content>
<!-- leave empty -->
</template>
<template v-if="isMultipleInput" #callout-message>
<I18nT keypath="logs.details.body.multipleInputs">
<template #button>
<N8nLink size="small" @click="handleClickOpenNdv">
{{ locale.baseText('logs.details.body.multipleInputs.openingTheNode') }}
</N8nLink>
</template>
</I18nT>
</template>
</RunData>
</template>
<style lang="scss" module>
.title {
text-transform: uppercase;
letter-spacing: 3px;
}
</style>

View File

@@ -0,0 +1,135 @@
import { computed, type ShallowRef } from 'vue';
import {
LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH,
LOCAL_STORAGE_PANEL_HEIGHT,
LOCAL_STORAGE_PANEL_WIDTH,
} 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';
export function useLayout(
pipContainer: Readonly<ShallowRef<HTMLElement | null>>,
pipContent: Readonly<ShallowRef<HTMLElement | null>>,
container: Readonly<ShallowRef<HTMLElement | null>>,
logsContainer: Readonly<ShallowRef<HTMLElement | null>>,
) {
const canvasStore = useCanvasStore();
const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore();
const panelState = computed(() => workflowsStore.logsPanelState);
const resizer = useResizablePanel(LOCAL_STORAGE_PANEL_HEIGHT, {
container: document.body,
position: 'bottom',
snap: false,
defaultSize: (size) => size * 0.3,
minSize: 160,
maxSize: (size) => size * 0.75,
allowCollapse: true,
});
const chatPanelResizer = useResizablePanel(LOCAL_STORAGE_PANEL_WIDTH, {
container,
defaultSize: (size) => size * 0.3,
minSize: 300,
maxSize: (size) => size * 0.8,
});
const overviewPanelResizer = useResizablePanel(LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH, {
container: logsContainer,
defaultSize: (size) => size * 0.3,
minSize: 80,
maxSize: 500,
allowFullSize: true,
});
const isOpen = computed(() =>
panelState.value === LOGS_PANEL_STATE.CLOSED
? resizer.isResizing.value && resizer.size.value > 0
: !resizer.isCollapsed.value,
);
const isCollapsingDetailsPanel = computed(() => overviewPanelResizer.isFullSize.value);
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
initialHeight: 400,
initialWidth: window.document.body.offsetWidth * 0.8,
container: pipContainer,
content: pipContent,
shouldPopOut: computed(() => panelState.value === LOGS_PANEL_STATE.FLOATING),
onRequestClose: () => {
if (!isOpen.value) {
return;
}
telemetry.track('User toggled log view', { new_state: 'attached' });
workflowsStore.setPreferPoppedOutLogsView(false);
},
});
function handleToggleOpen(open?: boolean) {
const wasOpen = panelState.value !== LOGS_PANEL_STATE.CLOSED;
if (open === wasOpen) {
return;
}
workflowsStore.toggleLogsPanelOpen(open);
telemetry.track('User toggled log view', {
new_state: wasOpen ? 'collapsed' : 'attached',
});
}
function handlePopOut() {
telemetry.track('User toggled log view', { new_state: 'floating' });
workflowsStore.toggleLogsPanelOpen(true);
workflowsStore.setPreferPoppedOutLogsView(true);
}
function handleResizeEnd() {
if (panelState.value === LOGS_PANEL_STATE.CLOSED && !resizer.isCollapsed.value) {
handleToggleOpen(true);
}
if (resizer.isCollapsed.value) {
handleToggleOpen(false);
}
resizer.onResizeEnd();
}
watch([panelState, resizer.size], ([state, height]) => {
canvasStore.setPanelHeight(
state === LOGS_PANEL_STATE.FLOATING
? 0
: state === LOGS_PANEL_STATE.ATTACHED
? height
: 32 /* collapsed panel height */,
);
});
return {
height: resizer.size,
chatPanelWidth: chatPanelResizer.size,
overviewPanelWidth: overviewPanelResizer.size,
canPopOut,
isOpen,
isCollapsingDetailsPanel,
isPoppedOut,
isOverviewPanelFullWidth: overviewPanelResizer.isFullSize,
pipWindow,
onToggleOpen: handleToggleOpen,
onPopOut: handlePopOut,
onResize: resizer.onResize,
onResizeEnd: handleResizeEnd,
onChatPanelResize: chatPanelResizer.onResize,
onChatPanelResizeEnd: chatPanelResizer.onResizeEnd,
onOverviewPanelResize: overviewPanelResizer.onResize,
onOverviewPanelResizeEnd: overviewPanelResizer.onResizeEnd,
};
}

View File

@@ -0,0 +1,172 @@
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, '333');
const { size } = useResizablePanel(localStorageKey, { container, defaultSize: 444 });
expect(size.value).toBe(333);
});
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);
});
});

View File

@@ -0,0 +1,147 @@
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>;
defaultSize: GetSize;
minSize?: GetSize;
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 size = useLocalStorage(localStorageKey, -1, { writeDefaults: false });
const isResizing = ref(false);
const constrainedSize = computed(() => {
if (isResizing.value && allowCollapse && size.value < 30) {
return 0;
}
if (isResizing.value && allowFullSize && size.value > containerSize.value - 30) {
return containerSize.value;
}
const defaultSizeValue = resolveSize(defaultSize, containerSize.value);
if (Number.isNaN(size.value) || size.value < 0) {
return defaultSizeValue;
}
const minSizeValue = resolveSize(minSize, containerSize.value);
const maxSizeValue = resolveSize(maxSize, containerSize.value);
return Math.max(
minSizeValue,
Math.min(
snap && Math.abs(defaultSizeValue - size.value) < 30 ? defaultSizeValue : size.value,
maxSizeValue,
),
);
});
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();
isResizing.value = true;
size.value = Math.max(
0,
position === 'bottom'
? (containerRect ? getSize(containerRect) : 0) - getValue(data)
: getValue(data) - (containerRect ? getValue(containerRect) : 0),
);
}
function onResizeEnd() {
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 },
);
watch(containerSize, (newValue, oldValue) => {
if (size.value > 0 && oldValue > 0) {
// Update size to maintain proportion
const ratio = size.value / oldValue;
size.value = Math.round(newValue * ratio);
}
});
return {
isResizing: computed(() => isResizing.value),
isCollapsed: computed(() => isResizing.value && constrainedSize.value <= 0),
isFullSize: computed(() => isResizing.value && constrainedSize.value >= containerSize.value),
size: constrainedSize,
onResize,
onResizeEnd,
};
}

View File

@@ -12,3 +12,11 @@ export const LOGS_PANEL_STATE = {
} as const;
export type LogsPanelState = (typeof LOGS_PANEL_STATE)[keyof typeof LOGS_PANEL_STATE];
export const LOG_DETAILS_CONTENT = {
INPUT: 'input',
OUTPUT: 'output',
BOTH: 'both',
};
export type LogDetailsContent = (typeof LOG_DETAILS_CONTENT)[keyof typeof LOG_DETAILS_CONTENT];

View File

@@ -352,6 +352,7 @@ function activatePane() {
<template>
<RunData
:class="$style.runData"
:node="currentNode"
:nodes="isMappingMode ? rootNodesParents : parentNodes"
:workflow="workflow"
@@ -521,6 +522,10 @@ function activatePane() {
</template>
<style lang="scss" module>
.runData {
background-color: var(--color-run-data-background);
}
.mappedNode {
padding: 0 var(--spacing-s) var(--spacing-s);
}

View File

@@ -323,6 +323,7 @@ const activatePane = () => {
<template>
<RunData
ref="runDataRef"
:class="$style.runData"
:node="node"
:workflow="workflow"
:run-index="runIndex"
@@ -440,6 +441,9 @@ const activatePane = () => {
:global([data-output-type='logs'] [class*='displayModes']) {
display: none;
}
.runData {
background-color: var(--color-run-data-background);
}
.outputTypeSelect {
margin-bottom: var(--spacing-4xs);
width: fit-content;

View File

@@ -78,7 +78,6 @@ import {
N8nInfoTip,
N8nLink,
N8nOption,
N8nRadioButtons,
N8nSelect,
N8nSpinner,
N8nTabs,
@@ -92,6 +91,8 @@ import { useSchemaPreviewStore } from '@/stores/schemaPreview.store';
import { asyncComputed } from '@vueuse/core';
import { usePostHog } from '@/stores/posthog.store';
import ViewSubExecution from './ViewSubExecution.vue';
import RunDataItemCount from '@/components/RunDataItemCount.vue';
import RunDataDisplayModeSelect from '@/components/RunDataDisplayModeSelect.vue';
const LazyRunDataTable = defineAsyncComponent(
async () => await import('@/components/RunDataTable.vue'),
@@ -119,7 +120,7 @@ type Props = {
runIndex: number;
tooMuchDataTitle: string;
executingMessage: string;
pushRef: string;
pushRef?: string;
paneType: NodePanelType;
noDataInBranchMessage: string;
node?: INodeUi | null;
@@ -135,6 +136,11 @@ type Props = {
isPaneActive?: boolean;
hidePagination?: boolean;
calloutMessage?: string;
disableRunIndexSelection?: boolean;
disableEdit?: boolean;
disablePin?: boolean;
compact?: boolean;
tableHeaderBgColor?: 'base' | 'light';
};
const props = withDefaults(defineProps<Props>(), {
@@ -149,7 +155,26 @@ const props = withDefaults(defineProps<Props>(), {
isExecuting: false,
hidePagination: false,
calloutMessage: undefined,
disableRunIndexSelection: false,
disableEdit: false,
disablePin: false,
compact: false,
tableHeaderBgColor: 'base',
});
defineSlots<{
content: {};
'callout-message': {};
header: {};
'input-select': {};
'before-data': {};
'run-info': {};
'node-waiting': {};
'node-not-run': {};
'no-output-data': {};
'recovered-artificial-output-data': {};
}>();
const emit = defineEmits<{
search: [search: string];
runChange: [runIndex: number];
@@ -249,27 +274,6 @@ const canPinData = computed(
pinnedData.isValidNodeType.value &&
!(binaryData.value && binaryData.value.length > 0),
);
const displayModes = computed(() => {
const defaults: Array<{ label: string; value: IRunDataDisplayMode }> = [
{ label: i18n.baseText('runData.schema'), value: 'schema' },
{ label: i18n.baseText('runData.table'), value: 'table' },
{ label: i18n.baseText('runData.json'), value: 'json' },
];
if (binaryData.value.length) {
defaults.push({ label: i18n.baseText('runData.binary'), value: 'binary' });
}
if (
isPaneTypeOutput.value &&
activeNode.value?.type === HTML_NODE_TYPE &&
activeNode.value.parameters.operation === 'generateHtmlTemplate'
) {
defaults.unshift({ label: 'HTML', value: 'html' });
}
return defaults;
});
const hasNodeRun = computed(() =>
Boolean(
@@ -512,6 +516,9 @@ const parentNodePinnedData = computed(() => {
});
const showPinButton = computed(() => {
if (props.disablePin) {
return false;
}
if (!rawInputData.value.length && !pinnedData.hasData.value) {
return false;
}
@@ -593,6 +600,14 @@ const hasPreviewSchema = asyncComputed(async () => {
return false;
}, false);
const itemsCountProps = computed<InstanceType<typeof RunDataItemCount>['$props']>(() => ({
search: search.value,
dataCount: dataCount.value,
unfilteredDataCount: unfilteredDataCount.value,
subExecutionsCount: activeTaskMetadata.value?.subExecutionsCount,
muted: props.compact || (props.paneType === 'input' && maxRunIndex.value === 0),
}));
watch(node, (newNode, prevNode) => {
if (newNode?.id === prevNode?.id) return;
init();
@@ -1303,7 +1318,10 @@ defineExpose({ enterEditMode });
</script>
<template>
<div :class="['run-data', $style.container]" @mouseover="activatePane">
<div
:class="['run-data', $style.container, props.compact ? $style.compact : '']"
@mouseover="activatePane"
>
<N8nCallout
v-if="
!isPaneTypeInput &&
@@ -1371,19 +1389,27 @@ defineExpose({ enterEditMode });
/>
</Suspense>
<N8nRadioButtons
<RunDataDisplayModeSelect
v-show="
hasPreviewSchema ||
(hasNodeRun && (inputData.length || binaryData.length || search) && !editMode.enabled)
"
:model-value="displayMode"
:options="displayModes"
data-test-id="ndv-run-data-display-mode"
@update:model-value="onDisplayModeChange"
:class="$style.displayModeSelect"
:compact="props.compact"
:value="displayMode"
:has-binary-data="binaryData.length > 0"
:pane-type="paneType"
:node-generates-html="
activeNode?.type === HTML_NODE_TYPE &&
activeNode.parameters.operation === 'generateHtmlTemplate'
"
@change="onDisplayModeChange"
/>
<RunDataItemCount v-if="props.compact" v-bind="itemsCountProps" />
<N8nIconButton
v-if="canPinData && !isReadOnlyRoute && !readOnlyEnv"
v-if="!props.disableEdit && canPinData && !isReadOnlyRoute && !readOnlyEnv"
v-show="!editMode.enabled"
:title="i18n.baseText('runData.editOutput')"
:circle="false"
@@ -1407,7 +1433,7 @@ defineExpose({ enterEditMode });
@toggle-pin-data="onTogglePinData({ source: 'pin-icon-click' })"
/>
<div v-show="editMode.enabled" :class="$style.editModeActions">
<div v-if="!props.disableEdit" v-show="editMode.enabled" :class="$style.editModeActions">
<N8nButton
type="tertiary"
:label="i18n.baseText('runData.editor.cancel')"
@@ -1428,7 +1454,7 @@ defineExpose({ enterEditMode });
</div>
<div
v-if="maxRunIndex > 0 && !displaysMultipleNodes"
v-if="maxRunIndex > 0 && !displaysMultipleNodes && !props.disableRunIndexSelection"
v-show="!editMode.enabled"
:class="$style.runSelector"
>
@@ -1479,9 +1505,11 @@ defineExpose({ enterEditMode });
<slot v-if="!displaysMultipleNodes" name="before-data" />
<div v-if="props.calloutMessage" :class="$style.hintCallout">
<div v-if="props.calloutMessage || $slots['callout-message']" :class="$style.hintCallout">
<N8nCallout theme="info" data-test-id="run-data-callout">
<N8nText v-n8n-html="props.calloutMessage" size="small"></N8nText>
<slot name="callout-message">
<N8nText v-n8n-html="props.calloutMessage" size="small"></N8nText>
</slot>
</N8nCallout>
</div>
@@ -1518,6 +1546,7 @@ defineExpose({ enterEditMode });
<div
v-else-if="
!props.compact &&
hasNodeRun &&
!isSearchInSchemaView &&
((dataCount > 0 && maxRunIndex === 0) || search) &&
@@ -1525,37 +1554,12 @@ defineExpose({ enterEditMode });
!displaysMultipleNodes
"
v-show="!editMode.enabled"
:class="[$style.itemsCount, { [$style.muted]: paneType === 'input' && maxRunIndex === 0 }]"
:class="$style.itemsCount"
data-test-id="ndv-items-count"
>
<slot v-if="inputSelectLocation === 'items'" name="input-select"></slot>
<N8nText v-if="search" :class="$style.itemsText">
{{
i18n.baseText('ndv.search.items', {
adjustToNumber: unfilteredDataCount,
interpolate: { matched: dataCount, count: unfilteredDataCount },
})
}}
</N8nText>
<N8nText v-else :class="$style.itemsText">
<span>
{{
i18n.baseText('ndv.output.items', {
adjustToNumber: dataCount,
interpolate: { count: dataCount },
})
}}
</span>
<span v-if="activeTaskMetadata?.subExecutionsCount">
{{
i18n.baseText('ndv.output.andSubExecutions', {
adjustToNumber: activeTaskMetadata.subExecutionsCount,
interpolate: { count: activeTaskMetadata.subExecutionsCount },
})
}}
</span>
</N8nText>
<RunDataItemCount v-bind="itemsCountProps" />
<ViewSubExecution
v-if="activeTaskMetadata && !(paneType === 'input' && hasInputOverwrite)"
:task-metadata="activeTaskMetadata"
@@ -1771,6 +1775,7 @@ defineExpose({ enterEditMode });
:total-runs="maxRunIndex"
:has-default-hover-state="paneType === 'input' && !search"
:search="search"
:header-bg-color="tableHeaderBgColor"
@mounted="emit('tableMounted', $event)"
@active-row-changed="onItemHover"
@display-mode-change="onDisplayModeChange"
@@ -1947,6 +1952,8 @@ defineExpose({ enterEditMode });
</template>
<style lang="scss" module>
@import '@/styles/variables';
.infoIcon {
color: var(--color-foreground-dark);
}
@@ -1970,7 +1977,6 @@ defineExpose({ enterEditMode });
position: relative;
width: 100%;
height: 100%;
background-color: var(--color-run-data-background);
display: flex;
flex-direction: column;
}
@@ -1994,6 +2000,11 @@ defineExpose({ enterEditMode });
min-height: calc(30px + var(--spacing-s));
scrollbar-width: thin;
.compact & {
margin-bottom: var(--spacing-4xs);
padding: var(--spacing-4xs) var(--spacing-s) 0 var(--spacing-s);
}
> *:first-child {
flex-grow: 1;
}
@@ -2055,18 +2066,6 @@ defineExpose({ enterEditMode });
padding-right: var(--spacing-s);
padding-bottom: var(--spacing-s);
flex-flow: wrap;
.itemsText {
flex-shrink: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&.muted .itemsText {
color: var(--color-text-light);
font-size: var(--font-size-2xs);
}
}
.inputSelect {
@@ -2183,6 +2182,7 @@ defineExpose({ enterEditMode });
.displayModes {
display: flex;
justify-content: flex-end;
align-items: center;
flex-grow: 1;
gap: var(--spacing-2xs);
}
@@ -2260,6 +2260,20 @@ defineExpose({ enterEditMode });
.schema {
padding: 0 var(--spacing-s);
}
.search,
.displayModeSelect {
.compact & {
opacity: 0;
visibility: hidden;
transition: opacity 0.3s $ease-out-expo;
}
.compact:hover & {
opacity: 1;
visibility: visible;
}
}
</style>
<style lang="scss" scoped>

View File

@@ -1,10 +1,14 @@
import { createTestNode, createTestTaskData, createTestWorkflowObject } from '@/__tests__/mocks';
import {
createTestLogEntry,
createTestNode,
createTestTaskData,
createTestWorkflowObject,
} from '@/__tests__/mocks';
import {
createAiData,
findLogEntryToAutoSelect,
getTreeNodeData,
createLogEntries,
type TreeNode,
} from '@/components/RunDataAi/utils';
import {
AGENT_LANGCHAIN_NODE_TYPE,
@@ -13,19 +17,6 @@ import {
NodeConnectionTypes,
} from 'n8n-workflow';
function createTestLogEntry(data: Partial<TreeNode>): TreeNode {
return {
node: 'test node',
runIndex: 0,
id: String(Math.random()),
children: [],
consumedTokens: { completionTokens: 0, totalTokens: 0, promptTokens: 0, isEstimate: false },
depth: 0,
startTime: 0,
...data,
};
}
describe(getTreeNodeData, () => {
it('should generate one node per execution', () => {
const workflow = createTestWorkflowObject({

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { type NodePanelType, type IRunDataDisplayMode } from '@/Interface';
import { N8nIcon, N8nRadioButtons } from '@n8n/design-system';
import { computed } from 'vue';
const { compact, value, hasBinaryData, paneType, nodeGeneratesHtml } = defineProps<{
compact: boolean;
value: IRunDataDisplayMode;
hasBinaryData: boolean;
paneType: NodePanelType;
nodeGeneratesHtml: boolean;
}>();
const emit = defineEmits<{ change: [IRunDataDisplayMode] }>();
const i18n = useI18n();
const options = computed(() => {
const defaults: Array<{ label: string; value: IRunDataDisplayMode }> = [
{ label: i18n.baseText('runData.schema'), value: 'schema' },
{ label: i18n.baseText('runData.table'), value: 'table' },
{ label: i18n.baseText('runData.json'), value: 'json' },
];
if (hasBinaryData) {
defaults.push({ label: i18n.baseText('runData.binary'), value: 'binary' });
}
if (paneType === 'output' && nodeGeneratesHtml) {
defaults.unshift({ label: 'HTML', value: 'html' });
}
return defaults;
});
</script>
<template>
<N8nRadioButtons
:model-value="value"
:options="options"
data-test-id="ndv-run-data-display-mode"
@update:model-value="(selected) => emit('change', selected)"
>
<template v-if="compact" #option="option">
<N8nIcon v-if="option.value === 'table'" icon="table" size="small" :class="$style.icon" />
<N8nIcon v-else-if="option.value === 'json'" icon="json" size="small" :class="$style.icon" />
<N8nIcon
v-else-if="option.value === 'binary'"
icon="binary"
size="small"
:class="$style.icon"
/>
<N8nIcon
v-else-if="option.value === 'schema'"
icon="schema"
size="small"
:class="$style.icon"
/>
<N8nIcon v-else-if="option.value === 'html'" icon="html" size="small" :class="$style.icon" />
<span v-else>{{ option.label }}</span>
</template>
</N8nRadioButtons>
</template>
<style lang="scss" module>
.icon {
padding-inline: var(--spacing-2xs);
}
</style>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { N8nText } from '@n8n/design-system';
const {
dataCount,
unfilteredDataCount,
subExecutionsCount = 0,
search,
muted,
} = defineProps<{
dataCount: number;
unfilteredDataCount: number;
subExecutionsCount?: number;
search: string;
muted: boolean;
}>();
const i18n = useI18n();
</script>
<template>
<N8nText v-if="search" :class="[$style.itemsText, muted ? $style.muted : '']">
{{
i18n.baseText('ndv.search.items', {
adjustToNumber: unfilteredDataCount,
interpolate: { matched: dataCount, count: unfilteredDataCount },
})
}}
</N8nText>
<N8nText v-else :class="[$style.itemsText, muted ? $style.muted : '']">
<span>
{{
i18n.baseText('ndv.output.items', {
adjustToNumber: dataCount,
interpolate: { count: dataCount },
})
}}
</span>
<span v-if="subExecutionsCount > 0">
{{
i18n.baseText('ndv.output.andSubExecutions', {
adjustToNumber: subExecutionsCount,
interpolate: { count: subExecutionsCount },
})
}}
</span>
</N8nText>
</template>
<style lang="scss" module>
.itemsText {
flex-shrink: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.muted {
color: var(--color-text-light);
font-size: var(--font-size-2xs);
}
</style>

View File

@@ -23,7 +23,7 @@ const LazyRunDataJsonActions = defineAsyncComponent(
const props = withDefaults(
defineProps<{
editMode: { enabled?: boolean; value?: string };
pushRef: string;
pushRef?: string;
paneType: string;
node: INodeUi;
inputData: INodeExecutionData[];

View File

@@ -25,7 +25,7 @@ const props = withDefaults(
defineProps<{
node: INodeUi;
paneType: string;
pushRef: string;
pushRef?: string;
distanceFromActive: number;
selectedJsonPath: string;
jsonData: IDataObject[];

View File

@@ -32,6 +32,7 @@ type Props = {
mappingEnabled?: boolean;
hasDefaultHoverState?: boolean;
search?: string;
headerBgColor?: 'base' | 'light';
};
const props = withDefaults(defineProps<Props>(), {
@@ -41,6 +42,7 @@ const props = withDefaults(defineProps<Props>(), {
mappingEnabled: false,
hasDefaultHoverState: false,
search: '',
headerBgColor: 'base',
});
const emit = defineEmits<{
activeRowChanged: [row: number | null];
@@ -422,7 +424,12 @@ watch(focusedMappableInput, (curr) => {
</script>
<template>
<div :class="[$style.dataDisplay, { [$style.highlight]: highlight }]">
<div
:class="[
$style.dataDisplay,
{ [$style.highlight]: highlight, [$style.lightHeader]: headerBgColor === 'light' },
]"
>
<table v-if="tableData.columns && tableData.columns.length === 0" :class="$style.table">
<thead>
<tr>
@@ -680,7 +687,8 @@ watch(focusedMappableInput, (curr) => {
border-collapse: separate;
text-align: left;
width: calc(100%);
font-size: var(--font-size-s);
font-size: var(--font-size-2xs);
color: var(--color-text-base);
th {
background-color: var(--color-background-base);
@@ -691,11 +699,19 @@ watch(focusedMappableInput, (curr) => {
top: 0;
color: var(--color-text-dark);
z-index: 1;
.lightHeader & {
background-color: var(--color-background-light);
}
&.tableRightMargin {
background-color: transparent;
}
}
td {
vertical-align: top;
padding: var(--spacing-2xs) var(--spacing-2xs) var(--spacing-2xs) var(--spacing-3xs);
padding: var(--spacing-4xs) var(--spacing-3xs);
border-bottom: var(--border-base);
border-left: var(--border-base);
overflow-wrap: break-word;
@@ -743,7 +759,7 @@ watch(focusedMappableInput, (curr) => {
.header {
display: flex;
align-items: center;
padding: var(--spacing-2xs);
padding: var(--spacing-4xs) var(--spacing-3xs);
span {
white-space: nowrap;
@@ -828,7 +844,6 @@ watch(focusedMappableInput, (curr) => {
.tableRightMargin {
// becomes necessary with large tables
background-color: var(--color-background-base) !important;
width: var(--spacing-s);
border-right: none !important;
border-top: none !important;

View File

@@ -3,7 +3,7 @@
exports[`InputPanel > should render 1`] = `
<div>
<div
class="run-data container"
class="run-data container runData"
data-test-id="ndv-input-panel"
data-v-2e5cd75c=""
>
@@ -38,7 +38,9 @@ exports[`InputPanel > should render 1`] = `
class="button active medium"
data-test-id="radio-button-mapping"
>
Mapping
</div>
</label>
<label
@@ -51,7 +53,9 @@ exports[`InputPanel > should render 1`] = `
class="button medium"
data-test-id="radio-button-debugging"
>
Debugging
</div>
</label>
@@ -65,7 +69,7 @@ exports[`InputPanel > should render 1`] = `
>
<!---->
<div
class="n8n-radio-buttons radioGroup"
class="n8n-radio-buttons radioGroup displayModeSelect"
data-test-id="ndv-run-data-display-mode"
data-v-2e5cd75c=""
role="radiogroup"
@@ -81,7 +85,9 @@ exports[`InputPanel > should render 1`] = `
class="button active medium"
data-test-id="radio-button-schema"
>
Schema
</div>
</label>
<label
@@ -94,7 +100,9 @@ exports[`InputPanel > should render 1`] = `
class="button medium"
data-test-id="radio-button-table"
>
Table
</div>
</label>
<label
@@ -107,13 +115,16 @@ exports[`InputPanel > should render 1`] = `
class="button medium"
data-test-id="radio-button-json"
>
JSON
</div>
</label>
</div>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<div
class="editModeActions"
data-v-2e5cd75c=""