mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
87da6a293b
commit
d66e72fa67
@ -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);
|
||||
`,
|
||||
}));
|
||||
|
25
packages/grafana-ui/src/components/Modal/ModalHeader.tsx
Normal file
25
packages/grafana-ui/src/components/Modal/ModalHeader.tsx
Normal file
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
39
packages/grafana-ui/src/components/Modal/ModalTabsHeader.tsx
Normal file
39
packages/grafana-ui/src/components/Modal/ModalTabsHeader.tsx
Normal file
@ -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>
|
||||
);
|
||||
};
|
60
packages/grafana-ui/src/components/Modal/getModalStyles.ts
Normal file
60
packages/grafana-ui/src/components/Modal/getModalStyles.ts
Normal file
@ -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,17 +71,19 @@ export class DashboardExporter {
|
||||
}
|
||||
|
||||
promises.push(
|
||||
this.datasourceSrv.get(datasource).then(ds => {
|
||||
if (ds.meta.builtIn) {
|
||||
getDataSourceSrv()
|
||||
.get(datasource)
|
||||
.then(ds => {
|
||||
if (ds.meta?.builtIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// add data source type to require list
|
||||
requires['datasource' + ds.meta.id] = {
|
||||
requires['datasource' + ds.meta?.id] = {
|
||||
type: 'datasource',
|
||||
id: ds.meta.id,
|
||||
name: ds.meta.name,
|
||||
version: ds.meta.info.version || '1.0.0',
|
||||
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
|
||||
@ -97,8 +97,8 @@ export class DashboardExporter {
|
||||
label: ds.name,
|
||||
description: '',
|
||||
type: 'datasource',
|
||||
pluginId: ds.meta.id,
|
||||
pluginName: ds.meta.name,
|
||||
pluginId: ds.meta?.id,
|
||||
pluginName: ds.meta?.name,
|
||||
};
|
||||
|
||||
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,18 +199,26 @@ export class DashNav extends PureComponent<Props> {
|
||||
)}
|
||||
|
||||
{canShare && (
|
||||
<ModalsController>
|
||||
{({ showModal, hideModal }) => (
|
||||
<DashNavButton
|
||||
tooltip="Share dashboard"
|
||||
classSuffix="share"
|
||||
icon="fa fa-share-square-o"
|
||||
onClick={this.onOpenShare}
|
||||
onClick={() => {
|
||||
showModal(ShareModal, {
|
||||
dashboard,
|
||||
onDismiss: hideModal,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ModalsController>
|
||||
)}
|
||||
|
||||
{canSave && (
|
||||
<ModalsController>
|
||||
{({ showModal, hideModal }) => {
|
||||
return (
|
||||
{({ showModal, hideModal }) => (
|
||||
<DashNavButton
|
||||
tooltip="Save dashboard"
|
||||
classSuffix="save"
|
||||
@ -233,8 +230,7 @@ export class DashNav extends PureComponent<Props> {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
)}
|
||||
</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>
|
126
public/app/features/dashboard/components/ShareModal/utils.ts
Normal file
126
public/app/features/dashboard/components/ShareModal/utils.ts
Normal file
@ -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…
Reference in New Issue
Block a user