1
0
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:
Fredrik Strand Oseberg 2023-01-27 13:13:41 +01:00 committed by GitHub
parent b27ca26770
commit 897e97330a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 393 additions and 42 deletions

View File

@ -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',

View File

@ -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

View File

@ -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>
);
};

View File

@ -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>
); );

View File

@ -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>
);
};

View File

@ -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 = {}) => {

View File

@ -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[];
} }

View File

@ -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;

View File

@ -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)

View File

@ -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,

View File

@ -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;

View File

@ -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';

View 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>;

View File

@ -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,

View File

@ -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 {

View File

@ -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>;
} }

View File

@ -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[]>;

View 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,
);
};

View File

@ -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": {

View File

@ -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);
});

View File

@ -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');
}
} }

View File

@ -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');
}
} }