feat(API): Implement BE api for insights data (#14064)

Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
Guillaume Jacquart
2025-04-02 16:34:57 +02:00
committed by GitHub
parent 501963f568
commit db381492a9
10 changed files with 667 additions and 61 deletions

View File

@@ -49,9 +49,9 @@ export const insightsByWorkflowDataSchemas = {
z z
.object({ .object({
workflowId: z.string(), workflowId: z.string(),
workflowName: z.string().optional(), workflowName: z.string(),
projectId: z.string().optional(), projectId: z.string(),
projectName: z.string().optional(), projectName: z.string(),
total: z.number(), total: z.number(),
succeeded: z.number(), succeeded: z.number(),
failed: z.number(), failed: z.number(),

View File

@@ -23,22 +23,19 @@ import {
import { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository'; import { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository';
import { InsightsService } from '../insights.service'; import { InsightsService } from '../insights.service';
async function truncateAll() {
const insightsRawRepository = Container.get(InsightsRawRepository);
const insightsMetadataRepository = Container.get(InsightsMetadataRepository);
const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository);
for (const repo of [
insightsRawRepository,
insightsMetadataRepository,
insightsByPeriodRepository,
]) {
await repo.delete({});
}
}
// Initialize DB once for all tests // Initialize DB once for all tests
beforeAll(async () => { beforeAll(async () => {
await testDb.init(); await testDb.init(['insights']);
});
beforeEach(async () => {
await testDb.truncate([
'InsightsRaw',
'InsightsByPeriod',
'InsightsMetadata',
'Workflow',
'Project',
]);
}); });
// Terminate DB once after all tests complete // Terminate DB once after all tests complete
@@ -60,8 +57,6 @@ describe('workflowExecuteAfterHandler', () => {
let workflow: IWorkflowDb & WorkflowEntity; let workflow: IWorkflowDb & WorkflowEntity;
beforeEach(async () => { beforeEach(async () => {
await truncateAll();
project = await createTeamProject(); project = await createTeamProject();
workflow = await createWorkflow( workflow = await createWorkflow(
{ {
@@ -261,10 +256,6 @@ describe('workflowExecuteAfterHandler', () => {
}); });
describe('compaction', () => { describe('compaction', () => {
beforeEach(async () => {
await truncateAll();
});
describe('compactRawToHour', () => { describe('compactRawToHour', () => {
type TestData = { type TestData = {
name: string; name: string;
@@ -731,8 +722,6 @@ describe('getInsightsSummary', () => {
let workflow: IWorkflowDb & WorkflowEntity; let workflow: IWorkflowDb & WorkflowEntity;
beforeEach(async () => { beforeEach(async () => {
await truncateAll();
project = await createTeamProject(); project = await createTeamProject();
workflow = await createWorkflow({}, project); workflow = await createWorkflow({}, project);
}); });
@@ -848,3 +837,323 @@ describe('getInsightsSummary', () => {
}); });
}); });
}); });
describe('getInsightsByWorkflow', () => {
let insightsService: InsightsService;
beforeAll(async () => {
insightsService = Container.get(InsightsService);
});
let project: 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);
workflow3 = await createWorkflow({}, project);
});
test('compacted data are are grouped by workflow correctly', async () => {
// ARRANGE
for (const workflow of [workflow1, workflow2]) {
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,
});
// 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,
failureRate: 2 / 7,
failed: 2,
runTime: 123,
succeeded: 5,
timeSaved: 0,
averageRunTime: 123 / 7,
});
expect(byWorkflow.data[1]).toEqual({
workflowId: workflow1.id,
workflowName: workflow1.name,
projectId: project.id,
projectName: project.name,
total: 6,
failureRate: 2 / 6,
failed: 2,
runTime: 123,
succeeded: 4,
timeSaved: 0,
averageRunTime: 123 / 6,
});
});
test('compacted data are grouped by workflow correctly with sorting', async () => {
// ARRANGE
for (const workflow of [workflow1, workflow2]) {
await createCompactedInsightsEvent(workflow, {
type: 'success',
value: workflow === workflow1 ? 1 : 2,
periodUnit: 'day',
periodStart: DateTime.utc(),
});
await createCompactedInsightsEvent(workflow, {
type: 'failure',
value: 2,
periodUnit: 'day',
periodStart: DateTime.utc(),
});
await createCompactedInsightsEvent(workflow, {
type: 'runtime_ms',
value: workflow === workflow1 ? 2 : 1,
periodUnit: 'day',
periodStart: DateTime.utc().minus({ days: 10 }),
});
}
// ACT
const byWorkflow = await insightsService.getInsightsByWorkflow({
maxAgeInDays: 14,
sortBy: 'runTime:desc',
});
// ASSERT
expect(byWorkflow.count).toEqual(2);
expect(byWorkflow.data).toHaveLength(2);
expect(byWorkflow.data[0].workflowId).toEqual(workflow1.id);
});
test('compacted data are grouped by workflow correctly with pagination', async () => {
// ARRANGE
for (const workflow of [workflow1, workflow2, workflow3]) {
await createCompactedInsightsEvent(workflow, {
type: 'success',
value: workflow === workflow1 ? 1 : workflow === workflow2 ? 2 : 3,
periodUnit: 'day',
periodStart: DateTime.utc(),
});
}
// ACT
const byWorkflow = await insightsService.getInsightsByWorkflow({
maxAgeInDays: 14,
sortBy: 'succeeded:desc',
skip: 1,
take: 1,
});
// ASSERT
expect(byWorkflow.count).toEqual(3);
expect(byWorkflow.data).toHaveLength(1);
expect(byWorkflow.data[0].workflowId).toEqual(workflow2.id);
});
test('compacted data are grouped by workflow correctly even with 0 data (check division by 0)', async () => {
// ACT
const byWorkflow = await insightsService.getInsightsByWorkflow({
maxAgeInDays: 14,
});
// ASSERT
expect(byWorkflow.count).toEqual(0);
expect(byWorkflow.data).toHaveLength(0);
});
});
describe('getInsightsByTime', () => {
let insightsService: InsightsService;
beforeAll(async () => {
insightsService = Container.get(InsightsService);
});
let project: Project;
let workflow1: IWorkflowDb & WorkflowEntity;
let workflow2: IWorkflowDb & WorkflowEntity;
beforeEach(async () => {
project = await createTeamProject();
workflow1 = await createWorkflow({}, project);
workflow2 = await createWorkflow({}, project);
});
test('returns empty array when no insights exist', async () => {
const byTime = await insightsService.getInsightsByTime({ maxAgeInDays: 14, periodUnit: 'day' });
expect(byTime).toEqual([]);
});
test('returns empty array when no insights in the time range exists', async () => {
await createCompactedInsightsEvent(workflow1, {
type: 'success',
value: 2,
periodUnit: 'day',
periodStart: DateTime.utc().minus({ days: 30 }),
});
const byTime = await insightsService.getInsightsByTime({ maxAgeInDays: 14, periodUnit: 'day' });
expect(byTime).toEqual([]);
});
test('compacted data are are grouped by time correctly', async () => {
// ARRANGE
for (const workflow of [workflow1, workflow2]) {
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' });
// 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,
});
});
});

View File

@@ -1,6 +1,14 @@
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm'; import {
BaseEntity,
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from '@n8n/typeorm';
import { UnexpectedError } from 'n8n-workflow'; import { UnexpectedError } from 'n8n-workflow';
import { InsightsMetadata } from './insights-metadata';
import type { PeriodUnit } from './insights-shared'; import type { PeriodUnit } from './insights-shared';
import { import {
isValidPeriodNumber, isValidPeriodNumber,
@@ -20,6 +28,10 @@ export class InsightsByPeriod extends BaseEntity {
@Column() @Column()
metaId: number; metaId: number;
@ManyToOne(() => InsightsMetadata)
@JoinColumn({ name: 'metaId' })
metadata: InsightsMetadata;
@Column({ name: 'type', type: 'int' }) @Column({ name: 'type', type: 'int' })
private type_: number; private type_: number;

View File

@@ -2,13 +2,14 @@ import { GlobalConfig } from '@n8n/config';
import { Container, Service } from '@n8n/di'; import { Container, Service } from '@n8n/di';
import type { SelectQueryBuilder } from '@n8n/typeorm'; import type { SelectQueryBuilder } from '@n8n/typeorm';
import { DataSource, Repository } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm';
import { DateTime } from 'luxon';
import { z } from 'zod'; import { z } from 'zod';
import { sql } from '@/utils/sql'; import { sql } from '@/utils/sql';
import { InsightsByPeriod } from '../entities/insights-by-period'; import { InsightsByPeriod } from '../entities/insights-by-period';
import type { PeriodUnit } from '../entities/insights-shared'; import type { PeriodUnit } from '../entities/insights-shared';
import { PeriodUnitToNumber } from '../entities/insights-shared'; import { PeriodUnitToNumber, TypeToNumber } from '../entities/insights-shared';
const dbType = Container.get(GlobalConfig).database.type; const dbType = Container.get(GlobalConfig).database.type;
@@ -22,6 +23,38 @@ const summaryParser = z
}) })
.array(); .array();
const aggregatedInsightsByWorkflowParser = z
.object({
workflowId: z.string(),
workflowName: z.string(),
projectId: z.string(),
projectName: z.string(),
total: z.union([z.number(), z.string()]).transform((value) => Number(value)),
succeeded: z.union([z.number(), z.string()]).transform((value) => Number(value)),
failed: z.union([z.number(), z.string()]).transform((value) => Number(value)),
failureRate: z.union([z.number(), z.string()]).transform((value) => Number(value)),
runTime: z.union([z.number(), z.string()]).transform((value) => Number(value)),
averageRunTime: z.union([z.number(), z.string()]).transform((value) => Number(value)),
timeSaved: z.union([z.number(), z.string()]).transform((value) => Number(value)),
})
.array();
const aggregatedInsightsByTimeParser = z
.object({
periodStart: z
.union([z.date(), z.string()])
.transform((value) =>
value instanceof Date
? value.toISOString()
: DateTime.fromSQL(value.toString(), { zone: 'utc' }).toISO(),
),
runTime: z.union([z.number(), z.string()]).transform((value) => Number(value)),
succeeded: z.union([z.number(), z.string()]).transform((value) => Number(value)),
failed: z.union([z.number(), z.string()]).transform((value) => Number(value)),
timeSaved: z.union([z.number(), z.string()]).transform((value) => Number(value)),
})
.array();
@Service() @Service()
export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> { export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
constructor(dataSource: DataSource) { constructor(dataSource: DataSource) {
@@ -207,6 +240,18 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
return result; return result;
} }
private getAgeLimitQuery(maxAgeInDays: number) {
if (maxAgeInDays === 0) {
return dbType === 'sqlite' ? "datetime('now')" : 'NOW()';
}
return dbType === 'sqlite'
? `datetime('now', '-${maxAgeInDays} days')`
: dbType === 'postgresdb'
? `NOW() - INTERVAL '${maxAgeInDays} days'`
: `DATE_SUB(NOW(), INTERVAL ${maxAgeInDays} DAY)`;
}
async getPreviousAndCurrentPeriodTypeAggregates(): Promise< async getPreviousAndCurrentPeriodTypeAggregates(): Promise<
Array<{ Array<{
period: 'previous' | 'current'; period: 'previous' | 'current';
@@ -214,26 +259,11 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
total_value: string | number; total_value: string | number;
}> }>
> { > {
const cte = const cte = sql`
dbType === 'sqlite'
? sql`
SELECT SELECT
datetime('now', '-7 days') AS current_start, ${this.getAgeLimitQuery(7)} AS current_start,
datetime('now') AS current_end, ${this.getAgeLimitQuery(0)} AS current_end,
datetime('now', '-14 days') AS previous_start ${this.getAgeLimitQuery(14)} AS previous_start
`
: dbType === 'postgresdb'
? sql`
SELECT
(NOW() - INTERVAL '7 days')::timestamptz AS current_start,
NOW()::timestamptz AS current_end,
(NOW() - INTERVAL '14 days')::timestamptz AS previous_start
`
: sql`
SELECT
DATE_SUB(NOW(), INTERVAL 7 DAY) AS current_start,
NOW() AS current_end,
DATE_SUB(NOW(), INTERVAL 14 DAY) AS previous_start
`; `;
const rawRows = await this.createQueryBuilder('insights') const rawRows = await this.createQueryBuilder('insights')
@@ -262,4 +292,86 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
return summaryParser.parse(rawRows); return summaryParser.parse(rawRows);
} }
private parseSortingParams(sortBy: string): [string, 'ASC' | 'DESC'] {
const [column, order] = sortBy.split(':');
return [column, order.toUpperCase() as 'ASC' | 'DESC'];
}
async getInsightsByWorkflow({
maxAgeInDays,
skip = 0,
take = 20,
sortBy = 'total:desc',
}: {
maxAgeInDays: number;
skip?: number;
take?: number;
sortBy?: 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)`;
const cte = sql`SELECT ${this.getAgeLimitQuery(maxAgeInDays)} AS start_date`;
const rawRowsQuery = this.createQueryBuilder('insights')
.addCommonTableExpression(cte, 'date_range')
.select([
'metadata.workflowId AS "workflowId"',
'metadata.workflowName AS "workflowName"',
'metadata.projectId AS "projectId"',
'metadata.projectName AS "projectName"',
`SUM(CASE WHEN insights.type = ${TypeToNumber.success} THEN value ELSE 0 END) AS "succeeded"`,
`SUM(CASE WHEN insights.type = ${TypeToNumber.failure} THEN value ELSE 0 END) AS "failed"`,
`SUM(CASE WHEN insights.type IN (${TypeToNumber.success}, ${TypeToNumber.failure}) THEN value ELSE 0 END) AS "total"`,
sql`CASE
WHEN ${sumOfExecutions} = 0 THEN 0
ELSE 1.0 * SUM(CASE WHEN insights.type = ${TypeToNumber.failure.toString()} THEN value ELSE 0 END) / ${sumOfExecutions}
END AS "failureRate"`,
`SUM(CASE WHEN insights.type = ${TypeToNumber.runtime_ms} THEN value ELSE 0 END) AS "runTime"`,
`SUM(CASE WHEN insights.type = ${TypeToNumber.time_saved_min} THEN value ELSE 0 END) AS "timeSaved"`,
sql`CASE
WHEN ${sumOfExecutions} = 0 THEN 0
ELSE 1.0 * SUM(CASE WHEN insights.type = ${TypeToNumber.runtime_ms.toString()} THEN value ELSE 0 END) / ${sumOfExecutions}
END AS "averageRunTime"`,
])
.innerJoin('insights.metadata', 'metadata')
// Use a cross join with the CTE
.innerJoin('date_range', 'date_range', '1=1')
.where('insights.periodStart >= date_range.start_date')
.groupBy('metadata.workflowId')
.addGroupBy('metadata.workflowName')
.addGroupBy('metadata.projectId')
.addGroupBy('metadata.projectName')
.orderBy(this.escapeField(sortField), sortOrder);
const count = (await rawRowsQuery.getRawMany()).length;
const rawRows = await rawRowsQuery.offset(skip).limit(take).getRawMany();
return { count, rows: aggregatedInsightsByWorkflowParser.parse(rawRows) };
}
async getInsightsByTime({
maxAgeInDays,
periodUnit,
}: { maxAgeInDays: number; periodUnit: PeriodUnit }) {
const cte = sql`SELECT ${this.getAgeLimitQuery(maxAgeInDays)} AS start_date`;
const rawRowsQuery = this.createQueryBuilder()
.addCommonTableExpression(cte, 'date_range')
.select([
`${this.getPeriodStartExpr(periodUnit)} as "periodStart"`,
`SUM(CASE WHEN type = ${TypeToNumber.runtime_ms} THEN value ELSE 0 END) AS "runTime"`,
`SUM(CASE WHEN type = ${TypeToNumber.success} THEN value ELSE 0 END) AS "succeeded"`,
`SUM(CASE WHEN type = ${TypeToNumber.failure} THEN value ELSE 0 END) AS "failed"`,
`SUM(CASE WHEN type = ${TypeToNumber.time_saved_min} THEN value ELSE 0 END) AS "timeSaved"`,
])
.innerJoin('date_range', 'date_range', '1=1')
.where(`${this.escapeField('periodStart')} >= date_range.start_date`)
.addGroupBy(this.getPeriodStartExpr(periodUnit))
.orderBy(this.getPeriodStartExpr(periodUnit), 'ASC');
const rawRows = await rawRowsQuery.getRawMany();
return aggregatedInsightsByTimeParser.parse(rawRows);
}
} }

View File

@@ -1,11 +1,17 @@
import type { InsightsSummary } from '@n8n/api-types'; import { ListInsightsWorkflowQueryDto } from '@n8n/api-types';
import type { InsightsSummary, InsightsByTime, InsightsByWorkflow } from '@n8n/api-types';
import { Get, GlobalScope, RestController } from '@/decorators'; import { Get, GlobalScope, Query, RestController } from '@/decorators';
import { paginationListQueryMiddleware } from '@/middlewares/list-query/pagination';
import { sortByQueryMiddleware } from '@/middlewares/list-query/sort-by';
import { AuthenticatedRequest } from '@/requests';
import { InsightsService } from './insights.service'; import { InsightsService } from './insights.service';
@RestController('/insights') @RestController('/insights')
export class InsightsController { export class InsightsController {
private readonly maxAgeInDaysFilteredInsights = 14;
constructor(private readonly insightsService: InsightsService) {} constructor(private readonly insightsService: InsightsService) {}
@Get('/summary') @Get('/summary')
@@ -13,4 +19,28 @@ export class InsightsController {
async getInsightsSummary(): Promise<InsightsSummary> { async getInsightsSummary(): Promise<InsightsSummary> {
return await this.insightsService.getInsightsSummary(); return await this.insightsService.getInsightsSummary();
} }
@Get('/by-workflow', { middlewares: [paginationListQueryMiddleware, sortByQueryMiddleware] })
@GlobalScope('insights:list')
async getInsightsByWorkflow(
_req: AuthenticatedRequest,
_res: Response,
@Query payload: ListInsightsWorkflowQueryDto,
): Promise<InsightsByWorkflow> {
return await this.insightsService.getInsightsByWorkflow({
maxAgeInDays: this.maxAgeInDaysFilteredInsights,
skip: payload.skip,
take: payload.take,
sortBy: payload.sortBy,
});
}
@Get('/by-time')
@GlobalScope('insights:list')
async getInsightsByTime(): Promise<InsightsByTime[]> {
return await this.insightsService.getInsightsByTime({
maxAgeInDays: this.maxAgeInDaysFilteredInsights,
periodUnit: 'day',
});
}
} }

View File

@@ -14,7 +14,7 @@ import { OnShutdown } from '@/decorators/on-shutdown';
import { InsightsMetadata } from '@/modules/insights/database/entities/insights-metadata'; import { InsightsMetadata } from '@/modules/insights/database/entities/insights-metadata';
import { InsightsRaw } from '@/modules/insights/database/entities/insights-raw'; import { InsightsRaw } from '@/modules/insights/database/entities/insights-raw';
import type { TypeUnit } from './database/entities/insights-shared'; import type { PeriodUnit, TypeUnit } from './database/entities/insights-shared';
import { NumberToType } from './database/entities/insights-shared'; import { NumberToType } from './database/entities/insights-shared';
import { InsightsByPeriodRepository } from './database/repositories/insights-by-period.repository'; import { InsightsByPeriodRepository } from './database/repositories/insights-by-period.repository';
import { InsightsRawRepository } from './database/repositories/insights-raw.repository'; import { InsightsRawRepository } from './database/repositories/insights-raw.repository';
@@ -300,4 +300,53 @@ export class InsightsService {
return result; return result;
} }
async getInsightsByWorkflow({
maxAgeInDays,
skip = 0,
take = 10,
sortBy = 'total:desc',
}: {
maxAgeInDays: number;
skip?: number;
take?: number;
sortBy?: string;
}) {
const { count, rows } = await this.insightsByPeriodRepository.getInsightsByWorkflow({
maxAgeInDays,
skip,
take,
sortBy,
});
return {
count,
data: rows,
};
}
async getInsightsByTime({
maxAgeInDays,
periodUnit,
}: { maxAgeInDays: number; periodUnit: PeriodUnit }) {
const rows = await this.insightsByPeriodRepository.getInsightsByTime({
maxAgeInDays,
periodUnit,
});
return rows.map((r) => {
const total = r.succeeded + r.failed;
return {
date: r.periodStart,
values: {
total,
succeeded: r.succeeded,
failed: r.failed,
failureRate: r.failed / total,
averageRunTime: r.runTime / total,
timeSaved: r.timeSaved,
},
};
});
}
} }

View File

@@ -0,0 +1,78 @@
import type { User } from '@/databases/entities/user';
import { Telemetry } from '@/telemetry';
import { mockInstance } from '@test/mocking';
import { createUser } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils';
let authOwnerAgent: SuperAgentTest;
let owner: User;
let admin: User;
let member: User;
mockInstance(Telemetry);
let agents: Record<string, SuperAgentTest> = {};
const testServer = utils.setupTestServer({
endpointGroups: ['insights', 'license', 'auth'],
enabledFeatures: [],
});
beforeAll(async () => {
owner = await createUser({ role: 'global:owner' });
admin = await createUser({ role: 'global:admin' });
member = await createUser({ role: 'global:member' });
authOwnerAgent = testServer.authAgentFor(owner);
agents.owner = authOwnerAgent;
agents.admin = testServer.authAgentFor(admin);
agents.member = testServer.authAgentFor(member);
});
describe('GET /insights routes work for owner and admins', () => {
test.each(['owner', 'member', 'admin'])(
'Call should work and return empty summary for user %s',
async (agentName: string) => {
const authAgent = agents[agentName];
await authAgent.get('/insights/summary').expect(agentName === 'member' ? 403 : 200);
await authAgent.get('/insights/by-time').expect(agentName === 'member' ? 403 : 200);
await authAgent.get('/insights/by-workflow').expect(agentName === 'member' ? 403 : 200);
},
);
});
describe('GET /insights/by-worklow', () => {
test('Call should work with valid query parameters', async () => {
await authOwnerAgent
.get('/insights/by-workflow')
.query({ skip: '10', take: '20', sortBy: 'total:desc' })
.expect(200);
});
test.each<{ skip: string; take?: string; sortBy?: string }>([
{
skip: 'not_a_number',
take: '20',
},
{
skip: '1',
take: 'not_a_number',
},
])(
'Call should return internal server error with invalid pagination query parameters',
async (queryParams) => {
await authOwnerAgent.get('/insights/by-workflow').query(queryParams).expect(500);
},
);
test('Call should return bad request with invalid sortby query parameters', async () => {
await authOwnerAgent
.get('/insights/by-workflow')
.query({
skip: '1',
take: '20',
sortBy: 'not_a_sortby',
})
.expect(400);
});
});

View File

@@ -11,13 +11,18 @@ import * as Db from '@/db';
export const testDbPrefix = 'n8n_test_'; export const testDbPrefix = 'n8n_test_';
type Extensions = 'insights';
let loadedExtensions: Extensions[] = [];
/** /**
* Initialize one test DB per suite run, with bootstrap connection if needed. * Initialize one test DB per suite run, with bootstrap connection if needed.
*/ */
export async function init() { export async function init(extensionNames: Extensions[] = []) {
const globalConfig = Container.get(GlobalConfig); const globalConfig = Container.get(GlobalConfig);
const dbType = globalConfig.database.type; const dbType = globalConfig.database.type;
const testDbName = `${testDbPrefix}${randomString(6, 10).toLowerCase()}_${Date.now()}`; const testDbName = `${testDbPrefix}${randomString(6, 10).toLowerCase()}_${Date.now()}`;
loadedExtensions = extensionNames;
if (dbType === 'postgresdb') { if (dbType === 'postgresdb') {
const bootstrapPostgres = await new Connection( const bootstrapPostgres = await new Connection(
@@ -98,16 +103,23 @@ export async function truncate(names: Array<(typeof repositories)[number]>) {
for (const name of names) { for (const name of names) {
let RepositoryClass: Class<Repository<object>>; let RepositoryClass: Class<Repository<object>>;
try { const fileName = `${kebabCase(name)}.repository`;
RepositoryClass = (await import(`@/databases/repositories/${kebabCase(name)}.repository`))[ const paths = [
`${name}Repository` `@/databases/repositories/${fileName}.ee`,
]; `@/databases/repositories/${fileName}`,
} catch (e) {
RepositoryClass = (await import(`@/databases/repositories/${kebabCase(name)}.repository.ee`))[
`${name}Repository`
]; ];
for (const extension of loadedExtensions) {
paths.push(
`@/modules/${extension}/database/repositories/${fileName}`,
`@/modules/${extension}/database/repositories/${fileName}.ee`,
);
} }
RepositoryClass = (await Promise.any(paths.map(async (path) => await import(path))))[
`${name}Repository`
];
await Container.get(RepositoryClass).delete({}); await Container.get(RepositoryClass).delete({});
} }
} }

View File

@@ -44,7 +44,8 @@ type EndpointGroup =
| 'apiKeys' | 'apiKeys'
| 'evaluation' | 'evaluation'
| 'ai' | 'ai'
| 'folder'; | 'folder'
| 'insights';
export interface SetupProps { export interface SetupProps {
endpointGroups?: EndpointGroup[]; endpointGroups?: EndpointGroup[];

View File

@@ -290,6 +290,9 @@ export const setupTestServer = ({
case 'folder': case 'folder':
await import('@/controllers/folder.controller'); await import('@/controllers/folder.controller');
case 'insights':
await import('@/modules/insights/insights.controller');
} }
} }