mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06: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:
parent
d863e569b6
commit
ec743cf9a7
@ -1,24 +1,34 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useForm, Mode, OnSubmit, DeepPartial, FormContextValues } from 'react-hook-form';
|
||||
|
||||
type FormAPI<T> = Pick<FormContextValues<T>, 'register' | 'errors' | 'control' | 'formState'>;
|
||||
import { useForm, Mode, OnSubmit, DeepPartial } from 'react-hook-form';
|
||||
import { FormAPI } from '../../types';
|
||||
|
||||
interface FormProps<T> {
|
||||
validateOn?: Mode;
|
||||
validateOnMount?: boolean;
|
||||
validateFieldsOnMount?: string[];
|
||||
defaultValues?: DeepPartial<T>;
|
||||
onSubmit: OnSubmit<T>;
|
||||
children: (api: FormAPI<T>) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function Form<T>({ defaultValues, onSubmit, children, validateOn = 'onSubmit' }: FormProps<T>) {
|
||||
const { handleSubmit, register, errors, control, reset, getValues, formState } = useForm<T>({
|
||||
export function Form<T>({
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
validateOnMount = false,
|
||||
validateFieldsOnMount,
|
||||
children,
|
||||
validateOn = 'onSubmit',
|
||||
}: FormProps<T>) {
|
||||
const { handleSubmit, register, errors, control, triggerValidation, getValues, formState } = useForm<T>({
|
||||
mode: validateOn,
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reset({ ...getValues(), ...defaultValues });
|
||||
}, [defaultValues]);
|
||||
if (validateOnMount) {
|
||||
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 { GrafanaTheme } from '@grafana/data';
|
||||
import { css, cx } from 'emotion';
|
||||
|
||||
export interface LabelProps extends React.HTMLAttributes<HTMLLegendElement> {
|
||||
children: string;
|
||||
children: string | ReactNode;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,7 @@ import { AsyncSelect, Select } from './Select/Select';
|
||||
import { Form } from './Form';
|
||||
import { Field } from './Field';
|
||||
import { Switch } from './Switch';
|
||||
import { Legend } from './Legend';
|
||||
import { TextArea } from './TextArea/TextArea';
|
||||
import { Checkbox } from './Checkbox';
|
||||
|
||||
@ -25,6 +26,7 @@ const Forms = {
|
||||
AsyncSelect,
|
||||
TextArea,
|
||||
Checkbox,
|
||||
Legend,
|
||||
};
|
||||
|
||||
export default Forms;
|
||||
|
@ -154,7 +154,7 @@ export class Select<T> extends PureComponent<LegacySelectProps<T>> {
|
||||
onBlur={onBlur}
|
||||
openMenuOnFocus={openMenuOnFocus}
|
||||
maxMenuHeight={maxMenuHeight}
|
||||
noOptionsMessage={() => noOptionsMessage}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
isMulti={isMulti}
|
||||
backspaceRemovesValue={backspaceRemovesValue}
|
||||
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 './completion';
|
||||
export * from './storybook';
|
||||
export * from './forms';
|
||||
|
@ -8,18 +8,20 @@ import { SelectableValue, DataSourceSelectItem } from '@grafana/data';
|
||||
export interface Props {
|
||||
onChange: (ds: DataSourceSelectItem) => void;
|
||||
datasources: DataSourceSelectItem[];
|
||||
current: DataSourceSelectItem;
|
||||
current?: DataSourceSelectItem;
|
||||
hideTextValue?: boolean;
|
||||
onBlur?: () => void;
|
||||
autoFocus?: boolean;
|
||||
openMenuOnFocus?: boolean;
|
||||
showLoading?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export class DataSourcePicker extends PureComponent<Props> {
|
||||
static defaultProps: Partial<Props> = {
|
||||
autoFocus: false,
|
||||
openMenuOnFocus: false,
|
||||
placeholder: 'Select datasource',
|
||||
};
|
||||
|
||||
searchInput: HTMLElement;
|
||||
@ -30,11 +32,23 @@ export class DataSourcePicker extends PureComponent<Props> {
|
||||
|
||||
onChange = (item: SelectableValue<string>) => {
|
||||
const ds = this.props.datasources.find(ds => ds.name === item.value);
|
||||
this.props.onChange(ds);
|
||||
|
||||
if (ds) {
|
||||
this.props.onChange(ds);
|
||||
}
|
||||
};
|
||||
|
||||
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 => ({
|
||||
value: ds.name,
|
||||
@ -63,7 +77,7 @@ export class DataSourcePicker extends PureComponent<Props> {
|
||||
onBlur={onBlur}
|
||||
openMenuOnFocus={openMenuOnFocus}
|
||||
maxMenuHeight={500}
|
||||
placeholder="Select datasource"
|
||||
placeholder={placeholder}
|
||||
noOptionsMessage={() => 'No datasources found'}
|
||||
value={value}
|
||||
/>
|
||||
|
@ -9,7 +9,7 @@ import { DashboardSearchHit } from '../../../types';
|
||||
|
||||
export interface Props {
|
||||
onChange: ($folder: { title: string; id: number }) => void;
|
||||
enableCreateNew: boolean;
|
||||
enableCreateNew?: boolean;
|
||||
rootName?: string;
|
||||
enableReset?: boolean;
|
||||
dashboardId?: any;
|
||||
@ -43,6 +43,7 @@ export class FolderPicker extends PureComponent<Props, State> {
|
||||
enableReset: false,
|
||||
initialTitle: '',
|
||||
enableCreateNew: false,
|
||||
useInNextGenForms: false,
|
||||
};
|
||||
|
||||
componentDidMount = async () => {
|
||||
@ -115,7 +116,7 @@ export class FolderPicker extends PureComponent<Props, State> {
|
||||
folder = resetFolder;
|
||||
}
|
||||
|
||||
if (!folder) {
|
||||
if (folder.value === -1) {
|
||||
if (contextSrv.isEditor) {
|
||||
folder = rootFolder;
|
||||
} else {
|
||||
|
@ -14,6 +14,7 @@ import userReducers from 'app/features/profile/state/reducers';
|
||||
import organizationReducers from 'app/features/org/state/reducers';
|
||||
import ldapReducers from 'app/features/admin/state/reducers';
|
||||
import templatingReducers from 'app/features/variables/state/reducers';
|
||||
import importDashboardReducers from 'app/features/manage-dashboards/state/reducers';
|
||||
|
||||
const rootReducers = {
|
||||
...sharedReducers,
|
||||
@ -30,6 +31,7 @@ const rootReducers = {
|
||||
...organizationReducers,
|
||||
...ldapReducers,
|
||||
...templatingReducers,
|
||||
...importDashboardReducers,
|
||||
};
|
||||
|
||||
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]) {
|
||||
const node = navIndex[id];
|
||||
const main = {
|
||||
...node.parentItem,
|
||||
};
|
||||
|
||||
main.children = main.children.map(item => {
|
||||
return {
|
||||
...item,
|
||||
active: item.url === node.url,
|
||||
};
|
||||
});
|
||||
let main: NavModelItem;
|
||||
if (!onlyChild && node.parentItem) {
|
||||
main = { ...node.parentItem };
|
||||
|
||||
main.children =
|
||||
main.children &&
|
||||
main.children.map(item => {
|
||||
return {
|
||||
...item,
|
||||
active: item.url === node.url,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
main = node;
|
||||
}
|
||||
|
||||
return {
|
||||
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';
|
||||
// Pages
|
||||
import FolderDashboardsCtrl from 'app/features/folders/FolderDashboardsCtrl';
|
||||
import DashboardImportCtrl from 'app/features/manage-dashboards/DashboardImportCtrl';
|
||||
import LdapPage from 'app/features/admin/ldap/LdapPage';
|
||||
import UserAdminPage from 'app/features/admin/UserAdminPage';
|
||||
import SignupPage from 'app/features/profile/SignupPage';
|
||||
@ -109,9 +108,13 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
|
||||
},
|
||||
})
|
||||
.when('/dashboard/import', {
|
||||
templateUrl: 'public/app/features/manage-dashboards/partials/dashboard_import.html',
|
||||
controller: DashboardImportCtrl,
|
||||
controllerAs: 'ctrl',
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
component: () =>
|
||||
SafeDynamicImport(
|
||||
import(/* webpackChunkName: "DashboardImport"*/ 'app/features/manage-dashboards/DashboardImportPage')
|
||||
),
|
||||
},
|
||||
})
|
||||
.when('/datasources', {
|
||||
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 { ApiKeysState } from './apiKeys';
|
||||
import { TemplatingState } from '../features/variables/state/reducers';
|
||||
import { ImportDashboardState } from '../features/manage-dashboards/state/reducers';
|
||||
|
||||
export interface StoreState {
|
||||
navIndex: NavIndex;
|
||||
@ -44,6 +45,7 @@ export interface StoreState {
|
||||
userAdmin: UserAdminState;
|
||||
userListAdmin: UserListAdminState;
|
||||
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 |
Loading…
Reference in New Issue
Block a user