diff --git a/frontend/src/component/project/Project/Project.tsx b/frontend/src/component/project/Project/Project.tsx index 46b6064454..0b5c272737 100644 --- a/frontend/src/component/project/Project/Project.tsx +++ b/frontend/src/component/project/Project/Project.tsx @@ -37,6 +37,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 { Badge } from 'component/common/Badge/Badge'; import { ProjectDoraMetrics } from './ProjectDoraMetrics/ProjectDoraMetrics'; export const Project = () => { @@ -61,18 +62,21 @@ export const Project = () => { path: basePath, name: 'overview', flag: undefined, + new: false, }, { title: 'Health', path: `${basePath}/health`, name: 'health', flag: undefined, + new: false, }, { title: 'Archive', path: `${basePath}/archive`, name: 'archive', flag: undefined, + new: false, }, { title: 'Change requests', @@ -80,24 +84,28 @@ export const Project = () => { name: 'change-request', isEnterprise: true, flag: undefined, + new: false, }, { - title: 'DORA Metrics', - path: `${basePath}/dora`, + title: 'Metrics', + path: `${basePath}/metrics`, name: 'dora', flag: 'doraMetrics', + new: true, }, { title: 'Event log', path: `${basePath}/logs`, name: 'logs', flag: undefined, + new: false, }, { title: 'Project settings', path: `${basePath}/settings`, name: 'settings', flag: undefined, + new: false, }, ] .filter(tab => { @@ -216,10 +224,28 @@ export const Project = () => { tab.isEnterprise ? 'end' : undefined } icon={ - (tab.isEnterprise && - isPro() && - enterpriseIcon) || - undefined + <> + + New + + } + /> + {(tab.isEnterprise && + isPro() && + enterpriseIcon) || + undefined} + } /> ); @@ -260,7 +286,7 @@ export const Project = () => { element={} /> } /> - } /> + } /> } /> ({ + backgroundColor: theme.palette.background.paper, + padding: theme.spacing(2, 4), + borderRadius: theme.shape.borderRadiusLarge, + marginBottom: theme.spacing(2), +})); + +const StyledBtnContainer = styled(Box)(({ theme }) => ({ + marginTop: theme.spacing(3), +})); + +const StyledDivider = styled(Divider)(({ theme }) => ({ + marginTop: theme.spacing(3), + marginBottom: theme.spacing(1.5), +})); + +const StyledFlexBox = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + marginTop: theme.spacing(1), + marginRight: theme.spacing(3), +})); + +const StyledIconWrapper = styled(Box)(({ theme }) => ({ + color: theme.palette.primary.main, + marginRight: theme.spacing(1), + display: 'flex', + alignItems: 'center', +})); + +const StyledHeader = styled(Typography)(({ theme }) => ({ + fontSize: theme.fontSizes.mainHeader, + marginBottom: theme.spacing(2), + fontWeight: 'bold', +})); + +const StyledLink = styled('a')(({ theme }) => ({ + textDecoration: 'none', +})); + +export const ProjectDoraFeedback = () => { + const { trackEvent } = usePlausibleTracker(); + const { value, setValue } = createLocalStorage( + `project:metrics:plausible`, + { sent: false } + ); + const [metrics, setMetrics] = useState(value); + + useEffect(() => { + setValue(metrics); + }, [metrics]); + + const onBtnClick = (type: string) => { + try { + trackEvent('project-metrics', { + props: { + eventType: type, + }, + }); + + if (!Boolean(metrics.sent)) { + setMetrics({ sent: true }); + } + } catch (e) { + console.error('error sending metrics'); + } + }; + + const recipientEmail = 'recipient@example.com'; + const emailSubject = "I'd like to get involved"; + const emailBody = `Hello Unleash,\n\nI just saw the new metrics page you are experimenting with in Unleash. I'd like to be involved in user tests and give my feedback on this feature.\n\nRegards,\n`; + + const mailtoURL = `mailto:${recipientEmail}?subject=${encodeURIComponent( + emailSubject + )}&body=${encodeURIComponent(emailBody)}`; + + return ( + + + We are trying something experimental! + + + We are considering adding project metrics to see how a project + performs. As a first step, we have added a{' '} + lead time for changes indicator that is calculated per + feature toggle based on the creation of the feature toggle and + when it was first turned on in an environment of type + production. + + + + {' '} + Is this useful to you? + + + + + + } + elseShow={ + ({ marginTop: theme.spacing(3) })}> + Thank you for the feedback. Feel free to check out the + sketches and leave comments, or get in touch with our UX + team if you'd like to be involved in usertests and the + development of this feature. + + } + /> + + + + + + + + + View sketches + + + + + + + + + Get involved with our UX team + + + + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectDoraMetrics/ProjectDoraMetrics.tsx b/frontend/src/component/project/Project/ProjectDoraMetrics/ProjectDoraMetrics.tsx index f300e7552f..5c1a4212e7 100644 --- a/frontend/src/component/project/Project/ProjectDoraMetrics/ProjectDoraMetrics.tsx +++ b/frontend/src/component/project/Project/ProjectDoraMetrics/ProjectDoraMetrics.tsx @@ -15,6 +15,7 @@ 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'; +import { ProjectDoraFeedback } from './ProjectDoraFeedback/ProjectDoraFeedback'; const resolveDoraMetrics = (input: number) => { const ONE_MONTH = 30; @@ -42,7 +43,7 @@ export const ProjectDoraMetrics = () => { if (loading) { return Array(5).fill({ name: 'Featurename', - timeToProduction: 'Tag type for production', + timeToProduction: 'Data for production', }); } @@ -54,7 +55,7 @@ export const ProjectDoraMetrics = () => { { Header: 'Name', accessor: 'name', - width: '50%', + width: '40%', Cell: ({ row: { original: { name }, @@ -90,7 +91,23 @@ export const ProjectDoraMetrics = () => { {original.timeToProduction} days ), - width: 150, + width: 200, + disableGlobalFilter: true, + disableSortBy: true, + }, + { + Header: `Deviation`, + id: 'Deviation from average', + align: 'center', + Cell: ({ row: { original } }: any) => ( + + {dora.projectAverage - original.timeToProduction} days + + ), + width: 300, disableGlobalFilter: true, disableSortBy: true, }, @@ -143,51 +160,49 @@ export const ProjectDoraMetrics = () => { ); 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. - - } + <> + + } - /> - + > + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
+ 0} + show={ + + No features with data found “ + {globalFilter} + ” + + } + /> + } + /> +
+ ); }; diff --git a/frontend/src/hooks/usePlausibleTracker.ts b/frontend/src/hooks/usePlausibleTracker.ts index 1540504afb..71b3caf1d1 100644 --- a/frontend/src/hooks/usePlausibleTracker.ts +++ b/frontend/src/hooks/usePlausibleTracker.ts @@ -45,6 +45,7 @@ export type CustomEvents = | 'feature-type-edit' | 'strategy-variants' | 'search-filter-suggestions' + | 'project-metrics' | 'open-integration'; export const usePlausibleTracker = () => { diff --git a/src/lib/openapi/spec/project-dora-metrics-schema.ts b/src/lib/openapi/spec/project-dora-metrics-schema.ts index f041bc31e3..2591ccf535 100644 --- a/src/lib/openapi/spec/project-dora-metrics-schema.ts +++ b/src/lib/openapi/spec/project-dora-metrics-schema.ts @@ -8,11 +8,16 @@ export const projectDoraMetricsSchema = { required: ['features'], description: 'A projects dora metrics', properties: { + projectAverage: { + type: 'number', + description: + 'The average time it takes a feature toggle to be enabled in production. The measurement unit is days.', + }, features: { type: 'array', items: { $ref: '#/components/schemas/doraFeaturesSchema' }, description: - 'An array of objects containing feature toggle name and timeToProduction values', + 'An array of objects containing feature toggle name and timeToProduction values. The measurement unit of timeToProduction is days.', }, }, components: { diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 84a7b97fab..e77683c1f9 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -719,17 +719,33 @@ export default class ProjectService { } async getDoraMetrics(projectId: string): Promise { - const featureToggleNames = (await this.featureToggleStore.getAll()).map( - (feature) => feature.name, + const activeFeatureToggles = ( + await this.featureToggleStore.getAll({ project: projectId }) + ).map((feature) => feature.name); + + const archivedFeatureToggles = ( + await this.featureToggleStore.getAll({ + project: projectId, + archived: true, + }) + ).map((feature) => feature.name); + + const featureToggleNames = [ + ...activeFeatureToggles, + ...archivedFeatureToggles, + ]; + + const projectAverage = calculateAverageTimeToProd( + await this.projectStatsStore.getTimeToProdDates(projectId), ); - const avgTimeToProductionPerToggle = + const toggleAverage = await this.projectStatsStore.getTimeToProdDatesForFeatureToggles( projectId, featureToggleNames, ); - return { features: avgTimeToProductionPerToggle }; + return { features: toggleAverage, projectAverage: projectAverage }; } async changeRole( diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts index ad5ef64a40..92eec4d607 100644 --- a/src/test/e2e/services/project-service.e2e.test.ts +++ b/src/test/e2e/services/project-service.e2e.test.ts @@ -1603,3 +1603,219 @@ test('should get correct amount of project members for current and past window', expect(result.updates.projectActivityCurrentWindow).toBe(6); expect(result.updates.projectActivityPastWindow).toBe(0); }); + +test('should return average time to production per toggle', async () => { + const project = { + id: 'average-time-to-prod-per-toggle', + name: 'average-time-to-prod-per-toggle', + mode: 'open' as const, + defaultStickiness: 'clientId', + }; + + await projectService.createProject(project, user.id); + + const toggles = [ + { name: 'average-prod-time-pt', subdays: 7 }, + { name: 'average-prod-time-pt-2', subdays: 14 }, + { name: 'average-prod-time-pt-3', subdays: 40 }, + { name: 'average-prod-time-pt-4', subdays: 15 }, + { name: 'average-prod-time-pt-5', subdays: 2 }, + ]; + + const featureToggles = await Promise.all( + toggles.map((toggle) => { + return featureToggleService.createFeatureToggle( + project.id, + toggle, + user, + ); + }), + ); + + await Promise.all( + featureToggles.map((toggle) => { + return stores.eventStore.store( + new FeatureEnvironmentEvent({ + enabled: true, + project: project.id, + featureName: toggle.name, + environment: 'default', + createdBy: 'Fredrik', + tags: [], + }), + ); + }), + ); + + await Promise.all( + toggles.map((toggle) => + updateFeature(toggle.name, { + created_at: subDays(new Date(), toggle.subdays), + }), + ), + ); + + const result = await projectService.getDoraMetrics(project.id); + + expect(result.features).toHaveLength(5); + expect(result.features[0].timeToProduction).toBeTruthy(); + expect(result.projectAverage).toBeTruthy(); +}); + +test('should return average time to production per toggle for a specific project', async () => { + const project1 = { + id: 'average-time-to-prod-per-toggle-1', + name: 'Project 1', + mode: 'open' as const, + defaultStickiness: 'clientId', + }; + + const project2 = { + id: 'average-time-to-prod-per-toggle-2', + name: 'Project 2', + mode: 'open' as const, + defaultStickiness: 'clientId', + }; + + await projectService.createProject(project1, user.id); + await projectService.createProject(project2, user.id); + + const togglesProject1 = [ + { name: 'average-prod-time-pt-10', subdays: 7 }, + { name: 'average-prod-time-pt-11', subdays: 14 }, + { name: 'average-prod-time-pt-12', subdays: 40 }, + ]; + + const togglesProject2 = [ + { name: 'average-prod-time-pt-13', subdays: 15 }, + { name: 'average-prod-time-pt-14', subdays: 2 }, + ]; + + const featureTogglesProject1 = await Promise.all( + togglesProject1.map((toggle) => { + return featureToggleService.createFeatureToggle( + project1.id, + toggle, + user, + ); + }), + ); + + const featureTogglesProject2 = await Promise.all( + togglesProject2.map((toggle) => { + return featureToggleService.createFeatureToggle( + project2.id, + toggle, + user, + ); + }), + ); + + await Promise.all( + featureTogglesProject1.map((toggle) => { + return stores.eventStore.store( + new FeatureEnvironmentEvent({ + enabled: true, + project: project1.id, + featureName: toggle.name, + environment: 'default', + createdBy: 'Fredrik', + tags: [], + }), + ); + }), + ); + + await Promise.all( + featureTogglesProject2.map((toggle) => { + return stores.eventStore.store( + new FeatureEnvironmentEvent({ + enabled: true, + project: project2.id, + featureName: toggle.name, + environment: 'default', + createdBy: 'Fredrik', + tags: [], + }), + ); + }), + ); + + await Promise.all( + togglesProject1.map((toggle) => + updateFeature(toggle.name, { + created_at: subDays(new Date(), toggle.subdays), + }), + ), + ); + + await Promise.all( + togglesProject2.map((toggle) => + updateFeature(toggle.name, { + created_at: subDays(new Date(), toggle.subdays), + }), + ), + ); + + const resultProject1 = await projectService.getDoraMetrics(project1.id); + const resultProject2 = await projectService.getDoraMetrics(project2.id); + + expect(resultProject1.features).toHaveLength(3); + expect(resultProject2.features).toHaveLength(2); +}); + +test('should return average time to production per toggle and include archived toggles', async () => { + const project1 = { + id: 'average-time-to-prod-per-toggle-12', + name: 'Project 1', + mode: 'open' as const, + defaultStickiness: 'clientId', + }; + + await projectService.createProject(project1, user.id); + + const togglesProject1 = [ + { name: 'average-prod-time-pta-10', subdays: 7 }, + { name: 'average-prod-time-pta-11', subdays: 14 }, + { name: 'average-prod-time-pta-12', subdays: 40 }, + ]; + + const featureTogglesProject1 = await Promise.all( + togglesProject1.map((toggle) => { + return featureToggleService.createFeatureToggle( + project1.id, + toggle, + user, + ); + }), + ); + + await Promise.all( + featureTogglesProject1.map((toggle) => { + return stores.eventStore.store( + new FeatureEnvironmentEvent({ + enabled: true, + project: project1.id, + featureName: toggle.name, + environment: 'default', + createdBy: 'Fredrik', + tags: [], + }), + ); + }), + ); + + await Promise.all( + togglesProject1.map((toggle) => + updateFeature(toggle.name, { + created_at: subDays(new Date(), toggle.subdays), + }), + ), + ); + + await featureToggleService.archiveToggle('average-prod-time-pta-12', user); + + const resultProject1 = await projectService.getDoraMetrics(project1.id); + + expect(resultProject1.features).toHaveLength(3); +});