diff --git a/src/lib/features/project/project-controller.ts b/src/lib/features/project/project-controller.ts index 55d0a2a770..6057f0e06c 100644 --- a/src/lib/features/project/project-controller.ts +++ b/src/lib/features/project/project-controller.ts @@ -20,16 +20,14 @@ import { deprecatedProjectOverviewSchema, type ProjectDoraMetricsSchema, projectDoraMetricsSchema, + projectInsightsSchema, + type ProjectInsightsSchema, projectOverviewSchema, type ProjectsSchema, projectsSchema, } from '../../openapi'; import { getStandardResponses } from '../../openapi/util/standard-responses'; -import type { - AccessService, - OpenApiService, - SettingService, -} from '../../services'; +import type { OpenApiService } from '../../services'; import type { IAuthRequest } from '../../routes/unleash-types'; import { ProjectApiTokenController } from '../../routes/admin-api/project/api-token'; import ProjectArchiveController from '../../routes/admin-api/project/project-archive'; @@ -48,10 +46,6 @@ import { normalizeQueryParams } from '../feature-search/search-utils'; export default class ProjectController extends Controller { private projectService: ProjectService; - private settingService: SettingService; - - private accessService: AccessService; - private openApiService: OpenApiService; private flagResolver: IFlagResolver; @@ -60,8 +54,6 @@ export default class ProjectController extends Controller { super(config); this.projectService = services.projectService; this.openApiService = services.openApiService; - this.settingService = services.settingService; - this.accessService = services.accessService; this.flagResolver = config.flagResolver; this.route({ @@ -127,6 +119,26 @@ export default class ProjectController extends Controller { ], }); + this.route({ + method: 'get', + path: '/:projectId/insights', + handler: this.getProjectInsights, + permission: NONE, + middleware: [ + this.openApiService.validPath({ + tags: ['Unstable'], + operationId: 'getProjectInsights', + summary: 'Get an overview of a project insights.', + description: + 'This endpoint returns insights into the specified projects stats, health, lead time for changes, feature types used, members and change requests.', + responses: { + 200: createResponseSchema('projectInsightsSchema'), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); + this.route({ method: 'get', path: '/:projectId/dora', @@ -232,6 +244,74 @@ export default class ProjectController extends Controller { ); } + async getProjectInsights( + req: IAuthRequest, + res: Response, + ): Promise { + const result = { + stats: { + avgTimeToProdCurrentWindow: 17.1, + createdCurrentWindow: 3, + createdPastWindow: 6, + archivedCurrentWindow: 0, + archivedPastWindow: 1, + projectActivityCurrentWindow: 458, + projectActivityPastWindow: 578, + projectMembersAddedCurrentWindow: 0, + }, + featureTypeCounts: [ + { + type: 'experiment', + count: 4, + }, + { + type: 'permission', + count: 1, + }, + { + type: 'release', + count: 24, + }, + ], + leadTime: { + projectAverage: 17.1, + features: [ + { name: 'feature1', timeToProduction: 120 }, + { name: 'feature2', timeToProduction: 0 }, + { name: 'feature3', timeToProduction: 33 }, + { name: 'feature4', timeToProduction: 131 }, + { name: 'feature5', timeToProduction: 2 }, + ], + }, + health: { + rating: 80, + activeCount: 23, + potentiallyStaleCount: 3, + staleCount: 5, + }, + members: { + active: 20, + inactive: 3, + totalPreviousMonth: 15, + }, + changeRequests: { + total: 24, + approved: 5, + applied: 2, + rejected: 4, + reviewRequired: 10, + scheduled: 3, + }, + }; + + this.openApiService.respondWithValidation( + 200, + res, + projectInsightsSchema.$id, + serializeDates(result), + ); + } + async getProjectOverview( req: IAuthRequest, res: Response, diff --git a/src/lib/features/project/projects.e2e.test.ts b/src/lib/features/project/projects.e2e.test.ts index 5c07145314..d8d1c6de51 100644 --- a/src/lib/features/project/projects.e2e.test.ts +++ b/src/lib/features/project/projects.e2e.test.ts @@ -287,3 +287,15 @@ test('response should include last seen at per environment for multiple environm expect(body.features[1].lastSeenAt).toBe('2023-10-01T12:34:56.000Z'); }); + +test('project insights happy path', async () => { + const { body } = await app.request + .get('/api/admin/projects/default/insights') + .expect('Content-Type', /json/) + .expect(200); + + expect(body.leadTime.features[0]).toEqual({ + name: 'feature1', + timeToProduction: 120, + }); +}); diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 0fea90eee5..2f8bcf4ecb 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -134,6 +134,7 @@ export * from './project-application-sdk-schema'; export * from './project-applications-schema'; export * from './project-dora-metrics-schema'; export * from './project-environment-schema'; +export * from './project-insights-schema'; export * from './project-overview-schema'; export * from './project-schema'; export * from './project-stats-schema'; diff --git a/src/lib/openapi/spec/project-insights-schema.ts b/src/lib/openapi/spec/project-insights-schema.ts new file mode 100644 index 0000000000..60fef5e162 --- /dev/null +++ b/src/lib/openapi/spec/project-insights-schema.ts @@ -0,0 +1,148 @@ +import type { FromSchema } from 'json-schema-to-ts'; +import { projectStatsSchema } from './project-stats-schema'; +import { featureTypeCountSchema } from './feature-type-count-schema'; +import { doraFeaturesSchema } from './dora-features-schema'; +import { projectDoraMetricsSchema } from './project-dora-metrics-schema'; + +export const projectInsightsSchema = { + $id: '#/components/schemas/projectInsightsSchema', + type: 'object', + additionalProperties: false, + required: ['stats', 'leadTime', 'featureTypeCounts', 'health', 'members'], + description: + 'A high-level overview of a project insights. It contains information such as project statistics, overall health, types of flags, members overview, change requests overview.', + properties: { + stats: { + $ref: '#/components/schemas/projectStatsSchema', + description: 'Project statistics', + }, + health: { + type: 'object', + required: [ + 'rating', + 'activeCount', + 'potentiallyStaleCount', + 'staleCount', + ], + properties: { + rating: { + type: 'integer', + description: + "An indicator of the [project's health](https://docs.getunleash.io/reference/technical-debt#health-rating) on a scale from 0 to 100", + example: 95, + }, + activeCount: { + type: 'number', + description: 'The number of active feature toggles.', + example: 12, + }, + potentiallyStaleCount: { + type: 'number', + description: + 'The number of potentially stale feature toggles.', + example: 5, + }, + staleCount: { + type: 'number', + description: 'The number of stale feature toggles.', + example: 10, + }, + }, + description: 'Health summary of the project', + }, + leadTime: { + type: 'object', + $ref: '#/components/schemas/projectDoraMetricsSchema', + description: 'Lead time (DORA) metrics', + }, + featureTypeCounts: { + type: 'array', + items: { + $ref: '#/components/schemas/featureTypeCountSchema', + }, + description: 'The number of features of each type', + }, + members: { + type: 'object', + required: ['active', 'inactive'], + properties: { + active: { + type: 'number', + description: + 'The number of active project members who have used Unleash in the past 60 days', + example: 10, + }, + inactive: { + type: 'number', + description: + 'The number of inactive project members who have not used Unleash in the past 60 days', + example: 10, + }, + totalPreviousMonth: { + type: 'number', + description: + 'The number of total project members in the previous month', + example: 8, + }, + }, + description: 'Active/inactive users summary', + }, + changeRequests: { + type: 'object', + required: [ + 'total', + 'applied', + 'rejected', + 'reviewRequired', + 'approved', + 'scheduled', + ], + properties: { + total: { + type: 'number', + description: + 'The number of total change requests in this project', + example: 10, + }, + applied: { + type: 'number', + description: 'The number of applied change requests', + example: 5, + }, + rejected: { + type: 'number', + description: 'The number of rejected change requests', + example: 2, + }, + reviewRequired: { + type: 'number', + description: + 'The number of change requests awaiting the review', + example: 2, + }, + approved: { + type: 'number', + description: 'The number of approved change requests', + example: 1, + }, + scheduled: { + type: 'number', + description: 'The number of scheduled change requests', + example: 1, + }, + }, + description: + 'Count of change requests in different stages of the [process](https://docs.getunleash.io/reference/change-requests#change-request-flow). Only for enterprise users.', + }, + }, + components: { + schemas: { + projectStatsSchema, + featureTypeCountSchema, + projectDoraMetricsSchema, + doraFeaturesSchema, + }, + }, +} as const; + +export type ProjectInsightsSchema = FromSchema;