mirror of https://github.com/grafana/grafana
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
parent
87da6a293b
commit
d66e72fa67
@ -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> |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -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); |
||||||
|
`,
|
||||||
|
})); |
@ -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,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> |
|
@ -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); |
||||||
|
} |
Loading…
Reference in new issue