mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Dashboards: show changes in save dialog (#46557)
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
@@ -197,7 +197,7 @@ exports[`no enzyme tests`] = {
|
|||||||
"public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.test.tsx:2536713486": [
|
"public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.test.tsx:2536713486": [
|
||||||
[1, 17, 13, "RegExp match", "2409514259"]
|
[1, 17, 13, "RegExp match", "2409514259"]
|
||||||
],
|
],
|
||||||
"public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx:4134073823": [
|
"public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx:1262111696": [
|
||||||
[1, 17, 13, "RegExp match", "2409514259"]
|
[1, 17, 13, "RegExp match", "2409514259"]
|
||||||
],
|
],
|
||||||
"public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:1044891955": [
|
"public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:1044891955": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Story } from '@storybook/react';
|
import { Story } from '@storybook/react';
|
||||||
import { Button, Drawer } from '@grafana/ui';
|
import { Button, Drawer, Tab, TabsBar } from '@grafana/ui';
|
||||||
import { UseState } from '../../utils/storybook/UseState';
|
import { UseState } from '../../utils/storybook/UseState';
|
||||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||||
import mdx from './Drawer.mdx';
|
import mdx from './Drawer.mdx';
|
||||||
@@ -72,6 +72,7 @@ export const Global: Story<Props> = (args) => {
|
|||||||
</UseState>
|
</UseState>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Global.args = {
|
Global.args = {
|
||||||
title: 'Drawer title',
|
title: 'Drawer title',
|
||||||
};
|
};
|
||||||
@@ -223,7 +224,31 @@ export const InLine: Story<Props> = (args) => {
|
|||||||
</UseState>
|
</UseState>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
InLine.args = {
|
InLine.args = {
|
||||||
title: 'Storybook',
|
title: 'Storybook',
|
||||||
inline: true,
|
inline: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function WithTabs() {
|
||||||
|
const [activeTab, setActiveTab] = useState('options');
|
||||||
|
|
||||||
|
const tabs = (
|
||||||
|
<TabsBar>
|
||||||
|
<Tab label={'Options'} active={activeTab === 'options'} onChangeTab={() => setActiveTab('options')} />
|
||||||
|
<Tab
|
||||||
|
label={'Changes'}
|
||||||
|
active={activeTab === 'changes'}
|
||||||
|
onChangeTab={() => setActiveTab('changes')}
|
||||||
|
counter={10}
|
||||||
|
/>
|
||||||
|
</TabsBar>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer title={'Main title'} subtitle={'Sub title'} width={700} onClose={() => {}} tabs={tabs}>
|
||||||
|
{activeTab === 'options' && <div>Here are some options</div>}
|
||||||
|
{activeTab === 'changes' && <div>Here are some changes</div>}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { CSSProperties, FC, ReactNode, useState, useEffect } from 'react';
|
import React, { CSSProperties, ReactNode, useState, useEffect } from 'react';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import RcDrawer from 'rc-drawer';
|
import RcDrawer from 'rc-drawer';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
@@ -6,7 +6,7 @@ import { selectors } from '@grafana/e2e-selectors';
|
|||||||
|
|
||||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||||
import { IconButton } from '../IconButton/IconButton';
|
import { IconButton } from '../IconButton/IconButton';
|
||||||
import { stylesFactory, useTheme2 } from '../../themes';
|
import { useStyles2 } from '../../themes';
|
||||||
import { FocusScope } from '@react-aria/focus';
|
import { FocusScope } from '@react-aria/focus';
|
||||||
import { useDialog } from '@react-aria/dialog';
|
import { useDialog } from '@react-aria/dialog';
|
||||||
import { useOverlay } from '@react-aria/overlays';
|
import { useOverlay } from '@react-aria/overlays';
|
||||||
@@ -25,71 +25,15 @@ export interface Props {
|
|||||||
width?: number | string;
|
width?: number | string;
|
||||||
/** Should the Drawer be expandable to full width */
|
/** Should the Drawer be expandable to full width */
|
||||||
expandable?: boolean;
|
expandable?: boolean;
|
||||||
|
/** Tabs */
|
||||||
|
tabs?: React.ReactNode;
|
||||||
/** Set to true if the component rendered within in drawer content has its own scroll */
|
/** Set to true if the component rendered within in drawer content has its own scroll */
|
||||||
scrollableContent?: boolean;
|
scrollableContent?: boolean;
|
||||||
|
|
||||||
/** Callback for closing the drawer */
|
/** Callback for closing the drawer */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyles = stylesFactory((theme: GrafanaTheme2, scrollableContent: boolean) => {
|
export function Drawer({
|
||||||
return {
|
|
||||||
container: css`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
`,
|
|
||||||
drawer: css`
|
|
||||||
.drawer-content {
|
|
||||||
background-color: ${theme.colors.background.primary};
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
&.drawer-open .drawer-mask {
|
|
||||||
background-color: ${theme.components.overlay.background};
|
|
||||||
backdrop-filter: blur(1px);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.drawer-mask {
|
|
||||||
background-color: ${theme.components.overlay.background};
|
|
||||||
backdrop-filter: blur(1px);
|
|
||||||
}
|
|
||||||
.drawer-open .drawer-content-wrapper {
|
|
||||||
box-shadow: ${theme.shadows.z3};
|
|
||||||
}
|
|
||||||
z-index: ${theme.zIndex.dropdown};
|
|
||||||
`,
|
|
||||||
header: css`
|
|
||||||
background-color: ${theme.colors.background.canvas};
|
|
||||||
z-index: 1;
|
|
||||||
flex-grow: 0;
|
|
||||||
padding-top: ${theme.spacing(0.5)};
|
|
||||||
`,
|
|
||||||
actions: css`
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: flex-end;
|
|
||||||
`,
|
|
||||||
titleWrapper: css`
|
|
||||||
margin-bottom: ${theme.spacing(3)};
|
|
||||||
padding: ${theme.spacing(0, 1, 0, 3)};
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
`,
|
|
||||||
titleSpacing: css`
|
|
||||||
margin-bottom: ${theme.spacing(2)};
|
|
||||||
`,
|
|
||||||
content: css`
|
|
||||||
padding: ${theme.spacing(2)};
|
|
||||||
flex: 1;
|
|
||||||
overflow: ${!scrollableContent ? 'hidden' : 'auto'};
|
|
||||||
z-index: 0;
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Drawer: FC<Props> = ({
|
|
||||||
children,
|
children,
|
||||||
inline = false,
|
inline = false,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -99,9 +43,9 @@ export const Drawer: FC<Props> = ({
|
|||||||
subtitle,
|
subtitle,
|
||||||
width = '40%',
|
width = '40%',
|
||||||
expandable = false,
|
expandable = false,
|
||||||
}) => {
|
tabs,
|
||||||
const theme = useTheme2();
|
}: Props) {
|
||||||
const drawerStyles = getStyles(theme, scrollableContent);
|
const drawerStyles = useStyles2(getStyles);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const currentWidth = isExpanded ? '100%' : width;
|
const currentWidth = isExpanded ? '100%' : width;
|
||||||
@@ -119,6 +63,8 @@ export const Drawer: FC<Props> = ({
|
|||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const content = <div className={drawerStyles.content}>{children}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RcDrawer
|
<RcDrawer
|
||||||
level={null}
|
level={null}
|
||||||
@@ -172,15 +118,76 @@ export const Drawer: FC<Props> = ({
|
|||||||
<h3 {...titleProps}>{title}</h3>
|
<h3 {...titleProps}>{title}</h3>
|
||||||
{typeof subtitle === 'string' && <div className="muted">{subtitle}</div>}
|
{typeof subtitle === 'string' && <div className="muted">{subtitle}</div>}
|
||||||
{typeof subtitle !== 'string' && subtitle}
|
{typeof subtitle !== 'string' && subtitle}
|
||||||
|
{tabs && <div className={drawerStyles.tabsWrapper}>{tabs}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{typeof title !== 'string' && title}
|
{typeof title !== 'string' && title}
|
||||||
<div className={drawerStyles.content}>
|
<div className={drawerStyles.contentScroll}>
|
||||||
{!scrollableContent ? children : <CustomScrollbar>{children}</CustomScrollbar>}
|
{!scrollableContent ? content : <CustomScrollbar autoHeightMin="100%">{content}</CustomScrollbar>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FocusScope>
|
</FocusScope>
|
||||||
</RcDrawer>
|
</RcDrawer>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
container: css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
flex: 1 1 0;
|
||||||
|
`,
|
||||||
|
drawer: css`
|
||||||
|
.drawer-content {
|
||||||
|
background-color: ${theme.colors.background.primary};
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
&.drawer-open .drawer-mask {
|
||||||
|
background-color: ${theme.components.overlay.background};
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.drawer-mask {
|
||||||
|
background-color: ${theme.components.overlay.background};
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
}
|
||||||
|
.drawer-open .drawer-content-wrapper {
|
||||||
|
box-shadow: ${theme.shadows.z3};
|
||||||
|
}
|
||||||
|
z-index: ${theme.zIndex.dropdown};
|
||||||
|
`,
|
||||||
|
header: css`
|
||||||
|
background-color: ${theme.colors.background.canvas};
|
||||||
|
flex-grow: 0;
|
||||||
|
padding-top: ${theme.spacing(0.5)};
|
||||||
|
`,
|
||||||
|
actions: css`
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: flex-end;
|
||||||
|
`,
|
||||||
|
titleWrapper: css`
|
||||||
|
margin-bottom: ${theme.spacing(3)};
|
||||||
|
padding: ${theme.spacing(0, 1, 0, 3)};
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
`,
|
||||||
|
content: css({
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
height: '100%',
|
||||||
|
flexGrow: 1,
|
||||||
|
}),
|
||||||
|
contentScroll: css({
|
||||||
|
minHeight: 0,
|
||||||
|
flex: 1,
|
||||||
|
}),
|
||||||
|
tabsWrapper: css({
|
||||||
|
paddingLeft: theme.spacing(2),
|
||||||
|
margin: theme.spacing(3, -1, -3, -3),
|
||||||
|
}),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import appEvents from 'app/core/app_events';
|
|||||||
import { getExploreUrl } from 'app/core/utils/explore';
|
import { getExploreUrl } from 'app/core/utils/explore';
|
||||||
import { DashboardModel } from 'app/features/dashboard/state';
|
import { DashboardModel } from 'app/features/dashboard/state';
|
||||||
import { ShareModal } from 'app/features/dashboard/components/ShareModal';
|
import { ShareModal } from 'app/features/dashboard/components/ShareModal';
|
||||||
import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
|
import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { exitKioskMode, toggleKioskMode } from '../navigation/kiosk';
|
import { exitKioskMode, toggleKioskMode } from '../navigation/kiosk';
|
||||||
import {
|
import {
|
||||||
@@ -200,7 +200,7 @@ export class KeybindingSrv {
|
|||||||
if (dashboard.meta.canSave) {
|
if (dashboard.meta.canSave) {
|
||||||
appEvents.publish(
|
appEvents.publish(
|
||||||
new ShowModalReactEvent({
|
new ShowModalReactEvent({
|
||||||
component: SaveDashboardModalProxy,
|
component: SaveDashboardDrawer,
|
||||||
props: {
|
props: {
|
||||||
dashboard,
|
dashboard,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
|
|||||||
import { DashboardModel } from '../../state';
|
import { DashboardModel } from '../../state';
|
||||||
import { KioskMode } from 'app/types';
|
import { KioskMode } from 'app/types';
|
||||||
import { ShareModal } from 'app/features/dashboard/components/ShareModal';
|
import { ShareModal } from 'app/features/dashboard/components/ShareModal';
|
||||||
import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
|
import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer';
|
||||||
import { DashboardCommentsModal } from 'app/features/dashboard/components/DashboardComments/DashboardCommentsModal';
|
import { DashboardCommentsModal } from 'app/features/dashboard/components/DashboardComments/DashboardCommentsModal';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { toggleKioskMode } from 'app/core/navigation/kiosk';
|
import { toggleKioskMode } from 'app/core/navigation/kiosk';
|
||||||
@@ -228,7 +228,7 @@ class DashNav extends PureComponent<Props> {
|
|||||||
tooltip="Save dashboard"
|
tooltip="Save dashboard"
|
||||||
icon="save"
|
icon="save"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showModal(SaveDashboardModalProxy, {
|
showModal(SaveDashboardDrawer, {
|
||||||
dashboard,
|
dashboard,
|
||||||
onDismiss: hideModal,
|
onDismiss: hideModal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { DataSourceApi, PanelData, PanelPlugin } from '@grafana/data';
|
import { DataSourceApi, formattedValueToString, getValueFormat, PanelData, PanelPlugin } from '@grafana/data';
|
||||||
import { getTemplateSrv } from '@grafana/runtime';
|
import { getTemplateSrv } from '@grafana/runtime';
|
||||||
import { CustomScrollbar, Drawer, TabContent } from '@grafana/ui';
|
import { Drawer, Tab, TabsBar } from '@grafana/ui';
|
||||||
import { getPanelInspectorStyles } from 'app/features/inspector/styles';
|
|
||||||
import { InspectMetadataTab } from 'app/features/inspector/InspectMetadataTab';
|
import { InspectMetadataTab } from 'app/features/inspector/InspectMetadataTab';
|
||||||
import { InspectSubtitle } from 'app/features/inspector/InspectSubtitle';
|
|
||||||
import { InspectJSONTab } from 'app/features/inspector/InspectJSONTab';
|
import { InspectJSONTab } from 'app/features/inspector/InspectJSONTab';
|
||||||
import { QueryInspector } from 'app/features/inspector/QueryInspector';
|
import { QueryInspector } from 'app/features/inspector/QueryInspector';
|
||||||
import { InspectStatsTab } from 'app/features/inspector/InspectStatsTab';
|
import { InspectStatsTab } from 'app/features/inspector/InspectStatsTab';
|
||||||
@@ -13,7 +11,6 @@ import { InspectDataTab } from 'app/features/inspector/InspectDataTab';
|
|||||||
import { InspectTab } from 'app/features/inspector/types';
|
import { InspectTab } from 'app/features/inspector/types';
|
||||||
import { DashboardModel, PanelModel } from '../../state';
|
import { DashboardModel, PanelModel } from '../../state';
|
||||||
import { GetDataOptions } from '../../../query/state/PanelQueryRunner';
|
import { GetDataOptions } from '../../../query/state/PanelQueryRunner';
|
||||||
import { InspectActionsTab } from './PanelInspectActions';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dashboard: DashboardModel;
|
dashboard: DashboardModel;
|
||||||
@@ -50,7 +47,6 @@ export const InspectContent: React.FC<Props> = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = getPanelInspectorStyles();
|
|
||||||
const error = data?.error;
|
const error = data?.error;
|
||||||
|
|
||||||
// Validate that the active tab is actually valid and allowed
|
// Validate that the active tab is actually valid and allowed
|
||||||
@@ -58,22 +54,31 @@ export const InspectContent: React.FC<Props> = ({
|
|||||||
if (!tabs.find((item) => item.value === currentTab)) {
|
if (!tabs.find((item) => item.value === currentTab)) {
|
||||||
activeTab = InspectTab.JSON;
|
activeTab = InspectTab.JSON;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = getTemplateSrv().replace(panel.title, panel.scopedVars, 'text');
|
const title = getTemplateSrv().replace(panel.title, panel.scopedVars, 'text');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={`Inspect: ${title || 'Panel'}`}
|
title={`Inspect: ${title || 'Panel'}`}
|
||||||
subtitle={
|
subtitle={data && formatStats(data)}
|
||||||
<InspectSubtitle
|
|
||||||
tabs={tabs}
|
|
||||||
tab={activeTab}
|
|
||||||
data={data}
|
|
||||||
onSelectTab={(item) => setCurrentTab(item.value || InspectTab.Data)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
width="50%"
|
width="50%"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
expandable
|
expandable
|
||||||
|
scrollableContent
|
||||||
|
tabs={
|
||||||
|
<TabsBar>
|
||||||
|
{tabs.map((t, index) => {
|
||||||
|
return (
|
||||||
|
<Tab
|
||||||
|
key={`${t.value}-${index}`}
|
||||||
|
label={t.label}
|
||||||
|
active={t.value === activeTab}
|
||||||
|
onChangeTab={() => setCurrentTab(t.value || InspectTab.Data)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TabsBar>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{activeTab === InspectTab.Data && (
|
{activeTab === InspectTab.Data && (
|
||||||
<InspectDataTab
|
<InspectDataTab
|
||||||
@@ -85,23 +90,31 @@ export const InspectContent: React.FC<Props> = ({
|
|||||||
timeZone={dashboard.timezone}
|
timeZone={dashboard.timezone}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CustomScrollbar autoHeightMin="100%">
|
{data && activeTab === InspectTab.Meta && (
|
||||||
<TabContent className={styles.tabContent}>
|
<InspectMetadataTab data={data} metadataDatasource={metadataDatasource} />
|
||||||
{data && activeTab === InspectTab.Meta && (
|
)}
|
||||||
<InspectMetadataTab data={data} metadataDatasource={metadataDatasource} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === InspectTab.JSON && (
|
{activeTab === InspectTab.JSON && (
|
||||||
<InspectJSONTab panel={panel} dashboard={dashboard} data={data} onClose={onClose} />
|
<InspectJSONTab panel={panel} dashboard={dashboard} data={data} onClose={onClose} />
|
||||||
)}
|
)}
|
||||||
{activeTab === InspectTab.Error && <InspectErrorTab error={error} />}
|
{activeTab === InspectTab.Error && <InspectErrorTab error={error} />}
|
||||||
{data && activeTab === InspectTab.Stats && <InspectStatsTab data={data} timeZone={dashboard.getTimezone()} />}
|
{data && activeTab === InspectTab.Stats && <InspectStatsTab data={data} timeZone={dashboard.getTimezone()} />}
|
||||||
{data && activeTab === InspectTab.Query && (
|
{data && activeTab === InspectTab.Query && (
|
||||||
<QueryInspector panel={panel} data={data.series} onRefreshQuery={() => panel.refresh()} />
|
<QueryInspector panel={panel} data={data.series} onRefreshQuery={() => panel.refresh()} />
|
||||||
)}
|
)}
|
||||||
{activeTab === InspectTab.Actions && <InspectActionsTab panel={panel} data={data} />}
|
|
||||||
</TabContent>
|
|
||||||
</CustomScrollbar>
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatStats(data: PanelData) {
|
||||||
|
const { request } = data;
|
||||||
|
if (!request) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryCount = request.targets.length;
|
||||||
|
const requestTime = request.endTime ? request.endTime - request.startTime : 0;
|
||||||
|
const formatted = formattedValueToString(getValueFormat('ms')(requestTime));
|
||||||
|
|
||||||
|
return `${queryCount} queries with total query time of ${formatted}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { DashNavTimeControls } from '../DashNav/DashNavTimeControls';
|
|||||||
import { OptionsPane } from './OptionsPane';
|
import { OptionsPane } from './OptionsPane';
|
||||||
import { SubMenuItems } from 'app/features/dashboard/components/SubMenu/SubMenuItems';
|
import { SubMenuItems } from 'app/features/dashboard/components/SubMenu/SubMenuItems';
|
||||||
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
|
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
|
||||||
import { SaveDashboardModalProxy } from '../SaveDashboard/SaveDashboardModalProxy';
|
import { SaveDashboardDrawer } from '../SaveDashboard/SaveDashboardDrawer';
|
||||||
import { DashboardPanel } from '../../dashgrid/DashboardPanel';
|
import { DashboardPanel } from '../../dashgrid/DashboardPanel';
|
||||||
|
|
||||||
import { discardPanelChanges, initPanelEditor, updatePanelEditorUIState } from './state/actions';
|
import { discardPanelChanges, initPanelEditor, updatePanelEditorUIState } from './state/actions';
|
||||||
@@ -145,7 +145,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
|||||||
onSaveDashboard = () => {
|
onSaveDashboard = () => {
|
||||||
appEvents.publish(
|
appEvents.publish(
|
||||||
new ShowModalReactEvent({
|
new ShowModalReactEvent({
|
||||||
component: SaveDashboardModalProxy,
|
component: SaveDashboardDrawer,
|
||||||
props: { dashboard: this.props.dashboard },
|
props: { dashboard: this.props.dashboard },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { css } from '@emotion/css';
|
|
||||||
import { Modal } from '@grafana/ui';
|
|
||||||
import { SaveDashboardAsForm } from './forms/SaveDashboardAsForm';
|
|
||||||
import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy';
|
|
||||||
import { useDashboardSave } from './useDashboardSave';
|
|
||||||
import { SaveDashboardModalProps } from './types';
|
|
||||||
|
|
||||||
export const SaveDashboardAsModal: React.FC<
|
|
||||||
SaveDashboardModalProps & {
|
|
||||||
isNew?: boolean;
|
|
||||||
}
|
|
||||||
> = ({ dashboard, onDismiss, isNew }) => {
|
|
||||||
const { state, onDashboardSave } = useDashboardSave(dashboard);
|
|
||||||
const [dashboardSaveModelClone, setDashboardSaveModelClone] = useState();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{state.error && (
|
|
||||||
<SaveDashboardErrorProxy
|
|
||||||
error={state.error}
|
|
||||||
dashboard={dashboard}
|
|
||||||
dashboardSaveModel={dashboardSaveModelClone}
|
|
||||||
onDismiss={onDismiss}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!state.error && (
|
|
||||||
<Modal
|
|
||||||
isOpen={true}
|
|
||||||
title="Save dashboard as..."
|
|
||||||
icon="copy"
|
|
||||||
onDismiss={onDismiss}
|
|
||||||
className={css`
|
|
||||||
width: 500px;
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<SaveDashboardAsForm
|
|
||||||
dashboard={dashboard}
|
|
||||||
onCancel={onDismiss}
|
|
||||||
onSuccess={onDismiss}
|
|
||||||
onSubmit={(clone, options, dashboard) => {
|
|
||||||
setDashboardSaveModelClone(clone);
|
|
||||||
return onDashboardSave(clone, options, dashboard);
|
|
||||||
}}
|
|
||||||
isNew={isNew}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button, ButtonVariant, ModalsController, FullWidthButtonContainer } from '@grafana/ui';
|
import { Button, ButtonVariant, ModalsController, FullWidthButtonContainer } from '@grafana/ui';
|
||||||
import { DashboardModel } from 'app/features/dashboard/state';
|
import { DashboardModel } from 'app/features/dashboard/state';
|
||||||
import { SaveDashboardAsModal } from './SaveDashboardAsModal';
|
import { SaveDashboardDrawer } from './SaveDashboardDrawer';
|
||||||
import { SaveDashboardModalProxy } from './SaveDashboardModalProxy';
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
interface SaveDashboardButtonProps {
|
interface SaveDashboardButtonProps {
|
||||||
@@ -17,7 +16,7 @@ export const SaveDashboardButton: React.FC<SaveDashboardButtonProps> = ({ dashbo
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showModal(SaveDashboardModalProxy, {
|
showModal(SaveDashboardDrawer, {
|
||||||
dashboard,
|
dashboard,
|
||||||
onSaveSuccess,
|
onSaveSuccess,
|
||||||
onDismiss: hideModal,
|
onDismiss: hideModal,
|
||||||
@@ -45,10 +44,11 @@ export const SaveDashboardAsButton: React.FC<SaveDashboardButtonProps & { varian
|
|||||||
<FullWidthButtonContainer>
|
<FullWidthButtonContainer>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showModal(SaveDashboardAsModal, {
|
showModal(SaveDashboardDrawer, {
|
||||||
dashboard,
|
dashboard,
|
||||||
onSaveSuccess,
|
onSaveSuccess,
|
||||||
onDismiss: hideModal,
|
onDismiss: hideModal,
|
||||||
|
isCopy: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { Spinner, useStyles2 } from '@grafana/ui';
|
||||||
|
import { Diffs } from '../VersionHistory/utils';
|
||||||
|
import { DiffGroup } from '../VersionHistory/DiffGroup';
|
||||||
|
import { DiffViewer } from '../VersionHistory/DiffViewer';
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { useAsync } from 'react-use';
|
||||||
|
|
||||||
|
interface SaveDashboardDiffProps {
|
||||||
|
oldValue?: any;
|
||||||
|
newValue?: any;
|
||||||
|
|
||||||
|
// calculated by parent so we can see summary in tabs
|
||||||
|
diff?: Diffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SaveDashboardDiff = ({ diff, oldValue, newValue }: SaveDashboardDiffProps) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const loader = useAsync(async () => {
|
||||||
|
const oldJSON = JSON.stringify(oldValue ?? {}, null, 2);
|
||||||
|
const newJSON = JSON.stringify(newValue ?? {}, null, 2);
|
||||||
|
return {
|
||||||
|
oldJSON,
|
||||||
|
newJSON,
|
||||||
|
diffs: Object.entries(diff ?? []).map(([key, diffs]) => (
|
||||||
|
<DiffGroup diffs={diffs} key={key} title={key} /> // this takes a long time for large diffs
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}, [diff, oldValue, newValue]);
|
||||||
|
|
||||||
|
const { value } = loader;
|
||||||
|
if (!value || !oldValue) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.diffs.length) {
|
||||||
|
return <div>No changes in this dashboard</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.spacer}>{value.diffs}</div>
|
||||||
|
|
||||||
|
<h4>JSON Diff</h4>
|
||||||
|
<DiffViewer oldValue={value.oldJSON} newValue={value.newJSON} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
spacer: css`
|
||||||
|
margin-bottom: ${theme.v1.spacing.xl};
|
||||||
|
`,
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Drawer, Tab, TabsBar } from '@grafana/ui';
|
||||||
|
import { SaveDashboardData, SaveDashboardModalProps, SaveDashboardOptions } from './types';
|
||||||
|
import { jsonDiff } from '../VersionHistory/utils';
|
||||||
|
import { useAsync } from 'react-use';
|
||||||
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
|
import { useDashboardSave } from './useDashboardSave';
|
||||||
|
import { SaveProvisionedDashboardForm } from './forms/SaveProvisionedDashboardForm';
|
||||||
|
import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy';
|
||||||
|
import { SaveDashboardAsForm } from './forms/SaveDashboardAsForm';
|
||||||
|
import { SaveDashboardForm } from './forms/SaveDashboardForm';
|
||||||
|
import { SaveDashboardDiff } from './SaveDashboardDiff';
|
||||||
|
|
||||||
|
export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCopy }: SaveDashboardModalProps) => {
|
||||||
|
const [options, setOptions] = useState<SaveDashboardOptions>({});
|
||||||
|
|
||||||
|
const isProvisioned = dashboard.meta.provisioned;
|
||||||
|
const isNew = dashboard.version === 0;
|
||||||
|
|
||||||
|
const previous = useAsync(async () => {
|
||||||
|
if (isNew) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await backendSrv.getDashboardByUid(dashboard.uid);
|
||||||
|
return result.dashboard;
|
||||||
|
}, [dashboard, isNew]);
|
||||||
|
|
||||||
|
const data = useMemo<SaveDashboardData>(() => {
|
||||||
|
const clone = dashboard.getSaveModelClone({
|
||||||
|
saveTimerange: Boolean(options.saveTimerange),
|
||||||
|
saveVariables: Boolean(options.saveVariables),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!previous.value) {
|
||||||
|
return { clone, diff: {}, diffCount: 0, hasChanges: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloneJSON = JSON.stringify(clone, null, 2);
|
||||||
|
const cloneSafe = JSON.parse(cloneJSON); // avoids undefined issues
|
||||||
|
|
||||||
|
const diff = jsonDiff(previous.value, cloneSafe);
|
||||||
|
let diffCount = 0;
|
||||||
|
for (const d of Object.values(diff)) {
|
||||||
|
diffCount += d.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clone,
|
||||||
|
diff,
|
||||||
|
diffCount,
|
||||||
|
hasChanges: diffCount > 0 && !isNew,
|
||||||
|
};
|
||||||
|
}, [dashboard, previous.value, options, isNew]);
|
||||||
|
|
||||||
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
const { state, onDashboardSave } = useDashboardSave(dashboard);
|
||||||
|
const onSuccess = onSaveSuccess
|
||||||
|
? () => {
|
||||||
|
onDismiss();
|
||||||
|
onSaveSuccess();
|
||||||
|
}
|
||||||
|
: onDismiss;
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
if (showDiff) {
|
||||||
|
return <SaveDashboardDiff diff={data.diff} oldValue={previous.value} newValue={data.clone} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNew || isCopy) {
|
||||||
|
return (
|
||||||
|
<SaveDashboardAsForm
|
||||||
|
dashboard={dashboard}
|
||||||
|
onCancel={onDismiss}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
onSubmit={onDashboardSave}
|
||||||
|
isNew={isNew}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProvisioned) {
|
||||||
|
return <SaveProvisionedDashboardForm dashboard={dashboard} onCancel={onDismiss} onSuccess={onSuccess} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SaveDashboardForm
|
||||||
|
dashboard={dashboard}
|
||||||
|
saveModel={data}
|
||||||
|
onCancel={onDismiss}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
onSubmit={onDashboardSave}
|
||||||
|
options={options}
|
||||||
|
onOptionsChange={setOptions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.error) {
|
||||||
|
return (
|
||||||
|
<SaveDashboardErrorProxy
|
||||||
|
error={state.error}
|
||||||
|
dashboard={dashboard}
|
||||||
|
dashboardSaveModel={data.clone}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={isCopy ? 'Save dashboard copy' : 'Save dashboard'}
|
||||||
|
onClose={onDismiss}
|
||||||
|
width={'40%'}
|
||||||
|
subtitle={dashboard.title}
|
||||||
|
tabs={
|
||||||
|
<TabsBar>
|
||||||
|
<Tab label={'Details'} active={!showDiff} onChangeTab={() => setShowDiff(false)} />
|
||||||
|
<Tab label={'Changes'} active={showDiff} onChangeTab={() => setShowDiff(true)} counter={data.diffCount} />
|
||||||
|
</TabsBar>
|
||||||
|
}
|
||||||
|
expandable
|
||||||
|
scrollableContent
|
||||||
|
>
|
||||||
|
{renderBody()}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Modal } from '@grafana/ui';
|
|
||||||
import { css } from '@emotion/css';
|
|
||||||
import { SaveDashboardForm } from './forms/SaveDashboardForm';
|
|
||||||
import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy';
|
|
||||||
import { useDashboardSave } from './useDashboardSave';
|
|
||||||
import { SaveDashboardModalProps } from './types';
|
|
||||||
|
|
||||||
export const SaveDashboardModal: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss, onSaveSuccess }) => {
|
|
||||||
const { state, onDashboardSave } = useDashboardSave(dashboard);
|
|
||||||
const [dashboardSaveModelClone, setDashboardSaveModelClone] = useState();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{state.error && (
|
|
||||||
<SaveDashboardErrorProxy
|
|
||||||
error={state.error}
|
|
||||||
dashboard={dashboard}
|
|
||||||
dashboardSaveModel={dashboardSaveModelClone}
|
|
||||||
onDismiss={onDismiss}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!state.error && (
|
|
||||||
<Modal
|
|
||||||
isOpen={true}
|
|
||||||
title="Save dashboard"
|
|
||||||
icon="copy"
|
|
||||||
onDismiss={onDismiss}
|
|
||||||
className={css`
|
|
||||||
width: 500px;
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<SaveDashboardForm
|
|
||||||
dashboard={dashboard}
|
|
||||||
onCancel={onDismiss}
|
|
||||||
onSuccess={() => {
|
|
||||||
onDismiss();
|
|
||||||
if (onSaveSuccess) {
|
|
||||||
onSaveSuccess();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSubmit={(clone, options, dashboard) => {
|
|
||||||
setDashboardSaveModelClone(clone);
|
|
||||||
return onDashboardSave(clone, options, dashboard);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { SaveProvisionedDashboard } from './SaveProvisionedDashboard';
|
|
||||||
import { SaveDashboardAsModal } from './SaveDashboardAsModal';
|
|
||||||
import { SaveDashboardModalProps } from './types';
|
|
||||||
import { SaveDashboardModal } from './SaveDashboardModal';
|
|
||||||
|
|
||||||
export const SaveDashboardModalProxy: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss, onSaveSuccess }) => {
|
|
||||||
const isProvisioned = dashboard.meta.provisioned;
|
|
||||||
const isNew = dashboard.version === 0;
|
|
||||||
const isChanged = dashboard.version > 0;
|
|
||||||
|
|
||||||
const modalProps = {
|
|
||||||
dashboard,
|
|
||||||
onDismiss,
|
|
||||||
onSaveSuccess,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isChanged && !isProvisioned && <SaveDashboardModal {...modalProps} />}
|
|
||||||
{isProvisioned && <SaveProvisionedDashboard {...modalProps} />}
|
|
||||||
{isNew && <SaveDashboardAsModal {...modalProps} isNew />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Modal } from '@grafana/ui';
|
|
||||||
import { SaveProvisionedDashboardForm } from './forms/SaveProvisionedDashboardForm';
|
|
||||||
import { SaveDashboardModalProps } from './types';
|
|
||||||
|
|
||||||
export const SaveProvisionedDashboard: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss }) => {
|
|
||||||
return (
|
|
||||||
<Modal isOpen={true} title="Cannot save provisioned dashboard" icon="copy" onDismiss={onDismiss}>
|
|
||||||
<SaveProvisionedDashboardForm dashboard={dashboard} onCancel={onDismiss} onSuccess={onDismiss} />
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button, Input, Switch, Form, Field, InputControl, Modal } from '@grafana/ui';
|
import { Button, Input, Switch, Form, Field, InputControl, HorizontalGroup } from '@grafana/ui';
|
||||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||||
import { SaveDashboardFormProps } from '../types';
|
import { SaveDashboardFormProps } from '../types';
|
||||||
@@ -119,17 +119,19 @@ export const SaveDashboardAsForm: React.FC<SaveDashboardAsFormProps> = ({
|
|||||||
name="$folder"
|
name="$folder"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Copy tags">
|
{!isNew && (
|
||||||
<Switch {...register('copyTags')} />
|
<Field label="Copy tags">
|
||||||
</Field>
|
<Switch {...register('copyTags')} />
|
||||||
<Modal.ButtonRow>
|
</Field>
|
||||||
|
)}
|
||||||
|
<HorizontalGroup>
|
||||||
<Button type="button" variant="secondary" onClick={onCancel} fill="outline">
|
<Button type="button" variant="secondary" onClick={onCancel} fill="outline">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" aria-label="Save dashboard button">
|
<Button type="submit" aria-label="Save dashboard button">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.ButtonRow>
|
</HorizontalGroup>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mount } from 'enzyme';
|
|||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { DashboardModel } from 'app/features/dashboard/state';
|
import { DashboardModel } from 'app/features/dashboard/state';
|
||||||
import { SaveDashboardForm } from './SaveDashboardForm';
|
import { SaveDashboardForm } from './SaveDashboardForm';
|
||||||
|
import { SaveDashboardOptions } from '../types';
|
||||||
|
|
||||||
const prepareDashboardMock = (
|
const prepareDashboardMock = (
|
||||||
timeChanged: boolean,
|
timeChanged: boolean,
|
||||||
@@ -36,6 +37,16 @@ const renderAndSubmitForm = async (dashboard: any, submitSpy: any) => {
|
|||||||
submitSpy(jsonModel);
|
submitSpy(jsonModel);
|
||||||
return { status: 'success' };
|
return { status: 'success' };
|
||||||
}}
|
}}
|
||||||
|
saveModel={{
|
||||||
|
clone: dashboard,
|
||||||
|
diff: {},
|
||||||
|
diffCount: 0,
|
||||||
|
hasChanges: true,
|
||||||
|
}}
|
||||||
|
options={{}}
|
||||||
|
onOptionsChange={(opts: SaveDashboardOptions) => {
|
||||||
|
return;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,6 +67,16 @@ describe('SaveDashboardAsForm', () => {
|
|||||||
onSubmit={async () => {
|
onSubmit={async () => {
|
||||||
return {};
|
return {};
|
||||||
}}
|
}}
|
||||||
|
saveModel={{
|
||||||
|
clone: prepareDashboardMock(true, true, jest.fn(), jest.fn()) as any,
|
||||||
|
diff: {},
|
||||||
|
diffCount: 0,
|
||||||
|
hasChanges: true,
|
||||||
|
}}
|
||||||
|
options={{}}
|
||||||
|
onOptionsChange={(opts: SaveDashboardOptions) => {
|
||||||
|
return;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +1,106 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Button, Checkbox, Form, Modal, TextArea } from '@grafana/ui';
|
import { Button, Checkbox, Form, TextArea } from '@grafana/ui';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
import { SaveDashboardFormProps } from '../types';
|
import { DashboardModel } from 'app/features/dashboard/state';
|
||||||
|
import { SaveDashboardData, SaveDashboardOptions } from '../types';
|
||||||
|
import { Stack } from '@grafana/experimental';
|
||||||
|
|
||||||
interface SaveDashboardFormDTO {
|
interface FormDTO {
|
||||||
message: string;
|
message: string;
|
||||||
saveVariables: boolean;
|
|
||||||
saveTimerange: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SaveDashboardForm: React.FC<SaveDashboardFormProps> = ({ dashboard, onCancel, onSuccess, onSubmit }) => {
|
type Props = {
|
||||||
|
dashboard: DashboardModel; // original
|
||||||
|
saveModel: SaveDashboardData; // already cloned
|
||||||
|
onCancel: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onSubmit?: (clone: any, options: SaveDashboardOptions, dashboard: DashboardModel) => Promise<any>;
|
||||||
|
options: SaveDashboardOptions;
|
||||||
|
onOptionsChange: (opts: SaveDashboardOptions) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SaveDashboardForm = ({
|
||||||
|
dashboard,
|
||||||
|
saveModel,
|
||||||
|
options,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
onSuccess,
|
||||||
|
onOptionsChange,
|
||||||
|
}: Props) => {
|
||||||
const hasTimeChanged = useMemo(() => dashboard.hasTimeChanged(), [dashboard]);
|
const hasTimeChanged = useMemo(() => dashboard.hasTimeChanged(), [dashboard]);
|
||||||
const hasVariableChanged = useMemo(() => dashboard.hasVariableValuesChanged(), [dashboard]);
|
const hasVariableChanged = useMemo(() => dashboard.hasVariableValuesChanged(), [dashboard]);
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
onSubmit={async (data: SaveDashboardFormDTO) => {
|
onSubmit={async (data: FormDTO) => {
|
||||||
if (!onSubmit) {
|
if (!onSubmit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setSaving(true);
|
||||||
const result = await onSubmit(dashboard.getSaveModelClone(data), data, dashboard);
|
const result = await onSubmit(saveModel.clone, options, dashboard);
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
if (data.saveVariables) {
|
if (options.saveVariables) {
|
||||||
dashboard.resetOriginalVariables();
|
dashboard.resetOriginalVariables();
|
||||||
}
|
}
|
||||||
if (data.saveTimerange) {
|
if (options.saveTimerange) {
|
||||||
dashboard.resetOriginalTime();
|
dashboard.resetOriginalTime();
|
||||||
}
|
}
|
||||||
onSuccess();
|
onSuccess();
|
||||||
}
|
}
|
||||||
|
setSaving(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ register, errors }) => (
|
{({ register, errors }) => (
|
||||||
<>
|
<Stack direction="column" gap={2}>
|
||||||
<div>
|
{hasTimeChanged && (
|
||||||
{hasTimeChanged && (
|
<Checkbox
|
||||||
<Checkbox
|
checked={options.saveTimerange}
|
||||||
{...register('saveTimerange')}
|
onChange={() =>
|
||||||
label="Save current time range as dashboard default"
|
onOptionsChange({
|
||||||
aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
|
...options,
|
||||||
/>
|
saveTimerange: !options.saveTimerange,
|
||||||
)}
|
})
|
||||||
{hasVariableChanged && (
|
}
|
||||||
<Checkbox
|
label="Save current time range as dashboard default"
|
||||||
{...register('saveVariables')}
|
aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
|
||||||
label="Save current variable values as dashboard default"
|
/>
|
||||||
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
|
)}
|
||||||
/>
|
{hasVariableChanged && (
|
||||||
)}
|
<Checkbox
|
||||||
{(hasVariableChanged || hasTimeChanged) && <div className="gf-form-group" />}
|
checked={options.saveVariables}
|
||||||
|
onChange={() =>
|
||||||
|
onOptionsChange({
|
||||||
|
...options,
|
||||||
|
saveVariables: !options.saveVariables,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Save current variable values as dashboard default"
|
||||||
|
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<TextArea {...register('message')} placeholder="Add a note to describe your changes." autoFocus />
|
<TextArea {...register('message')} placeholder="Add a note to describe your changes." autoFocus rows={5} />
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal.ButtonRow>
|
<Stack alignItems="center">
|
||||||
<Button variant="secondary" onClick={onCancel} fill="outline">
|
<Button variant="secondary" onClick={onCancel} fill="outline">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" aria-label={selectors.pages.SaveDashboardModal.save}>
|
<Button
|
||||||
Save
|
type="submit"
|
||||||
|
disabled={!saveModel.hasChanges}
|
||||||
|
icon={saving ? 'fa fa-spinner' : undefined}
|
||||||
|
aria-label={selectors.pages.SaveDashboardModal.save}
|
||||||
|
>
|
||||||
|
{saving ? '' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.ButtonRow>
|
{!saveModel.hasChanges && <div>No changes to save</div>}
|
||||||
</>
|
</Stack>
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import { Button, ClipboardButton, Modal, stylesFactory, TextArea, useTheme } from '@grafana/ui';
|
import { Button, ClipboardButton, HorizontalGroup, stylesFactory, TextArea, useTheme } from '@grafana/ui';
|
||||||
import { SaveDashboardFormProps } from '../types';
|
import { SaveDashboardFormProps } from '../types';
|
||||||
import { GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { Stack } from '@grafana/experimental';
|
||||||
|
|
||||||
export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({ dashboard, onCancel }) => {
|
export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({ dashboard, onCancel }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -29,7 +30,7 @@ export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({
|
|||||||
const styles = getStyles(theme);
|
const styles = getStyles(theme);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<Stack direction="column" gap={2}>
|
||||||
<div>
|
<div>
|
||||||
This dashboard cannot be saved from the Grafana UI because it has been provisioned from another source. Copy
|
This dashboard cannot be saved from the Grafana UI because it has been provisioned from another source. Copy
|
||||||
the JSON or save it to a file below, then you can update your dashboard in the provisioning source.
|
the JSON or save it to a file below, then you can update your dashboard in the provisioning source.
|
||||||
@@ -57,7 +58,7 @@ export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({
|
|||||||
}}
|
}}
|
||||||
className={styles.json}
|
className={styles.json}
|
||||||
/>
|
/>
|
||||||
<Modal.ButtonRow>
|
<HorizontalGroup>
|
||||||
<Button variant="secondary" onClick={onCancel} fill="outline">
|
<Button variant="secondary" onClick={onCancel} fill="outline">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -65,8 +66,8 @@ export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({
|
|||||||
Copy JSON to clipboard
|
Copy JSON to clipboard
|
||||||
</ClipboardButton>
|
</ClipboardButton>
|
||||||
<Button onClick={saveToFile}>Save JSON to file</Button>
|
<Button onClick={saveToFile}>Save JSON to file</Button>
|
||||||
</Modal.ButtonRow>
|
</HorizontalGroup>
|
||||||
</div>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||||
|
import { Diffs } from '../VersionHistory/utils';
|
||||||
|
|
||||||
|
export interface SaveDashboardData {
|
||||||
|
clone: DashboardModel; // cloned copy
|
||||||
|
diff: Diffs;
|
||||||
|
diffCount: number; // cumulative count
|
||||||
|
hasChanges: boolean; // not new and has changes
|
||||||
|
}
|
||||||
|
|
||||||
export interface SaveDashboardOptions extends CloneOptions {
|
export interface SaveDashboardOptions extends CloneOptions {
|
||||||
folderId?: number;
|
folderId?: number;
|
||||||
@@ -18,4 +26,5 @@ export interface SaveDashboardModalProps {
|
|||||||
dashboard: DashboardModel;
|
dashboard: DashboardModel;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
onSaveSuccess?: () => void;
|
onSaveSuccess?: () => void;
|
||||||
|
isCopy?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
AnnotationQuery,
|
AnnotationQuery,
|
||||||
AppEvent,
|
AppEvent,
|
||||||
DashboardCursorSync,
|
DashboardCursorSync,
|
||||||
|
dateTime,
|
||||||
dateTimeFormat,
|
dateTimeFormat,
|
||||||
dateTimeFormatTimeAgo,
|
dateTimeFormatTimeAgo,
|
||||||
DateTimeInput,
|
DateTimeInput,
|
||||||
@@ -1077,7 +1078,16 @@ export class DashboardModel implements TimeModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasTimeChanged() {
|
hasTimeChanged() {
|
||||||
return !isEqual(this.time, this.originalTime);
|
const { time, originalTime } = this;
|
||||||
|
if (isEqual(time, originalTime)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare momemt values vs strings values
|
||||||
|
return !(
|
||||||
|
isEqual(dateTime(time?.from), dateTime(originalTime?.from)) &&
|
||||||
|
isEqual(dateTime(time?.to), dateTime(originalTime?.to))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
resetOriginalVariables(initial = false) {
|
resetOriginalVariables(initial = false) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
transformDataFrame,
|
transformDataFrame,
|
||||||
TimeZone,
|
TimeZone,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { Button, Container, Spinner, Table } from '@grafana/ui';
|
import { Button, Spinner, Table } from '@grafana/ui';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { InspectDataOptions } from './InspectDataOptions';
|
import { InspectDataOptions } from './InspectDataOptions';
|
||||||
import { getPanelInspectorStyles } from './styles';
|
import { getPanelInspectorStyles } from './styles';
|
||||||
@@ -232,8 +232,8 @@ export class InspectDataTab extends PureComponent<Props, State> {
|
|||||||
const hasTraces = dataFrames.some((df) => df?.meta?.preferredVisualisationType === 'trace');
|
const hasTraces = dataFrames.some((df) => df?.meta?.preferredVisualisationType === 'trace');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.dataTabContent} aria-label={selectors.components.PanelInspector.Data.content}>
|
<div className={styles.wrap} aria-label={selectors.components.PanelInspector.Data.content}>
|
||||||
<div className={styles.actionsWrapper}>
|
<div className={styles.toolbar}>
|
||||||
<InspectDataOptions
|
<InspectDataOptions
|
||||||
data={data}
|
data={data}
|
||||||
panel={panel}
|
panel={panel}
|
||||||
@@ -281,7 +281,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Container grow={1}>
|
<div className={styles.content}>
|
||||||
<AutoSizer>
|
<AutoSizer>
|
||||||
{({ width, height }) => {
|
{({ width, height }) => {
|
||||||
if (width === 0) {
|
if (width === 0) {
|
||||||
@@ -295,7 +295,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</AutoSizer>
|
</AutoSizer>
|
||||||
</Container>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const InspectErrorTab: React.FC<InspectErrorTabProps> = ({ error }) => {
|
|||||||
if (error.data) {
|
if (error.data) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3>{error.data.message}</h3>
|
<h4>{error.data.message}</h4>
|
||||||
<JSONFormatter json={error} open={2} />
|
<JSONFormatter json={error} open={2} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export class InspectJSONTab extends PureComponent<Props, State> {
|
|||||||
const styles = getPanelInspectorStyles();
|
const styles = getPanelInspectorStyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={styles.wrap}>
|
||||||
<div className={styles.toolbar} aria-label={selectors.components.PanelInspector.Json.content}>
|
<div className={styles.toolbar} aria-label={selectors.components.PanelInspector.Json.content}>
|
||||||
<Field label="Select source" className="flex-grow-1">
|
<Field label="Select source" className="flex-grow-1">
|
||||||
<Select
|
<Select
|
||||||
@@ -162,7 +162,7 @@ export class InspectJSONTab extends PureComponent<Props, State> {
|
|||||||
)}
|
)}
|
||||||
</AutoSizer>
|
</AutoSizer>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import React, { FC } from 'react';
|
|
||||||
import { css } from '@emotion/css';
|
|
||||||
import { stylesFactory, useTheme, Tab, TabsBar } from '@grafana/ui';
|
|
||||||
import { GrafanaTheme, SelectableValue, PanelData, getValueFormat, formattedValueToString } from '@grafana/data';
|
|
||||||
import { InspectTab } from '../inspector/types';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tab: InspectTab;
|
|
||||||
tabs: Array<{ label: string; value: InspectTab }>;
|
|
||||||
data?: PanelData;
|
|
||||||
onSelectTab: (tab: SelectableValue<InspectTab>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InspectSubtitle: FC<Props> = ({ tab, tabs, onSelectTab, data }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const styles = getStyles(theme);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{data && <div className="muted">{formatStats(data)}</div>}
|
|
||||||
<TabsBar className={styles.tabsBar}>
|
|
||||||
{tabs.map((t, index) => {
|
|
||||||
return (
|
|
||||||
<Tab
|
|
||||||
key={`${t.value}-${index}`}
|
|
||||||
label={t.label}
|
|
||||||
active={t.value === tab}
|
|
||||||
onChangeTab={() => onSelectTab(t)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TabsBar>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
|
||||||
return {
|
|
||||||
tabsBar: css`
|
|
||||||
padding-left: ${theme.spacing.md};
|
|
||||||
margin: ${theme.spacing.lg} -${theme.spacing.sm} -${theme.spacing.lg} -${theme.spacing.lg};
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatStats(data: PanelData) {
|
|
||||||
const { request } = data;
|
|
||||||
if (!request) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryCount = request.targets.length;
|
|
||||||
const requestTime = request.endTime ? request.endTime - request.startTime : 0;
|
|
||||||
const formatted = formattedValueToString(getValueFormat('ms')(requestTime));
|
|
||||||
|
|
||||||
return `${queryCount} queries with total query time of ${formatted}`;
|
|
||||||
}
|
|
||||||
@@ -264,7 +264,7 @@ export class QueryInspector extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={styles.wrap}>
|
||||||
<div aria-label={selectors.components.PanelInspector.Query.content}>
|
<div aria-label={selectors.components.PanelInspector.Query.content}>
|
||||||
<h3 className="section-heading">Query inspector</h3>
|
<h3 className="section-heading">Query inspector</h3>
|
||||||
<p className="small muted">
|
<p className="small muted">
|
||||||
@@ -306,7 +306,7 @@ export class QueryInspector extends PureComponent<Props, State> {
|
|||||||
)}
|
)}
|
||||||
<div className="flex-grow-1" />
|
<div className="flex-grow-1" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.contentQueryInspector}>
|
<div className={styles.content}>
|
||||||
{isLoading && <LoadingPlaceholder text="Loading query inspector..." />}
|
{isLoading && <LoadingPlaceholder text="Loading query inspector..." />}
|
||||||
{!isLoading && haveData && (
|
{!isLoading && haveData && (
|
||||||
<JSONFormatter json={response} open={openNodes} onDidRender={this.setFormattedJson} />
|
<JSONFormatter json={response} open={openNodes} onDidRender={this.setFormattedJson} />
|
||||||
@@ -315,7 +315,7 @@ export class QueryInspector extends PureComponent<Props, State> {
|
|||||||
<p className="muted">No request and response collected yet. Hit refresh button</p>
|
<p className="muted">No request and response collected yet. Hit refresh button</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,6 @@ export const getPanelInspectorStyles = stylesFactory(() => {
|
|||||||
content: css`
|
content: css`
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-bottom: 16px;
|
|
||||||
`,
|
|
||||||
contentQueryInspector: css`
|
|
||||||
flex-grow: 1;
|
|
||||||
padding: ${config.theme.spacing.md} 0;
|
|
||||||
`,
|
`,
|
||||||
editor: css`
|
editor: css`
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
@@ -42,20 +37,6 @@ export const getPanelInspectorStyles = stylesFactory(() => {
|
|||||||
dataFrameSelect: css`
|
dataFrameSelect: css`
|
||||||
flex-grow: 2;
|
flex-grow: 2;
|
||||||
`,
|
`,
|
||||||
tabContent: css`
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
`,
|
|
||||||
dataTabContent: css`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
`,
|
|
||||||
actionsWrapper: css`
|
|
||||||
display: flex;
|
|
||||||
`,
|
|
||||||
leftActions: css`
|
leftActions: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user