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 = () => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const {
|
||||
data: { averageHealth, staleFlags },
|
||||
data: { health, staleFlags },
|
||||
} = useProjectStatus(projectId);
|
||||
const healthRating = health.current;
|
||||
const { isOss } = useUiConfig();
|
||||
const theme = useTheme();
|
||||
const circumference = 2 * Math.PI * ChartRadius; //
|
||||
@ -99,12 +100,12 @@ export const ProjectHealth = () => {
|
||||
const gapLength = 0.3;
|
||||
const filledLength = 1 - gapLength;
|
||||
const offset = 0.75 - gapLength / 2;
|
||||
const healthLength = (averageHealth / 100) * circumference * 0.7;
|
||||
const healthLength = (healthRating / 100) * circumference * 0.7;
|
||||
|
||||
const healthColor =
|
||||
averageHealth >= 0 && averageHealth <= 24
|
||||
healthRating >= 0 && healthRating <= 24
|
||||
? theme.palette.error.main
|
||||
: averageHealth >= 25 && averageHealth <= 74
|
||||
: healthRating >= 25 && healthRating <= 74
|
||||
? theme.palette.warning.border
|
||||
: theme.palette.success.border;
|
||||
|
||||
@ -141,14 +142,13 @@ export const ProjectHealth = () => {
|
||||
fill={theme.palette.text.primary}
|
||||
fontSize={theme.typography.h1.fontSize}
|
||||
>
|
||||
{averageHealth}%
|
||||
{healthRating}%
|
||||
</text>
|
||||
</StyledSVG>
|
||||
</SVGWrapper>
|
||||
<TextContainer>
|
||||
<Typography>
|
||||
On average, your project health has remained at{' '}
|
||||
{averageHealth}% the last 4 weeks
|
||||
Your current project health rating is {healthRating}%
|
||||
</Typography>
|
||||
{!isOss() && (
|
||||
<Link to={`/insights?project=IS%3A${projectId}`}>
|
||||
|
@ -11,7 +11,9 @@ const placeholderData: ProjectStatusSchema = {
|
||||
apiTokens: 0,
|
||||
segments: 0,
|
||||
},
|
||||
averageHealth: 0,
|
||||
health: {
|
||||
current: 0,
|
||||
},
|
||||
lifecycleSummary: {
|
||||
initial: {
|
||||
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.
|
||||
* @minimum 0
|
||||
*/
|
||||
averageHealth: number;
|
||||
health: {
|
||||
current: number;
|
||||
};
|
||||
/** Feature flag lifecycle statistics for this project. */
|
||||
lifecycleSummary: ProjectStatusSchemaLifecycleSummary;
|
||||
/** 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 SegmentStore from '../segment/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 {
|
||||
createFakeProjectLifecycleSummaryReadModel,
|
||||
createProjectLifecycleSummaryReadModel,
|
||||
} from './project-lifecycle-read-model/createProjectLifecycleSummaryReadModel';
|
||||
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 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 = (
|
||||
db: Db,
|
||||
@ -44,14 +46,23 @@ export const createProjectStatusService = (
|
||||
createProjectLifecycleSummaryReadModel(db, config);
|
||||
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(
|
||||
{
|
||||
eventStore,
|
||||
projectStore,
|
||||
apiTokenStore,
|
||||
segmentStore,
|
||||
featureTypeStore,
|
||||
featureToggleStore,
|
||||
},
|
||||
new PersonalDashboardReadModel(db),
|
||||
projectLifecycleSummaryReadModel,
|
||||
projectStaleFlagsReadModel,
|
||||
);
|
||||
@ -62,14 +73,17 @@ export const createFakeProjectStatusService = () => {
|
||||
const projectStore = new FakeProjectStore();
|
||||
const apiTokenStore = new FakeApiTokenStore();
|
||||
const segmentStore = new FakeSegmentStore();
|
||||
const featureTypeStore = new FakeFeatureTypeStore();
|
||||
const featureToggleStore = new FakeFeatureToggleStore();
|
||||
const projectStatusService = new ProjectStatusService(
|
||||
{
|
||||
eventStore,
|
||||
projectStore,
|
||||
apiTokenStore,
|
||||
segmentStore,
|
||||
featureTypeStore,
|
||||
featureToggleStore,
|
||||
},
|
||||
new FakePersonalDashboardReadModel(),
|
||||
createFakeProjectLifecycleSummaryReadModel(),
|
||||
new FakeProjectStaleFlagsReadModel(),
|
||||
);
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { calculateHealthRating } from '../../domain/project-health/project-health';
|
||||
import type { ProjectStatusSchema } from '../../openapi';
|
||||
import type {
|
||||
IApiTokenStore,
|
||||
IEventStore,
|
||||
IFeatureToggleStore,
|
||||
IFeatureTypeStore,
|
||||
IProjectStore,
|
||||
ISegmentStore,
|
||||
IUnleashStores,
|
||||
} 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 { IProjectStaleFlagsReadModel } from './project-stale-flags-read-model/project-stale-flags-read-model-type';
|
||||
|
||||
@ -15,9 +17,10 @@ export class ProjectStatusService {
|
||||
private projectStore: IProjectStore;
|
||||
private apiTokenStore: IApiTokenStore;
|
||||
private segmentStore: ISegmentStore;
|
||||
private personalDashboardReadModel: IPersonalDashboardReadModel;
|
||||
private projectLifecycleSummaryReadModel: IProjectLifecycleSummaryReadModel;
|
||||
private projectStaleFlagsReadModel: IProjectStaleFlagsReadModel;
|
||||
private featureTypeStore: IFeatureTypeStore;
|
||||
private featureToggleStore: IFeatureToggleStore;
|
||||
|
||||
constructor(
|
||||
{
|
||||
@ -25,11 +28,17 @@ export class ProjectStatusService {
|
||||
projectStore,
|
||||
apiTokenStore,
|
||||
segmentStore,
|
||||
featureTypeStore,
|
||||
featureToggleStore,
|
||||
}: Pick<
|
||||
IUnleashStores,
|
||||
'eventStore' | 'projectStore' | 'apiTokenStore' | 'segmentStore'
|
||||
| 'eventStore'
|
||||
| 'projectStore'
|
||||
| 'apiTokenStore'
|
||||
| 'segmentStore'
|
||||
| 'featureTypeStore'
|
||||
| 'featureToggleStore'
|
||||
>,
|
||||
personalDashboardReadModel: IPersonalDashboardReadModel,
|
||||
projectLifecycleReadModel: IProjectLifecycleSummaryReadModel,
|
||||
projectStaleFlagsReadModel: IProjectStaleFlagsReadModel,
|
||||
) {
|
||||
@ -37,9 +46,21 @@ export class ProjectStatusService {
|
||||
this.projectStore = projectStore;
|
||||
this.apiTokenStore = apiTokenStore;
|
||||
this.segmentStore = segmentStore;
|
||||
this.personalDashboardReadModel = personalDashboardReadModel;
|
||||
this.projectLifecycleSummaryReadModel = projectLifecycleReadModel;
|
||||
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> {
|
||||
@ -48,7 +69,7 @@ export class ProjectStatusService {
|
||||
apiTokens,
|
||||
segments,
|
||||
activityCountByDate,
|
||||
healthScores,
|
||||
currentHealth,
|
||||
lifecycleSummary,
|
||||
staleFlagCount,
|
||||
] = await Promise.all([
|
||||
@ -56,7 +77,7 @@ export class ProjectStatusService {
|
||||
this.apiTokenStore.countProjectTokens(projectId),
|
||||
this.segmentStore.getProjectSegmentCount(projectId),
|
||||
this.eventStore.getProjectRecentEventActivity(projectId),
|
||||
this.personalDashboardReadModel.getLatestHealthScores(projectId, 4),
|
||||
this.calculateHealthRating(projectId),
|
||||
this.projectLifecycleSummaryReadModel.getProjectLifecycleSummary(
|
||||
projectId,
|
||||
),
|
||||
@ -65,11 +86,6 @@ export class ProjectStatusService {
|
||||
),
|
||||
]);
|
||||
|
||||
const averageHealth = healthScores.length
|
||||
? healthScores.reduce((acc, num) => acc + num, 0) /
|
||||
healthScores.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
resources: {
|
||||
members,
|
||||
@ -77,7 +93,9 @@ export class ProjectStatusService {
|
||||
segments,
|
||||
},
|
||||
activityCountByDate,
|
||||
averageHealth: Math.round(averageHealth),
|
||||
health: {
|
||||
current: currentHealth,
|
||||
},
|
||||
lifecycleSummary,
|
||||
staleFlags: {
|
||||
total: staleFlagCount,
|
||||
|
@ -196,33 +196,13 @@ test('project resources should contain the right data', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('project health should be correct average', 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);
|
||||
|
||||
test('project health contains the current health score', async () => {
|
||||
const { body } = await app.request
|
||||
.get('/api/admin/projects/default/status')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200);
|
||||
|
||||
expect(body.averageHealth).toBe(40);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(body.health.current).toBe(100);
|
||||
});
|
||||
|
||||
test('project status contains lifecycle data', async () => {
|
||||
|
@ -3,7 +3,9 @@ import type { ProjectStatusSchema } from './project-status-schema';
|
||||
|
||||
test('projectStatusSchema', () => {
|
||||
const data: ProjectStatusSchema = {
|
||||
averageHealth: 50,
|
||||
health: {
|
||||
current: 50,
|
||||
},
|
||||
lifecycleSummary: {
|
||||
initial: {
|
||||
currentFlags: 0,
|
||||
|
@ -31,7 +31,7 @@ export const projectStatusSchema = {
|
||||
required: [
|
||||
'activityCountByDate',
|
||||
'resources',
|
||||
'averageHealth',
|
||||
'health',
|
||||
'lifecycleSummary',
|
||||
'staleFlags',
|
||||
],
|
||||
@ -43,11 +43,19 @@ export const projectStatusSchema = {
|
||||
description:
|
||||
'Array of activity records with date and count, representing the project’s daily activity statistics.',
|
||||
},
|
||||
averageHealth: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description:
|
||||
'The average health score over the last 4 weeks, indicating whether features are stale or active.',
|
||||
health: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['current'],
|
||||
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: {
|
||||
type: 'object',
|
||||
|
Loading…
Reference in New Issue
Block a user