From 04b2b488f6b7d95b092b3a1a9c1f88e35370802d Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Wed, 20 Nov 2024 11:41:45 +0100 Subject: [PATCH] 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. --- .../Project/ProjectStatus/ProjectHealth.tsx | 14 +++--- .../useProjectStatus/useProjectStatus.ts | 4 +- .../src/openapi/models/projectStatusSchema.ts | 4 +- .../createProjectStatusService.ts | 22 ++++++++-- .../project-status/project-status-service.ts | 44 +++++++++++++------ .../projects-status.e2e.test.ts | 24 +--------- .../spec/project-status-schema.test.ts | 4 +- src/lib/openapi/spec/project-status-schema.ts | 20 ++++++--- 8 files changed, 81 insertions(+), 55 deletions(-) diff --git a/frontend/src/component/project/Project/ProjectStatus/ProjectHealth.tsx b/frontend/src/component/project/Project/ProjectStatus/ProjectHealth.tsx index 45f93c1b36..8c2250399c 100644 --- a/frontend/src/component/project/Project/ProjectStatus/ProjectHealth.tsx +++ b/frontend/src/component/project/Project/ProjectStatus/ProjectHealth.tsx @@ -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}% - On average, your project health has remained at{' '} - {averageHealth}% the last 4 weeks + Your current project health rating is {healthRating}% {!isOss() && ( diff --git a/frontend/src/hooks/api/getters/useProjectStatus/useProjectStatus.ts b/frontend/src/hooks/api/getters/useProjectStatus/useProjectStatus.ts index 4bea5d3a11..208209e411 100644 --- a/frontend/src/hooks/api/getters/useProjectStatus/useProjectStatus.ts +++ b/frontend/src/hooks/api/getters/useProjectStatus/useProjectStatus.ts @@ -11,7 +11,9 @@ const placeholderData: ProjectStatusSchema = { apiTokens: 0, segments: 0, }, - averageHealth: 0, + health: { + current: 0, + }, lifecycleSummary: { initial: { currentFlags: 0, diff --git a/frontend/src/openapi/models/projectStatusSchema.ts b/frontend/src/openapi/models/projectStatusSchema.ts index f33b5f9dd2..851ca98543 100644 --- a/frontend/src/openapi/models/projectStatusSchema.ts +++ b/frontend/src/openapi/models/projectStatusSchema.ts @@ -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 */ diff --git a/src/lib/features/project-status/createProjectStatusService.ts b/src/lib/features/project-status/createProjectStatusService.ts index 0c5c02463c..c869477540 100644 --- a/src/lib/features/project-status/createProjectStatusService.ts +++ b/src/lib/features/project-status/createProjectStatusService.ts @@ -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(), ); diff --git a/src/lib/features/project-status/project-status-service.ts b/src/lib/features/project-status/project-status-service.ts index 28f21d946f..856673ae7a 100644 --- a/src/lib/features/project-status/project-status-service.ts +++ b/src/lib/features/project-status/project-status-service.ts @@ -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 { + 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 { @@ -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, diff --git a/src/lib/features/project-status/projects-status.e2e.test.ts b/src/lib/features/project-status/projects-status.e2e.test.ts index dc681c528f..fd0e7e3876 100644 --- a/src/lib/features/project-status/projects-status.e2e.test.ts +++ b/src/lib/features/project-status/projects-status.e2e.test.ts @@ -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 () => { diff --git a/src/lib/openapi/spec/project-status-schema.test.ts b/src/lib/openapi/spec/project-status-schema.test.ts index 2f753cf8ff..04ed8a0bc0 100644 --- a/src/lib/openapi/spec/project-status-schema.test.ts +++ b/src/lib/openapi/spec/project-status-schema.test.ts @@ -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, diff --git a/src/lib/openapi/spec/project-status-schema.ts b/src/lib/openapi/spec/project-status-schema.ts index 5f51f03721..9f8f181376 100644 --- a/src/lib/openapi/spec/project-status-schema.ts +++ b/src/lib/openapi/spec/project-status-schema.ts @@ -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',