mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-04 00:18:01 +01:00
Feat/instance health (#4586)
Hackathon --------- Co-authored-by: chriswk <chriswk@getunleash.io>
This commit is contained in:
parent
3b2d6a4cbf
commit
0d5f33f8ba
@ -18,6 +18,7 @@ import UsersAdmin from './users/UsersAdmin';
|
|||||||
import NotFound from 'component/common/NotFound/NotFound';
|
import NotFound from 'component/common/NotFound/NotFound';
|
||||||
import { AdminIndex } from './AdminIndex';
|
import { AdminIndex } from './AdminIndex';
|
||||||
import { AdminTabsMenu } from './menu/AdminTabsMenu';
|
import { AdminTabsMenu } from './menu/AdminTabsMenu';
|
||||||
|
import { InstanceHealth } from './instance-health/InstanceHealth';
|
||||||
|
|
||||||
export const Admin = () => {
|
export const Admin = () => {
|
||||||
return (
|
return (
|
||||||
@ -34,6 +35,7 @@ export const Admin = () => {
|
|||||||
<Route path="groups/*" element={<GroupsAdmin />} />
|
<Route path="groups/*" element={<GroupsAdmin />} />
|
||||||
<Route path="roles/*" element={<Roles />} />
|
<Route path="roles/*" element={<Roles />} />
|
||||||
<Route path="instance" element={<InstanceAdmin />} />
|
<Route path="instance" element={<InstanceAdmin />} />
|
||||||
|
<Route path="instance-health" element={<InstanceHealth />} />
|
||||||
<Route path="network/*" element={<Network />} />
|
<Route path="network/*" element={<Network />} />
|
||||||
<Route path="maintenance" element={<MaintenanceAdmin />} />
|
<Route path="maintenance" element={<MaintenanceAdmin />} />
|
||||||
<Route path="cors" element={<CorsAdmin />} />
|
<Route path="cors" element={<CorsAdmin />} />
|
||||||
|
@ -80,6 +80,13 @@ export const adminRoutes: INavigationMenuItem[] = [
|
|||||||
menu: { adminSettings: true },
|
menu: { adminSettings: true },
|
||||||
group: 'instance',
|
group: 'instance',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/instance-health',
|
||||||
|
title: 'Instance health',
|
||||||
|
menu: { adminSettings: true },
|
||||||
|
group: 'instance',
|
||||||
|
flag: 'instanceHealthDashboard',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/instance-privacy',
|
path: '/admin/instance-privacy',
|
||||||
title: 'Instance privacy',
|
title: 'Instance privacy',
|
||||||
|
@ -13,6 +13,7 @@ import { useInstanceStats } from '../../../../hooks/api/getters/useInstanceStats
|
|||||||
import { formatApiPath } from '../../../../utils/formatPath';
|
import { formatApiPath } from '../../../../utils/formatPath';
|
||||||
import { PageContent } from '../../../common/PageContent/PageContent';
|
import { PageContent } from '../../../common/PageContent/PageContent';
|
||||||
import { PageHeader } from '../../../common/PageHeader/PageHeader';
|
import { PageHeader } from '../../../common/PageHeader/PageHeader';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
export const InstanceStats: VFC = () => {
|
export const InstanceStats: VFC = () => {
|
||||||
const { stats } = useInstanceStats();
|
const { stats } = useInstanceStats();
|
||||||
@ -32,6 +33,21 @@ export const InstanceStats: VFC = () => {
|
|||||||
{ title: 'Instance Id', value: stats?.instanceId },
|
{ title: 'Instance Id', value: stats?.instanceId },
|
||||||
{ title: versionTitle, value: version },
|
{ title: versionTitle, value: version },
|
||||||
{ title: 'Users', value: stats?.users },
|
{ 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: 'Feature toggles', value: stats?.featureToggles },
|
||||||
{ title: 'Projects', value: stats?.projects },
|
{ title: 'Projects', value: stats?.projects },
|
||||||
{ title: 'Environments', value: stats?.environments },
|
{ title: 'Environments', value: stats?.environments },
|
||||||
@ -64,7 +80,22 @@ export const InstanceStats: VFC = () => {
|
|||||||
{rows.map(row => (
|
{rows.map(row => (
|
||||||
<TableRow key={row.title}>
|
<TableRow key={row.title}>
|
||||||
<TableCell component="th" scope="row">
|
<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>
|
||||||
<TableCell align="right">{row.value}</TableCell>
|
<TableCell align="right">{row.value}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
220
frontend/src/component/admin/instance-health/InstanceHealth.tsx
Normal file
220
frontend/src/component/admin/instance-health/InstanceHealth.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -2,29 +2,10 @@ import useSWR from 'swr';
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { formatApiPath } from 'utils/formatPath';
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
import { InstanceAdminStatsSchema } from 'openapi';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IInstanceStatsResponse {
|
export interface IInstanceStatsResponse {
|
||||||
stats?: InstanceStats;
|
stats?: InstanceAdminStatsSchema;
|
||||||
refetchGroup: () => void;
|
refetchGroup: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error?: Error;
|
error?: Error;
|
||||||
|
@ -59,6 +59,7 @@ export interface IFlags {
|
|||||||
integrationsRework?: boolean;
|
integrationsRework?: boolean;
|
||||||
multipleRoles?: boolean;
|
multipleRoles?: boolean;
|
||||||
doraMetrics?: boolean;
|
doraMetrics?: boolean;
|
||||||
|
instanceHealthDashboard?: boolean;
|
||||||
[key: string]: boolean | Variant | undefined;
|
[key: string]: boolean | Variant | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -548,6 +548,7 @@ export * from './importTogglesSchema';
|
|||||||
export * from './importTogglesValidateItemSchema';
|
export * from './importTogglesValidateItemSchema';
|
||||||
export * from './importTogglesValidateSchema';
|
export * from './importTogglesValidateSchema';
|
||||||
export * from './instanceAdminStatsSchema';
|
export * from './instanceAdminStatsSchema';
|
||||||
|
export * from './instanceAdminStatsSchemaActiveUsers';
|
||||||
export * from './instanceAdminStatsSchemaClientAppsItem';
|
export * from './instanceAdminStatsSchemaClientAppsItem';
|
||||||
export * from './instanceAdminStatsSchemaClientAppsItemRange';
|
export * from './instanceAdminStatsSchemaClientAppsItemRange';
|
||||||
export * from './invoicesSchema';
|
export * from './invoicesSchema';
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* See `gen:api` script in package.json
|
* See `gen:api` script in package.json
|
||||||
*/
|
*/
|
||||||
|
import type { InstanceAdminStatsSchemaActiveUsers } from './instanceAdminStatsSchemaActiveUsers';
|
||||||
import type { InstanceAdminStatsSchemaClientAppsItem } from './instanceAdminStatsSchemaClientAppsItem';
|
import type { InstanceAdminStatsSchemaClientAppsItem } from './instanceAdminStatsSchemaClientAppsItem';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -19,6 +20,8 @@ export interface InstanceAdminStatsSchema {
|
|||||||
versionEnterprise?: string;
|
versionEnterprise?: string;
|
||||||
/** The number of users this instance has */
|
/** The number of users this instance has */
|
||||||
users?: number;
|
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 */
|
/** The number of feature-toggles this instance has */
|
||||||
featureToggles?: number;
|
featureToggles?: number;
|
||||||
/** The number of projects defined in this instance. */
|
/** The number of projects defined in this instance. */
|
||||||
@ -41,6 +44,10 @@ export interface InstanceAdminStatsSchema {
|
|||||||
OIDCenabled?: boolean;
|
OIDCenabled?: boolean;
|
||||||
/** A count of connected applications in the last week, last month and all time since last restart */
|
/** A count of connected applications in the last week, last month and all time since last restart */
|
||||||
clientApps?: InstanceAdminStatsSchemaClientAppsItem[];
|
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 */
|
/** 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;
|
sum?: string;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
};
|
@ -201,6 +201,31 @@ class UserStore implements IUserStore {
|
|||||||
.then((res) => Number(res[0].count));
|
.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 {}
|
destroy(): void {}
|
||||||
|
|
||||||
async exists(id: number): Promise<boolean> {
|
async exists(id: number): Promise<boolean> {
|
||||||
|
@ -39,6 +39,34 @@ export const instanceAdminStatsSchema = {
|
|||||||
example: 8,
|
example: 8,
|
||||||
minimum: 0,
|
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: {
|
featureToggles: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
description: 'The number of feature-toggles this instance has',
|
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: {
|
sum: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description:
|
description:
|
||||||
|
@ -108,6 +108,11 @@ class InstanceAdminController extends Controller {
|
|||||||
users: 10,
|
users: 10,
|
||||||
versionEnterprise: '5.1.7',
|
versionEnterprise: '5.1.7',
|
||||||
versionOSS: '5.1.7',
|
versionOSS: '5.1.7',
|
||||||
|
activeUsers: {
|
||||||
|
last90: 15,
|
||||||
|
last30: 10,
|
||||||
|
last7: 5,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,7 +31,7 @@ test('get snapshot should not call getStats', async () => {
|
|||||||
// subsequent calls to getStatsSnapshot don't call getStats
|
// subsequent calls to getStatsSnapshot don't call getStats
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const stats = instanceStatsService.getStatsSnapshot();
|
const stats = instanceStatsService.getStatsSnapshot();
|
||||||
expect(stats.clientApps).toStrictEqual([
|
expect(stats?.clientApps).toStrictEqual([
|
||||||
{ range: 'allTime', count: 0 },
|
{ range: 'allTime', count: 0 },
|
||||||
{ range: '30d', count: 0 },
|
{ range: '30d', count: 0 },
|
||||||
{ range: '7d', count: 0 },
|
{ range: '7d', count: 0 },
|
||||||
|
@ -12,7 +12,7 @@ import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
|
|||||||
import { IGroupStore } from '../types/stores/group-store';
|
import { IGroupStore } from '../types/stores/group-store';
|
||||||
import { IProjectStore } from '../types/stores/project-store';
|
import { IProjectStore } from '../types/stores/project-store';
|
||||||
import { IStrategyStore } from '../types/stores/strategy-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 { ISegmentStore } from '../types/stores/segment-store';
|
||||||
import { IRoleStore } from '../types/stores/role-store';
|
import { IRoleStore } from '../types/stores/role-store';
|
||||||
import VersionService from './version-service';
|
import VersionService from './version-service';
|
||||||
@ -43,6 +43,7 @@ export interface InstanceStats {
|
|||||||
SAMLenabled: boolean;
|
SAMLenabled: boolean;
|
||||||
OIDCenabled: boolean;
|
OIDCenabled: boolean;
|
||||||
clientApps: { range: TimeRange; count: number }[];
|
clientApps: { range: TimeRange; count: number }[];
|
||||||
|
activeUsers: IActiveUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstanceStatsSigned extends InstanceStats {
|
export interface InstanceStatsSigned extends InstanceStats {
|
||||||
@ -176,6 +177,7 @@ export class InstanceStatsService {
|
|||||||
const [
|
const [
|
||||||
featureToggles,
|
featureToggles,
|
||||||
users,
|
users,
|
||||||
|
activeUsers,
|
||||||
projects,
|
projects,
|
||||||
contextFields,
|
contextFields,
|
||||||
groups,
|
groups,
|
||||||
@ -193,6 +195,7 @@ export class InstanceStatsService {
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.getToggleCount(),
|
this.getToggleCount(),
|
||||||
this.userStore.count(),
|
this.userStore.count(),
|
||||||
|
this.userStore.getActiveUsersCount(),
|
||||||
this.projectStore.count(),
|
this.projectStore.count(),
|
||||||
this.contextFieldStore.count(),
|
this.contextFieldStore.count(),
|
||||||
this.groupStore.count(),
|
this.groupStore.count(),
|
||||||
@ -215,6 +218,7 @@ export class InstanceStatsService {
|
|||||||
versionOSS: versionInfo.current.oss,
|
versionOSS: versionInfo.current.oss,
|
||||||
versionEnterprise: versionInfo.current.enterprise,
|
versionEnterprise: versionInfo.current.enterprise,
|
||||||
users,
|
users,
|
||||||
|
activeUsers,
|
||||||
featureToggles,
|
featureToggles,
|
||||||
projects,
|
projects,
|
||||||
contextFields,
|
contextFields,
|
||||||
|
@ -30,7 +30,8 @@ export type IFlagKey =
|
|||||||
| 'newApplicationList'
|
| 'newApplicationList'
|
||||||
| 'integrationsRework'
|
| 'integrationsRework'
|
||||||
| 'multipleRoles'
|
| 'multipleRoles'
|
||||||
| 'doraMetrics';
|
| 'doraMetrics'
|
||||||
|
| 'instanceHealthDashboard';
|
||||||
|
|
||||||
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||||
|
|
||||||
@ -138,7 +139,6 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_NEW_APPLICATION_LIST,
|
process.env.UNLEASH_EXPERIMENTAL_NEW_APPLICATION_LIST,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
doraMetrics: parseEnvVarBoolean(process.env.UNLEASH_DORA_METRICS, false),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultExperimentalOptions: IExperimentalOptions = {
|
export const defaultExperimentalOptions: IExperimentalOptions = {
|
||||||
|
@ -19,6 +19,12 @@ export interface IUserUpdateFields {
|
|||||||
email?: string;
|
email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IActiveUsers {
|
||||||
|
last7: number;
|
||||||
|
last30: number;
|
||||||
|
last90: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IUserStore extends Store<IUser, number> {
|
export interface IUserStore extends Store<IUser, number> {
|
||||||
update(id: number, fields: IUserUpdateFields): Promise<IUser>;
|
update(id: number, fields: IUserUpdateFields): Promise<IUser>;
|
||||||
insert(user: ICreateUser): Promise<IUser>;
|
insert(user: ICreateUser): Promise<IUser>;
|
||||||
@ -32,4 +38,5 @@ export interface IUserStore extends Store<IUser, number> {
|
|||||||
incLoginAttempts(user: IUser): Promise<void>;
|
incLoginAttempts(user: IUser): Promise<void>;
|
||||||
successfullyLogin(user: IUser): Promise<void>;
|
successfullyLogin(user: IUser): Promise<void>;
|
||||||
count(): Promise<number>;
|
count(): Promise<number>;
|
||||||
|
getActiveUsersCount(): Promise<IActiveUsers>;
|
||||||
}
|
}
|
||||||
|
@ -43,6 +43,7 @@ process.nextTick(async () => {
|
|||||||
segmentChangeRequests: true,
|
segmentChangeRequests: true,
|
||||||
newApplicationList: true,
|
newApplicationList: true,
|
||||||
doraMetrics: true,
|
doraMetrics: true,
|
||||||
|
instanceHealthDashboard: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
|
21
src/test/fixtures/fake-user-store.ts
vendored
21
src/test/fixtures/fake-user-store.ts
vendored
@ -1,5 +1,6 @@
|
|||||||
import User, { IUser } from '../../lib/types/user';
|
import User, { IUser } from '../../lib/types/user';
|
||||||
import {
|
import {
|
||||||
|
IActiveUsers,
|
||||||
ICreateUser,
|
ICreateUser,
|
||||||
IUserLookup,
|
IUserLookup,
|
||||||
IUserStore,
|
IUserStore,
|
||||||
@ -46,6 +47,26 @@ class UserStoreMock implements IUserStore {
|
|||||||
return this.data.find((u) => u.id === key);
|
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> {
|
async insert(user: User): Promise<User> {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
user.id = this.idSeq;
|
user.id = this.idSeq;
|
||||||
|
Loading…
Reference in New Issue
Block a user