mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-15 17:50:48 +02:00
Feat/stats service (#2211)
Introduces an instance stats service exposing usage metrics of the Unleash installation.
This commit is contained in:
parent
1bdd516fc7
commit
cf4fc2303b
@ -0,0 +1,11 @@
|
|||||||
|
import AdminMenu from '../menu/AdminMenu';
|
||||||
|
import { InstanceStats } from './InstanceStats/InstanceStats';
|
||||||
|
|
||||||
|
export const InstanceAdmin = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminMenu />
|
||||||
|
<InstanceStats />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -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 (
|
||||||
|
<PageContent header={<PageHeader title="Instance Statistics" />}>
|
||||||
|
<Box sx={{ display: 'grid', gap: 4 }}>
|
||||||
|
<Table aria-label="Instance statistics">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Field</TableCell>
|
||||||
|
<TableCell align="right">Value</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map(row => (
|
||||||
|
<TableRow key={row.title}>
|
||||||
|
<TableCell component="th" scope="row">
|
||||||
|
{row.title}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">{row.value}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<span style={{ textAlign: 'center' }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<Download />}
|
||||||
|
aria-label="Download instance statistics"
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
target="_blank"
|
||||||
|
href={formatApiPath(
|
||||||
|
'/api/admin/instance-admin/statistics/csv'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Box>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
};
|
@ -135,6 +135,19 @@ function AdminMenu() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Tab
|
||||||
|
value="/admin/instance"
|
||||||
|
label={
|
||||||
|
<NavLink
|
||||||
|
to="/admin/instance"
|
||||||
|
style={({ isActive }) =>
|
||||||
|
createNavLinkStyle({ isActive, theme })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Instance stats
|
||||||
|
</NavLink>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{isBilling && (
|
{isBilling && (
|
||||||
<Tab
|
<Tab
|
||||||
value="/admin/billing"
|
value="/admin/billing"
|
||||||
|
@ -454,6 +454,16 @@ exports[`returns all baseRoutes 1`] = `
|
|||||||
"title": "Single sign-on",
|
"title": "Single sign-on",
|
||||||
"type": "protected",
|
"type": "protected",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"component": [Function],
|
||||||
|
"menu": {
|
||||||
|
"adminSettings": true,
|
||||||
|
},
|
||||||
|
"parent": "/admin",
|
||||||
|
"path": "/admin/instance",
|
||||||
|
"title": "Instance stats",
|
||||||
|
"type": "protected",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"component": [Function],
|
"component": [Function],
|
||||||
"flag": "embedProxyFrontend",
|
"flag": "embedProxyFrontend",
|
||||||
|
@ -59,6 +59,7 @@ import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
|
|||||||
import { CorsAdmin } from 'component/admin/cors';
|
import { CorsAdmin } from 'component/admin/cors';
|
||||||
import { InviteLink } from 'component/admin/users/InviteLink/InviteLink';
|
import { InviteLink } from 'component/admin/users/InviteLink/InviteLink';
|
||||||
import { Profile } from 'component/user/Profile/Profile';
|
import { Profile } from 'component/user/Profile/Profile';
|
||||||
|
import { InstanceAdmin } from '../admin/instance-admin/InstanceAdmin';
|
||||||
|
|
||||||
export const routes: IRoute[] = [
|
export const routes: IRoute[] = [
|
||||||
// Splash
|
// Splash
|
||||||
@ -499,6 +500,14 @@ export const routes: IRoute[] = [
|
|||||||
type: 'protected',
|
type: 'protected',
|
||||||
menu: { adminSettings: true },
|
menu: { adminSettings: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/instance',
|
||||||
|
parent: '/admin',
|
||||||
|
title: 'Instance stats',
|
||||||
|
component: InstanceAdmin,
|
||||||
|
type: 'protected',
|
||||||
|
menu: { adminSettings: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/cors',
|
path: '/admin/cors',
|
||||||
parent: '/admin',
|
parent: '/admin',
|
||||||
|
@ -0,0 +1,52 @@
|
|||||||
|
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;
|
||||||
|
SAMLenabled: boolean;
|
||||||
|
OIDCenabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IInstanceStatsResponse {
|
||||||
|
stats?: InstanceStats;
|
||||||
|
refetchGroup: () => 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());
|
||||||
|
};
|
@ -107,8 +107,10 @@
|
|||||||
"helmet": "^5.0.0",
|
"helmet": "^5.0.0",
|
||||||
"ip": "^1.1.8",
|
"ip": "^1.1.8",
|
||||||
"joi": "^17.3.0",
|
"joi": "^17.3.0",
|
||||||
|
"js-sha256": "^0.9.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"json-schema-to-ts": "2.5.5",
|
"json-schema-to-ts": "2.5.5",
|
||||||
|
"json2csv": "^5.0.7",
|
||||||
"knex": "^2.0.0",
|
"knex": "^2.0.0",
|
||||||
"log4js": "^6.0.0",
|
"log4js": "^6.0.0",
|
||||||
"make-fetch-happen": "^10.1.2",
|
"make-fetch-happen": "^10.1.2",
|
||||||
|
@ -141,6 +141,7 @@ exports[`should create default config 1`] = `
|
|||||||
},
|
},
|
||||||
"strategySegmentsLimit": 5,
|
"strategySegmentsLimit": 5,
|
||||||
"ui": {
|
"ui": {
|
||||||
|
"environment": "Open Source",
|
||||||
"flags": {
|
"flags": {
|
||||||
"E": true,
|
"E": true,
|
||||||
"ENABLE_DARK_MODE_SUPPORT": false,
|
"ENABLE_DARK_MODE_SUPPORT": false,
|
||||||
|
@ -100,7 +100,9 @@ function loadClientCachingOptions(
|
|||||||
|
|
||||||
function loadUI(options: IUnleashOptions): IUIConfig {
|
function loadUI(options: IUnleashOptions): IUIConfig {
|
||||||
const uiO = options.ui || {};
|
const uiO = options.ui || {};
|
||||||
const ui: IUIConfig = {};
|
const ui: IUIConfig = {
|
||||||
|
environment: 'Open Source',
|
||||||
|
};
|
||||||
|
|
||||||
ui.flags = {
|
ui.flags = {
|
||||||
E: true,
|
E: true,
|
||||||
|
@ -119,5 +119,11 @@ class ContextFieldStore implements IContextFieldStore {
|
|||||||
async delete(name: string): Promise<void> {
|
async delete(name: string): Promise<void> {
|
||||||
return this.db(TABLE).where({ name }).del();
|
return this.db(TABLE).where({ name }).del();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async count(): Promise<number> {
|
||||||
|
return this.db(TABLE)
|
||||||
|
.count('*')
|
||||||
|
.then((res) => Number(res[0].count));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export default ContextFieldStore;
|
export default ContextFieldStore;
|
||||||
|
@ -175,6 +175,12 @@ export default class GroupStore implements IGroupStore {
|
|||||||
return rowToGroup(row[0]);
|
return rowToGroup(row[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async count(): Promise<number> {
|
||||||
|
return this.db(T.GROUPS)
|
||||||
|
.count('*')
|
||||||
|
.then((res) => Number(res[0].count));
|
||||||
|
}
|
||||||
|
|
||||||
async addUsersToGroup(
|
async addUsersToGroup(
|
||||||
groupId: number,
|
groupId: number,
|
||||||
users: IGroupUserModel[],
|
users: IGroupUserModel[],
|
||||||
|
@ -47,6 +47,13 @@ export default class RoleStore implements IRoleStore {
|
|||||||
return rows.map(this.mapRow);
|
return rows.map(this.mapRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async count(): Promise<number> {
|
||||||
|
return this.db
|
||||||
|
.from(T.ROLES)
|
||||||
|
.count('*')
|
||||||
|
.then((res) => Number(res[0].count));
|
||||||
|
}
|
||||||
|
|
||||||
async create(role: ICustomRoleInsert): Promise<ICustomRole> {
|
async create(role: ICustomRoleInsert): Promise<ICustomRole> {
|
||||||
const row = await this.db(T.ROLES)
|
const row = await this.db(T.ROLES)
|
||||||
.insert({
|
.insert({
|
||||||
|
@ -50,6 +50,13 @@ export default class SegmentStore implements ISegmentStore {
|
|||||||
this.logger = getLogger('lib/db/segment-store.ts');
|
this.logger = getLogger('lib/db/segment-store.ts');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async count(): Promise<number> {
|
||||||
|
return this.db
|
||||||
|
.from(T.segments)
|
||||||
|
.count('*')
|
||||||
|
.then((res) => Number(res[0].count));
|
||||||
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
segment: PartialSome<ISegment, 'id'>,
|
segment: PartialSome<ISegment, 'id'>,
|
||||||
user: Partial<Pick<User, 'username' | 'email'>>,
|
user: Partial<Pick<User, 'username' | 'email'>>,
|
||||||
|
@ -74,6 +74,13 @@ export default class StrategyStore implements IStrategyStore {
|
|||||||
await this.db(TABLE).del();
|
await this.db(TABLE).del();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async count(): Promise<number> {
|
||||||
|
return this.db
|
||||||
|
.from(TABLE)
|
||||||
|
.count('*')
|
||||||
|
.then((res) => Number(res[0].count));
|
||||||
|
}
|
||||||
|
|
||||||
destroy(): void {}
|
destroy(): void {}
|
||||||
|
|
||||||
async exists(name: string): Promise<boolean> {
|
async exists(name: string): Promise<boolean> {
|
||||||
|
@ -10,11 +10,14 @@ import {
|
|||||||
} from './types/events';
|
} from './types/events';
|
||||||
import { createMetricsMonitor } from './metrics';
|
import { createMetricsMonitor } from './metrics';
|
||||||
import createStores from '../test/fixtures/store';
|
import createStores from '../test/fixtures/store';
|
||||||
|
import { InstanceStatsService } from './services/instance-stats-service';
|
||||||
|
import VersionService from './services/version-service';
|
||||||
|
|
||||||
const monitor = createMetricsMonitor();
|
const monitor = createMetricsMonitor();
|
||||||
const eventBus = new EventEmitter();
|
const eventBus = new EventEmitter();
|
||||||
const prometheusRegister = register;
|
const prometheusRegister = register;
|
||||||
let eventStore: IEventStore;
|
let eventStore: IEventStore;
|
||||||
|
let statsService: InstanceStatsService;
|
||||||
let stores;
|
let stores;
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
const config = createTestConfig({
|
const config = createTestConfig({
|
||||||
@ -24,6 +27,8 @@ beforeAll(() => {
|
|||||||
});
|
});
|
||||||
stores = createStores();
|
stores = createStores();
|
||||||
eventStore = stores.eventStore;
|
eventStore = stores.eventStore;
|
||||||
|
const versionService = new VersionService(stores, config);
|
||||||
|
statsService = new InstanceStatsService(stores, config, versionService);
|
||||||
const db = {
|
const db = {
|
||||||
client: {
|
client: {
|
||||||
pool: {
|
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.
|
// @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(() => {
|
afterAll(() => {
|
||||||
monitor.stopMonitoring();
|
monitor.stopMonitoring();
|
||||||
@ -102,6 +115,9 @@ test('should collect metrics for db query timings', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should collect metrics for feature toggle size', async () => {
|
test('should collect metrics for feature toggle size', async () => {
|
||||||
|
await new Promise((done) => {
|
||||||
|
setTimeout(done, 10);
|
||||||
|
});
|
||||||
const metrics = await prometheusRegister.metrics();
|
const metrics = await prometheusRegister.metrics();
|
||||||
expect(metrics).toMatch(/feature_toggles_total{version="(.*)"} 0/);
|
expect(metrics).toMatch(/feature_toggles_total{version="(.*)"} 0/);
|
||||||
});
|
});
|
||||||
|
@ -22,6 +22,7 @@ import { IUnleashConfig } from './types/option';
|
|||||||
import { IUnleashStores } from './types/stores';
|
import { IUnleashStores } from './types/stores';
|
||||||
import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns';
|
import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns';
|
||||||
import Timer = NodeJS.Timer;
|
import Timer = NodeJS.Timer;
|
||||||
|
import { InstanceStatsService } from './services/instance-stats-service';
|
||||||
|
|
||||||
export default class MetricsMonitor {
|
export default class MetricsMonitor {
|
||||||
timer?: Timer;
|
timer?: Timer;
|
||||||
@ -38,19 +39,14 @@ export default class MetricsMonitor {
|
|||||||
stores: IUnleashStores,
|
stores: IUnleashStores,
|
||||||
version: string,
|
version: string,
|
||||||
eventBus: EventEmitter,
|
eventBus: EventEmitter,
|
||||||
|
instanceStatsService: InstanceStatsService,
|
||||||
db: Knex,
|
db: Knex,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!config.server.serverMetrics) {
|
if (!config.server.serverMetrics) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { eventStore } = stores;
|
||||||
eventStore,
|
|
||||||
featureToggleStore,
|
|
||||||
userStore,
|
|
||||||
projectStore,
|
|
||||||
environmentStore,
|
|
||||||
} = stores;
|
|
||||||
|
|
||||||
client.collectDefaultMetrics();
|
client.collectDefaultMetrics();
|
||||||
|
|
||||||
@ -97,6 +93,40 @@ export default class MetricsMonitor {
|
|||||||
name: 'environments_total',
|
name: 'environments_total',
|
||||||
help: 'Number of environments',
|
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({
|
const clientSdkVersionUsage = new client.Counter({
|
||||||
name: 'client_sdk_versions',
|
name: 'client_sdk_versions',
|
||||||
@ -105,41 +135,51 @@ export default class MetricsMonitor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function collectStaticCounters() {
|
async function collectStaticCounters() {
|
||||||
let togglesCount: number = 0;
|
|
||||||
let usersCount: number;
|
|
||||||
let projectsCount: number;
|
|
||||||
let environmentsCount: number;
|
|
||||||
try {
|
try {
|
||||||
togglesCount = await featureToggleStore.count({
|
const stats = await instanceStatsService.getStats();
|
||||||
archived: false,
|
|
||||||
});
|
|
||||||
usersCount = await userStore.count();
|
|
||||||
projectsCount = await projectStore.count();
|
|
||||||
environmentsCount = await environmentStore.count();
|
|
||||||
// eslint-disable-next-line no-empty
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
featureTogglesTotal.reset();
|
featureTogglesTotal.reset();
|
||||||
featureTogglesTotal.labels(version).set(togglesCount);
|
featureTogglesTotal.labels(version).set(stats.featureToggles);
|
||||||
if (usersCount) {
|
|
||||||
usersTotal.reset();
|
usersTotal.reset();
|
||||||
usersTotal.set(usersCount);
|
usersTotal.set(stats.users);
|
||||||
}
|
|
||||||
if (projectsCount) {
|
|
||||||
projectsTotal.reset();
|
projectsTotal.reset();
|
||||||
projectsTotal.set(projectsCount);
|
projectsTotal.set(stats.projects);
|
||||||
}
|
|
||||||
if (environmentsCount) {
|
|
||||||
environmentsTotal.reset();
|
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) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
process.nextTick(() => {
|
||||||
collectStaticCounters();
|
collectStaticCounters();
|
||||||
this.timer = setInterval(
|
this.timer = setInterval(
|
||||||
() => collectStaticCounters(),
|
() => collectStaticCounters(),
|
||||||
hoursToMilliseconds(2),
|
hoursToMilliseconds(2),
|
||||||
).unref();
|
).unref();
|
||||||
|
});
|
||||||
|
|
||||||
eventBus.on(
|
eventBus.on(
|
||||||
events.REQUEST_TIME,
|
events.REQUEST_TIME,
|
||||||
|
@ -121,6 +121,7 @@ import { validateTagTypeSchema } from './spec/validate-tag-type-schema';
|
|||||||
import { variantSchema } from './spec/variant-schema';
|
import { variantSchema } from './spec/variant-schema';
|
||||||
import { variantsSchema } from './spec/variants-schema';
|
import { variantsSchema } from './spec/variants-schema';
|
||||||
import { versionSchema } from './spec/version-schema';
|
import { versionSchema } from './spec/version-schema';
|
||||||
|
import { instanceAdminStatsSchema } from './spec/instance-admin-stats-schema';
|
||||||
import apiVersion from '../util/version';
|
import apiVersion from '../util/version';
|
||||||
|
|
||||||
// All schemas in `openapi/spec` should be listed here.
|
// All schemas in `openapi/spec` should be listed here.
|
||||||
@ -176,6 +177,7 @@ export const schemas = {
|
|||||||
healthOverviewSchema,
|
healthOverviewSchema,
|
||||||
healthReportSchema,
|
healthReportSchema,
|
||||||
idSchema,
|
idSchema,
|
||||||
|
instanceAdminStatsSchema,
|
||||||
legalValueSchema,
|
legalValueSchema,
|
||||||
loginSchema,
|
loginSchema,
|
||||||
meSchema,
|
meSchema,
|
||||||
|
13
src/lib/openapi/spec/instance-admin-stats-schema.test.ts
Normal file
13
src/lib/openapi/spec/instance-admin-stats-schema.test.ts
Normal file
@ -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();
|
||||||
|
});
|
65
src/lib/openapi/spec/instance-admin-stats-schema.ts
Normal file
65
src/lib/openapi/spec/instance-admin-stats-schema.ts
Normal file
@ -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
|
||||||
|
>;
|
@ -45,6 +45,11 @@ const OPENAPI_TAGS = [
|
|||||||
description:
|
description:
|
||||||
'[Import and export](https://docs.getunleash.io/deploy/import_export) the state of your Unleash instance.',
|
'[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',
|
name: 'Metrics',
|
||||||
description: 'Register, read, or delete metrics recorded by Unleash.',
|
description: 'Register, read, or delete metrics recorded by Unleash.',
|
||||||
|
@ -26,6 +26,7 @@ import ConstraintsController from './constraints';
|
|||||||
import PatController from './user/pat';
|
import PatController from './user/pat';
|
||||||
import { PublicSignupController } from './public-signup';
|
import { PublicSignupController } from './public-signup';
|
||||||
import { conditionalMiddleware } from '../../middleware/conditional-middleware';
|
import { conditionalMiddleware } from '../../middleware/conditional-middleware';
|
||||||
|
import InstanceAdminController from './instance-admin';
|
||||||
|
|
||||||
class AdminApi extends Controller {
|
class AdminApi extends Controller {
|
||||||
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
||||||
@ -114,6 +115,10 @@ class AdminApi extends Controller {
|
|||||||
new PublicSignupController(config, services).router,
|
new PublicSignupController(config, services).router,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
this.app.use(
|
||||||
|
'/instance-admin',
|
||||||
|
new InstanceAdminController(config, services).router,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
84
src/lib/routes/admin-api/instance-admin.ts
Normal file
84
src/lib/routes/admin-api/instance-admin.ts
Normal file
@ -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<IUnleashServices, 'instanceStatsService' | 'openApiService'>,
|
||||||
|
) {
|
||||||
|
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<InstanceStats>,
|
||||||
|
): Promise<void> {
|
||||||
|
const instanceStats = await this.instanceStatsService.getSignedStats();
|
||||||
|
res.json(instanceStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatisticsCSV(
|
||||||
|
req: AuthedRequest,
|
||||||
|
res: Response<UiConfigSchema>,
|
||||||
|
): Promise<void> {
|
||||||
|
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;
|
@ -67,6 +67,7 @@ async function createApp(
|
|||||||
stores,
|
stores,
|
||||||
serverVersion,
|
serverVersion,
|
||||||
config.eventBus,
|
config.eventBus,
|
||||||
|
services.instanceStatsService,
|
||||||
db,
|
db,
|
||||||
);
|
);
|
||||||
const unleash: Omit<IUnleash, 'stop'> = {
|
const unleash: Omit<IUnleash, 'stop'> = {
|
||||||
|
@ -36,6 +36,8 @@ import EdgeService from './edge-service';
|
|||||||
import PatService from './pat-service';
|
import PatService from './pat-service';
|
||||||
import { PublicSignupTokenService } from './public-signup-token-service';
|
import { PublicSignupTokenService } from './public-signup-token-service';
|
||||||
import { LastSeenService } from './client-metrics/last-seen-service';
|
import { LastSeenService } from './client-metrics/last-seen-service';
|
||||||
|
import { InstanceStatsService } from './instance-stats-service';
|
||||||
|
|
||||||
export const createServices = (
|
export const createServices = (
|
||||||
stores: IUnleashStores,
|
stores: IUnleashStores,
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
@ -116,6 +118,12 @@ export const createServices = (
|
|||||||
userService,
|
userService,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const instanceStatsService = new InstanceStatsService(
|
||||||
|
stores,
|
||||||
|
config,
|
||||||
|
versionService,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessService,
|
accessService,
|
||||||
addonService,
|
addonService,
|
||||||
@ -154,6 +162,7 @@ export const createServices = (
|
|||||||
patService,
|
patService,
|
||||||
publicSignupTokenService,
|
publicSignupTokenService,
|
||||||
lastSeenService,
|
lastSeenService,
|
||||||
|
instanceStatsService,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
185
src/lib/services/instance-stats-service.ts
Normal file
185
src/lib/services/instance-stats-service.ts
Normal file
@ -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<IUnleashConfig, 'getLogger'>,
|
||||||
|
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<number> {
|
||||||
|
return this.featureToggleStore.count({
|
||||||
|
archived: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasOIDC(): Promise<boolean> {
|
||||||
|
const settings = await this.settingStore.get(
|
||||||
|
'unleash.enterprise.auth.oidc',
|
||||||
|
);
|
||||||
|
|
||||||
|
return settings?.enabled || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasSAML(): Promise<boolean> {
|
||||||
|
const settings = await this.settingStore.get(
|
||||||
|
'unleash.enterprise.auth.saml',
|
||||||
|
);
|
||||||
|
|
||||||
|
return settings?.enabled || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats(): Promise<InstanceStats> {
|
||||||
|
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<InstanceStatsSigned> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
@ -139,6 +139,7 @@ export interface IListeningHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IUIConfig {
|
export interface IUIConfig {
|
||||||
|
environment?: string;
|
||||||
slogan?: string;
|
slogan?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
links?: [
|
links?: [
|
||||||
|
@ -34,6 +34,7 @@ import EdgeService from '../services/edge-service';
|
|||||||
import PatService from '../services/pat-service';
|
import PatService from '../services/pat-service';
|
||||||
import { PublicSignupTokenService } from '../services/public-signup-token-service';
|
import { PublicSignupTokenService } from '../services/public-signup-token-service';
|
||||||
import { LastSeenService } from '../services/client-metrics/last-seen-service';
|
import { LastSeenService } from '../services/client-metrics/last-seen-service';
|
||||||
|
import { InstanceStatsService } from '../services/instance-stats-service';
|
||||||
|
|
||||||
export interface IUnleashServices {
|
export interface IUnleashServices {
|
||||||
accessService: AccessService;
|
accessService: AccessService;
|
||||||
@ -73,4 +74,5 @@ export interface IUnleashServices {
|
|||||||
clientSpecService: ClientSpecService;
|
clientSpecService: ClientSpecService;
|
||||||
patService: PatService;
|
patService: PatService;
|
||||||
lastSeenService: LastSeenService;
|
lastSeenService: LastSeenService;
|
||||||
|
instanceStatsService: InstanceStatsService;
|
||||||
}
|
}
|
||||||
|
@ -20,4 +20,5 @@ export interface IContextField extends IContextFieldDto {
|
|||||||
export interface IContextFieldStore extends Store<IContextField, string> {
|
export interface IContextFieldStore extends Store<IContextField, string> {
|
||||||
create(data: IContextFieldDto): Promise<IContextField>;
|
create(data: IContextFieldDto): Promise<IContextField>;
|
||||||
update(data: IContextFieldDto): Promise<IContextField>;
|
update(data: IContextFieldDto): Promise<IContextField>;
|
||||||
|
count(): Promise<number>;
|
||||||
}
|
}
|
||||||
|
@ -58,4 +58,6 @@ export interface IGroupStore extends Store<IGroup, number> {
|
|||||||
existsWithName(name: string): Promise<boolean>;
|
existsWithName(name: string): Promise<boolean>;
|
||||||
|
|
||||||
create(group: IStoreGroup): Promise<IGroup>;
|
create(group: IStoreGroup): Promise<IGroup>;
|
||||||
|
|
||||||
|
count(): Promise<number>;
|
||||||
}
|
}
|
||||||
|
@ -28,4 +28,5 @@ export interface IRoleStore extends Store<ICustomRole, number> {
|
|||||||
getRootRoles(): Promise<IRole[]>;
|
getRootRoles(): Promise<IRole[]>;
|
||||||
getRootRoleForAllUsers(): Promise<IUserRole[]>;
|
getRootRoleForAllUsers(): Promise<IUserRole[]>;
|
||||||
nameInUse(name: string, existingId: number): Promise<boolean>;
|
nameInUse(name: string, existingId: number): Promise<boolean>;
|
||||||
|
count(): Promise<number>;
|
||||||
}
|
}
|
||||||
|
@ -25,4 +25,6 @@ export interface ISegmentStore extends Store<ISegment, number> {
|
|||||||
getAllFeatureStrategySegments(): Promise<IFeatureStrategySegment[]>;
|
getAllFeatureStrategySegments(): Promise<IFeatureStrategySegment[]>;
|
||||||
|
|
||||||
existsByName(name: string): Promise<boolean>;
|
existsByName(name: string): Promise<boolean>;
|
||||||
|
|
||||||
|
count(): Promise<number>;
|
||||||
}
|
}
|
||||||
|
@ -38,4 +38,5 @@ export interface IStrategyStore extends Store<IStrategy, string> {
|
|||||||
reactivateStrategy({ name }: Pick<IStrategy, 'name'>): Promise<void>;
|
reactivateStrategy({ name }: Pick<IStrategy, 'name'>): Promise<void>;
|
||||||
importStrategy(data: IMinimalStrategy): Promise<void>;
|
importStrategy(data: IMinimalStrategy): Promise<void>;
|
||||||
dropCustomStrategies(): Promise<void>;
|
dropCustomStrategies(): Promise<void>;
|
||||||
|
count(): Promise<number>;
|
||||||
}
|
}
|
||||||
|
74
src/test/e2e/api/admin/instance-admin.e2e.test.ts
Normal file
74
src/test/e2e/api/admin/instance-admin.e2e.test.ts
Normal file
@ -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"/);
|
||||||
|
});
|
@ -1573,6 +1573,65 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
],
|
],
|
||||||
"type": "object",
|
"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": {
|
"legalValueSchema": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"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": {
|
"/api/admin/invite-link/tokens": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getAllPublicSignupTokens",
|
"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.",
|
"description": "[Import and export](https://docs.getunleash.io/deploy/import_export) the state of your Unleash instance.",
|
||||||
"name": "Import/Export",
|
"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.",
|
"description": "Register, read, or delete metrics recorded by Unleash.",
|
||||||
"name": "Metrics",
|
"name": "Metrics",
|
||||||
|
@ -6,6 +6,10 @@ import {
|
|||||||
import NotFoundError from '../../lib/error/notfound-error';
|
import NotFoundError from '../../lib/error/notfound-error';
|
||||||
|
|
||||||
export default class FakeContextFieldStore implements IContextFieldStore {
|
export default class FakeContextFieldStore implements IContextFieldStore {
|
||||||
|
count(): Promise<number> {
|
||||||
|
return Promise.resolve(0);
|
||||||
|
}
|
||||||
|
|
||||||
defaultContextFields: IContextField[] = [
|
defaultContextFields: IContextField[] = [
|
||||||
{
|
{
|
||||||
name: 'environment',
|
name: 'environment',
|
||||||
|
4
src/test/fixtures/fake-group-store.ts
vendored
4
src/test/fixtures/fake-group-store.ts
vendored
@ -9,6 +9,10 @@ import Group, {
|
|||||||
} from '../../lib/types/group';
|
} from '../../lib/types/group';
|
||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
export default class FakeGroupStore implements IGroupStore {
|
export default class FakeGroupStore implements IGroupStore {
|
||||||
|
count(): Promise<number> {
|
||||||
|
return Promise.resolve(0);
|
||||||
|
}
|
||||||
|
|
||||||
data: IGroup[];
|
data: IGroup[];
|
||||||
|
|
||||||
async getAll(): Promise<IGroup[]> {
|
async getAll(): Promise<IGroup[]> {
|
||||||
|
4
src/test/fixtures/fake-role-store.ts
vendored
4
src/test/fixtures/fake-role-store.ts
vendored
@ -8,6 +8,10 @@ import {
|
|||||||
} from 'lib/types/stores/role-store';
|
} from 'lib/types/stores/role-store';
|
||||||
|
|
||||||
export default class FakeRoleStore implements IRoleStore {
|
export default class FakeRoleStore implements IRoleStore {
|
||||||
|
count(): Promise<number> {
|
||||||
|
return Promise.resolve(0);
|
||||||
|
}
|
||||||
|
|
||||||
roles: ICustomRole[] = [];
|
roles: ICustomRole[] = [];
|
||||||
|
|
||||||
getGroupRolesForProject(projectId: string): Promise<IRole[]> {
|
getGroupRolesForProject(projectId: string): Promise<IRole[]> {
|
||||||
|
4
src/test/fixtures/fake-segment-store.ts
vendored
4
src/test/fixtures/fake-segment-store.ts
vendored
@ -2,6 +2,10 @@ import { ISegmentStore } from '../../lib/types/stores/segment-store';
|
|||||||
import { IFeatureStrategySegment, ISegment } from '../../lib/types/model';
|
import { IFeatureStrategySegment, ISegment } from '../../lib/types/model';
|
||||||
|
|
||||||
export default class FakeSegmentStore implements ISegmentStore {
|
export default class FakeSegmentStore implements ISegmentStore {
|
||||||
|
count(): Promise<number> {
|
||||||
|
return Promise.resolve(0);
|
||||||
|
}
|
||||||
|
|
||||||
create(): Promise<ISegment> {
|
create(): Promise<ISegment> {
|
||||||
throw new Error('Method not implemented.');
|
throw new Error('Method not implemented.');
|
||||||
}
|
}
|
||||||
|
4
src/test/fixtures/fake-strategies-store.ts
vendored
4
src/test/fixtures/fake-strategies-store.ts
vendored
@ -7,6 +7,10 @@ import {
|
|||||||
import NotFoundError from '../../lib/error/notfound-error';
|
import NotFoundError from '../../lib/error/notfound-error';
|
||||||
|
|
||||||
export default class FakeStrategiesStore implements IStrategyStore {
|
export default class FakeStrategiesStore implements IStrategyStore {
|
||||||
|
count(): Promise<number> {
|
||||||
|
return Promise.resolve(0);
|
||||||
|
}
|
||||||
|
|
||||||
defaultStrategy: IStrategy = {
|
defaultStrategy: IStrategy = {
|
||||||
name: 'default',
|
name: 'default',
|
||||||
description: 'default strategy',
|
description: 'default strategy',
|
||||||
|
24
yarn.lock
24
yarn.lock
@ -2490,6 +2490,11 @@ commander@^2.20.3:
|
|||||||
resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz"
|
resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz"
|
||||||
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
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:
|
commander@^9.1.0:
|
||||||
version "9.2.0"
|
version "9.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-9.2.0.tgz#6e21014b2ed90d8b7c9647230d8b7a94a4a419a9"
|
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"
|
resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.1.5.tgz#1ff1645e6b4d1b028cd3f862db88c9d887f26e2a"
|
||||||
integrity sha512-08bOAKweV2NUC1wqTtf3qZlnpOX/R2DU9ikpjOHs0H+ibQv3zpncVQg6um4uYtRtrwIX8M4Nh3ytK4HGlYAq7Q==
|
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:
|
js-tokens@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"
|
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"
|
resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz"
|
||||||
integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
|
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:
|
json5@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz"
|
resolved "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz"
|
||||||
@ -4954,6 +4973,11 @@ jsonfile@^6.0.1:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs "^4.1.6"
|
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:
|
jsprim@^1.2.2:
|
||||||
version "1.4.1"
|
version "1.4.1"
|
||||||
resolved "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz"
|
resolved "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz"
|
||||||
|
Loading…
Reference in New Issue
Block a user