mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
perf(editor): Improve performance of the new logs view (#14861)
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user