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();
expect(rendered.getByText('AI Agent')).toBeInTheDocument();
expect(await rendered.findByText('AI Agent')).toBeInTheDocument();
operations.deleteNode(aiAgentNode.id);

View File

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

View File

@@ -29,6 +29,7 @@ describe('LogsOverviewPanel', () => {
isOpen: false,
isReadOnly: false,
isCompact: false,
scrollToSelection: false,
execution: {
...aiChatExecutionResponse,
tree: createLogEntries(
@@ -86,16 +87,17 @@ describe('LogsOverviewPanel', () => {
expect(summary.queryByText('Success in 1.999s')).toBeInTheDocument();
expect(summary.queryByText('555 Tokens')).toBeInTheDocument();
await fireEvent.click(rendered.getByText('Overview'));
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]);
expect(row1.queryByText('AI Agent')).toBeInTheDocument();
expect(row1.queryByText('Success in 1.778s')).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]);
@@ -114,7 +116,7 @@ describe('LogsOverviewPanel', () => {
const rendered = render({
isOpen: true,
});
const aiAgentRow = rendered.getAllByRole('treeitem')[0];
const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0];
expect(ndvStore.activeNodeName).toBe(null);
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 waitFor(() =>
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 { useI18n } from '@/composables/useI18n';
import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
import { computed, nextTick } from 'vue';
import { ElTree, type TreeNode as ElTreeNode } from 'element-plus';
import { ref, computed, nextTick, watch } from 'vue';
import { useTelemetry } from '@/composables/useTelemetry';
import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.vue';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
@@ -13,20 +12,24 @@ import { useRouter } from 'vue-router';
import ExecutionSummary from '@/components/CanvasChat/future/components/ExecutionSummary.vue';
import {
type ExecutionLogViewData,
flattenLogEntries,
getSubtreeTotalConsumedTokens,
getTotalConsumedTokens,
type LatestNodeInfo,
type LogEntry,
} from '@/components/RunDataAi/utils';
import { useVirtualList } from '@vueuse/core';
const { isOpen, isReadOnly, selected, isCompact, execution, latestNodeInfo } = defineProps<{
isOpen: boolean;
selected?: LogEntry;
isReadOnly: boolean;
isCompact: boolean;
execution?: ExecutionLogViewData;
latestNodeInfo: Record<string, LatestNodeInfo>;
}>();
const { isOpen, isReadOnly, selected, isCompact, execution, latestNodeInfo, scrollToSelection } =
defineProps<{
isOpen: boolean;
selected?: LogEntry;
isReadOnly: boolean;
isCompact: boolean;
execution?: ExecutionLogViewData;
latestNodeInfo: Record<string, LatestNodeInfo>;
scrollToSelection: boolean;
}>();
const emit = defineEmits<{
clickHeader: [];
@@ -50,6 +53,11 @@ const switchViewOptions = computed(() => [
const consumedTokens = computed(() =>
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) {
if (selected?.node === clicked.node && selected?.runIndex === clicked.runIndex) {
@@ -73,8 +81,8 @@ function handleSwitchView(value: 'overview' | 'details') {
);
}
function handleToggleExpanded(treeNode: ElTreeNode) {
treeNode.expanded = !treeNode.expanded;
function handleToggleExpanded(treeNode: LogEntry) {
collapsedEntries.value[treeNode.id] = !collapsedEntries.value[treeNode.id];
}
async function handleOpenNdv(treeNode: LogEntry) {
@@ -90,6 +98,22 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
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>
<template>
@@ -132,7 +156,7 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
>
{{ locale.baseText('logs.overview.body.empty.message') }}
</N8nText>
<div v-else :class="$style.scrollable">
<template v-else>
<ExecutionSummary
v-if="execution"
:class="$style.summary"
@@ -144,20 +168,12 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
: undefined
"
/>
<ElTree
v-if="(execution?.tree ?? []).length > 0"
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 }">
<div :class="$style.tree" v-bind="virtualList.containerProps">
<div v-bind="virtualList.wrapperProps.value" role="tree">
<LogsOverviewRow
v-for="{ data } of virtualList.list.value"
:key="data.id"
:data="data"
:node="elTreeNode"
:is-read-only="isReadOnly"
:is-selected="
data.node.name === selected?.node.name && data.runIndex === selected?.runIndex
@@ -165,12 +181,14 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
:is-compact="isCompact"
:should-show-consumed-tokens="consumedTokens.totalTokens > 0"
:latest-info="latestNodeInfo[data.node.id]"
:expanded="!collapsedEntries[data.id]"
@click.stop="handleClickNode(data)"
@toggle-expanded="handleToggleExpanded"
@open-ndv="handleOpenNdv"
@trigger-partial-execution="handleTriggerPartialExecution"
/>
</template>
</ElTree>
</div>
</div>
<N8nRadioButtons
size="small"
:class="$style.switchViewButtons"
@@ -178,7 +196,7 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
:options="switchViewOptions"
@update:model-value="handleSwitchView"
/>
</div>
</template>
</div>
</div>
</template>
@@ -222,13 +240,6 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
text-align: center;
}
.scrollable {
flex-grow: 1;
flex-shrink: 1;
overflow: auto;
scroll-padding-block: var(--spacing-2xs);
}
.summary {
padding: var(--spacing-2xs);
}

View File

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

View File

@@ -457,3 +457,19 @@ export function deepToRaw<T>(sourceObj: T): T {
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;
}