diff --git a/frontend/src/component/admin/instance-admin/InstanceAdmin.tsx b/frontend/src/component/admin/instance-admin/InstanceAdmin.tsx
new file mode 100644
index 0000000000..f8a0248085
--- /dev/null
+++ b/frontend/src/component/admin/instance-admin/InstanceAdmin.tsx
@@ -0,0 +1,11 @@
+import AdminMenu from '../menu/AdminMenu';
+import { InstanceStats } from './InstanceStats/InstanceStats';
+
+export const InstanceAdmin = () => {
+ return (
+
+ );
+};
diff --git a/frontend/src/component/admin/instance-admin/InstanceStats/InstanceStats.tsx b/frontend/src/component/admin/instance-admin/InstanceStats/InstanceStats.tsx
new file mode 100644
index 0000000000..42c172234c
--- /dev/null
+++ b/frontend/src/component/admin/instance-admin/InstanceStats/InstanceStats.tsx
@@ -0,0 +1,89 @@
+import { Download } from '@mui/icons-material';
+import {
+ Button,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+} from '@mui/material';
+import { Box } from '@mui/system';
+import { VFC } from 'react';
+import { useInstanceStats } from '../../../../hooks/api/getters/useInstanceStats/useInstanceStats';
+import { formatApiPath } from '../../../../utils/formatPath';
+import { PageContent } from '../../../common/PageContent/PageContent';
+import { PageHeader } from '../../../common/PageHeader/PageHeader';
+
+export const InstanceStats: VFC = () => {
+ const { stats, loading } = useInstanceStats();
+
+ let versionTitle;
+ let version;
+
+ if (stats?.versionEnterprise) {
+ versionTitle = 'Unleash Enterprise version';
+ version = stats.versionEnterprise;
+ } else {
+ versionTitle = 'Unleash OSS version';
+ version = stats?.versionOSS;
+ }
+
+ const rows = [
+ { title: 'Instance Id', value: stats?.instanceId },
+ { title: versionTitle, value: version },
+ { title: 'Users', value: stats?.users },
+ { title: 'Feature toggles', value: stats?.featureToggles },
+ { title: 'Projects', value: stats?.projects },
+ { title: 'Environments', value: stats?.environments },
+ { title: 'Roles', value: stats?.roles },
+ { title: 'Groups', value: stats?.groups },
+ { title: 'Context fields', value: stats?.contextFields },
+ { title: 'Strategies', value: stats?.strategies },
+ ];
+
+ if (stats?.versionEnterprise) {
+ rows.push(
+ { title: 'SAML enabled', value: stats?.SAMLenabled ? 'Yes' : 'No' },
+ { title: 'OIDC enabled', value: stats?.OIDCenabled ? 'Yes' : 'No' }
+ );
+ }
+
+ return (
+ }>
+
+
+
+
+ Field
+ Value
+
+
+
+ {rows.map(row => (
+
+
+ {row.title}
+
+ {row.value}
+
+ ))}
+
+
+
+ }
+ aria-label="Download instance statistics"
+ color="primary"
+ variant="contained"
+ target="_blank"
+ href={formatApiPath(
+ '/api/admin/instance-admin/statistics/csv'
+ )}
+ >
+ Download
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/menu/AdminMenu.tsx b/frontend/src/component/admin/menu/AdminMenu.tsx
index 2498596767..40606af2e8 100644
--- a/frontend/src/component/admin/menu/AdminMenu.tsx
+++ b/frontend/src/component/admin/menu/AdminMenu.tsx
@@ -135,6 +135,19 @@ function AdminMenu() {
}
/>
+
+ createNavLinkStyle({ isActive, theme })
+ }
+ >
+ Instance stats
+
+ }
+ />
{isBilling && (
void;
+ loading: boolean;
+ error?: Error;
+}
+
+export const useInstanceStats = (): IInstanceStatsResponse => {
+ const { data, error, mutate } = useSWR(
+ formatApiPath(`api/admin/instance-admin/statistics`),
+ fetcher
+ );
+
+ return useMemo(
+ () => ({
+ stats: data,
+ loading: !error && !data,
+ refetchGroup: () => mutate(),
+ error,
+ }),
+ [data, error, mutate]
+ );
+};
+
+const fetcher = (path: string) => {
+ return fetch(path)
+ .then(handleErrorResponses('Instance Stats'))
+ .then(res => res.json());
+};
diff --git a/package.json b/package.json
index a67d44f4e3..3efb5aaad5 100644
--- a/package.json
+++ b/package.json
@@ -107,8 +107,10 @@
"helmet": "^5.0.0",
"ip": "^1.1.8",
"joi": "^17.3.0",
+ "js-sha256": "^0.9.0",
"js-yaml": "^4.1.0",
"json-schema-to-ts": "2.5.5",
+ "json2csv": "^5.0.7",
"knex": "^2.0.0",
"log4js": "^6.0.0",
"make-fetch-happen": "^10.1.2",
diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap
index a8e9eb40c0..13cc4fa01e 100644
--- a/src/lib/__snapshots__/create-config.test.ts.snap
+++ b/src/lib/__snapshots__/create-config.test.ts.snap
@@ -141,6 +141,7 @@ exports[`should create default config 1`] = `
},
"strategySegmentsLimit": 5,
"ui": {
+ "environment": "Open Source",
"flags": {
"E": true,
"ENABLE_DARK_MODE_SUPPORT": false,
diff --git a/src/lib/create-config.ts b/src/lib/create-config.ts
index 0a7e5d69a1..268881e22d 100644
--- a/src/lib/create-config.ts
+++ b/src/lib/create-config.ts
@@ -100,7 +100,9 @@ function loadClientCachingOptions(
function loadUI(options: IUnleashOptions): IUIConfig {
const uiO = options.ui || {};
- const ui: IUIConfig = {};
+ const ui: IUIConfig = {
+ environment: 'Open Source',
+ };
ui.flags = {
E: true,
diff --git a/src/lib/db/context-field-store.ts b/src/lib/db/context-field-store.ts
index 904a4f5ec6..e10eed3086 100644
--- a/src/lib/db/context-field-store.ts
+++ b/src/lib/db/context-field-store.ts
@@ -119,5 +119,11 @@ class ContextFieldStore implements IContextFieldStore {
async delete(name: string): Promise {
return this.db(TABLE).where({ name }).del();
}
+
+ async count(): Promise {
+ return this.db(TABLE)
+ .count('*')
+ .then((res) => Number(res[0].count));
+ }
}
export default ContextFieldStore;
diff --git a/src/lib/db/group-store.ts b/src/lib/db/group-store.ts
index b9f3489d2e..cfcc3389cf 100644
--- a/src/lib/db/group-store.ts
+++ b/src/lib/db/group-store.ts
@@ -175,6 +175,12 @@ export default class GroupStore implements IGroupStore {
return rowToGroup(row[0]);
}
+ async count(): Promise {
+ return this.db(T.GROUPS)
+ .count('*')
+ .then((res) => Number(res[0].count));
+ }
+
async addUsersToGroup(
groupId: number,
users: IGroupUserModel[],
diff --git a/src/lib/db/role-store.ts b/src/lib/db/role-store.ts
index 67b6f790ad..1e6dea9fd7 100644
--- a/src/lib/db/role-store.ts
+++ b/src/lib/db/role-store.ts
@@ -47,6 +47,13 @@ export default class RoleStore implements IRoleStore {
return rows.map(this.mapRow);
}
+ async count(): Promise {
+ return this.db
+ .from(T.ROLES)
+ .count('*')
+ .then((res) => Number(res[0].count));
+ }
+
async create(role: ICustomRoleInsert): Promise {
const row = await this.db(T.ROLES)
.insert({
diff --git a/src/lib/db/segment-store.ts b/src/lib/db/segment-store.ts
index 20eca85979..f1041b4c0f 100644
--- a/src/lib/db/segment-store.ts
+++ b/src/lib/db/segment-store.ts
@@ -50,6 +50,13 @@ export default class SegmentStore implements ISegmentStore {
this.logger = getLogger('lib/db/segment-store.ts');
}
+ async count(): Promise {
+ return this.db
+ .from(T.segments)
+ .count('*')
+ .then((res) => Number(res[0].count));
+ }
+
async create(
segment: PartialSome,
user: Partial>,
diff --git a/src/lib/db/strategy-store.ts b/src/lib/db/strategy-store.ts
index be5347925d..e8ded7f4a3 100644
--- a/src/lib/db/strategy-store.ts
+++ b/src/lib/db/strategy-store.ts
@@ -74,6 +74,13 @@ export default class StrategyStore implements IStrategyStore {
await this.db(TABLE).del();
}
+ async count(): Promise {
+ return this.db
+ .from(TABLE)
+ .count('*')
+ .then((res) => Number(res[0].count));
+ }
+
destroy(): void {}
async exists(name: string): Promise {
diff --git a/src/lib/metrics.test.ts b/src/lib/metrics.test.ts
index a9b7cb195e..ec9b05ac44 100644
--- a/src/lib/metrics.test.ts
+++ b/src/lib/metrics.test.ts
@@ -10,11 +10,14 @@ import {
} from './types/events';
import { createMetricsMonitor } from './metrics';
import createStores from '../test/fixtures/store';
+import { InstanceStatsService } from './services/instance-stats-service';
+import VersionService from './services/version-service';
const monitor = createMetricsMonitor();
const eventBus = new EventEmitter();
const prometheusRegister = register;
let eventStore: IEventStore;
+let statsService: InstanceStatsService;
let stores;
beforeAll(() => {
const config = createTestConfig({
@@ -24,6 +27,8 @@ beforeAll(() => {
});
stores = createStores();
eventStore = stores.eventStore;
+ const versionService = new VersionService(stores, config);
+ statsService = new InstanceStatsService(stores, config, versionService);
const db = {
client: {
pool: {
@@ -37,7 +42,15 @@ beforeAll(() => {
},
};
// @ts-ignore - We don't want a full knex implementation for our tests, it's enough that it actually yields the numbers we want.
- monitor.startMonitoring(config, stores, '4.0.0', eventBus, db);
+ monitor.startMonitoring(
+ config,
+ stores,
+ '4.0.0',
+ eventBus,
+ statsService,
+ //@ts-ignore
+ db,
+ );
});
afterAll(() => {
monitor.stopMonitoring();
@@ -102,6 +115,9 @@ test('should collect metrics for db query timings', async () => {
});
test('should collect metrics for feature toggle size', async () => {
+ await new Promise((done) => {
+ setTimeout(done, 10);
+ });
const metrics = await prometheusRegister.metrics();
expect(metrics).toMatch(/feature_toggles_total{version="(.*)"} 0/);
});
diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts
index 1ff13dc4f7..2529c494a1 100644
--- a/src/lib/metrics.ts
+++ b/src/lib/metrics.ts
@@ -22,6 +22,7 @@ import { IUnleashConfig } from './types/option';
import { IUnleashStores } from './types/stores';
import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns';
import Timer = NodeJS.Timer;
+import { InstanceStatsService } from './services/instance-stats-service';
export default class MetricsMonitor {
timer?: Timer;
@@ -38,19 +39,14 @@ export default class MetricsMonitor {
stores: IUnleashStores,
version: string,
eventBus: EventEmitter,
+ instanceStatsService: InstanceStatsService,
db: Knex,
): Promise {
if (!config.server.serverMetrics) {
return;
}
- const {
- eventStore,
- featureToggleStore,
- userStore,
- projectStore,
- environmentStore,
- } = stores;
+ const { eventStore } = stores;
client.collectDefaultMetrics();
@@ -97,6 +93,40 @@ export default class MetricsMonitor {
name: 'environments_total',
help: 'Number of environments',
});
+ const groupsTotal = new client.Gauge({
+ name: 'groups_total',
+ help: 'Number of groups',
+ });
+
+ const rolesTotal = new client.Gauge({
+ name: 'roles_total',
+ help: 'Number of roles',
+ });
+
+ const segmentsTotal = new client.Gauge({
+ name: 'segments_total',
+ help: 'Number of segments',
+ });
+
+ const contextTotal = new client.Gauge({
+ name: 'context_total',
+ help: 'Number of context',
+ });
+
+ const strategiesTotal = new client.Gauge({
+ name: 'strategies_total',
+ help: 'Number of strategies',
+ });
+
+ const samlEnabled = new client.Gauge({
+ name: 'saml_enabled',
+ help: 'Whether SAML is enabled',
+ });
+
+ const oidcEnabled = new client.Gauge({
+ name: 'oidc_enabled',
+ help: 'Whether OIDC is enabled',
+ });
const clientSdkVersionUsage = new client.Counter({
name: 'client_sdk_versions',
@@ -105,41 +135,51 @@ export default class MetricsMonitor {
});
async function collectStaticCounters() {
- let togglesCount: number = 0;
- let usersCount: number;
- let projectsCount: number;
- let environmentsCount: number;
try {
- togglesCount = await featureToggleStore.count({
- archived: false,
- });
- usersCount = await userStore.count();
- projectsCount = await projectStore.count();
- environmentsCount = await environmentStore.count();
- // eslint-disable-next-line no-empty
- } catch (e) {}
+ const stats = await instanceStatsService.getStats();
+
+ featureTogglesTotal.reset();
+ featureTogglesTotal.labels(version).set(stats.featureToggles);
- featureTogglesTotal.reset();
- featureTogglesTotal.labels(version).set(togglesCount);
- if (usersCount) {
usersTotal.reset();
- usersTotal.set(usersCount);
- }
- if (projectsCount) {
+ usersTotal.set(stats.users);
+
projectsTotal.reset();
- projectsTotal.set(projectsCount);
- }
- if (environmentsCount) {
+ projectsTotal.set(stats.projects);
+
environmentsTotal.reset();
- environmentsTotal.set(environmentsCount);
- }
+ environmentsTotal.set(stats.environments);
+
+ groupsTotal.reset();
+ groupsTotal.set(stats.groups);
+
+ rolesTotal.reset();
+ rolesTotal.set(stats.roles);
+
+ segmentsTotal.reset();
+ segmentsTotal.set(stats.segments);
+
+ contextTotal.reset();
+ contextTotal.set(stats.contextFields);
+
+ strategiesTotal.reset();
+ strategiesTotal.set(stats.strategies);
+
+ samlEnabled.reset();
+ samlEnabled.set(stats.SAMLenabled ? 1 : 0);
+
+ oidcEnabled.reset();
+ oidcEnabled.set(stats.OIDCenabled ? 1 : 0);
+ } catch (e) {}
}
- collectStaticCounters();
- this.timer = setInterval(
- () => collectStaticCounters(),
- hoursToMilliseconds(2),
- ).unref();
+ process.nextTick(() => {
+ collectStaticCounters();
+ this.timer = setInterval(
+ () => collectStaticCounters(),
+ hoursToMilliseconds(2),
+ ).unref();
+ });
eventBus.on(
events.REQUEST_TIME,
diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts
index afe319e858..41358e2b79 100644
--- a/src/lib/openapi/index.ts
+++ b/src/lib/openapi/index.ts
@@ -121,6 +121,7 @@ import { validateTagTypeSchema } from './spec/validate-tag-type-schema';
import { variantSchema } from './spec/variant-schema';
import { variantsSchema } from './spec/variants-schema';
import { versionSchema } from './spec/version-schema';
+import { instanceAdminStatsSchema } from './spec/instance-admin-stats-schema';
import apiVersion from '../util/version';
// All schemas in `openapi/spec` should be listed here.
@@ -176,6 +177,7 @@ export const schemas = {
healthOverviewSchema,
healthReportSchema,
idSchema,
+ instanceAdminStatsSchema,
legalValueSchema,
loginSchema,
meSchema,
diff --git a/src/lib/openapi/spec/instance-admin-stats-schema.test.ts b/src/lib/openapi/spec/instance-admin-stats-schema.test.ts
new file mode 100644
index 0000000000..9b689b11a7
--- /dev/null
+++ b/src/lib/openapi/spec/instance-admin-stats-schema.test.ts
@@ -0,0 +1,13 @@
+import { validateSchema } from '../validate';
+import { InstanceAdminStatsSchema } from './instance-admin-stats-schema';
+
+test('instanceAdminStatsSchema', () => {
+ const data: InstanceAdminStatsSchema = {
+ instanceId: '123',
+ users: 0,
+ };
+
+ expect(
+ validateSchema('#/components/schemas/instanceAdminStatsSchema', data),
+ ).toBeUndefined();
+});
diff --git a/src/lib/openapi/spec/instance-admin-stats-schema.ts b/src/lib/openapi/spec/instance-admin-stats-schema.ts
new file mode 100644
index 0000000000..f875fc67f6
--- /dev/null
+++ b/src/lib/openapi/spec/instance-admin-stats-schema.ts
@@ -0,0 +1,65 @@
+import { FromSchema } from 'json-schema-to-ts';
+
+export const instanceAdminStatsSchema = {
+ $id: '#/components/schemas/instanceAdminStatsSchema',
+ type: 'object',
+ additionalProperties: false,
+ required: ['instanceId'],
+ properties: {
+ instanceId: {
+ type: 'string',
+ },
+ timestamp: {
+ type: 'string',
+ format: 'date-time',
+ nullable: true,
+ },
+ versionOSS: {
+ type: 'string',
+ },
+ versionEnterprise: {
+ type: 'string',
+ },
+ users: {
+ type: 'number',
+ },
+ featureToggles: {
+ type: 'number',
+ },
+ projects: {
+ type: 'number',
+ },
+ contextFields: {
+ type: 'number',
+ },
+ roles: {
+ type: 'number',
+ },
+ groups: {
+ type: 'number',
+ },
+ environments: {
+ type: 'number',
+ },
+ segments: {
+ type: 'number',
+ },
+ strategies: {
+ type: 'number',
+ },
+ SAMLenabled: {
+ type: 'number',
+ },
+ OIDCenabled: {
+ type: 'number',
+ },
+ sum: {
+ type: 'string',
+ },
+ },
+ components: {},
+} as const;
+
+export type InstanceAdminStatsSchema = FromSchema<
+ typeof instanceAdminStatsSchema
+>;
diff --git a/src/lib/openapi/util/openapi-tags.ts b/src/lib/openapi/util/openapi-tags.ts
index e04a5ed4a7..d55b9282fa 100644
--- a/src/lib/openapi/util/openapi-tags.ts
+++ b/src/lib/openapi/util/openapi-tags.ts
@@ -45,6 +45,11 @@ const OPENAPI_TAGS = [
description:
'[Import and export](https://docs.getunleash.io/deploy/import_export) the state of your Unleash instance.',
},
+ {
+ name: 'Instance Admin',
+ description:
+ 'Instance admin endpoints used to manage the Unleash instance itself.',
+ },
{
name: 'Metrics',
description: 'Register, read, or delete metrics recorded by Unleash.',
diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts
index dce4d343be..1792c8085f 100644
--- a/src/lib/routes/admin-api/index.ts
+++ b/src/lib/routes/admin-api/index.ts
@@ -26,6 +26,7 @@ import ConstraintsController from './constraints';
import PatController from './user/pat';
import { PublicSignupController } from './public-signup';
import { conditionalMiddleware } from '../../middleware/conditional-middleware';
+import InstanceAdminController from './instance-admin';
class AdminApi extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices) {
@@ -114,6 +115,10 @@ class AdminApi extends Controller {
new PublicSignupController(config, services).router,
),
);
+ this.app.use(
+ '/instance-admin',
+ new InstanceAdminController(config, services).router,
+ );
}
}
diff --git a/src/lib/routes/admin-api/instance-admin.ts b/src/lib/routes/admin-api/instance-admin.ts
new file mode 100644
index 0000000000..48a7debdc9
--- /dev/null
+++ b/src/lib/routes/admin-api/instance-admin.ts
@@ -0,0 +1,84 @@
+import { Parser } from 'json2csv';
+import { Response } from 'express';
+import { AuthedRequest } from '../../types/core';
+import { IUnleashServices } from '../../types/services';
+import { IUnleashConfig } from '../../types/option';
+import Controller from '../controller';
+import { NONE } from '../../types/permissions';
+import { UiConfigSchema } from '../../openapi/spec/ui-config-schema';
+import {
+ InstanceStats,
+ InstanceStatsService,
+} from '../../services/instance-stats-service';
+import { OpenApiService } from '../../services/openapi-service';
+import { createResponseSchema } from '../../openapi/util/create-response-schema';
+
+class InstanceAdminController extends Controller {
+ private instanceStatsService: InstanceStatsService;
+
+ private openApiService: OpenApiService;
+
+ constructor(
+ config: IUnleashConfig,
+ {
+ instanceStatsService,
+ openApiService,
+ }: Pick,
+ ) {
+ super(config);
+
+ this.openApiService = openApiService;
+ this.instanceStatsService = instanceStatsService;
+
+ this.route({
+ method: 'get',
+ path: '/statistics/csv',
+ handler: this.getStatisticsCSV,
+ permission: NONE,
+ });
+
+ this.route({
+ method: 'get',
+ path: '/statistics',
+ handler: this.getStatistics,
+ permission: NONE,
+ middleware: [
+ openApiService.validPath({
+ tags: ['Instance Admin'],
+ operationId: 'getInstanceAdminStats',
+ responses: {
+ 200: createResponseSchema('instanceAdminStatsSchema'),
+ },
+ deprecated: true,
+ }),
+ ],
+ });
+ }
+
+ async getStatistics(
+ req: AuthedRequest,
+ res: Response,
+ ): Promise {
+ const instanceStats = await this.instanceStatsService.getSignedStats();
+ res.json(instanceStats);
+ }
+
+ async getStatisticsCSV(
+ req: AuthedRequest,
+ res: Response,
+ ): Promise {
+ const instanceStats = await this.instanceStatsService.getSignedStats();
+ const fileName = `unleash-${
+ instanceStats.instanceId
+ }-${Date.now()}.csv`;
+
+ const json2csvParser = new Parser();
+ const csv = json2csvParser.parse(instanceStats);
+
+ res.contentType('csv');
+ res.attachment(fileName);
+ res.send(csv);
+ }
+}
+
+export default InstanceAdminController;
diff --git a/src/lib/server-impl.ts b/src/lib/server-impl.ts
index 379389346c..514898c430 100644
--- a/src/lib/server-impl.ts
+++ b/src/lib/server-impl.ts
@@ -67,6 +67,7 @@ async function createApp(
stores,
serverVersion,
config.eventBus,
+ services.instanceStatsService,
db,
);
const unleash: Omit = {
diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts
index be559687a3..4dfb8ad960 100644
--- a/src/lib/services/index.ts
+++ b/src/lib/services/index.ts
@@ -36,6 +36,8 @@ import EdgeService from './edge-service';
import PatService from './pat-service';
import { PublicSignupTokenService } from './public-signup-token-service';
import { LastSeenService } from './client-metrics/last-seen-service';
+import { InstanceStatsService } from './instance-stats-service';
+
export const createServices = (
stores: IUnleashStores,
config: IUnleashConfig,
@@ -116,6 +118,12 @@ export const createServices = (
userService,
);
+ const instanceStatsService = new InstanceStatsService(
+ stores,
+ config,
+ versionService,
+ );
+
return {
accessService,
addonService,
@@ -154,6 +162,7 @@ export const createServices = (
patService,
publicSignupTokenService,
lastSeenService,
+ instanceStatsService,
};
};
diff --git a/src/lib/services/instance-stats-service.ts b/src/lib/services/instance-stats-service.ts
new file mode 100644
index 0000000000..b7b66267d6
--- /dev/null
+++ b/src/lib/services/instance-stats-service.ts
@@ -0,0 +1,185 @@
+import { sha256 } from 'js-sha256';
+import { Logger } from '../logger';
+import { IUnleashConfig } from '../types/option';
+import { IUnleashStores } from '../types/stores';
+import { IContextFieldStore } from '../types/stores/context-field-store';
+import { IEnvironmentStore } from '../types/stores/environment-store';
+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 { ISegmentStore } from '../types/stores/segment-store';
+import { IRoleStore } from '../types/stores/role-store';
+import VersionService from './version-service';
+import { ISettingStore } from '../types/stores/settings-store';
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export 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;
+ SAMLenabled: boolean;
+ OIDCenabled: boolean;
+}
+
+interface InstanceStatsSigned extends InstanceStats {
+ sum: string;
+}
+
+export class InstanceStatsService {
+ private logger: Logger;
+
+ private strategyStore: IStrategyStore;
+
+ private userStore: IUserStore;
+
+ private featureToggleStore: IFeatureToggleStore;
+
+ private contextFieldStore: IContextFieldStore;
+
+ private projectStore: IProjectStore;
+
+ private groupStore: IGroupStore;
+
+ private environmentStore: IEnvironmentStore;
+
+ private segmentStore: ISegmentStore;
+
+ private roleStore: IRoleStore;
+
+ private versionService: VersionService;
+
+ private settingStore: ISettingStore;
+
+ constructor(
+ {
+ featureToggleStore,
+ userStore,
+ projectStore,
+ environmentStore,
+ strategyStore,
+ contextFieldStore,
+ groupStore,
+ segmentStore,
+ roleStore,
+ settingStore,
+ }: Pick<
+ IUnleashStores,
+ | 'featureToggleStore'
+ | 'userStore'
+ | 'projectStore'
+ | 'environmentStore'
+ | 'strategyStore'
+ | 'contextFieldStore'
+ | 'groupStore'
+ | 'segmentStore'
+ | 'roleStore'
+ | 'settingStore'
+ >,
+ { getLogger }: Pick,
+ versionService: VersionService,
+ ) {
+ this.strategyStore = strategyStore;
+ this.userStore = userStore;
+ this.featureToggleStore = featureToggleStore;
+ this.environmentStore = environmentStore;
+ this.projectStore = projectStore;
+ this.groupStore = groupStore;
+ this.contextFieldStore = contextFieldStore;
+ this.segmentStore = segmentStore;
+ this.roleStore = roleStore;
+ this.versionService = versionService;
+ this.settingStore = settingStore;
+ this.logger = getLogger('services/stats-service.js');
+ }
+
+ async getToggleCount(): Promise {
+ return this.featureToggleStore.count({
+ archived: false,
+ });
+ }
+
+ async hasOIDC(): Promise {
+ const settings = await this.settingStore.get(
+ 'unleash.enterprise.auth.oidc',
+ );
+
+ return settings?.enabled || false;
+ }
+
+ async hasSAML(): Promise {
+ const settings = await this.settingStore.get(
+ 'unleash.enterprise.auth.saml',
+ );
+
+ return settings?.enabled || false;
+ }
+
+ async getStats(): Promise {
+ const versionInfo = this.versionService.getVersionInfo();
+
+ const [
+ featureToggles,
+ users,
+ projects,
+ contextFields,
+ groups,
+ roles,
+ environments,
+ segments,
+ strategies,
+ SAMLenabled,
+ OIDCenabled,
+ ] = await Promise.all([
+ this.getToggleCount(),
+ this.userStore.count(),
+ this.projectStore.count(),
+ this.contextFieldStore.count(),
+ this.groupStore.count(),
+ this.roleStore.count(),
+ this.environmentStore.count(),
+ this.segmentStore.count(),
+ this.strategyStore.count(),
+ this.hasSAML(),
+ this.hasOIDC(),
+ ]);
+
+ return {
+ timestamp: new Date(),
+ instanceId: versionInfo.instanceId,
+ versionOSS: versionInfo.current.oss,
+ versionEnterprise: versionInfo.current.enterprise,
+ users,
+ featureToggles,
+ projects,
+ contextFields,
+ roles,
+ groups,
+ environments,
+ segments,
+ strategies,
+ SAMLenabled,
+ OIDCenabled,
+ };
+ }
+
+ async getSignedStats(): Promise {
+ const instanceStats = await this.getStats();
+
+ const sum = sha256(
+ `${instanceStats.instanceId}${instanceStats.users}${instanceStats.featureToggles}${instanceStats.projects}${instanceStats.roles}${instanceStats.groups}${instanceStats.environments}${instanceStats.segments}`,
+ );
+ return { ...instanceStats, sum };
+ }
+}
diff --git a/src/lib/types/option.ts b/src/lib/types/option.ts
index ff62a08134..e4804e6ff8 100644
--- a/src/lib/types/option.ts
+++ b/src/lib/types/option.ts
@@ -139,6 +139,7 @@ export interface IListeningHost {
}
export interface IUIConfig {
+ environment?: string;
slogan?: string;
name?: string;
links?: [
diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts
index b2c7da1ee0..5c2f32e020 100644
--- a/src/lib/types/services.ts
+++ b/src/lib/types/services.ts
@@ -34,6 +34,7 @@ import EdgeService from '../services/edge-service';
import PatService from '../services/pat-service';
import { PublicSignupTokenService } from '../services/public-signup-token-service';
import { LastSeenService } from '../services/client-metrics/last-seen-service';
+import { InstanceStatsService } from '../services/instance-stats-service';
export interface IUnleashServices {
accessService: AccessService;
@@ -73,4 +74,5 @@ export interface IUnleashServices {
clientSpecService: ClientSpecService;
patService: PatService;
lastSeenService: LastSeenService;
+ instanceStatsService: InstanceStatsService;
}
diff --git a/src/lib/types/stores/context-field-store.ts b/src/lib/types/stores/context-field-store.ts
index b36295a7b6..546cc934a2 100644
--- a/src/lib/types/stores/context-field-store.ts
+++ b/src/lib/types/stores/context-field-store.ts
@@ -20,4 +20,5 @@ export interface IContextField extends IContextFieldDto {
export interface IContextFieldStore extends Store {
create(data: IContextFieldDto): Promise;
update(data: IContextFieldDto): Promise;
+ count(): Promise;
}
diff --git a/src/lib/types/stores/group-store.ts b/src/lib/types/stores/group-store.ts
index 060f357c0e..c21acf90fc 100644
--- a/src/lib/types/stores/group-store.ts
+++ b/src/lib/types/stores/group-store.ts
@@ -58,4 +58,6 @@ export interface IGroupStore extends Store {
existsWithName(name: string): Promise;
create(group: IStoreGroup): Promise;
+
+ count(): Promise;
}
diff --git a/src/lib/types/stores/role-store.ts b/src/lib/types/stores/role-store.ts
index ffa508269a..f4e0504ea4 100644
--- a/src/lib/types/stores/role-store.ts
+++ b/src/lib/types/stores/role-store.ts
@@ -28,4 +28,5 @@ export interface IRoleStore extends Store {
getRootRoles(): Promise;
getRootRoleForAllUsers(): Promise;
nameInUse(name: string, existingId: number): Promise;
+ count(): Promise;
}
diff --git a/src/lib/types/stores/segment-store.ts b/src/lib/types/stores/segment-store.ts
index aa56aec213..c28185140d 100644
--- a/src/lib/types/stores/segment-store.ts
+++ b/src/lib/types/stores/segment-store.ts
@@ -25,4 +25,6 @@ export interface ISegmentStore extends Store {
getAllFeatureStrategySegments(): Promise;
existsByName(name: string): Promise;
+
+ count(): Promise;
}
diff --git a/src/lib/types/stores/strategy-store.ts b/src/lib/types/stores/strategy-store.ts
index 583a7244cc..b07ea8bc02 100644
--- a/src/lib/types/stores/strategy-store.ts
+++ b/src/lib/types/stores/strategy-store.ts
@@ -38,4 +38,5 @@ export interface IStrategyStore extends Store {
reactivateStrategy({ name }: Pick): Promise;
importStrategy(data: IMinimalStrategy): Promise;
dropCustomStrategies(): Promise;
+ count(): Promise;
}
diff --git a/src/test/e2e/api/admin/instance-admin.e2e.test.ts b/src/test/e2e/api/admin/instance-admin.e2e.test.ts
new file mode 100644
index 0000000000..9eba75481e
--- /dev/null
+++ b/src/test/e2e/api/admin/instance-admin.e2e.test.ts
@@ -0,0 +1,74 @@
+import dbInit from '../../helpers/database-init';
+import { setupApp } from '../../helpers/test-helper';
+import getLogger from '../../../fixtures/no-logger';
+import { IUnleashStores } from '../../../../lib/types';
+
+let app;
+let db;
+let stores: IUnleashStores;
+
+beforeAll(async () => {
+ db = await dbInit('instance_admin_api_serial', getLogger);
+ stores = db.stores;
+ await stores.settingStore.insert('instanceInfo', { id: 'test-static' });
+ app = await setupApp(stores);
+});
+
+afterAll(async () => {
+ await app.destroy();
+ await db.destroy();
+});
+
+test('should return instance statistics', async () => {
+ stores.featureToggleStore.create('default', { name: 'TestStats1' });
+
+ return app.request
+ .get('/api/admin/instance-admin/statistics')
+ .expect('Content-Type', /json/)
+ .expect(200)
+ .expect((res) => {
+ expect(res.body.featureToggles).toBe(1);
+ });
+});
+
+test('should return instance statistics with correct number of projects', async () => {
+ stores.projectStore.create({
+ id: 'test',
+ name: 'Test',
+ description: 'lorem',
+ });
+
+ return app.request
+ .get('/api/admin/instance-admin/statistics')
+ .expect('Content-Type', /json/)
+ .expect(200)
+ .expect((res) => {
+ expect(res.body.projects).toBe(2);
+ });
+});
+
+test('should return signed instance statistics', async () => {
+ return app.request
+ .get('/api/admin/instance-admin/statistics')
+ .expect('Content-Type', /json/)
+ .expect(200)
+ .expect((res) => {
+ expect(res.body.instanceId).toBe('test-static');
+ expect(res.body.sum).toBe(
+ '5ba2cb7c3e29f4e5b3382c560b92b837f3603dc7db73a501ec331c7f0ed17bd0',
+ );
+ });
+});
+
+test('should return instance statistics as CVS', async () => {
+ stores.featureToggleStore.create('default', { name: 'TestStats2' });
+ stores.featureToggleStore.create('default', { name: 'TestStats3' });
+
+ const res = await app.request
+ .get('/api/admin/instance-admin/statistics/csv')
+ .expect('Content-Type', /text\/csv/)
+ .expect(200);
+
+ expect(res.text).toMatch(/featureToggles/);
+ expect(res.text).toMatch(/"sum"/);
+});
diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
index 1ed06f43d7..3adb52d20b 100644
--- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
+++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
@@ -1573,6 +1573,65 @@ exports[`should serve the OpenAPI spec 1`] = `
],
"type": "object",
},
+ "instanceAdminStatsSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "OIDCenabled": {
+ "type": "number",
+ },
+ "SAMLenabled": {
+ "type": "number",
+ },
+ "contextFields": {
+ "type": "number",
+ },
+ "environments": {
+ "type": "number",
+ },
+ "featureToggles": {
+ "type": "number",
+ },
+ "groups": {
+ "type": "number",
+ },
+ "instanceId": {
+ "type": "string",
+ },
+ "projects": {
+ "type": "number",
+ },
+ "roles": {
+ "type": "number",
+ },
+ "segments": {
+ "type": "number",
+ },
+ "strategies": {
+ "type": "number",
+ },
+ "sum": {
+ "type": "string",
+ },
+ "timestamp": {
+ "format": "date-time",
+ "nullable": true,
+ "type": "string",
+ },
+ "users": {
+ "type": "number",
+ },
+ "versionEnterprise": {
+ "type": "string",
+ },
+ "versionOSS": {
+ "type": "string",
+ },
+ },
+ "required": [
+ "instanceId",
+ ],
+ "type": "object",
+ },
"legalValueSchema": {
"additionalProperties": false,
"properties": {
@@ -4583,6 +4642,27 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
+ "/api/admin/instance-admin/statistics": {
+ "get": {
+ "deprecated": true,
+ "operationId": "getInstanceAdminStats",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/instanceAdminStatsSchema",
+ },
+ },
+ },
+ "description": "instanceAdminStatsSchema",
+ },
+ },
+ "tags": [
+ "Instance Admin",
+ ],
+ },
+ },
"/api/admin/invite-link/tokens": {
"get": {
"operationId": "getAllPublicSignupTokens",
@@ -7589,6 +7669,10 @@ If the provided project does not exist, the list of events will be empty.",
"description": "[Import and export](https://docs.getunleash.io/deploy/import_export) the state of your Unleash instance.",
"name": "Import/Export",
},
+ {
+ "description": "Instance admin endpoints used to manage the Unleash instance itself.",
+ "name": "Instance Admin",
+ },
{
"description": "Register, read, or delete metrics recorded by Unleash.",
"name": "Metrics",
diff --git a/src/test/fixtures/fake-context-field-store.ts b/src/test/fixtures/fake-context-field-store.ts
index 6dc691b6bf..74314417e7 100644
--- a/src/test/fixtures/fake-context-field-store.ts
+++ b/src/test/fixtures/fake-context-field-store.ts
@@ -6,6 +6,10 @@ import {
import NotFoundError from '../../lib/error/notfound-error';
export default class FakeContextFieldStore implements IContextFieldStore {
+ count(): Promise {
+ return Promise.resolve(0);
+ }
+
defaultContextFields: IContextField[] = [
{
name: 'environment',
diff --git a/src/test/fixtures/fake-group-store.ts b/src/test/fixtures/fake-group-store.ts
index 3395e6d0b6..47473187eb 100644
--- a/src/test/fixtures/fake-group-store.ts
+++ b/src/test/fixtures/fake-group-store.ts
@@ -9,6 +9,10 @@ import Group, {
} from '../../lib/types/group';
/* eslint-disable @typescript-eslint/no-unused-vars */
export default class FakeGroupStore implements IGroupStore {
+ count(): Promise {
+ return Promise.resolve(0);
+ }
+
data: IGroup[];
async getAll(): Promise {
diff --git a/src/test/fixtures/fake-role-store.ts b/src/test/fixtures/fake-role-store.ts
index 4165644a62..f021608092 100644
--- a/src/test/fixtures/fake-role-store.ts
+++ b/src/test/fixtures/fake-role-store.ts
@@ -8,6 +8,10 @@ import {
} from 'lib/types/stores/role-store';
export default class FakeRoleStore implements IRoleStore {
+ count(): Promise {
+ return Promise.resolve(0);
+ }
+
roles: ICustomRole[] = [];
getGroupRolesForProject(projectId: string): Promise {
diff --git a/src/test/fixtures/fake-segment-store.ts b/src/test/fixtures/fake-segment-store.ts
index ee810e2f34..620c767ca3 100644
--- a/src/test/fixtures/fake-segment-store.ts
+++ b/src/test/fixtures/fake-segment-store.ts
@@ -2,6 +2,10 @@ import { ISegmentStore } from '../../lib/types/stores/segment-store';
import { IFeatureStrategySegment, ISegment } from '../../lib/types/model';
export default class FakeSegmentStore implements ISegmentStore {
+ count(): Promise {
+ return Promise.resolve(0);
+ }
+
create(): Promise {
throw new Error('Method not implemented.');
}
diff --git a/src/test/fixtures/fake-strategies-store.ts b/src/test/fixtures/fake-strategies-store.ts
index 7a5e416272..307a4f206f 100644
--- a/src/test/fixtures/fake-strategies-store.ts
+++ b/src/test/fixtures/fake-strategies-store.ts
@@ -7,6 +7,10 @@ import {
import NotFoundError from '../../lib/error/notfound-error';
export default class FakeStrategiesStore implements IStrategyStore {
+ count(): Promise {
+ return Promise.resolve(0);
+ }
+
defaultStrategy: IStrategy = {
name: 'default',
description: 'default strategy',
diff --git a/yarn.lock b/yarn.lock
index 55f86e1c89..c6c911c399 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2490,6 +2490,11 @@ commander@^2.20.3:
resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+commander@^6.1.0:
+ version "6.2.1"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
+ integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
+
commander@^9.1.0:
version "9.2.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-9.2.0.tgz#6e21014b2ed90d8b7c9647230d8b7a94a4a419a9"
@@ -4859,6 +4864,11 @@ js-sdsl@^4.1.4:
resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.1.5.tgz#1ff1645e6b4d1b028cd3f862db88c9d887f26e2a"
integrity sha512-08bOAKweV2NUC1wqTtf3qZlnpOX/R2DU9ikpjOHs0H+ibQv3zpncVQg6um4uYtRtrwIX8M4Nh3ytK4HGlYAq7Q==
+js-sha256@^0.9.0:
+ version "0.9.0"
+ resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.9.0.tgz#0b89ac166583e91ef9123644bd3c5334ce9d0966"
+ integrity sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==
+
js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"
@@ -4928,6 +4938,15 @@ json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1:
resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz"
integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
+json2csv@^5.0.7:
+ version "5.0.7"
+ resolved "https://registry.yarnpkg.com/json2csv/-/json2csv-5.0.7.tgz#f3a583c25abd9804be873e495d1e65ad8d1b54ae"
+ integrity sha512-YRZbUnyaJZLZUJSRi2G/MqahCyRv9n/ds+4oIetjDF3jWQA7AG7iSeKTiZiCNqtMZM7HDyt0e/W6lEnoGEmMGA==
+ dependencies:
+ commander "^6.1.0"
+ jsonparse "^1.3.1"
+ lodash.get "^4.4.2"
+
json5@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz"
@@ -4954,6 +4973,11 @@ jsonfile@^6.0.1:
optionalDependencies:
graceful-fs "^4.1.6"
+jsonparse@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
+ integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==
+
jsprim@^1.2.2:
version "1.4.1"
resolved "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz"