mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-30 08:17:06 +02:00
fix: Fix inconsistent insight date range query behaviour (#21368)
This commit is contained in:
parent
342ec9c592
commit
440e83bdfc
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user