fix: Fix inconsistent insight date range query behaviour (#21368)

This commit is contained in:
Irénée 2025-11-03 09:39:14 +00:00 committed by GitHub
parent 342ec9c592
commit 440e83bdfc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 473 additions and 19 deletions

View File

@ -406,7 +406,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
.select([`${this.getPeriodStartExpr(periodUnit)} as "periodStart"`, ...typesAggregation])
.innerJoin('date_ranges', 'date_ranges', '1=1')
.where(`${this.escapeField('periodStart')} >= date_ranges.start_date`)
.andWhere(`${this.escapeField('periodStart')} <= date_ranges.end_date`)
.andWhere(`${this.escapeField('periodStart')} < date_ranges.end_date`)
.groupBy(this.getPeriodStartExpr(periodUnit))
.orderBy(this.getPeriodStartExpr(periodUnit), 'ASC');

View File

@ -211,13 +211,13 @@ const date = new Date(2000, 11, 19);
// Test helper constants
const DEFAULT_DATE_RANGE = {
startDate: '2000-12-13T00:00:00.000Z',
endDate: '2000-12-19T00:00:00.000Z',
startDate: new Date('2000-12-13T00:00:00.000Z'),
endDate: new Date('2000-12-19T00:00:00.000Z'),
};
const SINGLE_DAY_RANGE = {
startDate: '2000-12-19T00:00:00.000Z',
endDate: '2000-12-19T00:00:00.000Z',
startDate: new Date('2000-12-19T00:00:00.000Z'),
endDate: new Date('2000-12-19T00:00:00.000Z'),
};
const DEFAULT_TABLE_PARAMS = {
@ -396,8 +396,8 @@ describe('InsightsDashboard', () => {
await userEvent.click(dayOption);
expect(mockTelemetry.track).toHaveBeenCalledWith('User updated insights time range', {
end_date: SINGLE_DAY_RANGE.endDate,
start_date: SINGLE_DAY_RANGE.startDate,
end_date: SINGLE_DAY_RANGE.endDate.toISOString(),
start_date: SINGLE_DAY_RANGE.startDate.toISOString(),
range_length_days: 1,
type: 'preset',
});

View File

@ -6,7 +6,7 @@ import type { ProjectSharingData } from '@/features/collaboration/projects/proje
import InsightsSummary from '@/features/execution/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/execution/insights/insights.store';
import type { DateValue } from '@internationalized/date';
import { getLocalTimeZone, today } from '@internationalized/date';
import { getLocalTimeZone, now, toCalendarDateTime, today } from '@internationalized/date';
import type { InsightsDateRange, InsightsSummaryType } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import {
@ -122,6 +122,20 @@ const range = shallowRef<{
end: maxDate.copy(),
});
/**
* Converts the range to a UTC date range with the current time
*/
const getFilteredRange = () => {
const timezone = getLocalTimeZone();
const startDate = toCalendarDateTime(range.value.start, now(timezone)).toDate(timezone);
const endDate = toCalendarDateTime(range.value.end, now(timezone)).toDate(timezone);
return {
startDate,
endDate,
};
};
const fetchPaginatedTableData = ({
page = 0,
itemsPerPage = 25,
@ -138,9 +152,7 @@ const fetchPaginatedTableData = ({
const sortKey = sortBy.length ? transformFilter(sortBy[0]) : undefined;
const startDate = range.value.start?.toDate(getLocalTimeZone()).toISOString() as unknown as Date;
const endDate = range.value.end?.toDate(getLocalTimeZone()).toISOString() as unknown as Date;
const { startDate, endDate } = getFilteredRange();
void insightsStore.table.execute(0, {
skip,
take,
@ -156,10 +168,7 @@ watch(
() => {
sortTableBy.value = [{ id: props.insightType, desc: true }];
const startDate = range.value.start
?.toDate(getLocalTimeZone())
.toISOString() as unknown as Date;
const endDate = range.value.end?.toDate(getLocalTimeZone()).toISOString() as unknown as Date;
const { startDate, endDate } = getFilteredRange();
if (insightsStore.isSummaryEnabled) {
void insightsStore.summary.execute(0, {
@ -174,6 +183,7 @@ watch(
endDate,
projectId: selectedProject.value?.id,
});
if (insightsStore.isDashboardEnabled) {
fetchPaginatedTableData({
sortBy: sortTableBy.value,

View File

@ -0,0 +1,411 @@
import {
fetchInsightsSummary,
fetchInsightsByTime,
fetchInsightsTimeSaved,
fetchInsightsByWorkflow,
serializeInsightsFilter,
} from '@/features/execution/insights/insights.api';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type {
InsightsSummary,
InsightsByTime,
InsightsByWorkflow,
ListInsightsWorkflowQueryDto,
InsightsDateFilterDto,
} from '@n8n/api-types';
import { expect } from 'vitest';
vi.mock('@n8n/rest-api-client', () => ({
makeRestApiRequest: vi.fn(),
}));
describe('insights.api', () => {
const mockContext = { baseUrl: '/rest', pushRef: 'test-push-ref' };
afterEach(() => {
vi.clearAllMocks();
});
describe('serializeInsightsFilter', () => {
it('should return undefined when filter is undefined', () => {
const result = serializeInsightsFilter(undefined);
expect(result).toBeUndefined();
});
it('should serialize filter with Date objects to ISO strings', () => {
const startDate = new Date('2025-01-01T00:00:00.000Z');
const endDate = new Date('2025-01-31T23:59:59.999Z');
const filter: InsightsDateFilterDto = {
startDate,
endDate,
};
const result = serializeInsightsFilter(filter);
expect(result).toEqual({
startDate: '2025-01-01T00:00:00.000Z',
endDate: '2025-01-31T23:59:59.999Z',
});
});
it('should handle filter with only startDate', () => {
const startDate = new Date('2025-01-01T00:00:00.000Z');
const filter: InsightsDateFilterDto = {
startDate,
};
const result = serializeInsightsFilter(filter);
expect(result).toEqual({
startDate: '2025-01-01T00:00:00.000Z',
});
});
it('should handle filter with only endDate', () => {
const endDate = new Date('2025-01-31T23:59:59.999Z');
const filter: InsightsDateFilterDto = {
endDate,
};
const result = serializeInsightsFilter(filter);
expect(result).toEqual({
endDate: '2025-01-31T23:59:59.999Z',
});
});
it('should preserve additional filter properties', () => {
const startDate = new Date('2025-01-01T00:00:00.000Z');
const endDate = new Date('2025-01-31T23:59:59.999Z');
const filter: ListInsightsWorkflowQueryDto = {
startDate,
endDate,
take: 10,
skip: 0,
sortBy: 'workflowName:asc',
};
const result = serializeInsightsFilter(filter);
expect(result).toEqual({
startDate: '2025-01-01T00:00:00.000Z',
endDate: '2025-01-31T23:59:59.999Z',
take: 10,
skip: 0,
sortBy: 'workflowName:asc',
});
});
it('should handle empty filter object', () => {
const filter: InsightsDateFilterDto = {};
const result = serializeInsightsFilter(filter);
expect(result).toEqual({});
});
});
describe('fetchInsightsSummary', () => {
it('should make GET request to /insights/summary without filter', async () => {
const mockSummary: InsightsSummary = {
total: { value: 100, deviation: null, unit: 'count' },
failed: { value: 20, deviation: 5, unit: 'count' },
failureRate: { value: 0.2, deviation: -0.05, unit: 'ratio' },
timeSaved: { value: 120, deviation: 30, unit: 'minute' },
averageRunTime: { value: 5000, deviation: 200, unit: 'millisecond' },
};
vi.mocked(makeRestApiRequest).mockResolvedValue(mockSummary);
const result = await fetchInsightsSummary(mockContext);
expect(makeRestApiRequest).toHaveBeenCalledWith(
mockContext,
'GET',
'/insights/summary',
undefined,
);
expect(result).toEqual(mockSummary);
});
it('should make GET request to /insights/summary with serialized filter', async () => {
const startDate = new Date('2025-01-01T00:00:00.000Z');
const endDate = new Date('2025-01-31T23:59:59.999Z');
const filter: InsightsDateFilterDto = {
startDate,
endDate,
};
const mockSummary: InsightsSummary = {
total: { value: 50, deviation: null, unit: 'count' },
failed: { value: 10, deviation: 2, unit: 'count' },
failureRate: { value: 0.2, deviation: -0.03, unit: 'ratio' },
timeSaved: { value: 60, deviation: 15, unit: 'minute' },
averageRunTime: { value: 3000, deviation: 100, unit: 'millisecond' },
};
vi.mocked(makeRestApiRequest).mockResolvedValue(mockSummary);
const result = await fetchInsightsSummary(mockContext, filter);
expect(makeRestApiRequest).toHaveBeenCalledWith(mockContext, 'GET', '/insights/summary', {
startDate: '2025-01-01T00:00:00.000Z',
endDate: '2025-01-31T23:59:59.999Z',
});
expect(result).toEqual(mockSummary);
});
});
describe('fetchInsightsByTime', () => {
it('should make GET request to /insights/by-time without filter', async () => {
const mockInsightsByTime: InsightsByTime[] = [
{
date: '2025-01-01T00:00:00.000Z',
values: {
total: 10,
succeeded: 8,
failed: 2,
failureRate: 0.2,
averageRunTime: 5000,
timeSaved: 30,
},
},
{
date: '2025-01-02T00:00:00.000Z',
values: {
total: 15,
succeeded: 12,
failed: 3,
failureRate: 0.2,
averageRunTime: 4500,
timeSaved: 45,
},
},
];
vi.mocked(makeRestApiRequest).mockResolvedValue(mockInsightsByTime);
const result = await fetchInsightsByTime(mockContext);
expect(makeRestApiRequest).toHaveBeenCalledWith(
mockContext,
'GET',
'/insights/by-time',
undefined,
);
expect(result).toEqual(mockInsightsByTime);
});
it('should make GET request to /insights/by-time with serialized filter', async () => {
const startDate = new Date('2025-01-01T00:00:00.000Z');
const endDate = new Date('2025-01-31T23:59:59.999Z');
const filter: InsightsDateFilterDto = {
startDate,
endDate,
};
const mockInsightsByTime: InsightsByTime[] = [
{
date: '2025-01-15T00:00:00.000Z',
values: {
total: 20,
succeeded: 18,
failed: 2,
failureRate: 0.1,
averageRunTime: 4000,
timeSaved: 60,
},
},
];
vi.mocked(makeRestApiRequest).mockResolvedValue(mockInsightsByTime);
const result = await fetchInsightsByTime(mockContext, filter);
expect(makeRestApiRequest).toHaveBeenCalledWith(mockContext, 'GET', '/insights/by-time', {
startDate: '2025-01-01T00:00:00.000Z',
endDate: '2025-01-31T23:59:59.999Z',
});
expect(result).toEqual(mockInsightsByTime);
});
});
describe('fetchInsightsTimeSaved', () => {
it('should make GET request to /insights/by-time/time-saved without filter', async () => {
const mockTimeSaved: InsightsByTime[] = [
{
date: '2025-01-01T00:00:00.000Z',
values: {
total: 10,
succeeded: 8,
failed: 2,
failureRate: 0.2,
averageRunTime: 5000,
timeSaved: 60,
},
},
{
date: '2025-01-02T00:00:00.000Z',
values: {
total: 15,
succeeded: 12,
failed: 3,
failureRate: 0.2,
averageRunTime: 4500,
timeSaved: 90,
},
},
];
vi.mocked(makeRestApiRequest).mockResolvedValue(mockTimeSaved);
const result = await fetchInsightsTimeSaved(mockContext);
expect(makeRestApiRequest).toHaveBeenCalledWith(
mockContext,
'GET',
'/insights/by-time/time-saved',
undefined,
);
expect(result).toEqual(mockTimeSaved);
});
it('should make GET request to /insights/by-time/time-saved with serialized filter', async () => {
const startDate = new Date('2025-01-01T00:00:00.000Z');
const endDate = new Date('2025-01-31T23:59:59.999Z');
const filter: InsightsDateFilterDto = {
startDate,
endDate,
};
const mockTimeSaved: InsightsByTime[] = [
{
date: '2025-01-15T00:00:00.000Z',
values: {
total: 20,
succeeded: 18,
failed: 2,
failureRate: 0.1,
averageRunTime: 4000,
timeSaved: 120,
},
},
];
vi.mocked(makeRestApiRequest).mockResolvedValue(mockTimeSaved);
const result = await fetchInsightsTimeSaved(mockContext, filter);
expect(makeRestApiRequest).toHaveBeenCalledWith(
mockContext,
'GET',
'/insights/by-time/time-saved',
{
startDate: '2025-01-01T00:00:00.000Z',
endDate: '2025-01-31T23:59:59.999Z',
},
);
expect(result).toEqual(mockTimeSaved);
});
});
describe('fetchInsightsByWorkflow', () => {
it('should make GET request to /insights/by-workflow without filter', async () => {
const mockInsightsByWorkflow: InsightsByWorkflow = {
data: [
{
workflowId: 'workflow-1',
workflowName: 'Test Workflow 1',
projectId: 'project-1',
projectName: 'Test Project',
total: 50,
succeeded: 40,
failed: 10,
failureRate: 0.2,
runTime: 250000,
averageRunTime: 5000,
timeSaved: 150,
},
],
count: 1,
};
vi.mocked(makeRestApiRequest).mockResolvedValue(mockInsightsByWorkflow);
const result = await fetchInsightsByWorkflow(mockContext);
expect(makeRestApiRequest).toHaveBeenCalledWith(
mockContext,
'GET',
'/insights/by-workflow',
undefined,
);
expect(result).toEqual(mockInsightsByWorkflow);
});
it('should make GET request to /insights/by-workflow with serialized filter', async () => {
const startDate = new Date('2025-01-01T00:00:00.000Z');
const endDate = new Date('2025-01-31T23:59:59.999Z');
const filter: ListInsightsWorkflowQueryDto = {
startDate,
endDate,
take: 10,
skip: 0,
sortBy: 'workflowName:asc',
};
const mockInsightsByWorkflow: InsightsByWorkflow = {
data: [
{
workflowId: 'workflow-1',
workflowName: 'Test Workflow 1',
projectId: 'project-1',
projectName: 'Test Project 1',
total: 30,
succeeded: 25,
failed: 5,
failureRate: 0.17,
runTime: 120000,
averageRunTime: 4000,
timeSaved: 90,
},
{
workflowId: 'workflow-2',
workflowName: 'Test Workflow 2',
projectId: 'project-2',
projectName: 'Test Project 2',
total: 20,
succeeded: 18,
failed: 2,
failureRate: 0.1,
runTime: 80000,
averageRunTime: 4000,
timeSaved: 60,
},
],
count: 2,
};
vi.mocked(makeRestApiRequest).mockResolvedValue(mockInsightsByWorkflow);
const result = await fetchInsightsByWorkflow(mockContext, filter);
expect(makeRestApiRequest).toHaveBeenCalledWith(mockContext, 'GET', '/insights/by-workflow', {
startDate: '2025-01-01T00:00:00.000Z',
endDate: '2025-01-31T23:59:59.999Z',
take: 10,
skip: 0,
sortBy: 'workflowName:asc',
});
expect(result).toEqual(mockInsightsByWorkflow);
});
});
});

View File

@ -8,26 +8,59 @@ import type {
InsightsDateFilterDto,
} from '@n8n/api-types';
type SerializedDateFilter<T> = Omit<T, 'startDate' | 'endDate'> & {
startDate?: string;
endDate?: string;
};
export function serializeInsightsFilter<
T extends InsightsDateFilterDto | ListInsightsWorkflowQueryDto,
>(filter?: T): SerializedDateFilter<T> | undefined {
if (!filter) return undefined;
const { startDate, endDate, ...rest } = filter;
const serialized: SerializedDateFilter<T> = { ...rest };
if (startDate) {
serialized.startDate = startDate.toISOString();
}
if (endDate) {
serialized.endDate = endDate.toISOString();
}
return serialized;
}
export const fetchInsightsSummary = async (
context: IRestApiContext,
filter?: InsightsDateFilterDto,
): Promise<InsightsSummary> =>
await makeRestApiRequest(context, 'GET', '/insights/summary', filter);
await makeRestApiRequest(context, 'GET', '/insights/summary', serializeInsightsFilter(filter));
export const fetchInsightsByTime = async (
context: IRestApiContext,
filter?: InsightsDateFilterDto,
): Promise<InsightsByTime[]> =>
await makeRestApiRequest(context, 'GET', '/insights/by-time', filter);
await makeRestApiRequest(context, 'GET', '/insights/by-time', serializeInsightsFilter(filter));
export const fetchInsightsTimeSaved = async (
context: IRestApiContext,
filter?: InsightsDateFilterDto,
): Promise<InsightsByTime[]> =>
await makeRestApiRequest(context, 'GET', '/insights/by-time/time-saved', filter);
await makeRestApiRequest(
context,
'GET',
'/insights/by-time/time-saved',
serializeInsightsFilter(filter),
);
export const fetchInsightsByWorkflow = async (
context: IRestApiContext,
filter?: ListInsightsWorkflowQueryDto,
): Promise<InsightsByWorkflow> =>
await makeRestApiRequest(context, 'GET', '/insights/by-workflow', filter);
await makeRestApiRequest(
context,
'GET',
'/insights/by-workflow',
serializeInsightsFilter(filter),
);