mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(core): Implement API to retrieve summary metrics (#13927)
Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { mockInstance } from '@test/mocking';
|
||||
import * as testDb from '@test-integration/test-db';
|
||||
|
||||
import { TypeToNumber } from '../entities/insights-shared';
|
||||
import { InsightsController } from '../insights.controller';
|
||||
import { InsightsByPeriodRepository } from '../repositories/insights-by-period.repository';
|
||||
|
||||
// Initialize DB once for all tests
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
// Terminate DB once after all tests complete
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('InsightsController', () => {
|
||||
const insightsByPeriodRepository = mockInstance(InsightsByPeriodRepository);
|
||||
let controller: InsightsController;
|
||||
beforeAll(async () => {
|
||||
controller = Container.get(InsightsController);
|
||||
});
|
||||
|
||||
describe('getInsightsSummary', () => {
|
||||
it('should return default insights if no data', async () => {
|
||||
// ARRANGE
|
||||
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([]);
|
||||
|
||||
// ACT
|
||||
const response = await controller.getInsightsSummary();
|
||||
|
||||
// ASSERT
|
||||
expect(response).toEqual({
|
||||
total: { deviation: 0, unit: 'count', value: 0 },
|
||||
failed: { deviation: 0, unit: 'count', value: 0 },
|
||||
failureRate: { deviation: 0, unit: 'ratio', value: 0 },
|
||||
averageRunTime: { deviation: 0, unit: 'time', value: 0 },
|
||||
timeSaved: { deviation: 0, unit: 'time', value: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the insights summary with deviation = current if insights exist only for current period', async () => {
|
||||
// ARRANGE
|
||||
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([
|
||||
{ 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();
|
||||
|
||||
// ASSERT
|
||||
expect(response).toEqual({
|
||||
total: { deviation: 30, unit: 'count', value: 30 },
|
||||
failed: { deviation: 10, unit: 'count', value: 10 },
|
||||
failureRate: { deviation: 0.33, unit: 'ratio', value: 0.33 },
|
||||
averageRunTime: { deviation: 10, unit: 'time', value: 10 },
|
||||
timeSaved: { deviation: 10, unit: 'time', value: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the insights summary if insights exist for both periods', 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();
|
||||
|
||||
// ASSERT
|
||||
expect(response).toEqual({
|
||||
total: { deviation: 10, unit: 'count', value: 30 },
|
||||
failed: { deviation: 6, unit: 'count', value: 10 },
|
||||
failureRate: { deviation: 0.33 - 0.2, unit: 'ratio', value: 0.33 },
|
||||
averageRunTime: { deviation: 300 / 30 - 40 / 20, unit: 'time', value: 10 },
|
||||
timeSaved: { deviation: 5, unit: 'time', value: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow';
|
||||
import type { Project } from '@/databases/entities/project';
|
||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
import type { IWorkflowDb } from '@/interfaces';
|
||||
import type { TypeUnits } from '@/modules/insights/entities/insights-shared';
|
||||
import type { TypeUnit } from '@/modules/insights/entities/insights-shared';
|
||||
import { InsightsMetadataRepository } from '@/modules/insights/repositories/insights-metadata.repository';
|
||||
import { InsightsRawRepository } from '@/modules/insights/repositories/insights-raw.repository';
|
||||
import { createTeamProject } from '@test-integration/db/projects';
|
||||
@@ -38,7 +38,6 @@ async function truncateAll() {
|
||||
|
||||
// Initialize DB once for all tests
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
@@ -74,7 +73,7 @@ describe('workflowExecuteAfterHandler', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test.each<{ status: ExecutionStatus; type: TypeUnits }>([
|
||||
test.each<{ status: ExecutionStatus; type: TypeUnit }>([
|
||||
{ status: 'success', type: 'success' },
|
||||
{ status: 'error', type: 'failure' },
|
||||
{ status: 'crashed', type: 'failure' },
|
||||
@@ -524,20 +523,28 @@ describe('compaction', () => {
|
||||
const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0);
|
||||
expect(accumulatedValues).toBe(batchSize);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compactionSchedule', () => {
|
||||
test('compaction is running on schedule', async () => {
|
||||
// ARRANGE
|
||||
const insightsService = Container.get(InsightsService);
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
// ARRANGE
|
||||
const insightsService = Container.get(InsightsService);
|
||||
insightsService.initializeCompaction();
|
||||
|
||||
// spy on the compactInsights method to check if it's called
|
||||
insightsService.compactInsights = jest.fn();
|
||||
// spy on the compactInsights method to check if it's called
|
||||
insightsService.compactInsights = jest.fn();
|
||||
|
||||
// ACT
|
||||
// advance by 1 hour and 1 minute
|
||||
jest.advanceTimersByTime(1000 * 60 * 60);
|
||||
// ACT
|
||||
// advance by 1 hour and 1 minute
|
||||
jest.advanceTimersByTime(1000 * 60 * 61);
|
||||
|
||||
// ASSERT
|
||||
expect(insightsService.compactInsights).toHaveBeenCalledTimes(1);
|
||||
// ASSERT
|
||||
expect(insightsService.compactInsights).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -602,3 +609,68 @@ describe('compaction', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInsightsSummary', () => {
|
||||
let insightsService: InsightsService;
|
||||
beforeAll(async () => {
|
||||
insightsService = Container.get(InsightsService);
|
||||
});
|
||||
|
||||
let project: Project;
|
||||
let workflow: IWorkflowDb & WorkflowEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAll();
|
||||
|
||||
project = await createTeamProject();
|
||||
workflow = await createWorkflow({}, project);
|
||||
});
|
||||
|
||||
test('compacted data are summarized correctly', async () => {
|
||||
// ARRANGE
|
||||
// last 7 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: 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 }),
|
||||
});
|
||||
|
||||
// ACT
|
||||
const summary = await insightsService.getInsightsSummary();
|
||||
|
||||
// ASSERT
|
||||
expect(summary).toEqual({
|
||||
averageRunTime: { deviation: -123, unit: 'time', value: 0 },
|
||||
failed: { deviation: 2, unit: 'count', value: 2 },
|
||||
failureRate: { deviation: 0.5, unit: 'ratio', value: 0.5 },
|
||||
timeSaved: { deviation: 0, unit: 'time', value: 0 },
|
||||
total: { deviation: 3, unit: 'count', value: 4 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { DateTime } from 'luxon';
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
@@ -19,7 +18,6 @@ async function getWorkflowSharing(workflow: IWorkflowBase) {
|
||||
relations: { project: true },
|
||||
});
|
||||
}
|
||||
export const { type: dbType } = Container.get(GlobalConfig).database;
|
||||
|
||||
export async function createMetadata(workflow: WorkflowEntity) {
|
||||
const insightsMetadataRepository = Container.get(InsightsMetadataRepository);
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as testDb from '@test-integration/test-db';
|
||||
|
||||
import { InsightsRawRepository } from '../../repositories/insights-raw.repository';
|
||||
import { InsightsByPeriod } from '../insights-by-period';
|
||||
import type { PeriodUnits, TypeUnits } from '../insights-shared';
|
||||
import type { PeriodUnit, TypeUnit } from '../insights-shared';
|
||||
|
||||
let insightsRawRepository: InsightsRawRepository;
|
||||
|
||||
@@ -22,7 +22,7 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe('Insights By Period', () => {
|
||||
test.each(['time_saved_min', 'runtime_ms', 'failure', 'success'] satisfies TypeUnits[])(
|
||||
test.each(['time_saved_min', 'runtime_ms', 'failure', 'success'] satisfies TypeUnit[])(
|
||||
'`%s` can be serialized and deserialized correctly',
|
||||
(typeUnit) => {
|
||||
// ARRANGE
|
||||
@@ -35,7 +35,7 @@ describe('Insights By Period', () => {
|
||||
expect(insightByPeriod.type).toBe(typeUnit);
|
||||
},
|
||||
);
|
||||
test.each(['hour', 'day', 'week'] satisfies PeriodUnits[])(
|
||||
test.each(['hour', 'day', 'week'] satisfies PeriodUnit[])(
|
||||
'`%s` can be serialized and deserialized correctly',
|
||||
(periodUnit) => {
|
||||
// ARRANGE
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as testDb from '@test-integration/test-db';
|
||||
import { createMetadata, createRawInsightsEvent } from './db-utils';
|
||||
import { InsightsRawRepository } from '../../repositories/insights-raw.repository';
|
||||
import { InsightsRaw } from '../insights-raw';
|
||||
import type { TypeUnits } from '../insights-shared';
|
||||
import type { TypeUnit } from '../insights-shared';
|
||||
|
||||
let insightsRawRepository: InsightsRawRepository;
|
||||
|
||||
@@ -26,7 +26,7 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe('Insights Raw Entity', () => {
|
||||
test.each(['success', 'failure', 'runtime_ms', 'time_saved_min'] satisfies TypeUnits[])(
|
||||
test.each(['success', 'failure', 'runtime_ms', 'time_saved_min'] satisfies TypeUnit[])(
|
||||
'`%s` can be serialized and deserialized correctly',
|
||||
(typeUnit) => {
|
||||
// ARRANGE
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import type { PeriodUnits } from './insights-shared';
|
||||
import type { PeriodUnit } from './insights-shared';
|
||||
import {
|
||||
isValidPeriodNumber,
|
||||
isValidTypeNumber,
|
||||
@@ -53,7 +53,7 @@ export class InsightsByPeriod extends BaseEntity {
|
||||
return NumberToPeriodUnit[this.periodUnit_];
|
||||
}
|
||||
|
||||
set periodUnit(value: PeriodUnits) {
|
||||
set periodUnit(value: PeriodUnit) {
|
||||
this.periodUnit_ = PeriodUnitToNumber[value];
|
||||
}
|
||||
|
||||
|
||||
@@ -11,14 +11,16 @@ export const PeriodUnitToNumber = {
|
||||
day: 1,
|
||||
week: 2,
|
||||
} as const;
|
||||
export type PeriodUnits = keyof typeof PeriodUnitToNumber;
|
||||
export type PeriodUnitNumbers = (typeof PeriodUnitToNumber)[PeriodUnits];
|
||||
|
||||
export type PeriodUnit = keyof typeof PeriodUnitToNumber;
|
||||
|
||||
export type PeriodUnitNumber = (typeof PeriodUnitToNumber)[PeriodUnit];
|
||||
export const NumberToPeriodUnit = Object.entries(PeriodUnitToNumber).reduce(
|
||||
(acc, [key, value]: [PeriodUnits, PeriodUnitNumbers]) => {
|
||||
(acc, [key, value]: [PeriodUnit, PeriodUnitNumber]) => {
|
||||
acc[value] = key;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<PeriodUnitNumbers, PeriodUnits>,
|
||||
{} as Record<PeriodUnitNumber, PeriodUnit>,
|
||||
);
|
||||
export function isValidPeriodNumber(value: number) {
|
||||
return isValid(value, NumberToPeriodUnit);
|
||||
@@ -31,14 +33,16 @@ export const TypeToNumber = {
|
||||
success: 2,
|
||||
failure: 3,
|
||||
} as const;
|
||||
export type TypeUnits = keyof typeof TypeToNumber;
|
||||
export type TypeUnitNumbers = (typeof TypeToNumber)[TypeUnits];
|
||||
|
||||
export type TypeUnit = keyof typeof TypeToNumber;
|
||||
|
||||
export type TypeUnitNumber = (typeof TypeToNumber)[TypeUnit];
|
||||
export const NumberToType = Object.entries(TypeToNumber).reduce(
|
||||
(acc, [key, value]: [TypeUnits, TypeUnitNumbers]) => {
|
||||
(acc, [key, value]: [TypeUnit, TypeUnitNumber]) => {
|
||||
acc[value] = key;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<TypeUnitNumbers, TypeUnits>,
|
||||
{} as Record<TypeUnitNumber, TypeUnit>,
|
||||
);
|
||||
|
||||
export function isValidTypeNumber(value: number) {
|
||||
|
||||
16
packages/cli/src/modules/insights/insights.controller.ts
Normal file
16
packages/cli/src/modules/insights/insights.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { InsightsSummary } from '@n8n/api-types';
|
||||
|
||||
import { Get, GlobalScope, RestController } from '@/decorators';
|
||||
|
||||
import { InsightsService } from './insights.service';
|
||||
|
||||
@RestController('/insights')
|
||||
export class InsightsController {
|
||||
constructor(private readonly insightsService: InsightsService) {}
|
||||
|
||||
@Get('/summary')
|
||||
@GlobalScope('insights:list')
|
||||
async getInsightsSummary(): Promise<InsightsSummary> {
|
||||
return await this.insightsService.getInsightsSummary();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { N8nModule } from '@/decorators/module';
|
||||
|
||||
import { InsightsService } from './insights.service';
|
||||
|
||||
import './insights.controller';
|
||||
|
||||
@N8nModule()
|
||||
export class InsightsModule implements BaseN8nModule {
|
||||
constructor(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { InsightsSummary } from '@n8n/api-types';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import type { ExecutionLifecycleHooks } from 'n8n-core';
|
||||
import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import {
|
||||
UnexpectedError,
|
||||
type ExecutionStatus,
|
||||
type IRun,
|
||||
type WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { SharedWorkflow } from '@/databases/entities/shared-workflow';
|
||||
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||
@@ -9,6 +14,8 @@ import { OnShutdown } from '@/decorators/on-shutdown';
|
||||
import { InsightsMetadata } from '@/modules/insights/entities/insights-metadata';
|
||||
import { InsightsRaw } from '@/modules/insights/entities/insights-raw';
|
||||
|
||||
import type { TypeUnit } from './entities/insights-shared';
|
||||
import { NumberToType } from './entities/insights-shared';
|
||||
import { InsightsConfig } from './insights.config';
|
||||
import { InsightsByPeriodRepository } from './repositories/insights-by-period.repository';
|
||||
import { InsightsRawRepository } from './repositories/insights-raw.repository';
|
||||
@@ -49,6 +56,13 @@ export class InsightsService {
|
||||
private readonly insightsByPeriodRepository: InsightsByPeriodRepository,
|
||||
private readonly insightsRawRepository: InsightsRawRepository,
|
||||
) {
|
||||
this.initializeCompaction();
|
||||
}
|
||||
|
||||
initializeCompaction() {
|
||||
if (this.compactInsightsTimer !== undefined) {
|
||||
clearInterval(this.compactInsightsTimer);
|
||||
}
|
||||
const intervalMilliseconds = config.compactionIntervalMinutes * 60 * 1000;
|
||||
this.compactInsightsTimer = setInterval(
|
||||
async () => await this.compactInsights(),
|
||||
@@ -178,4 +192,84 @@ export class InsightsService {
|
||||
periodUnit: 'day',
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: add return type once rebased on master and InsightsSummary is
|
||||
// available
|
||||
async getInsightsSummary(): Promise<InsightsSummary> {
|
||||
const rows = await this.insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates();
|
||||
|
||||
// Initialize data structures for both periods
|
||||
const data = {
|
||||
current: { byType: {} as Record<TypeUnit, number> },
|
||||
previous: { byType: {} as Record<TypeUnit, number> },
|
||||
};
|
||||
|
||||
// Organize data by period and type
|
||||
rows.forEach((row) => {
|
||||
const { period, type, total_value } = row;
|
||||
if (!data[period]) return;
|
||||
|
||||
data[period].byType[NumberToType[type]] = total_value ? Number(total_value) : 0;
|
||||
});
|
||||
|
||||
// Get values with defaults for missing data
|
||||
const getValueByType = (period: 'current' | 'previous', type: TypeUnit) =>
|
||||
data[period]?.byType[type] ?? 0;
|
||||
|
||||
// Calculate metrics
|
||||
const currentSuccesses = getValueByType('current', 'success');
|
||||
const currentFailures = getValueByType('current', 'failure');
|
||||
const previousSuccesses = getValueByType('previous', 'success');
|
||||
const previousFailures = getValueByType('previous', 'failure');
|
||||
|
||||
const currentTotal = currentSuccesses + currentFailures;
|
||||
const previousTotal = previousSuccesses + previousFailures;
|
||||
|
||||
const currentFailureRate =
|
||||
currentTotal > 0 ? Math.round((currentFailures / currentTotal) * 100) / 100 : 0;
|
||||
const previousFailureRate =
|
||||
previousTotal > 0 ? Math.round((previousFailures / previousTotal) * 100) / 100 : 0;
|
||||
|
||||
const currentTotalRuntime = getValueByType('current', 'runtime_ms') ?? 0;
|
||||
const previousTotalRuntime = getValueByType('previous', 'runtime_ms') ?? 0;
|
||||
|
||||
const currentAvgRuntime =
|
||||
currentTotal > 0 ? Math.round((currentTotalRuntime / currentTotal) * 100) / 100 : 0;
|
||||
const previousAvgRuntime =
|
||||
previousTotal > 0 ? Math.round((previousTotalRuntime / previousTotal) * 100) / 100 : 0;
|
||||
|
||||
const currentTimeSaved = getValueByType('current', 'time_saved_min');
|
||||
const previousTimeSaved = getValueByType('previous', 'time_saved_min');
|
||||
|
||||
// Return the formatted result
|
||||
const result: InsightsSummary = {
|
||||
averageRunTime: {
|
||||
value: currentAvgRuntime,
|
||||
unit: 'time',
|
||||
deviation: currentAvgRuntime - previousAvgRuntime,
|
||||
},
|
||||
failed: {
|
||||
value: currentFailures,
|
||||
unit: 'count',
|
||||
deviation: currentFailures - previousFailures,
|
||||
},
|
||||
failureRate: {
|
||||
value: currentFailureRate,
|
||||
unit: 'ratio',
|
||||
deviation: currentFailureRate - previousFailureRate,
|
||||
},
|
||||
timeSaved: {
|
||||
value: currentTimeSaved,
|
||||
unit: 'time',
|
||||
deviation: currentTimeSaved - previousTimeSaved,
|
||||
},
|
||||
total: {
|
||||
value: currentTotal,
|
||||
unit: 'count',
|
||||
deviation: currentTotal - previousTotal,
|
||||
},
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { sql } from '@/utils/sql';
|
||||
|
||||
import { InsightsByPeriod } from '../entities/insights-by-period';
|
||||
import type { PeriodUnits } from '../entities/insights-shared';
|
||||
import type { PeriodUnit } from '../entities/insights-shared';
|
||||
import { PeriodUnitToNumber } from '../entities/insights-shared';
|
||||
|
||||
const dbType = Container.get(GlobalConfig).database.type;
|
||||
|
||||
const summaryParser = z
|
||||
.object({
|
||||
period: z.enum(['previous', 'current']),
|
||||
type: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]),
|
||||
|
||||
// depending on db engine, sum(value) can be a number or a string - because of big numbers
|
||||
total_value: z.union([z.number(), z.string()]),
|
||||
})
|
||||
.array();
|
||||
|
||||
@Service()
|
||||
export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
|
||||
constructor(dataSource: DataSource) {
|
||||
@@ -20,7 +31,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
|
||||
return this.manager.connection.driver.escape(fieldName);
|
||||
}
|
||||
|
||||
private getPeriodFilterExpr(periodUnit: PeriodUnits) {
|
||||
private getPeriodFilterExpr(periodUnit: PeriodUnit) {
|
||||
const daysAgo = periodUnit === 'day' ? 90 : 180;
|
||||
// Database-specific period start expression to filter out data to compact by days matching the periodUnit
|
||||
let periodStartExpr = `date('now', '-${daysAgo} days')`;
|
||||
@@ -33,7 +44,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
|
||||
return periodStartExpr;
|
||||
}
|
||||
|
||||
private getPeriodStartExpr(periodUnit: PeriodUnits) {
|
||||
private getPeriodStartExpr(periodUnit: PeriodUnit) {
|
||||
// Database-specific period start expression to truncate timestamp to the periodUnit
|
||||
// SQLite by default
|
||||
let periodStartExpr = `strftime('%Y-%m-%d ${periodUnit === 'hour' ? '%H' : '00'}:00:00.000', periodStart)`;
|
||||
@@ -49,7 +60,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
|
||||
return periodStartExpr;
|
||||
}
|
||||
|
||||
getPeriodInsightsBatchQuery(periodUnit: PeriodUnits, compactionBatchSize: number) {
|
||||
getPeriodInsightsBatchQuery(periodUnit: PeriodUnit, compactionBatchSize: number) {
|
||||
// Build the query to gather period insights data for the batch
|
||||
const batchQuery = this.createQueryBuilder()
|
||||
.select(
|
||||
@@ -64,7 +75,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
|
||||
return batchQuery;
|
||||
}
|
||||
|
||||
getAggregationQuery(periodUnit: PeriodUnits) {
|
||||
getAggregationQuery(periodUnit: PeriodUnit) {
|
||||
// Get the start period expression depending on the period unit and database type
|
||||
const periodStartExpr = this.getPeriodStartExpr(periodUnit);
|
||||
|
||||
@@ -91,7 +102,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
|
||||
}: {
|
||||
sourceBatchQuery: string;
|
||||
sourceTableName?: string;
|
||||
periodUnit: PeriodUnits;
|
||||
periodUnit: PeriodUnit;
|
||||
}): Promise<number> {
|
||||
// Create temp table that only exists in this transaction for rows to compact
|
||||
const getBatchAndStoreInTemporaryTable = sql`
|
||||
@@ -161,4 +172,60 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getPreviousAndCurrentPeriodTypeAggregates(): Promise<
|
||||
Array<{
|
||||
period: 'previous' | 'current';
|
||||
type: 0 | 1 | 2 | 3;
|
||||
total_value: string | number;
|
||||
}>
|
||||
> {
|
||||
const cte =
|
||||
dbType === 'sqlite'
|
||||
? sql`
|
||||
SELECT
|
||||
datetime('now', '-7 days') AS current_start,
|
||||
datetime('now') AS current_end,
|
||||
datetime('now', '-14 days') AS previous_start
|
||||
`
|
||||
: dbType === 'postgresdb'
|
||||
? sql`
|
||||
SELECT
|
||||
(CURRENT_DATE - INTERVAL '7 days')::timestamptz AS current_start,
|
||||
CURRENT_DATE::timestamptz AS current_end,
|
||||
(CURRENT_DATE - INTERVAL '14 days')::timestamptz AS previous_start
|
||||
`
|
||||
: sql`
|
||||
SELECT
|
||||
DATE_SUB(CURDATE(), INTERVAL 7 DAY) AS current_start,
|
||||
CURDATE() AS current_end,
|
||||
DATE_SUB(CURDATE(), INTERVAL 14 DAY) AS previous_start
|
||||
`;
|
||||
|
||||
const rawRows = await this.createQueryBuilder('insights')
|
||||
.addCommonTableExpression(cte, 'date_ranges')
|
||||
.select(
|
||||
sql`
|
||||
CASE
|
||||
WHEN insights.periodStart >= date_ranges.current_start AND insights.periodStart <= date_ranges.current_end
|
||||
THEN 'current'
|
||||
ELSE 'previous'
|
||||
END
|
||||
`,
|
||||
'period',
|
||||
)
|
||||
.addSelect('insights.type', 'type')
|
||||
.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();
|
||||
|
||||
return summaryParser.parse(rawRows);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
||||
'project:read',
|
||||
'project:update',
|
||||
'project:delete',
|
||||
'insights:list',
|
||||
];
|
||||
|
||||
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();
|
||||
|
||||
Reference in New Issue
Block a user