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:
parent
0f91c6b0c2
commit
04b2b488f6
@ -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}`}>
|
||||||
|
@ -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,
|
||||||
|
@ -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 */
|
||||||
|
@ -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(),
|
||||||
);
|
);
|
||||||
|
@ -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,
|
||||||
|
@ -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 () => {
|
||||||
|
@ -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,
|
||||||
|
@ -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 project’s daily activity statistics.',
|
'Array of activity records with date and count, representing the project’s 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',
|
||||||
|
Loading…
Reference in New Issue
Block a user