diff --git a/frontend/src/component/admin/Admin.tsx b/frontend/src/component/admin/Admin.tsx index 50be47aa0b..0dc7730e4d 100644 --- a/frontend/src/component/admin/Admin.tsx +++ b/frontend/src/component/admin/Admin.tsx @@ -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 = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/component/admin/adminRoutes.ts b/frontend/src/component/admin/adminRoutes.ts index 6cb2cbabb7..d7b6f080f7 100644 --- a/frontend/src/component/admin/adminRoutes.ts +++ b/frontend/src/component/admin/adminRoutes.ts @@ -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', diff --git a/frontend/src/component/admin/instance-admin/InstanceStats/InstanceStats.tsx b/frontend/src/component/admin/instance-admin/InstanceStats/InstanceStats.tsx index fb1dc3f0bd..029aba1282 100644 --- a/frontend/src/component/admin/instance-admin/InstanceStats/InstanceStats.tsx +++ b/frontend/src/component/admin/instance-admin/InstanceStats/InstanceStats.tsx @@ -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 => ( - {row.title} + ({ + marginLeft: row.offset + ? theme.spacing(2) + : 0, + })} + > + {row.title} + + } + elseShow={row.title} + /> {row.value} diff --git a/frontend/src/component/admin/instance-health/InstanceHealth.tsx b/frontend/src/component/admin/instance-health/InstanceHealth.tsx new file mode 100644 index 0000000000..4470f8e6f8 --- /dev/null +++ b/frontend/src/component/admin/instance-health/InstanceHealth.tsx @@ -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 = () => { + 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 ( + + + {value}% + + + ); + }, + }, + ], + [] + ); + + 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 ( + <> + + + } + > + {/* + Sum of all configuration and state modifications in + the project. + */} + {/* FIXME: tooltip */} + + + + + ({dormantUsersPercentage.toFixed(1)}%) + + } + > + {/* + Sum of all configuration and state modifications in + the project. + */} + + + + } + > + + + } + > + + + }> + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
+
+ + ); +}; diff --git a/frontend/src/hooks/api/getters/useInstanceStats/useInstanceStats.ts b/frontend/src/hooks/api/getters/useInstanceStats/useInstanceStats.ts index 844397f440..25de460925 100644 --- a/frontend/src/hooks/api/getters/useInstanceStats/useInstanceStats.ts +++ b/frontend/src/hooks/api/getters/useInstanceStats/useInstanceStats.ts @@ -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; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 5c097fb59f..6bcb7cecc5 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -59,6 +59,7 @@ export interface IFlags { integrationsRework?: boolean; multipleRoles?: boolean; doraMetrics?: boolean; + instanceHealthDashboard?: boolean; [key: string]: boolean | Variant | undefined; } diff --git a/frontend/src/openapi/models/index.ts b/frontend/src/openapi/models/index.ts index 044674ada0..5017b45c2d 100644 --- a/frontend/src/openapi/models/index.ts +++ b/frontend/src/openapi/models/index.ts @@ -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'; diff --git a/frontend/src/openapi/models/instanceAdminStatsSchema.ts b/frontend/src/openapi/models/instanceAdminStatsSchema.ts index 54dfe2e16a..a8e4370f7e 100644 --- a/frontend/src/openapi/models/instanceAdminStatsSchema.ts +++ b/frontend/src/openapi/models/instanceAdminStatsSchema.ts @@ -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; } diff --git a/frontend/src/openapi/models/instanceAdminStatsSchemaActiveUsers.ts b/frontend/src/openapi/models/instanceAdminStatsSchemaActiveUsers.ts new file mode 100644 index 0000000000..2a07f834ce --- /dev/null +++ b/frontend/src/openapi/models/instanceAdminStatsSchemaActiveUsers.ts @@ -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; +}; diff --git a/src/lib/db/user-store.ts b/src/lib/db/user-store.ts index 6141ecff6a..b8d33f93b6 100644 --- a/src/lib/db/user-store.ts +++ b/src/lib/db/user-store.ts @@ -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 { diff --git a/src/lib/openapi/spec/instance-admin-stats-schema.ts b/src/lib/openapi/spec/instance-admin-stats-schema.ts index 74593d0084..1296d0f053 100644 --- a/src/lib/openapi/spec/instance-admin-stats-schema.ts +++ b/src/lib/openapi/spec/instance-admin-stats-schema.ts @@ -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: diff --git a/src/lib/routes/admin-api/instance-admin.ts b/src/lib/routes/admin-api/instance-admin.ts index 94c17bb9a8..e9f1117662 100644 --- a/src/lib/routes/admin-api/instance-admin.ts +++ b/src/lib/routes/admin-api/instance-admin.ts @@ -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, + }, }; } diff --git a/src/lib/services/instance-stats-service.test.ts b/src/lib/services/instance-stats-service.test.ts index 2f24ffa886..a542d43c04 100644 --- a/src/lib/services/instance-stats-service.test.ts +++ b/src/lib/services/instance-stats-service.test.ts @@ -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 }, diff --git a/src/lib/services/instance-stats-service.ts b/src/lib/services/instance-stats-service.ts index 9116dbacc1..19fa933c8d 100644 --- a/src/lib/services/instance-stats-service.ts +++ b/src/lib/services/instance-stats-service.ts @@ -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, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index ba7926d512..2c535759a2 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -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 = { diff --git a/src/lib/types/stores/user-store.ts b/src/lib/types/stores/user-store.ts index 32b3beaa83..23de31bb49 100644 --- a/src/lib/types/stores/user-store.ts +++ b/src/lib/types/stores/user-store.ts @@ -19,6 +19,12 @@ export interface IUserUpdateFields { email?: string; } +export interface IActiveUsers { + last7: number; + last30: number; + last90: number; +} + export interface IUserStore extends Store { update(id: number, fields: IUserUpdateFields): Promise; insert(user: ICreateUser): Promise; @@ -32,4 +38,5 @@ export interface IUserStore extends Store { incLoginAttempts(user: IUser): Promise; successfullyLogin(user: IUser): Promise; count(): Promise; + getActiveUsersCount(): Promise; } diff --git a/src/server-dev.ts b/src/server-dev.ts index 438ee7929f..ecc1d3b7ea 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -43,6 +43,7 @@ process.nextTick(async () => { segmentChangeRequests: true, newApplicationList: true, doraMetrics: true, + instanceHealthDashboard: true, }, }, authentication: { diff --git a/src/test/fixtures/fake-user-store.ts b/src/test/fixtures/fake-user-store.ts index f7d247472a..89da421cb1 100644 --- a/src/test/fixtures/fake-user-store.ts +++ b/src/test/fixtures/fake-user-store.ts @@ -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 { + 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 { // eslint-disable-next-line no-param-reassign user.id = this.idSeq;