mirror of
https://github.com/grafana/grafana.git
synced 2024-12-28 18:01:40 -06:00
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:
parent
fcceb5716d
commit
6e639f3c72
@ -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.
|
||||
|
@ -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`.
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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));
|
||||
|
@ -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' }}>
|
||||
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -34,4 +34,6 @@ export enum AccessControlAction {
|
||||
LDAPUsersSync = 'ldap.user:sync',
|
||||
LDAPStatusRead = 'ldap.status:read',
|
||||
DataSourcesExplore = 'datasources:explore',
|
||||
|
||||
ActionServerStatsRead = 'server.stats:read',
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user