mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-03 01:18:43 +02:00
Feat/project stats members (#3009)
This PR adds project members to the project stats and connects the stats to the UI.
This commit is contained in:
parent
b27ca26770
commit
897e97330a
@ -31,7 +31,7 @@ export const StyledProjectInfoWidgetContainer = styled('div')(({ theme }) => ({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
padding: theme.spacing(3, 2, 3, 2),
|
padding: theme.spacing(3, 2, 3, 2),
|
||||||
[theme.breakpoints.down('md')]: {
|
[theme.breakpoints.down('md')]: {
|
||||||
margin: theme.spacing(0, 0.5),
|
margin: theme.spacing(0, 1),
|
||||||
...flexRow,
|
...flexRow,
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
@ -10,7 +10,7 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
|||||||
import { useLastViewedProject } from '../../../hooks/useLastViewedProject';
|
import { useLastViewedProject } from '../../../hooks/useLastViewedProject';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { ProjectStatus } from './ProjectStatus/ProjectStatus';
|
import { ProjectStats } from './ProjectStats/ProjectStats';
|
||||||
|
|
||||||
const refreshInterval = 15 * 1000;
|
const refreshInterval = 15 * 1000;
|
||||||
|
|
||||||
@ -36,7 +36,9 @@ const StyledContentContainer = styled(Box)(() => ({
|
|||||||
const ProjectOverview = () => {
|
const ProjectOverview = () => {
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
const projectName = useProjectNameOrId(projectId);
|
const projectName = useProjectNameOrId(projectId);
|
||||||
const { project, loading } = useProject(projectId, { refreshInterval });
|
const { project, loading } = useProject(projectId, {
|
||||||
|
refreshInterval,
|
||||||
|
});
|
||||||
const { members, features, health, description, environments } = project;
|
const { members, features, health, description, environments } = project;
|
||||||
usePageTitle(`Project overview – ${projectName}`);
|
usePageTitle(`Project overview – ${projectName}`);
|
||||||
const { setLastViewed } = useLastViewedProject();
|
const { setLastViewed } = useLastViewedProject();
|
||||||
@ -58,7 +60,7 @@ const ProjectOverview = () => {
|
|||||||
<StyledContentContainer>
|
<StyledContentContainer>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(uiConfig?.flags.newProjectOverview)}
|
condition={Boolean(uiConfig?.flags.newProjectOverview)}
|
||||||
show={<ProjectStatus />}
|
show={<ProjectStats stats={project.stats} />}
|
||||||
/>
|
/>
|
||||||
<StyledProjectToggles>
|
<StyledProjectToggles>
|
||||||
<ProjectFeatureToggles
|
<ProjectFeatureToggles
|
||||||
|
@ -0,0 +1,72 @@
|
|||||||
|
import { Box, styled } from '@mui/material';
|
||||||
|
import { StatusBox } from './StatusBox';
|
||||||
|
|
||||||
|
const StyledBox = styled(Box)(({ theme }) => ({
|
||||||
|
padding: theme.spacing(0, 0, 2, 2),
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
[theme.breakpoints.down('md')]: {
|
||||||
|
paddingLeft: 0,
|
||||||
|
},
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IProjectStatsProps {
|
||||||
|
stats: any; // awaiting type generation
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectStats = ({ stats }: IProjectStatsProps) => {
|
||||||
|
const {
|
||||||
|
avgTimeToProdCurrentWindow,
|
||||||
|
avgTimeToProdPastWindow,
|
||||||
|
projectActivityCurrentWindow,
|
||||||
|
projectActivityPastWindow,
|
||||||
|
createdCurrentWindow,
|
||||||
|
createdPastWindow,
|
||||||
|
archivedCurrentWindow,
|
||||||
|
archivedPastWindow,
|
||||||
|
} = stats;
|
||||||
|
|
||||||
|
const calculatePercentage = (partial: number, total: number) => {
|
||||||
|
const percentage = (partial * 100) / total;
|
||||||
|
|
||||||
|
if (Number.isInteger(percentage)) {
|
||||||
|
return percentage;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledBox>
|
||||||
|
<StatusBox
|
||||||
|
title="Total changes"
|
||||||
|
boxText={projectActivityCurrentWindow}
|
||||||
|
change={
|
||||||
|
projectActivityCurrentWindow - projectActivityPastWindow
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusBox
|
||||||
|
title="Avg. time to production"
|
||||||
|
boxText={`${avgTimeToProdCurrentWindow} days`}
|
||||||
|
change={calculatePercentage(
|
||||||
|
avgTimeToProdCurrentWindow,
|
||||||
|
avgTimeToProdPastWindow
|
||||||
|
)}
|
||||||
|
percentage
|
||||||
|
/>{' '}
|
||||||
|
<StatusBox
|
||||||
|
title="Features created"
|
||||||
|
boxText={createdCurrentWindow}
|
||||||
|
change={createdCurrentWindow - createdPastWindow}
|
||||||
|
/>
|
||||||
|
<StatusBox
|
||||||
|
title="Features archived"
|
||||||
|
boxText={archivedCurrentWindow}
|
||||||
|
change={archivedCurrentWindow - archivedPastWindow}
|
||||||
|
/>
|
||||||
|
</StyledBox>
|
||||||
|
);
|
||||||
|
};
|
@ -1,15 +1,28 @@
|
|||||||
import { ArrowOutward, SouthEast } from '@mui/icons-material';
|
import { ArrowOutward, SouthEast } from '@mui/icons-material';
|
||||||
import { Box, Typography, styled } from '@mui/material';
|
import { Box, Typography, styled } from '@mui/material';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { flexRow } from 'themes/themeStyles';
|
import { flexRow } from 'themes/themeStyles';
|
||||||
|
|
||||||
const StyledBox = styled(Box)(({ theme }) => ({
|
const StyledBox = styled(Box)(({ theme }) => ({
|
||||||
padding: theme.spacing(4, 2),
|
padding: theme.spacing(4, 2),
|
||||||
backgroundColor: theme.palette.background.paper,
|
backgroundColor: theme.palette.background.paper,
|
||||||
minWidth: '240px',
|
minWidth: '24%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderRadius: `${theme.shape.borderRadiusLarge}px`,
|
borderRadius: `${theme.shape.borderRadiusLarge}px`,
|
||||||
|
[theme.breakpoints.down('lg')]: {
|
||||||
|
minWidth: '49%',
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
':nth-child(n+3)': {
|
||||||
|
marginTop: theme.spacing(2),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
':nth-child(n+2)': {
|
||||||
|
marginTop: theme.spacing(2),
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledTypographyHeader = styled(Typography)(({ theme }) => ({
|
const StyledTypographyHeader = styled(Typography)(({ theme }) => ({
|
||||||
@ -42,6 +55,7 @@ interface IStatusBoxProps {
|
|||||||
title: string;
|
title: string;
|
||||||
boxText: string;
|
boxText: string;
|
||||||
change: number;
|
change: number;
|
||||||
|
percentage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveIcon = (change: number) => {
|
const resolveIcon = (change: number) => {
|
||||||
@ -62,23 +76,43 @@ const resolveColor = (change: number) => {
|
|||||||
return 'error.main';
|
return 'error.main';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StatusBox = ({ title, boxText, change }: IStatusBoxProps) => {
|
export const StatusBox = ({
|
||||||
|
title,
|
||||||
|
boxText,
|
||||||
|
change,
|
||||||
|
percentage,
|
||||||
|
}: IStatusBoxProps) => {
|
||||||
return (
|
return (
|
||||||
<StyledBox>
|
<StyledBox>
|
||||||
<StyledTypographyHeader>{title}</StyledTypographyHeader>
|
<StyledTypographyHeader>{title}</StyledTypographyHeader>
|
||||||
<Box sx={{ ...flexRow }}>
|
<Box sx={{ ...flexRow }}>
|
||||||
<StyledTypographyCount>{boxText}</StyledTypographyCount>
|
<StyledTypographyCount>{boxText}</StyledTypographyCount>
|
||||||
<StyledBoxChangeContainer>
|
<ConditionallyRender
|
||||||
<Box sx={{ ...flexRow }}>
|
condition={change !== 0}
|
||||||
{resolveIcon(change)}
|
show={
|
||||||
<StyledTypographyChange color={resolveColor(change)}>
|
<StyledBoxChangeContainer>
|
||||||
{change}
|
<Box sx={{ ...flexRow }}>
|
||||||
</StyledTypographyChange>
|
{resolveIcon(change)}
|
||||||
</Box>
|
<StyledTypographyChange
|
||||||
<StyledTypographySubtext>
|
color={resolveColor(change)}
|
||||||
this month
|
>
|
||||||
</StyledTypographySubtext>
|
{change}
|
||||||
</StyledBoxChangeContainer>
|
{percentage ? '%' : ''}
|
||||||
|
</StyledTypographyChange>
|
||||||
|
</Box>
|
||||||
|
<StyledTypographySubtext>
|
||||||
|
this month
|
||||||
|
</StyledTypographySubtext>
|
||||||
|
</StyledBoxChangeContainer>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<StyledBoxChangeContainer>
|
||||||
|
<StyledTypographySubtext>
|
||||||
|
No change
|
||||||
|
</StyledTypographySubtext>
|
||||||
|
</StyledBoxChangeContainer>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</StyledBox>
|
</StyledBox>
|
||||||
);
|
);
|
@ -1,24 +0,0 @@
|
|||||||
import { Box, styled } from '@mui/material';
|
|
||||||
import { StatusBox } from './StatusBox';
|
|
||||||
|
|
||||||
const StyledBox = styled(Box)(({ theme }) => ({
|
|
||||||
padding: theme.spacing(0, 0, 2, 2),
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const ProjectStatus = () => {
|
|
||||||
return (
|
|
||||||
<StyledBox>
|
|
||||||
<StatusBox title="Total changes" boxText={'86'} change={-24} />
|
|
||||||
<StatusBox
|
|
||||||
title="Total changes"
|
|
||||||
boxText={'6 days'}
|
|
||||||
change={-12}
|
|
||||||
/>{' '}
|
|
||||||
<StatusBox title="Total changes" boxText={'86'} change={-24} />
|
|
||||||
<StatusBox title="Total changes" boxText={'86'} change={-24} />
|
|
||||||
</StyledBox>
|
|
||||||
);
|
|
||||||
};
|
|
@ -12,6 +12,7 @@ const fallbackProject: IProject = {
|
|||||||
version: '1',
|
version: '1',
|
||||||
description: 'Default',
|
description: 'Default',
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
stats: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const useProject = (id: string, options: SWRConfiguration = {}) => {
|
const useProject = (id: string, options: SWRConfiguration = {}) => {
|
||||||
|
@ -19,7 +19,7 @@ export interface IProject {
|
|||||||
description?: string;
|
description?: string;
|
||||||
environments: string[];
|
environments: string[];
|
||||||
health: number;
|
health: number;
|
||||||
|
stats: object;
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
features: IFeatureToggleListItem[];
|
features: IFeatureToggleListItem[];
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,31 @@ import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
|||||||
|
|
||||||
const TABLE = 'project_stats';
|
const TABLE = 'project_stats';
|
||||||
|
|
||||||
|
const PROJECT_STATS_COLUMNS = [
|
||||||
|
'avg_time_to_prod_current_window',
|
||||||
|
'avg_time_to_prod_past_window',
|
||||||
|
'project',
|
||||||
|
'features_created_current_window',
|
||||||
|
'features_created_past_window',
|
||||||
|
'features_archived_current_window',
|
||||||
|
'features_archived_past_window',
|
||||||
|
'project_changes_current_window',
|
||||||
|
'project_changes_past_window',
|
||||||
|
'project_members_added_current_window',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface IProjectStatsRow {
|
||||||
|
avg_time_to_prod_current_window: number;
|
||||||
|
avg_time_to_prod_past_window: number;
|
||||||
|
features_created_current_window: number;
|
||||||
|
features_created_past_window: number;
|
||||||
|
features_archived_current_window: number;
|
||||||
|
features_archived_past_window: number;
|
||||||
|
project_changes_current_window: number;
|
||||||
|
project_changes_past_window: number;
|
||||||
|
project_members_added_current_window: number;
|
||||||
|
}
|
||||||
|
|
||||||
class ProjectStatsStore implements IProjectStatsStore {
|
class ProjectStatsStore implements IProjectStatsStore {
|
||||||
private db: Knex;
|
private db: Knex;
|
||||||
|
|
||||||
@ -43,10 +68,40 @@ class ProjectStatsStore implements IProjectStatsStore {
|
|||||||
project_changes_current_window:
|
project_changes_current_window:
|
||||||
status.projectActivityCurrentWindow,
|
status.projectActivityCurrentWindow,
|
||||||
project_changes_past_window: status.projectActivityPastWindow,
|
project_changes_past_window: status.projectActivityPastWindow,
|
||||||
|
project_members_added_current_window:
|
||||||
|
status.projectMembersAddedCurrentWindow,
|
||||||
})
|
})
|
||||||
.onConflict('project')
|
.onConflict('project')
|
||||||
.merge();
|
.merge();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProjectStats(projectId: string): Promise<IProjectStats> {
|
||||||
|
const row = await this.db(TABLE)
|
||||||
|
.select(PROJECT_STATS_COLUMNS)
|
||||||
|
.where({ project: projectId })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return this.mapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapRow(row: IProjectStatsRow): IProjectStats | undefined {
|
||||||
|
if (!row) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
avgTimeToProdCurrentWindow: row.avg_time_to_prod_current_window,
|
||||||
|
avgTimeToProdPastWindow: row.avg_time_to_prod_past_window,
|
||||||
|
createdCurrentWindow: row.features_created_current_window,
|
||||||
|
createdPastWindow: row.features_created_past_window,
|
||||||
|
archivedCurrentWindow: row.features_archived_current_window,
|
||||||
|
archivedPastWindow: row.features_archived_past_window,
|
||||||
|
projectActivityCurrentWindow: row.project_changes_current_window,
|
||||||
|
projectActivityPastWindow: row.project_changes_past_window,
|
||||||
|
projectMembersAddedCurrentWindow:
|
||||||
|
row.project_members_added_current_window,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ProjectStatsStore;
|
export default ProjectStatsStore;
|
||||||
|
@ -394,6 +394,40 @@ class ProjectStore implements IProjectStore {
|
|||||||
return Number(members.count);
|
return Number(members.count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMembersCountByProjectAfterDate(
|
||||||
|
projectId: string,
|
||||||
|
date: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const members = await this.db
|
||||||
|
.from((db) => {
|
||||||
|
db.select('user_id')
|
||||||
|
.from('role_user')
|
||||||
|
.leftJoin('roles', 'role_user.role_id', 'roles.id')
|
||||||
|
.where((builder) =>
|
||||||
|
builder
|
||||||
|
.where('project', projectId)
|
||||||
|
.whereNot('type', 'root')
|
||||||
|
.andWhere('role_user.created_at', '>=', date),
|
||||||
|
)
|
||||||
|
.union((queryBuilder) => {
|
||||||
|
queryBuilder
|
||||||
|
.select('user_id')
|
||||||
|
.from('group_role')
|
||||||
|
.leftJoin(
|
||||||
|
'group_user',
|
||||||
|
'group_user.group_id',
|
||||||
|
'group_role.group_id',
|
||||||
|
)
|
||||||
|
.where('project', projectId)
|
||||||
|
.andWhere('group_role.created_at', '>=', date);
|
||||||
|
})
|
||||||
|
.as('query');
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
.first();
|
||||||
|
return Number(members.count);
|
||||||
|
}
|
||||||
|
|
||||||
async count(): Promise<number> {
|
async count(): Promise<number> {
|
||||||
return this.db
|
return this.db
|
||||||
.from(TABLE)
|
.from(TABLE)
|
||||||
|
@ -84,6 +84,7 @@ import {
|
|||||||
proxyFeaturesSchema,
|
proxyFeaturesSchema,
|
||||||
proxyMetricsSchema,
|
proxyMetricsSchema,
|
||||||
publicSignupTokenCreateSchema,
|
publicSignupTokenCreateSchema,
|
||||||
|
projectStatsSchema,
|
||||||
publicSignupTokenSchema,
|
publicSignupTokenSchema,
|
||||||
publicSignupTokensSchema,
|
publicSignupTokensSchema,
|
||||||
publicSignupTokenUpdateSchema,
|
publicSignupTokenUpdateSchema,
|
||||||
@ -225,6 +226,7 @@ export const schemas = {
|
|||||||
publicSignupTokensSchema,
|
publicSignupTokensSchema,
|
||||||
publicSignupTokenUpdateSchema,
|
publicSignupTokenUpdateSchema,
|
||||||
pushVariantsSchema,
|
pushVariantsSchema,
|
||||||
|
projectStatsSchema,
|
||||||
resetPasswordSchema,
|
resetPasswordSchema,
|
||||||
requestsPerSecondSchema,
|
requestsPerSecondSchema,
|
||||||
requestsPerSecondSegmentedSchema,
|
requestsPerSecondSegmentedSchema,
|
||||||
|
@ -7,6 +7,7 @@ import { featureSchema } from './feature-schema';
|
|||||||
import { constraintSchema } from './constraint-schema';
|
import { constraintSchema } from './constraint-schema';
|
||||||
import { environmentSchema } from './environment-schema';
|
import { environmentSchema } from './environment-schema';
|
||||||
import { featureEnvironmentSchema } from './feature-environment-schema';
|
import { featureEnvironmentSchema } from './feature-environment-schema';
|
||||||
|
import { projectStatsSchema } from './project-stats-schema';
|
||||||
|
|
||||||
export const healthOverviewSchema = {
|
export const healthOverviewSchema = {
|
||||||
$id: '#/components/schemas/healthOverviewSchema',
|
$id: '#/components/schemas/healthOverviewSchema',
|
||||||
@ -14,6 +15,9 @@ export const healthOverviewSchema = {
|
|||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: ['version', 'name'],
|
required: ['version', 'name'],
|
||||||
properties: {
|
properties: {
|
||||||
|
stats: {
|
||||||
|
$ref: '#/components/schemas/projectStatsSchema',
|
||||||
|
},
|
||||||
version: {
|
version: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
},
|
},
|
||||||
@ -60,6 +64,7 @@ export const healthOverviewSchema = {
|
|||||||
parametersSchema,
|
parametersSchema,
|
||||||
featureStrategySchema,
|
featureStrategySchema,
|
||||||
variantSchema,
|
variantSchema,
|
||||||
|
projectStatsSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -125,3 +125,4 @@ export * from './requests-per-second-segmented-schema';
|
|||||||
export * from './export-result-schema';
|
export * from './export-result-schema';
|
||||||
export * from './export-query-schema';
|
export * from './export-query-schema';
|
||||||
export * from './push-variants-schema';
|
export * from './push-variants-schema';
|
||||||
|
export * from './project-stats-schema';
|
||||||
|
39
src/lib/openapi/spec/project-stats-schema.ts
Normal file
39
src/lib/openapi/spec/project-stats-schema.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
|
export const projectStatsSchema = {
|
||||||
|
$id: '#/components/schemas/projectStatsSchema',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
avgTimeToProdCurrentWindow: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
avgTimeToProdPastWindow: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
createdCurrentWindow: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
createdPastWindow: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
archivedCurrentWindow: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
archivedPastWindow: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
projectActivityCurrentWindow: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
projectActivityPastWindow: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
projectMembersAddedCurrentWindow: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ProjectStatsSchema = FromSchema<typeof projectStatsSchema>;
|
@ -71,6 +71,7 @@ export interface IProjectStats {
|
|||||||
archivedPastWindow: Count;
|
archivedPastWindow: Count;
|
||||||
projectActivityCurrentWindow: Count;
|
projectActivityCurrentWindow: Count;
|
||||||
projectActivityPastWindow: Count;
|
projectActivityPastWindow: Count;
|
||||||
|
projectMembersAddedCurrentWindow: Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ICalculateStatus {
|
interface ICalculateStatus {
|
||||||
@ -757,6 +758,12 @@ export default class ProjectService {
|
|||||||
eventsPastWindow,
|
eventsPastWindow,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const projectMembersAddedCurrentWindow =
|
||||||
|
await this.store.getMembersCountByProjectAfterDate(
|
||||||
|
projectId,
|
||||||
|
dateMinusThirtyDays,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectId,
|
projectId,
|
||||||
updates: {
|
updates: {
|
||||||
@ -771,6 +778,8 @@ export default class ProjectService {
|
|||||||
projectActivityCurrentWindow:
|
projectActivityCurrentWindow:
|
||||||
projectActivityCurrentWindow.length,
|
projectActivityCurrentWindow.length,
|
||||||
projectActivityPastWindow: projectActivityPastWindow.length,
|
projectActivityPastWindow: projectActivityPastWindow.length,
|
||||||
|
projectMembersAddedCurrentWindow:
|
||||||
|
projectMembersAddedCurrentWindow,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -795,7 +804,13 @@ export default class ProjectService {
|
|||||||
project: projectId,
|
project: projectId,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectStats = await this.projectStatsStore.getProjectStats(
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
stats: projectStats || {},
|
||||||
name: project.name,
|
name: project.name,
|
||||||
description: project.description,
|
description: project.description,
|
||||||
health: project.health,
|
health: project.health,
|
||||||
|
@ -3,6 +3,7 @@ import { LogProvider } from '../logger';
|
|||||||
import { IRole } from './stores/access-store';
|
import { IRole } from './stores/access-store';
|
||||||
import { IUser } from './user';
|
import { IUser } from './user';
|
||||||
import { ALL_OPERATORS } from '../util/constants';
|
import { ALL_OPERATORS } from '../util/constants';
|
||||||
|
import { IProjectStats } from 'lib/services/project-service';
|
||||||
|
|
||||||
export type Operator = typeof ALL_OPERATORS[number];
|
export type Operator = typeof ALL_OPERATORS[number];
|
||||||
|
|
||||||
@ -182,6 +183,7 @@ export interface IProjectOverview {
|
|||||||
health: number;
|
health: number;
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
|
stats: IProjectStats | {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProjectHealthReport extends IProjectOverview {
|
export interface IProjectHealthReport extends IProjectOverview {
|
||||||
|
@ -2,4 +2,5 @@ import { IProjectStats } from 'lib/services/project-service';
|
|||||||
|
|
||||||
export interface IProjectStatsStore {
|
export interface IProjectStatsStore {
|
||||||
updateProjectStats(projectId: string, status: IProjectStats): Promise<void>;
|
updateProjectStats(projectId: string, status: IProjectStats): Promise<void>;
|
||||||
|
getProjectStats(projectId: string): Promise<IProjectStats>;
|
||||||
}
|
}
|
||||||
|
@ -54,6 +54,11 @@ export interface IProjectStore extends Store<IProject, string> {
|
|||||||
|
|
||||||
getMembersCountByProject(projectId: string): Promise<number>;
|
getMembersCountByProject(projectId: string): Promise<number>;
|
||||||
|
|
||||||
|
getMembersCountByProjectAfterDate(
|
||||||
|
projectId: string,
|
||||||
|
date: string,
|
||||||
|
): Promise<number>;
|
||||||
|
|
||||||
getProjectsByUser(userId: number): Promise<string[]>;
|
getProjectsByUser(userId: number): Promise<string[]>;
|
||||||
|
|
||||||
getMembersCount(): Promise<IProjectMembersCount[]>;
|
getMembersCount(): Promise<IProjectMembersCount[]>;
|
||||||
|
19
src/migrations/20230127111638-new-project-stats-field.js
Normal file
19
src/migrations/20230127111638-new-project-stats-field.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
exports.up = function (db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
ALTER table project_stats
|
||||||
|
ADD COLUMN IF NOT EXISTS project_members_added_current_window INTEGER DEFAULT 0
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
ALTER table project_stats
|
||||||
|
DROP COLUMN project_members_added_current_window
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
@ -1627,6 +1627,9 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
|
"stats": {
|
||||||
|
"$ref": "#/components/schemas/projectStatsSchema",
|
||||||
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
@ -1681,6 +1684,9 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
"staleCount": {
|
"staleCount": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
},
|
},
|
||||||
|
"stats": {
|
||||||
|
"$ref": "#/components/schemas/projectStatsSchema",
|
||||||
|
},
|
||||||
"updatedAt": {
|
"updatedAt": {
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
@ -2460,6 +2466,39 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
|
"projectStatsSchema": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"archivedCurrentWindow": {
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"archivedPastWindow": {
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"avgTimeToProdCurrentWindow": {
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"avgTimeToProdPastWindow": {
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"createdCurrentWindow": {
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"createdPastWindow": {
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"projectActivityCurrentWindow": {
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"projectActivityPastWindow": {
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"projectMembersAddedCurrentWindow": {
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
"projectsSchema": {
|
"projectsSchema": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
@ -1212,3 +1212,39 @@ test('should get correct amount of features archived in current and past window'
|
|||||||
expect(result.updates.archivedCurrentWindow).toBe(2);
|
expect(result.updates.archivedCurrentWindow).toBe(2);
|
||||||
expect(result.updates.archivedPastWindow).toBe(2);
|
expect(result.updates.archivedPastWindow).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should get correct amount of project members for current and past window', async () => {
|
||||||
|
const project = {
|
||||||
|
id: 'features-members',
|
||||||
|
name: 'features-members',
|
||||||
|
};
|
||||||
|
|
||||||
|
await projectService.createProject(project, user.id);
|
||||||
|
|
||||||
|
const users = [
|
||||||
|
{ name: 'memberOne', email: 'memberOne@getunleash.io' },
|
||||||
|
{ name: 'memberTwo', email: 'memberTwo@getunleash.io' },
|
||||||
|
{ name: 'memberThree', email: 'memberThree@getunleash.io' },
|
||||||
|
{ name: 'memberFour', email: 'memberFour@getunleash.io' },
|
||||||
|
{ name: 'memberFive', email: 'memberFive@getunleash.io' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const createdUsers = await Promise.all(
|
||||||
|
users.map((userObj) => stores.userStore.insert(userObj)),
|
||||||
|
);
|
||||||
|
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
createdUsers.map((createdUser) =>
|
||||||
|
projectService.addUser(
|
||||||
|
project.id,
|
||||||
|
memberRole.id,
|
||||||
|
createdUser.id,
|
||||||
|
'test',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await projectService.getStatusUpdates(project.id);
|
||||||
|
expect(result.updates.projectMembersAddedCurrentWindow).toBe(5);
|
||||||
|
});
|
||||||
|
@ -9,4 +9,8 @@ export default class FakeProjectStatsStore implements IProjectStatsStore {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
throw new Error('not implemented');
|
throw new Error('not implemented');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getProjectStats(projectId: string): Promise<IProjectStats> {
|
||||||
|
throw new Error('not implemented');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
9
src/test/fixtures/fake-project-store.ts
vendored
9
src/test/fixtures/fake-project-store.ts
vendored
@ -150,4 +150,13 @@ export default class FakeProjectStore implements IProjectStore {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
throw new Error('Method not implemented.');
|
throw new Error('Method not implemented.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMembersCountByProjectAfterDate(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
projectId: string,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
date: string,
|
||||||
|
): Promise<number> {
|
||||||
|
throw new Error('Method not implemented');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user