Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/stats service #2211

Merged
merged 11 commits into from
Oct 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/src/component/admin/instance-admin/InstanceAdmin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import AdminMenu from '../menu/AdminMenu';
import { InstanceStats } from './InstanceStats/InstanceStats';

export const InstanceAdmin = () => {
return (
<div>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should InstanceStats be available for everyone in the system? Currently we don't gate the stats for Admins only.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was the intention, all users with access should be able to access it for now, as there is not secrets here. All stats will be implicitly available for read only users anyway (number of projects, feature toggles etc).

<AdminMenu />
<InstanceStats />
</div>
);
};
Original file line number Diff line number Diff line change
@@ -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>
);
};
13 changes: 13 additions & 0 deletions frontend/src/component/admin/menu/AdminMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,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",
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/component/menu/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -497,6 +498,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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
};
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/lib/__snapshots__/create-config.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ exports[`should create default config 1`] = `
},
"strategySegmentsLimit": 5,
"ui": {
"environment": "Open Source",
"flags": {
"E": true,
"ENABLE_DARK_MODE_SUPPORT": false,
Expand Down
4 changes: 3 additions & 1 deletion src/lib/create-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/lib/db/context-field-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
6 changes: 6 additions & 0 deletions src/lib/db/group-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,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[],
Expand Down
7 changes: 7 additions & 0 deletions src/lib/db/role-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
7 changes: 7 additions & 0 deletions src/lib/db/segment-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'>>,
Expand Down
7 changes: 7 additions & 0 deletions src/lib/db/strategy-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
18 changes: 17 additions & 1 deletion src/lib/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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: {
Expand All @@ -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();
Expand Down Expand Up @@ -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/);
});
Expand Down