mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-31 00:16:47 +01:00
fix: insights UI improvements and aggreated TTP (#6584)
Various ui enhancements Aggregates the time to production and metrics summary by averaging by date across all projects to get the value. Creates a single dataset for the aggregation. This makes theme behave like eg the Health chart (showing aggregated graph when show all projects and per project when not) Gradient fill when all projects across all related charts Attached recording with generated data for 3 months https://github.com/Unleash/unleash/assets/104830839/7acd80a8-b799-4a35-9a2e-bf3798f56d32 --------- Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
parent
646a8f0192
commit
c126ae130d
218
frontend/src/component/executiveDashboard/Charts.tsx
Normal file
218
frontend/src/component/executiveDashboard/Charts.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import { ConditionallyRender } from '../common/ConditionallyRender/ConditionallyRender';
|
||||
import { Widget } from './components/Widget/Widget';
|
||||
import { UserStats } from './componentsStat/UserStats/UserStats';
|
||||
import { UsersChart } from './componentsChart/UsersChart/UsersChart';
|
||||
import { UsersPerProjectChart } from './componentsChart/UsersPerProjectChart/UsersPerProjectChart';
|
||||
import { FlagStats } from './componentsStat/FlagStats/FlagStats';
|
||||
import { FlagsChart } from './componentsChart/FlagsChart/FlagsChart';
|
||||
import { FlagsProjectChart } from './componentsChart/FlagsProjectChart/FlagsProjectChart';
|
||||
import { HealthStats } from './componentsStat/HealthStats/HealthStats';
|
||||
import { ProjectHealthChart } from './componentsChart/ProjectHealthChart/ProjectHealthChart';
|
||||
import { TimeToProduction } from './componentsStat/TimeToProduction/TimeToProduction';
|
||||
import { TimeToProductionChart } from './componentsChart/TimeToProductionChart/TimeToProductionChart';
|
||||
import { MetricsSummaryChart } from './componentsChart/MetricsSummaryChart/MetricsSummaryChart';
|
||||
import { UpdatesPerEnvironmentTypeChart } from './componentsChart/UpdatesPerEnvironmentTypeChart/UpdatesPerEnvironmentTypeChart';
|
||||
import type { ExecutiveSummarySchema } from '../../openapi';
|
||||
import type { GroupedDataByProject } from './hooks/useGroupedProjectTrends';
|
||||
import { Box, styled } from '@mui/material';
|
||||
import { allOption } from '../common/ProjectSelect/ProjectSelect';
|
||||
import type { VFC } from 'react';
|
||||
|
||||
interface IChartsProps {
|
||||
flagTrends: ExecutiveSummarySchema['flagTrends'];
|
||||
projectsData: ExecutiveSummarySchema['projectFlagTrends'];
|
||||
groupedProjectsData: GroupedDataByProject<
|
||||
ExecutiveSummarySchema['projectFlagTrends']
|
||||
>;
|
||||
metricsData: ExecutiveSummarySchema['metricsSummaryTrends'];
|
||||
groupedMetricsData: GroupedDataByProject<
|
||||
ExecutiveSummarySchema['metricsSummaryTrends']
|
||||
>;
|
||||
users: ExecutiveSummarySchema['users'];
|
||||
userTrends: ExecutiveSummarySchema['userTrends'];
|
||||
environmentTypeTrends: ExecutiveSummarySchema['environmentTypeTrends'];
|
||||
summary: {
|
||||
total: number;
|
||||
active: number;
|
||||
stale: number;
|
||||
potentiallyStale: number;
|
||||
averageUsers: number;
|
||||
averageHealth?: string;
|
||||
};
|
||||
avgDaysToProduction: number;
|
||||
loading: boolean;
|
||||
projects: string[];
|
||||
}
|
||||
|
||||
const StyledGrid = styled(Box)(({ theme }) => ({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(2, 1fr)`,
|
||||
gridAutoRows: 'auto',
|
||||
gap: theme.spacing(2),
|
||||
paddingBottom: theme.spacing(2),
|
||||
[theme.breakpoints.up('md')]: {
|
||||
gridTemplateColumns: `300px 1fr`,
|
||||
},
|
||||
}));
|
||||
|
||||
const ChartWidget = styled(Widget)(({ theme }) => ({
|
||||
[theme.breakpoints.down('md')]: {
|
||||
gridColumnStart: 'span 2',
|
||||
order: 2,
|
||||
},
|
||||
}));
|
||||
|
||||
export const Charts: VFC<IChartsProps> = ({
|
||||
projects,
|
||||
users,
|
||||
summary,
|
||||
userTrends,
|
||||
groupedProjectsData,
|
||||
flagTrends,
|
||||
avgDaysToProduction,
|
||||
groupedMetricsData,
|
||||
environmentTypeTrends,
|
||||
loading,
|
||||
}) => {
|
||||
const showAllProjects = projects[0] === allOption.id;
|
||||
const isOneProjectSelected = projects.length === 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledGrid>
|
||||
<ConditionallyRender
|
||||
condition={showAllProjects}
|
||||
show={
|
||||
<Widget title='Total users'>
|
||||
<UserStats
|
||||
count={users.total}
|
||||
active={users.active}
|
||||
inactive={users.inactive}
|
||||
/>
|
||||
</Widget>
|
||||
}
|
||||
elseShow={
|
||||
<Widget
|
||||
title={
|
||||
isOneProjectSelected
|
||||
? 'Users in project'
|
||||
: 'Users per project on average'
|
||||
}
|
||||
>
|
||||
<UserStats count={summary.averageUsers} />
|
||||
</Widget>
|
||||
}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={showAllProjects}
|
||||
show={
|
||||
<ChartWidget title='Users'>
|
||||
<UsersChart
|
||||
userTrends={userTrends}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</ChartWidget>
|
||||
}
|
||||
elseShow={
|
||||
<ChartWidget title='Users per project'>
|
||||
<UsersPerProjectChart
|
||||
projectFlagTrends={groupedProjectsData}
|
||||
/>
|
||||
</ChartWidget>
|
||||
}
|
||||
/>
|
||||
<Widget
|
||||
title='Total flags'
|
||||
tooltip='Active flags (not archived) that currently exist across selected projects.'
|
||||
>
|
||||
<FlagStats
|
||||
count={summary.total}
|
||||
flagsPerUser={
|
||||
showAllProjects
|
||||
? (summary.total / users.total).toFixed(2)
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</Widget>
|
||||
<ConditionallyRender
|
||||
condition={showAllProjects}
|
||||
show={
|
||||
<ChartWidget title='Number of flags'>
|
||||
<FlagsChart
|
||||
flagTrends={flagTrends}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</ChartWidget>
|
||||
}
|
||||
elseShow={
|
||||
<ChartWidget title='Flags per project'>
|
||||
<FlagsProjectChart
|
||||
projectFlagTrends={groupedProjectsData}
|
||||
/>
|
||||
</ChartWidget>
|
||||
}
|
||||
/>
|
||||
<Widget
|
||||
title='Average health'
|
||||
tooltip='Average health is a percentage of flags that are not stale nor potencially stale.'
|
||||
>
|
||||
<HealthStats
|
||||
value={summary.averageHealth}
|
||||
healthy={summary.active}
|
||||
stale={summary.stale}
|
||||
potentiallyStale={summary.potentiallyStale}
|
||||
/>
|
||||
</Widget>
|
||||
<ChartWidget
|
||||
title={
|
||||
showAllProjects ? 'Healthy flags' : 'Health per project'
|
||||
}
|
||||
tooltip='How the health changes over time'
|
||||
>
|
||||
<ProjectHealthChart
|
||||
projectFlagTrends={groupedProjectsData}
|
||||
isAggregate={showAllProjects}
|
||||
/>
|
||||
</ChartWidget>
|
||||
<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" and averaged across selected projects. '
|
||||
>
|
||||
<TimeToProduction daysToProduction={avgDaysToProduction} />
|
||||
</Widget>
|
||||
<ChartWidget
|
||||
title={
|
||||
showAllProjects
|
||||
? 'Time to production'
|
||||
: 'Time to production per project'
|
||||
}
|
||||
tooltip='How the time to production changes over time'
|
||||
>
|
||||
<TimeToProductionChart
|
||||
projectFlagTrends={groupedProjectsData}
|
||||
isAggregate={showAllProjects}
|
||||
/>
|
||||
</ChartWidget>
|
||||
</StyledGrid>
|
||||
<Widget
|
||||
title={showAllProjects ? 'Metrics' : 'Metrics per project'}
|
||||
tooltip='Summary of all flag evaluations reported by SDKs.'
|
||||
>
|
||||
<MetricsSummaryChart
|
||||
metricsSummaryTrends={groupedMetricsData}
|
||||
isAggregate={showAllProjects}
|
||||
/>
|
||||
</Widget>
|
||||
<Widget
|
||||
title='Updates per environment type'
|
||||
tooltip='Summary of all configuration updates per environment type'
|
||||
sx={{ mt: (theme) => theme.spacing(2) }}
|
||||
>
|
||||
<UpdatesPerEnvironmentTypeChart
|
||||
environmentTypeTrends={environmentTypeTrends}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</Widget>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,64 +1,26 @@
|
||||
import { useState, type VFC } from 'react';
|
||||
import { Box, styled } from '@mui/material';
|
||||
import { ArrayParam, withDefault } from 'use-query-params';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { usePersistentTableState } from 'hooks/usePersistentTableState';
|
||||
import {
|
||||
allOption,
|
||||
ProjectSelect,
|
||||
} from 'component/common/ProjectSelect/ProjectSelect';
|
||||
import { useExecutiveDashboard } from 'hooks/api/getters/useExecutiveSummary/useExecutiveSummary';
|
||||
|
||||
import { useFilteredFlagsSummary } from './hooks/useFilteredFlagsSummary';
|
||||
import { useFilteredTrends } from './hooks/useFilteredTrends';
|
||||
|
||||
import { Widget } from './components/Widget/Widget';
|
||||
import { DashboardHeader } from './components/DashboardHeader/DashboardHeader';
|
||||
import { useDashboardData } from './hooks/useDashboardData';
|
||||
import { Charts } from './Charts';
|
||||
|
||||
import { UserStats } from './componentsStat/UserStats/UserStats';
|
||||
import { FlagStats } from './componentsStat/FlagStats/FlagStats';
|
||||
import { HealthStats } from './componentsStat/HealthStats/HealthStats';
|
||||
|
||||
import { UsersChart } from './componentsChart/UsersChart/UsersChart';
|
||||
import { FlagsChart } from './componentsChart/FlagsChart/FlagsChart';
|
||||
import { FlagsProjectChart } from './componentsChart/FlagsProjectChart/FlagsProjectChart';
|
||||
import { ProjectHealthChart } from './componentsChart/ProjectHealthChart/ProjectHealthChart';
|
||||
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',
|
||||
gridTemplateColumns: `repeat(2, 1fr)`,
|
||||
gridAutoRows: 'auto',
|
||||
gap: theme.spacing(2),
|
||||
paddingBottom: theme.spacing(2),
|
||||
[theme.breakpoints.up('md')]: {
|
||||
gridTemplateColumns: `300px 1fr`,
|
||||
},
|
||||
}));
|
||||
|
||||
const ChartWidget = styled(Widget)(({ theme }) => ({
|
||||
[theme.breakpoints.down('md')]: {
|
||||
gridColumnStart: 'span 2',
|
||||
order: 2,
|
||||
},
|
||||
}));
|
||||
|
||||
const StickyWrapper = styled(Box, {
|
||||
shouldForwardProp: (prop) => prop !== 'scrolled',
|
||||
})<{ scrolled?: boolean }>(({ theme, scrolled }) => ({
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1000,
|
||||
padding: scrolled ? theme.spacing(2, 0) : theme.spacing(0, 0, 2),
|
||||
background: theme.palette.background.application,
|
||||
transition: 'padding 0.3s ease',
|
||||
}));
|
||||
const StickyWrapper = styled(Box)<{ scrolled?: boolean }>(
|
||||
({ theme, scrolled }) => ({
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1000,
|
||||
padding: scrolled ? theme.spacing(2, 0) : theme.spacing(0, 0, 2),
|
||||
background: theme.palette.background.application,
|
||||
transition: 'padding 0.3s ease',
|
||||
}),
|
||||
);
|
||||
|
||||
export const ExecutiveDashboard: VFC = () => {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
@ -73,27 +35,8 @@ export const ExecutiveDashboard: VFC = () => {
|
||||
const projects = state.projects
|
||||
? (state.projects.filter(Boolean) as string[])
|
||||
: [];
|
||||
const showAllProjects = projects[0] === allOption.id;
|
||||
const projectsData = useFilteredTrends(
|
||||
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 dashboardData = useDashboardData(executiveDashboardData, projects);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrolled && window.scrollY > 0) {
|
||||
@ -121,133 +64,7 @@ export const ExecutiveDashboard: VFC = () => {
|
||||
}
|
||||
/>
|
||||
</StickyWrapper>
|
||||
<StyledGrid>
|
||||
<ConditionallyRender
|
||||
condition={showAllProjects}
|
||||
show={
|
||||
<Widget title='Total users'>
|
||||
<UserStats
|
||||
count={users.total}
|
||||
active={users.active}
|
||||
inactive={users.inactive}
|
||||
/>
|
||||
</Widget>
|
||||
}
|
||||
elseShow={
|
||||
<Widget
|
||||
title={
|
||||
isOneProjectSelected
|
||||
? 'Users in project'
|
||||
: 'Users per project on average'
|
||||
}
|
||||
>
|
||||
<UserStats count={summary.averageUsers} />
|
||||
</Widget>
|
||||
}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={showAllProjects}
|
||||
show={
|
||||
<ChartWidget title='Users'>
|
||||
<UsersChart
|
||||
userTrends={executiveDashboardData.userTrends}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</ChartWidget>
|
||||
}
|
||||
elseShow={
|
||||
<ChartWidget title='Users per project'>
|
||||
<UsersPerProjectChart
|
||||
projectFlagTrends={groupedProjectsData}
|
||||
/>
|
||||
</ChartWidget>
|
||||
}
|
||||
/>
|
||||
<Widget
|
||||
title='Total flags'
|
||||
tooltip='Active flags (not archived) that currently exist across selected projects.'
|
||||
>
|
||||
<FlagStats
|
||||
count={summary.total}
|
||||
flagsPerUser={
|
||||
showAllProjects
|
||||
? (summary.total / users.total).toFixed(2)
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</Widget>
|
||||
<ConditionallyRender
|
||||
condition={showAllProjects}
|
||||
show={
|
||||
<ChartWidget title='Number of flags'>
|
||||
<FlagsChart
|
||||
flagTrends={executiveDashboardData.flagTrends}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</ChartWidget>
|
||||
}
|
||||
elseShow={
|
||||
<ChartWidget title='Flags per project'>
|
||||
<FlagsProjectChart
|
||||
projectFlagTrends={groupedProjectsData}
|
||||
/>
|
||||
</ChartWidget>
|
||||
}
|
||||
/>
|
||||
<Widget
|
||||
title='Average health'
|
||||
tooltip='Average health is a percentage of flags that are not stale nor potencially stale.'
|
||||
>
|
||||
<HealthStats
|
||||
value={summary.averageHealth}
|
||||
healthy={summary.active}
|
||||
stale={summary.stale}
|
||||
potentiallyStale={summary.potentiallyStale}
|
||||
/>
|
||||
</Widget>
|
||||
<ChartWidget
|
||||
title={
|
||||
showAllProjects ? 'Healthy flags' : 'Health per project'
|
||||
}
|
||||
>
|
||||
<ProjectHealthChart
|
||||
projectFlagTrends={groupedProjectsData}
|
||||
isAggregate={showAllProjects}
|
||||
/>
|
||||
</ChartWidget>
|
||||
<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'
|
||||
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={groupedMetricsData}
|
||||
/>
|
||||
</Widget>
|
||||
<Widget
|
||||
title='Updates per environment type'
|
||||
tooltip='Summary of all configuration updates per environment type'
|
||||
sx={{ mt: (theme) => theme.spacing(2) }}
|
||||
>
|
||||
<UpdatesPerEnvironmentTypeChart
|
||||
environmentTypeTrends={environmentTypeTrends}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</Widget>
|
||||
<Charts loading={loading} projects={projects} {...dashboardData} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -35,24 +35,28 @@ const InfoLine = ({
|
||||
</Typography>
|
||||
);
|
||||
|
||||
const InfoSummary = ({ data }: { data: { key: string; value: number }[] }) => (
|
||||
const InfoSummary = ({
|
||||
data,
|
||||
}: { data: { key: string; value: string | number }[] }) => (
|
||||
<Box display={'flex'} flexDirection={'row'}>
|
||||
{data.map(({ key, value }) => (
|
||||
<div style={{ flex: 1, flexDirection: 'column' }} key={key}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<Typography variant={'body1'} component={'p'}>
|
||||
{key}
|
||||
</Typography>
|
||||
{data
|
||||
.filter(({ value }) => value !== 'N/A')
|
||||
.map(({ key, value }) => (
|
||||
<div style={{ flex: 1, flexDirection: 'column' }} key={key}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<Typography variant={'body1'} component={'p'}>
|
||||
{key}
|
||||
</Typography>
|
||||
</div>
|
||||
<div style={{ flex: 1, textAlign: 'center' }}>{value}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, textAlign: 'center' }}>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@ -131,15 +135,15 @@ export const MetricsSummaryTooltip: VFC<{ tooltip: TooltipState | null }> = ({
|
||||
data={[
|
||||
{
|
||||
key: 'Flags',
|
||||
value: point.value.totalFlags ?? 0,
|
||||
value: point.value.totalFlags ?? 'N/A',
|
||||
},
|
||||
{
|
||||
key: 'Environments',
|
||||
value: point.value.totalEnvironments ?? 0,
|
||||
value: point.value.totalEnvironments ?? 'N/A',
|
||||
},
|
||||
{
|
||||
key: 'Apps',
|
||||
value: point.value.totalApps ?? 0,
|
||||
value: point.value.totalApps ?? 'N/A',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
@ -0,0 +1,165 @@
|
||||
import { aggregateDataPerDate } from './aggregate-metrics-by-day';
|
||||
|
||||
describe('aggregateDataPerDate', () => {
|
||||
it('should correctly aggregate data for a single item', () => {
|
||||
const items = [
|
||||
{
|
||||
date: '2024-03-19',
|
||||
totalFlags: 5,
|
||||
totalNo: 2,
|
||||
totalRequests: 7,
|
||||
totalYes: 3,
|
||||
project: 'default',
|
||||
totalApps: 2,
|
||||
totalEnvironments: 3,
|
||||
week: '2024-01',
|
||||
},
|
||||
];
|
||||
|
||||
const expected = {
|
||||
'2024-03-19': {
|
||||
totalFlags: 5,
|
||||
totalNo: 2,
|
||||
totalRequests: 7,
|
||||
totalYes: 3,
|
||||
},
|
||||
};
|
||||
|
||||
expect(aggregateDataPerDate(items)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should aggregate multiple items for the same date correctly', () => {
|
||||
const items = [
|
||||
{
|
||||
date: '2024-03-19',
|
||||
totalFlags: 1,
|
||||
totalNo: 2,
|
||||
totalRequests: 3,
|
||||
totalYes: 4,
|
||||
project: 'default',
|
||||
totalApps: 2,
|
||||
totalEnvironments: 3,
|
||||
week: '2024-01',
|
||||
},
|
||||
{
|
||||
date: '2024-03-19',
|
||||
totalFlags: 5,
|
||||
totalNo: 6,
|
||||
totalRequests: 7,
|
||||
totalYes: 8,
|
||||
project: 'default',
|
||||
totalApps: 2,
|
||||
totalEnvironments: 3,
|
||||
week: '2024-01',
|
||||
},
|
||||
];
|
||||
|
||||
const expected = {
|
||||
'2024-03-19': {
|
||||
totalFlags: 6,
|
||||
totalNo: 8,
|
||||
totalRequests: 10,
|
||||
totalYes: 12,
|
||||
},
|
||||
};
|
||||
|
||||
expect(aggregateDataPerDate(items)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should aggregate items across different dates correctly', () => {
|
||||
const items = [
|
||||
{
|
||||
date: '2024-03-18',
|
||||
totalFlags: 10,
|
||||
totalNo: 20,
|
||||
totalRequests: 30,
|
||||
totalYes: 40,
|
||||
project: 'default',
|
||||
totalApps: 2,
|
||||
totalEnvironments: 3,
|
||||
week: '2024-01',
|
||||
},
|
||||
{
|
||||
date: '2024-03-19',
|
||||
totalFlags: 1,
|
||||
totalNo: 2,
|
||||
totalRequests: 3,
|
||||
totalYes: 4,
|
||||
project: 'default',
|
||||
totalApps: 2,
|
||||
totalEnvironments: 3,
|
||||
week: '2024-01',
|
||||
},
|
||||
];
|
||||
|
||||
const expected = {
|
||||
'2024-03-18': {
|
||||
totalFlags: 10,
|
||||
totalNo: 20,
|
||||
totalRequests: 30,
|
||||
totalYes: 40,
|
||||
},
|
||||
'2024-03-19': {
|
||||
totalFlags: 1,
|
||||
totalNo: 2,
|
||||
totalRequests: 3,
|
||||
totalYes: 4,
|
||||
},
|
||||
};
|
||||
|
||||
expect(aggregateDataPerDate(items)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should correctly handle items with all metrics at zero', () => {
|
||||
const items = [
|
||||
{
|
||||
date: '2024-03-19',
|
||||
totalFlags: 0,
|
||||
totalNo: 0,
|
||||
totalRequests: 0,
|
||||
totalYes: 0,
|
||||
project: 'default',
|
||||
totalApps: 2,
|
||||
totalEnvironments: 3,
|
||||
week: '2024-01',
|
||||
},
|
||||
];
|
||||
|
||||
const expected = {
|
||||
'2024-03-19': {
|
||||
totalFlags: 0,
|
||||
totalNo: 0,
|
||||
totalRequests: 0,
|
||||
totalYes: 0,
|
||||
},
|
||||
};
|
||||
|
||||
expect(aggregateDataPerDate(items)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should return an empty object for an empty array input', () => {
|
||||
expect(aggregateDataPerDate([])).toEqual({});
|
||||
});
|
||||
|
||||
// Test for immutability of input
|
||||
it('should not mutate the input array', () => {
|
||||
const items = [
|
||||
{
|
||||
date: '2024-03-19',
|
||||
totalFlags: 1,
|
||||
totalNo: 2,
|
||||
totalRequests: 3,
|
||||
totalYes: 4,
|
||||
project: 'default',
|
||||
totalApps: 2,
|
||||
totalEnvironments: 3,
|
||||
week: '2024-01',
|
||||
},
|
||||
];
|
||||
const itemsCopy = [...items];
|
||||
|
||||
aggregateDataPerDate(items);
|
||||
|
||||
expect(items).toEqual(itemsCopy);
|
||||
});
|
||||
});
|
@ -0,0 +1,33 @@
|
||||
import type { ExecutiveSummarySchema } from 'openapi';
|
||||
|
||||
export function aggregateDataPerDate(
|
||||
items: ExecutiveSummarySchema['metricsSummaryTrends'],
|
||||
) {
|
||||
return items.reduce(
|
||||
(acc, item) => {
|
||||
if (!acc[item.date]) {
|
||||
acc[item.date] = {
|
||||
totalFlags: 0,
|
||||
totalNo: 0,
|
||||
totalRequests: 0,
|
||||
totalYes: 0,
|
||||
};
|
||||
}
|
||||
|
||||
acc[item.date].totalFlags += item.totalFlags;
|
||||
acc[item.date].totalNo += item.totalNo;
|
||||
acc[item.date].totalRequests += item.totalRequests;
|
||||
acc[item.date].totalYes += item.totalYes;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as {
|
||||
[date: string]: {
|
||||
totalFlags: number;
|
||||
totalNo: number;
|
||||
totalRequests: number;
|
||||
totalYes: number;
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
@ -1,36 +1,80 @@
|
||||
import { useMemo, type VFC } from 'react';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
|
||||
import type { ExecutiveSummarySchema } from 'openapi';
|
||||
import { LineChart, NotEnoughData } from '../../components/LineChart/LineChart';
|
||||
import {
|
||||
fillGradientPrimary,
|
||||
LineChart,
|
||||
NotEnoughData,
|
||||
} from '../../components/LineChart/LineChart';
|
||||
import { MetricsSummaryTooltip } from './MetricsChartTooltip/MetricsChartTooltip';
|
||||
import { useMetricsSummary } from '../../hooks/useMetricsSummary';
|
||||
import { usePlaceholderData } from 'component/executiveDashboard/hooks/usePlaceholderData';
|
||||
import type { GroupedDataByProject } from '../../hooks/useGroupedProjectTrends';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { aggregateDataPerDate } from './MetricsChartTooltip/aggregate-metrics-by-day';
|
||||
|
||||
interface IMetricsSummaryChartProps {
|
||||
metricsSummaryTrends: GroupedDataByProject<
|
||||
ExecutiveSummarySchema['metricsSummaryTrends']
|
||||
>;
|
||||
isAggregate?: boolean;
|
||||
}
|
||||
|
||||
export const MetricsSummaryChart: VFC<IMetricsSummaryChartProps> = ({
|
||||
metricsSummaryTrends,
|
||||
isAggregate,
|
||||
}) => {
|
||||
const data = useMetricsSummary(metricsSummaryTrends);
|
||||
const theme = useTheme();
|
||||
const metricsSummary = useMetricsSummary(metricsSummaryTrends);
|
||||
const notEnoughData = useMemo(
|
||||
() => !data.datasets.some((d) => d.data.length > 1),
|
||||
[data],
|
||||
() => !metricsSummary.datasets.some((d) => d.data.length > 1),
|
||||
[metricsSummary],
|
||||
);
|
||||
const placeholderData = usePlaceholderData();
|
||||
|
||||
const aggregatedPerDay = useMemo(() => {
|
||||
const result = aggregateDataPerDate(
|
||||
Object.values(metricsSummary.datasets).flatMap((item) => item.data),
|
||||
);
|
||||
const data = Object.entries(result)
|
||||
.map(([date, trends]) => ({ date, ...trends }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Total Requests',
|
||||
data: data,
|
||||
borderColor: theme.palette.primary.light,
|
||||
backgroundColor: fillGradientPrimary,
|
||||
fill: true,
|
||||
order: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [JSON.stringify(metricsSummaryTrends), theme]);
|
||||
|
||||
const data = isAggregate ? aggregatedPerDay : metricsSummary;
|
||||
|
||||
return (
|
||||
<LineChart
|
||||
data={notEnoughData ? placeholderData : data}
|
||||
isLocalTooltip
|
||||
TooltipComponent={MetricsSummaryTooltip}
|
||||
overrideOptions={{
|
||||
parsing: { yAxisKey: 'totalRequests', xAxisKey: 'date' },
|
||||
}}
|
||||
overrideOptions={
|
||||
notEnoughData
|
||||
? {}
|
||||
: {
|
||||
parsing: {
|
||||
yAxisKey: 'totalRequests',
|
||||
xAxisKey: 'date',
|
||||
},
|
||||
}
|
||||
}
|
||||
cover={notEnoughData ? <NotEnoughData /> : false}
|
||||
/>
|
||||
);
|
||||
|
@ -4,6 +4,7 @@ import type { ExecutiveSummarySchema } from 'openapi';
|
||||
import { HealthTooltip } from './HealthChartTooltip/HealthChartTooltip';
|
||||
import { useProjectChartData } from 'component/executiveDashboard/hooks/useProjectChartData';
|
||||
import {
|
||||
fillGradientPrimary,
|
||||
LineChart,
|
||||
NotEnoughData,
|
||||
} from 'component/executiveDashboard/components/LineChart/LineChart';
|
||||
@ -73,7 +74,8 @@ export const ProjectHealthChart: VFC<IProjectHealthChartProps> = ({
|
||||
date: item.date,
|
||||
})),
|
||||
borderColor: theme.palette.primary.light,
|
||||
fill: false,
|
||||
backgroundColor: fillGradientPrimary,
|
||||
fill: true,
|
||||
order: 3,
|
||||
},
|
||||
],
|
||||
|
@ -1,39 +1,108 @@
|
||||
import { useMemo, type VFC } from 'react';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import type { ExecutiveSummarySchema } from 'openapi';
|
||||
import { LineChart } from '../../components/LineChart/LineChart';
|
||||
import {
|
||||
fillGradientPrimary,
|
||||
LineChart,
|
||||
NotEnoughData,
|
||||
} from '../../components/LineChart/LineChart';
|
||||
import { useProjectChartData } from '../../hooks/useProjectChartData';
|
||||
import type { GroupedDataByProject } from '../../hooks/useGroupedProjectTrends';
|
||||
import { usePlaceholderData } from '../../hooks/usePlaceholderData';
|
||||
import { TimeToProductionTooltip } from './TimeToProductionTooltip/TimeToProductionTooltip';
|
||||
import { useTheme } from '@mui/material';
|
||||
|
||||
interface ITimeToProductionChartProps {
|
||||
projectFlagTrends: GroupedDataByProject<
|
||||
ExecutiveSummarySchema['projectFlagTrends']
|
||||
>;
|
||||
isAggregate?: boolean;
|
||||
}
|
||||
|
||||
type GroupedDataByDate<T> = Record<string, T[]>;
|
||||
|
||||
type DateResult<T> = Record<string, T>;
|
||||
|
||||
function averageTimeToProduction(
|
||||
projectsData: ExecutiveSummarySchema['projectFlagTrends'],
|
||||
): DateResult<number> {
|
||||
// Group the data by date
|
||||
const groupedData: GroupedDataByDate<number> = {};
|
||||
projectsData.forEach((item) => {
|
||||
const { date, timeToProduction } = item;
|
||||
if (!groupedData[date]) {
|
||||
groupedData[date] = [];
|
||||
}
|
||||
if (timeToProduction !== undefined) {
|
||||
groupedData[date].push(timeToProduction);
|
||||
}
|
||||
});
|
||||
// Calculate the average time to production for each date
|
||||
const averageByDate: DateResult<number> = {};
|
||||
Object.entries(groupedData).forEach(([date, times]) => {
|
||||
const sum = times.reduce((acc, curr) => acc + curr, 0);
|
||||
const average = sum / times.length;
|
||||
averageByDate[date] = average;
|
||||
});
|
||||
return averageByDate;
|
||||
}
|
||||
|
||||
export const TimeToProductionChart: VFC<ITimeToProductionChartProps> = ({
|
||||
projectFlagTrends,
|
||||
isAggregate,
|
||||
}) => {
|
||||
const data = useProjectChartData(projectFlagTrends);
|
||||
const theme = useTheme();
|
||||
const projectsDatasets = useProjectChartData(projectFlagTrends);
|
||||
const notEnoughData = useMemo(
|
||||
() => !data.datasets.some((d) => d.data.length > 1),
|
||||
[data],
|
||||
() => !projectsDatasets.datasets.some((d) => d.data.length > 1),
|
||||
[projectsDatasets],
|
||||
);
|
||||
|
||||
const aggregatedPerDay = useMemo(() => {
|
||||
const result = averageTimeToProduction(
|
||||
Object.values(projectsDatasets.datasets).flatMap(
|
||||
(item) => item.data,
|
||||
),
|
||||
);
|
||||
const data = Object.entries(result)
|
||||
.map(([date, timeToProduction]) => ({ date, timeToProduction }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Time to production',
|
||||
data,
|
||||
borderColor: theme.palette.primary.light,
|
||||
backgroundColor: fillGradientPrimary,
|
||||
fill: true,
|
||||
order: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [JSON.stringify(projectsDatasets), theme]);
|
||||
|
||||
const data = isAggregate ? aggregatedPerDay : projectsDatasets;
|
||||
const placeholderData = usePlaceholderData();
|
||||
return (
|
||||
<LineChart
|
||||
data={notEnoughData ? placeholderData : data}
|
||||
isLocalTooltip
|
||||
TooltipComponent={TimeToProductionTooltip}
|
||||
overrideOptions={{
|
||||
parsing: {
|
||||
yAxisKey: 'timeToProduction',
|
||||
xAxisKey: 'date',
|
||||
},
|
||||
}}
|
||||
overrideOptions={
|
||||
notEnoughData
|
||||
? {}
|
||||
: {
|
||||
parsing: {
|
||||
yAxisKey: 'timeToProduction',
|
||||
xAxisKey: 'date',
|
||||
},
|
||||
}
|
||||
}
|
||||
cover={notEnoughData ? <NotEnoughData /> : false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -29,7 +29,7 @@ const getInterval = (days?: number) => {
|
||||
return `${weeks.toFixed(1)} weeks`;
|
||||
}
|
||||
} else {
|
||||
return `${days} days`;
|
||||
return `${days.toFixed(2)} days`;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -7,6 +7,7 @@ import type {
|
||||
} from 'openapi';
|
||||
import { LineChart, NotEnoughData } from '../../components/LineChart/LineChart';
|
||||
import { usePlaceholderData } from 'component/executiveDashboard/hooks/usePlaceholderData';
|
||||
import { UpdatesPerEnvironmentTypeChartTooltip } from './UpdatesPerEnvironmentTypeChartTooltip/UpdatesPerEnvironmentTypeChartTooltip';
|
||||
|
||||
interface IUpdatesPerEnvironmnetTypeChart {
|
||||
environmentTypeTrends: ExecutiveSummarySchema['environmentTypeTrends'];
|
||||
@ -67,25 +68,35 @@ export const UpdatesPerEnvironmentTypeChart: VFC<
|
||||
|
||||
const data = useMemo(() => {
|
||||
const grouped = groupByDate(environmentTypeTrends);
|
||||
const labels = environmentTypeTrends?.map((item) => item.date);
|
||||
const datasets = Object.entries(grouped).map(
|
||||
([environmentType, trends]) => {
|
||||
const color = getEnvironmentTypeColor(environmentType);
|
||||
return {
|
||||
label: environmentType,
|
||||
data: trends.map((item) => item.totalUpdates),
|
||||
data: trends,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
fill: false,
|
||||
};
|
||||
},
|
||||
);
|
||||
return { labels, datasets };
|
||||
return { datasets };
|
||||
}, [theme, environmentTypeTrends]);
|
||||
|
||||
return (
|
||||
<LineChart
|
||||
data={notEnoughData || isLoading ? placeholderData : data}
|
||||
overrideOptions={
|
||||
notEnoughData
|
||||
? {}
|
||||
: {
|
||||
parsing: {
|
||||
yAxisKey: 'totalUpdates',
|
||||
xAxisKey: 'date',
|
||||
},
|
||||
}
|
||||
}
|
||||
TooltipComponent={UpdatesPerEnvironmentTypeChartTooltip}
|
||||
cover={notEnoughData ? <NotEnoughData /> : isLoading}
|
||||
/>
|
||||
);
|
||||
|
@ -0,0 +1,43 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ExecutiveSummarySchema } from 'openapi';
|
||||
import { useFilteredTrends } from './useFilteredTrends';
|
||||
import { useGroupedProjectTrends } from './useGroupedProjectTrends';
|
||||
import { useFilteredFlagsSummary } from './useFilteredFlagsSummary';
|
||||
import { useAvgTimeToProduction } from './useAvgTimeToProduction';
|
||||
|
||||
export const useDashboardData = (
|
||||
executiveDashboardData: ExecutiveSummarySchema,
|
||||
projects: string[],
|
||||
) =>
|
||||
useMemo(() => {
|
||||
const projectsData = useFilteredTrends(
|
||||
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);
|
||||
|
||||
return {
|
||||
...executiveDashboardData,
|
||||
projectsData,
|
||||
groupedProjectsData,
|
||||
metricsData,
|
||||
groupedMetricsData,
|
||||
users,
|
||||
environmentTypeTrends,
|
||||
summary,
|
||||
avgDaysToProduction,
|
||||
};
|
||||
}, [executiveDashboardData, projects]);
|
Loading…
Reference in New Issue
Block a user