1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

feat: average time to production chart (#6565)

Adds live data to TimeToProductionChart and AverageTimeToProduction
gauge.
Create a custom tooltip

Changes the interaction mode for tooltips as per @nicolaesocaciu pairing
session

Improvement:
Extract grouping by project to its own hook (3 charts that needed
grouped data where handling it independently.

<img width="1331" alt="Screenshot 2024-03-14 at 17 19 07"
src="https://github.com/Unleash/unleash/assets/104830839/199c556c-8264-46e3-9dd5-9a864588de1f">

Closes #
[1-2143](https://linear.app/unleash/issue/1-2143/time-to-production-total-aggregation)

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
andreas-unleash 2024-03-15 10:37:02 +02:00 committed by GitHub
parent 4d78c6dadf
commit 45634689f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 366 additions and 70 deletions

View File

@ -26,6 +26,10 @@ import { ProjectHealthChart } from './componentsChart/ProjectHealthChart/Project
import { MetricsSummaryChart } from './componentsChart/MetricsSummaryChart/MetricsSummaryChart';
import { UsersPerProjectChart } from './componentsChart/UsersPerProjectChart/UsersPerProjectChart';
import { UpdatesPerEnvironmentTypeChart } from './componentsChart/UpdatesPerEnvironmentTypeChart/UpdatesPerEnvironmentTypeChart';
import { TimeToProduction } from './componentsStat/TimeToProduction/TimeToProduction';
import { TimeToProductionChart } from './componentsChart/TimeToProductionChart/TimeToProductionChart';
import { useGroupedProjectTrends } from './hooks/useGroupedProjectTrends';
import { useAvgTimeToProduction } from './hooks/useAvgTimeToProduction';
const StyledGrid = styled(Box)(({ theme }) => ({
display: 'grid',
@ -74,14 +78,21 @@ export const ExecutiveDashboard: VFC = () => {
executiveDashboardData.projectFlagTrends,
projects,
);
const groupedProjectsData = useGroupedProjectTrends(projectsData);
const metricsData = useFilteredTrends(
executiveDashboardData.metricsSummaryTrends,
projects,
);
const groupedMetricsData = useGroupedProjectTrends(metricsData);
const { users, environmentTypeTrends } = executiveDashboardData;
const summary = useFilteredFlagsSummary(projectsData);
const avgDaysToProduction = useAvgTimeToProduction(groupedProjectsData);
const isOneProjectSelected = projects.length === 1;
const handleScroll = () => {
@ -147,7 +158,7 @@ export const ExecutiveDashboard: VFC = () => {
elseShow={
<ChartWidget title='Users per project'>
<UsersPerProjectChart
projectFlagTrends={projectsData}
projectFlagTrends={groupedProjectsData}
/>
</ChartWidget>
}
@ -178,7 +189,7 @@ export const ExecutiveDashboard: VFC = () => {
elseShow={
<ChartWidget title='Flags per project'>
<FlagsProjectChart
projectFlagTrends={projectsData}
projectFlagTrends={groupedProjectsData}
/>
</ChartWidget>
}
@ -200,25 +211,32 @@ export const ExecutiveDashboard: VFC = () => {
}
>
<ProjectHealthChart
projectFlagTrends={projectsData}
projectFlagTrends={groupedProjectsData}
isAggregate={showAllProjects}
/>
</ChartWidget>
{/* <Widget title='Average time to production'>
<TimeToProduction
//FIXME: data from API
daysToProduction={5.2}
/>
<Widget
title='Average time to production'
tooltip='How long did it take on average from a feature toggle was created until it was enabled in an environment of type production. This is calculated only from feature toggles with the type of "release". '
>
<TimeToProduction daysToProduction={avgDaysToProduction} />
</Widget>
<ChartWidget title='Time to production'>
<TimeToProductionChart projectFlagTrends={projectsData} />
</ChartWidget> */}
<ChartWidget
title='Time to production'
tooltip='How the average time to production changes over time'
>
<TimeToProductionChart
projectFlagTrends={groupedProjectsData}
/>
</ChartWidget>
</StyledGrid>
<Widget
title='Metrics'
tooltip='Summary of all flag evaluations reported by SDKs.'
>
<MetricsSummaryChart metricsSummaryTrends={metricsData} />
<MetricsSummaryChart
metricsSummaryTrends={groupedMetricsData}
/>
</Widget>
<Widget
title='Updates per environment type'

View File

@ -28,14 +28,18 @@ export const createOptions = (
tooltip: {
enabled: false,
position: 'nearest',
interaction: {
axis: 'xy',
mode: 'nearest',
},
external: createTooltip(setTooltip),
},
},
locale: locationSettings.locale,
interaction: {
intersect: false,
axis: 'x',
mode: 'index',
axis: 'xy',
mode: 'nearest',
},
elements: {
point: {

View File

@ -4,9 +4,12 @@ import { ExecutiveSummarySchema } from 'openapi';
import { LineChart, NotEnoughData } from '../../components/LineChart/LineChart';
import { useProjectChartData } from 'component/executiveDashboard/hooks/useProjectChartData';
import { usePlaceholderData } from 'component/executiveDashboard/hooks/usePlaceholderData';
import { GroupedDataByProject } from '../../hooks/useGroupedProjectTrends';
interface IFlagsProjectChartProps {
projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends'];
projectFlagTrends: GroupedDataByProject<
ExecutiveSummarySchema['projectFlagTrends']
>;
}
export const FlagsProjectChart: VFC<IFlagsProjectChartProps> = ({

View File

@ -5,9 +5,12 @@ import { LineChart, NotEnoughData } from '../../components/LineChart/LineChart';
import { MetricsSummaryTooltip } from './MetricsChartTooltip/MetricsChartTooltip';
import { useMetricsSummary } from '../../hooks/useMetricsSummary';
import { usePlaceholderData } from 'component/executiveDashboard/hooks/usePlaceholderData';
import { GroupedDataByProject } from '../../hooks/useGroupedProjectTrends';
interface IMetricsSummaryChartProps {
metricsSummaryTrends: ExecutiveSummarySchema['metricsSummaryTrends'];
metricsSummaryTrends: GroupedDataByProject<
ExecutiveSummarySchema['metricsSummaryTrends']
>;
}
export const MetricsSummaryChart: VFC<IMetricsSummaryChartProps> = ({
@ -15,7 +18,7 @@ export const MetricsSummaryChart: VFC<IMetricsSummaryChartProps> = ({
}) => {
const data = useMetricsSummary(metricsSummaryTrends);
const notEnoughData = useMemo(
() => (data.datasets.some((d) => d.data.length > 1) ? false : true),
() => !data.datasets.some((d) => d.data.length > 1),
[data],
);
const placeholderData = usePlaceholderData();

View File

@ -8,13 +8,16 @@ import {
NotEnoughData,
} from 'component/executiveDashboard/components/LineChart/LineChart';
import { useTheme } from '@mui/material';
import { GroupedDataByProject } from '../../hooks/useGroupedProjectTrends';
interface IFlagsProjectChartProps {
projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends'];
interface IProjectHealthChartProps {
projectFlagTrends: GroupedDataByProject<
ExecutiveSummarySchema['projectFlagTrends']
>;
isAggregate?: boolean;
}
export const ProjectHealthChart: VFC<IFlagsProjectChartProps> = ({
export const ProjectHealthChart: VFC<IProjectHealthChartProps> = ({
projectFlagTrends,
isAggregate,
}) => {

View File

@ -1,21 +1,33 @@
import { type VFC } from 'react';
import { useMemo, type VFC } from 'react';
import 'chartjs-adapter-date-fns';
import { ExecutiveSummarySchema } from 'openapi';
import { LineChart } from '../../components/LineChart/LineChart';
import { useProjectChartData } from '../../hooks/useProjectChartData';
import { GroupedDataByProject } from '../../hooks/useGroupedProjectTrends';
import { usePlaceholderData } from '../../hooks/usePlaceholderData';
import { TimeToProductionTooltip } from './TimeToProductionTooltip/TimeToProductionTooltip';
interface IFlagsProjectChartProps {
projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends'];
interface ITimeToProductionChartProps {
projectFlagTrends: GroupedDataByProject<
ExecutiveSummarySchema['projectFlagTrends']
>;
}
export const TimeToProductionChart: VFC<IFlagsProjectChartProps> = ({
export const TimeToProductionChart: VFC<ITimeToProductionChartProps> = ({
projectFlagTrends,
}) => {
const data = useProjectChartData(projectFlagTrends);
const notEnoughData = useMemo(
() => !data.datasets.some((d) => d.data.length > 1),
[data],
);
const placeholderData = usePlaceholderData();
return (
<LineChart
data={data}
data={notEnoughData ? placeholderData : data}
isLocalTooltip
TooltipComponent={TimeToProductionTooltip}
overrideOptions={{
parsing: {
yAxisKey: 'timeToProduction',

View File

@ -0,0 +1,117 @@
import { type VFC } from 'react';
import { type ExecutiveSummarySchemaProjectFlagTrendsItem } from 'openapi';
import { Box, Paper, Typography, styled } from '@mui/material';
import { Badge } from 'component/common/Badge/Badge';
import { TooltipState } from '../../../components/LineChart/ChartTooltip/ChartTooltip';
const StyledTooltipItemContainer = styled(Paper)(({ theme }) => ({
padding: theme.spacing(2),
}));
const StyledItemHeader = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
gap: theme.spacing(2),
alignItems: 'center',
}));
const getInterval = (days?: number) => {
if (!days) {
return 'N/A';
}
if (days > 11) {
const weeks = days / 7;
if (weeks > 6) {
const months = weeks / 4.34524;
return `${months.toFixed(2)} months`;
} else {
return `${weeks.toFixed(1)} weeks`;
}
} else {
return `${days} days`;
}
};
const resolveBadge = (input?: number) => {
const ONE_MONTH = 30;
const ONE_WEEK = 7;
if (!input) {
return null;
}
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 TimeToProductionTooltip: VFC<{ tooltip: TooltipState | null }> = ({
tooltip,
}) => {
const data = tooltip?.dataPoints.map((point) => {
return {
label: point.label,
title: point.dataset.label,
color: point.dataset.borderColor,
value: point.raw as ExecutiveSummarySchemaProjectFlagTrendsItem,
};
});
const limitedData = data?.slice(0, 5);
return (
<Box
sx={(theme) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
width: '300px',
})}
>
{limitedData?.map((point, index) => (
<StyledTooltipItemContainer
elevation={3}
key={`${point.title}-${index}`}
>
<StyledItemHeader>
<Typography
variant='body2'
color='textSecondary'
component='span'
>
{point.label}
</Typography>
</StyledItemHeader>
<StyledItemHeader>
<Typography variant='body2' component='span'>
<Typography
sx={{ color: point.color }}
component='span'
>
{'● '}
</Typography>
<strong>{point.title}</strong>
</Typography>
<Typography
variant='body2'
component='span'
sx={{ mr: (theme) => theme.spacing(1), pt: 0.25 }}
>
{getInterval(point.value.timeToProduction)}
</Typography>
{resolveBadge(point.value.timeToProduction)}
</StyledItemHeader>
</StyledTooltipItemContainer>
)) || null}
</Box>
);
};

View File

@ -4,9 +4,12 @@ import { ExecutiveSummarySchema } from 'openapi';
import { LineChart, NotEnoughData } from '../../components/LineChart/LineChart';
import { useProjectChartData } from 'component/executiveDashboard/hooks/useProjectChartData';
import { usePlaceholderData } from 'component/executiveDashboard/hooks/usePlaceholderData';
import { GroupedDataByProject } from '../../hooks/useGroupedProjectTrends';
interface IUsersPerProjectChartProps {
projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends'];
projectFlagTrends: GroupedDataByProject<
ExecutiveSummarySchema['projectFlagTrends']
>;
}
export const UsersPerProjectChart: VFC<IUsersPerProjectChartProps> = ({

View File

@ -0,0 +1,34 @@
import { useAvgTimeToProduction } from './useAvgTimeToProduction';
import { renderHook } from '@testing-library/react-hooks';
describe('useAvgTimeToProduction', () => {
test('returns 0 when projectsData is empty', () => {
const projectsData = {};
const { result } = renderHook(() =>
useAvgTimeToProduction(projectsData),
);
expect(result.current).toBe(0);
});
test('calculates result.current time to production correctly', () => {
const projectsData = {
project1: [{ timeToProduction: 10 }, { timeToProduction: 20 }],
project2: [{ timeToProduction: 15 }, { timeToProduction: 25 }],
} as any;
const { result } = renderHook(() =>
useAvgTimeToProduction(projectsData),
);
expect(result.current).toBe(17.5);
});
test('ignores projects without time to production data', () => {
const projectsData = {
project1: [{ timeToProduction: 10 }, { timeToProduction: 20 }],
project2: [],
} as any;
const { result } = renderHook(() =>
useAvgTimeToProduction(projectsData),
);
expect(result.current).toBe(7.5);
});
});

View File

@ -0,0 +1,36 @@
import { useMemo } from 'react';
import type { ExecutiveSummarySchema } from 'openapi';
import type { GroupedDataByProject } from './useGroupedProjectTrends';
export const useAvgTimeToProduction = (
projectsData: GroupedDataByProject<
ExecutiveSummarySchema['projectFlagTrends']
>,
) =>
useMemo(() => {
const totalProjects = Object.keys(projectsData).length;
if (totalProjects === 0) {
return 0;
}
const totalAvgTimeToProduction = Object.entries(projectsData).reduce(
(acc, [_, trends]) => {
const validTrends = trends.filter(
(trend) => trend.timeToProduction !== undefined,
);
const avgTimeToProduction =
validTrends.reduce(
(sum, item) => sum + (item.timeToProduction || 0),
0,
) / (validTrends.length || 1);
return acc + (validTrends.length > 0 ? avgTimeToProduction : 0);
},
0,
);
const overallAverage = totalAvgTimeToProduction / totalProjects;
return overallAverage;
}, [projectsData]);

View File

@ -0,0 +1,62 @@
import { useGroupedProjectTrends } from './useGroupedProjectTrends';
import { renderHook } from '@testing-library/react-hooks';
describe('useGroupedProjectTrends', () => {
test('returns an empty object when input data is empty', () => {
const input: any[] = [];
const { result } = renderHook(() => useGroupedProjectTrends(input));
expect(result.current).toEqual({});
});
test('groups data by project correctly', () => {
const input = [
{ project: 'project1', data: 'data1' },
{ project: 'project2', data: 'data2' },
{ project: 'project1', data: 'data3' },
];
const { result } = renderHook(() => useGroupedProjectTrends(input));
expect(result.current).toEqual({
project1: [
{ project: 'project1', data: 'data1' },
{ project: 'project1', data: 'data3' },
],
project2: [{ project: 'project2', data: 'data2' }],
});
});
test('groups complex data by project correctly', () => {
const input = [
{
project: 'project1',
data: { some: { complex: { type: 'data1' } } },
},
{
project: 'project2',
data: { some: { complex: { type: 'data2' } } },
},
{
project: 'project1',
data: { some: { complex: { type: 'data3' } } },
},
];
const { result } = renderHook(() => useGroupedProjectTrends(input));
expect(result.current).toEqual({
project1: [
{
project: 'project1',
data: { some: { complex: { type: 'data1' } } },
},
{
project: 'project1',
data: { some: { complex: { type: 'data3' } } },
},
],
project2: [
{
project: 'project2',
data: { some: { complex: { type: 'data2' } } },
},
],
});
});
});

View File

@ -0,0 +1,35 @@
import { useMemo } from 'react';
export type GroupedDataByProject<T> = Record<string, T>;
export function groupDataByProject<T extends { project: string }>(
data: T[],
): GroupedDataByProject<T[]> {
if (!data || data.length === 0 || !('project' in data[0])) {
return {};
}
const groupedData: GroupedDataByProject<T[]> = {};
data.forEach((item) => {
const { project } = item;
if (!groupedData[project]) {
groupedData[project] = [];
}
groupedData[project].push(item);
});
return groupedData;
}
export const useGroupedProjectTrends = <
T extends {
project: string;
},
>(
input: T[],
) =>
useMemo<GroupedDataByProject<T[]>>(
() => groupDataByProject<T>(input),
[JSON.stringify(input)],
);

View File

@ -1,43 +1,19 @@
import { useMemo } from 'react';
import { useTheme } from '@mui/material';
import {
ExecutiveSummarySchema,
ExecutiveSummarySchemaMetricsSummaryTrendsItem,
} from 'openapi';
import { ExecutiveSummarySchema } from 'openapi';
import { useProjectColor } from './useProjectColor';
import { GroupedDataByProject } from './useGroupedProjectTrends';
type MetricsSummaryTrends = ExecutiveSummarySchema['metricsSummaryTrends'];
type GroupedDataByProject = Record<
string,
ExecutiveSummarySchemaMetricsSummaryTrendsItem[]
>;
function groupDataByProject(
data: ExecutiveSummarySchemaMetricsSummaryTrendsItem[],
): GroupedDataByProject {
const groupedData: GroupedDataByProject = {};
data.forEach((item) => {
const { project } = item;
if (!groupedData[project]) {
groupedData[project] = [];
}
groupedData[project].push(item);
});
return groupedData;
}
export const useMetricsSummary = (
metricsSummaryTrends: MetricsSummaryTrends,
metricsSummaryTrends: GroupedDataByProject<MetricsSummaryTrends>,
) => {
const theme = useTheme();
const getProjectColor = useProjectColor();
const data = useMemo(() => {
const groupedMetrics = groupDataByProject(metricsSummaryTrends);
const datasets = Object.entries(groupedMetrics).map(
const datasets = Object.entries(metricsSummaryTrends).map(
([project, trends]) => {
const color = getProjectColor(project);
return {

View File

@ -1,29 +1,19 @@
import { useMemo } from 'react';
import {
ExecutiveSummarySchema,
ExecutiveSummarySchemaProjectFlagTrendsItem,
} from '../../../openapi';
import { ExecutiveSummarySchema } from 'openapi';
import { useProjectColor } from './useProjectColor';
import { useTheme } from '@mui/material';
import { GroupedDataByProject } from './useGroupedProjectTrends';
type ProjectFlagTrends = ExecutiveSummarySchema['projectFlagTrends'];
export const useProjectChartData = (projectFlagTrends: ProjectFlagTrends) => {
export const useProjectChartData = (
projectFlagTrends: GroupedDataByProject<ProjectFlagTrends>,
) => {
const theme = useTheme();
const getProjectColor = useProjectColor();
const data = useMemo(() => {
const groupedFlagTrends = projectFlagTrends.reduce<
Record<string, ExecutiveSummarySchemaProjectFlagTrendsItem[]>
>((groups, item) => {
if (!groups[item.project]) {
groups[item.project] = [];
}
groups[item.project].push(item);
return groups;
}, {});
const datasets = Object.entries(groupedFlagTrends).map(
const datasets = Object.entries(projectFlagTrends).map(
([project, trends]) => {
const color = getProjectColor(project);
return {