mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
feat: DORA metrics lead time to production (#4589)
* Adds initial support for estimating dora metric lead time for changes per feature toggle
This commit is contained in:
parent
ff346adb94
commit
3b2d6a4cbf
@ -41,6 +41,7 @@ import { useFavoriteProjectsApi } from 'hooks/api/actions/useFavoriteProjectsApi
|
|||||||
import { ImportModal } from './Import/ImportModal';
|
import { ImportModal } from './Import/ImportModal';
|
||||||
import { IMPORT_BUTTON } from 'utils/testIds';
|
import { IMPORT_BUTTON } from 'utils/testIds';
|
||||||
import { EnterpriseBadge } from 'component/common/EnterpriseBadge/EnterpriseBadge';
|
import { EnterpriseBadge } from 'component/common/EnterpriseBadge/EnterpriseBadge';
|
||||||
|
import { ProjectDoraMetrics } from './ProjectDoraMetrics/ProjectDoraMetrics';
|
||||||
|
|
||||||
export const Project = () => {
|
export const Project = () => {
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
@ -63,34 +64,53 @@ export const Project = () => {
|
|||||||
title: 'Overview',
|
title: 'Overview',
|
||||||
path: basePath,
|
path: basePath,
|
||||||
name: 'overview',
|
name: 'overview',
|
||||||
|
flag: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Health',
|
title: 'Health',
|
||||||
path: `${basePath}/health`,
|
path: `${basePath}/health`,
|
||||||
name: 'health',
|
name: 'health',
|
||||||
|
flag: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Archive',
|
title: 'Archive',
|
||||||
path: `${basePath}/archive`,
|
path: `${basePath}/archive`,
|
||||||
name: 'archive',
|
name: 'archive',
|
||||||
|
flag: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Change requests',
|
title: 'Change requests',
|
||||||
path: `${basePath}/change-requests`,
|
path: `${basePath}/change-requests`,
|
||||||
name: 'change-request',
|
name: 'change-request',
|
||||||
isEnterprise: true,
|
isEnterprise: true,
|
||||||
|
flag: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'DORA Metrics',
|
||||||
|
path: `${basePath}/dora`,
|
||||||
|
name: 'dora',
|
||||||
|
flag: 'doraMetrics',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Event log',
|
title: 'Event log',
|
||||||
path: `${basePath}/logs`,
|
path: `${basePath}/logs`,
|
||||||
name: 'logs',
|
name: 'logs',
|
||||||
|
flag: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Project settings',
|
title: 'Project settings',
|
||||||
path: `${basePath}/settings`,
|
path: `${basePath}/settings`,
|
||||||
name: '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]
|
const activeTab = [...tabs]
|
||||||
.reverse()
|
.reverse()
|
||||||
@ -188,7 +208,8 @@ export const Project = () => {
|
|||||||
variant="scrollable"
|
variant="scrollable"
|
||||||
allowScrollButtonsMobile
|
allowScrollButtonsMobile
|
||||||
>
|
>
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => {
|
||||||
|
return (
|
||||||
<StyledTab
|
<StyledTab
|
||||||
key={tab.title}
|
key={tab.title}
|
||||||
label={tab.title}
|
label={tab.title}
|
||||||
@ -205,7 +226,8 @@ export const Project = () => {
|
|||||||
undefined
|
undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</StyledTabContainer>
|
</StyledTabContainer>
|
||||||
</StyledHeader>
|
</StyledHeader>
|
||||||
@ -242,6 +264,7 @@ export const Project = () => {
|
|||||||
element={<ChangeRequestOverview />}
|
element={<ChangeRequestOverview />}
|
||||||
/>
|
/>
|
||||||
<Route path="settings/*" element={<ProjectSettings />} />
|
<Route path="settings/*" element={<ProjectSettings />} />
|
||||||
|
<Route path="dora" element={<ProjectDoraMetrics />} />
|
||||||
<Route path="*" element={<ProjectOverview />} />
|
<Route path="*" element={<ProjectOverview />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<ImportModal
|
<ImportModal
|
||||||
|
@ -0,0 +1,194 @@
|
|||||||
|
import { Box, List, ListItem } from '@mui/material';
|
||||||
|
import { useProjectDoraMetrics } from 'hooks/api/getters/useProjectDoraMetrics/useProjectDoraMetrics';
|
||||||
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTable, useGlobalFilter, useSortBy } from 'react-table';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
SortableTableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
TablePlaceholder,
|
||||||
|
} from 'component/common/Table';
|
||||||
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||||
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
|
|
||||||
|
const resolveDoraMetrics = (input: number) => {
|
||||||
|
const ONE_MONTH = 30;
|
||||||
|
const ONE_WEEK = 7;
|
||||||
|
|
||||||
|
if (input >= ONE_MONTH) {
|
||||||
|
return <Badge color="error">Low</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input <= ONE_MONTH && input >= ONE_WEEK + 1) {
|
||||||
|
return <Badge>Medium</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input <= ONE_WEEK) {
|
||||||
|
return <Badge color="success">High</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box
|
||||||
|
data-loading
|
||||||
|
sx={{
|
||||||
|
pl: 2,
|
||||||
|
pr: 1,
|
||||||
|
paddingTop: 2,
|
||||||
|
paddingBottom: 2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortType: 'alphanumeric',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Time to production',
|
||||||
|
id: 'Time to production',
|
||||||
|
align: 'center',
|
||||||
|
Cell: ({ row: { original } }: any) => (
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'flex', justifyContent: 'center' }}
|
||||||
|
data-loading
|
||||||
|
>
|
||||||
|
{original.timeToProduction} days
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
width: 150,
|
||||||
|
disableGlobalFilter: true,
|
||||||
|
disableSortBy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'DORA',
|
||||||
|
id: 'dora',
|
||||||
|
align: 'center',
|
||||||
|
Cell: ({ row: { original } }: any) => (
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'flex', justifyContent: 'center' }}
|
||||||
|
data-loading
|
||||||
|
>
|
||||||
|
{resolveDoraMetrics(original.timeToProduction)}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
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 (
|
||||||
|
<PageContent
|
||||||
|
isLoading={loading}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title={`Lead time for changes (per feature toggle)`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table {...getTableProps()}>
|
||||||
|
<SortableTableHeader headerGroups={headerGroups} />
|
||||||
|
<TableBody {...getTableBodyProps()}>
|
||||||
|
{rows.map(row => {
|
||||||
|
prepareRow(row);
|
||||||
|
return (
|
||||||
|
<TableRow hover {...row.getRowProps()}>
|
||||||
|
{row.cells.map(cell => (
|
||||||
|
<TableCell {...cell.getCellProps()}>
|
||||||
|
{cell.render('Cell')}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={rows.length === 0}
|
||||||
|
show={
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={globalFilter?.length > 0}
|
||||||
|
show={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No tags found matching “
|
||||||
|
{globalFilter}
|
||||||
|
”
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No tags available. Get started by adding one.
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
};
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
@ -58,6 +58,8 @@ export interface IFlags {
|
|||||||
newApplicationList?: boolean;
|
newApplicationList?: boolean;
|
||||||
integrationsRework?: boolean;
|
integrationsRework?: boolean;
|
||||||
multipleRoles?: boolean;
|
multipleRoles?: boolean;
|
||||||
|
doraMetrics?: boolean;
|
||||||
|
[key: string]: boolean | Variant | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
@ -75,6 +75,7 @@ exports[`should create default config 1`] = `
|
|||||||
"demo": false,
|
"demo": false,
|
||||||
"disableBulkToggle": false,
|
"disableBulkToggle": false,
|
||||||
"disableNotifications": false,
|
"disableNotifications": false,
|
||||||
|
"doraMetrics": false,
|
||||||
"embedProxy": true,
|
"embedProxy": true,
|
||||||
"embedProxyFrontend": true,
|
"embedProxyFrontend": true,
|
||||||
"featuresExportImport": true,
|
"featuresExportImport": true,
|
||||||
@ -112,6 +113,7 @@ exports[`should create default config 1`] = `
|
|||||||
"demo": false,
|
"demo": false,
|
||||||
"disableBulkToggle": false,
|
"disableBulkToggle": false,
|
||||||
"disableNotifications": false,
|
"disableNotifications": false,
|
||||||
|
"doraMetrics": false,
|
||||||
"embedProxy": true,
|
"embedProxy": true,
|
||||||
"embedProxyFrontend": true,
|
"embedProxyFrontend": true,
|
||||||
"featuresExportImport": true,
|
"featuresExportImport": true,
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
IProjectStatsStore,
|
IProjectStatsStore,
|
||||||
} from 'lib/types/stores/project-stats-store-type';
|
} from 'lib/types/stores/project-stats-store-type';
|
||||||
import { Db } from './db';
|
import { Db } from './db';
|
||||||
|
import { DoraFeaturesSchema } from 'lib/openapi';
|
||||||
|
|
||||||
const TABLE = 'project_stats';
|
const TABLE = 'project_stats';
|
||||||
|
|
||||||
@ -144,6 +145,51 @@ class ProjectStatsStore implements IProjectStatsStore {
|
|||||||
.orderBy('events.created_at', 'asc');
|
.orderBy('events.created_at', 'asc');
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTimeToProdDatesForFeatureToggles(
|
||||||
|
projectId: string,
|
||||||
|
featureToggleNames: string[],
|
||||||
|
): Promise<DoraFeaturesSchema[]> {
|
||||||
|
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;
|
export default ProjectStatsStore;
|
||||||
|
@ -155,6 +155,8 @@ import {
|
|||||||
createStrategyVariantSchema,
|
createStrategyVariantSchema,
|
||||||
clientSegmentSchema,
|
clientSegmentSchema,
|
||||||
createGroupSchema,
|
createGroupSchema,
|
||||||
|
doraFeaturesSchema,
|
||||||
|
projectDoraMetricsSchema,
|
||||||
} from './spec';
|
} from './spec';
|
||||||
import { IServerOption } from '../types';
|
import { IServerOption } from '../types';
|
||||||
import { mapValues, omitKeys } from '../util';
|
import { mapValues, omitKeys } from '../util';
|
||||||
@ -367,6 +369,8 @@ export const schemas: UnleashSchemas = {
|
|||||||
createStrategyVariantSchema,
|
createStrategyVariantSchema,
|
||||||
clientSegmentSchema,
|
clientSegmentSchema,
|
||||||
createGroupSchema,
|
createGroupSchema,
|
||||||
|
doraFeaturesSchema,
|
||||||
|
projectDoraMetricsSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove JSONSchema keys that would result in an invalid OpenAPI spec.
|
// Remove JSONSchema keys that would result in an invalid OpenAPI spec.
|
||||||
|
24
src/lib/openapi/spec/dora-features-schema.ts
Normal file
24
src/lib/openapi/spec/dora-features-schema.ts
Normal file
@ -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<typeof doraFeaturesSchema>;
|
@ -154,3 +154,5 @@ export * from './client-segment-schema';
|
|||||||
export * from './update-feature-type-lifetime-schema';
|
export * from './update-feature-type-lifetime-schema';
|
||||||
export * from './create-group-schema';
|
export * from './create-group-schema';
|
||||||
export * from './application-usage-schema';
|
export * from './application-usage-schema';
|
||||||
|
export * from './dora-features-schema';
|
||||||
|
export * from './project-dora-metrics-schema';
|
||||||
|
27
src/lib/openapi/spec/project-dora-metrics-schema.ts
Normal file
27
src/lib/openapi/spec/project-dora-metrics-schema.ts
Normal file
@ -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
|
||||||
|
>;
|
@ -15,6 +15,8 @@ import ProjectService from '../../../services/project-service';
|
|||||||
import VariantsController from './variants';
|
import VariantsController from './variants';
|
||||||
import {
|
import {
|
||||||
createResponseSchema,
|
createResponseSchema,
|
||||||
|
ProjectDoraMetricsSchema,
|
||||||
|
projectDoraMetricsSchema,
|
||||||
ProjectOverviewSchema,
|
ProjectOverviewSchema,
|
||||||
projectOverviewSchema,
|
projectOverviewSchema,
|
||||||
projectsSchema,
|
projectsSchema,
|
||||||
@ -27,6 +29,7 @@ import { ProjectApiTokenController } from './api-token';
|
|||||||
import ProjectArchiveController from './project-archive';
|
import ProjectArchiveController from './project-archive';
|
||||||
import { createKnexTransactionStarter } from '../../../db/transaction';
|
import { createKnexTransactionStarter } from '../../../db/transaction';
|
||||||
import { Db } from '../../../db/db';
|
import { Db } from '../../../db/db';
|
||||||
|
import { InvalidOperationError } from '../../../error';
|
||||||
|
|
||||||
export default class ProjectApi extends Controller {
|
export default class ProjectApi extends Controller {
|
||||||
private projectService: ProjectService;
|
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(
|
this.use(
|
||||||
'/',
|
'/',
|
||||||
new ProjectFeaturesController(
|
new ProjectFeaturesController(
|
||||||
@ -136,4 +159,26 @@ export default class ProjectApi extends Controller {
|
|||||||
serializeDates(overview),
|
serializeDates(overview),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProjectDora(
|
||||||
|
req: IAuthRequest,
|
||||||
|
res: Response<ProjectDoraMetricsSchema>,
|
||||||
|
): Promise<void> {
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -56,6 +56,7 @@ import { calculateAverageTimeToProd } from '../features/feature-toggle/time-to-p
|
|||||||
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
||||||
import { uniqueByKey } from '../util/unique';
|
import { uniqueByKey } from '../util/unique';
|
||||||
import { PermissionError } from '../error';
|
import { PermissionError } from '../error';
|
||||||
|
import { ProjectDoraMetricsSchema } from 'lib/openapi';
|
||||||
|
|
||||||
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
|
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
|
||||||
|
|
||||||
@ -684,6 +685,20 @@ export default class ProjectService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDoraMetrics(projectId: string): Promise<ProjectDoraMetricsSchema> {
|
||||||
|
const featureToggleNames = (await this.featureToggleStore.getAll()).map(
|
||||||
|
(feature) => feature.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
const avgTimeToProductionPerToggle =
|
||||||
|
await this.projectStatsStore.getTimeToProdDatesForFeatureToggles(
|
||||||
|
projectId,
|
||||||
|
featureToggleNames,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { features: avgTimeToProductionPerToggle };
|
||||||
|
}
|
||||||
|
|
||||||
async changeRole(
|
async changeRole(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
roleId: number,
|
roleId: number,
|
||||||
@ -959,6 +974,7 @@ export default class ProjectService {
|
|||||||
: Promise.resolve(false),
|
: Promise.resolve(false),
|
||||||
this.projectStatsStore.getProjectStats(projectId),
|
this.projectStatsStore.getProjectStats(projectId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stats: projectStats,
|
stats: projectStats,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
|
@ -29,7 +29,8 @@ export type IFlagKey =
|
|||||||
| 'customRootRolesKillSwitch'
|
| 'customRootRolesKillSwitch'
|
||||||
| 'newApplicationList'
|
| 'newApplicationList'
|
||||||
| 'integrationsRework'
|
| 'integrationsRework'
|
||||||
| 'multipleRoles';
|
| 'multipleRoles'
|
||||||
|
| 'doraMetrics';
|
||||||
|
|
||||||
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||||
|
|
||||||
@ -137,6 +138,7 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_NEW_APPLICATION_LIST,
|
process.env.UNLEASH_EXPERIMENTAL_NEW_APPLICATION_LIST,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
doraMetrics: parseEnvVarBoolean(process.env.UNLEASH_DORA_METRICS, false),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultExperimentalOptions: IExperimentalOptions = {
|
export const defaultExperimentalOptions: IExperimentalOptions = {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { DoraFeaturesSchema } from 'lib/openapi';
|
||||||
import { IProjectStats } from 'lib/services/project-service';
|
import { IProjectStats } from 'lib/services/project-service';
|
||||||
|
|
||||||
export interface ICreateEnabledDates {
|
export interface ICreateEnabledDates {
|
||||||
@ -9,4 +10,8 @@ export interface IProjectStatsStore {
|
|||||||
updateProjectStats(projectId: string, status: IProjectStats): Promise<void>;
|
updateProjectStats(projectId: string, status: IProjectStats): Promise<void>;
|
||||||
getProjectStats(projectId: string): Promise<IProjectStats>;
|
getProjectStats(projectId: string): Promise<IProjectStats>;
|
||||||
getTimeToProdDates(projectId: string): Promise<ICreateEnabledDates[]>;
|
getTimeToProdDates(projectId: string): Promise<ICreateEnabledDates[]>;
|
||||||
|
getTimeToProdDatesForFeatureToggles(
|
||||||
|
projectId: string,
|
||||||
|
toggleNames: string[],
|
||||||
|
): Promise<DoraFeaturesSchema[]>;
|
||||||
}
|
}
|
||||||
|
@ -42,6 +42,7 @@ process.nextTick(async () => {
|
|||||||
lastSeenByEnvironment: true,
|
lastSeenByEnvironment: true,
|
||||||
segmentChangeRequests: true,
|
segmentChangeRequests: true,
|
||||||
newApplicationList: true,
|
newApplicationList: true,
|
||||||
|
doraMetrics: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
|
@ -20,4 +20,8 @@ export default class FakeProjectStatsStore implements IProjectStatsStore {
|
|||||||
getTimeToProdDates(): Promise<ICreateEnabledDates[]> {
|
getTimeToProdDates(): Promise<ICreateEnabledDates[]> {
|
||||||
throw new Error('not implemented');
|
throw new Error('not implemented');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTimeToProdDatesForFeatureToggles(): Promise<any> {
|
||||||
|
throw new Error('not implemented');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user