fix(editor): Don't render now when startedAt is null (#15283)

This commit is contained in:
Danny Martini
2025-05-14 10:31:52 +02:00
committed by GitHub
parent 0cddc9576f
commit 44ecad5883
12 changed files with 150 additions and 13 deletions

View File

@@ -868,7 +868,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
private toSummary(execution: {
id: number | string;
createdAt?: Date | string;
startedAt?: Date | string;
startedAt: Date | string | null;
stoppedAt?: Date | string;
waitTill?: Date | string | null;
}): ExecutionSummary {
@@ -967,7 +967,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
if (lastId) qb.andWhere('execution.id < :lastId', { lastId });
if (query.order?.startedAt === 'DESC') {
qb.orderBy({ 'execution.startedAt': 'DESC' });
qb.orderBy({ 'COALESCE(execution.startedAt, execution.createdAt)': 'DESC' });
} else if (query.order?.top) {
qb.orderBy(`(CASE WHEN execution.status = '${query.order.top}' THEN 0 ELSE 1 END)`);
} else {

View File

@@ -0,0 +1,49 @@
import { ExecutionRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { createExecution } from '@test-integration/db/executions';
import { createWorkflow } from '@test-integration/db/workflows';
import * as testDb from './shared/test-db';
describe('UserRepository', () => {
let executionRepository: ExecutionRepository;
beforeAll(async () => {
await testDb.init();
executionRepository = Container.get(ExecutionRepository);
});
beforeEach(async () => {
await testDb.truncate(['ExecutionEntity']);
});
afterAll(async () => {
await testDb.terminate();
});
describe('findManyByRangeQuery', () => {
test('sort by `createdAt` if `startedAt` is null', async () => {
const workflow = await createWorkflow();
const execution1 = await createExecution({}, workflow);
const execution2 = await createExecution({ startedAt: null }, workflow);
const execution3 = await createExecution({}, workflow);
const executions = await executionRepository.findManyByRangeQuery({
workflowId: workflow.id,
accessibleWorkflowIds: [workflow.id],
kind: 'range',
range: { limit: 10 },
order: { startedAt: 'DESC' },
});
// Executions are returned in reverse order, and if `startedAt` is not
// defined `createdAt` is used.
expect(executions.map((e) => e.id)).toStrictEqual([
execution3.id,
execution2.id,
execution1.id,
]);
});
});
});

View File

@@ -39,7 +39,7 @@ export async function createExecution(
finished: finished ?? true,
mode: mode ?? 'manual',
createdAt: new Date(),
startedAt: startedAt ?? new Date(),
startedAt: startedAt === undefined ? new Date() : startedAt,
...(workflow !== undefined && { workflowId: workflow.id }),
stoppedAt: stoppedAt ?? new Date(),
waitTill: waitTill ?? null,

View File

@@ -157,4 +157,48 @@ describe('GlobalExecutionsListItem', () => {
expect(globalExecutionsListItemQueuedTooltipRenderSpy).toHaveBeenCalled();
});
afterEach(() => {
vitest.useRealTimers();
});
it('uses `createdAt` to calculate running time if `startedAt` is undefined', async () => {
const createdAt = new Date('2024-09-27T12:00:00Z');
const now = new Date('2024-09-27T12:30:00Z');
vitest.useFakeTimers({ now });
const { getByTestId } = renderComponent({
props: {
execution: { status: 'running', id: 123, workflowName: 'Test Workflow', createdAt },
workflowPermissions: {},
concurrencyCap: 5,
},
});
const executionTimeElement = getByTestId('execution-time');
expect(executionTimeElement).toBeVisible();
expect(executionTimeElement.textContent).toBe('-1727438401s');
});
it('uses `createdAt` to calculate running time if `startedAt` is undefined and `stoppedAt` is defined', async () => {
const createdAt = new Date('2024-09-27T12:00:00Z');
const now = new Date('2024-09-27T12:30:00Z');
vitest.useFakeTimers({ now });
const { getByTestId } = renderComponent({
props: {
execution: {
status: 'running',
id: 123,
workflowName: 'Test Workflow',
createdAt,
stoppedAt: now,
},
workflowPermissions: {},
concurrencyCap: 5,
},
});
const executionTimeElement = getByTestId('execution-time');
expect(executionTimeElement).toBeVisible();
expect(executionTimeElement.textContent).toBe('30:00m');
});
});

View File

@@ -130,7 +130,7 @@ const formattedStoppedAtDate = computed(() => {
return props.execution.stoppedAt
? locale.displayTimer(
new Date(props.execution.stoppedAt).getTime() -
new Date(props.execution.startedAt).getTime(),
new Date(props.execution.startedAt ?? props.execution.createdAt).getTime(),
true,
)
: '';
@@ -233,11 +233,11 @@ async function handleActionItemClick(commandData: Command) {
<td>
{{ formattedStartedAtDate }}
</td>
<td>
<td data-test-id="execution-time">
<template v-if="formattedStoppedAtDate">
{{ formattedStoppedAtDate }}
</template>
<ExecutionsTime v-else :start-time="execution.startedAt" />
<ExecutionsTime v-else :start-time="execution.startedAt ?? execution.createdAt" />
</td>
<td>
<span v-if="execution.id">{{ execution.id }}</span>

View File

@@ -172,4 +172,29 @@ describe('WorkflowExecutionsCard', () => {
expect(executionTimeElement).toBeVisible();
expect(executionTimeElement.textContent).toBe('27 Sep - Starting soon');
});
afterEach(() => {
vitest.useRealTimers();
});
test('uses `createdAt` to calculate running time if `startedAt` is undefined', () => {
const createdAt = new Date('2024-09-27T12:00:00Z');
const now = new Date('2024-09-27T12:30:00Z');
vitest.useFakeTimers({ now });
const props = {
execution: {
id: '1',
mode: 'webhook',
status: 'running',
createdAt: createdAt.toISOString(),
},
workflowPermissions: { execute: true },
};
const { getByTestId } = renderComponent({ props });
const executionTimeElement = getByTestId('execution-time-in-status');
expect(executionTimeElement).toBeVisible();
expect(executionTimeElement.textContent).toBe('for -1727438401s');
});
});

View File

@@ -108,9 +108,11 @@ function onRetryMenuItemSelect(action: string): void {
v-if="executionUIDetails.name === 'running'"
:color="isActive ? 'text-dark' : 'text-base'"
size="small"
data-test-id="execution-time-in-status"
>
{{ locale.baseText('executionDetails.runningTimeRunning') }}
<ExecutionsTime :start-time="execution.startedAt" />
<!-- Just here to make typescript happy, since `startedAt` will always be defined for running executions -->
<ExecutionsTime :start-time="execution.startedAt ?? execution.createdAt" />
</N8nText>
<N8nText
v-if="executionUIDetails.name === 'new' && execution.createdAt"

View File

@@ -2,6 +2,7 @@ import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import type { ExecutionSummary } from 'n8n-workflow';
import { i18n } from '@/plugins/i18n';
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
import { mock } from 'vitest-mock-extended';
const { resolve, track } = vi.hoisted(() => ({
resolve: vi.fn(),
@@ -48,6 +49,21 @@ describe('useExecutionHelpers()', () => {
expect(uiDetails.runningTime).toEqual('0s');
},
);
it('use `createdAt` if `startedAt` is null', async () => {
const date = new Date('2025-01-01T00:00:00.000Z');
const execution = mock<ExecutionSummary>({
id: '1',
startedAt: null,
createdAt: date,
stoppedAt: date,
status: 'error',
});
const { getUIDetails } = useExecutionHelpers();
const uiDetails = getUIDetails(execution);
expect(uiDetails.startTime).toEqual('Jan 1, 00:00:00');
});
});
describe('formatDate()', () => {

View File

@@ -25,7 +25,7 @@ export function useExecutionHelpers() {
const status = {
name: 'unknown',
createdAt: execution.createdAt?.toString() ?? '',
startTime: formatDate(execution.startedAt),
startTime: formatDate(execution.startedAt ?? execution.createdAt),
label: 'Status unknown',
runningTime: '',
showTimestamp: true,

View File

@@ -58,12 +58,13 @@ describe('executions.store', () => {
});
it('should delete executions started before given date', async () => {
const deleteBefore = mockExecutions[1].startedAt;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const deleteBefore = mockExecutions[1].startedAt!;
await executionsStore.deleteExecutions({ deleteBefore });
expect(executionsStore.executions.length).toBe(2);
executionsStore.executions.forEach(({ startedAt }) =>
expect(startedAt.getTime()).toBeGreaterThanOrEqual(deleteBefore.getTime()),
expect(startedAt?.getTime()).toBeGreaterThanOrEqual(deleteBefore.getTime()),
);
});

View File

@@ -70,7 +70,7 @@ export const useExecutionsStore = defineStore('executions', () => {
const currentExecutionsById = ref<Record<string, ExecutionSummaryWithScopes>>({});
const startedAtSortFn = (a: ExecutionSummary, b: ExecutionSummary) =>
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
new Date(b.startedAt ?? b.createdAt).getTime() - new Date(a.startedAt ?? a.createdAt).getTime();
/**
* Prioritize `running` over `new` executions, then sort by start timestamp.
@@ -268,7 +268,7 @@ export const useExecutionsStore = defineStore('executions', () => {
if (sendData.deleteBefore) {
const deleteBefore = new Date(sendData.deleteBefore);
allExecutions.value.forEach((execution) => {
if (new Date(execution.startedAt) < deleteBefore) {
if (new Date(execution.startedAt ?? execution.createdAt) < deleteBefore) {
removeExecution(execution.id);
}
});

View File

@@ -2645,7 +2645,7 @@ export interface ExecutionSummary {
retrySuccessId?: string | null;
waitTill?: Date;
createdAt: Date;
startedAt: Date;
startedAt: Date | null;
stoppedAt?: Date;
workflowId: string;
workflowName?: string;