1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-27 00:19:39 +01:00

feat: expose stats, health and flag types insights (#6630)

This commit is contained in:
Mateusz Kwasniewski 2024-03-20 13:34:48 +01:00 committed by GitHub
parent 1becfc0202
commit 6dc6e36084
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 104 additions and 63 deletions

View File

@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import type { ProjectInsightsSchemaHealth } from '../../../../../openapi'; import type { ProjectInsightsSchemaHealth } from '../../../../../openapi';
import type { FC } from 'react'; import type { FC } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const Dot = styled('span', { const Dot = styled('span', {
shouldForwardProp: (prop) => prop !== 'color', shouldForwardProp: (prop) => prop !== 'color',
@ -48,10 +49,16 @@ export const ProjectHealth: FC<{ health: ProjectInsightsSchemaHealth }> = ({
return ( return (
<Container> <Container>
<Typography variant='h3'>Project Health</Typography> <Typography variant='h3'>Project Health</Typography>
<Alert severity='warning'> <ConditionallyRender
<b>Health alert!</b> Review your flags and delete the stale condition={staleCount > 0}
flags show={
</Alert> <Alert severity='warning'>
<b>Health alert!</b> Review your flags and delete the
stale flags
</Alert>
}
/>
<Box <Box
data-loading data-loading
sx={(theme) => ({ sx={(theme) => ({

View File

@ -39,6 +39,8 @@ import {
createPrivateProjectChecker, createPrivateProjectChecker,
} from '../private-project/createPrivateProjectChecker'; } from '../private-project/createPrivateProjectChecker';
import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store'; import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store';
import FeatureTypeStore from '../../db/feature-type-store';
import FakeFeatureTypeStore from '../../../test/fixtures/fake-feature-type-store';
export const createProjectService = ( export const createProjectService = (
db: Db, db: Db,
@ -66,6 +68,7 @@ export const createProjectService = (
eventBus, eventBus,
getLogger, getLogger,
); );
const featureTypeStore = new FeatureTypeStore(db, getLogger);
const projectStatsStore = new ProjectStatsStore(db, eventBus, getLogger); const projectStatsStore = new ProjectStatsStore(db, eventBus, getLogger);
const accessService: AccessService = createAccessService(db, config); const accessService: AccessService = createAccessService(db, config);
const featureToggleService = createFeatureToggleService(db, config); const featureToggleService = createFeatureToggleService(db, config);
@ -109,6 +112,7 @@ export const createProjectService = (
featureToggleStore, featureToggleStore,
environmentStore, environmentStore,
featureEnvironmentStore, featureEnvironmentStore,
featureTypeStore,
accountStore, accountStore,
projectStatsStore, projectStatsStore,
}, },
@ -133,6 +137,7 @@ export const createFakeProjectService = (
const accountStore = new FakeAccountStore(); const accountStore = new FakeAccountStore();
const environmentStore = new FakeEnvironmentStore(); const environmentStore = new FakeEnvironmentStore();
const featureEnvironmentStore = new FakeFeatureEnvironmentStore(); const featureEnvironmentStore = new FakeFeatureEnvironmentStore();
const featureTypeStore = new FakeFeatureTypeStore();
const projectStatsStore = new FakeProjectStatsStore(); const projectStatsStore = new FakeProjectStatsStore();
const { accessService } = createFakeAccessService(config); const { accessService } = createFakeAccessService(config);
const featureToggleService = createFakeFeatureToggleService(config); const featureToggleService = createFakeFeatureToggleService(config);
@ -168,6 +173,7 @@ export const createFakeProjectService = (
featureToggleStore, featureToggleStore,
environmentStore, environmentStore,
featureEnvironmentStore, featureEnvironmentStore,
featureTypeStore,
accountStore, accountStore,
projectStatsStore, projectStatsStore,
}, },

View File

@ -248,67 +248,15 @@ export default class ProjectController extends Controller {
req: IAuthRequest<IProjectParam, unknown, unknown, unknown>, req: IAuthRequest<IProjectParam, unknown, unknown, unknown>,
res: Response<ProjectInsightsSchema>, res: Response<ProjectInsightsSchema>,
): Promise<void> { ): Promise<void> {
const result = { const { projectId } = req.params;
stats: { const insights =
avgTimeToProdCurrentWindow: 17.1, await this.projectService.getProjectInsights(projectId);
createdCurrentWindow: 3,
createdPastWindow: 6,
archivedCurrentWindow: 0,
archivedPastWindow: 1,
projectActivityCurrentWindow: 458,
projectActivityPastWindow: 578,
projectMembersAddedCurrentWindow: 0,
},
featureTypeCounts: [
{
type: 'experiment',
count: 4,
},
{
type: 'permission',
count: 1,
},
{
type: 'release',
count: 24,
},
],
leadTime: {
projectAverage: 17.1,
features: [
{ name: 'feature1', timeToProduction: 120 },
{ name: 'feature2', timeToProduction: 0 },
{ name: 'feature3', timeToProduction: 33 },
{ name: 'feature4', timeToProduction: 131 },
{ name: 'feature5', timeToProduction: 2 },
],
},
health: {
rating: 80,
activeCount: 23,
potentiallyStaleCount: 3,
staleCount: 5,
},
members: {
active: 20,
inactive: 3,
totalPreviousMonth: 15,
},
changeRequests: {
total: 24,
approved: 5,
applied: 2,
rejected: 4,
reviewRequired: 10,
scheduled: 3,
},
};
this.openApiService.respondWithValidation( this.openApiService.respondWithValidation(
200, 200,
res, res,
projectInsightsSchema.$id, projectInsightsSchema.$id,
serializeDates(result), serializeDates(insights),
); );
} }

View File

@ -21,6 +21,7 @@ import {
type IFeatureEnvironmentStore, type IFeatureEnvironmentStore,
type IFeatureNaming, type IFeatureNaming,
type IFeatureToggleStore, type IFeatureToggleStore,
type IFeatureTypeStore,
type IFlagResolver, type IFlagResolver,
type IProject, type IProject,
type IProjectApplications, type IProjectApplications,
@ -75,6 +76,7 @@ import type {
IProjectEnterpriseSettingsUpdate, IProjectEnterpriseSettingsUpdate,
IProjectQuery, IProjectQuery,
} from './project-store-type'; } from './project-store-type';
import { calculateProjectHealth } from '../../domain/project-health/project-health';
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown'; const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
@ -119,6 +121,8 @@ export default class ProjectService {
private featureEnvironmentStore: IFeatureEnvironmentStore; private featureEnvironmentStore: IFeatureEnvironmentStore;
private featureTypeStore: IFeatureTypeStore;
private environmentStore: IEnvironmentStore; private environmentStore: IEnvironmentStore;
private groupService: GroupService; private groupService: GroupService;
@ -148,6 +152,7 @@ export default class ProjectService {
featureToggleStore, featureToggleStore,
environmentStore, environmentStore,
featureEnvironmentStore, featureEnvironmentStore,
featureTypeStore,
accountStore, accountStore,
projectStatsStore, projectStatsStore,
}: Pick< }: Pick<
@ -159,6 +164,7 @@ export default class ProjectService {
| 'featureEnvironmentStore' | 'featureEnvironmentStore'
| 'accountStore' | 'accountStore'
| 'projectStatsStore' | 'projectStatsStore'
| 'featureTypeStore'
>, >,
config: IUnleashConfig, config: IUnleashConfig,
accessService: AccessService, accessService: AccessService,
@ -174,6 +180,7 @@ export default class ProjectService {
this.accessService = accessService; this.accessService = accessService;
this.eventStore = eventStore; this.eventStore = eventStore;
this.featureToggleStore = featureToggleStore; this.featureToggleStore = featureToggleStore;
this.featureTypeStore = featureTypeStore;
this.featureToggleService = featureToggleService; this.featureToggleService = featureToggleService;
this.favoritesService = favoriteService; this.favoritesService = favoriteService;
this.privateProjectChecker = privateProjectChecker; this.privateProjectChecker = privateProjectChecker;
@ -722,6 +729,7 @@ export default class ProjectService {
(r) => r.project === project && r.name === RoleName.OWNER, (r) => r.project === project && r.name === RoleName.OWNER,
); );
} }
private async isAllowedToAddAccess( private async isAllowedToAddAccess(
userAddingAccess: number, userAddingAccess: number,
projectId: string, projectId: string,
@ -741,6 +749,7 @@ export default class ProjectService {
userRoles.some((userRole) => userRole.id === roleId), userRoles.some((userRole) => userRole.id === roleId),
); );
} }
async addAccess( async addAccess(
projectId: string, projectId: string,
roles: number[], roles: number[],
@ -1231,6 +1240,62 @@ export default class ProjectService {
}; };
} }
private async getHealthInsights(projectId: string) {
const [overview, featureTypes] = await Promise.all([
this.getProjectHealth(projectId, false, undefined),
this.featureTypeStore.getAll(),
]);
const { activeCount, potentiallyStaleCount, staleCount } =
calculateProjectHealth(overview.features, featureTypes);
return {
activeCount,
potentiallyStaleCount,
staleCount,
rating: overview.health,
};
}
async getProjectInsights(projectId: string) {
const result = {
leadTime: {
projectAverage: 17.1,
features: [
{ name: 'feature1', timeToProduction: 120 },
{ name: 'feature2', timeToProduction: 0 },
{ name: 'feature3', timeToProduction: 33 },
{ name: 'feature4', timeToProduction: 131 },
{ name: 'feature5', timeToProduction: 2 },
],
},
members: {
active: 20,
inactive: 3,
totalPreviousMonth: 15,
},
changeRequests: {
total: 24,
approved: 5,
applied: 2,
rejected: 4,
reviewRequired: 10,
scheduled: 3,
},
};
const [stats, featureTypeCounts, health] = await Promise.all([
this.projectStatsStore.getProjectStats(projectId),
this.featureToggleService.getFeatureTypeCounts({
projectId,
archived: false,
}),
this.getHealthInsights(projectId),
]);
return { ...result, stats, featureTypeCounts, health };
}
async getProjectHealth( async getProjectHealth(
projectId: string, projectId: string,
archived: boolean = false, archived: boolean = false,

View File

@ -294,8 +294,23 @@ test('project insights happy path', async () => {
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200); .expect(200);
expect(body.leadTime.features[0]).toEqual({ expect(body).toMatchObject({
name: 'feature1', stats: {
timeToProduction: 120, avgTimeToProdCurrentWindow: 0,
createdCurrentWindow: 0,
createdPastWindow: 0,
archivedCurrentWindow: 0,
archivedPastWindow: 0,
projectActivityCurrentWindow: 0,
projectActivityPastWindow: 0,
projectMembersAddedCurrentWindow: 0,
},
featureTypeCounts: [],
health: {
activeCount: 0,
potentiallyStaleCount: 0,
staleCount: 0,
rating: 100,
},
}); });
}); });