perf(editor): Improve performance of the new logs view (#14861)

This commit is contained in:
Suguru Inoue
2025-04-25 10:45:33 +02:00
committed by GitHub
parent cdf421e80f
commit 40aadbf880
6 changed files with 111 additions and 66 deletions

View File

@@ -277,7 +277,7 @@ describe('LogsPanel', () => {
const rendered = render(); const rendered = render();
expect(rendered.getByText('AI Agent')).toBeInTheDocument(); expect(await rendered.findByText('AI Agent')).toBeInTheDocument();
operations.deleteNode(aiAgentNode.id); operations.deleteNode(aiAgentNode.id);

View File

@@ -139,12 +139,17 @@ function handleResizeOverviewPanelEnd() {
@resizeend="handleResizeOverviewPanelEnd" @resizeend="handleResizeOverviewPanelEnd"
> >
<LogsOverviewPanel <LogsOverviewPanel
:key="execution?.id ?? ''"
:class="$style.logsOverview" :class="$style.logsOverview"
:is-open="isOpen" :is-open="isOpen"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:is-compact="isLogDetailsVisuallyOpen" :is-compact="isLogDetailsVisuallyOpen"
:selected="selectedLogEntry" :selected="selectedLogEntry"
:execution="execution" :execution="execution"
:scroll-to-selection="
manualLogEntrySelection.type !== 'selected' ||
manualLogEntrySelection.data.id !== selectedLogEntry?.id
"
:latest-node-info="latestNodeNameById" :latest-node-info="latestNodeNameById"
@click-header="onToggleOpen(true)" @click-header="onToggleOpen(true)"
@select="handleSelectLogEntry" @select="handleSelectLogEntry"

View File

@@ -29,6 +29,7 @@ describe('LogsOverviewPanel', () => {
isOpen: false, isOpen: false,
isReadOnly: false, isReadOnly: false,
isCompact: false, isCompact: false,
scrollToSelection: false,
execution: { execution: {
...aiChatExecutionResponse, ...aiChatExecutionResponse,
tree: createLogEntries( tree: createLogEntries(
@@ -86,16 +87,17 @@ describe('LogsOverviewPanel', () => {
expect(summary.queryByText('Success in 1.999s')).toBeInTheDocument(); expect(summary.queryByText('Success in 1.999s')).toBeInTheDocument();
expect(summary.queryByText('555 Tokens')).toBeInTheDocument(); expect(summary.queryByText('555 Tokens')).toBeInTheDocument();
await fireEvent.click(rendered.getByText('Overview'));
const tree = within(rendered.getByRole('tree')); const tree = within(rendered.getByRole('tree'));
expect(tree.queryAllByRole('treeitem')).toHaveLength(2); await waitFor(() => expect(tree.queryAllByRole('treeitem')).toHaveLength(2));
const row1 = within(tree.queryAllByRole('treeitem')[0]); const row1 = within(tree.queryAllByRole('treeitem')[0]);
expect(row1.queryByText('AI Agent')).toBeInTheDocument(); expect(row1.queryByText('AI Agent')).toBeInTheDocument();
expect(row1.queryByText('Success in 1.778s')).toBeInTheDocument(); expect(row1.queryByText('Success in 1.778s')).toBeInTheDocument();
expect(row1.queryByText('Started 00:00:00.002, 26 Mar')).toBeInTheDocument(); expect(row1.queryByText('Started 00:00:00.002, 26 Mar')).toBeInTheDocument();
expect(row1.queryByText('555 Tokens')).toBeInTheDocument();
const row2 = within(tree.queryAllByRole('treeitem')[1]); const row2 = within(tree.queryAllByRole('treeitem')[1]);
@@ -114,7 +116,7 @@ describe('LogsOverviewPanel', () => {
const rendered = render({ const rendered = render({
isOpen: true, isOpen: true,
}); });
const aiAgentRow = rendered.getAllByRole('treeitem')[0]; const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0];
expect(ndvStore.activeNodeName).toBe(null); expect(ndvStore.activeNodeName).toBe(null);
expect(ndvStore.output.run).toBe(undefined); expect(ndvStore.output.run).toBe(undefined);
@@ -140,10 +142,33 @@ describe('LogsOverviewPanel', () => {
), ),
}, },
}); });
const aiAgentRow = rendered.getAllByRole('treeitem')[0]; const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0];
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Test step')[0]); await fireEvent.click(within(aiAgentRow).getAllByLabelText('Test step')[0]);
await waitFor(() => await waitFor(() =>
expect(spyRun).toHaveBeenCalledWith(expect.objectContaining({ destinationNode: 'AI Agent' })), expect(spyRun).toHaveBeenCalledWith(expect.objectContaining({ destinationNode: 'AI Agent' })),
); );
}); });
it('should toggle subtree when chevron icon button is pressed', async () => {
const rendered = render({ isOpen: true });
await waitFor(() => expect(rendered.queryAllByRole('treeitem')).toHaveLength(2));
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
expect(rendered.queryByText('AI Model')).toBeInTheDocument();
// Close subtree of AI Agent
await fireEvent.click(rendered.getAllByLabelText('Toggle row')[0]);
await waitFor(() => expect(rendered.queryAllByRole('treeitem')).toHaveLength(1));
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
expect(rendered.queryByText('AI Model')).not.toBeInTheDocument();
// Re-open subtree of AI Agent
await fireEvent.click(rendered.getAllByLabelText('Toggle row')[0]);
await waitFor(() => expect(rendered.queryAllByRole('treeitem')).toHaveLength(2));
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
expect(rendered.queryByText('AI Model')).toBeInTheDocument();
});
}); });

View File

@@ -3,8 +3,7 @@ import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.v
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible'; import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system'; import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
import { computed, nextTick } from 'vue'; import { ref, computed, nextTick, watch } from 'vue';
import { ElTree, type TreeNode as ElTreeNode } from 'element-plus';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.vue'; import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.vue';
import { useRunWorkflow } from '@/composables/useRunWorkflow'; import { useRunWorkflow } from '@/composables/useRunWorkflow';
@@ -13,20 +12,24 @@ import { useRouter } from 'vue-router';
import ExecutionSummary from '@/components/CanvasChat/future/components/ExecutionSummary.vue'; import ExecutionSummary from '@/components/CanvasChat/future/components/ExecutionSummary.vue';
import { import {
type ExecutionLogViewData, type ExecutionLogViewData,
flattenLogEntries,
getSubtreeTotalConsumedTokens, getSubtreeTotalConsumedTokens,
getTotalConsumedTokens, getTotalConsumedTokens,
type LatestNodeInfo, type LatestNodeInfo,
type LogEntry, type LogEntry,
} from '@/components/RunDataAi/utils'; } from '@/components/RunDataAi/utils';
import { useVirtualList } from '@vueuse/core';
const { isOpen, isReadOnly, selected, isCompact, execution, latestNodeInfo } = defineProps<{ const { isOpen, isReadOnly, selected, isCompact, execution, latestNodeInfo, scrollToSelection } =
isOpen: boolean; defineProps<{
selected?: LogEntry; isOpen: boolean;
isReadOnly: boolean; selected?: LogEntry;
isCompact: boolean; isReadOnly: boolean;
execution?: ExecutionLogViewData; isCompact: boolean;
latestNodeInfo: Record<string, LatestNodeInfo>; execution?: ExecutionLogViewData;
}>(); latestNodeInfo: Record<string, LatestNodeInfo>;
scrollToSelection: boolean;
}>();
const emit = defineEmits<{ const emit = defineEmits<{
clickHeader: []; clickHeader: [];
@@ -50,6 +53,11 @@ const switchViewOptions = computed(() => [
const consumedTokens = computed(() => const consumedTokens = computed(() =>
getTotalConsumedTokens(...(execution?.tree ?? []).map(getSubtreeTotalConsumedTokens)), getTotalConsumedTokens(...(execution?.tree ?? []).map(getSubtreeTotalConsumedTokens)),
); );
const collapsedEntries = ref<Record<string, boolean>>({});
const flatLogEntries = computed(() =>
flattenLogEntries(execution?.tree ?? [], collapsedEntries.value),
);
const virtualList = useVirtualList(flatLogEntries, { itemHeight: 32 });
function handleClickNode(clicked: LogEntry) { function handleClickNode(clicked: LogEntry) {
if (selected?.node === clicked.node && selected?.runIndex === clicked.runIndex) { if (selected?.node === clicked.node && selected?.runIndex === clicked.runIndex) {
@@ -73,8 +81,8 @@ function handleSwitchView(value: 'overview' | 'details') {
); );
} }
function handleToggleExpanded(treeNode: ElTreeNode) { function handleToggleExpanded(treeNode: LogEntry) {
treeNode.expanded = !treeNode.expanded; collapsedEntries.value[treeNode.id] = !collapsedEntries.value[treeNode.id];
} }
async function handleOpenNdv(treeNode: LogEntry) { async function handleOpenNdv(treeNode: LogEntry) {
@@ -90,6 +98,22 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
await runWorkflow.runWorkflow({ destinationNode: latestName }); await runWorkflow.runWorkflow({ destinationNode: latestName });
} }
} }
// Scroll selected row into view
watch(
() => (scrollToSelection ? selected : undefined),
async (entry) => {
if (entry) {
const index = flatLogEntries.value.findIndex((e) => e.id === entry.id);
if (index >= 0) {
// Wait for the node to be added to the list, and then scroll
await nextTick(() => virtualList.scrollTo(index));
}
}
},
{ immediate: true },
);
</script> </script>
<template> <template>
@@ -132,7 +156,7 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
> >
{{ locale.baseText('logs.overview.body.empty.message') }} {{ locale.baseText('logs.overview.body.empty.message') }}
</N8nText> </N8nText>
<div v-else :class="$style.scrollable"> <template v-else>
<ExecutionSummary <ExecutionSummary
v-if="execution" v-if="execution"
:class="$style.summary" :class="$style.summary"
@@ -144,20 +168,12 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
: undefined : undefined
" "
/> />
<ElTree <div :class="$style.tree" v-bind="virtualList.containerProps">
v-if="(execution?.tree ?? []).length > 0" <div v-bind="virtualList.wrapperProps.value" role="tree">
node-key="id"
:class="$style.tree"
:indent="0"
:data="execution?.tree ?? []"
:expand-on-click-node="false"
:default-expand-all="true"
@node-click="handleClickNode"
>
<template #default="{ node: elTreeNode, data }">
<LogsOverviewRow <LogsOverviewRow
v-for="{ data } of virtualList.list.value"
:key="data.id"
:data="data" :data="data"
:node="elTreeNode"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:is-selected=" :is-selected="
data.node.name === selected?.node.name && data.runIndex === selected?.runIndex data.node.name === selected?.node.name && data.runIndex === selected?.runIndex
@@ -165,12 +181,14 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
:is-compact="isCompact" :is-compact="isCompact"
:should-show-consumed-tokens="consumedTokens.totalTokens > 0" :should-show-consumed-tokens="consumedTokens.totalTokens > 0"
:latest-info="latestNodeInfo[data.node.id]" :latest-info="latestNodeInfo[data.node.id]"
:expanded="!collapsedEntries[data.id]"
@click.stop="handleClickNode(data)"
@toggle-expanded="handleToggleExpanded" @toggle-expanded="handleToggleExpanded"
@open-ndv="handleOpenNdv" @open-ndv="handleOpenNdv"
@trigger-partial-execution="handleTriggerPartialExecution" @trigger-partial-execution="handleTriggerPartialExecution"
/> />
</template> </div>
</ElTree> </div>
<N8nRadioButtons <N8nRadioButtons
size="small" size="small"
:class="$style.switchViewButtons" :class="$style.switchViewButtons"
@@ -178,7 +196,7 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
:options="switchViewOptions" :options="switchViewOptions"
@update:model-value="handleSwitchView" @update:model-value="handleSwitchView"
/> />
</div> </template>
</div> </div>
</div> </div>
</template> </template>
@@ -222,13 +240,6 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
text-align: center; text-align: center;
} }
.scrollable {
flex-grow: 1;
flex-shrink: 1;
overflow: auto;
scroll-padding-block: var(--spacing-2xs);
}
.summary { .summary {
padding: var(--spacing-2xs); padding: var(--spacing-2xs);
} }

View File

@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { type TreeNode as ElTreeNode } from 'element-plus'; import { computed } from 'vue';
import { computed, useTemplateRef, watch } from 'vue';
import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system'; import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { upperFirst } from 'lodash-es'; import { upperFirst } from 'lodash-es';
@@ -17,25 +16,23 @@ import {
const props = defineProps<{ const props = defineProps<{
data: LogEntry; data: LogEntry;
node: ElTreeNode;
isSelected: boolean; isSelected: boolean;
isReadOnly: boolean; isReadOnly: boolean;
shouldShowConsumedTokens: boolean; shouldShowConsumedTokens: boolean;
isCompact: boolean; isCompact: boolean;
latestInfo?: LatestNodeInfo; latestInfo?: LatestNodeInfo;
expanded: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
toggleExpanded: [node: ElTreeNode]; toggleExpanded: [node: LogEntry];
triggerPartialExecution: [node: LogEntry]; triggerPartialExecution: [node: LogEntry];
openNdv: [node: LogEntry]; openNdv: [node: LogEntry];
}>(); }>();
const locale = useI18n(); const locale = useI18n();
const containerRef = useTemplateRef('containerRef');
const nodeTypeStore = useNodeTypesStore(); const nodeTypeStore = useNodeTypesStore();
const type = computed(() => nodeTypeStore.getNodeType(props.data.node.type)); const type = computed(() => nodeTypeStore.getNodeType(props.data.node.type));
const depth = computed(() => (props.node.level ?? 1) - 1);
const isSettled = computed( const isSettled = computed(
() => () =>
props.data.runData.executionStatus && props.data.runData.executionStatus &&
@@ -60,7 +57,7 @@ function isLastChild(level: number) {
let parent = props.data.parent; let parent = props.data.parent;
let data: LogEntry | undefined = props.data; let data: LogEntry | undefined = props.data;
for (let i = 0; i < depth.value - level; i++) { for (let i = 0; i < props.data.depth - level; i++) {
data = parent; data = parent;
parent = parent?.parent; parent = parent?.parent;
} }
@@ -73,22 +70,13 @@ function isLastChild(level: number) {
(data?.node === lastSibling?.node && data?.runIndex === lastSibling?.runIndex) (data?.node === lastSibling?.node && data?.runIndex === lastSibling?.runIndex)
); );
} }
// When selected, scroll into view
watch(
[() => props.isSelected, containerRef],
([isSelected, ref]) => {
if (isSelected && ref) {
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
},
{ immediate: true },
);
</script> </script>
<template> <template>
<div <div
ref="containerRef" role="treeitem"
tabindex="0"
:aria-expanded="props.data.children.length > 0 && props.expanded"
:class="{ :class="{
[$style.container]: true, [$style.container]: true,
[$style.compact]: props.isCompact, [$style.compact]: props.isCompact,
@@ -96,16 +84,16 @@ watch(
[$style.selected]: props.isSelected, [$style.selected]: props.isSelected,
}" }"
> >
<template v-for="level in depth" :key="level"> <template v-for="level in props.data.depth" :key="level">
<div <div
:class="{ :class="{
[$style.indent]: true, [$style.indent]: true,
[$style.connectorCurved]: level === depth, [$style.connectorCurved]: level === props.data.depth,
[$style.connectorStraight]: !isLastChild(level), [$style.connectorStraight]: !isLastChild(level),
}" }"
/> />
</template> </template>
<div :class="$style.background" :style="{ '--indent-depth': depth }" /> <div :class="$style.background" :style="{ '--indent-depth': props.data.depth }" />
<NodeIcon :node-type="type" :size="16" :class="$style.icon" /> <NodeIcon :node-type="type" :size="16" :class="$style.icon" />
<NodeName <NodeName
:class="$style.name" :class="$style.name"
@@ -146,7 +134,7 @@ watch(
<ConsumedTokenCountText <ConsumedTokenCountText
v-if=" v-if="
subtreeConsumedTokens.totalTokens > 0 && subtreeConsumedTokens.totalTokens > 0 &&
(props.data.children.length === 0 || !props.node.expanded) (props.data.children.length === 0 || !props.expanded)
" "
:consumed-tokens="subtreeConsumedTokens" :consumed-tokens="subtreeConsumedTokens"
/> />
@@ -168,7 +156,7 @@ watch(
icon="play" icon="play"
style="color: var(--color-text-base)" style="color: var(--color-text-base)"
:aria-label="locale.baseText('logs.overview.body.run')" :aria-label="locale.baseText('logs.overview.body.run')"
:class="[$style.partialExecutionButton, depth > 0 ? $style.unavailable : '']" :class="[$style.partialExecutionButton, props.data.depth > 0 ? $style.unavailable : '']"
:disabled="props.latestInfo?.deleted || props.latestInfo?.disabled" :disabled="props.latestInfo?.deleted || props.latestInfo?.disabled"
@click.stop="emit('triggerPartialExecution', props.data)" @click.stop="emit('triggerPartialExecution', props.data)"
/> />
@@ -194,9 +182,9 @@ watch(
}" }"
:class="$style.toggleButton" :class="$style.toggleButton"
:aria-label="locale.baseText('logs.overview.body.toggleRow')" :aria-label="locale.baseText('logs.overview.body.toggleRow')"
@click.stop="emit('toggleExpanded', props.node)" @click.stop="emit('toggleExpanded', props.data)"
> >
<N8nIcon size="medium" :icon="props.node.expanded ? 'chevron-down' : 'chevron-up'" /> <N8nIcon size="medium" :icon="props.expanded ? 'chevron-down' : 'chevron-up'" />
</N8nButton> </N8nButton>
</div> </div>
</template> </template>

View File

@@ -457,3 +457,19 @@ export function deepToRaw<T>(sourceObj: T): T {
return objectIterator(sourceObj); return objectIterator(sourceObj);
} }
export function flattenLogEntries(
entries: LogEntry[],
collapsedEntryIds: Record<string, boolean>,
ret: LogEntry[] = [],
): LogEntry[] {
for (const entry of entries) {
ret.push(entry);
if (!collapsedEntryIds[entry.id]) {
flattenLogEntries(entry.children, collapsedEntryIds, ret);
}
}
return ret;
}