Migration: Share dashboard/panel modal (#22436)

* ShareModal: refactor dashboard export modal

* Modal: show react modals with appEvents

* ShareModal: embed panel tab

* ShareModal: bind to shortcut (p s)

* grafana-ui: ClipboardButton component

* ShareModal: use ClipboardButton component

* ClipboardButton: add to storybook

* ShareModal: use event-based approach for dashboard share

* ShareModal: remove unused

* ModalReact: pass theme to the component

* ShareModal: styles clean up

* DashboardExporter: fix tests

* fixed whitespace betwen icon and link

* ShareModal: use theme from config

* Modal: tab header refactor

* ShareModal: tests

* ShareModal: fix share url rendering

* ShareModal: remove unused angular files

* Chore: fix strictNullChecks errors

* Modal: provide theme for event-based modal usage

* ShareModal: use ModalsController for opening modal

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
pull/22543/head
Alexander Zobnin 5 years ago committed by GitHub
parent 87da6a293b
commit d66e72fa67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      packages/grafana-ui/src/components/Button/Button.tsx
  2. 40
      packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.story.tsx
  3. 50
      packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx
  4. 46
      packages/grafana-ui/src/components/Modal/Modal.story.tsx
  5. 85
      packages/grafana-ui/src/components/Modal/Modal.tsx
  6. 25
      packages/grafana-ui/src/components/Modal/ModalHeader.tsx
  7. 39
      packages/grafana-ui/src/components/Modal/ModalTabsHeader.tsx
  8. 60
      packages/grafana-ui/src/components/Modal/getModalStyles.ts
  9. 10
      packages/grafana-ui/src/components/index.ts
  10. 13
      public/app/core/services/keybindingSrv.ts
  11. 3
      public/app/core/services/util_srv.ts
  12. 23
      public/app/core/utils/url.ts
  13. 86
      public/app/features/dashboard/components/DashExportModal/DashExportCtrl.ts
  14. 27
      public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts
  15. 62
      public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts
  16. 1
      public/app/features/dashboard/components/DashExportModal/index.ts
  17. 25
      public/app/features/dashboard/components/DashExportModal/template.html
  18. 62
      public/app/features/dashboard/components/DashNav/DashNav.tsx
  19. 119
      public/app/features/dashboard/components/ShareModal/ShareEmbed.tsx
  20. 120
      public/app/features/dashboard/components/ShareModal/ShareExport.tsx
  21. 179
      public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx
  22. 142
      public/app/features/dashboard/components/ShareModal/ShareLink.tsx
  23. 86
      public/app/features/dashboard/components/ShareModal/ShareModal.tsx
  24. 150
      public/app/features/dashboard/components/ShareModal/ShareModalCtrl.test.ts
  25. 137
      public/app/features/dashboard/components/ShareModal/ShareModalCtrl.ts
  26. 316
      public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx
  27. 175
      public/app/features/dashboard/components/ShareModal/ShareSnapshotCtrl.ts
  28. 2
      public/app/features/dashboard/components/ShareModal/index.ts
  29. 182
      public/app/features/dashboard/components/ShareModal/template.html
  30. 126
      public/app/features/dashboard/components/ShareModal/utils.ts
  31. 1
      public/app/features/dashboard/index.ts
  32. 8
      public/app/features/dashboard/utils/panel.ts
  33. 4
      public/test/mocks/datasource_srv.ts

@ -17,7 +17,7 @@ type CommonProps = {
styles?: ButtonStyles;
};
type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>;
export type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>;
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
const theme = useContext(ThemeContext);
const { size, variant, icon, children, className, styles: stylesProp, ...buttonProps } = props;
@ -43,7 +43,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, r
Button.displayName = 'Button';
type LinkButtonProps = CommonProps &
export type LinkButtonProps = CommonProps &
AnchorHTMLAttributes<HTMLAnchorElement> & {
// We allow disabled here even though it is not standard for a link. We use it as a selector to style it as
// disabled.

@ -0,0 +1,40 @@
import React, { useState } from 'react';
import { storiesOf } from '@storybook/react';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { ClipboardButton } from './ClipboardButton';
import { Input } from '../Input/Input';
import { text } from '@storybook/addon-knobs';
const getKnobs = () => {
return {
buttonText: text('Button text', 'Copy to clipboard'),
inputText: text('Input', 'go run build.go -goos linux -pkg-arch amd64 ${OPT} package-only'),
clipboardCopyMessage: text('Copy message', 'Value copied to clipboard'),
};
};
const Wrapper = () => {
const { inputText, buttonText } = getKnobs();
const [copyMessage, setCopyMessage] = useState('');
return (
<div style={{ width: '100%' }}>
<div style={{ display: 'flex', width: '100%', marginBottom: '1em' }}>
<ClipboardButton
variant="secondary"
getText={() => getKnobs().inputText}
onClipboardCopy={() => setCopyMessage(getKnobs().clipboardCopyMessage)}
>
{buttonText}
</ClipboardButton>
<Input value={inputText} onChange={() => {}} />
</div>
<span>{copyMessage}</span>
</div>
);
};
const story = storiesOf('General/ClipboardButton', module);
story.addDecorator(withCenteredStory);
story.add('copy to clipboard', () => <Wrapper />);

@ -0,0 +1,50 @@
import React, { PureComponent } from 'react';
import Clipboard from 'clipboard';
import { Button, ButtonProps } from '../Button/Button';
interface Props extends ButtonProps {
getText(): string;
onClipboardCopy?(e: Clipboard.Event): void;
onClipboardError?(e: Clipboard.Event): void;
}
export class ClipboardButton extends PureComponent<Props> {
// @ts-ignore
private clipboard: Clipboard;
// @ts-ignore
private elem: HTMLButtonElement;
setRef = (elem: HTMLButtonElement) => {
this.elem = elem;
};
componentDidMount() {
const { getText, onClipboardCopy, onClipboardError } = this.props;
this.clipboard = new Clipboard(this.elem, {
text: () => getText(),
});
this.clipboard.on('success', (e: Clipboard.Event) => {
onClipboardCopy && onClipboardCopy(e);
});
this.clipboard.on('error', (e: Clipboard.Event) => {
onClipboardError && onClipboardError(e);
});
}
componentWillUnmount() {
this.clipboard.destroy();
}
render() {
const { getText, onClipboardCopy, onClipboardError, children, ...buttonProps } = this.props;
return (
<Button {...buttonProps} ref={this.setRef}>
{children}
</Button>
);
}
}

@ -1,9 +1,12 @@
import React from 'react';
import React, { useState } from 'react';
import { storiesOf } from '@storybook/react';
import { oneLineTrim } from 'common-tags';
import { text, boolean } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { UseState } from '../../utils/storybook/UseState';
import { Modal } from './Modal';
import { ModalTabsHeader } from './ModalTabsHeader';
import { TabContent } from '../Tabs/TabContent';
const getKnobs = () => {
return {
@ -24,7 +27,6 @@ amet.`
};
const ModalStories = storiesOf('General/Modal', module);
ModalStories.addDecorator(withCenteredStory);
ModalStories.add('default', () => {
@ -33,7 +35,7 @@ ModalStories.add('default', () => {
<Modal
title={
<div className="modal-header-title">
<i className="fa fa-share-square-o" />
<i className="fa fa-exclamation-triangle" />
<span className="p-l-1">My Modal</span>
</div>
}
@ -43,3 +45,41 @@ ModalStories.add('default', () => {
</Modal>
);
});
const tabs = [
{ label: '1st child', value: 'first', active: true },
{ label: '2nd child', value: 'second', active: false },
{ label: '3rd child', value: 'third', active: false },
];
ModalStories.add('with tabs', () => {
const [activeTab, setActiveTab] = useState('first');
const modalHeader = (
<ModalTabsHeader
title="Modal With Tabs"
icon="cog"
tabs={tabs}
activeTab={activeTab}
onChangeTab={t => {
setActiveTab(t.value);
}}
/>
);
return (
<UseState initialState={tabs}>
{(state, updateState) => {
return (
<div>
<Modal title={modalHeader} isOpen={true}>
<TabContent>
{activeTab === state[0].value && <div>First tab content</div>}
{activeTab === state[1].value && <div>Second tab content</div>}
{activeTab === state[2].value && <div>Third tab content</div>}
</TabContent>
</Modal>
</div>
);
}}
</UseState>
);
});

@ -1,15 +1,15 @@
import React from 'react';
import { Portal } from '../Portal/Portal';
import { css, cx } from 'emotion';
import { stylesFactory, withTheme } from '../../themes';
import { GrafanaTheme } from '@grafana/data';
import { Icon } from '../Icon/Icon';
import { cx } from 'emotion';
import { withTheme } from '../../themes';
import { IconType } from '../Icon/types';
import { Themeable } from '../../types';
import { getModalStyles } from './getModalStyles';
import { ModalHeader } from './ModalHeader';
interface Props {
interface Props extends Themeable {
icon?: IconType;
title: string | JSX.Element;
theme: GrafanaTheme;
className?: string;
isOpen?: boolean;
@ -30,21 +30,15 @@ export class UnthemedModal extends React.PureComponent<Props> {
this.onDismiss();
};
renderDefaultHeader() {
const { title, icon, theme } = this.props;
const styles = getStyles(theme);
renderDefaultHeader(title: string) {
const { icon } = this.props;
return (
<h2 className={styles.modalHeaderTitle}>
{icon && <Icon name={icon} className={styles.modalHeaderIcon} />}
{title}
</h2>
);
return <ModalHeader icon={icon} title={title} />;
}
render() {
const { title, isOpen = false, theme, className } = this.props;
const styles = getStyles(theme);
const styles = getModalStyles(theme);
if (!isOpen) {
return null;
@ -54,7 +48,7 @@ export class UnthemedModal extends React.PureComponent<Props> {
<Portal>
<div className={cx(styles.modal, className)}>
<div className={styles.modalHeader}>
{typeof title === 'string' ? this.renderDefaultHeader() : title}
{typeof title === 'string' ? this.renderDefaultHeader(title) : title}
<a className={styles.modalHeaderClose} onClick={this.onDismiss}>
<i className="fa fa-remove" />
</a>
@ -68,60 +62,3 @@ export class UnthemedModal extends React.PureComponent<Props> {
}
export const Modal = withTheme(UnthemedModal);
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
modal: css`
position: fixed;
z-index: ${theme.zIndex.modal};
background: ${theme.colors.pageBg};
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
background-clip: padding-box;
outline: none;
width: 750px;
max-width: 100%;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
top: 10%;
`,
modalBackdrop: css`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: ${theme.zIndex.modalBackdrop};
background-color: ${theme.colors.blueFaint};
opacity: 0.8;
backdrop-filter: blur(4px);
`,
modalHeader: css`
background: ${theme.background.pageHeader};
box-shadow: ${theme.shadow.pageHeader};
border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
display: flex;
`,
modalHeaderTitle: css`
font-size: ${theme.typography.heading.h3};
padding-top: ${theme.spacing.sm};
margin: 0 ${theme.spacing.md};
`,
modalHeaderIcon: css`
margin-right: ${theme.spacing.md};
font-size: inherit;
&:before {
vertical-align: baseline;
}
`,
modalHeaderClose: css`
margin-left: auto;
padding: 9px ${theme.spacing.d};
`,
modalContent: css`
padding: calc(${theme.spacing.d} * 2);
overflow: auto;
width: 100%;
max-height: calc(90vh - ${theme.spacing.d} * 2);
`,
}));

@ -0,0 +1,25 @@
import React, { useContext } from 'react';
import { getModalStyles } from './getModalStyles';
import { IconType } from '../Icon/types';
import { ThemeContext } from '../../themes';
import { Icon } from '../Icon/Icon';
interface Props {
title: string;
icon?: IconType;
}
export const ModalHeader: React.FC<Props> = ({ icon, title, children }) => {
const theme = useContext(ThemeContext);
const styles = getModalStyles(theme);
return (
<>
<h2 className={styles.modalHeaderTitle}>
{icon && <Icon name={icon} className={styles.modalHeaderIcon} />}
{title}
</h2>
{children}
</>
);
};

@ -0,0 +1,39 @@
import React from 'react';
import { IconType } from '../Icon/types';
import { TabsBar } from '../Tabs/TabsBar';
import { Tab } from '../Tabs/Tab';
import { ModalHeader } from './ModalHeader';
interface ModalTab {
value: string;
label: string;
icon?: string;
}
interface Props {
icon: IconType;
title: string;
tabs: ModalTab[];
activeTab: string;
onChangeTab(tab: ModalTab): void;
}
export const ModalTabsHeader: React.FC<Props> = ({ icon, title, tabs, activeTab, onChangeTab }) => {
return (
<ModalHeader icon={icon} title={title}>
<TabsBar hideBorder={true}>
{tabs.map((t, index) => {
return (
<Tab
key={`${t.value}-${index}`}
label={t.label}
icon={t.icon}
active={t.value === activeTab}
onChangeTab={() => onChangeTab(t)}
/>
);
})}
</TabsBar>
</ModalHeader>
);
};

@ -0,0 +1,60 @@
import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { stylesFactory } from '../../themes';
export const getModalStyles = stylesFactory((theme: GrafanaTheme) => ({
modal: css`
position: fixed;
z-index: ${theme.zIndex.modal};
background: ${theme.colors.pageBg};
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
background-clip: padding-box;
outline: none;
width: 750px;
max-width: 100%;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
top: 10%;
`,
modalBackdrop: css`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: ${theme.zIndex.modalBackdrop};
background-color: ${theme.colors.blueFaint};
opacity: 0.8;
backdrop-filter: blur(4px);
`,
modalHeader: css`
background: ${theme.background.pageHeader};
box-shadow: ${theme.shadow.pageHeader};
border-bottom: 1px solid ${theme.colors.pageHeaderBorder};
display: flex;
`,
modalHeaderTitle: css`
font-size: ${theme.typography.heading.h3};
padding-top: ${theme.spacing.sm};
margin: 0 ${theme.spacing.md};
`,
modalHeaderIcon: css`
margin-right: ${theme.spacing.md};
font-size: inherit;
&:before {
vertical-align: baseline;
}
`,
modalHeaderClose: css`
margin-left: auto;
padding: 9px ${theme.spacing.d};
`,
modalContent: css`
padding: calc(${theme.spacing.d} * 2);
overflow: auto;
width: 100%;
max-height: calc(90vh - ${theme.spacing.d} * 2);
`,
}));

@ -7,6 +7,7 @@ export { Portal } from './Portal/Portal';
export { CustomScrollbar } from './CustomScrollbar/CustomScrollbar';
export * from './Button/Button';
export { ClipboardButton } from './ClipboardButton/ClipboardButton';
// Select
export { Select, AsyncSelect } from './Select/Select';
@ -39,13 +40,16 @@ export { TimePicker } from './TimePicker/TimePicker';
export { TimeOfDayPicker } from './TimePicker/TimeOfDayPicker';
export { List } from './List/List';
export { TagsInput } from './TagsInput/TagsInput';
export { Modal } from './Modal/Modal';
export { ModalsProvider, ModalRoot, ModalsController } from './Modal/ModalsContext';
export { ConfirmModal } from './ConfirmModal/ConfirmModal';
export { QueryField } from './QueryField/QueryField';
// TODO: namespace
export { Modal } from './Modal/Modal';
export { ModalHeader } from './Modal/ModalHeader';
export { ModalTabsHeader } from './Modal/ModalTabsHeader';
export { ModalsProvider, ModalRoot, ModalsController } from './Modal/ModalsContext';
// Renderless
export { SetInterval } from './SetInterval/SetInterval';

@ -15,6 +15,7 @@ import { ILocationService, IRootScopeService, ITimeoutService } from 'angular';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { getLocationSrv } from '@grafana/runtime';
import { DashboardModel } from '../../features/dashboard/state';
import { ShareModal } from 'app/features/dashboard/components/ShareModal/ShareModal';
import { SaveDashboardModalProxy } from '../../features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
export class KeybindingSrv {
@ -272,14 +273,14 @@ export class KeybindingSrv {
// share panel
this.bind('p s', () => {
if (dashboard.meta.focusPanelId) {
const shareScope: any = scope.$new();
const panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId);
shareScope.panel = panelInfo.panel;
shareScope.dashboard = dashboard;
appEvents.emit(CoreEvents.showModal, {
src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: shareScope,
appEvents.emit(CoreEvents.showModalReact, {
component: ShareModal,
props: {
dashboard: dashboard,
panel: panelInfo?.panel,
},
});
}
});

@ -7,6 +7,7 @@ import appEvents from 'app/core/app_events';
import { CoreEvents } from 'app/types';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { AngularModalProxy } from '../components/modals/AngularModalProxy';
import { provideTheme } from '../utils/ConfigProvider';
export class UtilSrv {
modalScope: any;
@ -36,7 +37,7 @@ export class UtilSrv {
},
};
const elem = React.createElement(AngularModalProxy, modalProps);
const elem = React.createElement(provideTheme(AngularModalProxy), modalProps);
this.reactModalRoot.appendChild(this.reactModalNode);
return ReactDOM.render(elem, this.reactModalNode);
}

@ -87,3 +87,26 @@ export function appendQueryToUrl(url: string, stringToAppend: string) {
return url;
}
/**
* Return search part (as object) of current url
*/
export function getUrlSearchParams() {
const search = window.location.search.substring(1);
const searchParamsSegments = search.split('&');
const params: any = {};
for (const p of searchParamsSegments) {
const keyValuePair = p.split('=');
if (keyValuePair.length > 1) {
// key-value param
const key = decodeURIComponent(keyValuePair[0]);
const value = decodeURIComponent(keyValuePair[1]);
params[key] = value;
} else if (keyValuePair.length === 1) {
// boolean param
const key = decodeURIComponent(keyValuePair[0]);
params[key] = true;
}
}
return params;
}

@ -1,86 +0,0 @@
import angular from 'angular';
import { saveAs } from 'file-saver';
import coreModule from 'app/core/core_module';
import { DashboardExporter } from './DashboardExporter';
import { DashboardSrv } from '../../services/DashboardSrv';
import DatasourceSrv from 'app/features/plugins/datasource_srv';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { CoreEvents } from 'app/types';
export class DashExportCtrl {
dash: any;
exporter: DashboardExporter;
dismiss: () => void;
shareExternally: boolean;
/** @ngInject */
constructor(
private dashboardSrv: DashboardSrv,
datasourceSrv: DatasourceSrv,
private $scope: any,
private $rootScope: GrafanaRootScope
) {
this.exporter = new DashboardExporter(datasourceSrv);
this.dash = this.dashboardSrv.getCurrent();
}
saveDashboardAsFile() {
if (this.shareExternally) {
this.exporter.makeExportable(this.dash).then((dashboardJson: any) => {
this.$scope.$apply(() => {
this.openSaveAsDialog(dashboardJson);
});
});
} else {
this.openSaveAsDialog(this.dash.getSaveModelClone());
}
}
viewJson() {
if (this.shareExternally) {
this.exporter.makeExportable(this.dash).then((dashboardJson: any) => {
this.$scope.$apply(() => {
this.openJsonModal(dashboardJson);
});
});
} else {
this.openJsonModal(this.dash.getSaveModelClone());
}
}
private openSaveAsDialog(dash: any) {
const blob = new Blob([angular.toJson(dash, true)], {
type: 'application/json;charset=utf-8',
});
saveAs(blob, dash.title + '-' + new Date().getTime() + '.json');
}
private openJsonModal(clone: object) {
const model = {
object: clone,
enableCopy: true,
};
this.$rootScope.appEvent(CoreEvents.showModal, {
src: 'public/app/partials/edit_json.html',
model: model,
});
this.dismiss();
}
}
export function dashExportDirective() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/components/DashExportModal/template.html',
controller: DashExportCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: { dismiss: '&' },
};
}
coreModule.directive('dashExportModal', dashExportDirective);

@ -1,15 +1,25 @@
import _ from 'lodash';
import config from 'app/core/config';
import { DashboardExporter } from './DashboardExporter';
import { DashboardModel } from '../../state/DashboardModel';
import { PanelPluginMeta } from '@grafana/data';
jest.mock('app/core/store', () => {
return {
getBool: jest.fn(),
};
});
import _ from 'lodash';
import config from 'app/core/config';
import { DashboardExporter } from './DashboardExporter';
import { DashboardModel } from '../../state/DashboardModel';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
import { PanelPluginMeta } from '@grafana/data';
jest.mock('@grafana/runtime', () => ({
getDataSourceSrv: () => ({
get: jest.fn(arg => getStub(arg)),
}),
config: {
buildInfo: {},
panels: {},
},
DataSourceWithBackend: jest.fn(),
}));
describe('given dashboard with repeated panels', () => {
let dash: any, exported: any;
@ -90,9 +100,6 @@ describe('given dashboard with repeated panels', () => {
config.buildInfo.version = '3.0.2';
//Stubs test function calls
const datasourceSrvStub = ({ get: jest.fn(arg => getStub(arg)) } as any) as DatasourceSrv;
config.panels['graph'] = {
id: 'graph',
name: 'Graph',
@ -112,7 +119,7 @@ describe('given dashboard with repeated panels', () => {
} as PanelPluginMeta;
dash = new DashboardModel(dash, {});
const exporter = new DashboardExporter(datasourceSrvStub);
const exporter = new DashboardExporter();
exporter.makeExportable(dash).then(clean => {
exported = clean;
done();

@ -2,9 +2,9 @@ import _ from 'lodash';
import config from 'app/core/config';
import { DashboardModel } from '../../state/DashboardModel';
import DatasourceSrv from 'app/features/plugins/datasource_srv';
import { PanelModel } from 'app/features/dashboard/state';
import { PanelPluginMeta } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
interface Input {
name: string;
@ -35,8 +35,6 @@ interface DataSources {
}
export class DashboardExporter {
constructor(private datasourceSrv: DatasourceSrv) {}
makeExportable(dashboard: DashboardModel) {
// clean up repeated rows and panels,
// this is done on the live real dashboard instance, not on a clone
@ -73,36 +71,38 @@ export class DashboardExporter {
}
promises.push(
this.datasourceSrv.get(datasource).then(ds => {
if (ds.meta.builtIn) {
return;
}
getDataSourceSrv()
.get(datasource)
.then(ds => {
if (ds.meta?.builtIn) {
return;
}
// add data source type to require list
requires['datasource' + ds.meta?.id] = {
type: 'datasource',
id: ds.meta?.id,
name: ds.meta?.name,
version: ds.meta?.info.version || '1.0.0',
};
// add data source type to require list
requires['datasource' + ds.meta.id] = {
type: 'datasource',
id: ds.meta.id,
name: ds.meta.name,
version: ds.meta.info.version || '1.0.0',
};
// if used via variable we can skip templatizing usage
if (datasourceVariable) {
return;
}
// if used via variable we can skip templatizing usage
if (datasourceVariable) {
return;
}
const refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase();
datasources[refName] = {
name: refName,
label: ds.name,
description: '',
type: 'datasource',
pluginId: ds.meta?.id,
pluginName: ds.meta?.name,
};
const refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase();
datasources[refName] = {
name: refName,
label: ds.name,
description: '',
type: 'datasource',
pluginId: ds.meta.id,
pluginName: ds.meta.name,
};
obj.datasource = '${' + refName + '}';
})
obj.datasource = '${' + refName + '}';
})
);
};

@ -1,2 +1 @@
export { DashboardExporter } from './DashboardExporter';
export { DashExportCtrl } from './DashExportCtrl';

@ -1,25 +0,0 @@
<div class="share-modal-header">
<div class="share-modal-big-icon">
<i class="fa fa-cloud-upload"></i>
</div>
<div>
<gf-form-switch
class="gf-form"
label="Export for sharing externally"
label-class="width-16"
checked="ctrl.shareExternally"
tooltip="Useful for sharing dashboard publicly on grafana.com. Will templatize data source names. Can then only be used with the specific dashboard import API.">
</gf-form-switch>
<div class="gf-form-button-row">
<button type="button" class="btn gf-form-btn width-10 btn-primary" ng-click="ctrl.saveDashboardAsFile()">
Save to file
</button>
<button type="button" class="btn gf-form-btn width-10 btn-secondary" ng-click="ctrl.viewJson()">
View JSON
</button>
<a class="btn btn-link" ng-click="ctrl.dismiss()">Cancel</a>
</div>
</div>
</div>

@ -15,6 +15,7 @@ import { updateLocation } from 'app/core/actions';
// Types
import { DashboardModel } from '../../state';
import { CoreEvents, StoreState } from 'app/types';
import { ShareModal } from '../ShareModal/ShareModal';
import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy';
export interface OwnProps {
@ -99,18 +100,6 @@ export class DashNav extends PureComponent<Props> {
this.forceUpdate();
};
onOpenShare = () => {
const $rootScope = this.props.$injector.get('$rootScope');
const modalScope = $rootScope.$new();
modalScope.tabIndex = 0;
modalScope.dashboard = this.props.dashboard;
appEvents.emit(CoreEvents.showModal, {
src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: modalScope,
});
};
renderDashboardTitleSearchButton() {
const { dashboard } = this.props;
@ -210,31 +199,38 @@ export class DashNav extends PureComponent<Props> {
)}
{canShare && (
<DashNavButton
tooltip="Share dashboard"
classSuffix="share"
icon="fa fa-share-square-o"
onClick={this.onOpenShare}
/>
<ModalsController>
{({ showModal, hideModal }) => (
<DashNavButton
tooltip="Share dashboard"
classSuffix="share"
icon="fa fa-share-square-o"
onClick={() => {
showModal(ShareModal, {
dashboard,
onDismiss: hideModal,
});
}}
/>
)}
</ModalsController>
)}
{canSave && (
<ModalsController>
{({ showModal, hideModal }) => {
return (
<DashNavButton
tooltip="Save dashboard"
classSuffix="save"
icon="fa fa-save"
onClick={() => {
showModal(SaveDashboardModalProxy, {
dashboard,
onDismiss: hideModal,
});
}}
/>
);
}}
{({ showModal, hideModal }) => (
<DashNavButton
tooltip="Save dashboard"
classSuffix="save"
icon="fa fa-save"
onClick={() => {
showModal(SaveDashboardModalProxy, {
dashboard,
onDismiss: hideModal,
});
}}
/>
)}
</ModalsController>
)}

@ -0,0 +1,119 @@
import React, { PureComponent } from 'react';
import { Switch, Select } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { buildIframeHtml } from './utils';
const themeOptions: Array<SelectableValue<string>> = [
{ label: 'current', value: 'current' },
{ label: 'dark', value: 'dark' },
{ label: 'light', value: 'light' },
];
interface Props {
dashboard: DashboardModel;
panel?: PanelModel;
}
interface State {
useCurrentTimeRange: boolean;
includeTemplateVars: boolean;
selectedTheme: SelectableValue<string>;
iframeHtml: string;
}
export class ShareEmbed extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
useCurrentTimeRange: true,
includeTemplateVars: true,
selectedTheme: themeOptions[0],
iframeHtml: '',
};
}
componentDidMount() {
this.buildIframeHtml();
}
buildIframeHtml = () => {
const { panel } = this.props;
const { useCurrentTimeRange, includeTemplateVars, selectedTheme } = this.state;
const iframeHtml = buildIframeHtml(useCurrentTimeRange, includeTemplateVars, selectedTheme.value, panel);
this.setState({ iframeHtml });
};
onUseCurrentTimeRangeChange = () => {
this.setState(
{
useCurrentTimeRange: !this.state.useCurrentTimeRange,
},
this.buildIframeHtml
);
};
onIncludeTemplateVarsChange = () => {
this.setState(
{
includeTemplateVars: !this.state.includeTemplateVars,
},
this.buildIframeHtml
);
};
onThemeChange = (value: SelectableValue<string>) => {
this.setState(
{
selectedTheme: value,
},
this.buildIframeHtml
);
};
render() {
const { useCurrentTimeRange, includeTemplateVars, selectedTheme, iframeHtml } = this.state;
return (
<div className="share-modal-body">
<div className="share-modal-header">
<div className="share-modal-big-icon">
<i className="gicon gicon-link"></i>
</div>
<div className="share-modal-content">
<div className="gf-form-group">
<Switch
labelClass="width-12"
label="Current time range"
checked={useCurrentTimeRange}
onChange={this.onUseCurrentTimeRangeChange}
/>
<Switch
labelClass="width-12"
label="Template variables"
checked={includeTemplateVars}
onChange={this.onIncludeTemplateVarsChange}
/>
<div className="gf-form">
<label className="gf-form-label width-12">Theme</label>
<Select width={10} options={themeOptions} value={selectedTheme} onChange={this.onThemeChange} />
</div>
</div>
<p className="share-modal-info-text">
The html code below can be pasted and included in another web page. Unless anonymous access is enabled,
the user viewing that page need to be signed into grafana for the graph to load.
</p>
<div className="gf-form-group gf-form--grow">
<div className="gf-form">
<textarea rows={5} data-share-panel-url className="gf-form-input" defaultValue={iframeHtml}></textarea>
</div>
</div>
</div>
</div>
</div>
);
}
}

@ -0,0 +1,120 @@
import React, { PureComponent } from 'react';
import { saveAs } from 'file-saver';
import { Button, Switch } from '@grafana/ui';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { DashboardExporter } from 'app/features/dashboard/components/DashExportModal';
import { appEvents } from 'app/core/core';
import { CoreEvents } from 'app/types';
interface Props {
dashboard: DashboardModel;
panel?: PanelModel;
onDismiss(): void;
}
interface State {
shareExternally: boolean;
}
export class ShareExport extends PureComponent<Props, State> {
private exporter: DashboardExporter;
constructor(props: Props) {
super(props);
this.state = {
shareExternally: false,
};
this.exporter = new DashboardExporter();
}
onShareExternallyChange = () => {
this.setState({
shareExternally: !this.state.shareExternally,
});
};
onSaveAsFile = () => {
const { dashboard } = this.props;
const { shareExternally } = this.state;
if (shareExternally) {
this.exporter.makeExportable(dashboard).then((dashboardJson: any) => {
this.openSaveAsDialog(dashboardJson);
});
} else {
this.openSaveAsDialog(dashboard.getSaveModelClone());
}
};
onViewJson = () => {
const { dashboard } = this.props;
const { shareExternally } = this.state;
if (shareExternally) {
this.exporter.makeExportable(dashboard).then((dashboardJson: any) => {
this.openJsonModal(dashboardJson);
});
} else {
this.openJsonModal(dashboard.getSaveModelClone());
}
};
openSaveAsDialog = (dash: any) => {
const dashboardJsonPretty = JSON.stringify(dash, null, 2);
const blob = new Blob([dashboardJsonPretty], {
type: 'application/json;charset=utf-8',
});
const time = new Date().getTime();
saveAs(blob, `${dash.title}-${time}.json`);
};
openJsonModal = (clone: object) => {
const model = {
object: clone,
enableCopy: true,
};
appEvents.emit(CoreEvents.showModal, {
src: 'public/app/partials/edit_json.html',
model,
});
this.props.onDismiss();
};
render() {
const { onDismiss } = this.props;
const { shareExternally } = this.state;
return (
<div className="share-modal-body">
<div className="share-modal-header">
<div className="share-modal-big-icon">
<i className="fa fa-cloud-upload"></i>
</div>
<div className="share-modal-content">
<Switch
labelClass="width-16"
label="Export for sharing externally"
checked={shareExternally}
onChange={this.onShareExternallyChange}
/>
<div className="gf-form-button-row">
<Button variant="primary" onClick={this.onSaveAsFile}>
Save to file
</Button>
<Button variant="secondary" onClick={this.onViewJson}>
View JSON
</Button>
<Button variant="inverse" onClick={onDismiss}>
Cancel
</Button>
</div>
</div>
</div>
</div>
);
}
}

@ -0,0 +1,179 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import config from 'app/core/config';
import { ShareLink, Props, State } from './ShareLink';
jest.mock('app/features/dashboard/services/TimeSrv', () => ({
getTimeSrv: () => ({
timeRange: () => {
return { from: new Date(1000), to: new Date(2000) };
},
}),
}));
let fillVariableValuesForUrlMock = (params: any) => {};
jest.mock('app/features/templating/template_srv', () => ({
fillVariableValuesForUrl: (params: any) => {
fillVariableValuesForUrlMock(params);
},
}));
function mockLocationHref(href: string) {
const location = window.location;
let search = '';
const searchPos = href.indexOf('?');
if (searchPos >= 0) {
search = href.substring(searchPos);
}
delete window.location;
(window as any).location = {
...location,
href,
search,
};
}
function setUTCTimeZone() {
(window as any).Intl.DateTimeFormat = () => {
return {
resolvedOptions: () => {
return { timeZone: 'UTC' };
},
};
};
}
interface ScenarioContext {
wrapper?: ShallowWrapper<Props, State, ShareLink>;
mount: (propOverrides?: Partial<Props>) => void;
setup: (fn: () => void) => void;
}
function shareLinkScenario(description: string, scenarioFn: (ctx: ScenarioContext) => void) {
describe(description, () => {
let setupFn: () => void;
const ctx: any = {
setup: (fn: any) => {
setupFn = fn;
},
mount: (propOverrides?: any) => {
const props: any = {
panel: undefined,
};
Object.assign(props, propOverrides);
ctx.wrapper = shallow(<ShareLink {...props} />);
},
};
beforeEach(() => {
setUTCTimeZone();
setupFn();
});
scenarioFn(ctx);
});
}
describe('ShareModal', () => {
shareLinkScenario('shareUrl with current time range and panel', ctx => {
ctx.setup(() => {
mockLocationHref('http://server/#!/test');
config.bootData = {
user: {
orgId: 1,
},
};
ctx.mount({
panel: { id: 22, options: {} },
});
});
it('should generate share url absolute time', () => {
const state = ctx.wrapper?.state();
expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&panelId=22&fullscreen');
});
it('should generate render url', () => {
mockLocationHref('http://dashboards.grafana.com/d/abcdefghi/my-dash');
ctx.mount({
panel: { id: 22, options: {} },
});
const state = ctx.wrapper?.state();
const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash';
const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
expect(state?.imageUrl).toContain(base + params);
});
it('should generate render url for scripted dashboard', () => {
mockLocationHref('http://dashboards.grafana.com/dashboard/script/my-dash.js');
ctx.mount({
panel: { id: 22, options: {} },
});
const state = ctx.wrapper?.state();
const base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js';
const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
expect(state?.imageUrl).toContain(base + params);
});
it('should remove panel id when no panel in scope', () => {
ctx.mount({
panel: undefined,
});
const state = ctx.wrapper?.state();
expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1');
});
it('should add theme when specified', () => {
ctx.wrapper?.setProps({ panel: undefined });
ctx.wrapper?.setState({ selectedTheme: { label: 'light', value: 'light' } });
const state = ctx.wrapper?.state();
expect(state?.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&theme=light');
});
it('should remove fullscreen from image url when is first param in querystring and modeSharePanel is true', () => {
mockLocationHref('http://server/#!/test?fullscreen&edit');
ctx.mount({
panel: { id: 1, options: {} },
});
const state = ctx.wrapper?.state();
expect(state?.shareUrl).toContain('?fullscreen&edit&from=1000&to=2000&orgId=1&panelId=1');
expect(state?.imageUrl).toContain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC');
});
it('should remove edit from image url when is first param in querystring and modeSharePanel is true', () => {
mockLocationHref('http://server/#!/test?edit&fullscreen');
ctx.mount({
panel: { id: 1, options: {} },
});
const state = ctx.wrapper?.state();
expect(state?.shareUrl).toContain('?edit&fullscreen&from=1000&to=2000&orgId=1&panelId=1');
expect(state?.imageUrl).toContain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC');
});
it('should include template variables in url', () => {
mockLocationHref('http://server/#!/test');
fillVariableValuesForUrlMock = (params: any) => {
params['var-app'] = 'mupp';
params['var-server'] = 'srv-01';
};
ctx.mount();
ctx.wrapper?.setState({ includeTemplateVars: true });
const state = ctx.wrapper?.state();
expect(state?.shareUrl).toContain(
'http://server/#!/test?from=1000&to=2000&orgId=1&var-app=mupp&var-server=srv-01'
);
});
});
});

@ -0,0 +1,142 @@
import React, { PureComponent } from 'react';
import { Switch, Select, ClipboardButton } from '@grafana/ui';
import { SelectableValue, PanelModel, AppEvents } from '@grafana/data';
import { DashboardModel } from 'app/features/dashboard/state';
import { buildImageUrl, buildShareUrl } from './utils';
import { appEvents } from 'app/core/core';
const themeOptions: Array<SelectableValue<string>> = [
{ label: 'current', value: 'current' },
{ label: 'dark', value: 'dark' },
{ label: 'light', value: 'light' },
];
export interface Props {
dashboard?: DashboardModel;
panel?: PanelModel;
}
export interface State {
useCurrentTimeRange: boolean;
includeTemplateVars: boolean;
selectedTheme: SelectableValue<string>;
shareUrl: string;
imageUrl: string;
}
export class ShareLink extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
useCurrentTimeRange: true,
includeTemplateVars: true,
selectedTheme: themeOptions[0],
shareUrl: '',
imageUrl: '',
};
}
componentDidMount() {
this.buildUrl();
}
componentDidUpdate(prevProps: Props, prevState: State) {
const { useCurrentTimeRange, includeTemplateVars, selectedTheme } = this.state;
if (
prevState.useCurrentTimeRange !== useCurrentTimeRange ||
prevState.includeTemplateVars !== includeTemplateVars ||
prevState.selectedTheme.value !== selectedTheme.value
) {
this.buildUrl();
}
}
buildUrl = () => {
const { panel } = this.props;
const { useCurrentTimeRange, includeTemplateVars, selectedTheme } = this.state;
const shareUrl = buildShareUrl(useCurrentTimeRange, includeTemplateVars, selectedTheme.value, panel);
const imageUrl = buildImageUrl(useCurrentTimeRange, includeTemplateVars, selectedTheme.value, panel);
this.setState({ shareUrl, imageUrl });
};
onUseCurrentTimeRangeChange = () => {
this.setState({ useCurrentTimeRange: !this.state.useCurrentTimeRange });
};
onIncludeTemplateVarsChange = () => {
this.setState({ includeTemplateVars: !this.state.includeTemplateVars });
};
onThemeChange = (value: SelectableValue<string>) => {
this.setState({ selectedTheme: value });
};
onShareUrlCopy = () => {
appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']);
};
getShareUrl = () => {
return this.state.shareUrl;
};
render() {
const { panel } = this.props;
const { useCurrentTimeRange, includeTemplateVars, selectedTheme, shareUrl, imageUrl } = this.state;
return (
<div className="share-modal-body">
<div className="share-modal-header">
<div className="share-modal-big-icon">
<i className="gicon gicon-link"></i>
</div>
<div className="share-modal-content">
<p className="share-modal-info-text">
Create a direct link to this dashboard or panel, customized with the options below.
</p>
<div className="gf-form-group">
<Switch
labelClass="width-12"
label="Current time range"
checked={useCurrentTimeRange}
onChange={this.onUseCurrentTimeRangeChange}
/>
<Switch
labelClass="width-12"
label="Template variables"
checked={includeTemplateVars}
onChange={this.onIncludeTemplateVarsChange}
/>
<div className="gf-form">
<label className="gf-form-label width-12">Theme</label>
<Select width={10} options={themeOptions} value={selectedTheme} onChange={this.onThemeChange} />
</div>
</div>
<div>
<div className="gf-form-group">
<div className="gf-form-inline">
<div className="gf-form gf-form--grow">
<input type="text" className="gf-form-input" defaultValue={shareUrl} />
</div>
<div className="gf-form">
<ClipboardButton variant="inverse" getText={this.getShareUrl} onClipboardCopy={this.onShareUrlCopy}>
Copy
</ClipboardButton>
{/* <button className="btn btn-inverse" clipboard-button="getShareUrl()">Copy</button> */}
</div>
</div>
</div>
</div>
{panel && (
<div className="gf-form">
<a href={imageUrl} target="_blank">
<i className="fa fa-camera"></i> Direct link rendered image
</a>
</div>
)}
</div>
</div>
</div>
);
}
}

@ -0,0 +1,86 @@
import React, { PureComponent } from 'react';
import { Modal, ModalTabsHeader, TabContent } from '@grafana/ui';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { ShareLink } from './ShareLink';
import { ShareSnapshot } from './ShareSnapshot';
import { ShareExport } from './ShareExport';
import { ShareEmbed } from './ShareEmbed';
const shareModalTabs = [
{ label: 'Link', value: 'link' },
{ label: 'Embed', value: 'embed' },
{ label: 'Snapshot', value: 'snapshot' },
{ label: 'Export', value: 'export' },
];
interface Props {
dashboard: DashboardModel;
panel?: PanelModel;
onDismiss(): void;
}
interface State {
tab: string;
}
function getInitialState(): State {
return {
tab: shareModalTabs[0].value,
};
}
export class ShareModal extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = getInitialState();
}
onDismiss = () => {
this.setState(getInitialState());
this.props.onDismiss();
};
onSelectTab = (t: any) => {
this.setState({ tab: t.value });
};
getTabs() {
const { panel } = this.props;
// Filter tabs for dashboard/panel share modal
return shareModalTabs.filter(t => {
if (panel) {
return t.value !== 'export';
}
return t.value !== 'embed';
});
}
renderTitle() {
const { panel } = this.props;
const { tab } = this.state;
const title = panel ? 'Share Panel' : 'Share';
const tabs = this.getTabs();
return (
<ModalTabsHeader title={title} icon="share-square-o" tabs={tabs} activeTab={tab} onChangeTab={this.onSelectTab} />
);
}
render() {
const { dashboard, panel } = this.props;
const { tab } = this.state;
return (
<Modal isOpen={true} title={this.renderTitle()} onDismiss={this.onDismiss}>
<TabContent>
{tab === 'link' && <ShareLink dashboard={dashboard} panel={panel} />}
{tab === 'embed' && panel && <ShareEmbed dashboard={dashboard} panel={panel} />}
{tab === 'snapshot' && <ShareSnapshot dashboard={dashboard} panel={panel} onDismiss={this.onDismiss} />}
{tab === 'export' && !panel && <ShareExport dashboard={dashboard} panel={panel} onDismiss={this.onDismiss} />}
</TabContent>
</Modal>
);
}
}

@ -1,150 +0,0 @@
import config from 'app/core/config';
import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
import { ShareModalCtrl } from './ShareModalCtrl';
import { TemplateSrv } from 'app/features/templating/template_srv';
describe('ShareModalCtrl', () => {
const ctx = {
timeSrv: {
timeRange: () => {
return { from: new Date(1000), to: new Date(2000) };
},
},
$location: {
absUrl: () => 'http://server/#!/test',
search: () => {
return { from: '', to: '' };
},
},
scope: {
dashboard: {
meta: {
isSnapshot: true,
},
},
},
templateSrv: {
fillVariableValuesForUrl: () => {},
},
} as any;
(window as any).Intl.DateTimeFormat = () => {
return {
resolvedOptions: () => {
return { timeZone: 'UTC' };
},
};
};
beforeEach(() => {
config.bootData = {
user: {
orgId: 1,
},
};
// @ts-ignore
ctx.ctrl = new ShareModalCtrl(
ctx.scope,
{} as any,
ctx.$location,
{},
ctx.timeSrv,
ctx.templateSrv,
new LinkSrv({} as TemplateSrv, ctx.stimeSrv)
);
});
describe('shareUrl with current time range and panel', () => {
it('should generate share url absolute time', () => {
ctx.scope.panel = { id: 22 };
ctx.scope.init();
expect(ctx.scope.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&panelId=22&fullscreen');
});
it('should generate render url', () => {
ctx.$location.absUrl = () => 'http://dashboards.grafana.com/d/abcdefghi/my-dash';
ctx.scope.panel = { id: 22 };
ctx.scope.init();
const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash';
const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
expect(ctx.scope.imageUrl).toContain(base + params);
});
it('should generate render url for scripted dashboard', () => {
ctx.$location.absUrl = () => 'http://dashboards.grafana.com/dashboard/script/my-dash.js';
ctx.scope.panel = { id: 22 };
ctx.scope.init();
const base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js';
const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
expect(ctx.scope.imageUrl).toContain(base + params);
});
it('should remove panel id when no panel in scope', () => {
ctx.$location.absUrl = () => 'http://server/#!/test';
ctx.scope.options.forCurrent = true;
ctx.scope.panel = null;
ctx.scope.init();
expect(ctx.scope.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1');
});
it('should add theme when specified', () => {
ctx.scope.options.theme = 'light';
ctx.scope.panel = null;
ctx.scope.init();
expect(ctx.scope.shareUrl).toBe('http://server/#!/test?from=1000&to=2000&orgId=1&theme=light');
});
it('should remove fullscreen from image url when is first param in querystring and modeSharePanel is true', () => {
ctx.$location.search = () => {
return { fullscreen: true, edit: true };
};
ctx.$location.absUrl = () => 'http://server/#!/test?fullscreen&edit';
ctx.scope.modeSharePanel = true;
ctx.scope.panel = { id: 1 };
ctx.scope.buildUrl();
expect(ctx.scope.shareUrl).toContain('?fullscreen&edit&from=1000&to=2000&orgId=1&panelId=1');
expect(ctx.scope.imageUrl).toContain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC');
});
it('should remove edit from image url when is first param in querystring and modeSharePanel is true', () => {
ctx.$location.search = () => {
return { edit: true, fullscreen: true };
};
ctx.$location.absUrl = () => 'http://server/#!/test?edit&fullscreen';
ctx.scope.modeSharePanel = true;
ctx.scope.panel = { id: 1 };
ctx.scope.buildUrl();
expect(ctx.scope.shareUrl).toContain('?edit&fullscreen&from=1000&to=2000&orgId=1&panelId=1');
expect(ctx.scope.imageUrl).toContain('?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&tz=UTC');
});
it('should include template variables in url', () => {
ctx.$location.search = () => {
return {};
};
ctx.$location.absUrl = () => 'http://server/#!/test';
ctx.scope.options.includeTemplateVars = true;
ctx.templateSrv.fillVariableValuesForUrl = (params: any) => {
params['var-app'] = 'mupp';
params['var-server'] = 'srv-01';
};
ctx.scope.buildUrl();
expect(ctx.scope.shareUrl).toContain(
'http://server/#!/test?from=1000&to=2000&orgId=1&var-app=mupp&var-server=srv-01'
);
});
});
});

@ -1,137 +0,0 @@
import angular, { ILocationService } from 'angular';
import { dateTime } from '@grafana/data';
import { e2e } from '@grafana/e2e';
import config from 'app/core/config';
import { appendQueryToUrl, toUrlParams } from 'app/core/utils/url';
import { TimeSrv } from '../../services/TimeSrv';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
/** @ngInject */
export function ShareModalCtrl(
$scope: any,
$rootScope: GrafanaRootScope,
$location: ILocationService,
$timeout: any,
timeSrv: TimeSrv,
templateSrv: TemplateSrv,
linkSrv: LinkSrv
) {
$scope.options = {
forCurrent: true,
includeTemplateVars: true,
theme: 'current',
};
$scope.editor = { index: $scope.tabIndex || 0 };
$scope.selectors = e2e.pages.SharePanelModal.selectors;
$scope.init = () => {
$scope.panel = $scope.model && $scope.model.panel ? $scope.model.panel : $scope.panel; // React pass panel and dashboard in the "model" property
$scope.dashboard = $scope.model && $scope.model.dashboard ? $scope.model.dashboard : $scope.dashboard; // ^
$scope.modeSharePanel = $scope.panel ? true : false;
$scope.tabs = [{ title: 'Link', src: 'shareLink.html' }];
if ($scope.modeSharePanel) {
$scope.modalTitle = 'Share Panel';
$scope.tabs.push({ title: 'Embed', src: 'shareEmbed.html' });
} else {
$scope.modalTitle = 'Share';
}
if (!$scope.dashboard.meta.isSnapshot) {
$scope.tabs.push({ title: 'Snapshot', src: 'shareSnapshot.html' });
}
if (!$scope.dashboard.meta.isSnapshot && !$scope.modeSharePanel) {
$scope.tabs.push({ title: 'Export', src: 'shareExport.html' });
}
$scope.buildUrl();
};
$scope.buildUrl = () => {
let baseUrl = $location.absUrl();
const queryStart = baseUrl.indexOf('?');
if (queryStart !== -1) {
baseUrl = baseUrl.substring(0, queryStart);
}
const params = angular.copy($location.search());
const range = timeSrv.timeRange();
params.from = range.from.valueOf();
params.to = range.to.valueOf();
params.orgId = config.bootData.user.orgId;
if ($scope.options.includeTemplateVars) {
templateSrv.fillVariableValuesForUrl(params);
}
if (!$scope.options.forCurrent) {
delete params.from;
delete params.to;
}
if ($scope.options.theme !== 'current') {
params.theme = $scope.options.theme;
}
if ($scope.modeSharePanel) {
params.panelId = $scope.panel.id;
params.fullscreen = true;
} else {
delete params.panelId;
delete params.fullscreen;
}
$scope.shareUrl = appendQueryToUrl(baseUrl, toUrlParams(params));
let soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/');
soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/');
delete params.fullscreen;
delete params.edit;
soloUrl = appendQueryToUrl(soloUrl, toUrlParams(params));
$scope.iframeHtml = '<iframe src="' + soloUrl + '" width="450" height="200" frameborder="0"></iframe>';
$scope.imageUrl = soloUrl.replace(
config.appSubUrl + '/dashboard-solo/',
config.appSubUrl + '/render/dashboard-solo/'
);
$scope.imageUrl = $scope.imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/');
$scope.imageUrl += '&width=1000&height=500' + $scope.getLocalTimeZone();
};
// This function will try to return the proper full name of the local timezone
// Chrome does not handle the timezone offset (but phantomjs does)
$scope.getLocalTimeZone = () => {
const utcOffset = '&tz=UTC' + encodeURIComponent(dateTime().format('Z'));
// Older browser does not the internationalization API
if (!(window as any).Intl) {
return utcOffset;
}
const dateFormat = (window as any).Intl.DateTimeFormat();
if (!dateFormat.resolvedOptions) {
return utcOffset;
}
const options = dateFormat.resolvedOptions();
if (!options.timeZone) {
return utcOffset;
}
return '&tz=' + encodeURIComponent(options.timeZone);
};
$scope.getShareUrl = () => {
return $scope.shareUrl;
};
}
angular.module('grafana.controllers').controller('ShareModalCtrl', ShareModalCtrl);

@ -0,0 +1,316 @@
import React, { PureComponent } from 'react';
import { Button, Select, LinkButton, Input, ClipboardButton } from '@grafana/ui';
import { SelectableValue, AppEvents } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { appEvents } from 'app/core/core';
const snapshotApiUrl = '/api/snapshots';
const expireOptions: Array<SelectableValue<number>> = [
{ label: 'Never', value: 0 },
{ label: '1 Hour', value: 60 * 60 },
{ label: '1 Day', value: 60 * 60 * 24 },
{ label: '7 Days', value: 60 * 60 * 24 * 7 },
];
interface Props {
dashboard: DashboardModel;
panel?: PanelModel;
onDismiss(): void;
}
interface State {
isLoading: boolean;
step: number;
snapshotName: string;
selectedExpireOption: SelectableValue<number>;
snapshotExpires?: number;
snapshotUrl: string;
deleteUrl: string;
timeoutSeconds: number;
externalEnabled: boolean;
sharingButtonText: string;
}
export class ShareSnapshot extends PureComponent<Props, State> {
private dashboard: DashboardModel;
constructor(props: Props) {
super(props);
this.dashboard = props.dashboard;
this.state = {
isLoading: false,
step: 1,
selectedExpireOption: expireOptions[0],
snapshotExpires: expireOptions[0].value,
snapshotName: props.dashboard.title,
timeoutSeconds: 4,
snapshotUrl: '',
deleteUrl: '',
externalEnabled: false,
sharingButtonText: '',
};
}
componentDidMount() {
this.getSnaphotShareOptions();
}
async getSnaphotShareOptions() {
const shareOptions = await getBackendSrv().get('/api/snapshot/shared-options');
this.setState({
sharingButtonText: shareOptions['externalSnapshotName'],
externalEnabled: shareOptions['externalEnabled'],
});
}
createSnapshot = (external?: boolean) => () => {
const { timeoutSeconds } = this.state;
this.dashboard.snapshot = {
timestamp: new Date(),
};
if (!external) {
this.dashboard.snapshot.originalUrl = window.location.href;
}
this.setState({ isLoading: true });
this.dashboard.startRefresh();
setTimeout(() => {
this.saveSnapshot(this.dashboard, external);
}, timeoutSeconds * 1000);
};
saveSnapshot = async (dashboard: DashboardModel, external?: boolean) => {
const { snapshotExpires } = this.state;
const dash = this.dashboard.getSaveModelClone();
this.scrubDashboard(dash);
const cmdData = {
dashboard: dash,
name: dash.title,
expires: snapshotExpires,
external: external,
};
try {
const results: { deleteUrl: any; url: any } = await getBackendSrv().post(snapshotApiUrl, cmdData);
this.setState({
deleteUrl: results.deleteUrl,
snapshotUrl: results.url,
step: 2,
});
} finally {
this.setState({ isLoading: false });
}
};
scrubDashboard = (dash: DashboardModel) => {
const { panel } = this.props;
const { snapshotName } = this.state;
// change title
dash.title = snapshotName;
// make relative times absolute
dash.time = getTimeSrv().timeRange();
// remove panel queries & links
dash.panels.forEach(panel => {
panel.targets = [];
panel.links = [];
panel.datasource = null;
});
// remove annotation queries
const annotations = dash.annotations.list.filter(annotation => annotation.enable);
dash.annotations.list = annotations.map((annotation: any) => {
return {
name: annotation.name,
enable: annotation.enable,
iconColor: annotation.iconColor,
snapshotData: annotation.snapshotData,
type: annotation.type,
builtIn: annotation.builtIn,
hide: annotation.hide,
};
});
// remove template queries
dash.templating.list.forEach(variable => {
variable.query = '';
variable.options = variable.current;
variable.refresh = false;
});
// snapshot single panel
if (panel) {
const singlePanel = panel.getSaveModel();
singlePanel.gridPos.w = 24;
singlePanel.gridPos.x = 0;
singlePanel.gridPos.y = 0;
singlePanel.gridPos.h = 20;
dash.panels = [singlePanel];
}
// cleanup snapshotData
delete this.dashboard.snapshot;
this.dashboard.forEachPanel((panel: PanelModel) => {
delete panel.snapshotData;
});
this.dashboard.annotations.list.forEach(annotation => {
delete annotation.snapshotData;
});
};
deleteSnapshot = async () => {
const { deleteUrl } = this.state;
await getBackendSrv().get(deleteUrl);
this.setState({ step: 3 });
};
getSnapshotUrl = () => {
return this.state.snapshotUrl;
};
onSnapshotNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ snapshotName: event.target.value });
};
onTimeoutChange = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ timeoutSeconds: Number(event.target.value) });
};
onExpireChange = (option: SelectableValue<number>) => {
this.setState({
selectedExpireOption: option,
snapshotExpires: option.value,
});
};
onSnapshotUrlCopy = () => {
appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']);
};
renderStep1() {
const { onDismiss } = this.props;
const {
snapshotName,
selectedExpireOption,
timeoutSeconds,
isLoading,
sharingButtonText,
externalEnabled,
} = this.state;
return (
<>
<div>
<p className="share-modal-info-text">
A snapshot is an instant way to share an interactive dashboard publicly. When created, we{' '}
<strong>strip sensitive data</strong> like queries (metric, template and annotation) and panel links,
leaving only the visible metric data and series names embedded into your dashboard.
</p>
<p className="share-modal-info-text">
Keep in mind, your <strong>snapshot can be viewed by anyone</strong> that has the link and can reach the
URL. Share wisely.
</p>
</div>
<div className="gf-form-group share-modal-options">
<div className="gf-form" ng-if="step === 1">
<label className="gf-form-label width-12">Snapshot name</label>
<Input width={15} value={snapshotName} onChange={this.onSnapshotNameChange} />
</div>
<div className="gf-form" ng-if="step === 1">
<label className="gf-form-label width-12">Expire</label>
<Select width={15} options={expireOptions} value={selectedExpireOption} onChange={this.onExpireChange} />
</div>
</div>
<p className="share-modal-info-text">
You may need to configure the timeout value if it takes a long time to collect your dashboard's metrics.
</p>
<div className="gf-form-group share-modal-options">
<div className="gf-form">
<span className="gf-form-label width-12">Timeout (seconds)</span>
<Input type="number" width={15} value={timeoutSeconds} onChange={this.onTimeoutChange} />
</div>
</div>
<div className="gf-form-button-row">
<Button className="width-10" variant="primary" disabled={isLoading} onClick={this.createSnapshot()}>
Local Snapshot
</Button>
{externalEnabled && (
<Button className="width-16" variant="secondary" disabled={isLoading} onClick={this.createSnapshot(true)}>
{sharingButtonText}
</Button>
)}
<Button variant="inverse" onClick={onDismiss}>
Cancel
</Button>
</div>
</>
);
}
renderStep2() {
const { snapshotUrl } = this.state;
return (
<>
<div className="gf-form" style={{ marginTop: '40px' }}>
<div className="gf-form-row">
<a href={snapshotUrl} className="large share-modal-link" target="_blank">
<i className="fa fa-external-link-square"></i> {snapshotUrl}
</a>
<br />
<ClipboardButton variant="inverse" getText={this.getSnapshotUrl} onClipboardCopy={this.onSnapshotUrlCopy}>
Copy Link
</ClipboardButton>
</div>
</div>
<div className="pull-right" ng-if="step === 2" style={{ padding: '5px' }}>
Did you make a mistake?{' '}
<LinkButton variant="link" target="_blank" onClick={this.deleteSnapshot}>
delete snapshot.
</LinkButton>
</div>
</>
);
}
renderStep3() {
return (
<div className="share-modal-header">
<p className="share-modal-info-text">
The snapshot has now been deleted. If it you have already accessed it once, It might take up to an hour before
it is removed from browser caches or CDN caches.
</p>
</div>
);
}
render() {
const { isLoading, step } = this.state;
return (
<div className="share-modal-body">
<div className="share-modal-header">
<div className="share-modal-big-icon">
{isLoading ? <i className="fa fa-spinner fa-spin"></i> : <i className="gicon gicon-snapshots"></i>}
</div>
<div className="share-modal-content">
{step === 1 && this.renderStep1()}
{step === 2 && this.renderStep2()}
{step === 3 && this.renderStep3()}
</div>
</div>
</div>
);
}
}

@ -1,175 +0,0 @@
import angular, { ILocationService, IScope } from 'angular';
import _ from 'lodash';
import { getBackendSrv } from '@grafana/runtime';
import { TimeSrv } from '../../services/TimeSrv';
import { DashboardModel } from '../../state/DashboardModel';
import { PanelModel } from '../../state/PanelModel';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { promiseToDigest } from '../../../../core/utils/promiseToDigest';
export class ShareSnapshotCtrl {
/** @ngInject */
constructor(
$scope: IScope & Record<string, any>,
$rootScope: GrafanaRootScope,
$location: ILocationService,
$timeout: any,
timeSrv: TimeSrv
) {
$scope.snapshot = {
name: $scope.dashboard.title,
expires: 0,
timeoutSeconds: 4,
};
$scope.step = 1;
$scope.expireOptions = [
{ text: '1 Hour', value: 60 * 60 },
{ text: '1 Day', value: 60 * 60 * 24 },
{ text: '7 Days', value: 60 * 60 * 24 * 7 },
{ text: 'Never', value: 0 },
];
$scope.accessOptions = [
{ text: 'Anyone with the link', value: 1 },
{ text: 'Organization users', value: 2 },
{ text: 'Public on the web', value: 3 },
];
$scope.init = () => {
promiseToDigest($scope)(
getBackendSrv()
.get('/api/snapshot/shared-options')
.then((options: { [x: string]: any }) => {
$scope.sharingButtonText = options['externalSnapshotName'];
$scope.externalEnabled = options['externalEnabled'];
})
);
};
$scope.apiUrl = '/api/snapshots';
$scope.createSnapshot = (external: any) => {
$scope.dashboard.snapshot = {
timestamp: new Date(),
};
if (!external) {
$scope.dashboard.snapshot.originalUrl = $location.absUrl();
}
$scope.loading = true;
$scope.snapshot.external = external;
$scope.dashboard.startRefresh();
$timeout(() => {
$scope.saveSnapshot(external);
}, $scope.snapshot.timeoutSeconds * 1000);
};
$scope.saveSnapshot = (external: any) => {
const dash = $scope.dashboard.getSaveModelClone();
$scope.scrubDashboard(dash);
const cmdData = {
dashboard: dash,
name: dash.title,
expires: $scope.snapshot.expires,
external: external,
};
promiseToDigest($scope)(
getBackendSrv()
.post($scope.apiUrl, cmdData)
.then(
(results: { deleteUrl: any; url: any }) => {
$scope.loading = false;
$scope.deleteUrl = results.deleteUrl;
$scope.snapshotUrl = results.url;
$scope.step = 2;
},
() => {
$scope.loading = false;
}
)
);
};
$scope.getSnapshotUrl = () => {
return $scope.snapshotUrl;
};
$scope.scrubDashboard = (dash: DashboardModel) => {
// change title
dash.title = $scope.snapshot.name;
// make relative times absolute
dash.time = timeSrv.timeRange();
// remove panel queries & links
_.each(dash.panels, panel => {
panel.targets = [];
panel.links = [];
panel.datasource = null;
});
// remove annotation queries
dash.annotations.list = _.chain(dash.annotations.list)
.filter(annotation => {
return annotation.enable;
})
.map((annotation: any) => {
return {
name: annotation.name,
enable: annotation.enable,
iconColor: annotation.iconColor,
snapshotData: annotation.snapshotData,
type: annotation.type,
builtIn: annotation.builtIn,
hide: annotation.hide,
};
})
.value();
// remove template queries
_.each(dash.templating.list, variable => {
variable.query = '';
variable.options = variable.current;
variable.refresh = false;
});
// snapshot single panel
if ($scope.modeSharePanel) {
const singlePanel = $scope.panel.getSaveModel();
singlePanel.gridPos.w = 24;
singlePanel.gridPos.x = 0;
singlePanel.gridPos.y = 0;
singlePanel.gridPos.h = 20;
dash.panels = [singlePanel];
}
// cleanup snapshotData
delete $scope.dashboard.snapshot;
$scope.dashboard.forEachPanel((panel: PanelModel) => {
delete panel.snapshotData;
});
_.each($scope.dashboard.annotations.list, annotation => {
delete annotation.snapshotData;
});
};
$scope.deleteSnapshot = () => {
promiseToDigest($scope)(
getBackendSrv()
.get($scope.deleteUrl)
.then(() => {
$scope.step = 3;
})
);
};
}
}
angular.module('grafana.controllers').controller('ShareSnapshotCtrl', ShareSnapshotCtrl);

@ -1,2 +0,0 @@
export { ShareModalCtrl } from './ShareModalCtrl';
export { ShareSnapshotCtrl } from './ShareSnapshotCtrl';

@ -1,182 +0,0 @@
<div class="modal-body" ng-controller="ShareModalCtrl" ng-init="init()">
<div class="modal-header">
<h2 class="modal-header-title">
<i class="fa fa-share-square-o"></i>
<span class="p-l-1">{{ modalTitle }}</span>
</h2>
<ul class="gf-tabs">
<li class="gf-tabs-item" ng-repeat="tab in tabs">
<a class="gf-tabs-link" ng-click="editor.index = $index" ng-class="{active: editor.index === $index}">
{{::tab.title}}
</a>
</li>
</ul>
<a class="modal-header-close" ng-click="dismiss();">
<i class="fa fa-remove"></i>
</a>
</div>
<div class="modal-content" ng-repeat="tab in tabs" ng-if="editor.index == $index">
<div ng-include src="tab.src" class="share-modal-body"></div>
</div>
</div>
<script type="text/ng-template" id="shareEmbed.html">
<div class="share-modal-header">
<div class="share-modal-big-icon">
<i class="fa fa-code"></i>
</div>
<div class="share-modal-content">
<div ng-include src="'shareLinkOptions.html'"></div>
<p class="share-modal-info-text">
The html code below can be pasted and included in another web page. Unless anonymous access
is enabled, the user viewing that page need to be signed into grafana for the graph to load.
</p>
<div class="gf-form-group gf-form--grow">
<div class="gf-form">
<textarea rows="5" data-share-panel-url class="gf-form-input" ng-model='iframeHtml'></textarea>
</div>
</div>
</div>
</div>
</script>
<script type="text/ng-template" id="shareExport.html">
<dash-export-modal dismiss="dismiss()"></dash-export-modal>
</script>
<script type="text/ng-template" id="shareLinkOptions.html">
<div class="gf-form-group">
<gf-form-switch class="gf-form"
label="Current time range" label-class="width-12" switch-class="max-width-6"
checked="options.forCurrent" on-change="buildUrl()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label="Template variables" label-class="width-12" switch-class="max-width-6"
checked="options.includeTemplateVars" on-change="buildUrl()">
</gf-form-switch>
<div class="gf-form">
<span class="gf-form-label width-12">Theme</span>
<div class="gf-form-select-wrapper width-10">
<select class="gf-form-input" ng-model="options.theme" ng-options="f as f for f in ['current', 'dark', 'light']" ng-change="buildUrl()"></select>
</div>
</div>
</div>
</script>
<script type="text/ng-template" id="shareLink.html">
<div class="share-modal-header">
<div class="share-modal-big-icon">
<i class="gicon gicon-link"></i>
</div>
<div class="share-modal-content">
<p class="share-modal-info-text">
Create a direct link to this dashboard or panel, customized with the options below.
</p>
<div ng-include src="'shareLinkOptions.html'"></div>
<div>
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<input type="text" data-share-panel-url class="gf-form-input" ng-model="shareUrl">
</div>
<div class="gf-form">
<button class="btn btn-inverse" clipboard-button="getShareUrl()">Copy</button>
</div>
</div>
</div>
</div>
<div class="gf-form" ng-show="modeSharePanel">
<a href="{{imageUrl}}" target="_blank" aria-label={{selectors.linkToRenderedImage}}><i class="fa fa-camera"></i> Direct link rendered image</a>
</div>
</div>
</script>
<script type="text/ng-template" id="shareSnapshot.html">
<div class="ng-cloak" ng-cloak ng-controller="ShareSnapshotCtrl" ng-init="init()">
<div class="share-modal-header">
<div class="share-modal-big-icon">
<i ng-if="loading" class="fa fa-spinner fa-spin"></i>
<i ng-if="!loading" class="gicon gicon-snapshots"></i>
</div>
<div class="share-modal-content">
<div ng-if="step === 1">
<p class="share-modal-info-text">
A snapshot is an instant way to share an interactive dashboard publicly.
When created, we <strong>strip sensitive data</strong> like queries (metric, template and annotation) and panel links,
leaving only the visible metric data and series names embedded into your dashboard.
</p>
<p class="share-modal-info-text">
Keep in mind, your <strong>snapshot can be viewed by anyone</strong> that has the link and can reach the URL.
Share wisely.
</p>
</div>
<div class="share-modal-header" ng-if="step === 3">
<p class="share-modal-info-text">
The snapshot has now been deleted. If it you have already accessed it once, It might take up to an hour before it is removed from
browser caches or CDN caches.
</p>
</div>
<div class="gf-form-group share-modal-options">
<div class="gf-form" ng-if="step === 1">
<span class="gf-form-label width-12">Snapshot name</span>
<input type="text" ng-model="snapshot.name" class="gf-form-input max-width-15">
</div>
<div class="gf-form" ng-if="step === 1">
<span class="gf-form-label width-12">Expire</span>
<div class="gf-form-select-wrapper max-width-15">
<select class="gf-form-input" ng-model="snapshot.expires" ng-options="f.value as f.text for f in expireOptions"></select>
</div>
</div>
<div class="gf-form" ng-if="step === 2" style="margin-top: 40px">
<div class="gf-form-row">
<a href="{{snapshotUrl}}" class="large share-modal-link" target="_blank">
<i class="fa fa-external-link-square"></i>
{{snapshotUrl}}
</a>
<br>
<button class="btn btn-inverse" clipboard-button="getSnapshotUrl()">Copy Link</button>
</div>
</div>
</div>
<div ng-if="step === 1">
<p class="share-modal-info-text">
You may need to configure the timeout value if it takes a long time to collect your dashboard's metrics.
</p>
</div>
<div class="gf-form-group share-modal-options">
<div class="gf-form" ng-if="step === 1">
<span class="gf-form-label width-12">Timeout (seconds)</span>
<input type="number" ng-model="snapshot.timeoutSeconds" class="gf-form-input max-width-15">
</div>
</div>
<div ng-if="step === 1" class="gf-form-button-row">
<button class="btn gf-form-btn width-10 btn-primary" ng-click="createSnapshot()" ng-disabled="loading">
Local Snapshot
</button>
<button class="btn gf-form-btn width-16 btn-secondary" ng-if="externalEnabled" ng-click="createSnapshot(true)" ng-disabled="loading">
{{sharingButtonText}}
</button>
<a class="btn btn-link" ng-click="dismiss()">Cancel</a>
</div>
<div class="pull-right" ng-if="step === 2" style="padding: 5px">
Did you make a mistake? <a class="pointer" ng-click="deleteSnapshot()" target="_blank">delete snapshot.</a>
</div>
</div>
</div>
</div>
</div>
</script>

@ -0,0 +1,126 @@
import { config } from '@grafana/runtime';
import { appendQueryToUrl, toUrlParams, getUrlSearchParams } from 'app/core/utils/url';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import templateSrv from 'app/features/templating/template_srv';
import { PanelModel, dateTime } from '@grafana/data';
export function buildParams(
useCurrentTimeRange: boolean,
includeTemplateVars: boolean,
selectedTheme?: string,
panel?: PanelModel
) {
const params = getUrlSearchParams();
const range = getTimeSrv().timeRange();
params.from = range.from.valueOf();
params.to = range.to.valueOf();
params.orgId = config.bootData.user.orgId;
if (!useCurrentTimeRange) {
delete params.from;
delete params.to;
}
if (includeTemplateVars) {
templateSrv.fillVariableValuesForUrl(params);
}
if (selectedTheme !== 'current') {
params.theme = selectedTheme;
}
if (panel) {
params.panelId = panel.id;
params.fullscreen = true;
} else {
delete params.panelId;
delete params.fullscreen;
}
return params;
}
export function buildBaseUrl() {
let baseUrl = window.location.href;
const queryStart = baseUrl.indexOf('?');
if (queryStart !== -1) {
baseUrl = baseUrl.substring(0, queryStart);
}
return baseUrl;
}
export function buildShareUrl(
useCurrentTimeRange: boolean,
includeTemplateVars: boolean,
selectedTheme?: string,
panel?: PanelModel
) {
const baseUrl = buildBaseUrl();
const params = buildParams(useCurrentTimeRange, includeTemplateVars, selectedTheme, panel);
return appendQueryToUrl(baseUrl, toUrlParams(params));
}
export function buildSoloUrl(
useCurrentTimeRange: boolean,
includeTemplateVars: boolean,
selectedTheme?: string,
panel?: PanelModel
) {
const baseUrl = buildBaseUrl();
const params = buildParams(useCurrentTimeRange, includeTemplateVars, selectedTheme, panel);
let soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/');
soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/');
delete params.fullscreen;
delete params.edit;
return appendQueryToUrl(soloUrl, toUrlParams(params));
}
export function buildImageUrl(
useCurrentTimeRange: boolean,
includeTemplateVars: boolean,
selectedTheme?: string,
panel?: PanelModel
) {
let soloUrl = buildSoloUrl(useCurrentTimeRange, includeTemplateVars, selectedTheme, panel);
let imageUrl = soloUrl.replace(config.appSubUrl + '/dashboard-solo/', config.appSubUrl + '/render/dashboard-solo/');
imageUrl = imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/');
imageUrl += '&width=1000&height=500' + getLocalTimeZone();
return imageUrl;
}
export function buildIframeHtml(
useCurrentTimeRange: boolean,
includeTemplateVars: boolean,
selectedTheme?: string,
panel?: PanelModel
) {
let soloUrl = buildSoloUrl(useCurrentTimeRange, includeTemplateVars, selectedTheme, panel);
return '<iframe src="' + soloUrl + '" width="450" height="200" frameborder="0"></iframe>';
}
export function getLocalTimeZone() {
const utcOffset = '&tz=UTC' + encodeURIComponent(dateTime().format('Z'));
// Older browser does not the internationalization API
if (!(window as any).Intl) {
return utcOffset;
}
const dateFormat = (window as any).Intl.DateTimeFormat();
if (!dateFormat.resolvedOptions) {
return utcOffset;
}
const options = dateFormat.resolvedOptions();
if (!options.timeZone) {
return utcOffset;
}
return '&tz=' + encodeURIComponent(options.timeZone);
}

@ -12,7 +12,6 @@ import './components/VersionHistory';
import './components/DashboardSettings';
import './components/SubMenu';
import './components/UnsavedChangesModal';
import './components/ShareModal';
import './components/AdHocFilters';
import './components/RowOptions';

@ -20,6 +20,8 @@ import templateSrv from 'app/features/templating/template_srv';
import { LS_PANEL_COPY_KEY, PANEL_BORDER } from 'app/core/constants';
import { CoreEvents } from 'app/types';
import { ShareModal } from 'app/features/dashboard/components/ShareModal/ShareModal';
export const removePanel = (dashboard: DashboardModel, panel: PanelModel, ask: boolean) => {
// confirm deletion
if (ask !== false) {
@ -82,9 +84,9 @@ export const editPanelJson = (dashboard: DashboardModel, panel: PanelModel) => {
};
export const sharePanel = (dashboard: DashboardModel, panel: PanelModel) => {
appEvents.emit(CoreEvents.showModal, {
src: 'public/app/features/dashboard/components/ShareModal/template.html',
model: {
appEvents.emit(CoreEvents.showModalReact, {
component: ShareModal,
props: {
dashboard: dashboard,
panel: panel,
},

@ -27,13 +27,13 @@ export class MockDataSourceApi extends DataSourceApi {
result: DataQueryResponse = { data: [] };
queryResolver: Promise<DataQueryResponse>;
constructor(name?: string, result?: DataQueryResponse) {
constructor(name?: string, result?: DataQueryResponse, meta?: any) {
super({ name: name ? name : 'MockDataSourceApi' } as DataSourceInstanceSettings);
if (result) {
this.result = result;
}
this.meta = {} as DataSourcePluginMeta;
this.meta = meta || ({} as DataSourcePluginMeta);
}
query(request: DataQueryRequest): Promise<DataQueryResponse> {

Loading…
Cancel
Save