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:
Peter Holmberg 2020-03-31 16:29:44 +02:00 committed by GitHub
parent d863e569b6
commit ec743cf9a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 826 additions and 545 deletions

View File

@ -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>;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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}

View 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'>;

View File

@ -2,3 +2,4 @@ export * from './theme';
export * from './input';
export * from './completion';
export * from './storybook';
export * from './forms';

View File

@ -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}
/>

View File

@ -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 {

View File

@ -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 = {};

View File

@ -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,

View File

@ -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');
});
});
});

View File

@ -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;

View 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;
`,
};
});

View File

@ -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>
);
};

View File

@ -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 URLs 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';
}

View File

@ -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';

View File

@ -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 URLs 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 />

View 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 };
});
};

View 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,
};

View 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;
});
};

View File

@ -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 />',

View File

@ -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;
}
/*

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB