Admin: Update stats page UI (#38014)

* Add StatCard

* Style cards

* Update types

* Add tests

* Move stats tab into licencing

* Prevent UI jumps when loading stats

* Fix merge conflicts

* Revert docs format
This commit is contained in:
Alex Khomenko 2021-08-24 19:13:48 +03:00 committed by GitHub
parent fcceb5716d
commit 6e639f3c72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 282 additions and 168 deletions

View File

@ -228,5 +228,4 @@ By default, the Grafana Server Admin is the only user who can create and manage
1. Create a built-in role assignment and map `fixed:permissions:admin:edit` and `fixed:permissions:admin:read` fixed roles to the `Editor` built-in role.
1. [Create a custom role]({{< ref "#create-your-custom-role" >}}) with `roles.builtin:add` and `roles:write` permissions, then create a built-in role assignment for `Editor` organization role.
Note that any user with the ability to modify roles can only create, update or delete roles with permissions they themselves have been granted. For example, a user with the `Editor` role would be able to create and manage roles only with the permissions they have, or with a subset of them.

View File

@ -276,7 +276,7 @@ In a nutshell, the two most important changes are:
```jsx
- <input ref={register({ required: true })} name="test" />
+ <input {...register('test', { required: true })} />
+ <input {...register('test', { required: true })} />;
```
- `InputControl`'s `as` prop has been replaced with `render`, which has `field` and `fieldState` objects as arguments. `onChange`, `onBlur`, `value`, `name`, and `ref` are parts of `field`.

View File

@ -376,12 +376,6 @@ func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink {
})
}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)) {
adminNavLinks = append(adminNavLinks, &dtos.NavLink{
Text: "Stats", Id: "server-stats", Url: hs.Cfg.AppSubURL + "/admin/stats", Icon: "graph-bar",
})
}
if hs.Cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) {
adminNavLinks = append(adminNavLinks, &dtos.NavLink{
Text: "LDAP", Id: "ldap", Url: hs.Cfg.AppSubURL + "/admin/ldap", Icon: "book",

View File

@ -49,7 +49,7 @@ func (l *OSSLicensingService) Init() error {
for _, node := range indexData.NavTree {
if node.Id == "admin" {
node.Children = append(node.Children, &dtos.NavLink{
Text: "Upgrade",
Text: "Stats and license",
Id: "upgrading",
Url: l.LicenseURL(req.SignedInUser),
Icon: "unlock",

View File

@ -1,27 +1,48 @@
import React from 'react';
// @ts-ignore
import renderer from 'react-test-renderer';
import { ServerStats } from './ServerStats';
import { createNavModel } from 'test/mocks/common';
import { render, screen } from '@testing-library/react';
import { ServerStats, Props } from './ServerStats';
import { ServerStat } from './state/apis';
const stats: ServerStat = {
activeAdmins: 1,
activeEditors: 0,
activeSessions: 1,
activeUsers: 1,
activeViewers: 0,
admins: 1,
alerts: 5,
dashboards: 1599,
datasources: 54,
editors: 2,
orgs: 1,
playlists: 1,
snapshots: 1,
stars: 3,
tags: 42,
users: 5,
viewers: 2,
};
const getServerStats = () => {
return Promise.resolve(stats);
};
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
getServerStats,
};
Object.assign(props, propOverrides);
render(<ServerStats {...props} />);
};
describe('ServerStats', () => {
it('Should render table with stats', (done) => {
const navModel = createNavModel('Admin', 'stats');
const stats: ServerStat[] = [
{ name: 'Total dashboards', value: 10 },
{ name: 'Total Users', value: 1 },
];
const getServerStats = () => {
return Promise.resolve(stats);
};
const page = renderer.create(<ServerStats navModel={navModel} getServerStats={getServerStats} />);
setTimeout(() => {
expect(page.toJSON()).toBeDefined();
done();
});
it('Should render page with stats', async () => {
setup();
expect(await screen.findByRole('heading', { name: /instance statistics/i })).toBeInTheDocument();
expect(screen.getByText('Dashboards (starred)')).toBeInTheDocument();
expect(screen.getByText('Tags')).toBeInTheDocument();
expect(screen.getByText('Playlists')).toBeInTheDocument();
expect(screen.getByText('Snapshots')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Manage dashboards' })).toBeInTheDocument();
});
});

View File

@ -1,79 +1,187 @@
import React, { PureComponent } from 'react';
import React, { useEffect, useState } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { Icon, Tooltip } from '@grafana/ui';
import { NavModel } from '@grafana/data';
import { StoreState } from 'app/types';
import { css } from '@emotion/css';
import { CardContainer, LinkButton, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { AccessControlAction, StoreState } from 'app/types';
import { getNavModel } from 'app/core/selectors/navModel';
import Page from 'app/core/components/Page/Page';
import { getServerStats, ServerStat } from './state/apis';
import { contextSrv } from '../../core/services/context_srv';
import { Loader } from '../plugins/admin/components/Loader';
interface Props {
navModel: NavModel;
getServerStats: () => Promise<ServerStat[]>;
export interface Props {
getServerStats: () => Promise<ServerStat | null>;
}
interface State {
stats: ServerStat[];
isLoading: boolean;
}
export const ServerStats = ({ getServerStats }: Props) => {
const [stats, setStats] = useState<ServerStat | null>(null);
const [isLoading, setIsLoading] = useState(true);
const styles = useStyles2(getStyles);
export class ServerStats extends PureComponent<Props, State> {
state: State = {
stats: [],
isLoading: true,
};
useEffect(() => {
getServerStats().then((stats) => {
setStats(stats);
setIsLoading(false);
});
}, [getServerStats]);
async componentDidMount() {
try {
const stats = await this.props.getServerStats();
this.setState({ stats, isLoading: false });
} catch (error) {
console.error(error);
}
if (!contextSrv.hasPermission(AccessControlAction.ActionServerStatsRead)) {
return null;
}
render() {
const { navModel } = this.props;
const { stats, isLoading } = this.state;
return (
<Page navModel={navModel}>
<Page.Contents isLoading={isLoading}>
<table className="filter-table form-inline">
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>{stats.map(StatItem)}</tbody>
</table>
</Page.Contents>
</Page>
);
}
}
function StatItem(stat: ServerStat) {
return (
<tr key={stat.name}>
<td>
{stat.name}{' '}
{stat.tooltip && (
<Tooltip content={stat.tooltip} placement={'top'}>
<Icon name={'info-circle'} />
</Tooltip>
)}
</td>
<td>{stat.value}</td>
</tr>
<>
<h2 className={styles.title}>Instance statistics</h2>
{isLoading ? (
<div className={styles.loader}>
<Loader text={'Loading instance stats...'} />
</div>
) : stats ? (
<div className={styles.row}>
<StatCard
content={[
{ name: 'Dashboards (starred)', value: `${stats.dashboards} (${stats.stars})` },
{ name: 'Tags', value: stats.tags },
{ name: 'Playlists', value: stats.playlists },
{ name: 'Snapshots', value: stats.snapshots },
]}
footer={
<LinkButton href={'/dashboards'} variant={'secondary'}>
Manage dashboards
</LinkButton>
}
/>
<div className={styles.doubleRow}>
<StatCard
content={[{ name: 'Data sources', value: stats.datasources }]}
footer={
<LinkButton href={'/datasources'} variant={'secondary'}>
Manage data sources
</LinkButton>
}
/>
<StatCard
content={[{ name: 'Alerts', value: stats.alerts }]}
footer={
<LinkButton href={'/alerting/list'} variant={'secondary'}>
Alerts
</LinkButton>
}
/>
</div>
<StatCard
content={[
{ name: 'Organisations', value: stats.orgs },
{ name: 'Users total', value: stats.users },
{ name: 'Active users in last 30 days', value: stats.activeUsers },
{ name: 'Active sessions', value: stats.activeSessions },
]}
footer={
<LinkButton href={'/admin/users'} variant={'secondary'}>
Manage users
</LinkButton>
}
/>
</div>
) : (
<p className={styles.notFound}>No stats found.</p>
)}
</>
);
}
};
const getStyles = (theme: GrafanaTheme2) => {
return {
title: css`
margin-bottom: ${theme.spacing(4)};
`,
row: css`
display: flex;
justify-content: space-between;
width: 100%;
& > div:not(:last-of-type) {
margin-right: ${theme.spacing(2)};
}
& > div {
width: 33.3%;
}
`,
doubleRow: css`
display: flex;
flex-direction: column;
& > div:first-of-type {
margin-bottom: ${theme.spacing(2)};
}
`,
loader: css`
height: 290px;
`,
notFound: css`
font-size: ${theme.typography.h6.fontSize};
text-align: center;
height: 290px;
`,
};
};
const mapStateToProps = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'server-stats'),
getServerStats: getServerStats,
getServerStats,
});
type StatCardProps = {
content: Array<Record<string, number | string>>;
footer?: JSX.Element;
};
const StatCard = ({ content, footer }: StatCardProps) => {
const styles = useStyles2(getCardStyles);
return (
<CardContainer className={styles.container} disableHover>
<div className={styles.inner}>
<div className={styles.content}>
{content.map((item) => {
return (
<div key={item.name} className={styles.row}>
<span>{item.name}</span>
<span>{item.value}</span>
</div>
);
})}
</div>
{footer && <div>{footer}</div>}
</div>
</CardContainer>
);
};
const getCardStyles = (theme: GrafanaTheme2) => {
return {
container: css`
padding: ${theme.spacing(2)};
`,
inner: css`
display: flex;
flex-direction: column;
width: 100%;
`,
content: css`
flex: 1 0 auto;
`,
row: css`
display: flex;
justify-content: space-between;
width: 100%;
margin-bottom: ${theme.spacing(2)};
align-items: center;
`,
};
};
export default hot(module)(connect(mapStateToProps)(ServerStats));

View File

@ -1,13 +1,14 @@
import React from 'react';
import { css } from '@emotion/css';
import { NavModel } from '@grafana/data';
import Page from '../../core/components/Page/Page';
import { LicenseChrome } from './LicenseChrome';
import { LinkButton } from '@grafana/ui';
import { hot } from 'react-hot-loader';
import { StoreState } from '../../types';
import { getNavModel } from '../../core/selectors/navModel';
import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
import { css } from '@emotion/css';
import { LinkButton, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, NavModel } from '@grafana/data';
import Page from '../../core/components/Page/Page';
import { getNavModel } from '../../core/selectors/navModel';
import { LicenseChrome } from './LicenseChrome';
import { StoreState } from '../../types';
import ServerStats from './ServerStats';
interface Props {
navModel: NavModel;
@ -17,6 +18,7 @@ export const UpgradePage: React.FC<Props> = ({ navModel }) => {
return (
<Page navModel={navModel}>
<Page.Contents>
<ServerStats />
<UpgradeInfo
editionNotice="You are running the open-source version of Grafana.
You have to install the Enterprise edition in order enable Enterprise features."
@ -33,27 +35,39 @@ interface UpgradeInfoProps {
}
export const UpgradeInfo: React.FC<UpgradeInfoProps> = ({ editionNotice }) => {
const columnStyles = css`
display: grid;
grid-template-columns: 100%;
column-gap: 20px;
row-gap: 40px;
@media (min-width: 1050px) {
grid-template-columns: 50% 50%;
}
`;
const styles = useStyles2(getStyles);
return (
<LicenseChrome header="Grafana Enterprise" subheader="Get your free trial" editionNotice={editionNotice}>
<div className={columnStyles}>
<FeatureInfo />
<ServiceInfo />
</div>
</LicenseChrome>
<>
<h2 className={styles.title}>Enterprise license</h2>
<LicenseChrome header="Grafana Enterprise" subheader="Get your free trial" editionNotice={editionNotice}>
<div className={styles.column}>
<FeatureInfo />
<ServiceInfo />
</div>
</LicenseChrome>
</>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
column: css`
display: grid;
grid-template-columns: 100%;
column-gap: 20px;
row-gap: 40px;
@media (min-width: 1050px) {
grid-template-columns: 50% 50%;
}
`,
title: css`
margin: ${theme.spacing(4)} 0;
`,
};
};
const GetEnterprise: React.FC = () => {
return (
<div style={{ marginTop: '40px', marginBottom: '30px' }}>

View File

@ -1,58 +1,30 @@
import React from 'react';
import { getBackendSrv } from '@grafana/runtime';
import { PopoverContent } from '@grafana/ui';
import { config } from 'app/core/config';
export interface ServerStat {
name: string;
value: number;
tooltip?: PopoverContent;
activeAdmins: number;
activeEditors: number;
activeSessions: number;
activeUsers: number;
activeViewers: number;
admins: number;
alerts: number;
dashboards: number;
datasources: number;
editors: number;
orgs: number;
playlists: number;
snapshots: number;
stars: number;
tags: number;
users: number;
viewers: number;
}
const { hasLicense } = config.licenseInfo;
export const getServerStats = async (): Promise<ServerStat[]> => {
try {
const res = await getBackendSrv().get('api/admin/stats');
return [
{ name: 'Total users', value: res.users },
...(!hasLicense
? [
{ name: 'Total admins', value: res.admins },
{ name: 'Total editors', value: res.editors },
{ name: 'Total viewers', value: res.viewers },
]
: []),
{
name: 'Active users (seen last 30 days)',
value: res.activeUsers,
tooltip: hasLicense
? () => (
<>
For active user count by role, see the <a href="/admin/licensing">Licensing page</a>.
</>
)
: '',
},
...(!hasLicense
? [
{ name: 'Active admins (seen last 30 days)', value: res.activeAdmins },
{ name: 'Active editors (seen last 30 days)', value: res.activeEditors },
{ name: 'Active viewers (seen last 30 days)', value: res.activeViewers },
]
: []),
{ name: 'Active sessions', value: res.activeSessions },
{ name: 'Total dashboards', value: res.dashboards },
{ name: 'Total orgs', value: res.orgs },
{ name: 'Total playlists', value: res.playlists },
{ name: 'Total snapshots', value: res.snapshots },
{ name: 'Total dashboard tags', value: res.tags },
{ name: 'Total starred dashboards', value: res.stars },
{ name: 'Total alerts', value: res.alerts },
{ name: 'Total data sources', value: res.datasources },
];
} catch (error) {
console.error(error);
throw error;
}
export const getServerStats = async (): Promise<ServerStat | null> => {
return getBackendSrv()
.get('api/admin/stats')
.catch((err) => {
console.error(err);
return null;
});
};

View File

@ -2,11 +2,15 @@ import React from 'react';
import { LoadingPlaceholder } from '@grafana/ui';
import { Page } from './Page';
export const Loader = () => {
export interface Props {
text?: string;
}
export const Loader = ({ text = 'Loading...' }: Props) => {
return (
<Page>
<div className="page-loader-wrapper">
<LoadingPlaceholder text="Loading..." />
<LoadingPlaceholder text={text} />
</div>
</Page>
);

View File

@ -34,4 +34,6 @@ export enum AccessControlAction {
LDAPUsersSync = 'ldap.user:sync',
LDAPStatusRead = 'ldap.status:read',
DataSourcesExplore = 'datasources:explore',
ActionServerStatsRead = 'server.stats:read',
}