perf(editor): Optimize log entries calculation with throttled watcher (no-changelog) (#18486)

This commit is contained in:
Alex Grozav
2025-08-26 13:45:35 +01:00
committed by GitHub
parent 38f25d74eb
commit 71ff4d8b6b
8 changed files with 79 additions and 32 deletions

View File

@@ -71,7 +71,7 @@ export function toggleInputPanel() {
}
export function clickOpenNdvAtRow(rowIndex: number) {
getLogEntries().eq(rowIndex).realHover();
getLogEntries().eq(rowIndex).trigger('focus').realHover();
getLogEntries().eq(rowIndex).find('[aria-label="Open..."]').click();
}

View File

@@ -63,7 +63,7 @@ describe('LogsPanel', () => {
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
function render() {
return renderComponent(LogsPanel, {
const wrapper = renderComponent(LogsPanel, {
global: {
provide: {
[ChatSymbol as symbol]: {},
@@ -78,9 +78,15 @@ describe('LogsPanel', () => {
],
},
});
vi.advanceTimersByTime(1000);
return wrapper;
}
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
pinia = createTestingPinia({ stubActions: false, fakeApp: true });
setActivePinia(pinia);
@@ -148,7 +154,9 @@ describe('LogsPanel', () => {
const rendered = render();
expect(rendered.queryByTestId('log-details-header')).toHaveTextContent('AI Agent');
await waitFor(() =>
expect(rendered.queryByTestId('log-details-header')).toHaveTextContent('AI Agent'),
);
expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
});
@@ -160,7 +168,9 @@ describe('LogsPanel', () => {
const rendered = render();
expect(rendered.queryByTestId('log-details-header')).toHaveTextContent('AI Model');
await waitFor(() =>
expect(rendered.queryByTestId('log-details-header')).toHaveTextContent('AI Model'),
);
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
});
@@ -289,6 +299,7 @@ describe('LogsPanel', () => {
const rendered = render();
await waitFor(() => expect(rendered.getByText('Overview')).toBeInTheDocument());
await fireEvent.click(rendered.getByText('Overview'));
expect(rendered.getByText(/Running/)).toBeInTheDocument();
@@ -300,6 +311,8 @@ describe('LogsPanel', () => {
data: { executionIndex: 0, startTime: Date.parse('2025-04-20T12:34:51.000Z'), source: [] },
});
vi.advanceTimersByTime(2000);
const lastTreeItem = await waitFor(() => {
const items = rendered.getAllByRole('treeitem');
@@ -321,6 +334,9 @@ describe('LogsPanel', () => {
executionStatus: 'success',
},
});
vi.advanceTimersByTime(1000);
expect(await lastTreeItem.findByText('AI Agent')).toBeInTheDocument();
expect(await lastTreeItem.findByText('Success')).toBeInTheDocument();
expect(lastTreeItem.getByText('in 33ms')).toBeInTheDocument();
@@ -334,6 +350,8 @@ describe('LogsPanel', () => {
stoppedAt: new Date('2025-04-20T12:34:56.000Z'),
});
vi.advanceTimersByTime(1000);
expect(await rendered.findByText('Success in 6s')).toBeInTheDocument();
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
});
@@ -417,6 +435,7 @@ describe('LogsPanel', () => {
const rendered = render();
await waitFor(() => expect(rendered.getByTestId('log-details-header')).toBeInTheDocument());
const header = within(rendered.getByTestId('log-details-header'));
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
@@ -472,7 +491,9 @@ describe('LogsPanel', () => {
const { getByTestId, findByRole } = render();
const overview = getByTestId('logs-overview');
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
await waitFor(async () =>
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/),
);
await fireEvent.keyDown(overview, { key: 'K' });
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
await fireEvent.keyDown(overview, { key: 'J' });
@@ -526,7 +547,9 @@ describe('LogsPanel', () => {
const { rerender, findByRole } = render();
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
await waitFor(async () =>
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/),
);
await canvasOperations.renameNode('AI Agent', 'Renamed Agent');
uiStore.lastSelectedNode = 'Renamed Agent';

View File

@@ -33,6 +33,8 @@ describe(useLogsExecutionData, () => {
describe('loadSubExecution', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
workflowsStore.setWorkflowExecutionData(
createTestWorkflowExecutionResponse({
id: 'e0',
@@ -59,6 +61,8 @@ describe(useLogsExecutionData, () => {
},
}),
);
vi.advanceTimersByTime(1000);
});
it('should add runs from sub execution to the entries', async () => {
@@ -79,6 +83,8 @@ describe(useLogsExecutionData, () => {
await loadSubExecution(entries.value[1]);
vi.advanceTimersByTime(1000);
await waitFor(() => {
expect(entries.value).toHaveLength(2);
expect(entries.value[1].children).toHaveLength(1);
@@ -104,6 +110,9 @@ describe(useLogsExecutionData, () => {
const { loadSubExecution, entries } = useLogsExecutionData();
await loadSubExecution(entries.value[1]);
vi.advanceTimersByTime(1000);
await waitFor(() => expect(showErrorSpy).toHaveBeenCalled());
});
});

View File

@@ -9,6 +9,7 @@ import { parse } from 'flatted';
import { useToast } from '@/composables/useToast';
import type { LatestNodeInfo, LogEntry } from '../logs.types';
import { isChatNode } from '@/utils/aiUtils';
import { PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
export function useLogsExecutionData() {
const nodeHelpers = useNodeHelpers();
@@ -47,6 +48,7 @@ export function useLogsExecutionData() {
nodes.some(isChatNode),
),
);
const entries = computed<LogEntry[]>(() => {
if (!execData.value?.data || !workflow.value) {
return [];
@@ -59,7 +61,8 @@ export function useLogsExecutionData() {
subWorkflowExecData.value,
);
});
const updateInterval = computed(() => ((entries.value?.length ?? 0) > 10 ? 300 : 0));
const updateInterval = computed(() => ((entries.value?.length ?? 0) > 1 ? 1000 : 0));
function resetExecutionData() {
execData.value = undefined;
@@ -126,6 +129,15 @@ export function useLogsExecutionData() {
{ immediate: true },
);
watch(
() => workflowsStore.workflowId,
(newId) => {
if (newId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
resetExecutionData();
}
},
);
return {
execution: computed(() => execData.value),
entries,

View File

@@ -13,12 +13,13 @@ import { useCanvasStore } from '@/stores/canvas.store';
import { useLogsStore } from '@/stores/logs.store';
import { useUIStore } from '@/stores/ui.store';
import { shallowRef, watch } from 'vue';
import { computed, type ComputedRef } from 'vue';
import { computed } from 'vue';
import type { Ref, ComputedRef } from 'vue';
import { useWorkflowsStore } from '@/stores/workflows.store';
export function useLogsSelection(
execution: ComputedRef<IExecutionResponse | undefined>,
tree: ComputedRef<LogEntry[]>,
tree: Ref<LogEntry[]>,
flatLogEntries: ComputedRef<LogEntry[]>,
toggleExpand: (entry: LogEntry, expand?: boolean) => void,
) {

View File

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

View File

@@ -2,7 +2,7 @@ import type { LOG_DETAILS_PANEL_STATE, LOGS_PANEL_STATE } from '@/features/logs/
import type { INodeUi, LlmTokenUsageData } from '@/Interface';
import type { IRunExecutionData, ITaskData, Workflow } from 'n8n-workflow';
export interface LogEntry {
export type LogEntry = {
parent?: LogEntry;
node: INodeUi;
id: string;
@@ -13,7 +13,7 @@ export interface LogEntry {
workflow: Workflow;
executionId: string;
execution: IRunExecutionData;
}
};
export interface LogTreeCreationContext {
parent: LogEntry | undefined;

View File

@@ -192,24 +192,7 @@ function findLogEntryToAutoSelect(subTree: LogEntry[]): LogEntry | undefined {
return subTree[subTree.length - 1];
}
export function createLogTree(
workflow: Workflow,
response: IExecutionResponse,
workflows: Record<string, Workflow> = {},
subWorkflowData: Record<string, IRunExecutionData> = {},
) {
return createLogTreeRec({
parent: undefined,
ancestorRunIndexes: [],
executionId: response.id,
workflow,
workflows,
data: response.data ?? { resultData: { runData: {} } },
subWorkflowData,
});
}
function createLogTreeRec(context: LogTreeCreationContext) {
function createLogTreeRec(context: LogTreeCreationContext): LogEntry[] {
const runData = context.data.resultData.runData;
return Object.entries(runData)
@@ -258,6 +241,23 @@ function createLogTreeRec(context: LogTreeCreationContext) {
.sort(sortLogEntries);
}
export function createLogTree(
workflow: Workflow,
response: IExecutionResponse,
workflows: Record<string, Workflow> = {},
subWorkflowData: Record<string, IRunExecutionData> = {},
): LogEntry[] {
return createLogTreeRec({
parent: undefined,
ancestorRunIndexes: [],
executionId: response.id,
workflow,
workflows,
data: response.data ?? { resultData: { runData: {} } },
subWorkflowData,
});
}
export function findLogEntryRec(
isMatched: (entry: LogEntry) => boolean,
entries: LogEntry[],