1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-09 13:47:13 +02:00

chore!: remove deprecated get project health report

This commit is contained in:
Nuno Góis 2025-05-29 18:18:47 +01:00
parent eef32b7cf5
commit 5482003b73
No known key found for this signature in database
GPG Key ID: 71ECC689F1091765
25 changed files with 4 additions and 1175 deletions

View File

@ -28,7 +28,6 @@ import useQueryParams from 'hooks/useQueryParams';
import { useEffect, useState, type ReactNode } from 'react';
import ProjectEnvironment from '../ProjectEnvironment/ProjectEnvironment.tsx';
import ProjectFlags from './ProjectFlags.tsx';
import ProjectHealth from './ProjectHealth/ProjectHealth.tsx';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import {
@ -375,7 +374,6 @@ export const Project = () => {
}}
/>
<Routes>
<Route path='health' element={<ProjectHealth />} />
<Route
path='access/*'
element={

View File

@ -1,46 +0,0 @@
import { useHealthReport } from 'hooks/api/getters/useHealthReport/useHealthReport';
import ApiError from 'component/common/ApiError/ApiError';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { usePageTitle } from 'hooks/usePageTitle';
import { ReportCard } from './ReportTable/ReportCard/ReportCard.tsx';
import { ReportTable } from './ReportTable/ReportTable.tsx';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useProjectOverviewNameOrId } from 'hooks/api/getters/useProjectOverview/useProjectOverview';
const ProjectHealth = () => {
const projectId = useRequiredPathParam('projectId');
const projectName = useProjectOverviewNameOrId(projectId);
usePageTitle(`Project health ${projectName}`);
const { healthReport, refetchHealthReport, error } = useHealthReport(
projectId,
{ refreshInterval: 15 * 1000 },
);
if (!healthReport) {
return null;
}
return (
<div>
<ConditionallyRender
condition={Boolean(error)}
show={
<ApiError
data-loading
style={{ maxWidth: '500px', marginTop: '1rem' }}
onClick={refetchHealthReport}
text={`Could not fetch health rating for ${projectId}`}
/>
}
/>
<ReportCard healthReport={healthReport} />
<ReportTable
projectId={projectId}
features={healthReport.features}
/>
</div>
);
};
export default ProjectHealth;

View File

@ -1,219 +0,0 @@
import { Box, Link, Paper, styled } from '@mui/material';
import CheckIcon from '@mui/icons-material/Check';
import { Link as RouterLink } from 'react-router-dom';
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import type { IProjectHealthReport } from 'interfaces/project';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import InfoOutlined from '@mui/icons-material/InfoOutlined';
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
const StyledBoxActive = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
color: theme.palette.success.dark,
'& svg': {
color: theme.palette.success.main,
},
}));
const StyledBoxStale = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
color: theme.palette.warning.dark,
'& svg': {
color: theme.palette.warning.main,
},
}));
const StyledPaper = styled(Paper)(({ theme }) => ({
padding: theme.spacing(4),
marginBottom: theme.spacing(2),
borderRadius: theme.shape.borderRadiusLarge,
boxShadow: 'none',
display: 'flex',
justifyContent: 'space-between',
[theme.breakpoints.down('md')]: {
flexDirection: 'column',
gap: theme.spacing(2),
},
}));
const StyledHeader = styled('h2')(({ theme }) => ({
fontSize: theme.fontSizes.mainHeader,
marginBottom: theme.spacing(1),
justifyItems: 'center',
display: 'flex',
}));
const StyledHealthRating = styled('p')(({ theme }) => ({
fontSize: '2rem',
fontWeight: theme.fontWeight.bold,
}));
const StyledLastUpdated = styled('p')(({ theme }) => ({
color: theme.palette.text.secondary,
}));
const StyledList = styled('ul')(({ theme }) => ({
listStyleType: 'none',
margin: 0,
padding: 0,
'& svg': {
marginRight: theme.spacing(1),
},
}));
const StyledAlignedItem = styled('p')(({ theme }) => ({
marginLeft: theme.spacing(4),
}));
interface IReportCardProps {
healthReport: IProjectHealthReport;
}
export const ReportCard = ({ healthReport }: IReportCardProps) => {
const healthRatingColor =
healthReport.health < 50
? 'error.main'
: healthReport.health < 75
? 'warning.main'
: 'success.main';
const StalenessInfoIcon = () => (
<HtmlTooltip
title={
<>
If your flag exceeds the expected lifetime of its flag type
it will be marked as potentially stale.
<Box sx={{ mt: 2 }}>
<a
href='https://docs.getunleash.io/reference/technical-debt#stale-and-potentially-stale-flags'
target='_blank'
rel='noreferrer'
>
Read more in the documentation
</a>
</Box>
</>
}
>
<InfoOutlined
sx={{ color: (theme) => theme.palette.text.secondary, ml: 1 }}
/>
</HtmlTooltip>
);
return (
<StyledPaper>
<Box>
<StyledHeader>Health rating</StyledHeader>
<ConditionallyRender
condition={healthReport.health > -1}
show={
<>
<StyledHealthRating
sx={{ color: healthRatingColor }}
>
{healthReport.health}%
</StyledHealthRating>
<StyledLastUpdated>
Last updated:{' '}
<TimeAgo
date={healthReport.updatedAt}
refresh={false}
/>
</StyledLastUpdated>
</>
}
/>
</Box>
<Box>
<StyledHeader>Flag report</StyledHeader>
<StyledList>
<li>
<ConditionallyRender
condition={Boolean(healthReport.activeCount)}
show={
<StyledBoxActive>
<CheckIcon />
<span>
{healthReport.activeCount} active flags
</span>
</StyledBoxActive>
}
/>
</li>
<ConditionallyRender
condition={Boolean(healthReport.activeCount)}
show={
<StyledAlignedItem>
Also includes potentially stale flags.
</StyledAlignedItem>
}
/>
<li>
<ConditionallyRender
condition={Boolean(healthReport.staleCount)}
show={
<StyledBoxStale>
<ReportProblemOutlinedIcon />
<span>
{healthReport.staleCount} stale flags
</span>
</StyledBoxStale>
}
/>
</li>
</StyledList>
</Box>
<Box sx={{ flexBasis: '40%' }}>
<StyledHeader>
Potential actions{' '}
<span>
<StalenessInfoIcon />
</span>
</StyledHeader>
<StyledList>
<li>
<ConditionallyRender
condition={Boolean(
healthReport.potentiallyStaleCount,
)}
show={
<StyledBoxStale>
<ReportProblemOutlinedIcon />
<span>
{healthReport.potentiallyStaleCount}{' '}
potentially stale flags
</span>
</StyledBoxStale>
}
/>
</li>
</StyledList>
<ConditionallyRender
condition={Boolean(healthReport.potentiallyStaleCount)}
show={
<>
<StyledAlignedItem>
Review your feature flags and delete unused
flags.
</StyledAlignedItem>
<Box sx={{ mt: 2 }}>
<Link
component={RouterLink}
to={'/feature-toggle-type'}
>
Configure feature types lifetime
</Link>
</Box>
</>
}
elseShow={<span>No action is required</span>}
/>
</Box>
</StyledPaper>
);
};

View File

@ -1,27 +0,0 @@
import type { VFC } from 'react';
import { Typography, useTheme } from '@mui/material';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import type { IReportTableRow } from 'component/project/Project/ProjectHealth/ReportTable/ReportTable';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
interface IReportExpiredCellProps {
row: {
original: IReportTableRow;
};
}
export const ReportExpiredCell: VFC<IReportExpiredCellProps> = ({ row }) => {
const theme = useTheme();
if (row.original.expiredAt) {
return <DateCell value={row.original.expiredAt} />;
}
return (
<TextCell>
<Typography variant='body2' color={theme.palette.text.secondary}>
N/A
</Typography>
</TextCell>
);
};

View File

@ -1,34 +0,0 @@
import type { IFeatureFlagListItem } from 'interfaces/featureToggle';
import { KILLSWITCH, PERMISSION } from 'constants/featureToggleTypes';
import { expired, getDiffInDays } from '../utils.js';
import { parseISO, subDays } from 'date-fns';
import type { FeatureTypeSchema } from 'openapi';
export const formatExpiredAt = (
feature: IFeatureFlagListItem,
featureTypes: FeatureTypeSchema[],
): string | undefined => {
const { type, createdAt } = feature;
const featureType = featureTypes.find(
(featureType) => featureType.id === type,
);
if (
featureType &&
(featureType.name === KILLSWITCH || featureType.name === PERMISSION)
) {
return;
}
const date = parseISO(createdAt);
const now = new Date();
const diff = getDiffInDays(date, now);
if (featureType && expired(diff, featureType)) {
const result = diff - (featureType?.lifetimeDays?.valueOf() || 0);
return subDays(now, result).toISOString();
}
return;
};

View File

@ -1,52 +0,0 @@
import type { VFC, ReactElement } from 'react';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import Check from '@mui/icons-material/Check';
import ReportProblemOutlined from '@mui/icons-material/ReportProblemOutlined';
import { styled } from '@mui/material';
import type { IReportTableRow } from 'component/project/Project/ProjectHealth/ReportTable/ReportTable';
const StyledTextPotentiallyStale = styled('span')(({ theme }) => ({
display: 'flex',
gap: '1ch',
alignItems: 'center',
color: theme.palette.warning.dark,
'& svg': { color: theme.palette.warning.main },
}));
const StyledTextHealthy = styled('span')(({ theme }) => ({
display: 'flex',
gap: '1ch',
alignItems: 'center',
color: theme.palette.success.dark,
'& svg': { color: theme.palette.success.main },
}));
interface IReportStatusCellProps {
row: {
original: IReportTableRow;
};
}
export const ReportStatusCell: VFC<IReportStatusCellProps> = ({
row,
}): ReactElement => {
if (row.original.status === 'potentially-stale') {
return (
<TextCell>
<StyledTextPotentiallyStale>
<ReportProblemOutlined />
<span>Potentially stale</span>
</StyledTextPotentiallyStale>
</TextCell>
);
}
return (
<TextCell>
<StyledTextHealthy>
<Check />
<span>Healthy</span>
</StyledTextHealthy>
</TextCell>
);
};

View File

@ -1,32 +0,0 @@
import type { IFeatureFlagListItem } from 'interfaces/featureToggle';
import { expired, getDiffInDays } from '../utils.js';
import { KILLSWITCH, PERMISSION } from 'constants/featureToggleTypes';
import { parseISO } from 'date-fns';
import type { FeatureTypeSchema } from 'openapi';
export type ReportingStatus = 'potentially-stale' | 'healthy';
export const formatStatus = (
feature: IFeatureFlagListItem,
featureTypes: FeatureTypeSchema[],
): ReportingStatus => {
const { type, createdAt } = feature;
const featureType = featureTypes.find(
(featureType) => featureType.id === type,
);
const date = parseISO(createdAt);
const now = new Date();
const diff = getDiffInDays(date, now);
if (
featureType &&
expired(diff, featureType) &&
type !== KILLSWITCH &&
type !== PERMISSION
) {
return 'potentially-stale';
}
return 'healthy';
};

View File

@ -1,237 +0,0 @@
import { useMemo } from 'react';
import type {
IEnvironments,
IFeatureFlagListItem,
} from 'interfaces/featureToggle';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { PageContent } from 'component/common/PageContent/PageContent';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { sortTypes } from 'utils/sortTypes';
import {
useFlexLayout,
useGlobalFilter,
useSortBy,
useTable,
} from 'react-table';
import { useMediaQuery, useTheme } from '@mui/material';
import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell';
import { FeatureNameCell } from 'component/common/Table/cells/FeatureNameCell/FeatureNameCell';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { FeatureStaleCell } from 'component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Search } from 'component/common/Search/Search';
import { ReportExpiredCell } from './ReportExpiredCell/ReportExpiredCell.tsx';
import { ReportStatusCell } from './ReportStatusCell/ReportStatusCell.tsx';
import {
formatStatus,
type ReportingStatus,
} from './ReportStatusCell/formatStatus.ts';
import { formatExpiredAt } from './ReportExpiredCell/formatExpiredAt.ts';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
import useFeatureTypes from 'hooks/api/getters/useFeatureTypes/useFeatureTypes';
interface IReportTableProps {
projectId: string;
features: IFeatureFlagListItem[];
}
export interface IReportTableRow {
project: string;
name: string;
type: string;
stale?: boolean;
status: ReportingStatus;
lastSeenAt?: string;
environments?: IEnvironments[];
createdAt: string;
expiredAt?: string;
}
export const ReportTable = ({ projectId, features }: IReportTableProps) => {
const theme = useTheme();
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
const { uiConfig } = useUiConfig();
const { featureTypes } = useFeatureTypes();
const data: IReportTableRow[] = useMemo<IReportTableRow[]>(
() =>
features.map((report) => ({
project: projectId,
name: report.name,
type: report.type,
stale: report.stale,
environments: report.environments,
status: formatStatus(report, featureTypes),
lastSeenAt: report.lastSeenAt,
createdAt: report.createdAt,
expiredAt: formatExpiredAt(report, featureTypes),
})),
[projectId, features, featureTypes],
);
const initialState = useMemo(
() => ({
hiddenColumns: [],
sortBy: [{ id: 'createdAt', desc: true }],
}),
[],
);
const COLUMNS = useMemo(
() => [
{
Header: 'Seen',
accessor: 'lastSeenAt',
Cell: ({ value, row: { original: feature } }: any) => {
return <FeatureEnvironmentSeenCell feature={feature} />;
},
align: 'center',
maxWidth: 80,
},
{
Header: 'Type',
accessor: 'type',
align: 'center',
Cell: FeatureTypeCell,
disableGlobalFilter: true,
maxWidth: 85,
},
{
Header: 'Name',
accessor: 'name',
sortType: 'alphanumeric',
Cell: FeatureNameCell,
minWidth: 120,
},
{
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
disableGlobalFilter: true,
maxWidth: 150,
},
{
Header: 'Expired',
accessor: 'expiredAt',
Cell: ReportExpiredCell,
disableGlobalFilter: true,
maxWidth: 150,
},
{
Header: 'Status',
id: 'status',
accessor: 'status',
Cell: ReportStatusCell,
disableGlobalFilter: true,
width: 180,
},
{
Header: 'State',
accessor: 'stale',
sortType: 'boolean',
Cell: FeatureStaleCell,
disableGlobalFilter: true,
maxWidth: 120,
},
],
[],
);
const {
headerGroups,
rows,
prepareRow,
state: { globalFilter },
setGlobalFilter,
setHiddenColumns,
} = useTable(
{
columns: COLUMNS as any,
data: data as any,
initialState,
sortTypes,
autoResetGlobalFilter: false,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
},
useGlobalFilter,
useFlexLayout,
useSortBy,
);
useConditionallyHiddenColumns(
[
{
condition: isExtraSmallScreen,
columns: ['stale'],
},
{
condition: isSmallScreen,
columns: ['expiredAt', 'lastSeenAt'],
},
{
condition: isMediumScreen,
columns: ['createdAt'],
},
],
setHiddenColumns,
COLUMNS,
);
const title =
rows.length < data.length
? `Feature flags (${rows.length} of ${data.length})`
: `Feature flags (${data.length})`;
return (
<PageContent
header={
<PageHeader
title={title}
actions={
<Search
initialValue={globalFilter}
onChange={setGlobalFilter}
/>
}
/>
}
>
<SearchHighlightProvider value={globalFilter}>
<VirtualizedTable
headerGroups={headerGroups}
prepareRow={prepareRow}
rows={rows}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={globalFilter?.length > 0}
show={
<TablePlaceholder>
No feature flags found matching &ldquo;
{globalFilter}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No feature flags available. Get started by
adding a new feature flag.
</TablePlaceholder>
}
/>
}
/>
</PageContent>
);
};

View File

@ -1,12 +0,0 @@
import differenceInDays from 'date-fns/differenceInDays';
import type { FeatureTypeSchema } from 'openapi';
export const getDiffInDays = (date: Date, now: Date) => {
return Math.abs(differenceInDays(date, now));
};
export const expired = (diff: number, type: FeatureTypeSchema) => {
if (type.lifetimeDays) return diff >= type?.lifetimeDays?.valueOf();
return false;
};

View File

@ -1,42 +0,0 @@
import useSWR, { mutate, type SWRConfiguration } from 'swr';
import { useCallback } from 'react';
import type { IProjectHealthReport } from 'interfaces/project';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler.js';
interface IUseHealthReportOutput {
healthReport: IProjectHealthReport | undefined;
refetchHealthReport: () => void;
loading: boolean;
error?: Error;
}
export const useHealthReport = (
projectId: string,
options?: SWRConfiguration,
): IUseHealthReportOutput => {
const path = formatApiPath(`api/admin/projects/${projectId}/health-report`);
const { data, error } = useSWR<IProjectHealthReport>(
path,
fetchHealthReport,
options,
);
const refetchHealthReport = useCallback(() => {
mutate(path).catch(console.warn);
}, [path]);
return {
healthReport: data,
refetchHealthReport,
loading: !error && !data,
error,
};
};
const fetchHealthReport = (path: string): Promise<IProjectHealthReport> => {
return fetch(path)
.then(handleErrorResponses('Health report'))
.then((res) => res.json());
};

View File

@ -1,14 +0,0 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
export type GetProjectHealthReport401 = {
/** The ID of the error instance */
id?: string;
/** A description of what went wrong. */
message?: string;
/** The name of the error kind */
name?: string;
};

View File

@ -1,14 +0,0 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
export type GetProjectHealthReport403 = {
/** The ID of the error instance */
id?: string;
/** A description of what went wrong. */
message?: string;
/** The name of the error kind */
name?: string;
};

View File

@ -1,14 +0,0 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
export type GetProjectHealthReport404 = {
/** The ID of the error instance */
id?: string;
/** A description of what went wrong. */
message?: string;
/** The name of the error kind */
name?: string;
};

View File

@ -1,66 +0,0 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
import type { ProjectEnvironmentSchema } from './projectEnvironmentSchema.js';
import type { CreateFeatureNamingPatternSchema } from './createFeatureNamingPatternSchema.js';
import type { FeatureSchema } from './featureSchema.js';
import type { HealthReportSchemaMode } from './healthReportSchemaMode.js';
import type { ProjectStatsSchema } from './projectStatsSchema.js';
/**
* A report of the current health of the requested project, with datapoints like counters of currently active, stale, and potentially stale feature flags.
*/
export interface HealthReportSchema {
/** The number of active feature flags. */
activeCount: number;
/**
* When the project was last updated.
* @nullable
*/
createdAt?: string | null;
/** A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy */
defaultStickiness: string;
/**
* The project's description
* @nullable
*/
description?: string | null;
/** An array containing the names of all the environments configured for the project. */
environments: ProjectEnvironmentSchema[];
/** Indicates if the project has been marked as a favorite by the current user requesting the project health overview. */
favorite?: boolean;
/**
* A limit on the number of features allowed in the project. Null if no limit.
* @nullable
*/
featureLimit?: number | null;
featureNaming?: CreateFeatureNamingPatternSchema;
/** An array containing an overview of all the features of the project and their individual status */
features: FeatureSchema[];
/** The overall [health rating](https://docs.getunleash.io/reference/technical-debt#project-status) of the project. */
health: number;
/**
* The number of users/members in the project.
* @minimum 0
*/
members: number;
/** The project's [collaboration mode](https://docs.getunleash.io/reference/project-collaboration-mode). Determines whether non-project members can submit change requests or not. */
mode: HealthReportSchemaMode;
/** The project's name */
name: string;
/** The number of potentially stale feature flags. */
potentiallyStaleCount: number;
/** The number of stale feature flags. */
staleCount: number;
/** Project statistics */
stats?: ProjectStatsSchema;
/**
* When the project was last updated.
* @nullable
*/
updatedAt?: string | null;
/** The project overview version. */
version: number;
}

View File

@ -1,18 +0,0 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
/**
* The project's [collaboration mode](https://docs.getunleash.io/reference/project-collaboration-mode). Determines whether non-project members can submit change requests or not.
*/
export type HealthReportSchemaMode =
(typeof HealthReportSchemaMode)[keyof typeof HealthReportSchemaMode];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const HealthReportSchemaMode = {
open: 'open',
protected: 'protected',
private: 'private',
} as const;

View File

@ -805,9 +805,6 @@ export * from './getProjectEnvironments404.js';
export * from './getProjectFlagCreators401.js';
export * from './getProjectFlagCreators403.js';
export * from './getProjectFlagCreators404.js';
export * from './getProjectHealthReport401.js';
export * from './getProjectHealthReport403.js';
export * from './getProjectHealthReport404.js';
export * from './getProjectInsights401.js';
export * from './getProjectInsights403.js';
export * from './getProjectInsights404.js';
@ -893,8 +890,6 @@ export * from './healthCheckSchema.js';
export * from './healthCheckSchemaHealth.js';
export * from './healthOverviewSchema.js';
export * from './healthOverviewSchemaMode.js';
export * from './healthReportSchema.js';
export * from './healthReportSchemaMode.js';
export * from './idSchema.js';
export * from './idsSchema.js';
export * from './importToggles404.js';

View File

@ -104,7 +104,6 @@ const PATH_STRATEGY = `${PATH_STRATEGIES}/:strategyId`;
type ProjectFeaturesServices = Pick<
IUnleashServices,
| 'featureToggleService'
| 'projectHealthService'
| 'openApiService'
| 'transactionalFeatureToggleService'
| 'featureTagService'

View File

@ -10,7 +10,6 @@ import {
} from '../../types/index.js';
import ProjectFeaturesController from '../feature-toggle/feature-toggle-controller.js';
import ProjectEnvironmentsController from '../project-environments/project-environments-controller.js';
import ProjectHealthReport from '../../routes/admin-api/project/health-report.js';
import type ProjectService from './project-service.js';
import VariantsController from '../../routes/admin-api/project/variants.js';
import {
@ -226,7 +225,6 @@ export default class ProjectController extends Controller {
'/',
new ProjectEnvironmentsController(config, services).router,
);
this.use('/', new ProjectHealthReport(config, services).router);
this.use('/', new VariantsController(config, services).router);
this.use('/', new ProjectApiTokenController(config, services).router);
this.use('/', new ProjectArchiveController(config, services).router);

View File

@ -1,35 +0,0 @@
import type { FromSchema } from 'json-schema-to-ts';
import { healthOverviewSchema } from './health-overview-schema.js';
export const healthReportSchema = {
...healthOverviewSchema,
$id: '#/components/schemas/healthReportSchema',
description:
'A report of the current health of the requested project, with datapoints like counters of currently active, stale, and potentially stale feature flags.',
required: [
...healthOverviewSchema.required,
'potentiallyStaleCount',
'activeCount',
'staleCount',
],
properties: {
...healthOverviewSchema.properties,
potentiallyStaleCount: {
type: 'number',
description: 'The number of potentially stale feature flags.',
example: 5,
},
activeCount: {
type: 'number',
description: 'The number of active feature flags.',
example: 2,
},
staleCount: {
type: 'number',
description: 'The number of stale feature flags.',
example: 10,
},
},
} as const;
export type HealthReportSchema = FromSchema<typeof healthReportSchema>;

View File

@ -110,7 +110,6 @@ export * from './group-user-model-schema.js';
export * from './groups-schema.js';
export * from './health-check-schema.js';
export * from './health-overview-schema.js';
export * from './health-report-schema.js';
export * from './id-schema.js';
export * from './ids-schema.js';
export * from './import-toggles-schema.js';

View File

@ -1,73 +0,0 @@
import type { Request, Response } from 'express';
import Controller from '../../controller.js';
import type { IUnleashServices } from '../../../services/index.js';
import type { IUnleashConfig } from '../../../types/option.js';
import type ProjectHealthService from '../../../services/project-health-service.js';
import type { Logger } from '../../../logger.js';
import type { IProjectParam } from '../../../types/model.js';
import { NONE } from '../../../types/permissions.js';
import type { OpenApiService } from '../../../services/openapi-service.js';
import { createResponseSchema } from '../../../openapi/util/create-response-schema.js';
import { getStandardResponses } from '../../../openapi/util/standard-responses.js';
import { serializeDates } from '../../../types/serialize-dates.js';
import {
healthReportSchema,
type HealthReportSchema,
} from '../../../openapi/spec/health-report-schema.js';
export default class ProjectHealthReport extends Controller {
private projectHealthService: ProjectHealthService;
private openApiService: OpenApiService;
private logger: Logger;
constructor(
config: IUnleashConfig,
{
projectHealthService,
openApiService,
}: Pick<IUnleashServices, 'projectHealthService' | 'openApiService'>,
) {
super(config);
this.logger = config.getLogger('/admin-api/project/health-report');
this.projectHealthService = projectHealthService;
this.openApiService = openApiService;
this.route({
method: 'get',
path: '/:projectId/health-report',
handler: this.getProjectHealthReport,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Projects'],
deprecated: true,
operationId: 'getProjectHealthReport',
summary: 'Get a health report for a project.',
description:
'This endpoint returns a health report for the specified project. This data is used for [the technical debt insights](https://docs.getunleash.io/reference/technical-debt)',
responses: {
200: createResponseSchema('healthReportSchema'),
...getStandardResponses(401, 403, 404),
},
}),
],
});
}
async getProjectHealthReport(
req: Request<IProjectParam>,
res: Response<HealthReportSchema>,
): Promise<void> {
const { projectId } = req.params;
const overview =
await this.projectHealthService.getProjectHealthReport(projectId);
this.openApiService.respondWithValidation(
200,
res,
healthReportSchema.$id,
serializeDates(overview),
);
}
}

View File

@ -326,11 +326,7 @@ export const createServices = (
? createProjectStatusService(db, config)
: createFakeProjectStatusService().projectStatusService;
const projectHealthService = new ProjectHealthService(
stores,
config,
projectService,
);
const projectHealthService = new ProjectHealthService(stores, config);
const exportImportService = db
? createExportImportTogglesService(db, config)

View File

@ -1,15 +1,11 @@
import type { IUnleashStores } from '../types/stores.js';
import type { IUnleashConfig } from '../types/option.js';
import type { Logger } from '../logger.js';
import type { IProject, IProjectHealthReport } from '../types/model.js';
import type { IProject } from '../types/model.js';
import type { IFeatureToggleStore } from '../features/feature-toggle/types/feature-toggle-store-type.js';
import type { IFeatureTypeStore } from '../types/stores/feature-type-store.js';
import type { IProjectStore } from '../features/project/project-store-type.js';
import type ProjectService from '../features/project/project-service.js';
import {
calculateProjectHealth,
calculateProjectHealthRating,
} from '../domain/project-health/project-health.js';
import { calculateProjectHealthRating } from '../domain/project-health/project-health.js';
import { batchExecute } from '../util/index.js';
import metricsHelper from '../util/metrics-helper.js';
import { FUNCTION_TIME } from '../metric-events.js';
@ -23,8 +19,6 @@ export default class ProjectHealthService {
private featureToggleStore: IFeatureToggleStore;
private projectService: ProjectService;
calculateHealthRating: (project: Pick<IProject, 'id'>) => Promise<number>;
private timer: Function;
@ -39,14 +33,12 @@ export default class ProjectHealthService {
'projectStore' | 'featureTypeStore' | 'featureToggleStore'
>,
{ getLogger, eventBus }: Pick<IUnleashConfig, 'getLogger' | 'eventBus'>,
projectService: ProjectService,
) {
this.logger = getLogger('services/project-health-service.ts');
this.projectStore = projectStore;
this.featureTypeStore = featureTypeStore;
this.featureToggleStore = featureToggleStore;
this.projectService = projectService;
this.calculateHealthRating = calculateProjectHealthRating(
this.featureTypeStore,
this.featureToggleStore,
@ -58,28 +50,6 @@ export default class ProjectHealthService {
});
}
async getProjectHealthReport(
projectId: string,
): Promise<IProjectHealthReport> {
const featureTypes = await this.featureTypeStore.getAll();
const overview = await this.projectService.getProjectHealth(
projectId,
false,
undefined,
);
const healthRating = calculateProjectHealth(
overview.features,
featureTypes,
);
return {
...overview,
...healthRating,
};
}
async setHealthRating(batchSize = 1): Promise<void> {
const projects = await this.projectStore.getAll();

View File

@ -77,180 +77,6 @@ test('Project with no stale toggles should have 100% health rating', async () =>
});
});
test('Health rating endpoint yields stale, potentially stale and active count on top of health', async () => {
const project = {
id: 'test-health',
name: 'Health rating',
description: 'Fancy',
};
await app.services.projectService.createProject(
project,
user,
extractAuditInfoFromUser(user),
);
await app.request
.post(`/api/admin/projects/${project.id}/features`)
.send({
name: 'health-report-new',
description: 'new',
stale: false,
})
.expect(201);
await app.request
.post(`/api/admin/projects/${project.id}/features`)
.send({
name: 'health-report-new-2',
description: 'new too',
stale: false,
})
.expect(201);
await app.request
.post(`/api/admin/projects/${project.id}/features`)
.send({
name: 'health-report-stale',
description: 'new too',
stale: true,
})
.expect(201);
await app.services.projectHealthService.setProjectHealthRating(project.id);
await app.request
.get(`/api/admin/projects/${project.id}/health-report`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
expect(res.body.health).toBe(67);
expect(res.body.activeCount).toBe(2);
expect(res.body.staleCount).toBe(1);
expect(res.body.potentiallyStaleCount).toBe(0);
});
});
test('Health rating endpoint does not include archived toggles when calculating potentially stale toggles', async () => {
const project = {
id: 'potentially-stale-archived',
name: 'Health rating',
description: 'Fancy',
};
await app.services.projectService.createProject(
project,
user,
extractAuditInfoFromUser(user),
);
await app.request
.post(`/api/admin/projects/${project.id}/features`)
.send({
name: 'potentially-stale-archive-fresh',
description: 'new',
stale: false,
})
.expect(201);
await app.request
.post(`/api/admin/projects/${project.id}/features`)
.send({
name: 'potentially-stale-archive-fresh-2',
description: 'new too',
stale: false,
})
.expect(201);
await app.request
.post(`/api/admin/projects/${project.id}/features`)
.send({
name: 'potentially-stale-archive-stale',
description: 'stale',
stale: true,
})
.expect(201);
await app.request
.post(`/api/admin/projects/${project.id}/features`)
.send({
name: 'potentially-archive-stale',
description: 'Really Old',
createdAt: new Date(2019, 5, 1),
})
.expect(201);
await app.request
.post(`/api/admin/projects/${project.id}/features`)
.send({
name: 'potentially-archive-stale-archived',
description: 'Really Old',
createdAt: new Date(2019, 5, 1),
archived: true,
})
.expect(201);
await app.services.projectHealthService.setProjectHealthRating(project.id);
await app.request
.get(`/api/admin/projects/${project.id}/health-report`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
expect(res.body.health).toBe(50);
expect(res.body.activeCount).toBe(3);
expect(res.body.staleCount).toBe(1);
expect(res.body.potentiallyStaleCount).toBe(1);
});
});
test('Health rating endpoint correctly handles potentially stale toggles', async () => {
const project = {
id: 'potentially-stale',
name: 'Health rating',
description: 'Fancy',
};
await app.services.projectService.createProject(
project,
user,
extractAuditInfoFromUser(user),
);
await app.request
.post(`/api/admin/projects/${project.id}/features`)
.send({
name: 'potentially-stale-fresh',
description: 'new',
stale: false,
})
.expect(201);
await app.request
.post(`/api/admin/projects/${project.id}/features`)
.send({
name: 'potentially-stale-fresh-2',
description: 'new too',
stale: false,
})
.expect(201);
await app.request
.post(`/api/admin/projects/${project.id}/features`)
.send({
name: 'potentially-stale-stale',
description: 'stale',
stale: true,
})
.expect(201);
await app.request
.post(`/api/admin/projects/${project.id}/features`)
.send({
name: 'potentially-stale',
description: 'Really Old',
createdAt: new Date(2019, 5, 1),
})
.expect(201);
await app.services.projectHealthService.setProjectHealthRating(project.id);
await app.request
.get(`/api/admin/projects/${project.id}/health-report`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
expect(res.body.health).toBe(50);
expect(res.body.activeCount).toBe(3);
expect(res.body.staleCount).toBe(1);
expect(res.body.potentiallyStaleCount).toBe(1);
});
});
test('Health report for non-existing project yields 404', async () => {
await app.request
.get('/api/admin/projects/some-crazy-project-name/health-report')
.expect(404);
});
test('Sorts environments by sort order', async () => {
const envOne = 'my-sorted-env1';
const envTwo = 'my-sorted-env2';
@ -337,16 +163,3 @@ test('Sorts environments correctly if sort order is equal', async () => {
expect(feature.environments[1].name).toBe(envTwo);
});
});
test('Update update_at when setHealth runs', async () => {
await app.services.projectHealthService.setProjectHealthRating('default');
await app.request
.get('/api/admin/projects/default/health-report')
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
const now = new Date().getTime();
const updatedAt = new Date(res.body.updatedAt).getTime();
expect(now - updatedAt).toBeLessThan(5000);
});
});

View File

@ -25,11 +25,7 @@ beforeAll(async () => {
email: 'test@getunleash.io',
});
projectService = createProjectService(db.rawDatabase, config);
projectHealthService = new ProjectHealthService(
stores,
config,
projectService,
);
projectHealthService = new ProjectHealthService(stores, config);
});
afterAll(async () => {