diff --git a/packages/@n8n/api-types/src/dto/insights/__tests__/date-filter.dto.test.ts b/packages/@n8n/api-types/src/dto/insights/__tests__/date-filter.dto.test.ts index e95f37f22cc..ae95c4ec8d3 100644 --- a/packages/@n8n/api-types/src/dto/insights/__tests__/date-filter.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/insights/__tests__/date-filter.dto.test.ts @@ -17,6 +17,15 @@ describe('InsightsDateFilterDto', () => { dateRange: 'week', }, }, + { + name: 'valid projectId', + request: { + projectId: '2gQLpmP5V4wOY627', + }, + parsedResult: { + projectId: '2gQLpmP5V4wOY627', + }, + }, ])('should validate $name', ({ request, parsedResult }) => { const result = InsightsDateFilterDto.safeParse(request); expect(result.success).toBe(true); @@ -35,6 +44,13 @@ describe('InsightsDateFilterDto', () => { }, expectedErrorPath: ['dateRange'], }, + { + name: 'invalid projectId value', + request: { + projectId: 10, + }, + expectedErrorPath: ['projectId'], + }, ])('should fail validation for $name', ({ request, expectedErrorPath }) => { const result = InsightsDateFilterDto.safeParse(request); diff --git a/packages/@n8n/api-types/src/dto/insights/__tests__/list-workflow-query.dto.test.ts b/packages/@n8n/api-types/src/dto/insights/__tests__/list-workflow-query.dto.test.ts index 39d8c4070d0..585e8916610 100644 --- a/packages/@n8n/api-types/src/dto/insights/__tests__/list-workflow-query.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/insights/__tests__/list-workflow-query.dto.test.ts @@ -77,6 +77,15 @@ describe('ListInsightsWorkflowQueryDto', () => { sortBy: 'total:asc', }, }, + { + name: 'valid projectId', + request: { + projectId: '2gQLpmP5V4wOY627', + }, + parsedResult: { + projectId: '2gQLpmP5V4wOY627', + }, + }, ])('should validate $name', ({ request, parsedResult }) => { const result = ListInsightsWorkflowQueryDto.safeParse(request); expect(result.success).toBe(true); @@ -111,6 +120,13 @@ describe('ListInsightsWorkflowQueryDto', () => { }, expectedErrorPath: ['sortBy'], }, + { + name: 'invalid projectId value', + request: { + projectId: 10, + }, + expectedErrorPath: ['projectId'], + }, ])('should fail validation for $name', ({ request, expectedErrorPath }) => { const result = ListInsightsWorkflowQueryDto.safeParse(request); diff --git a/packages/@n8n/api-types/src/dto/insights/date-filter.dto.ts b/packages/@n8n/api-types/src/dto/insights/date-filter.dto.ts index 4ec08fa00c2..bc11018ff14 100644 --- a/packages/@n8n/api-types/src/dto/insights/date-filter.dto.ts +++ b/packages/@n8n/api-types/src/dto/insights/date-filter.dto.ts @@ -10,4 +10,5 @@ const dateRange = z.enum(VALID_DATE_RANGE_OPTIONS).optional(); export class InsightsDateFilterDto extends Z.class({ dateRange, + projectId: z.string().optional(), }) {} diff --git a/packages/@n8n/api-types/src/dto/insights/list-workflow-query.dto.ts b/packages/@n8n/api-types/src/dto/insights/list-workflow-query.dto.ts index 092b2bf26c8..5fd40f2b33f 100644 --- a/packages/@n8n/api-types/src/dto/insights/list-workflow-query.dto.ts +++ b/packages/@n8n/api-types/src/dto/insights/list-workflow-query.dto.ts @@ -38,4 +38,5 @@ export class ListInsightsWorkflowQueryDto extends Z.class({ take: createTakeValidator(MAX_ITEMS_PER_PAGE), dateRange: InsightsDateFilterDto.shape.dateRange, sortBy: sortByValidator, + projectId: z.string().optional(), }) {} diff --git a/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts index 3231d9db367..2e5b4e0fe49 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts @@ -25,10 +25,15 @@ afterAll(async () => { describe('InsightsController', () => { const insightsByPeriodRepository = mockInstance(InsightsByPeriodRepository); let controller: InsightsController; + beforeAll(async () => { controller = Container.get(InsightsController); }); + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('getInsightsSummary', () => { it('should return default insights if no data', async () => { // ARRANGE @@ -41,6 +46,10 @@ describe('InsightsController', () => { ); // ASSERT + expect( + insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates, + ).toHaveBeenCalledWith({ periodLengthInDays: 7 }); + expect(response).toEqual({ total: { deviation: null, unit: 'count', value: 0 }, failed: { deviation: null, unit: 'count', value: 0 }, @@ -66,6 +75,10 @@ describe('InsightsController', () => { ); // ASSERT + expect( + insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates, + ).toHaveBeenCalledWith({ periodLengthInDays: 7 }); + expect(response).toEqual({ total: { deviation: null, unit: 'count', value: 30 }, failed: { deviation: null, unit: 'count', value: 10 }, @@ -95,6 +108,46 @@ describe('InsightsController', () => { ); // ASSERT + expect( + insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates, + ).toHaveBeenCalledWith({ periodLengthInDays: 7 }); + + expect(response).toEqual({ + total: { deviation: 10, unit: 'count', value: 30 }, + failed: { deviation: 6, unit: 'count', value: 10 }, + failureRate: { deviation: 0.333 - 0.2, unit: 'ratio', value: 0.333 }, + averageRunTime: { deviation: 300 / 30 - 40 / 20, unit: 'millisecond', value: 10 }, + timeSaved: { deviation: 5, unit: 'minute', value: 10 }, + }); + }); + + it('should use the query filters when provided', async () => { + // ARRANGE + insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([ + { period: 'previous', type: TypeToNumber.success, total_value: 16 }, + { period: 'previous', type: TypeToNumber.failure, total_value: 4 }, + { period: 'previous', type: TypeToNumber.runtime_ms, total_value: 40 }, + { period: 'previous', type: TypeToNumber.time_saved_min, total_value: 5 }, + { period: 'current', type: TypeToNumber.success, total_value: 20 }, + { period: 'current', type: TypeToNumber.failure, total_value: 10 }, + { period: 'current', type: TypeToNumber.runtime_ms, total_value: 300 }, + { period: 'current', type: TypeToNumber.time_saved_min, total_value: 10 }, + ]); + + // ACT + const response = await controller.getInsightsSummary( + mock(), + mock(), + { dateRange: 'month', projectId: 'test-project' }, + ); + + expect( + insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates, + ).toHaveBeenCalledWith({ + periodLengthInDays: 30, + projectId: 'test-project', + }); + expect(response).toEqual({ total: { deviation: 10, unit: 'count', value: 30 }, failed: { deviation: 6, unit: 'count', value: 10 }, @@ -105,7 +158,157 @@ describe('InsightsController', () => { }); }); + describe('getInsightsByWorkflow', () => { + const mockRows = [ + { + workflowId: 'workflow-1', + workflowName: 'Workflow A', + projectId: 'project-1', + projectName: 'Project Alpha', + total: 30664, + succeeded: 30077, + failed: 587, + failureRate: 0.019142968953822073, + runTime: 1587932583, + averageRunTime: 51784.91335116097, + timeSaved: 0, + }, + { + workflowId: 'workflow-2', + workflowName: 'Workflow B', + projectId: 'project-1', + projectName: 'Project Alpha', + total: 27332, + succeeded: 27332, + failed: 0, + failureRate: 0, + runTime: 1880, + averageRunTime: 0.06878384311429826, + timeSaved: 0, + }, + { + workflowId: 'workflow-3', + workflowName: 'Workflow C', + projectId: 'project-1', + projectName: 'Project Alpha', + total: 15167, + succeeded: 14956, + failed: 211, + failureRate: 0.013911782158633876, + runTime: 899930618, + averageRunTime: 59334.78064218369, + timeSaved: 0, + }, + ]; + + it('should return empty insights by workflow if no data', async () => { + // ARRANGE + insightsByPeriodRepository.getInsightsByWorkflow.mockResolvedValue({ count: 0, rows: [] }); + + // ACT + const response = await controller.getInsightsByWorkflow( + mock(), + mock(), + { + skip: 0, + take: 5, + sortBy: 'total:desc', + dateRange: 'week', + }, + ); + + // ASSERT + expect(insightsByPeriodRepository.getInsightsByWorkflow).toHaveBeenCalledWith({ + maxAgeInDays: 7, + skip: 0, + take: 5, + sortBy: 'total:desc', + }); + + expect(response).toEqual({ count: 0, data: [] }); + }); + + it('should return insights by workflow', async () => { + // ARRANGE + insightsByPeriodRepository.getInsightsByWorkflow.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + + // ACT + const response = await controller.getInsightsByWorkflow( + mock(), + mock(), + { + skip: 0, + take: 5, + sortBy: 'total:desc', + dateRange: 'week', + }, + ); + + // ASSERT + expect(insightsByPeriodRepository.getInsightsByWorkflow).toHaveBeenCalledWith({ + maxAgeInDays: 7, + skip: 0, + take: 5, + sortBy: 'total:desc', + }); + + expect(response).toEqual({ count: 3, data: mockRows }); + }); + + it('should use the query filters when provided', async () => { + // ARRANGE + insightsByPeriodRepository.getInsightsByWorkflow.mockResolvedValue({ + count: mockRows.length, + rows: mockRows, + }); + + // ACT + const response = await controller.getInsightsByWorkflow( + mock(), + mock(), + { + skip: 5, + take: 10, + sortBy: 'failureRate:asc', + dateRange: 'month', + projectId: 'test-project', + }, + ); + + // ASSERT + expect(insightsByPeriodRepository.getInsightsByWorkflow).toHaveBeenCalledWith({ + maxAgeInDays: 30, + skip: 5, + take: 10, + sortBy: 'failureRate:asc', + projectId: 'test-project', + }); + + expect(response).toEqual({ count: 3, data: mockRows }); + }); + }); + describe('getInsightsByTime', () => { + const mockData = [ + { + periodStart: '2023-10-01T00:00:00.000Z', + succeeded: 10, + timeSaved: 0, + failed: 2, + runTime: 10, + }, + { + periodStart: '2023-10-02T00:00:00.000Z', + succeeded: 12, + timeSaved: 0, + failed: 4, + runTime: 10, + }, + ]; + it('should return insights by time with empty data', async () => { // ARRANGE insightsByPeriodRepository.getInsightsByTime.mockResolvedValue([]); @@ -118,27 +321,16 @@ describe('InsightsController', () => { ); // ASSERT + expect(insightsByPeriodRepository.getInsightsByTime).toHaveBeenCalledWith({ + insightTypes: ['time_saved_min', 'runtime_ms', 'success', 'failure'], + maxAgeInDays: 7, + periodUnit: 'day', + }); expect(response).toEqual([]); }); - it('should return insights by time with all data', async () => { + it('should return insights by time', async () => { // ARRANGE - const mockData = [ - { - periodStart: '2023-10-01T00:00:00.000Z', - succeeded: 10, - timeSaved: 0, - failed: 2, - runTime: 10, - }, - { - periodStart: '2023-10-02T00:00:00.000Z', - succeeded: 12, - timeSaved: 0, - failed: 4, - runTime: 10, - }, - ]; insightsByPeriodRepository.getInsightsByTime.mockResolvedValue(mockData); // ACT @@ -149,6 +341,57 @@ describe('InsightsController', () => { ); // ASSERT + expect(insightsByPeriodRepository.getInsightsByTime).toHaveBeenCalledWith({ + insightTypes: ['time_saved_min', 'runtime_ms', 'success', 'failure'], + maxAgeInDays: 365, + periodUnit: 'week', + }); + + expect(response).toEqual([ + { + date: '2023-10-01T00:00:00.000Z', + values: { + succeeded: 10, + timeSaved: 0, + failed: 2, + averageRunTime: 10 / 12, + failureRate: 2 / 12, + total: 12, + }, + }, + { + date: '2023-10-02T00:00:00.000Z', + values: { + succeeded: 12, + timeSaved: 0, + failed: 4, + averageRunTime: 10 / 16, + failureRate: 4 / 16, + total: 16, + }, + }, + ]); + }); + + it('should use the projectId query filters when provided', async () => { + // ARRANGE + insightsByPeriodRepository.getInsightsByTime.mockResolvedValue(mockData); + + // ACT + const response = await controller.getInsightsByTime( + mock(), + mock(), + { dateRange: 'month', projectId: 'test-project' }, + ); + + // ASSERT + expect(insightsByPeriodRepository.getInsightsByTime).toHaveBeenCalledWith({ + insightTypes: ['time_saved_min', 'runtime_ms', 'success', 'failure'], + maxAgeInDays: 30, + periodUnit: 'day', + projectId: 'test-project', + }); + expect(response).toEqual([ { date: '2023-10-01T00:00:00.000Z', @@ -177,18 +420,19 @@ describe('InsightsController', () => { }); describe('getTimeSavedInsightsByTime', () => { + const mockData = [ + { + periodStart: '2023-10-01T00:00:00.000Z', + timeSaved: 0, + }, + { + periodStart: '2023-10-02T00:00:00.000Z', + timeSaved: 2, + }, + ]; + it('should return insights by time with limited data', async () => { // ARRANGE - const mockData = [ - { - periodStart: '2023-10-01T00:00:00.000Z', - timeSaved: 0, - }, - { - periodStart: '2023-10-02T00:00:00.000Z', - timeSaved: 2, - }, - ]; insightsByPeriodRepository.getInsightsByTime.mockResolvedValue(mockData); // ACT @@ -199,6 +443,47 @@ describe('InsightsController', () => { ); // ASSERT + expect(insightsByPeriodRepository.getInsightsByTime).toHaveBeenCalledWith({ + insightTypes: ['time_saved_min'], + maxAgeInDays: 7, + periodUnit: 'day', + }); + + expect(response).toEqual([ + { + date: '2023-10-01T00:00:00.000Z', + values: { + timeSaved: 0, + }, + }, + { + date: '2023-10-02T00:00:00.000Z', + values: { + timeSaved: 2, + }, + }, + ]); + }); + + it('should use the projectId query filters when provided', async () => { + // ARRANGE + insightsByPeriodRepository.getInsightsByTime.mockResolvedValue(mockData); + + // ACT + const response = await controller.getTimeSavedInsightsByTime( + mock(), + mock(), + { dateRange: 'month', projectId: 'test-project' }, + ); + + // ASSERT + expect(insightsByPeriodRepository.getInsightsByTime).toHaveBeenCalledWith({ + insightTypes: ['time_saved_min'], + maxAgeInDays: 30, + periodUnit: 'day', + projectId: 'test-project', + }); + expect(response).toEqual([ { date: '2023-10-01T00:00:00.000Z', diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts index 628a5ea5880..a67e47e87dd 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts @@ -258,6 +258,93 @@ describe('getInsightsSummary', () => { total: { deviation: -1, unit: 'count', value: 4 }, }); }); + + test('filter by projectId', async () => { + // ARRANGE + const otherProject = await createTeamProject(); + const otherWorkflow = await createWorkflow({}, otherProject); + + // last 6 days + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc(), + }); + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ day: 2 }), + }); + await createCompactedInsightsEvent(workflow, { + type: 'failure', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc(), + }); + + await createCompactedInsightsEvent(otherWorkflow, { + type: 'runtime_ms', + value: 430, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ day: 1 }), + }); + await createCompactedInsightsEvent(otherWorkflow, { + type: 'failure', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ day: 3 }), + }); + + // last 12 days + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ days: 10 }), + }); + await createCompactedInsightsEvent(workflow, { + type: 'runtime_ms', + value: 123, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ days: 10 }), + }); + await createCompactedInsightsEvent(otherWorkflow, { + type: 'runtime_ms', + value: 45, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ days: 11 }), + }); + //Outside range should not be taken into account + await createCompactedInsightsEvent(workflow, { + type: 'runtime_ms', + value: 123, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ days: 13 }), + }); + await createCompactedInsightsEvent(otherWorkflow, { + type: 'runtime_ms', + value: 100, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ days: 20 }), + }); + + // ACT + const summary = await insightsService.getInsightsSummary({ + periodLengthInDays: 6, + projectId: project.id, + }); + + // ASSERT + expect(summary).toEqual({ + averageRunTime: { deviation: -123, unit: 'millisecond', value: 0 }, + failed: { deviation: 1, unit: 'count', value: 1 }, + failureRate: { deviation: 0.333, unit: 'ratio', value: 0.333 }, + timeSaved: { deviation: 0, unit: 'minute', value: 0 }, + total: { deviation: 2, unit: 'count', value: 3 }, + }); + }); }); describe('getInsightsByWorkflow', () => { @@ -267,15 +354,20 @@ describe('getInsightsByWorkflow', () => { }); let project: Project; + let project2: Project; let workflow1: IWorkflowDb & WorkflowEntity; let workflow2: IWorkflowDb & WorkflowEntity; let workflow3: IWorkflowDb & WorkflowEntity; + let workflow4: IWorkflowDb & WorkflowEntity; beforeEach(async () => { project = await createTeamProject(); workflow1 = await createWorkflow({}, project); workflow2 = await createWorkflow({}, project); workflow3 = await createWorkflow({}, project); + + project2 = await createTeamProject(); + workflow4 = await createWorkflow({}, project2); }); test('compacted data are are grouped by workflow correctly', async () => { @@ -431,6 +523,100 @@ describe('getInsightsByWorkflow', () => { expect(byWorkflow.data[0].workflowId).toEqual(workflow2.id); }); + test('compacted data are grouped by workflow correctly with projectId filter', async () => { + // ARRANGE + for (const workflow of [workflow1, workflow2, workflow4]) { + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: workflow === workflow1 ? 1 : 2, + periodUnit: 'day', + periodStart: DateTime.utc(), + }); + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ day: 2 }), + }); + await createCompactedInsightsEvent(workflow, { + type: 'failure', + value: 2, + periodUnit: 'day', + periodStart: DateTime.utc(), + }); + // last 14 days + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ days: 10 }), + }); + await createCompactedInsightsEvent(workflow, { + type: 'runtime_ms', + value: 123, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ days: 10 }), + }); + + // Barely in range insight (should be included) + // 1 hour before 14 days ago + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'hour', + periodStart: DateTime.utc().minus({ days: 13, hours: 23 }), + }); + + // Out of date range insight (should not be included) + // 14 days ago + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ days: 14 }), + }); + } + + // ACT + const byWorkflow = await insightsService.getInsightsByWorkflow({ + maxAgeInDays: 14, + projectId: project.id, + }); + + // ASSERT + expect(byWorkflow.count).toEqual(2); + expect(byWorkflow.data).toHaveLength(2); + + // expect first workflow to be workflow 2, because it has a bigger total (default sorting) + expect(byWorkflow.data[0]).toMatchObject({ + workflowId: workflow2.id, + workflowName: workflow2.name, + projectId: project.id, + projectName: project.name, + total: 7, + failed: 2, + runTime: 123, + succeeded: 5, + timeSaved: 0, + }); + expect(byWorkflow.data[0].failureRate).toBeCloseTo(2 / 7); + expect(byWorkflow.data[0].averageRunTime).toBeCloseTo(123 / 7); + + expect(byWorkflow.data[1]).toMatchObject({ + workflowId: workflow1.id, + workflowName: workflow1.name, + projectId: project.id, + projectName: project.name, + total: 6, + failed: 2, + runTime: 123, + succeeded: 4, + timeSaved: 0, + }); + expect(byWorkflow.data[1].failureRate).toBeCloseTo(2 / 6); + expect(byWorkflow.data[1].averageRunTime).toBeCloseTo(123 / 6); + }); + test('compacted data are grouped by workflow correctly even with 0 data (check division by 0)', async () => { // ACT const byWorkflow = await insightsService.getInsightsByWorkflow({ @@ -450,13 +636,18 @@ describe('getInsightsByTime', () => { }); let project: Project; + let otherProject: Project; let workflow1: IWorkflowDb & WorkflowEntity; let workflow2: IWorkflowDb & WorkflowEntity; + let workflow3: IWorkflowDb & WorkflowEntity; beforeEach(async () => { project = await createTeamProject(); workflow1 = await createWorkflow({}, project); workflow2 = await createWorkflow({}, project); + + otherProject = await createTeamProject(); + workflow3 = await createWorkflow({}, otherProject); }); test('returns empty array when no insights exist', async () => { @@ -625,6 +816,113 @@ describe('getInsightsByTime', () => { failed: 4, }); }); + + test('compacted data are are grouped by time correctly with projectId filter', async () => { + // ARRANGE + for (const workflow of [workflow1, workflow2, workflow3]) { + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: workflow === workflow1 ? 1 : 2, + periodUnit: 'day', + periodStart: DateTime.utc(), + }); + // Check that hourly data is grouped together with the previous daily data + await createCompactedInsightsEvent(workflow, { + type: 'failure', + value: 2, + periodUnit: 'hour', + periodStart: DateTime.utc(), + }); + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ day: 2 }), + }); + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ days: 10 }), + }); + await createCompactedInsightsEvent(workflow, { + type: 'runtime_ms', + value: workflow === workflow1 ? 10 : 20, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ days: 10 }), + }); + + // Barely in range insight (should be included) + // 1 hour before 14 days ago + await createCompactedInsightsEvent(workflow, { + type: workflow === workflow1 ? 'success' : 'failure', + value: 1, + periodUnit: 'hour', + periodStart: DateTime.utc().minus({ days: 13, hours: 23 }), + }); + + // Out of date range insight (should not be included) + // 14 days ago + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ days: 14 }), + }); + } + + // ACT + const byTime = await insightsService.getInsightsByTime({ + maxAgeInDays: 14, + periodUnit: 'day', + projectId: project.id, + }); + + // ASSERT + expect(byTime).toHaveLength(4); + + // expect date to be sorted by oldest first + expect(byTime[0].date).toEqual(DateTime.utc().minus({ days: 14 }).startOf('day').toISO()); + expect(byTime[1].date).toEqual(DateTime.utc().minus({ days: 10 }).startOf('day').toISO()); + expect(byTime[2].date).toEqual(DateTime.utc().minus({ days: 2 }).startOf('day').toISO()); + expect(byTime[3].date).toEqual(DateTime.utc().startOf('day').toISO()); + + expect(byTime[0].values).toEqual({ + total: 2, + succeeded: 1, + failed: 1, + failureRate: 0.5, + averageRunTime: 0, + timeSaved: 0, + }); + + expect(byTime[1].values).toEqual({ + total: 2, + succeeded: 2, + failed: 0, + failureRate: 0, + averageRunTime: 15, + timeSaved: 0, + }); + + expect(byTime[2].values).toEqual({ + total: 2, + succeeded: 2, + failed: 0, + failureRate: 0, + averageRunTime: 0, + timeSaved: 0, + }); + + expect(byTime[3].values).toEqual({ + total: 7, + succeeded: 3, + failed: 4, + failureRate: 4 / 7, + averageRunTime: 0, + timeSaved: 0, + }); + }); }); describe('getAvailableDateRanges', () => { 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 d0dac1cd5da..7ad38ee6b32 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 @@ -282,7 +282,8 @@ export class InsightsByPeriodRepository extends Repository { async getPreviousAndCurrentPeriodTypeAggregates({ periodLengthInDays, - }: { periodLengthInDays: number }): Promise< + projectId, + }: { periodLengthInDays: number; projectId?: string }): Promise< Array<{ period: 'previous' | 'current'; type: 0 | 1 | 2 | 3; @@ -296,7 +297,7 @@ export class InsightsByPeriodRepository extends Repository { ${this.getAgeLimitQuery(periodLengthInDays * 2)} AS previous_start `; - const rawRows = await this.createQueryBuilder('insights') + const rawRowsQuery = this.createQueryBuilder('insights') .addCommonTableExpression(cte, 'date_ranges') .select( sql` @@ -312,13 +313,19 @@ export class InsightsByPeriodRepository extends Repository { .addSelect('SUM(value)', 'total_value') // Use a cross join with the CTE .innerJoin('date_ranges', 'date_ranges', '1=1') - // Filter to only include data from the last 14 days .where('insights.periodStart >= date_ranges.previous_start') .andWhere('insights.periodStart <= date_ranges.current_end') // Group by both period and type .groupBy('period') - .addGroupBy('insights.type') - .getRawMany(); + .addGroupBy('insights.type'); + + if (projectId) { + rawRowsQuery + .innerJoin('insights.metadata', 'metadata') + .andWhere('metadata.projectId = :projectId', { projectId }); + } + + const rawRows = await rawRowsQuery.getRawMany(); return summaryParser.parse(rawRows); } @@ -333,11 +340,13 @@ export class InsightsByPeriodRepository extends Repository { skip = 0, take = 20, sortBy = 'total:desc', + projectId, }: { maxAgeInDays: number; skip?: number; take?: number; sortBy?: string; + projectId?: string; }) { const [sortField, sortOrder] = this.parseSortingParams(sortBy); const sumOfExecutions = sql`SUM(CASE WHEN insights.type IN (${TypeToNumber.success.toString()}, ${TypeToNumber.failure.toString()}) THEN value ELSE 0 END)`; @@ -375,6 +384,10 @@ export class InsightsByPeriodRepository extends Repository { .addGroupBy('metadata.projectName') .orderBy(this.escapeField(sortField), sortOrder); + if (projectId) { + rawRowsQuery.andWhere('metadata.projectId = :projectId', { projectId }); + } + const count = (await rawRowsQuery.getRawMany()).length; const rawRows = await rawRowsQuery.offset(skip).limit(take).getRawMany(); @@ -385,14 +398,20 @@ export class InsightsByPeriodRepository extends Repository { maxAgeInDays, periodUnit, insightTypes, - }: { maxAgeInDays: number; periodUnit: PeriodUnit; insightTypes: TypeUnit[] }) { + projectId, + }: { + maxAgeInDays: number; + periodUnit: PeriodUnit; + insightTypes: TypeUnit[]; + projectId?: string; + }) { const cte = sql`SELECT ${this.getAgeLimitQuery(maxAgeInDays)} AS start_date`; const typesAggregation = insightTypes.map((type) => { - return `SUM(CASE WHEN type = ${TypeToNumber[type]} THEN value ELSE 0 END) AS "${displayTypeName[TypeToNumber[type]]}"`; + return `SUM(CASE WHEN insights.type = ${TypeToNumber[type]} THEN value ELSE 0 END) AS "${displayTypeName[TypeToNumber[type]]}"`; }); - const rawRowsQuery = this.createQueryBuilder() + const rawRowsQuery = this.createQueryBuilder('insights') .addCommonTableExpression(cte, 'date_range') .select([`${this.getPeriodStartExpr(periodUnit)} as "periodStart"`, ...typesAggregation]) .innerJoin('date_range', 'date_range', '1=1') @@ -400,6 +419,12 @@ export class InsightsByPeriodRepository extends Repository { .groupBy(this.getPeriodStartExpr(periodUnit)) .orderBy(this.getPeriodStartExpr(periodUnit), 'ASC'); + if (projectId) { + rawRowsQuery + .innerJoin('insights.metadata', 'metadata') + .andWhere('metadata.projectId = :projectId', { projectId }); + } + const rawRows = await rawRowsQuery.getRawMany(); return aggregatedInsightsByTimeParser.parse(rawRows); diff --git a/packages/cli/src/modules/insights/insights.controller.ts b/packages/cli/src/modules/insights/insights.controller.ts index 78e890eb45b..d049e8a6346 100644 --- a/packages/cli/src/modules/insights/insights.controller.ts +++ b/packages/cli/src/modules/insights/insights.controller.ts @@ -40,11 +40,12 @@ export class InsightsController { async getInsightsSummary( _req: AuthenticatedRequest, _res: Response, - @Query payload: InsightsDateFilterDto = { dateRange: 'week' }, + @Query query: InsightsDateFilterDto = { dateRange: 'week' }, ): Promise { - const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity(payload); + const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity(query); return await this.insightsService.getInsightsSummary({ periodLengthInDays: dateRangeAndMaxAgeInDays.maxAgeInDays, + projectId: query.projectId, }); } @@ -64,6 +65,7 @@ export class InsightsController { skip: payload.skip, take: payload.take, sortBy: payload.sortBy, + projectId: payload.projectId, }); } @@ -82,6 +84,7 @@ export class InsightsController { return (await this.insightsService.getInsightsByTime({ maxAgeInDays: dateRangeAndMaxAgeInDays.maxAgeInDays, periodUnit: dateRangeAndMaxAgeInDays.granularity, + projectId: payload.projectId, })) as InsightsByTime[]; } @@ -104,6 +107,7 @@ export class InsightsController { maxAgeInDays: dateRangeAndMaxAgeInDays.maxAgeInDays, periodUnit: dateRangeAndMaxAgeInDays.granularity, insightTypes: ['time_saved_min'], + projectId: payload.projectId, })) as RestrictedInsightsByTime[]; } } diff --git a/packages/cli/src/modules/insights/insights.service.ts b/packages/cli/src/modules/insights/insights.service.ts index 512096ed3b6..7a956afdfcd 100644 --- a/packages/cli/src/modules/insights/insights.service.ts +++ b/packages/cli/src/modules/insights/insights.service.ts @@ -62,9 +62,11 @@ export class InsightsService { async getInsightsSummary({ periodLengthInDays, - }: { periodLengthInDays: number }): Promise { + projectId, + }: { periodLengthInDays: number; projectId?: string }): Promise { const rows = await this.insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates({ periodLengthInDays, + projectId, }); // Initialize data structures for both periods @@ -151,17 +153,20 @@ export class InsightsService { skip = 0, take = 10, sortBy = 'total:desc', + projectId, }: { maxAgeInDays: number; skip?: number; take?: number; sortBy?: string; + projectId?: string; }) { const { count, rows } = await this.insightsByPeriodRepository.getInsightsByWorkflow({ maxAgeInDays, skip, take, sortBy, + projectId, }); return { @@ -175,11 +180,18 @@ export class InsightsService { periodUnit, // Default to all insight types insightTypes = Object.keys(TypeToNumber) as TypeUnit[], - }: { maxAgeInDays: number; periodUnit: PeriodUnit; insightTypes?: TypeUnit[] }) { + projectId, + }: { + maxAgeInDays: number; + periodUnit: PeriodUnit; + insightTypes?: TypeUnit[]; + projectId?: string; + }) { const rows = await this.insightsByPeriodRepository.getInsightsByTime({ maxAgeInDays, periodUnit, insightTypes, + projectId, }); return rows.map((r) => {