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%',
padding: theme.spacing(3, 2, 3, 2),
[theme.breakpoints.down('md')]: {
margin: theme.spacing(0, 0.5),
margin: theme.spacing(0, 1),
...flexRow,
flexDirection: 'column',
justifyContent: 'center',

View File

@ -10,7 +10,7 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useLastViewedProject } from '../../../hooks/useLastViewedProject';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ProjectStatus } from './ProjectStatus/ProjectStatus';
import { ProjectStats } from './ProjectStats/ProjectStats';
const refreshInterval = 15 * 1000;
@ -36,7 +36,9 @@ const StyledContentContainer = styled(Box)(() => ({
const ProjectOverview = () => {
const projectId = useRequiredPathParam('projectId');
const projectName = useProjectNameOrId(projectId);
const { project, loading } = useProject(projectId, { refreshInterval });
const { project, loading } = useProject(projectId, {
refreshInterval,
});
const { members, features, health, description, environments } = project;
usePageTitle(`Project overview ${projectName}`);
const { setLastViewed } = useLastViewedProject();
@ -58,7 +60,7 @@ const ProjectOverview = () => {
<StyledContentContainer>
<ConditionallyRender
condition={Boolean(uiConfig?.flags.newProjectOverview)}
show={<ProjectStatus />}
show={<ProjectStats stats={project.stats} />}
/>
<StyledProjectToggles>
<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 { Box, Typography, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { flexRow } from 'themes/themeStyles';
const StyledBox = styled(Box)(({ theme }) => ({
padding: theme.spacing(4, 2),
backgroundColor: theme.palette.background.paper,
minWidth: '240px',
minWidth: '24%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
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 }) => ({
@ -42,6 +55,7 @@ interface IStatusBoxProps {
title: string;
boxText: string;
change: number;
percentage?: boolean;
}
const resolveIcon = (change: number) => {
@ -62,23 +76,43 @@ const resolveColor = (change: number) => {
return 'error.main';
};
export const StatusBox = ({ title, boxText, change }: IStatusBoxProps) => {
export const StatusBox = ({
title,
boxText,
change,
percentage,
}: IStatusBoxProps) => {
return (
<StyledBox>
<StyledTypographyHeader>{title}</StyledTypographyHeader>
<Box sx={{ ...flexRow }}>
<StyledTypographyCount>{boxText}</StyledTypographyCount>
<StyledBoxChangeContainer>
<Box sx={{ ...flexRow }}>
{resolveIcon(change)}
<StyledTypographyChange color={resolveColor(change)}>
{change}
</StyledTypographyChange>
</Box>
<StyledTypographySubtext>
this month
</StyledTypographySubtext>
</StyledBoxChangeContainer>
<ConditionallyRender
condition={change !== 0}
show={
<StyledBoxChangeContainer>
<Box sx={{ ...flexRow }}>
{resolveIcon(change)}
<StyledTypographyChange
color={resolveColor(change)}
>
{change}
{percentage ? '%' : ''}
</StyledTypographyChange>
</Box>
<StyledTypographySubtext>
this month
</StyledTypographySubtext>
</StyledBoxChangeContainer>
}
elseShow={
<StyledBoxChangeContainer>
<StyledTypographySubtext>
No change
</StyledTypographySubtext>
</StyledBoxChangeContainer>
}
/>
</Box>
</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',
description: 'Default',
favorite: false,
stats: {},
};
const useProject = (id: string, options: SWRConfiguration = {}) => {

View File

@ -19,7 +19,7 @@ export interface IProject {
description?: string;
environments: string[];
health: number;
stats: object;
favorite: boolean;
features: IFeatureToggleListItem[];
}

View File

@ -9,6 +9,31 @@ import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
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 {
private db: Knex;
@ -43,10 +68,40 @@ class ProjectStatsStore implements IProjectStatsStore {
project_changes_current_window:
status.projectActivityCurrentWindow,
project_changes_past_window: status.projectActivityPastWindow,
project_members_added_current_window:
status.projectMembersAddedCurrentWindow,
})
.onConflict('project')
.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;

View File

@ -394,6 +394,40 @@ class ProjectStore implements IProjectStore {
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> {
return this.db
.from(TABLE)

View File

@ -84,6 +84,7 @@ import {
proxyFeaturesSchema,
proxyMetricsSchema,
publicSignupTokenCreateSchema,
projectStatsSchema,
publicSignupTokenSchema,
publicSignupTokensSchema,
publicSignupTokenUpdateSchema,
@ -225,6 +226,7 @@ export const schemas = {
publicSignupTokensSchema,
publicSignupTokenUpdateSchema,
pushVariantsSchema,
projectStatsSchema,
resetPasswordSchema,
requestsPerSecondSchema,
requestsPerSecondSegmentedSchema,

View File

@ -7,6 +7,7 @@ import { featureSchema } from './feature-schema';
import { constraintSchema } from './constraint-schema';
import { environmentSchema } from './environment-schema';
import { featureEnvironmentSchema } from './feature-environment-schema';
import { projectStatsSchema } from './project-stats-schema';
export const healthOverviewSchema = {
$id: '#/components/schemas/healthOverviewSchema',
@ -14,6 +15,9 @@ export const healthOverviewSchema = {
additionalProperties: false,
required: ['version', 'name'],
properties: {
stats: {
$ref: '#/components/schemas/projectStatsSchema',
},
version: {
type: 'number',
},
@ -60,6 +64,7 @@ export const healthOverviewSchema = {
parametersSchema,
featureStrategySchema,
variantSchema,
projectStatsSchema,
},
},
} as const;

View File

@ -125,3 +125,4 @@ export * from './requests-per-second-segmented-schema';
export * from './export-result-schema';
export * from './export-query-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;
projectActivityCurrentWindow: Count;
projectActivityPastWindow: Count;
projectMembersAddedCurrentWindow: Count;
}
interface ICalculateStatus {
@ -757,6 +758,12 @@ export default class ProjectService {
eventsPastWindow,
);
const projectMembersAddedCurrentWindow =
await this.store.getMembersCountByProjectAfterDate(
projectId,
dateMinusThirtyDays,
);
return {
projectId,
updates: {
@ -771,6 +778,8 @@ export default class ProjectService {
projectActivityCurrentWindow:
projectActivityCurrentWindow.length,
projectActivityPastWindow: projectActivityPastWindow.length,
projectMembersAddedCurrentWindow:
projectMembersAddedCurrentWindow,
},
};
}
@ -795,7 +804,13 @@ export default class ProjectService {
project: projectId,
userId,
});
const projectStats = await this.projectStatsStore.getProjectStats(
projectId,
);
return {
stats: projectStats || {},
name: project.name,
description: project.description,
health: project.health,

View File

@ -3,6 +3,7 @@ import { LogProvider } from '../logger';
import { IRole } from './stores/access-store';
import { IUser } from './user';
import { ALL_OPERATORS } from '../util/constants';
import { IProjectStats } from 'lib/services/project-service';
export type Operator = typeof ALL_OPERATORS[number];
@ -182,6 +183,7 @@ export interface IProjectOverview {
health: number;
favorite?: boolean;
updatedAt?: Date;
stats: IProjectStats | {};
}
export interface IProjectHealthReport extends IProjectOverview {

View File

@ -2,4 +2,5 @@ import { IProjectStats } from 'lib/services/project-service';
export interface IProjectStatsStore {
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>;
getMembersCountByProjectAfterDate(
projectId: string,
date: string,
): Promise<number>;
getProjectsByUser(userId: number): Promise<string[]>;
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": {
"type": "string",
},
"stats": {
"$ref": "#/components/schemas/projectStatsSchema",
},
"updatedAt": {
"format": "date-time",
"nullable": true,
@ -1681,6 +1684,9 @@ exports[`should serve the OpenAPI spec 1`] = `
"staleCount": {
"type": "number",
},
"stats": {
"$ref": "#/components/schemas/projectStatsSchema",
},
"updatedAt": {
"format": "date-time",
"nullable": true,
@ -2460,6 +2466,39 @@ exports[`should serve the OpenAPI spec 1`] = `
],
"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": {
"additionalProperties": false,
"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.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> {
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> {
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');
}
}