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>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
value="/admin/instance"
|
||||
label={
|
||||
<NavLink
|
||||
to="/admin/instance"
|
||||
style={({ isActive }) =>
|
||||
createNavLinkStyle({ isActive, theme })
|
||||
}
|
||||
>
|
||||
Instance stats
|
||||
</NavLink>
|
||||
}
|
||||
/>
|
||||
{isBilling && (
|
||||
<Tab
|
||||
value="/admin/billing"
|
||||
|
@ -454,6 +454,16 @@ exports[`returns all baseRoutes 1`] = `
|
||||
"title": "Single sign-on",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"menu": {
|
||||
"adminSettings": true,
|
||||
},
|
||||
"parent": "/admin",
|
||||
"path": "/admin/instance",
|
||||
"title": "Instance stats",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"flag": "embedProxyFrontend",
|
||||
|
@ -59,6 +59,7 @@ import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
|
||||
import { CorsAdmin } from 'component/admin/cors';
|
||||
import { InviteLink } from 'component/admin/users/InviteLink/InviteLink';
|
||||
import { Profile } from 'component/user/Profile/Profile';
|
||||
import { InstanceAdmin } from '../admin/instance-admin/InstanceAdmin';
|
||||
|
||||
export const routes: IRoute[] = [
|
||||
// Splash
|
||||
@ -499,6 +500,14 @@ export const routes: IRoute[] = [
|
||||
type: 'protected',
|
||||
menu: { adminSettings: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/instance',
|
||||
parent: '/admin',
|
||||
title: 'Instance stats',
|
||||
component: InstanceAdmin,
|
||||
type: 'protected',
|
||||
menu: { adminSettings: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/cors',
|
||||
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",
|
||||
"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",
|
||||
|
@ -141,6 +141,7 @@ exports[`should create default config 1`] = `
|
||||
},
|
||||
"strategySegmentsLimit": 5,
|
||||
"ui": {
|
||||
"environment": "Open Source",
|
||||
"flags": {
|
||||
"E": true,
|
||||
"ENABLE_DARK_MODE_SUPPORT": false,
|
||||
|
@ -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,
|
||||
|
@ -119,5 +119,11 @@ class ContextFieldStore implements IContextFieldStore {
|
||||
async delete(name: string): Promise<void> {
|
||||
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;
|
||||
|
@ -175,6 +175,12 @@ export default class GroupStore implements IGroupStore {
|
||||
return rowToGroup(row[0]);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this.db(T.GROUPS)
|
||||
.count('*')
|
||||
.then((res) => Number(res[0].count));
|
||||
}
|
||||
|
||||
async addUsersToGroup(
|
||||
groupId: number,
|
||||
users: IGroupUserModel[],
|
||||
|
@ -47,6 +47,13 @@ export default class RoleStore implements IRoleStore {
|
||||
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> {
|
||||
const row = await this.db(T.ROLES)
|
||||
.insert({
|
||||
|
@ -50,6 +50,13 @@ export default class SegmentStore implements ISegmentStore {
|
||||
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(
|
||||
segment: PartialSome<ISegment, 'id'>,
|
||||
user: Partial<Pick<User, 'username' | 'email'>>,
|
||||
|
@ -74,6 +74,13 @@ export default class StrategyStore implements IStrategyStore {
|
||||
await this.db(TABLE).del();
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this.db
|
||||
.from(TABLE)
|
||||
.count('*')
|
||||
.then((res) => Number(res[0].count));
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
|
||||
async exists(name: string): Promise<boolean> {
|
||||
|
@ -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/);
|
||||
});
|
||||
|
@ -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<void> {
|
||||
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,
|
||||
|
@ -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,
|
||||
|
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:
|
||||
'[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.',
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
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,
|
||||
serverVersion,
|
||||
config.eventBus,
|
||||
services.instanceStatsService,
|
||||
db,
|
||||
);
|
||||
const unleash: Omit<IUnleash, 'stop'> = {
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
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 {
|
||||
environment?: string;
|
||||
slogan?: string;
|
||||
name?: string;
|
||||
links?: [
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -20,4 +20,5 @@ export interface IContextField extends IContextFieldDto {
|
||||
export interface IContextFieldStore extends Store<IContextField, string> {
|
||||
create(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>;
|
||||
|
||||
create(group: IStoreGroup): Promise<IGroup>;
|
||||
|
||||
count(): Promise<number>;
|
||||
}
|
||||
|
@ -28,4 +28,5 @@ export interface IRoleStore extends Store<ICustomRole, number> {
|
||||
getRootRoles(): Promise<IRole[]>;
|
||||
getRootRoleForAllUsers(): Promise<IUserRole[]>;
|
||||
nameInUse(name: string, existingId: number): Promise<boolean>;
|
||||
count(): Promise<number>;
|
||||
}
|
||||
|
@ -25,4 +25,6 @@ export interface ISegmentStore extends Store<ISegment, number> {
|
||||
getAllFeatureStrategySegments(): Promise<IFeatureStrategySegment[]>;
|
||||
|
||||
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>;
|
||||
importStrategy(data: IMinimalStrategy): 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",
|
||||
},
|
||||
"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",
|
||||
|
@ -6,6 +6,10 @@ import {
|
||||
import NotFoundError from '../../lib/error/notfound-error';
|
||||
|
||||
export default class FakeContextFieldStore implements IContextFieldStore {
|
||||
count(): Promise<number> {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
defaultContextFields: IContextField[] = [
|
||||
{
|
||||
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';
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
export default class FakeGroupStore implements IGroupStore {
|
||||
count(): Promise<number> {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
data: 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';
|
||||
|
||||
export default class FakeRoleStore implements IRoleStore {
|
||||
count(): Promise<number> {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
roles: ICustomRole[] = [];
|
||||
|
||||
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';
|
||||
|
||||
export default class FakeSegmentStore implements ISegmentStore {
|
||||
count(): Promise<number> {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
create(): Promise<ISegment> {
|
||||
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';
|
||||
|
||||
export default class FakeStrategiesStore implements IStrategyStore {
|
||||
count(): Promise<number> {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
defaultStrategy: IStrategy = {
|
||||
name: 'default',
|
||||
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"
|
||||
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"
|
||||
|
Loading…
Reference in New Issue
Block a user