mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
DatasourceEditor: Add UI to edit custom HTTP headers (#17846)
* DatasourceEditor: Add UI to edit custom HTTP headers Support for custom headers was added in #11643 but was missing in the UI. Fixes #12779 * Review * Layout updates Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
parent
96099636dc
commit
5c89a8451e
@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||||
|
import { CustomHeadersSettings } from './CustomHeadersSettings';
|
||||||
|
import { DataSourceSettings } from '@grafana/data';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'UI/DataSource/CustomHeadersSettings',
|
||||||
|
component: CustomHeadersSettings,
|
||||||
|
decorators: [withCenteredStory, withHorizontallyCenteredStory],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const simple = () => {
|
||||||
|
const dataSourceConfig: DataSourceSettings<any, any> = {
|
||||||
|
id: 4,
|
||||||
|
orgId: 1,
|
||||||
|
name: 'gdev-influxdb',
|
||||||
|
type: 'influxdb',
|
||||||
|
typeLogoUrl: '',
|
||||||
|
access: 'direct',
|
||||||
|
url: 'http://localhost:8086',
|
||||||
|
password: '',
|
||||||
|
user: 'grafana',
|
||||||
|
database: 'site',
|
||||||
|
basicAuth: false,
|
||||||
|
basicAuthUser: '',
|
||||||
|
basicAuthPassword: '',
|
||||||
|
withCredentials: false,
|
||||||
|
isDefault: false,
|
||||||
|
jsonData: {
|
||||||
|
timeInterval: '15s',
|
||||||
|
httpMode: 'GET',
|
||||||
|
keepCookies: ['cookie1', 'cookie2'],
|
||||||
|
httpHeaderName1: 'X-Custom-Header',
|
||||||
|
},
|
||||||
|
secureJsonData: {
|
||||||
|
password: true,
|
||||||
|
httpHeaderValue1: 'X-Custom-Header',
|
||||||
|
},
|
||||||
|
secureJsonFields: {
|
||||||
|
httpHeaderValue1: true,
|
||||||
|
},
|
||||||
|
readOnly: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <CustomHeadersSettings dataSourceConfig={dataSourceConfig} onChange={() => {}} />;
|
||||||
|
};
|
@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mount } from 'enzyme';
|
||||||
|
import { CustomHeadersSettings, Props } from './CustomHeadersSettings';
|
||||||
|
|
||||||
|
const setup = (propOverrides?: object) => {
|
||||||
|
const props: Props = {
|
||||||
|
dataSourceConfig: {
|
||||||
|
id: 4,
|
||||||
|
orgId: 1,
|
||||||
|
name: 'gdev-influxdb',
|
||||||
|
type: 'influxdb',
|
||||||
|
typeLogoUrl: '',
|
||||||
|
access: 'direct',
|
||||||
|
url: 'http://localhost:8086',
|
||||||
|
password: '',
|
||||||
|
user: 'grafana',
|
||||||
|
database: 'site',
|
||||||
|
basicAuth: false,
|
||||||
|
basicAuthUser: '',
|
||||||
|
basicAuthPassword: '',
|
||||||
|
withCredentials: false,
|
||||||
|
isDefault: false,
|
||||||
|
jsonData: {
|
||||||
|
timeInterval: '15s',
|
||||||
|
httpMode: 'GET',
|
||||||
|
keepCookies: ['cookie1', 'cookie2'],
|
||||||
|
},
|
||||||
|
secureJsonData: {
|
||||||
|
password: true,
|
||||||
|
},
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
onChange: jest.fn(),
|
||||||
|
...propOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
return mount(<CustomHeadersSettings {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Render', () => {
|
||||||
|
it('should add a new header', () => {
|
||||||
|
const wrapper = setup();
|
||||||
|
const addButton = wrapper.find('Button').at(0);
|
||||||
|
addButton.simulate('click', { preventDefault: () => {} });
|
||||||
|
expect(wrapper.find('FormField').exists()).toBeTruthy();
|
||||||
|
expect(wrapper.find('SecretFormField').exists()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove a header', () => {
|
||||||
|
const wrapper = setup({
|
||||||
|
dataSourceConfig: {
|
||||||
|
jsonData: {
|
||||||
|
httpHeaderName1: 'X-Custom-Header',
|
||||||
|
},
|
||||||
|
secureJsonFields: {
|
||||||
|
httpHeaderValue1: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const removeButton = wrapper.find('Button').find({ variant: 'transparent' });
|
||||||
|
removeButton.simulate('click', { preventDefault: () => {} });
|
||||||
|
expect(wrapper.find('FormField').exists()).toBeFalsy();
|
||||||
|
expect(wrapper.find('SecretFormField').exists()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset a header', () => {
|
||||||
|
const wrapper = setup({
|
||||||
|
dataSourceConfig: {
|
||||||
|
jsonData: {
|
||||||
|
httpHeaderName1: 'X-Custom-Header',
|
||||||
|
},
|
||||||
|
secureJsonFields: {
|
||||||
|
httpHeaderValue1: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const resetButton = wrapper.find('button').at(0);
|
||||||
|
resetButton.simulate('click', { preventDefault: () => {} });
|
||||||
|
const { isConfigured } = wrapper.find('SecretFormField').props() as any;
|
||||||
|
expect(isConfigured).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,219 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import { css } from 'emotion';
|
||||||
|
import uniqueId from 'lodash/uniqueId';
|
||||||
|
import { DataSourceSettings } from '@grafana/data';
|
||||||
|
import { Button } from '../Button/Button';
|
||||||
|
import { FormField } from '../FormField/FormField';
|
||||||
|
import { SecretFormField } from '../SecretFormFied/SecretFormField';
|
||||||
|
import { stylesFactory } from '../../themes';
|
||||||
|
|
||||||
|
export interface CustomHeader {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
configured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomHeaders = CustomHeader[];
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
dataSourceConfig: DataSourceSettings<any, any>;
|
||||||
|
onChange: (config: DataSourceSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
headers: CustomHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomHeaderRowProps {
|
||||||
|
header: CustomHeader;
|
||||||
|
onReset: (id: string) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onChange: (value: CustomHeader) => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCustomHeaderRowStyles = stylesFactory(() => {
|
||||||
|
return {
|
||||||
|
layout: css`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
> * {
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
height: 100%;
|
||||||
|
&:first-child,
|
||||||
|
&:last-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const CustomHeaderRow: React.FC<CustomHeaderRowProps> = ({ header, onBlur, onChange, onRemove, onReset }) => {
|
||||||
|
const styles = getCustomHeaderRowStyles();
|
||||||
|
return (
|
||||||
|
<div className={styles.layout}>
|
||||||
|
<FormField
|
||||||
|
label="Header"
|
||||||
|
name="name"
|
||||||
|
placeholder="X-Custom-Header"
|
||||||
|
labelWidth={5}
|
||||||
|
value={header.name || ''}
|
||||||
|
onChange={e => onChange({ ...header, name: e.target.value })}
|
||||||
|
onBlur={onBlur}
|
||||||
|
/>
|
||||||
|
<SecretFormField
|
||||||
|
label="Value"
|
||||||
|
name="value"
|
||||||
|
isConfigured={header.configured}
|
||||||
|
value={header.value}
|
||||||
|
labelWidth={5}
|
||||||
|
inputWidth={header.configured ? 11 : 12}
|
||||||
|
placeholder="Header Value"
|
||||||
|
onReset={() => onReset(header.id)}
|
||||||
|
onChange={e => onChange({ ...header, value: e.target.value })}
|
||||||
|
onBlur={onBlur}
|
||||||
|
/>
|
||||||
|
<Button variant="transparent" size="xs" onClick={_e => onRemove(header.id)}>
|
||||||
|
<i className="fa fa-trash" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomHeaderRow.displayName = 'CustomHeaderRow';
|
||||||
|
|
||||||
|
export class CustomHeadersSettings extends PureComponent<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
headers: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
const { jsonData, secureJsonData, secureJsonFields } = this.props.dataSourceConfig;
|
||||||
|
this.state = {
|
||||||
|
headers: Object.keys(jsonData)
|
||||||
|
.sort()
|
||||||
|
.filter(key => key.startsWith('httpHeaderName'))
|
||||||
|
.map((key, index) => {
|
||||||
|
return {
|
||||||
|
id: uniqueId(),
|
||||||
|
name: jsonData[key],
|
||||||
|
value: secureJsonData !== undefined ? secureJsonData[key] : '',
|
||||||
|
configured: (secureJsonFields && secureJsonFields[`httpHeaderValue${index + 1}`]) || false,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings = () => {
|
||||||
|
const { headers } = this.state;
|
||||||
|
const { jsonData } = this.props.dataSourceConfig;
|
||||||
|
const secureJsonData = this.props.dataSourceConfig.secureJsonData || {};
|
||||||
|
for (const [index, header] of headers.entries()) {
|
||||||
|
jsonData[`httpHeaderName${index + 1}`] = header.name;
|
||||||
|
if (!header.configured) {
|
||||||
|
secureJsonData[`httpHeaderValue${index + 1}`] = header.value;
|
||||||
|
}
|
||||||
|
Object.keys(jsonData)
|
||||||
|
.filter(
|
||||||
|
key =>
|
||||||
|
key.startsWith('httpHeaderName') && parseInt(key.substring('httpHeaderName'.length), 10) > headers.length
|
||||||
|
)
|
||||||
|
.forEach(key => {
|
||||||
|
delete jsonData[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.onChange({
|
||||||
|
...this.props.dataSourceConfig,
|
||||||
|
jsonData,
|
||||||
|
secureJsonData,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onHeaderAdd = () => {
|
||||||
|
this.setState(prevState => {
|
||||||
|
return { headers: [...prevState.headers, { id: uniqueId(), name: '', value: '', configured: false }] };
|
||||||
|
}, this.updateSettings);
|
||||||
|
};
|
||||||
|
|
||||||
|
onHeaderChange = (headerIndex: number, value: CustomHeader) => {
|
||||||
|
this.setState(({ headers }) => {
|
||||||
|
return {
|
||||||
|
headers: headers.map((item, index) => {
|
||||||
|
if (headerIndex !== index) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
return { ...value };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onHeaderReset = (headerId: string) => {
|
||||||
|
this.setState(({ headers }) => {
|
||||||
|
return {
|
||||||
|
headers: headers.map((h, i) => {
|
||||||
|
if (h.id !== headerId) {
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...h,
|
||||||
|
value: '',
|
||||||
|
configured: false,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onHeaderRemove = (headerId: string) => {
|
||||||
|
this.setState(
|
||||||
|
({ headers }) => ({
|
||||||
|
headers: headers.filter(h => h.id !== headerId),
|
||||||
|
}),
|
||||||
|
this.updateSettings
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { headers } = this.state;
|
||||||
|
return (
|
||||||
|
<div className={'gf-form-group'}>
|
||||||
|
<div className="gf-form">
|
||||||
|
<h6>Custom HTTP Headers</h6>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{headers.map((header, i) => (
|
||||||
|
<CustomHeaderRow
|
||||||
|
key={header.id}
|
||||||
|
header={header}
|
||||||
|
onChange={h => {
|
||||||
|
this.onHeaderChange(i, h);
|
||||||
|
}}
|
||||||
|
onBlur={this.updateSettings}
|
||||||
|
onRemove={this.onHeaderRemove}
|
||||||
|
onReset={this.onHeaderReset}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="gf-form">
|
||||||
|
<Button
|
||||||
|
variant="inverse"
|
||||||
|
size="xs"
|
||||||
|
onClick={e => {
|
||||||
|
this.onHeaderAdd();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Header
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomHeadersSettings;
|
@ -7,6 +7,7 @@ import { HttpProxySettings } from './HttpProxySettings';
|
|||||||
import { TLSAuthSettings } from './TLSAuthSettings';
|
import { TLSAuthSettings } from './TLSAuthSettings';
|
||||||
import { DataSourceSettings } from '@grafana/data';
|
import { DataSourceSettings } from '@grafana/data';
|
||||||
import { HttpSettingsProps } from './types';
|
import { HttpSettingsProps } from './types';
|
||||||
|
import { CustomHeadersSettings } from './CustomHeadersSettings';
|
||||||
import { Select } from '../Select/Select';
|
import { Select } from '../Select/Select';
|
||||||
import { Input } from '../Input/Input';
|
import { Input } from '../Input/Input';
|
||||||
import { FormField } from '../FormField/FormField';
|
import { FormField } from '../FormField/FormField';
|
||||||
@ -207,6 +208,8 @@ export const DataSourceHttpSettings: React.FC<HttpSettingsProps> = props => {
|
|||||||
{(dataSourceConfig.jsonData.tlsAuth || dataSourceConfig.jsonData.tlsAuthWithCACert) && (
|
{(dataSourceConfig.jsonData.tlsAuth || dataSourceConfig.jsonData.tlsAuthWithCACert) && (
|
||||||
<TLSAuthSettings dataSourceConfig={dataSourceConfig} onChange={onChange} />
|
<TLSAuthSettings dataSourceConfig={dataSourceConfig} onChange={onChange} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<CustomHeadersSettings dataSourceConfig={dataSourceConfig} onChange={onChange} />
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import omit from 'lodash/omit';
|
import omit from 'lodash/omit';
|
||||||
import React, { InputHTMLAttributes, FunctionComponent } from 'react';
|
import React, { InputHTMLAttributes, FunctionComponent } from 'react';
|
||||||
import { FormField } from '../FormField/FormField';
|
import { FormField } from '../FormField/FormField';
|
||||||
|
import { css, cx } from 'emotion';
|
||||||
|
|
||||||
interface Props extends InputHTMLAttributes<HTMLInputElement> {
|
interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onReset'> {
|
||||||
// Function to use when reset is clicked. Means you have to reset the input value yourself as this is uncontrolled
|
// Function to use when reset is clicked. Means you have to reset the input value yourself as this is uncontrolled
|
||||||
// component (or do something else if required).
|
// component (or do something else if required).
|
||||||
onReset: () => void;
|
onReset: (event: React.SyntheticEvent<HTMLButtonElement>) => void;
|
||||||
isConfigured: boolean;
|
isConfigured: boolean;
|
||||||
|
|
||||||
label?: string;
|
label?: string;
|
||||||
@ -15,12 +16,18 @@ interface Props extends InputHTMLAttributes<HTMLInputElement> {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultProps = {
|
const getSecretFormFieldStyles = () => {
|
||||||
inputWidth: 12,
|
return {
|
||||||
placeholder: 'Password',
|
noRadiusInput: css`
|
||||||
label: 'Password',
|
border-bottom-right-radius: 0 !important;
|
||||||
|
border-top-right-radius: 0 !important;
|
||||||
|
`,
|
||||||
|
noRadiusButton: css`
|
||||||
|
border-bottom-left-radius: 0 !important;
|
||||||
|
border-top-left-radius: 0 !important;
|
||||||
|
`,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Form field that has 2 states configured and not configured. If configured it will not show its contents and adds
|
* Form field that has 2 states configured and not configured. If configured it will not show its contents and adds
|
||||||
* a reset button that will clear the input and makes it accessible. In non configured state it behaves like normal
|
* a reset button that will clear the input and makes it accessible. In non configured state it behaves like normal
|
||||||
@ -28,14 +35,15 @@ const defaultProps = {
|
|||||||
* to the user (like datasource passwords).
|
* to the user (like datasource passwords).
|
||||||
*/
|
*/
|
||||||
export const SecretFormField: FunctionComponent<Props> = ({
|
export const SecretFormField: FunctionComponent<Props> = ({
|
||||||
label,
|
label = 'Password',
|
||||||
labelWidth,
|
labelWidth,
|
||||||
inputWidth,
|
inputWidth = 12,
|
||||||
onReset,
|
onReset,
|
||||||
isConfigured,
|
isConfigured,
|
||||||
placeholder,
|
placeholder = 'Password',
|
||||||
...inputProps
|
...inputProps
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const styles = getSecretFormFieldStyles();
|
||||||
return (
|
return (
|
||||||
<FormField
|
<FormField
|
||||||
label={label!}
|
label={label!}
|
||||||
@ -45,12 +53,16 @@ export const SecretFormField: FunctionComponent<Props> = ({
|
|||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={`gf-form-input width-${inputWidth! - 2}`}
|
className={cx(`gf-form-input width-${inputWidth! - 2}`, styles.noRadiusInput)}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
value="configured"
|
value="configured"
|
||||||
{...omit(inputProps, 'value')}
|
{...omit(inputProps, 'value')}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-secondary gf-form-btn" onClick={onReset}>
|
<button
|
||||||
|
className={cx('btn btn-secondary gf-form-btn', styles.noRadiusButton)}
|
||||||
|
onClick={onReset}
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
>
|
||||||
reset
|
reset
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@ -67,5 +79,4 @@ export const SecretFormField: FunctionComponent<Props> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
SecretFormField.defaultProps = defaultProps;
|
|
||||||
SecretFormField.displayName = 'SecretFormField';
|
SecretFormField.displayName = 'SecretFormField';
|
||||||
|
@ -97,7 +97,6 @@ exports[`Render should disable basic auth password input 1`] = `
|
|||||||
labelWidth={10}
|
labelWidth={10}
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
onReset={[Function]}
|
onReset={[Function]}
|
||||||
placeholder="Password"
|
|
||||||
value=""
|
value=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -315,7 +314,6 @@ exports[`Render should hide basic auth fields when switch off 1`] = `
|
|||||||
labelWidth={10}
|
labelWidth={10}
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
onReset={[Function]}
|
onReset={[Function]}
|
||||||
placeholder="Password"
|
|
||||||
value=""
|
value=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -533,7 +531,6 @@ exports[`Render should hide white listed cookies input when browser access chose
|
|||||||
labelWidth={10}
|
labelWidth={10}
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
onReset={[Function]}
|
onReset={[Function]}
|
||||||
placeholder="Password"
|
|
||||||
value=""
|
value=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -751,7 +748,6 @@ exports[`Render should render component 1`] = `
|
|||||||
labelWidth={10}
|
labelWidth={10}
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
onReset={[Function]}
|
onReset={[Function]}
|
||||||
placeholder="Password"
|
|
||||||
value=""
|
value=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user