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;