diff --git a/docs/sources/enterprise/access-control/usage-scenarios.md b/docs/sources/enterprise/access-control/usage-scenarios.md index 0aa8e69868e..7f8f9703e65 100644 --- a/docs/sources/enterprise/access-control/usage-scenarios.md +++ b/docs/sources/enterprise/access-control/usage-scenarios.md @@ -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. diff --git a/packages/grafana-ui/src/components/Forms/Form.mdx b/packages/grafana-ui/src/components/Forms/Form.mdx index cff858a9d4f..3cfc01aaf10 100644 --- a/packages/grafana-ui/src/components/Forms/Form.mdx +++ b/packages/grafana-ui/src/components/Forms/Form.mdx @@ -276,7 +276,7 @@ In a nutshell, the two most important changes are: ```jsx - -+ ++ ; ``` - `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`. diff --git a/pkg/api/index.go b/pkg/api/index.go index 86b4185655d..859efa7cb0e 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -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", diff --git a/pkg/services/licensing/oss.go b/pkg/services/licensing/oss.go index 174ddf56c96..1113ea933f0 100644 --- a/pkg/services/licensing/oss.go +++ b/pkg/services/licensing/oss.go @@ -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", diff --git a/public/app/features/admin/ServerStats.test.tsx b/public/app/features/admin/ServerStats.test.tsx index 575fe89826d..7ddcde97f99 100644 --- a/public/app/features/admin/ServerStats.test.tsx +++ b/public/app/features/admin/ServerStats.test.tsx @@ -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) => { + const props: Props = { + getServerStats, + }; + Object.assign(props, propOverrides); + + render(); +}; 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(); - - 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(); }); }); diff --git a/public/app/features/admin/ServerStats.tsx b/public/app/features/admin/ServerStats.tsx index 95f45025dfe..81204802a2c 100644 --- a/public/app/features/admin/ServerStats.tsx +++ b/public/app/features/admin/ServerStats.tsx @@ -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; +export interface Props { + getServerStats: () => Promise; } -interface State { - stats: ServerStat[]; - isLoading: boolean; -} +export const ServerStats = ({ getServerStats }: Props) => { + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const styles = useStyles2(getStyles); -export class ServerStats extends PureComponent { - 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 ( - - - - - - - - - - {stats.map(StatItem)} -
NameValue
-
-
- ); - } -} - -function StatItem(stat: ServerStat) { return ( - - - {stat.name}{' '} - {stat.tooltip && ( - - - - )} - - {stat.value} - + <> +

Instance statistics

+ {isLoading ? ( +
+ +
+ ) : stats ? ( +
+ + Manage dashboards + + } + /> + +
+ + Manage data sources + + } + /> + + Alerts + + } + /> +
+ + Manage users + + } + /> +
+ ) : ( +

No stats found.

+ )} + ); -} +}; + +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>; + footer?: JSX.Element; +}; + +const StatCard = ({ content, footer }: StatCardProps) => { + const styles = useStyles2(getCardStyles); + return ( + +
+
+ {content.map((item) => { + return ( +
+ {item.name} + {item.value} +
+ ); + })} +
+ {footer &&
{footer}
} +
+
+ ); +}; + +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)); diff --git a/public/app/features/admin/UpgradePage.tsx b/public/app/features/admin/UpgradePage.tsx index f597052c7ee..b963cb0664e 100644 --- a/public/app/features/admin/UpgradePage.tsx +++ b/public/app/features/admin/UpgradePage.tsx @@ -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 = ({ navModel }) => { return ( + = ({ 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 ( - -
- - -
-
+ <> +

Enterprise license

+ +
+ + +
+
+ ); }; +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 (
diff --git a/public/app/features/admin/state/apis.tsx b/public/app/features/admin/state/apis.tsx index ae3a5731212..79c2061e4f5 100644 --- a/public/app/features/admin/state/apis.tsx +++ b/public/app/features/admin/state/apis.tsx @@ -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 => { - 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 Licensing page. - - ) - : '', - }, - ...(!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 => { + return getBackendSrv() + .get('api/admin/stats') + .catch((err) => { + console.error(err); + return null; + }); }; diff --git a/public/app/features/plugins/admin/components/Loader.tsx b/public/app/features/plugins/admin/components/Loader.tsx index 8cfa0d05dff..1113f8e7215 100644 --- a/public/app/features/plugins/admin/components/Loader.tsx +++ b/public/app/features/plugins/admin/components/Loader.tsx @@ -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 (
- +
); diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts index d5e795ed969..445ec2f34e8 100644 --- a/public/app/types/accessControl.ts +++ b/public/app/types/accessControl.ts @@ -34,4 +34,6 @@ export enum AccessControlAction { LDAPUsersSync = 'ldap.user:sync', LDAPStatusRead = 'ldap.status:read', DataSourcesExplore = 'datasources:explore', + + ActionServerStatsRead = 'server.stats:read', }