mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(core): Implement granularity and date range filtering on insights (#14841)
This commit is contained in:
committed by
GitHub
parent
5ff073bd7b
commit
28596a633e
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
import { InsightsDateFilterDto } from './date-filter.dto';
|
||||
import { paginationSchema } from '../pagination/pagination.dto';
|
||||
|
||||
const VALID_SORT_OPTIONS = [
|
||||
@@ -30,5 +31,6 @@ const sortByValidator = z
|
||||
|
||||
export class ListInsightsWorkflowQueryDto extends Z.class({
|
||||
...paginationSchema,
|
||||
dateRange: InsightsDateFilterDto.shape.dateRange,
|
||||
sortBy: sortByValidator,
|
||||
}) {}
|
||||
|
||||
@@ -29,11 +29,12 @@ export {
|
||||
SOURCE_CONTROL_FILE_TYPE,
|
||||
} from './schemas/source-controlled-file.schema';
|
||||
|
||||
export type {
|
||||
InsightsSummaryType,
|
||||
InsightsSummaryUnit,
|
||||
InsightsSummary,
|
||||
InsightsByWorkflow,
|
||||
InsightsByTime,
|
||||
InsightsDateRange,
|
||||
export {
|
||||
type InsightsSummaryType,
|
||||
type InsightsSummaryUnit,
|
||||
type InsightsSummary,
|
||||
type InsightsByWorkflow,
|
||||
type InsightsByTime,
|
||||
type InsightsDateRange,
|
||||
INSIGHTS_DATE_RANGE_KEYS,
|
||||
} from './schemas/insights.schema';
|
||||
|
||||
@@ -82,13 +82,21 @@ export const insightsByTimeDataSchemas = {
|
||||
})
|
||||
.strict(),
|
||||
} as const;
|
||||
|
||||
export const insightsByTimeSchema = z.object(insightsByTimeDataSchemas).strict();
|
||||
export type InsightsByTime = z.infer<typeof insightsByTimeSchema>;
|
||||
|
||||
export const INSIGHTS_DATE_RANGE_KEYS = [
|
||||
'day',
|
||||
'week',
|
||||
'2weeks',
|
||||
'month',
|
||||
'quarter',
|
||||
'6months',
|
||||
'year',
|
||||
] as const;
|
||||
export const insightsDateRangeSchema = z
|
||||
.object({
|
||||
key: z.enum(['day', 'week', '2weeks', 'month', 'quarter', '6months', 'year']),
|
||||
key: z.enum(INSIGHTS_DATE_RANGE_KEYS),
|
||||
licensed: z.boolean(),
|
||||
granularity: z.enum(['hour', 'day', 'week']),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import type { AuthenticatedRequest } from '@/requests';
|
||||
import { mockInstance } from '@test/mocking';
|
||||
import * as testDb from '@test-integration/test-db';
|
||||
|
||||
@@ -30,7 +32,10 @@ describe('InsightsController', () => {
|
||||
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([]);
|
||||
|
||||
// ACT
|
||||
const response = await controller.getInsightsSummary();
|
||||
const response = await controller.getInsightsSummary(
|
||||
mock<AuthenticatedRequest>(),
|
||||
mock<Response>(),
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(response).toEqual({
|
||||
@@ -52,7 +57,10 @@ describe('InsightsController', () => {
|
||||
]);
|
||||
|
||||
// ACT
|
||||
const response = await controller.getInsightsSummary();
|
||||
const response = await controller.getInsightsSummary(
|
||||
mock<AuthenticatedRequest>(),
|
||||
mock<Response>(),
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(response).toEqual({
|
||||
@@ -78,7 +86,10 @@ describe('InsightsController', () => {
|
||||
]);
|
||||
|
||||
// ACT
|
||||
const response = await controller.getInsightsSummary();
|
||||
const response = await controller.getInsightsSummary(
|
||||
mock<AuthenticatedRequest>(),
|
||||
mock<Response>(),
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(response).toEqual({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { InsightsDateRange } from '@n8n/api-types';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -517,6 +518,7 @@ describe('getAvailableDateRanges', () => {
|
||||
{ key: '2weeks', licensed: true, granularity: 'day' },
|
||||
{ key: 'month', licensed: true, granularity: 'day' },
|
||||
{ key: 'quarter', licensed: true, granularity: 'week' },
|
||||
{ key: '6months', licensed: true, granularity: 'week' },
|
||||
{ key: 'year', licensed: true, granularity: 'week' },
|
||||
]);
|
||||
});
|
||||
@@ -533,6 +535,7 @@ describe('getAvailableDateRanges', () => {
|
||||
{ key: '2weeks', licensed: true, granularity: 'day' },
|
||||
{ key: 'month', licensed: true, granularity: 'day' },
|
||||
{ key: 'quarter', licensed: true, granularity: 'week' },
|
||||
{ key: '6months', licensed: true, granularity: 'week' },
|
||||
{ key: 'year', licensed: true, granularity: 'week' },
|
||||
]);
|
||||
});
|
||||
@@ -549,6 +552,7 @@ describe('getAvailableDateRanges', () => {
|
||||
{ key: '2weeks', licensed: true, granularity: 'day' },
|
||||
{ key: 'month', licensed: true, granularity: 'day' },
|
||||
{ key: 'quarter', licensed: false, granularity: 'week' },
|
||||
{ key: '6months', licensed: false, granularity: 'week' },
|
||||
{ key: 'year', licensed: false, granularity: 'week' },
|
||||
]);
|
||||
});
|
||||
@@ -565,6 +569,7 @@ describe('getAvailableDateRanges', () => {
|
||||
{ key: '2weeks', licensed: false, granularity: 'day' },
|
||||
{ key: 'month', licensed: false, granularity: 'day' },
|
||||
{ key: 'quarter', licensed: false, granularity: 'week' },
|
||||
{ key: '6months', licensed: false, granularity: 'week' },
|
||||
{ key: 'year', licensed: false, granularity: 'week' },
|
||||
]);
|
||||
});
|
||||
@@ -581,7 +586,84 @@ describe('getAvailableDateRanges', () => {
|
||||
{ key: '2weeks', licensed: true, granularity: 'day' },
|
||||
{ key: 'month', licensed: true, granularity: 'day' },
|
||||
{ key: 'quarter', licensed: true, granularity: 'week' },
|
||||
{ key: '6months', licensed: false, granularity: 'week' },
|
||||
{ key: 'year', licensed: false, granularity: 'week' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMaxAgeInDaysAndGranularity', () => {
|
||||
let insightsService: InsightsService;
|
||||
let licenseMock: jest.Mocked<License>;
|
||||
|
||||
beforeAll(() => {
|
||||
licenseMock = mock<License>();
|
||||
insightsService = new InsightsService(
|
||||
mock<InsightsByPeriodRepository>(),
|
||||
mock<InsightsCompactionService>(),
|
||||
mock<InsightsCollectionService>(),
|
||||
licenseMock,
|
||||
mock<Logger>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns correct maxAgeInDays and granularity for a valid licensed date range', () => {
|
||||
licenseMock.getInsightsMaxHistory.mockReturnValue(365);
|
||||
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
|
||||
|
||||
const result = insightsService.getMaxAgeInDaysAndGranularity('month');
|
||||
|
||||
expect(result).toEqual({
|
||||
key: 'month',
|
||||
licensed: true,
|
||||
granularity: 'day',
|
||||
maxAgeInDays: 30,
|
||||
});
|
||||
});
|
||||
|
||||
test('throws an error if the date range is not available', () => {
|
||||
licenseMock.getInsightsMaxHistory.mockReturnValue(365);
|
||||
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
|
||||
|
||||
expect(() => {
|
||||
insightsService.getMaxAgeInDaysAndGranularity('invalidKey' as InsightsDateRange['key']);
|
||||
}).toThrowError('The selected date range is not available');
|
||||
});
|
||||
|
||||
test('throws an error if the date range is not licensed', () => {
|
||||
licenseMock.getInsightsMaxHistory.mockReturnValue(30);
|
||||
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(false);
|
||||
|
||||
expect(() => {
|
||||
insightsService.getMaxAgeInDaysAndGranularity('year');
|
||||
}).toThrowError('The selected date range exceeds the maximum history allowed by your license.');
|
||||
});
|
||||
|
||||
test('returns correct maxAgeInDays and granularity for a valid date range with hourly data disabled', () => {
|
||||
licenseMock.getInsightsMaxHistory.mockReturnValue(90);
|
||||
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(false);
|
||||
|
||||
const result = insightsService.getMaxAgeInDaysAndGranularity('quarter');
|
||||
|
||||
expect(result).toEqual({
|
||||
key: 'quarter',
|
||||
licensed: true,
|
||||
granularity: 'week',
|
||||
maxAgeInDays: 90,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns correct maxAgeInDays and granularity for a valid date range with unlimited history', () => {
|
||||
licenseMock.getInsightsMaxHistory.mockReturnValue(-1);
|
||||
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
|
||||
|
||||
const result = insightsService.getMaxAgeInDaysAndGranularity('day');
|
||||
|
||||
expect(result).toEqual({
|
||||
key: 'day',
|
||||
licensed: true,
|
||||
granularity: 'hour',
|
||||
maxAgeInDays: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
import { ListInsightsWorkflowQueryDto } from '@n8n/api-types';
|
||||
import { InsightsDateFilterDto, ListInsightsWorkflowQueryDto } from '@n8n/api-types';
|
||||
import type { InsightsSummary, InsightsByTime, InsightsByWorkflow } from '@n8n/api-types';
|
||||
import { Get, GlobalScope, Licensed, Query, RestController } from '@n8n/decorators';
|
||||
import type { UserError } from 'n8n-workflow';
|
||||
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { AuthenticatedRequest } from '@/requests';
|
||||
|
||||
import { InsightsService } from './insights.service';
|
||||
|
||||
@RestController('/insights')
|
||||
export class InsightsController {
|
||||
private readonly maxAgeInDaysFilteredInsights = 7;
|
||||
|
||||
constructor(private readonly insightsService: InsightsService) {}
|
||||
|
||||
/**
|
||||
* This method is used to transform the date range from the request payload into a maximum age in days.
|
||||
* It throws a ForbiddenError if the date range does not match the license insights max history
|
||||
*/
|
||||
private getMaxAgeInDaysAndGranularity(payload: InsightsDateFilterDto) {
|
||||
try {
|
||||
return this.insightsService.getMaxAgeInDaysAndGranularity(payload.dateRange ?? 'week');
|
||||
} catch (error: unknown) {
|
||||
throw new ForbiddenError((error as UserError).message);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/summary')
|
||||
@GlobalScope('insights:list')
|
||||
async getInsightsSummary(): Promise<InsightsSummary> {
|
||||
async getInsightsSummary(
|
||||
_req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Query payload: InsightsDateFilterDto = { dateRange: 'week' },
|
||||
): Promise<InsightsSummary> {
|
||||
const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity(payload);
|
||||
return await this.insightsService.getInsightsSummary({
|
||||
periodLengthInDays: this.maxAgeInDaysFilteredInsights,
|
||||
periodLengthInDays: dateRangeAndMaxAgeInDays.maxAgeInDays,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,8 +45,11 @@ export class InsightsController {
|
||||
_res: Response,
|
||||
@Query payload: ListInsightsWorkflowQueryDto,
|
||||
): Promise<InsightsByWorkflow> {
|
||||
const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity({
|
||||
dateRange: payload.dateRange ?? 'week',
|
||||
});
|
||||
return await this.insightsService.getInsightsByWorkflow({
|
||||
maxAgeInDays: this.maxAgeInDaysFilteredInsights,
|
||||
maxAgeInDays: dateRangeAndMaxAgeInDays.maxAgeInDays,
|
||||
skip: payload.skip,
|
||||
take: payload.take,
|
||||
sortBy: payload.sortBy,
|
||||
@@ -39,10 +59,15 @@ export class InsightsController {
|
||||
@Get('/by-time')
|
||||
@GlobalScope('insights:list')
|
||||
@Licensed('feat:insights:viewDashboard')
|
||||
async getInsightsByTime(): Promise<InsightsByTime[]> {
|
||||
async getInsightsByTime(
|
||||
_req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Query payload: InsightsDateFilterDto,
|
||||
): Promise<InsightsByTime[]> {
|
||||
const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity(payload);
|
||||
return await this.insightsService.getInsightsByTime({
|
||||
maxAgeInDays: this.maxAgeInDaysFilteredInsights,
|
||||
periodUnit: 'day',
|
||||
maxAgeInDays: dateRangeAndMaxAgeInDays.maxAgeInDays,
|
||||
periodUnit: dateRangeAndMaxAgeInDays.granularity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { InsightsSummary } from '@n8n/api-types';
|
||||
import type { InsightsDateRange } from '@n8n/api-types/src/schemas/insights.schema';
|
||||
import {
|
||||
type InsightsSummary,
|
||||
type InsightsDateRange,
|
||||
INSIGHTS_DATE_RANGE_KEYS,
|
||||
} from '@n8n/api-types';
|
||||
import { OnShutdown } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
import { Logger } from 'n8n-core';
|
||||
import type { ExecutionLifecycleHooks } from 'n8n-core';
|
||||
import type { IRun } from 'n8n-workflow';
|
||||
import { UserError, type IRun } from 'n8n-workflow';
|
||||
|
||||
import { License } from '@/license';
|
||||
|
||||
@@ -14,6 +17,16 @@ import { InsightsByPeriodRepository } from './database/repositories/insights-by-
|
||||
import { InsightsCollectionService } from './insights-collection.service';
|
||||
import { InsightsCompactionService } from './insights-compaction.service';
|
||||
|
||||
const keyRangeToDays: Record<InsightsDateRange['key'], number> = {
|
||||
day: 1,
|
||||
week: 7,
|
||||
'2weeks': 14,
|
||||
month: 30,
|
||||
quarter: 90,
|
||||
'6months': 180,
|
||||
year: 365,
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class InsightsService {
|
||||
constructor(
|
||||
@@ -181,6 +194,10 @@ export class InsightsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the available date ranges with their license authorization and time granularity
|
||||
* when grouped by time.
|
||||
*/
|
||||
getAvailableDateRanges(): InsightsDateRange[] {
|
||||
const maxHistoryInDays =
|
||||
this.license.getInsightsMaxHistory() === -1
|
||||
@@ -188,13 +205,31 @@ export class InsightsService {
|
||||
: this.license.getInsightsMaxHistory();
|
||||
const isHourlyDateEnabled = this.license.isInsightsHourlyDataEnabled();
|
||||
|
||||
return [
|
||||
{ key: 'day', licensed: isHourlyDateEnabled ?? false, granularity: 'hour' },
|
||||
{ key: 'week', licensed: maxHistoryInDays >= 7, granularity: 'day' },
|
||||
{ key: '2weeks', licensed: maxHistoryInDays >= 14, granularity: 'day' },
|
||||
{ key: 'month', licensed: maxHistoryInDays >= 30, granularity: 'day' },
|
||||
{ key: 'quarter', licensed: maxHistoryInDays >= 90, granularity: 'week' },
|
||||
{ key: 'year', licensed: maxHistoryInDays >= 365, granularity: 'week' },
|
||||
];
|
||||
return INSIGHTS_DATE_RANGE_KEYS.map((key) => ({
|
||||
key,
|
||||
licensed:
|
||||
key === 'day' ? (isHourlyDateEnabled ?? false) : maxHistoryInDays >= keyRangeToDays[key],
|
||||
granularity: key === 'day' ? 'hour' : keyRangeToDays[key] <= 30 ? 'day' : 'week',
|
||||
}));
|
||||
}
|
||||
|
||||
getMaxAgeInDaysAndGranularity(
|
||||
dateRangeKey: InsightsDateRange['key'],
|
||||
): InsightsDateRange & { maxAgeInDays: number } {
|
||||
const availableDateRanges = this.getAvailableDateRanges();
|
||||
|
||||
const dateRange = availableDateRanges.find((range) => range.key === dateRangeKey);
|
||||
if (!dateRange) {
|
||||
// Not supposed to happen if we trust the dateRangeKey type
|
||||
throw new UserError('The selected date range is not available');
|
||||
}
|
||||
|
||||
if (!dateRange.licensed) {
|
||||
throw new UserError(
|
||||
'The selected date range exceeds the maximum history allowed by your license.',
|
||||
);
|
||||
}
|
||||
|
||||
return { ...dateRange, maxAgeInDays: keyRangeToDays[dateRangeKey] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { InsightsDateRange } from '@n8n/api-types';
|
||||
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { mockInstance } from '@test/mocking';
|
||||
|
||||
@@ -11,6 +13,7 @@ let agents: Record<string, SuperAgentTest> = {};
|
||||
const testServer = utils.setupTestServer({
|
||||
endpointGroups: ['insights', 'license', 'auth'],
|
||||
enabledFeatures: ['feat:insights:viewSummary', 'feat:insights:viewDashboard'],
|
||||
quotas: { 'quota:insights:maxHistoryDays': 365 },
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -49,6 +52,54 @@ describe('GET /insights routes return 403 for dashboard routes when summary lice
|
||||
);
|
||||
});
|
||||
|
||||
describe('GET /insights routes return 403 if date range outside license limits', () => {
|
||||
beforeAll(() => {
|
||||
testServer.license.setDefaults({ quotas: { 'quota:insights:maxHistoryDays': 3 } });
|
||||
});
|
||||
|
||||
test('Call should throw forbidden for default week insights', async () => {
|
||||
const authAgent = agents.admin;
|
||||
await authAgent.get('/insights/summary').expect(403);
|
||||
await authAgent.get('/insights/by-time').expect(403);
|
||||
await authAgent.get('/insights/by-workflow').expect(403);
|
||||
});
|
||||
|
||||
test('Call should throw forbidden for daily data without viewHourlyData enabled', async () => {
|
||||
const authAgent = agents.admin;
|
||||
await authAgent.get('/insights/summary?dateRange=day').expect(403);
|
||||
await authAgent.get('/insights/by-time?dateRange=day').expect(403);
|
||||
await authAgent.get('/insights/by-workflow?dateRange=day').expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /insights routes return 200 if date range inside license limits', () => {
|
||||
beforeAll(() => {
|
||||
testServer.license.setDefaults({
|
||||
features: [
|
||||
'feat:insights:viewSummary',
|
||||
'feat:insights:viewDashboard',
|
||||
'feat:insights:viewHourlyData',
|
||||
],
|
||||
quotas: { 'quota:insights:maxHistoryDays': 365 },
|
||||
});
|
||||
});
|
||||
|
||||
test.each<InsightsDateRange['key']>([
|
||||
'day',
|
||||
'week',
|
||||
'2weeks',
|
||||
'month',
|
||||
'quarter',
|
||||
'6months',
|
||||
'year',
|
||||
])('Call should work for date range %s', async (dateRange) => {
|
||||
const authAgent = agents.admin;
|
||||
await authAgent.get(`/insights/summary?dateRange=${dateRange}`).expect(200);
|
||||
await authAgent.get(`/insights/by-time?dateRange=${dateRange}`).expect(200);
|
||||
await authAgent.get(`/insights/by-workflow?dateRange=${dateRange}`).expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /insights/by-workflow', () => {
|
||||
beforeAll(() => {
|
||||
testServer.license.setDefaults({
|
||||
|
||||
Reference in New Issue
Block a user