1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

chore(1-3133): change avg health to current health in project status (#8803)

This PR updates the project status service (and schemas and UI) to use
the project's current health instead of the 4-week average.

I nabbed the `calculateHealthRating` from
`src/lib/services/project-health-service.ts` instead of relying on the
service itself, because that service relies on the project service,
which relies on pretty much everything in the entire system.

However, I think we can split the health service into a service that
*does* need the project service (which is used for 1 of 3 methods) and a
service (or read model) that doesn't. We could then rely on the second
one for this service without too much overhead. Or we could extract the
`calculateHealthRating` into a shared function that takes its stores as
arguments. ... but I suggest doing that in a follow-up PR.

Because the calculation has been tested other places (especially if we
rely on a service / shared function for it), I've simplified the tests
to just verify that it's present.

I've changed the schema's `averageHealth` into an object in case we want
to include average health etc. in the future, but this is up for debate.
This commit is contained in:
Thomas Heartman 2024-11-20 11:41:45 +01:00 committed by GitHub
parent 0f91c6b0c2
commit 04b2b488f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 81 additions and 55 deletions

View File

@ -90,8 +90,9 @@ const Wrapper = styled(HealthGridTile)(({ theme }) => ({
export const ProjectHealth = () => { export const ProjectHealth = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const { const {
data: { averageHealth, staleFlags }, data: { health, staleFlags },
} = useProjectStatus(projectId); } = useProjectStatus(projectId);
const healthRating = health.current;
const { isOss } = useUiConfig(); const { isOss } = useUiConfig();
const theme = useTheme(); const theme = useTheme();
const circumference = 2 * Math.PI * ChartRadius; // const circumference = 2 * Math.PI * ChartRadius; //
@ -99,12 +100,12 @@ export const ProjectHealth = () => {
const gapLength = 0.3; const gapLength = 0.3;
const filledLength = 1 - gapLength; const filledLength = 1 - gapLength;
const offset = 0.75 - gapLength / 2; const offset = 0.75 - gapLength / 2;
const healthLength = (averageHealth / 100) * circumference * 0.7; const healthLength = (healthRating / 100) * circumference * 0.7;
const healthColor = const healthColor =
averageHealth >= 0 && averageHealth <= 24 healthRating >= 0 && healthRating <= 24
? theme.palette.error.main ? theme.palette.error.main
: averageHealth >= 25 && averageHealth <= 74 : healthRating >= 25 && healthRating <= 74
? theme.palette.warning.border ? theme.palette.warning.border
: theme.palette.success.border; : theme.palette.success.border;
@ -141,14 +142,13 @@ export const ProjectHealth = () => {
fill={theme.palette.text.primary} fill={theme.palette.text.primary}
fontSize={theme.typography.h1.fontSize} fontSize={theme.typography.h1.fontSize}
> >
{averageHealth}% {healthRating}%
</text> </text>
</StyledSVG> </StyledSVG>
</SVGWrapper> </SVGWrapper>
<TextContainer> <TextContainer>
<Typography> <Typography>
On average, your project health has remained at{' '} Your current project health rating is {healthRating}%
{averageHealth}% the last 4 weeks
</Typography> </Typography>
{!isOss() && ( {!isOss() && (
<Link to={`/insights?project=IS%3A${projectId}`}> <Link to={`/insights?project=IS%3A${projectId}`}>

View File

@ -11,7 +11,9 @@ const placeholderData: ProjectStatusSchema = {
apiTokens: 0, apiTokens: 0,
segments: 0, segments: 0,
}, },
averageHealth: 0, health: {
current: 0,
},
lifecycleSummary: { lifecycleSummary: {
initial: { initial: {
currentFlags: 0, currentFlags: 0,

View File

@ -17,7 +17,9 @@ export interface ProjectStatusSchema {
* The average health score over the last 4 weeks, indicating whether features are stale or active. * The average health score over the last 4 weeks, indicating whether features are stale or active.
* @minimum 0 * @minimum 0
*/ */
averageHealth: number; health: {
current: number;
};
/** Feature flag lifecycle statistics for this project. */ /** Feature flag lifecycle statistics for this project. */
lifecycleSummary: ProjectStatusSchemaLifecycleSummary; lifecycleSummary: ProjectStatusSchemaLifecycleSummary;
/** Key resources within the project */ /** Key resources within the project */

View File

@ -8,14 +8,16 @@ import FakeApiTokenStore from '../../../test/fixtures/fake-api-token-store';
import { ApiTokenStore } from '../../db/api-token-store'; import { ApiTokenStore } from '../../db/api-token-store';
import SegmentStore from '../segment/segment-store'; import SegmentStore from '../segment/segment-store';
import FakeSegmentStore from '../../../test/fixtures/fake-segment-store'; import FakeSegmentStore from '../../../test/fixtures/fake-segment-store';
import { PersonalDashboardReadModel } from '../personal-dashboard/personal-dashboard-read-model';
import { FakePersonalDashboardReadModel } from '../personal-dashboard/fake-personal-dashboard-read-model';
import { import {
createFakeProjectLifecycleSummaryReadModel, createFakeProjectLifecycleSummaryReadModel,
createProjectLifecycleSummaryReadModel, createProjectLifecycleSummaryReadModel,
} from './project-lifecycle-read-model/createProjectLifecycleSummaryReadModel'; } from './project-lifecycle-read-model/createProjectLifecycleSummaryReadModel';
import { ProjectStaleFlagsReadModel } from './project-stale-flags-read-model/project-stale-flags-read-model'; import { ProjectStaleFlagsReadModel } from './project-stale-flags-read-model/project-stale-flags-read-model';
import { FakeProjectStaleFlagsReadModel } from './project-stale-flags-read-model/fake-project-stale-flags-read-model'; import { FakeProjectStaleFlagsReadModel } from './project-stale-flags-read-model/fake-project-stale-flags-read-model';
import FeatureTypeStore from '../../db/feature-type-store';
import FeatureToggleStore from '../feature-toggle/feature-toggle-store';
import FakeFeatureToggleStore from '../feature-toggle/fakes/fake-feature-toggle-store';
import FakeFeatureTypeStore from '../../../test/fixtures/fake-feature-type-store';
export const createProjectStatusService = ( export const createProjectStatusService = (
db: Db, db: Db,
@ -44,14 +46,23 @@ export const createProjectStatusService = (
createProjectLifecycleSummaryReadModel(db, config); createProjectLifecycleSummaryReadModel(db, config);
const projectStaleFlagsReadModel = new ProjectStaleFlagsReadModel(db); const projectStaleFlagsReadModel = new ProjectStaleFlagsReadModel(db);
const featureTypeStore = new FeatureTypeStore(db, config.getLogger);
const featureToggleStore = new FeatureToggleStore(
db,
config.eventBus,
config.getLogger,
config.flagResolver,
);
return new ProjectStatusService( return new ProjectStatusService(
{ {
eventStore, eventStore,
projectStore, projectStore,
apiTokenStore, apiTokenStore,
segmentStore, segmentStore,
featureTypeStore,
featureToggleStore,
}, },
new PersonalDashboardReadModel(db),
projectLifecycleSummaryReadModel, projectLifecycleSummaryReadModel,
projectStaleFlagsReadModel, projectStaleFlagsReadModel,
); );
@ -62,14 +73,17 @@ export const createFakeProjectStatusService = () => {
const projectStore = new FakeProjectStore(); const projectStore = new FakeProjectStore();
const apiTokenStore = new FakeApiTokenStore(); const apiTokenStore = new FakeApiTokenStore();
const segmentStore = new FakeSegmentStore(); const segmentStore = new FakeSegmentStore();
const featureTypeStore = new FakeFeatureTypeStore();
const featureToggleStore = new FakeFeatureToggleStore();
const projectStatusService = new ProjectStatusService( const projectStatusService = new ProjectStatusService(
{ {
eventStore, eventStore,
projectStore, projectStore,
apiTokenStore, apiTokenStore,
segmentStore, segmentStore,
featureTypeStore,
featureToggleStore,
}, },
new FakePersonalDashboardReadModel(),
createFakeProjectLifecycleSummaryReadModel(), createFakeProjectLifecycleSummaryReadModel(),
new FakeProjectStaleFlagsReadModel(), new FakeProjectStaleFlagsReadModel(),
); );

View File

@ -1,12 +1,14 @@
import { calculateHealthRating } from '../../domain/project-health/project-health';
import type { ProjectStatusSchema } from '../../openapi'; import type { ProjectStatusSchema } from '../../openapi';
import type { import type {
IApiTokenStore, IApiTokenStore,
IEventStore, IEventStore,
IFeatureToggleStore,
IFeatureTypeStore,
IProjectStore, IProjectStore,
ISegmentStore, ISegmentStore,
IUnleashStores, IUnleashStores,
} from '../../types'; } from '../../types';
import type { IPersonalDashboardReadModel } from '../personal-dashboard/personal-dashboard-read-model-type';
import type { IProjectLifecycleSummaryReadModel } from './project-lifecycle-read-model/project-lifecycle-read-model-type'; import type { IProjectLifecycleSummaryReadModel } from './project-lifecycle-read-model/project-lifecycle-read-model-type';
import type { IProjectStaleFlagsReadModel } from './project-stale-flags-read-model/project-stale-flags-read-model-type'; import type { IProjectStaleFlagsReadModel } from './project-stale-flags-read-model/project-stale-flags-read-model-type';
@ -15,9 +17,10 @@ export class ProjectStatusService {
private projectStore: IProjectStore; private projectStore: IProjectStore;
private apiTokenStore: IApiTokenStore; private apiTokenStore: IApiTokenStore;
private segmentStore: ISegmentStore; private segmentStore: ISegmentStore;
private personalDashboardReadModel: IPersonalDashboardReadModel;
private projectLifecycleSummaryReadModel: IProjectLifecycleSummaryReadModel; private projectLifecycleSummaryReadModel: IProjectLifecycleSummaryReadModel;
private projectStaleFlagsReadModel: IProjectStaleFlagsReadModel; private projectStaleFlagsReadModel: IProjectStaleFlagsReadModel;
private featureTypeStore: IFeatureTypeStore;
private featureToggleStore: IFeatureToggleStore;
constructor( constructor(
{ {
@ -25,11 +28,17 @@ export class ProjectStatusService {
projectStore, projectStore,
apiTokenStore, apiTokenStore,
segmentStore, segmentStore,
featureTypeStore,
featureToggleStore,
}: Pick< }: Pick<
IUnleashStores, IUnleashStores,
'eventStore' | 'projectStore' | 'apiTokenStore' | 'segmentStore' | 'eventStore'
| 'projectStore'
| 'apiTokenStore'
| 'segmentStore'
| 'featureTypeStore'
| 'featureToggleStore'
>, >,
personalDashboardReadModel: IPersonalDashboardReadModel,
projectLifecycleReadModel: IProjectLifecycleSummaryReadModel, projectLifecycleReadModel: IProjectLifecycleSummaryReadModel,
projectStaleFlagsReadModel: IProjectStaleFlagsReadModel, projectStaleFlagsReadModel: IProjectStaleFlagsReadModel,
) { ) {
@ -37,9 +46,21 @@ export class ProjectStatusService {
this.projectStore = projectStore; this.projectStore = projectStore;
this.apiTokenStore = apiTokenStore; this.apiTokenStore = apiTokenStore;
this.segmentStore = segmentStore; this.segmentStore = segmentStore;
this.personalDashboardReadModel = personalDashboardReadModel;
this.projectLifecycleSummaryReadModel = projectLifecycleReadModel; this.projectLifecycleSummaryReadModel = projectLifecycleReadModel;
this.projectStaleFlagsReadModel = projectStaleFlagsReadModel; this.projectStaleFlagsReadModel = projectStaleFlagsReadModel;
this.featureTypeStore = featureTypeStore;
this.featureToggleStore = featureToggleStore;
}
private async calculateHealthRating(projectId: string): Promise<number> {
const featureTypes = await this.featureTypeStore.getAll();
const toggles = await this.featureToggleStore.getAll({
project: projectId,
archived: false,
});
return calculateHealthRating(toggles, featureTypes);
} }
async getProjectStatus(projectId: string): Promise<ProjectStatusSchema> { async getProjectStatus(projectId: string): Promise<ProjectStatusSchema> {
@ -48,7 +69,7 @@ export class ProjectStatusService {
apiTokens, apiTokens,
segments, segments,
activityCountByDate, activityCountByDate,
healthScores, currentHealth,
lifecycleSummary, lifecycleSummary,
staleFlagCount, staleFlagCount,
] = await Promise.all([ ] = await Promise.all([
@ -56,7 +77,7 @@ export class ProjectStatusService {
this.apiTokenStore.countProjectTokens(projectId), this.apiTokenStore.countProjectTokens(projectId),
this.segmentStore.getProjectSegmentCount(projectId), this.segmentStore.getProjectSegmentCount(projectId),
this.eventStore.getProjectRecentEventActivity(projectId), this.eventStore.getProjectRecentEventActivity(projectId),
this.personalDashboardReadModel.getLatestHealthScores(projectId, 4), this.calculateHealthRating(projectId),
this.projectLifecycleSummaryReadModel.getProjectLifecycleSummary( this.projectLifecycleSummaryReadModel.getProjectLifecycleSummary(
projectId, projectId,
), ),
@ -65,11 +86,6 @@ export class ProjectStatusService {
), ),
]); ]);
const averageHealth = healthScores.length
? healthScores.reduce((acc, num) => acc + num, 0) /
healthScores.length
: 0;
return { return {
resources: { resources: {
members, members,
@ -77,7 +93,9 @@ export class ProjectStatusService {
segments, segments,
}, },
activityCountByDate, activityCountByDate,
averageHealth: Math.round(averageHealth), health: {
current: currentHealth,
},
lifecycleSummary, lifecycleSummary,
staleFlags: { staleFlags: {
total: staleFlagCount, total: staleFlagCount,

View File

@ -196,33 +196,13 @@ test('project resources should contain the right data', async () => {
}); });
}); });
test('project health should be correct average', async () => { test('project health contains the current health score', async () => {
await insertHealthScore('2024-04', 100);
await insertHealthScore('2024-05', 0);
await insertHealthScore('2024-06', 0);
await insertHealthScore('2024-07', 90);
await insertHealthScore('2024-08', 70);
const { body } = await app.request const { body } = await app.request
.get('/api/admin/projects/default/status') .get('/api/admin/projects/default/status')
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200); .expect(200);
expect(body.averageHealth).toBe(40); expect(body.health.current).toBe(100);
});
test('project health stats should round to nearest integer', async () => {
await insertHealthScore('2024-04', 6);
await insertHealthScore('2024-05', 5);
const { body } = await app.request
.get('/api/admin/projects/default/status')
.expect('Content-Type', /json/)
.expect(200);
expect(body.averageHealth).toBe(6);
}); });
test('project status contains lifecycle data', async () => { test('project status contains lifecycle data', async () => {

View File

@ -3,7 +3,9 @@ import type { ProjectStatusSchema } from './project-status-schema';
test('projectStatusSchema', () => { test('projectStatusSchema', () => {
const data: ProjectStatusSchema = { const data: ProjectStatusSchema = {
averageHealth: 50, health: {
current: 50,
},
lifecycleSummary: { lifecycleSummary: {
initial: { initial: {
currentFlags: 0, currentFlags: 0,

View File

@ -31,7 +31,7 @@ export const projectStatusSchema = {
required: [ required: [
'activityCountByDate', 'activityCountByDate',
'resources', 'resources',
'averageHealth', 'health',
'lifecycleSummary', 'lifecycleSummary',
'staleFlags', 'staleFlags',
], ],
@ -43,11 +43,19 @@ export const projectStatusSchema = {
description: description:
'Array of activity records with date and count, representing the projects daily activity statistics.', 'Array of activity records with date and count, representing the projects daily activity statistics.',
}, },
averageHealth: { health: {
type: 'integer', type: 'object',
minimum: 0, additionalProperties: false,
description: required: ['current'],
'The average health score over the last 4 weeks, indicating whether features are stale or active.', description: "Information about the project's health rating",
properties: {
current: {
type: 'integer',
minimum: 0,
description: `The project's current health score, based on the ratio of healthy flags to stale and potentially stale flags.`,
example: 100,
},
},
}, },
resources: { resources: {
type: 'object', type: 'object',