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