mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2026-09-30 13:39:35 +02:00
feat(core): Implement granularity and date range filtering on insights (#14841)
This commit is contained in:
1 parent
5ff073bd7b
commit
28596a633e
8 files changed
+247
-32
No files matched your search
@@ -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] };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user