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} + + ))} + +
+ + + +
+
+ ); +}; 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"