mirror of
https://github.com/grafana/grafana.git
synced 2025-08-06 20:59:35 +08:00
Migration: Migrate Dashboard Import to React (#22338)
* first things * introduce headers and moving buttons * adding reducer and action for gcom dashboard * action working * continue building on import form * change dashboard title * add prop to not render a label * first things * introduce headers and moving buttons * adding reducer and action for gcom dashboard * action working * continue building on import form * change dashboard title * add prop to not render a label * import form layout * break out form to component * add actions and reader for file upload * fix upload issue * modified data types to handle both gcom and file upload * import dashboard json * save dashboard * start change uid * change dashboard uid * fix spacing and date format * fix import from json * handle uid and title change * revert change in panelinspect * redo fileupload component * after review * redo forms to use Forms functionality * first attempt on async validation * use ternary on uid input * removed unused actions, fixed async validation on form * post form if invalid, break out form to component * sync file with master * fix after merge * nits * export formapi type * redo page to use forms validation * fix inputs and validation * readd post * add guards on data source and constants * type checks and strict nulls * strict nulls * validate onchange and fix import button when valid * shorten validate call * reexport OnSubmit type * add comment for overwrite useEffect * move validation functions to util * fix button imports * remove angular import * move title and uid validation
This commit is contained in:
@ -1,24 +1,34 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useForm, Mode, OnSubmit, DeepPartial, FormContextValues } from 'react-hook-form';
|
import { useForm, Mode, OnSubmit, DeepPartial } from 'react-hook-form';
|
||||||
|
import { FormAPI } from '../../types';
|
||||||
type FormAPI<T> = Pick<FormContextValues<T>, 'register' | 'errors' | 'control' | 'formState'>;
|
|
||||||
|
|
||||||
interface FormProps<T> {
|
interface FormProps<T> {
|
||||||
validateOn?: Mode;
|
validateOn?: Mode;
|
||||||
|
validateOnMount?: boolean;
|
||||||
|
validateFieldsOnMount?: string[];
|
||||||
defaultValues?: DeepPartial<T>;
|
defaultValues?: DeepPartial<T>;
|
||||||
onSubmit: OnSubmit<T>;
|
onSubmit: OnSubmit<T>;
|
||||||
children: (api: FormAPI<T>) => React.ReactNode;
|
children: (api: FormAPI<T>) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Form<T>({ defaultValues, onSubmit, children, validateOn = 'onSubmit' }: FormProps<T>) {
|
export function Form<T>({
|
||||||
const { handleSubmit, register, errors, control, reset, getValues, formState } = useForm<T>({
|
defaultValues,
|
||||||
|
onSubmit,
|
||||||
|
validateOnMount = false,
|
||||||
|
validateFieldsOnMount,
|
||||||
|
children,
|
||||||
|
validateOn = 'onSubmit',
|
||||||
|
}: FormProps<T>) {
|
||||||
|
const { handleSubmit, register, errors, control, triggerValidation, getValues, formState } = useForm<T>({
|
||||||
mode: validateOn,
|
mode: validateOn,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reset({ ...getValues(), ...defaultValues });
|
if (validateOnMount) {
|
||||||
}, [defaultValues]);
|
triggerValidation(validateFieldsOnMount);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return <form onSubmit={handleSubmit(onSubmit)}>{children({ register, errors, control, formState })}</form>;
|
return <form onSubmit={handleSubmit(onSubmit)}>{children({ register, errors, control, getValues, formState })}</form>;
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { useTheme, stylesFactory } from '../../themes';
|
import { useTheme, stylesFactory } from '../../themes';
|
||||||
import { GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
import { css, cx } from 'emotion';
|
import { css, cx } from 'emotion';
|
||||||
|
|
||||||
export interface LabelProps extends React.HTMLAttributes<HTMLLegendElement> {
|
export interface LabelProps extends React.HTMLAttributes<HTMLLegendElement> {
|
||||||
children: string;
|
children: string | ReactNode;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ import { AsyncSelect, Select } from './Select/Select';
|
|||||||
import { Form } from './Form';
|
import { Form } from './Form';
|
||||||
import { Field } from './Field';
|
import { Field } from './Field';
|
||||||
import { Switch } from './Switch';
|
import { Switch } from './Switch';
|
||||||
|
import { Legend } from './Legend';
|
||||||
import { TextArea } from './TextArea/TextArea';
|
import { TextArea } from './TextArea/TextArea';
|
||||||
import { Checkbox } from './Checkbox';
|
import { Checkbox } from './Checkbox';
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ const Forms = {
|
|||||||
AsyncSelect,
|
AsyncSelect,
|
||||||
TextArea,
|
TextArea,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Legend,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Forms;
|
export default Forms;
|
||||||
|
@ -154,7 +154,7 @@ export class Select<T> extends PureComponent<LegacySelectProps<T>> {
|
|||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
openMenuOnFocus={openMenuOnFocus}
|
openMenuOnFocus={openMenuOnFocus}
|
||||||
maxMenuHeight={maxMenuHeight}
|
maxMenuHeight={maxMenuHeight}
|
||||||
noOptionsMessage={() => noOptionsMessage}
|
noOptionsMessage={noOptionsMessage}
|
||||||
isMulti={isMulti}
|
isMulti={isMulti}
|
||||||
backspaceRemovesValue={backspaceRemovesValue}
|
backspaceRemovesValue={backspaceRemovesValue}
|
||||||
menuIsOpen={isOpen}
|
menuIsOpen={isOpen}
|
||||||
|
4
packages/grafana-ui/src/types/forms.ts
Normal file
4
packages/grafana-ui/src/types/forms.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { FormContextValues } from 'react-hook-form';
|
||||||
|
export { OnSubmit as FormsOnSubmit, FieldErrors as FormFieldErrors } from 'react-hook-form';
|
||||||
|
|
||||||
|
export type FormAPI<T> = Pick<FormContextValues<T>, 'register' | 'errors' | 'control' | 'formState' | 'getValues'>;
|
@ -2,3 +2,4 @@ export * from './theme';
|
|||||||
export * from './input';
|
export * from './input';
|
||||||
export * from './completion';
|
export * from './completion';
|
||||||
export * from './storybook';
|
export * from './storybook';
|
||||||
|
export * from './forms';
|
||||||
|
@ -8,18 +8,20 @@ import { SelectableValue, DataSourceSelectItem } from '@grafana/data';
|
|||||||
export interface Props {
|
export interface Props {
|
||||||
onChange: (ds: DataSourceSelectItem) => void;
|
onChange: (ds: DataSourceSelectItem) => void;
|
||||||
datasources: DataSourceSelectItem[];
|
datasources: DataSourceSelectItem[];
|
||||||
current: DataSourceSelectItem;
|
current?: DataSourceSelectItem;
|
||||||
hideTextValue?: boolean;
|
hideTextValue?: boolean;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
openMenuOnFocus?: boolean;
|
openMenuOnFocus?: boolean;
|
||||||
showLoading?: boolean;
|
showLoading?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DataSourcePicker extends PureComponent<Props> {
|
export class DataSourcePicker extends PureComponent<Props> {
|
||||||
static defaultProps: Partial<Props> = {
|
static defaultProps: Partial<Props> = {
|
||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
openMenuOnFocus: false,
|
openMenuOnFocus: false,
|
||||||
|
placeholder: 'Select datasource',
|
||||||
};
|
};
|
||||||
|
|
||||||
searchInput: HTMLElement;
|
searchInput: HTMLElement;
|
||||||
@ -30,11 +32,23 @@ export class DataSourcePicker extends PureComponent<Props> {
|
|||||||
|
|
||||||
onChange = (item: SelectableValue<string>) => {
|
onChange = (item: SelectableValue<string>) => {
|
||||||
const ds = this.props.datasources.find(ds => ds.name === item.value);
|
const ds = this.props.datasources.find(ds => ds.name === item.value);
|
||||||
this.props.onChange(ds);
|
|
||||||
|
if (ds) {
|
||||||
|
this.props.onChange(ds);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { datasources, current, autoFocus, hideTextValue, onBlur, openMenuOnFocus, showLoading } = this.props;
|
const {
|
||||||
|
datasources,
|
||||||
|
current,
|
||||||
|
autoFocus,
|
||||||
|
hideTextValue,
|
||||||
|
onBlur,
|
||||||
|
openMenuOnFocus,
|
||||||
|
showLoading,
|
||||||
|
placeholder,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
const options = datasources.map(ds => ({
|
const options = datasources.map(ds => ({
|
||||||
value: ds.name,
|
value: ds.name,
|
||||||
@ -63,7 +77,7 @@ export class DataSourcePicker extends PureComponent<Props> {
|
|||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
openMenuOnFocus={openMenuOnFocus}
|
openMenuOnFocus={openMenuOnFocus}
|
||||||
maxMenuHeight={500}
|
maxMenuHeight={500}
|
||||||
placeholder="Select datasource"
|
placeholder={placeholder}
|
||||||
noOptionsMessage={() => 'No datasources found'}
|
noOptionsMessage={() => 'No datasources found'}
|
||||||
value={value}
|
value={value}
|
||||||
/>
|
/>
|
||||||
|
@ -9,7 +9,7 @@ import { DashboardSearchHit } from '../../../types';
|
|||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
onChange: ($folder: { title: string; id: number }) => void;
|
onChange: ($folder: { title: string; id: number }) => void;
|
||||||
enableCreateNew: boolean;
|
enableCreateNew?: boolean;
|
||||||
rootName?: string;
|
rootName?: string;
|
||||||
enableReset?: boolean;
|
enableReset?: boolean;
|
||||||
dashboardId?: any;
|
dashboardId?: any;
|
||||||
@ -43,6 +43,7 @@ export class FolderPicker extends PureComponent<Props, State> {
|
|||||||
enableReset: false,
|
enableReset: false,
|
||||||
initialTitle: '',
|
initialTitle: '',
|
||||||
enableCreateNew: false,
|
enableCreateNew: false,
|
||||||
|
useInNextGenForms: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount = async () => {
|
componentDidMount = async () => {
|
||||||
@ -115,7 +116,7 @@ export class FolderPicker extends PureComponent<Props, State> {
|
|||||||
folder = resetFolder;
|
folder = resetFolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!folder) {
|
if (folder.value === -1) {
|
||||||
if (contextSrv.isEditor) {
|
if (contextSrv.isEditor) {
|
||||||
folder = rootFolder;
|
folder = rootFolder;
|
||||||
} else {
|
} else {
|
||||||
|
@ -14,6 +14,7 @@ import userReducers from 'app/features/profile/state/reducers';
|
|||||||
import organizationReducers from 'app/features/org/state/reducers';
|
import organizationReducers from 'app/features/org/state/reducers';
|
||||||
import ldapReducers from 'app/features/admin/state/reducers';
|
import ldapReducers from 'app/features/admin/state/reducers';
|
||||||
import templatingReducers from 'app/features/variables/state/reducers';
|
import templatingReducers from 'app/features/variables/state/reducers';
|
||||||
|
import importDashboardReducers from 'app/features/manage-dashboards/state/reducers';
|
||||||
|
|
||||||
const rootReducers = {
|
const rootReducers = {
|
||||||
...sharedReducers,
|
...sharedReducers,
|
||||||
@ -30,6 +31,7 @@ const rootReducers = {
|
|||||||
...organizationReducers,
|
...organizationReducers,
|
||||||
...ldapReducers,
|
...ldapReducers,
|
||||||
...templatingReducers,
|
...templatingReducers,
|
||||||
|
...importDashboardReducers,
|
||||||
};
|
};
|
||||||
|
|
||||||
const addedReducers = {};
|
const addedReducers = {};
|
||||||
|
@ -15,19 +15,25 @@ function getNotFoundModel(): NavModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNavModel(navIndex: NavIndex, id: string, fallback?: NavModel): NavModel {
|
export function getNavModel(navIndex: NavIndex, id: string, fallback?: NavModel, onlyChild = false): NavModel {
|
||||||
if (navIndex[id]) {
|
if (navIndex[id]) {
|
||||||
const node = navIndex[id];
|
const node = navIndex[id];
|
||||||
const main = {
|
|
||||||
...node.parentItem,
|
|
||||||
};
|
|
||||||
|
|
||||||
main.children = main.children.map(item => {
|
let main: NavModelItem;
|
||||||
return {
|
if (!onlyChild && node.parentItem) {
|
||||||
...item,
|
main = { ...node.parentItem };
|
||||||
active: item.url === node.url,
|
|
||||||
};
|
main.children =
|
||||||
});
|
main.children &&
|
||||||
|
main.children.map(item => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
active: item.url === node.url,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
main = node;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
node: node,
|
node: node,
|
||||||
|
@ -1,86 +0,0 @@
|
|||||||
import { DashboardImportCtrl } from './DashboardImportCtrl';
|
|
||||||
import config from 'app/core/config';
|
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
|
||||||
import { IScope } from 'angular';
|
|
||||||
|
|
||||||
describe('DashboardImportCtrl', () => {
|
|
||||||
const ctx: any = {};
|
|
||||||
jest.spyOn(backendSrv, 'getDashboardByUid').mockImplementation(() => Promise.resolve([]));
|
|
||||||
jest.spyOn(backendSrv, 'search').mockImplementation(() => Promise.resolve([]));
|
|
||||||
const getMock = jest.spyOn(backendSrv, 'get');
|
|
||||||
const $scope = ({ $evalAsync: jest.fn() } as any) as IScope;
|
|
||||||
|
|
||||||
let navModelSrv: any;
|
|
||||||
let validationSrv: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
navModelSrv = {
|
|
||||||
getNav: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
validationSrv = {
|
|
||||||
validateNewDashboardName: jest.fn().mockReturnValue(Promise.resolve()),
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.ctrl = new DashboardImportCtrl($scope, validationSrv, navModelSrv, {} as any, {} as any);
|
|
||||||
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when uploading json', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
config.datasources = {
|
|
||||||
ds: {
|
|
||||||
type: 'test-db',
|
|
||||||
} as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.ctrl.onUpload({
|
|
||||||
__inputs: [
|
|
||||||
{
|
|
||||||
name: 'ds',
|
|
||||||
pluginId: 'test-db',
|
|
||||||
type: 'datasource',
|
|
||||||
pluginName: 'Test DB',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should build input model', () => {
|
|
||||||
expect(ctx.ctrl.inputs.length).toBe(1);
|
|
||||||
expect(ctx.ctrl.inputs[0].name).toBe('ds');
|
|
||||||
expect(ctx.ctrl.inputs[0].info).toBe('Select a Test DB data source');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set inputValid to false', () => {
|
|
||||||
expect(ctx.ctrl.inputsValid).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when specifying grafana.com url', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
ctx.ctrl.gnetUrl = 'http://grafana.com/dashboards/123';
|
|
||||||
// setup api mock
|
|
||||||
getMock.mockImplementation(() => Promise.resolve({ json: {} }));
|
|
||||||
return ctx.ctrl.checkGnetDashboard();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call gnet api with correct dashboard id', () => {
|
|
||||||
expect(getMock.mock.calls[0][0]).toBe('api/gnet/dashboards/123');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when specifying dashboard id', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
ctx.ctrl.gnetUrl = '2342';
|
|
||||||
// setup api mock
|
|
||||||
getMock.mockImplementation(() => Promise.resolve({ json: {} }));
|
|
||||||
return ctx.ctrl.checkGnetDashboard();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call gnet api with correct dashboard id', () => {
|
|
||||||
expect(getMock.mock.calls[0][0]).toBe('api/gnet/dashboards/2342');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,258 +0,0 @@
|
|||||||
import _ from 'lodash';
|
|
||||||
import config from 'app/core/config';
|
|
||||||
import locationUtil from 'app/core/utils/location_util';
|
|
||||||
import { ValidationSrv } from './services/ValidationSrv';
|
|
||||||
import { NavModelSrv } from 'app/core/core';
|
|
||||||
import { ILocationService, IScope } from 'angular';
|
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
|
||||||
import { promiseToDigest } from 'app/core/utils/promiseToDigest';
|
|
||||||
|
|
||||||
export class DashboardImportCtrl {
|
|
||||||
navModel: any;
|
|
||||||
step: number;
|
|
||||||
jsonText: string;
|
|
||||||
parseError: string;
|
|
||||||
nameExists: boolean;
|
|
||||||
uidExists: boolean;
|
|
||||||
dash: any;
|
|
||||||
inputs: any[];
|
|
||||||
inputsValid: boolean;
|
|
||||||
gnetUrl: string;
|
|
||||||
gnetError: string;
|
|
||||||
gnetInfo: any;
|
|
||||||
titleTouched: boolean;
|
|
||||||
hasNameValidationError: boolean;
|
|
||||||
nameValidationError: any;
|
|
||||||
hasUidValidationError: boolean;
|
|
||||||
uidValidationError: any;
|
|
||||||
autoGenerateUid: boolean;
|
|
||||||
autoGenerateUidValue: string;
|
|
||||||
folderId: number;
|
|
||||||
initialFolderTitle: string;
|
|
||||||
isValidFolderSelection: boolean;
|
|
||||||
|
|
||||||
/** @ngInject */
|
|
||||||
constructor(
|
|
||||||
private $scope: IScope,
|
|
||||||
private validationSrv: ValidationSrv,
|
|
||||||
navModelSrv: NavModelSrv,
|
|
||||||
private $location: ILocationService,
|
|
||||||
$routeParams: any
|
|
||||||
) {
|
|
||||||
this.navModel = navModelSrv.getNav('create', 'import');
|
|
||||||
|
|
||||||
this.step = 1;
|
|
||||||
this.nameExists = false;
|
|
||||||
this.uidExists = false;
|
|
||||||
this.autoGenerateUid = true;
|
|
||||||
this.autoGenerateUidValue = 'auto-generated';
|
|
||||||
this.folderId = $routeParams.folderId ? Number($routeParams.folderId) || 0 : null;
|
|
||||||
this.initialFolderTitle = 'Select a folder';
|
|
||||||
|
|
||||||
// check gnetId in url
|
|
||||||
if ($routeParams.gnetId) {
|
|
||||||
this.gnetUrl = $routeParams.gnetId;
|
|
||||||
this.checkGnetDashboard();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpload(dash: any) {
|
|
||||||
this.dash = dash;
|
|
||||||
this.dash.id = null;
|
|
||||||
this.step = 2;
|
|
||||||
this.inputs = [];
|
|
||||||
|
|
||||||
if (this.dash.__inputs) {
|
|
||||||
for (const input of this.dash.__inputs) {
|
|
||||||
const inputModel: any = {
|
|
||||||
name: input.name,
|
|
||||||
label: input.label,
|
|
||||||
info: input.description,
|
|
||||||
value: input.value,
|
|
||||||
type: input.type,
|
|
||||||
pluginId: input.pluginId,
|
|
||||||
options: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (input.type === 'datasource') {
|
|
||||||
this.setDatasourceOptions(input, inputModel);
|
|
||||||
} else if (!inputModel.info) {
|
|
||||||
inputModel.info = 'Specify a string constant';
|
|
||||||
}
|
|
||||||
|
|
||||||
this.inputs.push(inputModel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.inputsValid = this.inputs.length === 0;
|
|
||||||
this.titleChanged();
|
|
||||||
this.uidChanged(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
setDatasourceOptions(input: { pluginId: string; pluginName: string }, inputModel: any) {
|
|
||||||
const sources = _.filter(config.datasources, val => {
|
|
||||||
return val.type === input.pluginId;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sources.length === 0) {
|
|
||||||
inputModel.info = 'No data sources of type ' + input.pluginName + ' found';
|
|
||||||
} else if (!inputModel.info) {
|
|
||||||
inputModel.info = 'Select a ' + input.pluginName + ' data source';
|
|
||||||
}
|
|
||||||
|
|
||||||
inputModel.options = sources.map(val => {
|
|
||||||
return { text: val.name, value: val.name };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
inputValueChanged() {
|
|
||||||
this.inputsValid = true;
|
|
||||||
for (const input of this.inputs) {
|
|
||||||
if (!input.value) {
|
|
||||||
this.inputsValid = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
titleChanged() {
|
|
||||||
this.titleTouched = true;
|
|
||||||
this.nameExists = false;
|
|
||||||
|
|
||||||
promiseToDigest(this.$scope)(
|
|
||||||
this.validationSrv
|
|
||||||
.validateNewDashboardName(this.folderId, this.dash.title)
|
|
||||||
.then(() => {
|
|
||||||
this.nameExists = false;
|
|
||||||
this.hasNameValidationError = false;
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
if (err.type === 'EXISTING') {
|
|
||||||
this.nameExists = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.hasNameValidationError = true;
|
|
||||||
this.nameValidationError = err.message;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
uidChanged(initial: boolean) {
|
|
||||||
this.uidExists = false;
|
|
||||||
this.hasUidValidationError = false;
|
|
||||||
|
|
||||||
if (initial === true && this.dash.uid) {
|
|
||||||
this.autoGenerateUidValue = 'value set';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.dash.uid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
promiseToDigest(this.$scope)(
|
|
||||||
backendSrv
|
|
||||||
// @ts-ignore
|
|
||||||
.getDashboardByUid(this.dash.uid)
|
|
||||||
.then((res: any) => {
|
|
||||||
this.uidExists = true;
|
|
||||||
this.hasUidValidationError = true;
|
|
||||||
this.uidValidationError = `Dashboard named '${res.dashboard.title}' in folder '${res.meta.folderTitle}' has the same uid`;
|
|
||||||
})
|
|
||||||
.catch((err: any) => {
|
|
||||||
err.isHandled = true;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
onFolderChange = (folder: any) => {
|
|
||||||
this.folderId = folder.id;
|
|
||||||
this.titleChanged();
|
|
||||||
};
|
|
||||||
|
|
||||||
onEnterFolderCreation = () => {
|
|
||||||
this.inputsValid = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
onExitFolderCreation = () => {
|
|
||||||
this.inputValueChanged();
|
|
||||||
};
|
|
||||||
|
|
||||||
isValid() {
|
|
||||||
return this.inputsValid && this.folderId !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveDashboard() {
|
|
||||||
const inputs = this.inputs.map(input => {
|
|
||||||
return {
|
|
||||||
name: input.name,
|
|
||||||
type: input.type,
|
|
||||||
pluginId: input.pluginId,
|
|
||||||
value: input.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return promiseToDigest(this.$scope)(
|
|
||||||
backendSrv
|
|
||||||
.post('api/dashboards/import', {
|
|
||||||
dashboard: this.dash,
|
|
||||||
overwrite: true,
|
|
||||||
inputs: inputs,
|
|
||||||
folderId: this.folderId,
|
|
||||||
})
|
|
||||||
.then(res => {
|
|
||||||
const dashUrl = locationUtil.stripBaseFromUrl(res.importedUrl);
|
|
||||||
this.$location.url(dashUrl);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadJsonText() {
|
|
||||||
try {
|
|
||||||
this.parseError = '';
|
|
||||||
const dash = JSON.parse(this.jsonText);
|
|
||||||
this.onUpload(dash);
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
this.parseError = err.message;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkGnetDashboard() {
|
|
||||||
this.gnetError = '';
|
|
||||||
|
|
||||||
const match = /(^\d+$)|dashboards\/(\d+)/.exec(this.gnetUrl);
|
|
||||||
let dashboardId;
|
|
||||||
|
|
||||||
if (match && match[1]) {
|
|
||||||
dashboardId = match[1];
|
|
||||||
} else if (match && match[2]) {
|
|
||||||
dashboardId = match[2];
|
|
||||||
} else {
|
|
||||||
this.gnetError = 'Could not find dashboard';
|
|
||||||
}
|
|
||||||
|
|
||||||
return promiseToDigest(this.$scope)(
|
|
||||||
backendSrv
|
|
||||||
.get('api/gnet/dashboards/' + dashboardId)
|
|
||||||
.then(res => {
|
|
||||||
this.gnetInfo = res;
|
|
||||||
// store reference to grafana.com
|
|
||||||
res.json.gnetId = res.id;
|
|
||||||
this.onUpload(res.json);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
err.isHandled = true;
|
|
||||||
this.gnetError = err.data.message || err;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
back() {
|
|
||||||
this.gnetUrl = '';
|
|
||||||
this.step = 1;
|
|
||||||
this.gnetError = '';
|
|
||||||
this.gnetInfo = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DashboardImportCtrl;
|
|
162
public/app/features/manage-dashboards/DashboardImportPage.tsx
Normal file
162
public/app/features/manage-dashboards/DashboardImportPage.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import React, { FormEvent, PureComponent } from 'react';
|
||||||
|
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux';
|
||||||
|
import { css } from 'emotion';
|
||||||
|
import { AppEvents, NavModel } from '@grafana/data';
|
||||||
|
import { Button, Forms, stylesFactory } from '@grafana/ui';
|
||||||
|
import Page from 'app/core/components/Page/Page';
|
||||||
|
import { ImportDashboardOverview } from './components/ImportDashboardOverview';
|
||||||
|
import { DashboardFileUpload } from './components/DashboardFileUpload';
|
||||||
|
import { validateDashboardJson, validateGcomDashboard } from './utils/validation';
|
||||||
|
import { fetchGcomDashboard, importDashboardJson } from './state/actions';
|
||||||
|
import appEvents from 'app/core/app_events';
|
||||||
|
import { getNavModel } from 'app/core/selectors/navModel';
|
||||||
|
import { StoreState } from 'app/types';
|
||||||
|
|
||||||
|
interface OwnProps {}
|
||||||
|
|
||||||
|
interface ConnectedProps {
|
||||||
|
navModel: NavModel;
|
||||||
|
isLoaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
fetchGcomDashboard: typeof fetchGcomDashboard;
|
||||||
|
importDashboardJson: typeof importDashboardJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||||
|
|
||||||
|
class DashboardImportUnConnected extends PureComponent<Props> {
|
||||||
|
onFileUpload = (event: FormEvent<HTMLInputElement>) => {
|
||||||
|
const { importDashboardJson } = this.props;
|
||||||
|
const file = event.currentTarget.files && event.currentTarget.files.length > 0 && event.currentTarget.files[0];
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
const readerOnLoad = () => {
|
||||||
|
return (e: any) => {
|
||||||
|
let dashboard: any;
|
||||||
|
try {
|
||||||
|
dashboard = JSON.parse(e.target.result);
|
||||||
|
} catch (error) {
|
||||||
|
appEvents.emit(AppEvents.alertError, [
|
||||||
|
'Import failed',
|
||||||
|
'JSON -> JS Serialization failed: ' + error.message,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
importDashboardJson(dashboard);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
reader.onload = readerOnLoad();
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getDashboardFromJson = (formData: { dashboardJson: string }) => {
|
||||||
|
this.props.importDashboardJson(JSON.parse(formData.dashboardJson));
|
||||||
|
};
|
||||||
|
|
||||||
|
getGcomDashboard = (formData: { gcomDashboard: string }) => {
|
||||||
|
let dashboardId;
|
||||||
|
const match = /(^\d+$)|dashboards\/(\d+)/.exec(formData.gcomDashboard);
|
||||||
|
if (match && match[1]) {
|
||||||
|
dashboardId = match[1];
|
||||||
|
} else if (match && match[2]) {
|
||||||
|
dashboardId = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dashboardId) {
|
||||||
|
this.props.fetchGcomDashboard(dashboardId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderImportForm() {
|
||||||
|
const styles = importStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.option}>
|
||||||
|
<DashboardFileUpload onFileUpload={this.onFileUpload} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.option}>
|
||||||
|
<Forms.Legend>Import via grafana.com</Forms.Legend>
|
||||||
|
<Forms.Form onSubmit={this.getGcomDashboard} defaultValues={{ gcomDashboard: '' }}>
|
||||||
|
{({ register, errors }) => (
|
||||||
|
<Forms.Field
|
||||||
|
invalid={!!errors.gcomDashboard}
|
||||||
|
error={errors.gcomDashboard && errors.gcomDashboard.message}
|
||||||
|
>
|
||||||
|
<Forms.Input
|
||||||
|
size="md"
|
||||||
|
name="gcomDashboard"
|
||||||
|
placeholder="Grafana.com dashboard url or id"
|
||||||
|
type="text"
|
||||||
|
ref={register({
|
||||||
|
required: 'A Grafana dashboard url or id is required',
|
||||||
|
validate: validateGcomDashboard,
|
||||||
|
})}
|
||||||
|
addonAfter={<Button type="submit">Load</Button>}
|
||||||
|
/>
|
||||||
|
</Forms.Field>
|
||||||
|
)}
|
||||||
|
</Forms.Form>
|
||||||
|
</div>
|
||||||
|
<div className={styles.option}>
|
||||||
|
<Forms.Legend>Import via panel json</Forms.Legend>
|
||||||
|
<Forms.Form onSubmit={this.getDashboardFromJson} defaultValues={{ dashboardJson: '' }}>
|
||||||
|
{({ register, errors }) => (
|
||||||
|
<>
|
||||||
|
<Forms.Field
|
||||||
|
invalid={!!errors.dashboardJson}
|
||||||
|
error={errors.dashboardJson && errors.dashboardJson.message}
|
||||||
|
>
|
||||||
|
<Forms.TextArea
|
||||||
|
name="dashboardJson"
|
||||||
|
ref={register({
|
||||||
|
required: 'Need a dashboard json model',
|
||||||
|
validate: validateDashboardJson,
|
||||||
|
})}
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</Forms.Field>
|
||||||
|
<Button type="submit">Load</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Forms.Form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { isLoaded, navModel } = this.props;
|
||||||
|
return (
|
||||||
|
<Page navModel={navModel}>
|
||||||
|
<Page.Contents>{isLoaded ? <ImportDashboardOverview /> : this.renderImportForm()}</Page.Contents>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state: StoreState) => ({
|
||||||
|
navModel: getNavModel(state.navIndex, 'import', undefined, true),
|
||||||
|
isLoaded: state.importDashboard.isLoaded,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps: MapDispatchToProps<DispatchProps, Props> = {
|
||||||
|
fetchGcomDashboard,
|
||||||
|
importDashboardJson,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DashboardImportPage = connect(mapStateToProps, mapDispatchToProps)(DashboardImportUnConnected);
|
||||||
|
export default DashboardImportPage;
|
||||||
|
DashboardImportPage.displayName = 'DashboardImport';
|
||||||
|
|
||||||
|
const importStyles = stylesFactory(() => {
|
||||||
|
return {
|
||||||
|
option: css`
|
||||||
|
margin-bottom: 32px;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
});
|
@ -0,0 +1,39 @@
|
|||||||
|
import React, { FC, FormEvent } from 'react';
|
||||||
|
import { Forms, stylesFactory, useTheme } from '@grafana/ui';
|
||||||
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
|
import { css } from 'emotion';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onFileUpload: (event: FormEvent<HTMLInputElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||||
|
const buttonFormStyle = Forms.getFormStyles(theme, { variant: 'primary', invalid: false, size: 'md' }).button.button;
|
||||||
|
return {
|
||||||
|
fileUpload: css`
|
||||||
|
display: none;
|
||||||
|
`,
|
||||||
|
button: css`
|
||||||
|
${buttonFormStyle}
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DashboardFileUpload: FC<Props> = ({ onFileUpload }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const style = getStyles(theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={style.button}>
|
||||||
|
Upload .json file
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="fileUpload"
|
||||||
|
className={style.fileUpload}
|
||||||
|
onChange={onFileUpload}
|
||||||
|
multiple={false}
|
||||||
|
accept="application/json"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,155 @@
|
|||||||
|
import React, { FC, useEffect, useState } from 'react';
|
||||||
|
import { Button, Forms, FormAPI, FormsOnSubmit, HorizontalGroup, FormFieldErrors } from '@grafana/ui';
|
||||||
|
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||||
|
import DataSourcePicker from 'app/core/components/Select/DataSourcePicker';
|
||||||
|
import { DashboardInput, DashboardInputs, DataSourceInput, ImportDashboardDTO } from '../state/reducers';
|
||||||
|
import { validateTitle, validateUid } from '../utils/validation';
|
||||||
|
|
||||||
|
interface Props extends Omit<FormAPI<ImportDashboardDTO>, 'formState'> {
|
||||||
|
uidReset: boolean;
|
||||||
|
inputs: DashboardInputs;
|
||||||
|
initialFolderId: number;
|
||||||
|
|
||||||
|
onCancel: () => void;
|
||||||
|
onUidReset: () => void;
|
||||||
|
onSubmit: FormsOnSubmit<ImportDashboardDTO>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImportDashboardForm: FC<Props> = ({
|
||||||
|
register,
|
||||||
|
errors,
|
||||||
|
control,
|
||||||
|
getValues,
|
||||||
|
uidReset,
|
||||||
|
inputs,
|
||||||
|
initialFolderId,
|
||||||
|
onUidReset,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const [isSubmitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
/*
|
||||||
|
This useEffect is needed for overwriting a dashboard. It
|
||||||
|
submits the form even if there's validation errors on title or uid.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSubmitted && (errors.title || errors.uid)) {
|
||||||
|
onSubmit(getValues({ nest: true }), {} as any);
|
||||||
|
}
|
||||||
|
}, [errors]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Forms.Legend>Options</Forms.Legend>
|
||||||
|
<Forms.Field label="Name" invalid={!!errors.title} error={errors.title && errors.title.message}>
|
||||||
|
<Forms.Input
|
||||||
|
name="title"
|
||||||
|
size="md"
|
||||||
|
type="text"
|
||||||
|
ref={register({
|
||||||
|
required: 'Name is required',
|
||||||
|
validate: async (v: string) => await validateTitle(v, getValues().folderId),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Forms.Field>
|
||||||
|
<Forms.Field label="Folder">
|
||||||
|
<Forms.InputControl
|
||||||
|
as={FolderPicker}
|
||||||
|
name="folderId"
|
||||||
|
useNewForms
|
||||||
|
initialFolderId={initialFolderId}
|
||||||
|
control={control}
|
||||||
|
/>
|
||||||
|
</Forms.Field>
|
||||||
|
<Forms.Field
|
||||||
|
label="Unique identifier (uid)"
|
||||||
|
description="The unique identifier (uid) of a dashboard can be used for uniquely identify a dashboard between multiple Grafana installs.
|
||||||
|
The uid allows having consistent URL’s for accessing dashboards so changing the title of a dashboard will not break any
|
||||||
|
bookmarked links to that dashboard."
|
||||||
|
invalid={!!errors.uid}
|
||||||
|
error={errors.uid && errors.uid.message}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{!uidReset ? (
|
||||||
|
<Forms.Input
|
||||||
|
size="md"
|
||||||
|
name="uid"
|
||||||
|
disabled
|
||||||
|
ref={register({ validate: async (v: string) => await validateUid(v) })}
|
||||||
|
addonAfter={!uidReset && <Button onClick={onUidReset}>Change uid</Button>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Forms.Input
|
||||||
|
size="md"
|
||||||
|
name="uid"
|
||||||
|
ref={register({ required: true, validate: async (v: string) => await validateUid(v) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Forms.Field>
|
||||||
|
{inputs.dataSources &&
|
||||||
|
inputs.dataSources.map((input: DataSourceInput, index: number) => {
|
||||||
|
const dataSourceOption = `dataSources[${index}]`;
|
||||||
|
return (
|
||||||
|
<Forms.Field
|
||||||
|
label={input.label}
|
||||||
|
key={dataSourceOption}
|
||||||
|
invalid={errors.dataSources && !!errors.dataSources[index]}
|
||||||
|
error={errors.dataSources && errors.dataSources[index] && 'A data source is required'}
|
||||||
|
>
|
||||||
|
<Forms.InputControl
|
||||||
|
as={DataSourcePicker}
|
||||||
|
name={`${dataSourceOption}`}
|
||||||
|
datasources={input.options}
|
||||||
|
control={control}
|
||||||
|
placeholder={input.info}
|
||||||
|
rules={{ required: true }}
|
||||||
|
/>
|
||||||
|
</Forms.Field>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{inputs.constants &&
|
||||||
|
inputs.constants.map((input: DashboardInput, index) => {
|
||||||
|
const constantIndex = `constants[${index}]`;
|
||||||
|
return (
|
||||||
|
<Forms.Field
|
||||||
|
label={input.label}
|
||||||
|
error={errors.constants && errors.constants[index] && `${input.label} needs a value`}
|
||||||
|
invalid={errors.constants && !!errors.constants[index]}
|
||||||
|
key={constantIndex}
|
||||||
|
>
|
||||||
|
<Forms.Input
|
||||||
|
ref={register({ required: true })}
|
||||||
|
name={`${constantIndex}`}
|
||||||
|
size="md"
|
||||||
|
defaultValue={input.value}
|
||||||
|
/>
|
||||||
|
</Forms.Field>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<HorizontalGroup>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant={getButtonVariant(errors)}
|
||||||
|
onClick={() => {
|
||||||
|
setSubmitted(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getButtonText(errors)}
|
||||||
|
</Button>
|
||||||
|
<Button type="reset" variant="secondary" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</HorizontalGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function getButtonVariant(errors: FormFieldErrors<ImportDashboardDTO>) {
|
||||||
|
return errors && (errors.title || errors.uid) ? 'destructive' : 'primary';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getButtonText(errors: FormFieldErrors<ImportDashboardDTO>) {
|
||||||
|
return errors && (errors.title || errors.uid) ? 'Import (Overwrite)' : 'Import';
|
||||||
|
}
|
@ -0,0 +1,123 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import { dateTime } from '@grafana/data';
|
||||||
|
import { Forms } from '@grafana/ui';
|
||||||
|
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux';
|
||||||
|
import { ImportDashboardForm } from './ImportDashboardForm';
|
||||||
|
import { resetDashboard, saveDashboard } from '../state/actions';
|
||||||
|
import { DashboardInputs, DashboardSource, ImportDashboardDTO } from '../state/reducers';
|
||||||
|
import { StoreState } from 'app/types';
|
||||||
|
|
||||||
|
interface OwnProps {}
|
||||||
|
|
||||||
|
interface ConnectedProps {
|
||||||
|
dashboard: ImportDashboardDTO;
|
||||||
|
inputs: DashboardInputs;
|
||||||
|
source: DashboardSource;
|
||||||
|
meta?: any;
|
||||||
|
folderId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
resetDashboard: typeof resetDashboard;
|
||||||
|
saveDashboard: typeof saveDashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
uidReset: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImportDashboardOverviewUnConnected extends PureComponent<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
uidReset: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
onSubmit = (form: ImportDashboardDTO) => {
|
||||||
|
this.props.saveDashboard(form);
|
||||||
|
};
|
||||||
|
|
||||||
|
onCancel = () => {
|
||||||
|
this.props.resetDashboard();
|
||||||
|
};
|
||||||
|
|
||||||
|
onUidReset = () => {
|
||||||
|
this.setState({ uidReset: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { dashboard, inputs, meta, source, folderId } = this.props;
|
||||||
|
const { uidReset } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{source === DashboardSource.Gcom && (
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<div>
|
||||||
|
<Forms.Legend>
|
||||||
|
Importing Dashboard from{' '}
|
||||||
|
<a
|
||||||
|
href={`https://grafana.com/dashboards/${dashboard.gnetId}`}
|
||||||
|
className="external-link"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Grafana.com
|
||||||
|
</a>
|
||||||
|
</Forms.Legend>
|
||||||
|
</div>
|
||||||
|
<table className="filter-table form-inline">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Published by</td>
|
||||||
|
<td>{meta.orgName}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Updated on</td>
|
||||||
|
<td>{dateTime(meta.updatedAt).format('YYYY-MM-DD HH:mm:ss')}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Forms.Form
|
||||||
|
onSubmit={this.onSubmit}
|
||||||
|
defaultValues={{ ...dashboard, constants: [], dataSources: [], folderId }}
|
||||||
|
validateOnMount
|
||||||
|
validateFieldsOnMount={['title', 'uid']}
|
||||||
|
validateOn="onChange"
|
||||||
|
>
|
||||||
|
{({ register, errors, control, getValues }) => (
|
||||||
|
<ImportDashboardForm
|
||||||
|
register={register}
|
||||||
|
errors={errors}
|
||||||
|
control={control}
|
||||||
|
getValues={getValues}
|
||||||
|
uidReset={uidReset}
|
||||||
|
inputs={inputs}
|
||||||
|
onCancel={this.onCancel}
|
||||||
|
onUidReset={this.onUidReset}
|
||||||
|
onSubmit={this.onSubmit}
|
||||||
|
initialFolderId={folderId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Forms.Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state: StoreState) => ({
|
||||||
|
dashboard: state.importDashboard.dashboard,
|
||||||
|
meta: state.importDashboard.meta,
|
||||||
|
source: state.importDashboard.source,
|
||||||
|
inputs: state.importDashboard.inputs,
|
||||||
|
folderId: state.location.routeParams.folderId ? Number(state.location.routeParams.folderId) : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
|
||||||
|
resetDashboard,
|
||||||
|
saveDashboard,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImportDashboardOverview = connect(mapStateToProps, mapDispatchToProps)(ImportDashboardOverviewUnConnected);
|
||||||
|
ImportDashboardOverview.displayName = 'ImportDashboardOverview';
|
@ -1,170 +0,0 @@
|
|||||||
<page-header model="ctrl.navModel"></page-header>
|
|
||||||
|
|
||||||
<div class="page-container page-body" ng-cloak>
|
|
||||||
<div ng-if="ctrl.step === 1">
|
|
||||||
|
|
||||||
<form class="page-action-bar">
|
|
||||||
<div class="page-action-bar__spacer"></div>
|
|
||||||
<dash-upload on-upload="ctrl.onUpload(dash)"></dash-upload>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h5 class="section-heading">Grafana.com Dashboard</h5>
|
|
||||||
|
|
||||||
<div class="gf-form-group">
|
|
||||||
<div class="gf-form gf-form--grow">
|
|
||||||
<input type="text" class="gf-form-input max-width-30" ng-model="ctrl.gnetUrl"
|
|
||||||
placeholder="Paste Grafana.com dashboard url or id" ng-blur="ctrl.checkGnetDashboard()"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="gf-form" ng-if="ctrl.gnetError">
|
|
||||||
<label class="gf-form-label text-warning">
|
|
||||||
<i class="fa fa-warning"></i>
|
|
||||||
{{ctrl.gnetError}}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h5 class="section-heading">Or paste JSON</h5>
|
|
||||||
|
|
||||||
<div class="gf-form-group">
|
|
||||||
<div class="gf-form">
|
|
||||||
<textarea rows="10" data-share-panel-url="" class="gf-form-input" ng-model="ctrl.jsonText"></textarea>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-secondary" ng-click="ctrl.loadJsonText()">
|
|
||||||
<i class="fa fa-paste"></i>
|
|
||||||
Load
|
|
||||||
</button>
|
|
||||||
<span ng-if="ctrl.parseError" class="text-error p-l-1">
|
|
||||||
<i class="fa fa-warning"></i>
|
|
||||||
{{ctrl.parseError}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ng-if="ctrl.step === 2">
|
|
||||||
<div class="gf-form-group" ng-if="ctrl.dash.gnetId">
|
|
||||||
<h3 class="section-heading">
|
|
||||||
Importing Dashboard from
|
|
||||||
<a href="https://grafana.com/dashboards/{{ctrl.dash.gnetId}}" class="external-link"
|
|
||||||
target="_blank">Grafana.com</a>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="gf-form">
|
|
||||||
<label class="gf-form-label width-15">Published by</label>
|
|
||||||
<label class="gf-form-label width-15">{{ctrl.gnetInfo.orgName}}</label>
|
|
||||||
</div>
|
|
||||||
<div class="gf-form">
|
|
||||||
<label class="gf-form-label width-15">Updated on</label>
|
|
||||||
<label class="gf-form-label width-15">{{ctrl.gnetInfo.updatedAt | date : 'yyyy-MM-dd HH:mm:ss'}}</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="section-heading">
|
|
||||||
Options
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="gf-form-group">
|
|
||||||
<div class="gf-form-inline">
|
|
||||||
<div class="gf-form gf-form--grow">
|
|
||||||
<label class="gf-form-label width-15">Name</label>
|
|
||||||
<input type="text" class="gf-form-input" ng-model="ctrl.dash.title" give-focus="true"
|
|
||||||
ng-change="ctrl.titleChanged()" ng-class="{'validation-error': ctrl.nameExists || !ctrl.dash.title}">
|
|
||||||
<label class="gf-form-label text-success" ng-if="ctrl.titleTouched && !ctrl.hasNameValidationError">
|
|
||||||
<i class="fa fa-check"></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="gf-form-inline" ng-if="ctrl.hasNameValidationError">
|
|
||||||
<div class="gf-form offset-width-15 gf-form--grow">
|
|
||||||
<label class="gf-form-label text-warning gf-form-label--grow">
|
|
||||||
<i class="fa fa-warning"></i>
|
|
||||||
{{ctrl.nameValidationError}}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="gf-form-inline">
|
|
||||||
<div class="gf-form gf-form--grow">
|
|
||||||
<folder-picker label-class="width-15" initial-folder-id="ctrl.folderId"
|
|
||||||
initial-title="ctrl.initialFolderTitle" on-change="ctrl.onFolderChange"
|
|
||||||
enter-folder-creation="ctrl.onEnterFolderCreation" exit-folder-creation="ctrl.onExitFolderCreation"
|
|
||||||
enable-create-new="true">
|
|
||||||
</folder-picker>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="gf-form-inline">
|
|
||||||
<div class="gf-form gf-form--grow">
|
|
||||||
<span class="gf-form-label width-15">
|
|
||||||
Unique identifier (uid)
|
|
||||||
<info-popover mode="right-normal">
|
|
||||||
The unique identifier (uid) of a dashboard can be used for uniquely identify a dashboard between multiple
|
|
||||||
Grafana installs.
|
|
||||||
The uid allows having consistent URL’s for accessing dashboards so changing the title of a dashboard will
|
|
||||||
not break any
|
|
||||||
bookmarked links to that dashboard.
|
|
||||||
</info-popover>
|
|
||||||
</span>
|
|
||||||
<input type="text" class="gf-form-input" disabled="disabled" ng-model="ctrl.autoGenerateUidValue"
|
|
||||||
ng-if="ctrl.autoGenerateUid">
|
|
||||||
<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.autoGenerateUid = false"
|
|
||||||
ng-if="ctrl.autoGenerateUid">change</a>
|
|
||||||
<input type="text" class="gf-form-input" maxlength="40"
|
|
||||||
placeholder="optional, will be auto-generated if empty" ng-model="ctrl.dash.uid"
|
|
||||||
ng-change="ctrl.uidChanged()" ng-if="!ctrl.autoGenerateUid">
|
|
||||||
<label class="gf-form-label text-success" ng-if="!ctrl.autoGenerateUid && !ctrl.hasUidValidationError">
|
|
||||||
<i class="fa fa-check"></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="gf-form-inline" ng-if="ctrl.hasUidValidationError">
|
|
||||||
<div class="gf-form offset-width-15 gf-form--grow">
|
|
||||||
<label class="gf-form-label text-warning gf-form-label--grow">
|
|
||||||
<i class="fa fa-warning"></i>
|
|
||||||
{{ctrl.uidValidationError}}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ng-repeat="input in ctrl.inputs">
|
|
||||||
<div class="gf-form">
|
|
||||||
<label class="gf-form-label width-15">
|
|
||||||
{{input.label}}
|
|
||||||
<info-popover mode="right-normal">
|
|
||||||
{{input.info}}
|
|
||||||
</info-popover>
|
|
||||||
</label>
|
|
||||||
<!-- Data source input -->
|
|
||||||
<div class="gf-form-select-wrapper" style="width: 100%" ng-if="input.type === 'datasource'">
|
|
||||||
<select class="gf-form-input" ng-model="input.value" ng-options="v.value as v.text for v in input.options"
|
|
||||||
ng-change="ctrl.inputValueChanged()">
|
|
||||||
<option value="" ng-hide="input.value">{{input.info}}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<!-- Constant input -->
|
|
||||||
<input ng-if="input.type === 'constant'" type="text" class="gf-form-input" ng-model="input.value"
|
|
||||||
placeholder="{{input.default}}" ng-change="ctrl.inputValueChanged()">
|
|
||||||
<label class="gf-form-label text-success" ng-show="input.value">
|
|
||||||
<i class="fa fa-check"></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="gf-form-button-row">
|
|
||||||
<button type="button" class="btn btn-primary width-12" ng-click="ctrl.saveDashboard()"
|
|
||||||
ng-hide="ctrl.nameExists || ctrl.uidExists" ng-disabled="!ctrl.isValid()">
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-danger width-12" ng-click="ctrl.saveDashboard()"
|
|
||||||
ng-show="ctrl.nameExists || ctrl.uidExists" ng-disabled="!ctrl.isValid()">
|
|
||||||
Import (Overwrite)
|
|
||||||
</button>
|
|
||||||
<a class="btn btn-link" ng-click="ctrl.back()">Cancel</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer />
|
|
121
public/app/features/manage-dashboards/state/actions.ts
Normal file
121
public/app/features/manage-dashboards/state/actions.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { AppEvents, DataSourceInstanceSettings, DataSourceSelectItem } from '@grafana/data';
|
||||||
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
|
import config from 'app/core/config';
|
||||||
|
import {
|
||||||
|
clearDashboard,
|
||||||
|
setInputs,
|
||||||
|
setGcomDashboard,
|
||||||
|
setJsonDashboard,
|
||||||
|
InputType,
|
||||||
|
ImportDashboardDTO,
|
||||||
|
} from './reducers';
|
||||||
|
import locationUtil from 'app/core/utils/location_util';
|
||||||
|
import { updateLocation } from 'app/core/actions';
|
||||||
|
import { ThunkResult } from 'app/types';
|
||||||
|
import { appEvents } from '../../../core/core';
|
||||||
|
|
||||||
|
export function fetchGcomDashboard(id: string): ThunkResult<void> {
|
||||||
|
return async dispatch => {
|
||||||
|
try {
|
||||||
|
const dashboard = await getBackendSrv().get(`/api/gnet/dashboards/${id}`);
|
||||||
|
dispatch(setGcomDashboard(dashboard));
|
||||||
|
dispatch(processInputs(dashboard.json));
|
||||||
|
} catch (error) {
|
||||||
|
appEvents.emit(AppEvents.alertError, [error.data.message || error]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importDashboardJson(dashboard: any): ThunkResult<void> {
|
||||||
|
return async dispatch => {
|
||||||
|
dispatch(setJsonDashboard(dashboard));
|
||||||
|
dispatch(processInputs(dashboard));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function processInputs(dashboardJson: any): ThunkResult<void> {
|
||||||
|
return dispatch => {
|
||||||
|
if (dashboardJson && dashboardJson.__inputs) {
|
||||||
|
const inputs: any[] = [];
|
||||||
|
dashboardJson.__inputs.forEach((input: any) => {
|
||||||
|
const inputModel: any = {
|
||||||
|
name: input.name,
|
||||||
|
label: input.label,
|
||||||
|
info: input.description,
|
||||||
|
value: input.value,
|
||||||
|
type: input.type,
|
||||||
|
pluginId: input.pluginId,
|
||||||
|
options: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.type === InputType.DataSource) {
|
||||||
|
getDataSourceOptions(input, inputModel);
|
||||||
|
} else if (!inputModel.info) {
|
||||||
|
inputModel.info = 'Specify a string constant';
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs.push(inputModel);
|
||||||
|
});
|
||||||
|
dispatch(setInputs(inputs));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetDashboard(): ThunkResult<void> {
|
||||||
|
return dispatch => {
|
||||||
|
dispatch(clearDashboard());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDashboard(importDashboardForm: ImportDashboardDTO): ThunkResult<void> {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const dashboard = getState().importDashboard.dashboard;
|
||||||
|
const inputs = getState().importDashboard.inputs;
|
||||||
|
|
||||||
|
let inputsToPersist = [] as any[];
|
||||||
|
importDashboardForm.dataSources?.forEach((dataSource: DataSourceSelectItem, index: number) => {
|
||||||
|
const input = inputs.dataSources[index];
|
||||||
|
inputsToPersist.push({
|
||||||
|
name: input.name,
|
||||||
|
type: input.type,
|
||||||
|
pluginId: input.pluginId,
|
||||||
|
value: dataSource.value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
importDashboardForm.constants?.forEach((constant: any, index: number) => {
|
||||||
|
const input = inputs.constants[index];
|
||||||
|
|
||||||
|
inputsToPersist.push({
|
||||||
|
value: constant,
|
||||||
|
name: input.name,
|
||||||
|
type: input.type,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getBackendSrv().post('api/dashboards/import', {
|
||||||
|
dashboard: { ...dashboard, title: importDashboardForm.title, uid: importDashboardForm.uid },
|
||||||
|
overwrite: true,
|
||||||
|
inputs: inputsToPersist,
|
||||||
|
folderId: importDashboardForm.folderId,
|
||||||
|
});
|
||||||
|
const dashboardUrl = locationUtil.stripBaseFromUrl(result.importedUrl);
|
||||||
|
dispatch(updateLocation({ path: dashboardUrl }));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDataSourceOptions = (input: { pluginId: string; pluginName: string }, inputModel: any) => {
|
||||||
|
const sources = Object.values(config.datasources).filter(
|
||||||
|
(val: DataSourceInstanceSettings) => val.type === input.pluginId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sources.length === 0) {
|
||||||
|
inputModel.info = 'No data sources of type ' + input.pluginName + ' found';
|
||||||
|
} else if (!inputModel.info) {
|
||||||
|
inputModel.info = 'Select a ' + input.pluginName + ' data source';
|
||||||
|
}
|
||||||
|
|
||||||
|
inputModel.options = sources.map(val => {
|
||||||
|
return { name: val.name, value: val.name, meta: val.meta };
|
||||||
|
});
|
||||||
|
};
|
107
public/app/features/manage-dashboards/state/reducers.ts
Normal file
107
public/app/features/manage-dashboards/state/reducers.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { DataSourceSelectItem } from '@grafana/data';
|
||||||
|
|
||||||
|
export enum DashboardSource {
|
||||||
|
Gcom = 0,
|
||||||
|
Json = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportDashboardDTO {
|
||||||
|
title: string;
|
||||||
|
uid: string;
|
||||||
|
gnetId: string;
|
||||||
|
constants: string[];
|
||||||
|
dataSources: DataSourceSelectItem[];
|
||||||
|
folderId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum InputType {
|
||||||
|
DataSource = 'datasource',
|
||||||
|
Constant = 'constant',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardInput {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
info: string;
|
||||||
|
value: string;
|
||||||
|
type: InputType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataSourceInput extends DashboardInput {
|
||||||
|
pluginId: string;
|
||||||
|
options: DataSourceSelectItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardInputs {
|
||||||
|
dataSources: DataSourceInput[];
|
||||||
|
constants: DashboardInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportDashboardState {
|
||||||
|
meta: { updatedAt: string; orgName: string };
|
||||||
|
dashboard: any;
|
||||||
|
source: DashboardSource;
|
||||||
|
inputs: DashboardInputs;
|
||||||
|
isLoaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialImportDashboardState: ImportDashboardState = {
|
||||||
|
meta: { updatedAt: '', orgName: '' },
|
||||||
|
dashboard: {},
|
||||||
|
source: DashboardSource.Json,
|
||||||
|
inputs: {} as DashboardInputs,
|
||||||
|
isLoaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const importDashboardSlice = createSlice({
|
||||||
|
name: 'manageDashboards',
|
||||||
|
initialState: initialImportDashboardState,
|
||||||
|
reducers: {
|
||||||
|
setGcomDashboard: (state, action: PayloadAction<any>): ImportDashboardState => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
dashboard: {
|
||||||
|
...action.payload.json,
|
||||||
|
id: null,
|
||||||
|
},
|
||||||
|
meta: { updatedAt: action.payload.updatedAt, orgName: action.payload.orgName },
|
||||||
|
source: DashboardSource.Gcom,
|
||||||
|
isLoaded: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setJsonDashboard: (state, action: PayloadAction<any>): ImportDashboardState => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
dashboard: {
|
||||||
|
...action.payload,
|
||||||
|
id: null,
|
||||||
|
},
|
||||||
|
source: DashboardSource.Json,
|
||||||
|
isLoaded: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
clearDashboard: (state): ImportDashboardState => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
dashboard: {},
|
||||||
|
isLoaded: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setInputs: (state, action: PayloadAction<any[]>): ImportDashboardState => ({
|
||||||
|
...state,
|
||||||
|
inputs: {
|
||||||
|
dataSources: action.payload.filter(p => p.type === InputType.DataSource),
|
||||||
|
constants: action.payload.filter(p => p.type === InputType.Constant),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { clearDashboard, setInputs, setGcomDashboard, setJsonDashboard } = importDashboardSlice.actions;
|
||||||
|
|
||||||
|
export const importDashboardReducer = importDashboardSlice.reducer;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
importDashboard: importDashboardReducer,
|
||||||
|
};
|
43
public/app/features/manage-dashboards/utils/validation.ts
Normal file
43
public/app/features/manage-dashboards/utils/validation.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import validationSrv from '../services/ValidationSrv';
|
||||||
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
|
|
||||||
|
export const validateDashboardJson = (json: string) => {
|
||||||
|
try {
|
||||||
|
JSON.parse(json);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return 'Not valid JSON';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateGcomDashboard = (gcomDashboard: string) => {
|
||||||
|
// From DashboardImportCtrl
|
||||||
|
const match = /(^\d+$)|dashboards\/(\d+)/.exec(gcomDashboard);
|
||||||
|
|
||||||
|
return match && (match[1] || match[2]) ? true : 'Could not find a valid Grafana.com id';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateTitle = (newTitle: string, folderId: number) => {
|
||||||
|
return validationSrv
|
||||||
|
.validateNewDashboardName(folderId, newTitle)
|
||||||
|
.then(() => {
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (error.type === 'EXISTING') {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateUid = (value: string) => {
|
||||||
|
return getBackendSrv()
|
||||||
|
.get(`/api/dashboards/uid/${value}`)
|
||||||
|
.then(existingDashboard => {
|
||||||
|
return `Dashboard named '${existingDashboard?.dashboard.title}' in folder '${existingDashboard?.meta.folderTitle}' has the same uid`;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
error.isHandled = true;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
@ -3,7 +3,6 @@ import './ReactContainer';
|
|||||||
import { applyRouteRegistrationHandlers } from './registry';
|
import { applyRouteRegistrationHandlers } from './registry';
|
||||||
// Pages
|
// Pages
|
||||||
import FolderDashboardsCtrl from 'app/features/folders/FolderDashboardsCtrl';
|
import FolderDashboardsCtrl from 'app/features/folders/FolderDashboardsCtrl';
|
||||||
import DashboardImportCtrl from 'app/features/manage-dashboards/DashboardImportCtrl';
|
|
||||||
import LdapPage from 'app/features/admin/ldap/LdapPage';
|
import LdapPage from 'app/features/admin/ldap/LdapPage';
|
||||||
import UserAdminPage from 'app/features/admin/UserAdminPage';
|
import UserAdminPage from 'app/features/admin/UserAdminPage';
|
||||||
import SignupPage from 'app/features/profile/SignupPage';
|
import SignupPage from 'app/features/profile/SignupPage';
|
||||||
@ -109,9 +108,13 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.when('/dashboard/import', {
|
.when('/dashboard/import', {
|
||||||
templateUrl: 'public/app/features/manage-dashboards/partials/dashboard_import.html',
|
template: '<react-container />',
|
||||||
controller: DashboardImportCtrl,
|
resolve: {
|
||||||
controllerAs: 'ctrl',
|
component: () =>
|
||||||
|
SafeDynamicImport(
|
||||||
|
import(/* webpackChunkName: "DashboardImport"*/ 'app/features/manage-dashboards/DashboardImportPage')
|
||||||
|
),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.when('/datasources', {
|
.when('/datasources', {
|
||||||
template: '<react-container />',
|
template: '<react-container />',
|
||||||
|
@ -19,6 +19,7 @@ import { PanelEditorState } from '../features/dashboard/panel_editor/state/reduc
|
|||||||
import { PanelEditorStateNew } from '../features/dashboard/components/PanelEditor/state/reducers';
|
import { PanelEditorStateNew } from '../features/dashboard/components/PanelEditor/state/reducers';
|
||||||
import { ApiKeysState } from './apiKeys';
|
import { ApiKeysState } from './apiKeys';
|
||||||
import { TemplatingState } from '../features/variables/state/reducers';
|
import { TemplatingState } from '../features/variables/state/reducers';
|
||||||
|
import { ImportDashboardState } from '../features/manage-dashboards/state/reducers';
|
||||||
|
|
||||||
export interface StoreState {
|
export interface StoreState {
|
||||||
navIndex: NavIndex;
|
navIndex: NavIndex;
|
||||||
@ -44,6 +45,7 @@ export interface StoreState {
|
|||||||
userAdmin: UserAdminState;
|
userAdmin: UserAdminState;
|
||||||
userListAdmin: UserListAdminState;
|
userListAdmin: UserListAdminState;
|
||||||
templating: TemplatingState;
|
templating: TemplatingState;
|
||||||
|
importDashboard: ImportDashboardState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
BIN
public/e2e-test/screenShots/theOutput/smoke-test-scenario.png
Normal file
BIN
public/e2e-test/screenShots/theOutput/smoke-test-scenario.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 29 KiB |
Reference in New Issue
Block a user