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

Feat/instance health (#4586)

Hackathon

---------

Co-authored-by: chriswk <chriswk@getunleash.io>
This commit is contained in:
Tymoteusz Czech 2023-08-30 14:51:46 +02:00 committed by GitHub
parent 3b2d6a4cbf
commit 0d5f33f8ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 396 additions and 26 deletions

View File

@ -18,6 +18,7 @@ import UsersAdmin from './users/UsersAdmin';
import NotFound from 'component/common/NotFound/NotFound';
import { AdminIndex } from './AdminIndex';
import { AdminTabsMenu } from './menu/AdminTabsMenu';
import { InstanceHealth } from './instance-health/InstanceHealth';
export const Admin = () => {
return (
@ -34,6 +35,7 @@ export const Admin = () => {
<Route path="groups/*" element={<GroupsAdmin />} />
<Route path="roles/*" element={<Roles />} />
<Route path="instance" element={<InstanceAdmin />} />
<Route path="instance-health" element={<InstanceHealth />} />
<Route path="network/*" element={<Network />} />
<Route path="maintenance" element={<MaintenanceAdmin />} />
<Route path="cors" element={<CorsAdmin />} />

View File

@ -80,6 +80,13 @@ export const adminRoutes: INavigationMenuItem[] = [
menu: { adminSettings: true },
group: 'instance',
},
{
path: '/admin/instance-health',
title: 'Instance health',
menu: { adminSettings: true },
group: 'instance',
flag: 'instanceHealthDashboard',
},
{
path: '/admin/instance-privacy',
title: 'Instance privacy',

View File

@ -13,6 +13,7 @@ import { useInstanceStats } from '../../../../hooks/api/getters/useInstanceStats
import { formatApiPath } from '../../../../utils/formatPath';
import { PageContent } from '../../../common/PageContent/PageContent';
import { PageHeader } from '../../../common/PageHeader/PageHeader';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
export const InstanceStats: VFC = () => {
const { stats } = useInstanceStats();
@ -32,6 +33,21 @@ export const InstanceStats: VFC = () => {
{ title: 'Instance Id', value: stats?.instanceId },
{ title: versionTitle, value: version },
{ title: 'Users', value: stats?.users },
{
title: 'Active past 7 days',
value: stats?.activeUsers?.last7,
offset: true,
},
{
title: 'Active past 30 days',
value: stats?.activeUsers?.last30,
offset: true,
},
{
title: 'Active past 90 days',
value: stats?.activeUsers?.last90,
offset: true,
},
{ title: 'Feature toggles', value: stats?.featureToggles },
{ title: 'Projects', value: stats?.projects },
{ title: 'Environments', value: stats?.environments },
@ -64,7 +80,22 @@ export const InstanceStats: VFC = () => {
{rows.map(row => (
<TableRow key={row.title}>
<TableCell component="th" scope="row">
{row.title}
<ConditionallyRender
condition={Boolean(row.offset)}
show={
<Box
component="span"
sx={theme => ({
marginLeft: row.offset
? theme.spacing(2)
: 0,
})}
>
{row.title}
</Box>
}
elseShow={row.title}
/>
</TableCell>
<TableCell align="right">{row.value}</TableCell>
</TableRow>

View File

@ -0,0 +1,220 @@
import { VFC, useMemo } from 'react';
import { useSortBy, useTable } from 'react-table';
import { styled, Typography, Box } from '@mui/material';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { useInstanceStats } from 'hooks/api/getters/useInstanceStats/useInstanceStats';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { sortTypes } from 'utils/sortTypes';
import {
SortableTableHeader,
Table,
TableBody,
TableRow,
TableCell,
} from 'component/common/Table';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { HelpPopper } from 'component/project/Project/ProjectStats/HelpPopper';
import { StatusBox } from 'component/project/Project/ProjectStats/StatusBox';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
interface IInstanceHealthProps {}
const CardsGrid = styled('div')(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: theme.spacing(2),
paddingBottom: theme.spacing(2),
}));
const Card = styled('div')(({ theme }) => ({
padding: theme.spacing(2.5),
borderRadius: `${theme.shape.borderRadiusLarge}px`,
backgroundColor: `${theme.palette.background.paper}`,
border: `1px solid ${theme.palette.divider}`,
// boxShadow: theme.boxShadows.card,
display: 'flex',
flexDirection: 'column',
}));
/**
* @deprecated unify with project widget
*/
const StyledWidget = styled(Box)(({ theme }) => ({
position: 'relative',
padding: theme.spacing(3),
backgroundColor: theme.palette.background.paper,
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
borderRadius: `${theme.shape.borderRadiusLarge}px`,
[theme.breakpoints.down('lg')]: {
padding: theme.spacing(2),
},
}));
export const InstanceHealth: VFC<IInstanceHealthProps> = () => {
const { stats } = useInstanceStats();
const { projects } = useProjects();
// FIXME: loading state
const initialState = useMemo(
() => ({
hiddenColumns: [],
sortBy: [{ id: 'createdAt' }],
}),
[]
);
const data = useMemo(() => projects, [projects]);
const dormantUsersPercentage =
(1 - stats?.activeUsers?.last90! / stats?.users!) * 100;
const dormantUsersColor =
dormantUsersPercentage < 30
? 'success.main'
: dormantUsersPercentage < 50
? 'warning.main'
: 'error.main';
const COLUMNS = useMemo(
() => [
{
accessor: 'name',
Header: 'Project name',
Cell: TextCell,
width: '80%',
},
{
Header: 'Feature toggles',
accessor: 'featureCount',
Cell: TextCell,
},
{
Header: 'Created at',
accessor: 'createdAt',
Cell: DateCell,
},
{
Header: 'Members',
accessor: 'memberCount',
Cell: TextCell,
},
{
Header: 'Health',
accessor: 'health',
Cell: ({ value }: { value: number }) => {
const healthRatingColor =
value < 50
? 'error.main'
: value < 75
? 'warning.main'
: 'success.main';
return (
<TextCell>
<Typography
component="span"
sx={{ color: healthRatingColor }}
>
{value}%
</Typography>
</TextCell>
);
},
},
],
[]
);
const { headerGroups, rows, prepareRow, getTableProps, getTableBodyProps } =
useTable(
{
columns: COLUMNS as any,
data: data as any,
initialState,
sortTypes,
autoResetGlobalFilter: false,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
},
useSortBy
);
return (
<>
<CardsGrid>
<Card>
<StatusBox
title="User accounts"
boxText={String(stats?.users)}
customChangeElement={<></>}
>
{/* <HelpPopper id="user-accounts">
Sum of all configuration and state modifications in
the project.
</HelpPopper> */}
{/* FIXME: tooltip */}
</StatusBox>
</Card>
<Card>
<StatusBox
title="Dormant users"
boxText={String(
stats?.users! - stats?.activeUsers?.last90!
)}
customChangeElement={
<Typography
component="span"
sx={{ color: dormantUsersColor }}
>
({dormantUsersPercentage.toFixed(1)}%)
</Typography>
}
>
{/* <HelpPopper id="dormant-users">
Sum of all configuration and state modifications in
the project.
</HelpPopper> */}
</StatusBox>
</Card>
<Card>
<StatusBox
title="Number of projects"
boxText={String(projects?.length)}
customChangeElement={<></>}
></StatusBox>
</Card>
<Card>
<StatusBox
title="Number of feature toggles"
boxText={String(stats?.featureToggles)}
customChangeElement={<></>}
></StatusBox>
</Card>
</CardsGrid>
<PageContent header={<PageHeader title="Health per project" />}>
<Table {...getTableProps()} rowHeight="standard">
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow hover {...row.getRowProps()}>
{row.cells.map(cell => (
<TableCell {...cell.getCellProps()}>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</PageContent>
</>
);
};

View File

@ -2,29 +2,10 @@ import useSWR from 'swr';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
interface InstanceStats {
instanceId: string;
timestamp: Date;
versionOSS: string;
versionEnterprise?: string;
users: number;
featureToggles: number;
projects: number;
contextFields: number;
roles: number;
groups: number;
environments: number;
segments: number;
strategies: number;
featureExports: number;
featureImports: number;
SAMLenabled: boolean;
OIDCenabled: boolean;
}
import { InstanceAdminStatsSchema } from 'openapi';
export interface IInstanceStatsResponse {
stats?: InstanceStats;
stats?: InstanceAdminStatsSchema;
refetchGroup: () => void;
loading: boolean;
error?: Error;

View File

@ -59,6 +59,7 @@ export interface IFlags {
integrationsRework?: boolean;
multipleRoles?: boolean;
doraMetrics?: boolean;
instanceHealthDashboard?: boolean;
[key: string]: boolean | Variant | undefined;
}

View File

@ -548,6 +548,7 @@ export * from './importTogglesSchema';
export * from './importTogglesValidateItemSchema';
export * from './importTogglesValidateSchema';
export * from './instanceAdminStatsSchema';
export * from './instanceAdminStatsSchemaActiveUsers';
export * from './instanceAdminStatsSchemaClientAppsItem';
export * from './instanceAdminStatsSchemaClientAppsItemRange';
export * from './invoicesSchema';

View File

@ -3,6 +3,7 @@
* Do not edit manually.
* See `gen:api` script in package.json
*/
import type { InstanceAdminStatsSchemaActiveUsers } from './instanceAdminStatsSchemaActiveUsers';
import type { InstanceAdminStatsSchemaClientAppsItem } from './instanceAdminStatsSchemaClientAppsItem';
/**
@ -19,6 +20,8 @@ export interface InstanceAdminStatsSchema {
versionEnterprise?: string;
/** The number of users this instance has */
users?: number;
/** The number of active users in the last 7, 30 and 90 days */
activeUsers?: InstanceAdminStatsSchemaActiveUsers;
/** The number of feature-toggles this instance has */
featureToggles?: number;
/** The number of projects defined in this instance. */
@ -41,6 +44,10 @@ export interface InstanceAdminStatsSchema {
OIDCenabled?: boolean;
/** A count of connected applications in the last week, last month and all time since last restart */
clientApps?: InstanceAdminStatsSchemaClientAppsItem[];
/** The number of export operations on this instance */
featureExports?: number;
/** The number of import operations on this instance */
featureImports?: number;
/** A SHA-256 checksum of the instance statistics to be used to verify that the data in this object has not been tampered with */
sum?: string;
}

View File

@ -0,0 +1,17 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
/**
* The number of active users in the last 7, 30 and 90 days
*/
export type InstanceAdminStatsSchemaActiveUsers = {
/** The number of active users in the last 7 days */
last7?: number;
/** The number of active users in the last 30 days */
last30?: number;
/** The number of active users in the last 90 days */
last90?: number;
};

View File

@ -201,6 +201,31 @@ class UserStore implements IUserStore {
.then((res) => Number(res[0].count));
}
async getActiveUsersCount(): Promise<{
last7: number;
last30: number;
last90: number;
}> {
const result = await this.db.raw(
`SELECT
(SELECT COUNT(*) FROM ${TABLE} WHERE seen_at > NOW() - INTERVAL '1 week') AS last_week,
(SELECT COUNT(*) FROM ${TABLE} WHERE seen_at > NOW() - INTERVAL '1 month') AS last_month,
(SELECT COUNT(*) FROM ${TABLE} WHERE seen_at > NOW() - INTERVAL '3 months') AS last_quarter`,
);
const {
last_week: last7,
last_month: last30,
last_quarter: last90,
} = result.rows[0];
return {
last7,
last30,
last90,
};
}
destroy(): void {}
async exists(id: number): Promise<boolean> {

View File

@ -39,6 +39,34 @@ export const instanceAdminStatsSchema = {
example: 8,
minimum: 0,
},
activeUsers: {
type: 'object',
description:
'The number of active users in the last 7, 30 and 90 days',
properties: {
last7: {
type: 'number',
description:
'The number of active users in the last 7 days',
example: 5,
minimum: 0,
},
last30: {
type: 'number',
description:
'The number of active users in the last 30 days',
example: 10,
minimum: 0,
},
last90: {
type: 'number',
description:
'The number of active users in the last 90 days',
example: 15,
minimum: 0,
},
},
},
featureToggles: {
type: 'number',
description: 'The number of feature-toggles this instance has',
@ -124,6 +152,18 @@ export const instanceAdminStatsSchema = {
},
},
},
featureExports: {
type: 'number',
description: 'The number of export operations on this instance',
example: 0,
minimum: 0,
},
featureImports: {
type: 'number',
description: 'The number of import operations on this instance',
example: 0,
minimum: 0,
},
sum: {
type: 'string',
description:

View File

@ -108,6 +108,11 @@ class InstanceAdminController extends Controller {
users: 10,
versionEnterprise: '5.1.7',
versionOSS: '5.1.7',
activeUsers: {
last90: 15,
last30: 10,
last7: 5,
},
};
}

View File

@ -31,7 +31,7 @@ test('get snapshot should not call getStats', async () => {
// subsequent calls to getStatsSnapshot don't call getStats
for (let i = 0; i < 3; i++) {
const stats = instanceStatsService.getStatsSnapshot();
expect(stats.clientApps).toStrictEqual([
expect(stats?.clientApps).toStrictEqual([
{ range: 'allTime', count: 0 },
{ range: '30d', count: 0 },
{ range: '7d', count: 0 },

View File

@ -12,7 +12,7 @@ import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { IGroupStore } from '../types/stores/group-store';
import { IProjectStore } from '../types/stores/project-store';
import { IStrategyStore } from '../types/stores/strategy-store';
import { IUserStore } from '../types/stores/user-store';
import { IActiveUsers, IUserStore } from '../types/stores/user-store';
import { ISegmentStore } from '../types/stores/segment-store';
import { IRoleStore } from '../types/stores/role-store';
import VersionService from './version-service';
@ -43,6 +43,7 @@ export interface InstanceStats {
SAMLenabled: boolean;
OIDCenabled: boolean;
clientApps: { range: TimeRange; count: number }[];
activeUsers: IActiveUsers;
}
export interface InstanceStatsSigned extends InstanceStats {
@ -176,6 +177,7 @@ export class InstanceStatsService {
const [
featureToggles,
users,
activeUsers,
projects,
contextFields,
groups,
@ -193,6 +195,7 @@ export class InstanceStatsService {
] = await Promise.all([
this.getToggleCount(),
this.userStore.count(),
this.userStore.getActiveUsersCount(),
this.projectStore.count(),
this.contextFieldStore.count(),
this.groupStore.count(),
@ -215,6 +218,7 @@ export class InstanceStatsService {
versionOSS: versionInfo.current.oss,
versionEnterprise: versionInfo.current.enterprise,
users,
activeUsers,
featureToggles,
projects,
contextFields,

View File

@ -30,7 +30,8 @@ export type IFlagKey =
| 'newApplicationList'
| 'integrationsRework'
| 'multipleRoles'
| 'doraMetrics';
| 'doraMetrics'
| 'instanceHealthDashboard';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -138,7 +139,6 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_NEW_APPLICATION_LIST,
false,
),
doraMetrics: parseEnvVarBoolean(process.env.UNLEASH_DORA_METRICS, false),
};
export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -19,6 +19,12 @@ export interface IUserUpdateFields {
email?: string;
}
export interface IActiveUsers {
last7: number;
last30: number;
last90: number;
}
export interface IUserStore extends Store<IUser, number> {
update(id: number, fields: IUserUpdateFields): Promise<IUser>;
insert(user: ICreateUser): Promise<IUser>;
@ -32,4 +38,5 @@ export interface IUserStore extends Store<IUser, number> {
incLoginAttempts(user: IUser): Promise<void>;
successfullyLogin(user: IUser): Promise<void>;
count(): Promise<number>;
getActiveUsersCount(): Promise<IActiveUsers>;
}

View File

@ -43,6 +43,7 @@ process.nextTick(async () => {
segmentChangeRequests: true,
newApplicationList: true,
doraMetrics: true,
instanceHealthDashboard: true,
},
},
authentication: {

View File

@ -1,5 +1,6 @@
import User, { IUser } from '../../lib/types/user';
import {
IActiveUsers,
ICreateUser,
IUserLookup,
IUserStore,
@ -46,6 +47,26 @@ class UserStoreMock implements IUserStore {
return this.data.find((u) => u.id === key);
}
async getActiveUsersCount(): Promise<IActiveUsers> {
return Promise.resolve({
last7: this.data.filter(
(u) =>
u.seenAt &&
u.seenAt > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
).length,
last30: this.data.filter(
(u) =>
u.seenAt &&
u.seenAt > new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
).length,
last90: this.data.filter(
(u) =>
u.seenAt &&
u.seenAt > new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
).length,
});
}
async insert(user: User): Promise<User> {
// eslint-disable-next-line no-param-reassign
user.id = this.idSeq;