feat(editor): Log view improvements (#16489)

This commit is contained in:
Suguru Inoue
2025-07-01 09:30:17 +02:00
committed by GitHub
parent c11e4bd0a8
commit 4124b96a00
23 changed files with 446 additions and 201 deletions

View File

@@ -194,6 +194,7 @@ describe('Logs', () => {
it('should show logs for a workflow with a node that waits for webhook', () => {
workflow.navigateToNewWorkflowPage();
workflow.pasteWorkflow(Workflow_wait_for_webhook);
workflow.getCanvas().click('topLeft'); // click canvas to deselect nodes
workflow.clickZoomToFit();
logs.openLogsPanel();
@@ -202,7 +203,6 @@ describe('Logs', () => {
workflow.getNodesWithSpinner().should('contain.text', 'Wait');
workflow.getWaitingNodes().should('contain.text', 'Wait');
logs.getLogEntries().should('have.length', 2);
logs.getLogEntries().eq(0).click(); // click selected row to deselect
logs.getLogEntries().eq(1).should('contain.text', 'Wait node');
logs.getLogEntries().eq(1).should('contain.text', 'Waiting');
@@ -224,6 +224,7 @@ describe('Logs', () => {
.getOverviewStatus()
.contains(/Success in [\d\.]+m?s/)
.should('exist');
logs.getLogEntries().eq(1).click(); // click selected row to deselect
logs.getLogEntries().should('have.length', 2);
logs.getLogEntries().eq(1).should('contain.text', 'Wait node');
logs.getLogEntries().eq(1).should('contain.text', 'Success');

View File

@@ -215,7 +215,7 @@
"chat.window.logsFromNode": "from {nodeName} node",
"chat.window.noChatNode": "No Chat Node",
"chat.window.noExecution": "Nothing got executed yet",
"chat.window.chat.placeholder": "Type a message, or press up arrow for previous one",
"chat.window.chat.placeholder": "Type message, or press up for prev one",
"chat.window.chat.placeholderPristine": "Type a message",
"chat.window.chat.sendButtonText": "Send",
"chat.window.chat.provideMessage": "Please provide a message",

View File

@@ -1601,10 +1601,12 @@ defineExpose({ enterEditMode });
<div ref="dataContainerRef" :class="$style.dataContainer" data-test-id="ndv-data-container">
<div
v-if="isExecuting && !isWaitNodeWaiting"
:class="$style.center"
:class="[$style.center, $style.executingMessage]"
data-test-id="ndv-executing"
>
<div :class="$style.spinner"><N8nSpinner type="ring" /></div>
<div v-if="!props.compact" :class="$style.spinner">
<N8nSpinner type="ring" />
</div>
<N8nText>{{ executingMessage }}</N8nText>
</div>
@@ -2302,6 +2304,12 @@ defineExpose({ enterEditMode });
}
}
.executingMessage {
.compact & {
color: var(--color-text-light);
}
}
@container (max-width: 240px) {
/* Hide title when the panel is too narrow */
.compact:hover .title {

View File

@@ -175,6 +175,12 @@ function onCopyToClipboard(object: IDataObject | IDataObject[]) {
font-size: var(--font-size-xs);
}
}
p {
.compact & {
line-height: var(--font-line-height-xloose);
}
}
}
}
@@ -202,7 +208,7 @@ function onCopyToClipboard(object: IDataObject | IDataObject[]) {
.compact & {
padding-top: 0;
padding-inline: var(--spacing-2xs);
font-size: var(--font-size-xs);
font-size: var(--font-size-2xs);
}
}
</style>

View File

@@ -1,7 +1,7 @@
import { useActiveElement, useEventListener } from '@vueuse/core';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import type { MaybeRef, Ref } from 'vue';
import { computed, inject, unref } from 'vue';
import { computed, inject, ref, unref } from 'vue';
import { PiPWindowSymbol } from '@/constants';
type KeyboardEventHandler =
@@ -30,7 +30,7 @@ export const useKeybindings = (
disabled: MaybeRef<boolean>;
},
) => {
const pipWindow = inject(PiPWindowSymbol);
const pipWindow = inject(PiPWindowSymbol, ref<Window | undefined>());
const activeElement = useActiveElement({ window: pipWindow?.value });
const { isCtrlKeyPressed } = useDeviceSupport();

View File

@@ -482,7 +482,7 @@ export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
export const LOCAL_STORAGE_LOGS_PANEL_OPEN = 'N8N_LOGS_PANEL_OPEN';
export const LOCAL_STORAGE_LOGS_SYNC_SELECTION = 'N8N_LOGS_SYNC_SELECTION';
export const LOCAL_STORAGE_LOGS_SYNC_SELECTION = 'N8N_LOGS_SYNC_SELECTION_ENABLED';
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL = 'N8N_LOGS_DETAILS_PANEL';
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL_SUB_NODE = 'N8N_LOGS_DETAILS_PANEL_SUB_NODE';
export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';

View File

@@ -20,7 +20,7 @@ export function createTestLogTreeCreationContext(
workflows: {},
subWorkflowData: {},
executionId: 'test-execution-id',
depth: 0,
ancestorRunIndexes: [],
data: {
resultData: {
runData,

View File

@@ -17,7 +17,6 @@ export function createTestLogEntry(data: Partial<LogEntry> = {}): LogEntry {
id: uuid(),
children: [],
consumedTokens: { completionTokens: 0, totalTokens: 0, promptTokens: 0, isEstimate: false },
depth: 0,
workflow: createTestWorkflowObject(),
executionId,
execution: createTestWorkflowExecutionResponse({ id: executionId }).data!,

View File

@@ -255,7 +255,7 @@ async function copySessionId() {
.chat {
--chat--spacing: var(--spacing-xs);
--chat--message--padding: var(--spacing-2xs);
--chat--message--font-size: var(--font-size-xs);
--chat--message--font-size: var(--font-size-2xs);
--chat--input--font-size: var(--font-size-s);
--chat--input--placeholder--font-size: var(--font-size-xs);
--chat--message--bot--background: transparent;
@@ -269,7 +269,10 @@ async function copySessionId() {
--chat--color-typing: var(--color-text-light);
--chat--textarea--max-height: calc(var(--panel-height) * 0.3);
--chat--message--pre--background: var(--color-foreground-light);
--chat--textarea--height: 2.5rem;
--chat--textarea--height: calc(
var(--chat--input--padding) * 2 + var(--chat--input--font-size) *
var(--chat--input--line-height)
);
height: 100%;
display: flex;
flex-direction: column;
@@ -381,4 +384,12 @@ async function copySessionId() {
--input-border-color: #4538a3;
}
}
.messagesHistory {
height: var(--chat--textarea--height);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -93,7 +93,8 @@ describe('LogsOverviewPanel', () => {
const row1 = within(tree.queryAllByRole('treeitem')[0]);
expect(row1.queryByText('AI Agent')).toBeInTheDocument();
expect(row1.queryByText('Success in 1.778s')).toBeInTheDocument();
expect(row1.queryByText('Success')).toBeInTheDocument();
expect(row1.queryByText('in 1.778s')).toBeInTheDocument();
expect(row1.queryByText('Started 00:00:00.002, 26 Mar')).toBeInTheDocument();
const row2 = within(tree.queryAllByRole('treeitem')[1]);

View File

@@ -8,14 +8,11 @@ import LogsOverviewRow from '@/features/logs/components/LogsOverviewRow.vue';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { useRouter } from 'vue-router';
import LogsViewExecutionSummary from '@/features/logs/components/LogsViewExecutionSummary.vue';
import {
getSubtreeTotalConsumedTokens,
getTotalConsumedTokens,
hasSubExecution,
} from '@/features/logs/logs.utils';
import { getSubtreeTotalConsumedTokens, getTotalConsumedTokens } from '@/features/logs/logs.utils';
import { useVirtualList } from '@vueuse/core';
import { type IExecutionResponse } from '@/Interface';
import type { LatestNodeInfo, LogEntry } from '@/features/logs/logs.types';
import { getScrollbarWidth } from '@/utils/htmlUtils';
const {
isOpen,
@@ -43,7 +40,6 @@ const emit = defineEmits<{
clearExecutionData: [];
openNdv: [LogEntry];
toggleExpanded: [LogEntry];
loadSubExecution: [LogEntry];
}>();
defineSlots<{ actions: {} }>();
@@ -57,6 +53,7 @@ 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 },
]);
const hasStaticScrollbar = getScrollbarWidth() > 0;
const consumedTokens = computed(() =>
getTotalConsumedTokens(
...entries.map((entry) =>
@@ -73,6 +70,12 @@ const shouldShowTokenCountColumn = computed(
consumedTokens.value.totalTokens > 0 ||
entries.some((entry) => getSubtreeTotalConsumedTokens(entry, true).totalTokens > 0),
);
const isExpanded = computed(() =>
flatLogEntries.reduce<Record<string, boolean>>((acc, entry, index, arr) => {
acc[entry.id] = arr[index + 1]?.parent?.id === entry.id;
return acc;
}, {}),
);
const virtualList = useVirtualList(
toRef(() => flatLogEntries),
{ itemHeight: 32 },
@@ -82,14 +85,6 @@ function handleSwitchView(value: 'overview' | 'details') {
emit('select', value === 'overview' ? undefined : flatLogEntries[0]);
}
function handleToggleExpanded(treeNode: LogEntry) {
if (hasSubExecution(treeNode) && treeNode.children.length === 0) {
emit('loadSubExecution', treeNode);
return;
}
emit('toggleExpanded', treeNode);
}
async function handleTriggerPartialExecution(treeNode: LogEntry) {
const latestName = latestNodeInfo[treeNode.node.id]?.name ?? treeNode.node.name;
@@ -98,25 +93,46 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
}
}
// While executing, scroll to the bottom if there's no selection
watch(
[() => execution?.status === 'running', () => flatLogEntries.length],
async ([isRunning, flatEntryCount], [wasRunning]) => {
await nextTick(() => {
if (selected === undefined && (isRunning || wasRunning)) {
virtualList.scrollTo(flatEntryCount - 1);
}
});
},
{ immediate: true },
);
// Scroll selected row into view
watch(
() => selected,
async (selection) => {
if (selection && virtualList.list.value.every((e) => e.data.id !== selection.id)) {
const index = flatLogEntries.findIndex((e) => e.id === selection?.id);
() => selected?.id,
async (selectedId) => {
await nextTick(() => {
if (selectedId === undefined) {
return;
}
const index = virtualList.list.value.some((e) => e.data.id === selectedId)
? -1
: flatLogEntries.findIndex((e) => e.id === selectedId);
if (index >= 0) {
// Wait for the node to be added to the list, and then scroll
await nextTick(() => virtualList.scrollTo(index));
virtualList.scrollTo(index);
}
}
});
},
{ immediate: true },
);
</script>
<template>
<div :class="$style.container" data-test-id="logs-overview">
<div
:class="[$style.container, hasStaticScrollbar ? $style.staticScrollBar : '']"
data-test-id="logs-overview"
>
<LogsPanelHeader
:title="locale.baseText('logs.overview.header.title')"
data-test-id="logs-overview-header"
@@ -180,9 +196,9 @@ watch(
:is-compact="isCompact"
:should-show-token-count-column="shouldShowTokenCountColumn"
:latest-info="latestNodeInfo[data.node.id]"
:expanded="virtualList.list.value[index + 1]?.data.parent?.id === data.id"
:expanded="isExpanded[data.id]"
:can-open-ndv="data.executionId === execution?.id"
@toggle-expanded="handleToggleExpanded(data)"
@toggle-expanded="emit('toggleExpanded', data)"
@open-ndv="emit('openNdv', data)"
@trigger-partial-execution="handleTriggerPartialExecution(data)"
@toggle-selected="emit('select', selected?.id === data.id ? undefined : data)"
@@ -228,6 +244,7 @@ watch(
flex-direction: column;
align-items: stretch;
justify-content: stretch;
padding-right: var(--spacing-5xs);
&.empty {
align-items: center;
@@ -247,7 +264,30 @@ watch(
.tree {
padding: 0 var(--spacing-2xs) var(--spacing-2xs) var(--spacing-2xs);
scroll-padding-block: var(--spacing-3xs);
.container:not(.staticScrollBar) & {
scroll-padding-block: var(--spacing-3xs);
@supports not (selector(::-webkit-scrollbar)) {
scrollbar-width: thin;
}
@supports selector(::-webkit-scrollbar) {
padding-right: var(--spacing-5xs);
scrollbar-gutter: stable;
&::-webkit-scrollbar {
width: var(--spacing-4xs);
}
&::-webkit-scrollbar-thumb {
border-radius: var(--spacing-4xs);
background: var(--color-foreground-dark);
}
}
}
/* For programmatically triggered scroll in useVirtualList to animate, make it scroll smoothly */
scroll-behavior: smooth;
& :global(.el-icon) {
display: none;

View File

@@ -4,7 +4,7 @@ import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import LogsViewConsumedTokenCountText from '@/features/logs/components/LogsViewConsumedTokenCountText.vue';
import upperFirst from 'lodash/upperFirst';
import { useI18n } from '@n8n/i18n';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
import { I18nT } from 'vue-i18n';
import { toDayMonth, toTime } from '@/utils/formatters/dateFormatter';
import LogsViewNodeName from '@/features/logs/components/LogsViewNodeName.vue';
@@ -35,12 +35,13 @@ const locale = useI18n();
const now = useTimestamp({ interval: 1000 });
const nodeTypeStore = useNodeTypesStore();
const type = computed(() => nodeTypeStore.getNodeType(props.data.node.type));
const isSettled = computed(
() =>
props.data.runData?.executionStatus &&
!['running', 'waiting'].includes(props.data.runData.executionStatus),
);
const isRunning = computed(() => props.data.runData?.executionStatus === 'running');
const isWaiting = computed(() => props.data.runData?.executionStatus === 'waiting');
const isSettled = computed(() => !isRunning.value && !isWaiting.value);
const isError = computed(() => !!props.data.runData?.error);
const statusTextKeyPath = computed<BaseTextKey>(() =>
isSettled.value ? 'logs.overview.body.summaryText.in' : 'logs.overview.body.summaryText.for',
);
const startedAtText = computed(() => {
if (props.data.runData === undefined) {
return '—';
@@ -72,23 +73,21 @@ const subtreeConsumedTokens = computed(() =>
const hasChildren = computed(() => props.data.children.length > 0 || hasSubExecution(props.data));
function isLastChild(level: number) {
let parent = props.data.parent;
let data: LogEntry | undefined = props.data;
const indents = computed(() => {
const ret: Array<{ straight: boolean; curved: boolean }> = [];
for (let i = 0; i < props.data.depth - level; i++) {
data = parent;
parent = parent?.parent;
let data: LogEntry = props.data;
while (data.parent !== undefined) {
const siblings = data.parent?.children ?? [];
const lastSibling = siblings[siblings.length - 1];
ret.unshift({ straight: lastSibling?.id !== data.id, curved: data === props.data });
data = data.parent;
}
const siblings = parent?.children ?? [];
const lastSibling = siblings[siblings.length - 1];
return (
(data === undefined && lastSibling === undefined) ||
(data?.node === lastSibling?.node && data?.runIndex === lastSibling?.runIndex)
);
}
return ret;
});
// Focus when selected: For scrolling into view and for keyboard navigation to work
watch(
@@ -119,16 +118,16 @@ watch(
}"
@click.stop="emit('toggleSelected')"
>
<template v-for="level in props.data.depth" :key="level">
<div
:class="{
[$style.indent]: true,
[$style.connectorCurved]: level === props.data.depth,
[$style.connectorStraight]: !isLastChild(level),
}"
/>
</template>
<div :class="$style.background" :style="{ '--indent-depth': props.data.depth }" />
<div
v-for="(indent, level) in indents"
:key="level"
:class="{
[$style.indent]: true,
[$style.connectorCurved]: indent.curved,
[$style.connectorStraight]: indent.straight,
}"
/>
<div :class="$style.background" :style="{ '--indent-depth': indents.length }" />
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
<LogsViewNodeName
:class="$style.name"
@@ -138,23 +137,17 @@ watch(
:is-deleted="latestInfo?.deleted ?? false"
/>
<N8nText v-if="!isCompact" tag="div" color="text-light" size="small" :class="$style.timeTook">
<I18nT v-if="isSettled" keypath="logs.overview.body.summaryText.in">
<I18nT v-if="timeText !== undefined" :keypath="statusTextKeyPath">
<template #status>
<N8nText v-if="isError" color="danger" :bold="true" size="small">
<N8nIcon icon="triangle-alert" :class="$style.errorIcon" />
<N8nText :color="isError ? 'danger' : undefined" :bold="isError" size="small">
<AnimatedSpinner v-if="isRunning" :class="$style.statusTextIcon" />
<N8nIcon v-else-if="isWaiting" icon="status-waiting" :class="$style.statusTextIcon" />
<N8nIcon v-else-if="isError" icon="triangle-alert" :class="$style.statusTextIcon" />
{{ statusText }}
</N8nText>
<template v-else>{{ statusText }}</template>
</template>
<template #time>{{ timeText }}</template>
</I18nT>
<template v-else-if="timeText !== undefined">
{{
locale.baseText('logs.overview.body.summaryText.for', {
interpolate: { status: statusText, time: timeText },
})
}}
</template>
<template v-else>—</template>
</N8nText>
<N8nText
@@ -193,10 +186,8 @@ watch(
size="small"
icon="square-pen"
icon-size="medium"
style="color: var(--color-text-base)"
:style="{
visibility: props.canOpenNdv ? '' : 'hidden',
color: 'var(--color-text-base)',
}"
:disabled="props.latestInfo?.deleted"
:class="$style.openNdvButton"
@@ -211,12 +202,15 @@ watch(
type="secondary"
size="small"
icon="play"
style="color: var(--color-text-base)"
:aria-label="locale.baseText('logs.overview.body.run')"
:class="[$style.partialExecutionButton, props.data.depth > 0 ? $style.unavailable : '']"
:class="[$style.partialExecutionButton, indents.length > 0 ? $style.unavailable : '']"
:disabled="props.latestInfo?.deleted || props.latestInfo?.disabled"
@click.stop="emit('triggerPartialExecution')"
/>
<template v-if="isCompact && !hasChildren">
<AnimatedSpinner v-if="isRunning" :class="$style.statusIcon" />
<N8nIcon v-else-if="isWaiting" icon="status-waiting" :class="$style.statusIcon" />
</template>
<N8nButton
v-if="!isCompact || hasChildren"
type="secondary"
@@ -226,7 +220,6 @@ watch(
:square="true"
:style="{
visibility: hasChildren ? '' : 'hidden',
color: 'var(--color-text-base)', // give higher specificity than the style from the component itself
}"
:class="$style.toggleButton"
:aria-label="locale.baseText('logs.overview.body.toggleRow')"
@@ -244,6 +237,7 @@ watch(
position: relative;
z-index: 1;
padding-inline-end: var(--spacing-5xs);
cursor: pointer;
& > * {
overflow: hidden;
@@ -322,8 +316,8 @@ watch(
flex-shrink: 0;
width: 20%;
.errorIcon {
margin-right: var(--spacing-4xs);
.statusTextIcon {
margin-right: var(--spacing-5xs);
vertical-align: text-bottom;
}
}
@@ -395,4 +389,17 @@ watch(
.toggleButton {
display: inline-flex;
}
.statusIcon {
color: var(--color-text-light);
flex-grow: 0;
flex-shrink: 0;
width: 26px;
height: 26px;
padding: var(--spacing-3xs);
&.placeholder {
color: transparent;
}
}
</style>

View File

@@ -322,7 +322,8 @@ describe('LogsPanel', () => {
},
});
expect(await lastTreeItem.findByText('AI Agent')).toBeInTheDocument();
expect(lastTreeItem.getByText('Success in 33ms')).toBeInTheDocument();
expect(await lastTreeItem.findByText('Success')).toBeInTheDocument();
expect(lastTreeItem.getByText('in 33ms')).toBeInTheDocument();
workflowsStore.setWorkflowExecutionData({
...workflowsStore.workflowExecutionData!,

View File

@@ -58,7 +58,7 @@ const {
const { entries, execution, hasChat, latestNodeNameById, resetExecutionData, loadSubExecution } =
useLogsExecutionData();
const { flatLogEntries, toggleExpanded } = useLogsTreeExpand(entries);
const { flatLogEntries, toggleExpanded } = useLogsTreeExpand(entries, loadSubExecution);
const { selected, select, selectNext, selectPrev } = useLogsSelection(
execution,
entries,
@@ -171,7 +171,6 @@ function handleOpenNdv(treeNode: LogEntry) {
@resizeend="handleResizeOverviewPanelEnd"
>
<LogsOverviewPanel
:key="execution?.id ?? ''"
:class="$style.logsOverview"
:is-open="isOpen"
:is-read-only="isReadOnly"
@@ -186,7 +185,6 @@ function handleOpenNdv(treeNode: LogEntry) {
@clear-execution-data="resetExecutionData"
@toggle-expanded="toggleExpanded"
@open-ndv="handleOpenNdv"
@load-sub-execution="loadSubExecution"
>
<template #actions>
<LogsPanelActions

View File

@@ -2,10 +2,10 @@
import { type KeyMap, useKeybindings } from '@/composables/useKeybindings';
import { PiPWindowSymbol } from '@/constants';
import { useActiveElement } from '@vueuse/core';
import { computed, toRef, inject } from 'vue';
import { ref, computed, toRef, inject } from 'vue';
const { container, keyMap } = defineProps<{ keyMap: KeyMap; container: HTMLElement | null }>();
const pipWindow = inject(PiPWindowSymbol);
const pipWindow = inject(PiPWindowSymbol, ref<Window | undefined>());
const activeElement = useActiveElement({ window: pipWindow?.value });
const isBlurred = computed(() => {
@@ -25,3 +25,7 @@ useKeybindings(
{ disabled: isBlurred },
);
</script>
<template>
<div />
</template>

View File

@@ -9,6 +9,7 @@ import { N8nLink, N8nText } from '@n8n/design-system';
import { computed, inject, ref } from 'vue';
import { I18nT } from 'vue-i18n';
import { PiPWindowSymbol } from '@/constants';
import { isSubNodeLog } from '../logs.utils';
const { title, logEntry, paneType } = defineProps<{
title: string;
@@ -28,7 +29,7 @@ const isMultipleInput = computed(
const runDataProps = computed<
Pick<InstanceType<typeof RunData>['$props'], 'node' | 'runIndex' | 'overrideOutputs'> | undefined
>(() => {
if (logEntry.depth > 0 || paneType === 'output') {
if (isSubNodeLog(logEntry) || paneType === 'output') {
return { node: logEntry.node, runIndex: logEntry.runIndex };
}
@@ -81,7 +82,7 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {
:disable-edit="true"
:disable-hover-highlight="true"
:display-mode="displayMode"
:disable-ai-content="logEntry.depth === 0"
:disable-ai-content="!isSubNodeLog(logEntry)"
:is-executing="isExecuting"
table-header-bg-color="light"
@display-mode-change="handleChangeDisplayMode"

View File

@@ -12,8 +12,8 @@ import type { IExecutionResponse } from '@/Interface';
import { useCanvasStore } from '@/stores/canvas.store';
import { useLogsStore } from '@/stores/logs.store';
import { useUIStore } from '@/stores/ui.store';
import { watch } from 'vue';
import { computed, ref, type ComputedRef } from 'vue';
import { shallowRef, watch } from 'vue';
import { computed, type ComputedRef } from 'vue';
export function useLogsSelection(
execution: ComputedRef<IExecutionResponse | undefined>,
@@ -22,8 +22,12 @@ export function useLogsSelection(
toggleExpand: (entry: LogEntry, expand?: boolean) => void,
) {
const telemetry = useTelemetry();
const manualLogEntrySelection = ref<LogEntrySelection>({ type: 'initial' });
const selected = computed(() => findSelectedLogEntry(manualLogEntrySelection.value, tree.value));
const manualLogEntrySelection = shallowRef<LogEntrySelection>({ type: 'initial' });
const nodeNameToSelect = shallowRef<string>();
const isExecutionStopped = computed(() => execution.value?.stoppedAt !== undefined);
const selected = computed(() =>
findSelectedLogEntry(manualLogEntrySelection.value, tree.value, !isExecutionStopped.value),
);
const logsStore = useLogsStore();
const uiStore = useUIStore();
const canvasStore = useCanvasStore();
@@ -38,7 +42,7 @@ export function useLogsSelection(
function select(value: LogEntry | undefined) {
manualLogEntrySelection.value =
value === undefined ? { type: 'none' } : { type: 'selected', id: value.id };
value === undefined ? { type: 'none' } : { type: 'selected', entry: value };
if (value) {
syncSelectionToCanvasIfEnabled(value);
@@ -55,21 +59,31 @@ export function useLogsSelection(
function selectPrev() {
const entries = flatLogEntries.value;
if (entries.length === 0) {
return;
}
const prevEntry = selected.value
? (getEntryAtRelativeIndex(entries, selected.value.id, -1) ?? entries[0])
: entries[entries.length - 1];
manualLogEntrySelection.value = { type: 'selected', id: prevEntry.id };
manualLogEntrySelection.value = { type: 'selected', entry: prevEntry };
syncSelectionToCanvasIfEnabled(prevEntry);
}
function selectNext() {
const entries = flatLogEntries.value;
if (entries.length === 0) {
return;
}
const nextEntry = selected.value
? (getEntryAtRelativeIndex(entries, selected.value.id, 1) ?? entries[entries.length - 1])
: entries[0];
manualLogEntrySelection.value = { type: 'selected', id: nextEntry.id };
manualLogEntrySelection.value = { type: 'selected', entry: nextEntry };
syncSelectionToCanvasIfEnabled(nextEntry);
}
@@ -93,16 +107,19 @@ export function useLogsSelection(
canvasStore.hasRangeSelection ||
selected.value?.node.name === selectedOnCanvas
) {
nodeNameToSelect.value = undefined;
return;
}
const entry = findLogEntryRec((e) => e.node.name === selectedOnCanvas, tree.value);
if (!entry) {
nodeNameToSelect.value = selectedOnCanvas;
return;
}
manualLogEntrySelection.value = { type: 'selected', id: entry.id };
nodeNameToSelect.value = undefined;
manualLogEntrySelection.value = { type: 'selected', entry };
let parent = entry.parent;
@@ -114,5 +131,22 @@ export function useLogsSelection(
{ immediate: true },
);
watch(
tree,
(t) => {
if (nodeNameToSelect.value === undefined) {
return;
}
const entry = findLogEntryRec((e) => e.node.name === nodeNameToSelect.value, t);
if (entry) {
nodeNameToSelect.value = undefined;
manualLogEntrySelection.value = { type: 'selected', entry };
}
},
{ immediate: true },
);
return { selected, select, selectPrev, selectNext };
}

View File

@@ -1,14 +1,24 @@
import { flattenLogEntries } from '@/features/logs/logs.utils';
import { computed, ref, type ComputedRef } from 'vue';
import { flattenLogEntries, hasSubExecution } from '@/features/logs/logs.utils';
import { computed, shallowRef, type ComputedRef } from 'vue';
import type { LogEntry } from '../logs.types';
export function useLogsTreeExpand(entries: ComputedRef<LogEntry[]>) {
const collapsedEntries = ref<Record<string, boolean>>({});
export function useLogsTreeExpand(
entries: ComputedRef<LogEntry[]>,
loadSubExecution: (logEntry: LogEntry) => Promise<void>,
) {
const collapsedEntries = shallowRef<Record<string, boolean>>({});
const flatLogEntries = computed(() => flattenLogEntries(entries.value, collapsedEntries.value));
function toggleExpanded(treeNode: LogEntry, expand?: boolean) {
collapsedEntries.value[treeNode.id] =
expand === undefined ? !collapsedEntries.value[treeNode.id] : !expand;
if (hasSubExecution(treeNode) && treeNode.children.length === 0) {
void loadSubExecution(treeNode);
return;
}
collapsedEntries.value = {
...collapsedEntries.value,
[treeNode.id]: expand === undefined ? !collapsedEntries.value[treeNode.id] : !expand,
};
}
return {

View File

@@ -7,7 +7,6 @@ export interface LogEntry {
node: INodeUi;
id: string;
children: LogEntry[];
depth: number;
runIndex: number;
runData: ITaskData | undefined;
consumedTokens: LlmTokenUsageData;
@@ -18,7 +17,7 @@ export interface LogEntry {
export interface LogTreeCreationContext {
parent: LogEntry | undefined;
depth: number;
ancestorRunIndexes: number[];
workflow: Workflow;
executionId: string;
data: IRunExecutionData;
@@ -34,7 +33,7 @@ export interface LatestNodeInfo {
export type LogEntrySelection =
| { type: 'initial' }
| { type: 'selected'; id: string }
| { type: 'selected'; entry: LogEntry }
| { type: 'none' };
export type LogsPanelState = (typeof LOGS_PANEL_STATE)[keyof typeof LOGS_PANEL_STATE];

View File

@@ -79,15 +79,13 @@ describe(getTreeNodeData, () => {
expect(logTree.length).toBe(1);
expect(logTree[0].id).toBe('test-wf-id:A:test-execution-id:0');
expect(logTree[0].depth).toBe(0);
expect(logTree[0].id).toBe('test-wf-id:test-node-id-a:0');
expect(logTree[0].runIndex).toBe(0);
expect(logTree[0].parent).toBe(undefined);
expect(logTree[0].runData?.startTime).toBe(1740528000000);
expect(logTree[0].children.length).toBe(2);
expect(logTree[0].children[0].id).toBe('test-wf-id:B:test-execution-id:0');
expect(logTree[0].children[0].depth).toBe(1);
expect(logTree[0].children[0].id).toBe('test-wf-id:test-node-id-b:0:0');
expect(logTree[0].children[0].runIndex).toBe(0);
expect(logTree[0].children[0].parent?.node.name).toBe('A');
expect(logTree[0].children[0].runData?.startTime).toBe(1740528000001);
@@ -95,23 +93,20 @@ describe(getTreeNodeData, () => {
expect(logTree[0].children[0].consumedTokens.completionTokens).toBe(1);
expect(logTree[0].children[0].children.length).toBe(1);
expect(logTree[0].children[0].children[0].id).toBe('test-wf-id:C:test-execution-id:0');
expect(logTree[0].children[0].children[0].depth).toBe(2);
expect(logTree[0].children[0].children[0].id).toBe('test-wf-id:test-node-id-c:0:0:0');
expect(logTree[0].children[0].children[0].runIndex).toBe(0);
expect(logTree[0].children[0].children[0].parent?.node.name).toBe('B');
expect(logTree[0].children[0].children[0].consumedTokens.isEstimate).toBe(true);
expect(logTree[0].children[0].children[0].consumedTokens.completionTokens).toBe(7);
expect(logTree[0].children[1].id).toBe('test-wf-id:B:test-execution-id:1');
expect(logTree[0].children[1].depth).toBe(1);
expect(logTree[0].children[1].id).toBe('test-wf-id:test-node-id-b:0:1');
expect(logTree[0].children[1].runIndex).toBe(1);
expect(logTree[0].children[1].parent?.node.name).toBe('A');
expect(logTree[0].children[1].consumedTokens.isEstimate).toBe(false);
expect(logTree[0].children[1].consumedTokens.completionTokens).toBe(4);
expect(logTree[0].children[1].children.length).toBe(1);
expect(logTree[0].children[1].children[0].id).toBe('test-wf-id:C:test-execution-id:1');
expect(logTree[0].children[1].children[0].depth).toBe(2);
expect(logTree[0].children[1].children[0].id).toBe('test-wf-id:test-node-id-c:0:1:1');
expect(logTree[0].children[1].children[0].runIndex).toBe(1);
expect(logTree[0].children[1].children[0].parent?.node.name).toBe('B');
expect(logTree[0].children[1].children[0].consumedTokens.completionTokens).toBe(0);
@@ -554,14 +549,15 @@ describe(getTreeNodeData, () => {
});
describe(findSelectedLogEntry, () => {
function find(state: LogEntrySelection, response: IExecutionResponse) {
function find(state: LogEntrySelection, response: IExecutionResponse, isExecuting: boolean) {
return findSelectedLogEntry(
state,
createLogTree(createTestWorkflowObject(response.workflowData), response),
isExecuting,
);
}
describe('when log is not manually selected', () => {
describe('when execution is finished and log is not manually selected', () => {
it('should return undefined if no execution data exists', () => {
const response = createTestWorkflowExecutionResponse({
workflowData: createTestWorkflow({
@@ -574,7 +570,7 @@ describe(findSelectedLogEntry, () => {
data: { resultData: { runData: {} } },
});
expect(find({ type: 'initial' }, response)).toBe(undefined);
expect(find({ type: 'initial' }, response, false)).toBe(undefined);
});
it('should return first log entry with error', () => {
@@ -589,19 +585,27 @@ describe(findSelectedLogEntry, () => {
data: {
resultData: {
runData: {
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ executionStatus: 'success' })],
A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
C: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
createTestTaskData({ executionStatus: 'success', startTime: 2 }),
createTestTaskData({
error: {} as ExecutionError,
executionStatus: 'error',
startTime: 3,
}),
createTestTaskData({
error: {} as ExecutionError,
executionStatus: 'error',
startTime: 4,
}),
],
},
},
},
});
expect(find({ type: 'initial' }, response)).toEqual(
expect(find({ type: 'initial' }, response, false)).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 1 }),
);
});
@@ -625,19 +629,27 @@ describe(findSelectedLogEntry, () => {
data: {
resultData: {
runData: {
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ executionStatus: 'success' })],
A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
C: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
createTestTaskData({ executionStatus: 'success', startTime: 2 }),
createTestTaskData({
error: {} as ExecutionError,
executionStatus: 'error',
startTime: 3,
}),
createTestTaskData({
error: {} as ExecutionError,
executionStatus: 'error',
startTime: 4,
}),
],
},
},
},
});
expect(find({ type: 'initial' }, response)).toEqual(
expect(find({ type: 'initial' }, response, false)).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 1 }),
);
});
@@ -654,24 +666,55 @@ describe(findSelectedLogEntry, () => {
data: {
resultData: {
runData: {
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ executionStatus: 'success' })],
A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
C: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
createTestTaskData({ executionStatus: 'success', startTime: 2 }),
createTestTaskData({ executionStatus: 'success', startTime: 3 }),
createTestTaskData({ executionStatus: 'success', startTime: 4 }),
],
},
},
},
});
expect(find({ type: 'initial' }, response)).toEqual(
expect(find({ type: 'initial' }, response, false)).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'B' }), runIndex: 0 }),
);
});
it('should return first log entry if there is no log entry with error nor executed AI agent node', () => {
it('should return first log entry with error when it appears after a log entry for AI agent', () => {
const response = createTestWorkflowExecutionResponse({
workflowData: createTestWorkflow({
nodes: [
createTestNode({ name: 'A' }),
createTestNode({ name: 'B', type: AGENT_LANGCHAIN_NODE_TYPE }),
createTestNode({ name: 'C' }),
],
}),
data: {
resultData: {
runData: {
A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
C: [
createTestTaskData({
executionStatus: 'success',
error: {} as ExecutionError,
startTime: 2,
}),
],
},
},
},
});
expect(find({ type: 'initial' }, response, false)).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 0 }),
);
});
it('should return last log entry if there is no log entry with error nor executed AI agent node', () => {
const response = createTestWorkflowExecutionResponse({
workflowData: createTestWorkflow({
nodes: [
@@ -683,48 +726,98 @@ describe(findSelectedLogEntry, () => {
data: {
resultData: {
runData: {
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ executionStatus: 'success' })],
A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
C: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'success', startTime: 2 }),
createTestTaskData({ executionStatus: 'success', startTime: 3 }),
createTestTaskData({ executionStatus: 'success', startTime: 4 }),
],
},
},
},
});
expect(find({ type: 'initial' }, response)).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'A' }), runIndex: 0 }),
expect(find({ type: 'initial' }, response, false)).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 2 }),
);
});
});
describe('when log is manually selected', () => {
it('should return manually selected log', () => {
const response = createTestWorkflowExecutionResponse({
id: 'my-exec-id',
workflowData: createTestWorkflow({
id: 'test-wf-id',
nodes: [createTestNode({ name: 'A' }), createTestNode({ name: 'B' })],
}),
data: {
resultData: {
runData: {
A: [createTestTaskData({ executionStatus: 'success' })],
B: [createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' })],
},
const nodeA = createTestNode({ name: 'A', id: 'a' });
const nodeB = createTestNode({ name: 'B', id: 'b' });
const workflowData = createTestWorkflow({
id: 'test-wf-id',
nodes: [nodeA, nodeB],
});
const response = createTestWorkflowExecutionResponse({
workflowData,
data: {
resultData: {
runData: {
A: [
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'success' }),
createTestTaskData({ executionStatus: 'success' }),
],
B: [createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' })],
},
},
});
},
});
const result = find({ type: 'selected', id: 'test-wf-id:A:my-exec-id:0' }, response);
it('should return manually selected log', () => {
const result = find(
{ type: 'selected', entry: createTestLogEntry({ id: 'test-wf-id:a:0' }) },
response,
false,
);
expect(result).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'A' }), runIndex: 0 }),
);
});
it('should return the log with same node and closest run index as selected if the exact run index is not found in logs', () => {
const result = find(
{
type: 'selected',
entry: createTestLogEntry({
id: 'test-wf-id:a:4',
executionId: response.id,
node: nodeA,
runIndex: 4,
workflow: createTestWorkflowObject(workflowData),
}),
},
response,
false,
);
expect(result).toEqual(
expect.objectContaining({ node: expect.objectContaining({ name: 'A' }), runIndex: 2 }),
);
});
it('should not fallback to the closest run index while executing', () => {
const result = find(
{
type: 'selected',
entry: createTestLogEntry({
id: 'test-wf-id:a:4',
executionId: response.id,
node: nodeA,
runIndex: 4,
workflow: createTestWorkflowObject(workflowData),
}),
},
response,
true,
);
expect(result).toBe(undefined);
});
});
});
@@ -909,28 +1002,24 @@ describe(createLogTree, () => {
expect(logs).toHaveLength(2);
expect(logs[0].node.name).toBe('A');
expect(logs[0].depth).toBe(0);
expect(logs[0].workflow).toBe(workflow);
expect(logs[0].execution).toBe(rootExecutionData.data);
expect(logs[0].executionId).toBe('root-exec-id');
expect(logs[0].children).toHaveLength(0);
expect(logs[1].node.name).toBe('B');
expect(logs[1].depth).toBe(0);
expect(logs[1].workflow).toBe(workflow);
expect(logs[1].execution).toBe(rootExecutionData.data);
expect(logs[1].executionId).toBe('root-exec-id');
expect(logs[1].children).toHaveLength(2);
expect(logs[1].children[0].node.name).toBe('C');
expect(logs[1].children[0].depth).toBe(1);
expect(logs[1].children[0].workflow).toBe(subWorkflow);
expect(logs[1].children[0].execution).toBe(subExecutionData);
expect(logs[1].children[0].executionId).toBe('sub-exec-id');
expect(logs[1].children[0].children).toHaveLength(0);
expect(logs[1].children[1].node.name).toBe('C');
expect(logs[1].children[1].depth).toBe(1);
expect(logs[1].children[1].workflow).toBe(subWorkflow);
expect(logs[1].children[1].execution).toBe(subExecutionData);
expect(logs[1].children[1].executionId).toBe('sub-exec-id');

View File

@@ -17,8 +17,8 @@ import type { LogEntry, LogEntrySelection, LogTreeCreationContext } from './logs
import { isProxy, isReactive, isRef, toRaw } from 'vue';
import { CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE } from '@/constants';
import { type ChatMessage } from '@n8n/chat/types';
import get from 'lodash-es/get';
import isEmpty from 'lodash-es/isEmpty';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { v4 as uuid } from 'uuid';
import { TOOL_EXECUTOR_NODE_NAME } from '@n8n/constants';
@@ -54,8 +54,9 @@ function createNode(
return {
parent: context.parent,
node,
id: `${context.workflow.id}:${node.name}:${context.executionId}:${runIndex}`,
depth: context.depth,
// The ID consists of workflow ID, node ID and run index (including ancestor's), which
// makes it possible to identify the same log across different executions
id: `${context.workflow.id}:${node.id}:${[...context.ancestorRunIndexes, runIndex].join(':')}`,
runIndex,
runData,
children,
@@ -85,7 +86,7 @@ function getChildNodes(
return createLogTreeRec({
...context,
parent: treeNode,
depth: context.depth + 1,
ancestorRunIndexes: [...context.ancestorRunIndexes, runIndex ?? 0],
workflow,
executionId: subExecutionLocator.executionId,
data: subWorkflowRunData,
@@ -121,7 +122,7 @@ function getChildNodes(
return subNode
? getTreeNodeData(subNode, t, index, {
...context,
depth: context.depth + 1,
ancestorRunIndexes: [...context.ancestorRunIndexes, runIndex ?? 0],
parent: treeNode,
})
: [];
@@ -171,28 +172,25 @@ export function getSubtreeTotalConsumedTokens(
return calculate(treeNode);
}
function findLogEntryToAutoSelectRec(subTree: LogEntry[], depth: number): LogEntry | undefined {
for (const entry of subTree) {
if (entry.runData?.error) {
return entry;
}
function findLogEntryToAutoSelect(subTree: LogEntry[]): LogEntry | undefined {
const entryWithError = findLogEntryRec((e) => !!e.runData?.error, subTree);
const childAutoSelect = findLogEntryToAutoSelectRec(entry.children, depth + 1);
if (childAutoSelect) {
return childAutoSelect;
}
if (entry.node.type === AGENT_LANGCHAIN_NODE_TYPE) {
if (isPlaceholderLog(entry) && entry.children.length > 0) {
return entry.children[0];
}
return entry;
}
if (entryWithError) {
return entryWithError;
}
return depth === 0 ? subTree[0] : undefined;
const entryForAiAgent = findLogEntryRec(
(entry) =>
entry.node.type === AGENT_LANGCHAIN_NODE_TYPE ||
(entry.parent?.node.type === AGENT_LANGCHAIN_NODE_TYPE && isPlaceholderLog(entry.parent)),
subTree,
);
if (entryForAiAgent) {
return entryForAiAgent;
}
return subTree[subTree.length - 1];
}
export function createLogTree(
@@ -203,7 +201,7 @@ export function createLogTree(
) {
return createLogTreeRec({
parent: undefined,
depth: 0,
ancestorRunIndexes: [],
executionId: response.id,
workflow,
workflows,
@@ -283,20 +281,33 @@ export function findLogEntryRec(
export function findSelectedLogEntry(
selection: LogEntrySelection,
entries: LogEntry[],
isExecuting: boolean,
): LogEntry | undefined {
switch (selection.type) {
case 'initial':
return findLogEntryToAutoSelectRec(entries, 0);
return isExecuting ? undefined : findLogEntryToAutoSelect(entries);
case 'none':
return undefined;
case 'selected': {
const entry = findLogEntryRec((e) => e.id === selection.id, entries);
const found = findLogEntryRec((e) => e.id === selection.entry.id, entries);
if (entry) {
return entry;
if (found === undefined && !isExecuting) {
for (let runIndex = selection.entry.runIndex - 1; runIndex >= 0; runIndex--) {
const fallback = findLogEntryRec(
(e) =>
e.workflow.id === selection.entry.workflow.id &&
e.node.id === selection.entry.node.id &&
e.runIndex === runIndex,
entries,
);
if (fallback !== undefined) {
return fallback;
}
}
}
return findLogEntryToAutoSelectRec(entries, 0);
return found;
}
}
}

View File

@@ -32,7 +32,9 @@ export const useLogsStore = defineStore('logs', () => {
LOG_DETAILS_PANEL_STATE.BOTH,
{ writeDefaults: false },
);
const isLogSelectionSyncedWithCanvas = useLocalStorage(LOCAL_STORAGE_LOGS_SYNC_SELECTION, false);
const isLogSelectionSyncedWithCanvas = useLocalStorage(LOCAL_STORAGE_LOGS_SYNC_SELECTION, true, {
writeDefaults: false,
});
const isSubNodeSelected = ref(false);
const telemetry = useTelemetry();

View File

@@ -73,3 +73,26 @@ export function isOutsideSelected(el: HTMLElement | null) {
selection.anchorOffset !== selection.focusOffset)
);
}
let scrollbarWidth: number | undefined;
export function getScrollbarWidth() {
if (scrollbarWidth !== undefined) {
return scrollbarWidth;
}
const outer = document.createElement('div');
const inner = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.overflow = 'scroll';
document.body.appendChild(outer);
outer.appendChild(inner);
scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
outer.parentElement?.removeChild(outer);
return scrollbarWidth;
}