Dashboard: Empty/No Panels dashboard with a new design (#65161)

* Empty Dashboard state has its own CTA items and its own separate box to choose a library panel to create

* show empty dashboard screen if no panels

* start page for empty dashboard

* add feature flag for empty dashboard redesign

* only show empty dashboard redesign if FF
This commit is contained in:
Polina Boneva 2023-03-28 12:42:23 +03:00 committed by GitHub
parent a89202eab2
commit 221c5efedc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 356 additions and 11 deletions

View File

@ -82,6 +82,7 @@ Alpha features might be changed or removed without prior notice.
| `mysqlAnsiQuotes` | Use double quotes to escape keyword in a MySQL query |
| `elasticsearchBackendMigration` | Use Elasticsearch as backend data source |
| `datasourceOnboarding` | Enable data source onboarding page |
| `emptyDashboardPage` | Enable the redesigned user interface of a dashboard page that includes no panels |
| `secureSocksDatasourceProxy` | Enable secure socks tunneling for supported core datasources |
| `authnService` | Use new auth service to perform authentication |
| `alertingBacktesting` | Rule backtesting API for alerting |

View File

@ -66,6 +66,7 @@ export interface FeatureToggles {
accessTokenExpirationCheck?: boolean;
elasticsearchBackendMigration?: boolean;
datasourceOnboarding?: boolean;
emptyDashboardPage?: boolean;
secureSocksDatasourceProxy?: boolean;
authnService?: boolean;
disablePrometheusExemplarSampling?: boolean;

View File

@ -320,6 +320,13 @@ var (
State: FeatureStateAlpha,
Owner: grafanaDashboardsSquad,
},
{
Name: "emptyDashboardPage",
Description: "Enable the redesigned user interface of a dashboard page that includes no panels",
State: FeatureStateAlpha,
FrontendOnly: true,
Owner: grafanaDashboardsSquad,
},
{
Name: "secureSocksDatasourceProxy",
Description: "Enable secure socks tunneling for supported core datasources",

View File

@ -47,6 +47,7 @@ nestedFolders,alpha,@grafana/backend-platform,true,false,false,false
accessTokenExpirationCheck,stable,@grafana/grafana-authnz-team,false,false,false,false
elasticsearchBackendMigration,alpha,@grafana/observability-logs,false,false,false,false
datasourceOnboarding,alpha,@grafana/dashboards-squad,false,false,false,false
emptyDashboardPage,alpha,@grafana/dashboards-squad,false,false,false,true
secureSocksDatasourceProxy,alpha,@grafana/hosted-grafana-team,false,false,false,false
authnService,alpha,@grafana/grafana-authnz-team,false,false,false,false
disablePrometheusExemplarSampling,stable,@grafana/observability-metrics,false,false,false,false

1 Name State Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
47 accessTokenExpirationCheck stable @grafana/grafana-authnz-team false false false false
48 elasticsearchBackendMigration alpha @grafana/observability-logs false false false false
49 datasourceOnboarding alpha @grafana/dashboards-squad false false false false
50 emptyDashboardPage alpha @grafana/dashboards-squad false false false true
51 secureSocksDatasourceProxy alpha @grafana/hosted-grafana-team false false false false
52 authnService alpha @grafana/grafana-authnz-team false false false false
53 disablePrometheusExemplarSampling stable @grafana/observability-metrics false false false false

View File

@ -199,6 +199,10 @@ const (
// Enable data source onboarding page
FlagDatasourceOnboarding = "datasourceOnboarding"
// FlagEmptyDashboardPage
// Enable the redesigned user interface of a dashboard page that includes no panels
FlagEmptyDashboardPage = "emptyDashboardPage"
// FlagSecureSocksDatasourceProxy
// Enable secure socks tunneling for supported core datasources
FlagSecureSocksDatasourceProxy = "secureSocksDatasourceProxy"

View File

@ -0,0 +1,100 @@
import { css, cx, keyframes } from '@emotion/css';
import React from 'react';
import tinycolor from 'tinycolor2';
import { GrafanaTheme2 } from '@grafana/data';
import { LibraryPanel } from '@grafana/schema';
import { IconButton, useStyles2 } from '@grafana/ui';
import {
LibraryPanelsSearch,
LibraryPanelsSearchVariant,
} from '../../../library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch';
import { DashboardModel, PanelModel } from '../../state';
interface Props {
panel: PanelModel;
dashboard: DashboardModel;
}
export const AddLibraryPanelWidget = ({ panel, dashboard }: Props) => {
const onCancelAddPanel = (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
dashboard.removePanel(panel);
};
const onAddLibraryPanel = (panelInfo: LibraryPanel) => {
const { gridPos } = panel;
const newPanel = {
...panelInfo.model,
gridPos,
libraryPanel: panelInfo,
};
dashboard.addPanel(newPanel);
dashboard.removePanel(panel);
};
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
<div className={cx('panel-container', styles.callToAction)}>
<div className={cx(styles.headerRow, 'grid-drag-handle')}>
<span>Add panel from panel library</span>
<div className="flex-grow-1" />
<IconButton aria-label="Close 'Add Panel' widget" name="times" onClick={onCancelAddPanel} />
</div>
<LibraryPanelsSearch onClick={onAddLibraryPanel} variant={LibraryPanelsSearchVariant.Tight} showPanelFilter />
</div>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => {
const pulsate = keyframes({
'0%': {
boxShadow: `0 0 0 2px ${theme.colors.background.canvas}, 0 0 0px 4px ${theme.colors.primary.main}`,
},
'50%': {
boxShadow: `0 0 0 2px ${theme.components.dashboard.background}, 0 0 0px 4px ${tinycolor(theme.colors.primary.main)
.darken(20)
.toHexString()}`,
},
'100%': {
boxShadow: `0 0 0 2px ${theme.components.dashboard.background}, 0 0 0px 4px ${theme.colors.primary.main}`,
},
});
return {
// wrapper is used to make sure box-shadow animation isn't cut off in dashboard page
wrapper: css({
height: '100%',
paddingTop: `${theme.spacing(0.5)}`,
}),
headerRow: css({
display: 'flex',
alignItems: 'center',
height: '38px',
flexShrink: 0,
width: '100%',
fontSize: theme.typography.fontSize,
fontWeight: theme.typography.fontWeightMedium,
paddingLeft: `${theme.spacing(1)}`,
transition: 'background-color 0.1s ease-in-out',
cursor: 'move',
'&:hover': {
background: `${theme.colors.background.secondary}`,
},
}),
callToAction: css({
overflow: 'hidden',
outline: '2px dotted transparent',
outlineOffset: '2px',
boxShadow: '0 0 0 2px black, 0 0 0px 4px #1f60c4',
animation: `${pulsate} 2s ease infinite`,
}),
};
};

View File

@ -0,0 +1 @@
export { AddLibraryPanelWidget } from './AddLibraryPanelWidget';

View File

@ -48,6 +48,7 @@ import { liveTimer } from '../dashgrid/liveTimer';
import { getTimeSrv } from '../services/TimeSrv';
import { cleanUpDashboardAndVariables } from '../state/actions';
import { initDashboard } from '../state/initDashboard';
import { calculateNewPanelGridPos } from '../utils/panel';
export interface DashboardPageRouteParams {
uid?: string;
@ -361,17 +362,9 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
return;
}
// Move all panels down by the height of the "add panel" widget.
// This is to work around an issue with react-grid-layout that can mess up the layout
// in certain configurations. (See https://github.com/react-grid-layout/react-grid-layout/issues/1787)
const addPanelWidgetHeight = 8;
for (const panel of dashboard.panelIterator()) {
panel.gridPos.y += addPanelWidgetHeight;
}
dashboard.addPanel({
type: 'add-panel',
gridPos: { x: 0, y: 0, w: 12, h: addPanelWidgetHeight },
gridPos: calculateNewPanelGridPos(dashboard),
title: 'Panel Title',
});

View File

@ -0,0 +1,185 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { locationService, reportInteraction } from '@grafana/runtime';
import { Button, useStyles2 } from '@grafana/ui';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { calculateNewPanelGridPos } from 'app/features/dashboard/utils/panel';
export interface Props {
dashboard: DashboardModel;
canCreate: boolean;
}
export const DashboardEmpty = ({ dashboard, canCreate }: Props) => {
const onCreateNewPanel = () => {
const newPanel: Partial<PanelModel> = {
type: 'timeseries',
title: 'Panel Title',
gridPos: calculateNewPanelGridPos(dashboard),
};
dashboard.addPanel(newPanel);
locationService.partial({ editPanel: newPanel.id });
};
const onCreateNewRow = () => {
const newRow = {
type: 'row',
title: 'Row title',
gridPos: { x: 0, y: 0 },
};
dashboard.addPanel(newRow);
};
const onAddLibraryPanel = () => {
const newPanel = {
type: 'add-library-panel',
gridPos: calculateNewPanelGridPos(dashboard),
};
dashboard.addPanel(newPanel);
};
const styles = useStyles2(getStyles);
return (
<div className={styles.centeredContent}>
<div className={cx(styles.centeredContent, styles.wrapper)}>
<div className={cx(styles.containerBox, styles.centeredContent, styles.visualizationContainer)}>
<h1 className={cx(styles.headerSection, styles.headerBig)}>
Start your new dashboard by adding a visualization
</h1>
<h4 className={cx(styles.bodySection, styles.bodyBig)}>
Select a data source and then query and visualize your data with charts, stats and tables or create lists,
markdowns and other widgets.
</h4>
<Button
size="lg"
icon="plus"
aria-label="Add new panel"
onClick={() => {
reportInteraction('Create new panel');
onCreateNewPanel();
}}
disabled={!canCreate}
>
Add visualization
</Button>
</div>
<div className={cx(styles.centeredContent, styles.others)}>
<div className={cx(styles.containerBox, styles.centeredContent, styles.rowContainer)}>
<h2 className={cx(styles.headerSection, styles.headerSmall)}>Add a row</h2>
<h5 className={cx(styles.bodySection, styles.bodySmall)}>
Group your visualizations into expandable sections.
</h5>
<Button
icon="plus"
fill="outline"
aria-label="Add new row"
onClick={() => {
reportInteraction('Create new row');
onCreateNewRow();
}}
disabled={!canCreate}
>
Add row
</Button>
</div>
<div className={cx(styles.containerBox, styles.centeredContent, styles.libraryContainer)}>
<h2 className={cx(styles.headerSection, styles.headerSmall)}>Import panel</h2>
<h5 className={cx(styles.bodySection, styles.bodySmall)}>
Import visualizations that are shared with other dashboards.
</h5>
<Button
icon="plus"
fill="outline"
aria-label="Add new panel from panel library"
onClick={() => {
reportInteraction('Add a panel from the panel library');
onAddLibraryPanel();
}}
disabled={!canCreate}
>
Import library panel
</Button>
</div>
</div>
</div>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css({
label: 'dashboard-empty-wrapper',
flexDirection: 'column',
maxWidth: '890px',
gap: theme.spacing.gridSize * 4,
}),
containerBox: css({
label: 'container-box',
flexDirection: 'column',
boxSizing: 'border-box',
border: '1px dashed rgba(110, 159, 255, 0.5)',
}),
centeredContent: css({
label: 'centered',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}),
visualizationContainer: css({
label: 'visualization-container',
padding: theme.spacing.gridSize * 4,
}),
others: css({
label: 'others-wrapper',
alignItems: 'stretch',
flexDirection: 'row',
gap: theme.spacing.gridSize * 4,
[theme.breakpoints.down('sm')]: {
flexDirection: 'column',
},
}),
rowContainer: css({
label: 'row-container',
padding: theme.spacing.gridSize * 3,
}),
libraryContainer: css({
label: 'library-container',
padding: theme.spacing.gridSize * 3,
}),
visualizationContent: css({
gap: theme.spacing.gridSize * 2,
}),
headerSection: css({
label: 'header-section',
fontWeight: 600,
textAlign: 'center',
}),
headerBig: css({
marginBottom: theme.spacing.gridSize * 2,
}),
headerSmall: css({
marginBottom: theme.spacing.gridSize,
}),
bodySection: css({
label: 'body-section',
fontWeight: theme.typography.fontWeightRegular,
color: theme.colors.text.secondary,
textAlign: 'center',
}),
bodyBig: css({
maxWidth: '75%',
marginBottom: theme.spacing.gridSize * 4,
}),
bodySmall: css({
marginBottom: theme.spacing.gridSize * 3,
}),
};
};

View File

@ -8,11 +8,13 @@ import { config } from '@grafana/runtime';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { DashboardPanelsChangedEvent } from 'app/types/events';
import { AddLibraryPanelWidget } from '../components/AddLibraryPanelWidget';
import { AddPanelWidget } from '../components/AddPanelWidget';
import { DashboardRow } from '../components/DashboardRow';
import { DashboardModel, PanelModel } from '../state';
import { GridPos } from '../state/PanelModel';
import { DashboardEmpty } from './DashboardEmpty';
import { DashboardPanel } from './DashboardPanel';
export interface Props {
@ -187,6 +189,10 @@ export class DashboardGrid extends PureComponent<Props, State> {
return <AddPanelWidget key={panel.key} panel={panel} dashboard={this.props.dashboard} />;
}
if (panel.type === 'add-library-panel') {
return <AddLibraryPanelWidget key={panel.key} panel={panel} dashboard={this.props.dashboard} />;
}
return (
<DashboardPanel
key={panel.key}
@ -203,7 +209,8 @@ export class DashboardGrid extends PureComponent<Props, State> {
}
render() {
const { isEditable } = this.props;
const { dashboard, isEditable } = this.props;
const hasPanels = dashboard.panels && dashboard.panels.length > 0;
/**
* We have a parent with "flex: 1 1 0" we need to reset it to "flex: 1 1 auto" to have the AutoSizer
@ -225,7 +232,40 @@ export class DashboardGrid extends PureComponent<Props, State> {
moving panels. https://github.com/grafana/grafana/issues/18497
theme.breakpoints.md = 769
*/
return (
return config.featureToggles.emptyDashboardPage ? (
hasPanels ? (
/**
* The children is using a width of 100% so we need to guarantee that it is wrapped
* in an element that has the calculated size given by the AutoSizer. The AutoSizer
* has a width of 0 and will let its content overflow its div.
*/
<div style={{ width: `${width}px`, height: '100%' }}>
<ReactGridLayout
width={width}
isDraggable={draggable}
isResizable={isEditable}
containerPadding={[0, 0]}
useCSSTransforms={false}
margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]}
cols={GRID_COLUMN_COUNT}
rowHeight={GRID_CELL_HEIGHT}
draggableHandle=".grid-drag-handle"
draggableCancel=".grid-drag-cancel"
layout={this.buildLayout()}
onDragStop={this.onDragStop}
onResize={this.onResize}
onResizeStop={this.onResizeStop}
onLayoutChange={this.onLayoutChange}
>
{this.renderPanels(width)}
</ReactGridLayout>
</div>
) : (
<div style={{ width: `${width}px`, height: '100%', padding: `${draggable ? '100px 0' : '0'}` }}>
<DashboardEmpty dashboard={dashboard} canCreate={isEditable} />
</div>
)
) : (
/**
* The children is using a width of 100% so we need to guarantee that it is wrapped
* in an element that has the calculated size given by the AutoSizer. The AutoSizer

View File

@ -181,3 +181,15 @@ export function calculateInnerPanelHeight(panel: PanelModel, containerHeight: nu
const headerHeight = panel.hasTitle() ? config.theme.panelHeaderHeight : 0;
return containerHeight - headerHeight - chromePadding - PANEL_BORDER;
}
export function calculateNewPanelGridPos(dashboard: DashboardModel): PanelModel['gridPos'] {
// Move all panels down by the height of the "add panel" widget.
// This is to work around an issue with react-grid-layout that can mess up the layout
// in certain configurations. (See https://github.com/react-grid-layout/react-grid-layout/issues/1787)
const addPanelWidgetHeight = 8;
for (const panel of dashboard.panelIterator()) {
panel.gridPos.y += addPanelWidgetHeight;
}
return { x: 0, y: 0, w: 12, h: addPanelWidgetHeight };
}