diff --git a/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts b/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts index 2a00b358a7f..68f97880670 100644 --- a/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts +++ b/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts @@ -406,7 +406,7 @@ export class InsightsByPeriodRepository extends Repository { .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'); diff --git a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.test.ts b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.test.ts index e1590bf3ac6..bd291171715 100644 --- a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.test.ts +++ b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.test.ts @@ -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', }); diff --git a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.vue b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.vue index bcb3c92b225..c11d5d7731c 100644 --- a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.vue +++ b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.vue @@ -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, diff --git a/packages/frontend/editor-ui/src/features/execution/insights/insights.api.test.ts b/packages/frontend/editor-ui/src/features/execution/insights/insights.api.test.ts new file mode 100644 index 00000000000..31202cb91ac --- /dev/null +++ b/packages/frontend/editor-ui/src/features/execution/insights/insights.api.test.ts @@ -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); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/execution/insights/insights.api.ts b/packages/frontend/editor-ui/src/features/execution/insights/insights.api.ts index ee9ab96829a..68e9b2c957b 100644 --- a/packages/frontend/editor-ui/src/features/execution/insights/insights.api.ts +++ b/packages/frontend/editor-ui/src/features/execution/insights/insights.api.ts @@ -8,26 +8,59 @@ import type { InsightsDateFilterDto, } from '@n8n/api-types'; +type SerializedDateFilter = Omit & { + startDate?: string; + endDate?: string; +}; + +export function serializeInsightsFilter< + T extends InsightsDateFilterDto | ListInsightsWorkflowQueryDto, +>(filter?: T): SerializedDateFilter | undefined { + if (!filter) return undefined; + + const { startDate, endDate, ...rest } = filter; + const serialized: SerializedDateFilter = { ...rest }; + + if (startDate) { + serialized.startDate = startDate.toISOString(); + } + if (endDate) { + serialized.endDate = endDate.toISOString(); + } + + return serialized; +} + export const fetchInsightsSummary = async ( context: IRestApiContext, filter?: InsightsDateFilterDto, ): Promise => - await makeRestApiRequest(context, 'GET', '/insights/summary', filter); + await makeRestApiRequest(context, 'GET', '/insights/summary', serializeInsightsFilter(filter)); export const fetchInsightsByTime = async ( context: IRestApiContext, filter?: InsightsDateFilterDto, ): Promise => - 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 => - 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 => - await makeRestApiRequest(context, 'GET', '/insights/by-workflow', filter); + await makeRestApiRequest( + context, + 'GET', + '/insights/by-workflow', + serializeInsightsFilter(filter), + );