diff --git a/frontend/src/component/project/Project/Project.tsx b/frontend/src/component/project/Project/Project.tsx index 1d33cd7e78..dafd327277 100644 --- a/frontend/src/component/project/Project/Project.tsx +++ b/frontend/src/component/project/Project/Project.tsx @@ -41,6 +41,7 @@ import { useFavoriteProjectsApi } from 'hooks/api/actions/useFavoriteProjectsApi import { ImportModal } from './Import/ImportModal'; import { IMPORT_BUTTON } from 'utils/testIds'; import { EnterpriseBadge } from 'component/common/EnterpriseBadge/EnterpriseBadge'; +import { ProjectDoraMetrics } from './ProjectDoraMetrics/ProjectDoraMetrics'; export const Project = () => { const projectId = useRequiredPathParam('projectId'); @@ -63,34 +64,53 @@ export const Project = () => { title: 'Overview', path: basePath, name: 'overview', + flag: undefined, }, { title: 'Health', path: `${basePath}/health`, name: 'health', + flag: undefined, }, { title: 'Archive', path: `${basePath}/archive`, name: 'archive', + flag: undefined, }, { title: 'Change requests', path: `${basePath}/change-requests`, name: 'change-request', isEnterprise: true, + flag: undefined, + }, + { + title: 'DORA Metrics', + path: `${basePath}/dora`, + name: 'dora', + flag: 'doraMetrics', }, { title: 'Event log', path: `${basePath}/logs`, name: 'logs', + flag: undefined, }, { title: 'Project settings', path: `${basePath}/settings`, name: 'settings', + flag: undefined, }, - ].filter(tab => !(isOss() && tab.isEnterprise)); + ] + .filter(tab => { + if (tab.flag) { + return uiConfig.flags[tab.flag]; + } + return true; + }) + .filter(tab => !(isOss() && tab.isEnterprise)); const activeTab = [...tabs] .reverse() @@ -188,24 +208,26 @@ export const Project = () => { variant="scrollable" allowScrollButtonsMobile > - {tabs.map(tab => ( - navigate(tab.path)} - data-testid={`TAB_${tab.title}`} - iconPosition={ - tab.isEnterprise ? 'end' : undefined - } - icon={ - (tab.isEnterprise && - isPro() && - enterpriseIcon) || - undefined - } - /> - ))} + {tabs.map(tab => { + return ( + navigate(tab.path)} + data-testid={`TAB_${tab.title}`} + iconPosition={ + tab.isEnterprise ? 'end' : undefined + } + icon={ + (tab.isEnterprise && + isPro() && + enterpriseIcon) || + undefined + } + /> + ); + })} @@ -242,6 +264,7 @@ export const Project = () => { element={} /> } /> + } /> } /> { + const ONE_MONTH = 30; + const ONE_WEEK = 7; + + if (input >= ONE_MONTH) { + return Low; + } + + if (input <= ONE_MONTH && input >= ONE_WEEK + 1) { + return Medium; + } + + if (input <= ONE_WEEK) { + return High; + } +}; + +export const ProjectDoraMetrics = () => { + const projectId = useRequiredPathParam('projectId'); + + const { dora, loading } = useProjectDoraMetrics(projectId); + + const data = useMemo(() => { + if (loading) { + return Array(5).fill({ + name: 'Featurename', + timeToProduction: 'Tag type for production', + }); + } + + return dora.features; + }, [dora, loading]); + + const columns = useMemo( + () => [ + { + Header: 'Name', + accessor: 'name', + width: '50%', + Cell: ({ + row: { + original: { name, description }, + }, + }: any) => { + return ( + + {name} + + ); + }, + sortType: 'alphanumeric', + }, + { + Header: 'Time to production', + id: 'Time to production', + align: 'center', + Cell: ({ row: { original } }: any) => ( + + {original.timeToProduction} days + + ), + width: 150, + disableGlobalFilter: true, + disableSortBy: true, + }, + { + Header: 'DORA', + id: 'dora', + align: 'center', + Cell: ({ row: { original } }: any) => ( + + {resolveDoraMetrics(original.timeToProduction)} + + ), + width: 200, + disableGlobalFilter: true, + disableSortBy: true, + }, + ], + [JSON.stringify(dora.features), loading] + ); + + const initialState = useMemo( + () => ({ + sortBy: [{ id: 'name', desc: false }], + hiddenColumns: ['description'], + }), + [] + ); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow, + state: { globalFilter }, + setGlobalFilter, + } = useTable( + { + columns: columns as any[], // TODO: fix after `react-table` v8 update + data, + initialState, + autoResetGlobalFilter: false, + autoResetSortBy: false, + disableSortRemove: true, + }, + useGlobalFilter, + useSortBy + ); + + return ( + + } + > + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
+ 0} + show={ + + No tags found matching “ + {globalFilter} + ” + + } + elseShow={ + + No tags available. Get started by adding one. + + } + /> + } + /> +
+ ); +}; diff --git a/frontend/src/hooks/api/getters/useProjectDoraMetrics/useProjectDoraMetrics.ts b/frontend/src/hooks/api/getters/useProjectDoraMetrics/useProjectDoraMetrics.ts new file mode 100644 index 0000000000..349ea19fe7 --- /dev/null +++ b/frontend/src/hooks/api/getters/useProjectDoraMetrics/useProjectDoraMetrics.ts @@ -0,0 +1,38 @@ +import useSWR, { mutate, SWRConfiguration } from 'swr'; +import { useState, useEffect } from 'react'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; + +export const useProjectDoraMetrics = ( + projectId: string, + options: SWRConfiguration = {} +) => { + const KEY = `api/admin/projects/${projectId}/dora`; + const path = formatApiPath(KEY); + + const fetcher = () => { + return fetch(path, { + method: 'GET', + }) + .then(handleErrorResponses('Dora metrics')) + .then(res => res.json()); + }; + + const { data, error } = useSWR(KEY, fetcher, options); + const [loading, setLoading] = useState(!error && !data); + + const refetchDoraMetrics = () => { + mutate(KEY); + }; + + useEffect(() => { + setLoading(!error && !data); + }, [data, error]); + + return { + dora: data || {}, + error, + loading, + refetchDoraMetrics, + }; +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 812e3cfb51..5c097fb59f 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -58,6 +58,8 @@ export interface IFlags { newApplicationList?: boolean; integrationsRework?: boolean; multipleRoles?: boolean; + doraMetrics?: boolean; + [key: string]: boolean | Variant | undefined; } export interface IVersionInfo { diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 77e911276c..2fd8d2cf80 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -75,6 +75,7 @@ exports[`should create default config 1`] = ` "demo": false, "disableBulkToggle": false, "disableNotifications": false, + "doraMetrics": false, "embedProxy": true, "embedProxyFrontend": true, "featuresExportImport": true, @@ -112,6 +113,7 @@ exports[`should create default config 1`] = ` "demo": false, "disableBulkToggle": false, "disableNotifications": false, + "doraMetrics": false, "embedProxy": true, "embedProxyFrontend": true, "featuresExportImport": true, diff --git a/src/lib/db/project-stats-store.ts b/src/lib/db/project-stats-store.ts index 0f3211067b..7915ba0b6a 100644 --- a/src/lib/db/project-stats-store.ts +++ b/src/lib/db/project-stats-store.ts @@ -9,6 +9,7 @@ import { IProjectStatsStore, } from 'lib/types/stores/project-stats-store-type'; import { Db } from './db'; +import { DoraFeaturesSchema } from 'lib/openapi'; const TABLE = 'project_stats'; @@ -144,6 +145,51 @@ class ProjectStatsStore implements IProjectStatsStore { .orderBy('events.created_at', 'asc'); return result; } + + async getTimeToProdDatesForFeatureToggles( + projectId: string, + featureToggleNames: string[], + ): Promise { + const result = await this.db + .select('events.feature_name') + .distinctOn('events.feature_name') + .select( + this.db.raw( + 'events.created_at as enabled, features.created_at as created', + ), + ) + .from('events') + .innerJoin( + 'environments', + 'environments.name', + '=', + 'events.environment', + ) + .innerJoin('features', 'features.name', '=', 'events.feature_name') + .whereIn('events.feature_name', featureToggleNames) + .where('events.type', '=', 'feature-environment-enabled') + .where('environments.type', '=', 'production') + .where('features.type', '=', 'release') + .where(this.db.raw('events.created_at > features.created_at')) + .where('features.project', '=', projectId) + .orderBy('events.feature_name') + .orderBy('events.created_at', 'asc'); + + const timeDifferenceData: DoraFeaturesSchema[] = result.map((row) => { + const enabledDate = new Date(row.enabled).getTime(); + const createdDate = new Date(row.created).getTime(); + const timeDifferenceInDays = Math.floor( + (enabledDate - createdDate) / (1000 * 60 * 60 * 24), + ); + + return { + name: row.feature_name, + timeToProduction: timeDifferenceInDays, + }; + }); + + return timeDifferenceData; + } } export default ProjectStatsStore; diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index bb1c366aad..d3ed7c82f2 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -155,6 +155,8 @@ import { createStrategyVariantSchema, clientSegmentSchema, createGroupSchema, + doraFeaturesSchema, + projectDoraMetricsSchema, } from './spec'; import { IServerOption } from '../types'; import { mapValues, omitKeys } from '../util'; @@ -367,6 +369,8 @@ export const schemas: UnleashSchemas = { createStrategyVariantSchema, clientSegmentSchema, createGroupSchema, + doraFeaturesSchema, + projectDoraMetricsSchema, }; // Remove JSONSchema keys that would result in an invalid OpenAPI spec. diff --git a/src/lib/openapi/spec/dora-features-schema.ts b/src/lib/openapi/spec/dora-features-schema.ts new file mode 100644 index 0000000000..7f44acf4ed --- /dev/null +++ b/src/lib/openapi/spec/dora-features-schema.ts @@ -0,0 +1,24 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const doraFeaturesSchema = { + $id: '#/components/schemas/doraFeaturesSchema', + type: 'object', + additionalProperties: false, + required: ['name', 'timeToProduction'], + description: + 'The representation of a dora time to production feature metric', + properties: { + name: { + type: 'string', + description: 'The name of a feature toggle', + }, + timeToProduction: { + type: 'number', + description: + 'The average number of days it takes a feature toggle to get into production', + }, + }, + components: {}, +} as const; + +export type DoraFeaturesSchema = FromSchema; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 3d02e293da..8e068b6df6 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -154,3 +154,5 @@ export * from './client-segment-schema'; export * from './update-feature-type-lifetime-schema'; export * from './create-group-schema'; export * from './application-usage-schema'; +export * from './dora-features-schema'; +export * from './project-dora-metrics-schema'; diff --git a/src/lib/openapi/spec/project-dora-metrics-schema.ts b/src/lib/openapi/spec/project-dora-metrics-schema.ts new file mode 100644 index 0000000000..f041bc31e3 --- /dev/null +++ b/src/lib/openapi/spec/project-dora-metrics-schema.ts @@ -0,0 +1,27 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { doraFeaturesSchema } from './dora-features-schema'; + +export const projectDoraMetricsSchema = { + $id: '#/components/schemas/projectDoraMetricsSchema', + type: 'object', + additionalProperties: false, + required: ['features'], + description: 'A projects dora metrics', + properties: { + features: { + type: 'array', + items: { $ref: '#/components/schemas/doraFeaturesSchema' }, + description: + 'An array of objects containing feature toggle name and timeToProduction values', + }, + }, + components: { + schemas: { + doraFeaturesSchema, + }, + }, +} as const; + +export type ProjectDoraMetricsSchema = FromSchema< + typeof projectDoraMetricsSchema +>; diff --git a/src/lib/routes/admin-api/project/index.ts b/src/lib/routes/admin-api/project/index.ts index 227064ef67..7de2cd7cc5 100644 --- a/src/lib/routes/admin-api/project/index.ts +++ b/src/lib/routes/admin-api/project/index.ts @@ -15,6 +15,8 @@ import ProjectService from '../../../services/project-service'; import VariantsController from './variants'; import { createResponseSchema, + ProjectDoraMetricsSchema, + projectDoraMetricsSchema, ProjectOverviewSchema, projectOverviewSchema, projectsSchema, @@ -27,6 +29,7 @@ import { ProjectApiTokenController } from './api-token'; import ProjectArchiveController from './project-archive'; import { createKnexTransactionStarter } from '../../../db/transaction'; import { Db } from '../../../db/db'; +import { InvalidOperationError } from '../../../error'; export default class ProjectApi extends Controller { private projectService: ProjectService; @@ -81,6 +84,26 @@ export default class ProjectApi extends Controller { ], }); + this.route({ + method: 'get', + path: '/:projectId/dora', + handler: this.getProjectDora, + permission: NONE, + middleware: [ + services.openApiService.validPath({ + tags: ['Projects'], + operationId: 'getProjectDora', + summary: 'Get an overview project dora metrics.', + description: + 'This endpoint returns an overview of the specified dora metrics', + responses: { + 200: createResponseSchema('projectDoraMetricsSchema'), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); + this.use( '/', new ProjectFeaturesController( @@ -136,4 +159,26 @@ export default class ProjectApi extends Controller { serializeDates(overview), ); } + + async getProjectDora( + req: IAuthRequest, + res: Response, + ): Promise { + if (this.config.flagResolver.isEnabled('doraMetrics')) { + const { projectId } = req.params; + + const dora = await this.projectService.getDoraMetrics(projectId); + + this.openApiService.respondWithValidation( + 200, + res, + projectDoraMetricsSchema.$id, + dora, + ); + } else { + throw new InvalidOperationError( + 'Feature dora metrics is not enabled', + ); + } + } } diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index c3b0012df0..d6b175904a 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -56,6 +56,7 @@ import { calculateAverageTimeToProd } from '../features/feature-toggle/time-to-p import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type'; import { uniqueByKey } from '../util/unique'; import { PermissionError } from '../error'; +import { ProjectDoraMetricsSchema } from 'lib/openapi'; const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown'; @@ -684,6 +685,20 @@ export default class ProjectService { } } + async getDoraMetrics(projectId: string): Promise { + const featureToggleNames = (await this.featureToggleStore.getAll()).map( + (feature) => feature.name, + ); + + const avgTimeToProductionPerToggle = + await this.projectStatsStore.getTimeToProdDatesForFeatureToggles( + projectId, + featureToggleNames, + ); + + return { features: avgTimeToProductionPerToggle }; + } + async changeRole( projectId: string, roleId: number, @@ -959,6 +974,7 @@ export default class ProjectService { : Promise.resolve(false), this.projectStatsStore.getProjectStats(projectId), ]); + return { stats: projectStats, name: project.name, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 3536f06908..ba7926d512 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -29,7 +29,8 @@ export type IFlagKey = | 'customRootRolesKillSwitch' | 'newApplicationList' | 'integrationsRework' - | 'multipleRoles'; + | 'multipleRoles' + | 'doraMetrics'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -137,6 +138,7 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_NEW_APPLICATION_LIST, false, ), + doraMetrics: parseEnvVarBoolean(process.env.UNLEASH_DORA_METRICS, false), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/lib/types/stores/project-stats-store-type.ts b/src/lib/types/stores/project-stats-store-type.ts index b8b0558cbe..6d80d2029f 100644 --- a/src/lib/types/stores/project-stats-store-type.ts +++ b/src/lib/types/stores/project-stats-store-type.ts @@ -1,3 +1,4 @@ +import { DoraFeaturesSchema } from 'lib/openapi'; import { IProjectStats } from 'lib/services/project-service'; export interface ICreateEnabledDates { @@ -9,4 +10,8 @@ export interface IProjectStatsStore { updateProjectStats(projectId: string, status: IProjectStats): Promise; getProjectStats(projectId: string): Promise; getTimeToProdDates(projectId: string): Promise; + getTimeToProdDatesForFeatureToggles( + projectId: string, + toggleNames: string[], + ): Promise; } diff --git a/src/server-dev.ts b/src/server-dev.ts index 7922fe17e9..438ee7929f 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -42,6 +42,7 @@ process.nextTick(async () => { lastSeenByEnvironment: true, segmentChangeRequests: true, newApplicationList: true, + doraMetrics: true, }, }, authentication: { diff --git a/src/test/fixtures/fake-project-stats-store.ts b/src/test/fixtures/fake-project-stats-store.ts index d42f25869e..bfcbabad2c 100644 --- a/src/test/fixtures/fake-project-stats-store.ts +++ b/src/test/fixtures/fake-project-stats-store.ts @@ -20,4 +20,8 @@ export default class FakeProjectStatsStore implements IProjectStatsStore { getTimeToProdDates(): Promise { throw new Error('not implemented'); } + + getTimeToProdDatesForFeatureToggles(): Promise { + throw new Error('not implemented'); + } }