InfluxDB: Config page refresh (#103060)

pull/107211/head
Adam Yeats 6 months ago committed by GitHub
parent 35f89a456c
commit 3503fc209e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      packages/grafana-data/src/types/featureToggles.gen.ts
  2. 8
      pkg/services/featuremgmt/registry.go
  3. 1
      pkg/services/featuremgmt/toggles_gen.csv
  4. 4
      pkg/services/featuremgmt/toggles_gen.go
  5. 960
      pkg/services/featuremgmt/toggles_gen.json
  6. 1
      public/app/features/datasources/components/ButtonRow.tsx
  7. 12
      public/app/features/datasources/components/EditDataSource.tsx
  8. 104
      public/app/plugins/datasource/influxdb/components/editor/config-v2/AdvancedDBConnectionSettings.test.tsx
  9. 100
      public/app/plugins/datasource/influxdb/components/editor/config-v2/AdvancedDBConnectionSettings.tsx
  10. 71
      public/app/plugins/datasource/influxdb/components/editor/config-v2/AdvancedHttpSettings.test.tsx
  11. 119
      public/app/plugins/datasource/influxdb/components/editor/config-v2/AdvancedHttpSettings.tsx
  12. 131
      public/app/plugins/datasource/influxdb/components/editor/config-v2/AuthSettings.test.tsx
  13. 267
      public/app/plugins/datasource/influxdb/components/editor/config-v2/AuthSettings.tsx
  14. 44
      public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.test.tsx
  15. 41
      public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.tsx
  16. 96
      public/app/plugins/datasource/influxdb/components/editor/config-v2/DatabaseConnectionSection.test.tsx
  17. 65
      public/app/plugins/datasource/influxdb/components/editor/config-v2/DatabaseConnectionSection.tsx
  18. 46
      public/app/plugins/datasource/influxdb/components/editor/config-v2/InfluxFluxDBConnection.test.tsx
  19. 58
      public/app/plugins/datasource/influxdb/components/editor/config-v2/InfluxFluxDBConnection.tsx
  20. 41
      public/app/plugins/datasource/influxdb/components/editor/config-v2/InfluxInfluxQLDBConnection.test.tsx
  21. 57
      public/app/plugins/datasource/influxdb/components/editor/config-v2/InfluxInfluxQLDBConnection.tsx
  22. 39
      public/app/plugins/datasource/influxdb/components/editor/config-v2/InfluxSQLDBConnection.test.tsx
  23. 45
      public/app/plugins/datasource/influxdb/components/editor/config-v2/InfluxSQLDBConnection.tsx
  24. 16
      public/app/plugins/datasource/influxdb/components/editor/config-v2/LeftSideBar.test.tsx
  25. 40
      public/app/plugins/datasource/influxdb/components/editor/config-v2/LeftSideBar.tsx
  26. 90
      public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.test.tsx
  27. 131
      public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.tsx
  28. 57
      public/app/plugins/datasource/influxdb/components/editor/config-v2/constants.ts
  29. 33
      public/app/plugins/datasource/influxdb/components/editor/config-v2/helpers.ts
  30. 89
      public/app/plugins/datasource/influxdb/components/editor/config-v2/tracking.ts
  31. 22
      public/app/plugins/datasource/influxdb/components/editor/config-v2/types.ts
  32. 141
      public/app/plugins/datasource/influxdb/components/editor/config-v2/versions.ts
  33. 9
      public/app/plugins/datasource/influxdb/module.ts
  34. 2
      public/app/plugins/datasource/influxdb/types.ts

@ -1037,4 +1037,9 @@ export interface FeatureToggles {
* @default true
*/
tabularNumbers?: boolean;
/**
* Enables new design for the InfluxDB data source configuration page
* @default false
*/
newInfluxDSConfigPageDesign?: boolean;
}

@ -1780,6 +1780,14 @@ var (
Owner: grafanaFrontendPlatformSquad,
Expression: "true",
},
{
Name: "newInfluxDSConfigPageDesign",
Description: "Enables new design for the InfluxDB data source configuration page",
Stage: FeatureStagePrivatePreview,
FrontendOnly: false,
Owner: grafanaPartnerPluginsSquad,
Expression: "false",
},
}
)

@ -232,3 +232,4 @@ alertEnrichment,experimental,@grafana/alerting-squad,false,false,false
alertingImportAlertmanagerAPI,experimental,@grafana/alerting-squad,false,false,false
preferLibraryPanelTitle,privatePreview,@grafana/dashboards-squad,false,false,false
tabularNumbers,GA,@grafana/grafana-frontend-platform,false,false,false
newInfluxDSConfigPageDesign,privatePreview,@grafana/partner-datasources,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
232 alertingImportAlertmanagerAPI experimental @grafana/alerting-squad false false false
233 preferLibraryPanelTitle privatePreview @grafana/dashboards-squad false false false
234 tabularNumbers GA @grafana/grafana-frontend-platform false false false
235 newInfluxDSConfigPageDesign privatePreview @grafana/partner-datasources false false false

@ -938,4 +938,8 @@ const (
// FlagTabularNumbers
// Use fixed-width numbers globally in the UI
FlagTabularNumbers = "tabularNumbers"
// FlagNewInfluxDSConfigPageDesign
// Enables new design for the InfluxDB data source configuration page
FlagNewInfluxDSConfigPageDesign = "newInfluxDSConfigPageDesign"
)

File diff suppressed because it is too large Load Diff

@ -31,6 +31,7 @@ export function ButtonRow({ canSave, canDelete, onDelete, onSubmit, onTest }: Pr
disabled={!canSave}
onClick={onSubmit}
data-testid={selectors.pages.DataSource.saveAndTest}
id={selectors.pages.DataSource.saveAndTest}
>
<Trans i18nKey="datasources.button-row.save-and-test">Save &amp; test</Trans>
</Button>

@ -123,6 +123,16 @@ export function EditDataSourceView({
let currentJsonData = dataSource.jsonData;
let currentSecureJsonData = dataSource.secureJsonData;
const isPDCInjected = components.some((component) => component.meta.pluginId === 'grafana-pdc-app');
const dataSourceWithIsPDCInjected = {
...dataSource,
jsonData: {
...dataSource.jsonData,
pdcInjected: isPDCInjected,
},
};
const dsi = getDataSourceSrv()?.getInstanceSettings(dataSource.uid);
const onSubmit = async (e: React.MouseEvent<HTMLButtonElement> | React.FormEvent<HTMLFormElement>) => {
@ -190,7 +200,7 @@ export function EditDataSourceView({
<DataSourcePluginContextProvider instanceSettings={dsi}>
<DataSourcePluginSettings
plugin={plugin}
dataSource={dataSource}
dataSource={dataSourceWithIsPDCInjected}
dataSourceMeta={dataSourceMeta}
onModelChange={onOptionsChange}
/>

@ -0,0 +1,104 @@
import '@testing-library/jest-dom';
import { render, screen, fireEvent } from '@testing-library/react';
import { InfluxVersion } from '../../../types';
import { AdvancedDbConnectionSettings } from './AdvancedDBConnectionSettings';
import { createTestProps } from './helpers';
describe('AdvancedDbConnectionSettings', () => {
const onOptionsChangeMock = jest.fn();
const defaultProps = createTestProps({
options: {
jsonData: {},
secureJsonData: {},
secureJsonFields: {},
},
mocks: {
onOptionsChange: onOptionsChangeMock,
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('toggles visibility of advanced settings', () => {
render(<AdvancedDbConnectionSettings {...defaultProps} />);
const toggle = screen.getByTestId('influxdb-v2-config-toggle-switch');
fireEvent.click(toggle);
});
it('renders HTTP Method field for InfluxQL version', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: { version: InfluxVersion.InfluxQL },
},
};
render(<AdvancedDbConnectionSettings {...props} />);
const toggle = screen.getByTestId('influxdb-v2-config-toggle-switch');
fireEvent.click(toggle);
expect(screen.getByTestId('influxdb-v2-config-http-method-select')).toBeInTheDocument();
});
it('renders insecure connection switch for SQL version and triggers change', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: { version: InfluxVersion.SQL, insecureGrpc: false },
},
};
render(<AdvancedDbConnectionSettings {...props} />);
const toggle = screen.getByTestId('influxdb-v2-config-toggle-switch');
fireEvent.click(toggle);
const switchEl = screen.getByTestId('influxdb-v2-config-insecure-switch');
expect(switchEl).toBeInTheDocument();
fireEvent.click(switchEl);
expect(onOptionsChangeMock).toHaveBeenCalled();
});
it('renders Min time interval input for InfluxQL', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: { version: InfluxVersion.InfluxQL, timeInterval: '' },
},
};
render(<AdvancedDbConnectionSettings {...props} />);
const toggle = screen.getByTestId('influxdb-v2-config-toggle-switch');
fireEvent.click(toggle);
const input = screen.getByTestId('influxdb-v2-config-time-interval');
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: '15' } });
expect(onOptionsChangeMock).toHaveBeenCalled();
});
it('renders Min time interval input for Flux', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: { version: InfluxVersion.Flux, timeInterval: '' },
},
};
render(<AdvancedDbConnectionSettings {...props} />);
const toggle = screen.getByTestId('influxdb-v2-config-toggle-switch');
fireEvent.click(toggle);
const input = screen.getByTestId('influxdb-v2-config-time-interval');
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: '15' } });
expect(onOptionsChangeMock).toHaveBeenCalled();
});
});

@ -0,0 +1,100 @@
import { cx } from '@emotion/css';
import { useState } from 'react';
import {
onUpdateDatasourceJsonDataOption,
onUpdateDatasourceJsonDataOptionChecked,
onUpdateDatasourceJsonDataOptionSelect,
} from '@grafana/data';
import { InlineFieldRow, InlineField, Combobox, InlineSwitch, Input, Space, useStyles2 } from '@grafana/ui';
import { InfluxVersion } from '../../../types';
import { getInlineLabelStyles, HTTP_MODES } from './constants';
import {
trackInfluxDBConfigV2AdvancedDbConnectionSettingsHTTPMethodClicked,
trackInfluxDBConfigV2AdvancedDbConnectionSettingsInsecureConnectClicked,
trackInfluxDBConfigV2AdvancedDbConnectionSettingsMinTimeClicked,
trackInfluxDBConfigV2AdvancedDbConnectionSettingsToggleClicked,
} from './tracking';
import { Props } from './types';
export const AdvancedDbConnectionSettings = (props: Props) => {
const { options } = props;
const styles = useStyles2(getInlineLabelStyles);
const [advancedDbConnectionSettingsIsOpen, setAdvancedDbConnectionSettingsIsOpen] = useState(
() => !!options.jsonData.timeInterval || !!options.jsonData.insecureGrpc
);
return (
<>
<Space v={2} />
<InlineField label={<div className={cx(styles.label)}>Advanced Database Settings</div>} labelWidth={40}>
<InlineSwitch
data-testid="influxdb-v2-config-toggle-switch"
value={advancedDbConnectionSettingsIsOpen}
onChange={() => setAdvancedDbConnectionSettingsIsOpen(!advancedDbConnectionSettingsIsOpen)}
onBlur={trackInfluxDBConfigV2AdvancedDbConnectionSettingsToggleClicked}
/>
</InlineField>
{advancedDbConnectionSettingsIsOpen && (
<>
{options.jsonData.version === InfluxVersion.InfluxQL && (
<InlineFieldRow>
<InlineField
label="HTTP Method"
labelWidth={30}
tooltip="You can use either GET or POST HTTP method to query your InfluxDB database. The POST
method allows you to perform heavy requests (with a lots of WHERE clause) while the GET method
will restrict you and return an error if the query is too large."
>
<Combobox
width={30}
value={HTTP_MODES.find((httpMode) => httpMode.value === options.jsonData.httpMode)}
options={HTTP_MODES}
onChange={onUpdateDatasourceJsonDataOptionSelect(props, 'httpMode')}
onBlur={trackInfluxDBConfigV2AdvancedDbConnectionSettingsHTTPMethodClicked}
data-testid="influxdb-v2-config-http-method-select"
/>
</InlineField>
</InlineFieldRow>
)}
{options.jsonData.version === InfluxVersion.SQL && (
<InlineFieldRow>
<InlineField label="Insecure Connection" labelWidth={30}>
<InlineSwitch
data-testid="influxdb-v2-config-insecure-switch"
value={options.jsonData.insecureGrpc ?? false}
onChange={onUpdateDatasourceJsonDataOptionChecked(props, 'insecureGrpc')}
onBlur={trackInfluxDBConfigV2AdvancedDbConnectionSettingsInsecureConnectClicked}
/>
</InlineField>
</InlineFieldRow>
)}
{(options.jsonData.version === InfluxVersion.InfluxQL || options.jsonData.version === InfluxVersion.Flux) && (
<InlineFieldRow>
<InlineField
label="Min time interval"
labelWidth={30}
tooltip="A lower limit for the auto group by time interval. Recommended to be set to write frequency, for example 1m if your data is written every minute."
>
<Input
className="width-15"
data-testid="influxdb-v2-config-time-interval"
onBlur={trackInfluxDBConfigV2AdvancedDbConnectionSettingsMinTimeClicked}
onChange={onUpdateDatasourceJsonDataOption(props, 'timeInterval')}
placeholder="10s"
value={options.jsonData.timeInterval || ''}
/>
</InlineField>
</InlineFieldRow>
)}
</>
)}
</>
);
};

@ -0,0 +1,71 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { AdvancedHttpSettings } from './AdvancedHttpSettings';
import { createTestProps } from './helpers';
describe('AdvancedHttpSettings', () => {
const onOptionsChangeMock = jest.fn();
const defaultProps = createTestProps({
options: {},
mocks: {
onOptionsChange: onOptionsChangeMock,
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('renders toggle and toggles section visibility', () => {
render(<AdvancedHttpSettings {...defaultProps} />);
const toggle = screen.getByTestId('influxdb-v2-config-advanced-http-settings-toggle');
expect(toggle).toBeInTheDocument();
fireEvent.click(toggle);
expect(screen.getByLabelText('Timeout in seconds')).toBeInTheDocument();
});
it('calls onOptionsChange when timeout is changed', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: {
...defaultProps.options.jsonData,
timeout: 10,
keepCookies: [],
},
},
};
render(<AdvancedHttpSettings {...props} />);
const input = screen.getByLabelText('Timeout in seconds');
fireEvent.change(input, { target: { value: '20' } });
expect(props.onOptionsChange).toHaveBeenCalled();
});
it('calls onOptionsChange when allowed cookies are changed', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: {
...defaultProps.options.jsonData,
timeout: 10,
keepCookies: ['session'],
},
},
};
render(<AdvancedHttpSettings {...props} />);
const cookieInput = screen.getByPlaceholderText('New cookie (hit enter to add)');
fireEvent.change(cookieInput, { target: { value: 'auth' } });
fireEvent.keyDown(cookieInput, { key: 'Enter', code: 'Enter' });
expect(props.onOptionsChange).toHaveBeenCalled();
});
});

@ -0,0 +1,119 @@
import { cx } from '@emotion/css';
import { useState } from 'react';
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import {
Box,
InlineField,
InlineSwitch,
Field,
TagsInput,
Input,
CustomHeadersSettings,
useStyles2,
} from '@grafana/ui';
import { InfluxOptions } from '../../../types';
import { getInlineLabelStyles } from './constants';
import {
trackInfluxDBConfigV2AdvancedHTTPSettingsTimeoutField,
trackInfluxDBConfigV2AdvancedHTTPSettingsToggleClicked,
} from './tracking';
export type Props = DataSourcePluginOptionsEditorProps<InfluxOptions>;
export const AdvancedHttpSettings = ({ options, onOptionsChange }: Props) => {
const styles = useStyles2(getInlineLabelStyles);
const [advancedHttpSettingsIsOpen, setAdvancedHttpSettingsIsOpen] = useState(
() => 'keepCookies' in options.jsonData || 'timeout' in options.jsonData
);
return (
<>
<Box display="flex" alignItems="center">
<InlineField label={<div className={cx(styles.label)}>Advanced HTTP Settings</div>} labelWidth={40}>
<InlineSwitch
data-testid="influxdb-v2-config-advanced-http-settings-toggle"
value={advancedHttpSettingsIsOpen}
onChange={() => setAdvancedHttpSettingsIsOpen(!advancedHttpSettingsIsOpen)}
onBlur={trackInfluxDBConfigV2AdvancedHTTPSettingsToggleClicked}
/>
</InlineField>
</Box>
{advancedHttpSettingsIsOpen && options.access === 'proxy' && (
<>
<Box paddingLeft={1} marginY={1}>
<Box width="50%" marginBottom={2}>
<Field
label="Allowed cookies"
description="Grafana proxy deletes forwarded cookies by default. Specify cookies by name that should
be forwarded to the data source."
disabled={options.readOnly}
noMargin
>
<TagsInput
id="advanced-http-cookies"
placeholder="New cookie (hit enter to add)"
tags={
'keepCookies' in options.jsonData && Array.isArray(options.jsonData.keepCookies)
? options.jsonData.keepCookies
: []
}
onChange={(e) => {
onOptionsChange({
...options,
jsonData: {
...options.jsonData,
...{ keepCookies: e },
},
});
}}
/>
</Field>
</Box>
<Box width="50%" marginBottom={2}>
<Field
htmlFor="advanced-http-timeout"
label="Timeout"
description="HTTP request timeout in seconds."
disabled={options.readOnly}
noMargin
>
<Input
id="advanced-http-timeout"
type="number"
min={0}
placeholder="Timeout in seconds"
aria-label="Timeout in seconds"
value={
'timeout' in options.jsonData && typeof options.jsonData.timeout === 'number'
? options.jsonData.timeout.toString()
: ''
}
onChange={(e) => {
const parsed = parseInt(e.currentTarget.value, 10);
onOptionsChange({
...options,
jsonData: {
...options.jsonData,
...{ timeout: parsed },
},
});
}}
onBlur={trackInfluxDBConfigV2AdvancedHTTPSettingsTimeoutField}
/>
</Field>
</Box>
{advancedHttpSettingsIsOpen && (
<CustomHeadersSettings dataSourceConfig={options} onChange={onOptionsChange} />
)}
</Box>
</>
)}
</>
);
};

@ -0,0 +1,131 @@
import '@testing-library/jest-dom';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { AuthSettings } from './AuthSettings';
import { createTestProps } from './helpers';
describe('AuthSettings', () => {
const onOptionsChangeMock = jest.fn();
const defaultProps = createTestProps({
options: {
jsonData: {},
secureJsonData: {},
secureJsonFields: {},
},
mocks: {
onOptionsChange: onOptionsChangeMock,
},
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('collapsible behaviour', () => {
it('starts collapsed when no auth option is active', () => {
render(<AuthSettings {...defaultProps} />);
// Heading inside the collapsible body should be absent
expect(screen.queryByText(/Authentication Method/i)).not.toBeInTheDocument();
});
it('expands when the top‑level switch is toggled', () => {
render(<AuthSettings {...defaultProps} />);
fireEvent.click(screen.getByTestId('influxdb-v2-config-auth-settings-toggle'));
expect(screen.getByText(/Authentication Method/i)).toBeInTheDocument();
});
});
describe('Basic Auth', () => {
beforeEach(() => {
render(<AuthSettings {...defaultProps} />);
// open section first
fireEvent.click(screen.getByTestId('influxdb-v2-config-auth-settings-toggle'));
});
it('reveals Basic Auth inputs when Basic Auth is selected', () => {
fireEvent.click(screen.getByRole('radio', { name: /Basic Auth/i }));
expect(screen.getByPlaceholderText('User')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
});
it('propagates user input via onOptionsChange', async () => {
fireEvent.click(screen.getByRole('radio', { name: /Basic Auth/i }));
fireEvent.change(screen.getByPlaceholderText('User'), {
target: { value: 'john_doe' },
});
expect(onOptionsChangeMock).toHaveBeenCalledWith(expect.objectContaining({ basicAuthUser: 'john_doe' }));
});
});
describe('TLS Settings', () => {
beforeEach(() => {
render(<AuthSettings {...defaultProps} />);
// Expand the settings panel once for all tests
fireEvent.click(screen.getByTestId('influxdb-v2-config-auth-settings-toggle'));
});
describe('TLS Client Auth', () => {
it('shows and hides Server Name input when toggled Enabled/Disabled', () => {
const tlsRow = screen.getByTestId('influxdb-v2-config-auth-settings-tls-client-auth-toggle');
// Initially hidden
expect(screen.queryByPlaceholderText('domain.example.com')).not.toBeInTheDocument();
// Enable TLS Client Auth
fireEvent.click(within(tlsRow).getByText('Enabled'));
expect(screen.getByPlaceholderText('domain.example.com')).toBeInTheDocument();
// Disable again
fireEvent.click(within(tlsRow).getByText('Disabled'));
expect(screen.queryByPlaceholderText('domain.example.com')).not.toBeInTheDocument();
});
});
describe('CA Cert', () => {
it('shows and hides certificate textarea when toggled Enabled/Disabled', () => {
const placeholderText = 'Begins with -----BEGIN CERTIFICATE-----';
const caRow = screen.getByTestId('influxdb-v2-config-auth-settings-ca-cert-toggle');
const enabledRadio = within(caRow).getByText('Enabled');
const disabledRadio = within(caRow).getByText('Disabled');
// Initially hidden
expect(screen.queryByPlaceholderText(placeholderText)).not.toBeInTheDocument();
// Enable
fireEvent.click(enabledRadio);
expect(screen.getByPlaceholderText(placeholderText)).toBeInTheDocument();
// Disable
fireEvent.click(disabledRadio);
expect(screen.queryByPlaceholderText(placeholderText)).not.toBeInTheDocument();
});
});
describe('Skip TLS Verify', () => {
it('toggles checked state of the switch', () => {
const skipSwitch = screen.getByTestId('influxdb-v2-config-auth-settings-skip-tls-verify');
// Default unchecked
expect(skipSwitch).not.toBeChecked();
// Enable
fireEvent.click(skipSwitch);
expect(skipSwitch).toBeChecked();
// Disable
fireEvent.click(skipSwitch);
expect(skipSwitch).not.toBeChecked();
});
});
});
});

@ -0,0 +1,267 @@
import { cx } from '@emotion/css';
import { useCallback, useMemo, useState } from 'react';
import {
onUpdateDatasourceOption,
onUpdateDatasourceSecureJsonDataOption,
updateDatasourcePluginResetOption,
} from '@grafana/data';
import { AuthMethod, convertLegacyAuthProps } from '@grafana/plugin-ui';
import {
Box,
CertificationKey,
Field,
InlineField,
InlineSwitch,
Input,
Label,
RadioButtonGroup,
SecretInput,
useStyles2,
Text,
Stack,
} from '@grafana/ui';
import { AUTH_RADIO_BUTTON_OPTIONS, getInlineLabelStyles, RADIO_BUTTON_OPTIONS } from './constants';
import {
trackInfluxDBConfigV2AuthSettingsAuthMethodSelected,
trackInfluxDBConfigV2AuthSettingsToggleClicked,
} from './tracking';
import { Props } from './types';
type AuthOptionState = {
basicAuth: boolean;
tlsClientAuth: boolean;
caCert: boolean;
skipTLS: boolean;
oAuthForward: boolean;
withCredentials: boolean;
};
export const AuthSettings = (props: Props) => {
const { options, onOptionsChange } = props;
const styles = useStyles2(getInlineLabelStyles);
/**
* Derived props from legacy helpers
*/
const authProps = useMemo(
() =>
convertLegacyAuthProps({
config: options,
onChange: onOptionsChange,
}),
[options, onOptionsChange]
);
/**
* Selected authentication method. Fallback to the most common if the selected one is missing.
*/
const isAuthMethod = (v: unknown): v is AuthMethod =>
v === AuthMethod.NoAuth || v === AuthMethod.BasicAuth || v === AuthMethod.OAuthForward;
const selectedMethod = useMemo<AuthMethod | undefined>(() => {
if (isAuthMethod(authProps.selectedMethod)) {
return authProps.selectedMethod;
}
return isAuthMethod(authProps.mostCommonMethod) ? authProps.mostCommonMethod : undefined;
}, [authProps.selectedMethod, authProps.mostCommonMethod]);
/**
* Local UI state
*/
const [authOptions, setAuthOptions] = useState<AuthOptionState>({
basicAuth: selectedMethod === AuthMethod.BasicAuth,
tlsClientAuth: authProps.TLS?.TLSClientAuth.enabled ?? false,
caCert: authProps.TLS?.selfSignedCertificate.enabled ?? false,
skipTLS: authProps.TLS?.skipTLSVerification.enabled ?? false,
oAuthForward: selectedMethod === AuthMethod.OAuthForward,
withCredentials: options.withCredentials ?? false,
});
/**
* Expand/collapse toplevel section
*/
const [authenticationSettingsIsOpen, setAuthenticationSettingsIsOpen] = useState(
Object.values(authOptions).some(Boolean)
);
const toggleOpen = useCallback(() => {
setAuthenticationSettingsIsOpen((prev) => {
trackInfluxDBConfigV2AuthSettingsToggleClicked();
return !prev;
});
}, []);
const handleAuthMethodChange = useCallback(
(option: AuthMethod) => {
authProps.onAuthMethodSelect(option);
setAuthOptions((prev) => ({
...prev,
basicAuth: option === AuthMethod.BasicAuth,
oAuthForward: option === AuthMethod.OAuthForward,
}));
trackInfluxDBConfigV2AuthSettingsAuthMethodSelected({ authMethod: option });
},
[authProps]
);
/**
* Wraps the toggle of an auth option, updates the local state and calls the onToggle callback
* provided by the legacy `authProps` provided by `@grafana/plugin-ui`.
*/
const toggleOption = useCallback((key: keyof AuthOptionState, onToggle: (value: boolean) => void) => {
setAuthOptions((prev) => {
const nextValue = !prev[key];
const next = { ...prev, [key]: nextValue };
onToggle(nextValue);
return next;
});
}, []);
return (
<Stack direction="column">
{/* Header toggle */}
<Box alignItems="center">
<InlineField label={<div className={cx(styles.label)}>Auth and TLS/SSL Settings</div>} labelWidth={35}>
<InlineSwitch
data-testid="influxdb-v2-config-auth-settings-toggle"
value={authenticationSettingsIsOpen}
onChange={toggleOpen}
/>
</InlineField>
</Box>
{/* Collapsible settings body */}
{authenticationSettingsIsOpen && (
<Box paddingLeft={1}>
{/* Authentication Method */}
<Box marginBottom={2}>
<Field label={<Text element="h5">Authentication Method</Text>} noMargin>
<Box width="50%" marginY={2}>
<RadioButtonGroup
options={AUTH_RADIO_BUTTON_OPTIONS}
value={selectedMethod}
onChange={handleAuthMethodChange}
/>
</Box>
</Field>
</Box>
<Box marginBottom={2}>
{/* Basic Auth settings */}
{authOptions.basicAuth && (
<>
<Box display="flex" direction="column" width="60%" marginBottom={2}>
<InlineField label="User" labelWidth={14} grow>
<Input
placeholder="User"
onChange={onUpdateDatasourceOption(props, 'basicAuthUser')}
value={options.basicAuthUser || ''}
/>
</InlineField>
<InlineField label="Password" labelWidth={14} grow>
<SecretInput
placeholder="Password"
isConfigured={options.secureJsonFields.basicAuthPassword || false}
onChange={onUpdateDatasourceSecureJsonDataOption(props, 'basicAuthPassword')}
onReset={() => updateDatasourcePluginResetOption(props, 'basicAuthPassword')}
value={options.secureJsonData?.basicAuthPassword || ''}
/>
</InlineField>
</Box>
</>
)}
</Box>
{/* TLS Client Auth */}
<Box marginBottom={2}>
<Field noMargin>
<>
<Text element="h5">TLS Settings</Text>
<Box
display="flex"
alignItems="center"
data-testid="influxdb-v2-config-auth-settings-tls-client-auth-toggle"
marginTop={2}
>
<Label style={{ width: '125px' }}>TLS Client Auth</Label>
<RadioButtonGroup
options={RADIO_BUTTON_OPTIONS}
value={authOptions.tlsClientAuth}
onChange={() => toggleOption('tlsClientAuth', authProps.TLS!.TLSClientAuth.onToggle)}
size="sm"
/>
</Box>
{authOptions.tlsClientAuth && (
<Box marginTop={2}>
<InlineField label="Server Name" labelWidth={14} grow>
<Input
placeholder="domain.example.com"
onChange={(e) => authProps.TLS?.TLSClientAuth.onServerNameChange(e.currentTarget.value)}
value={authProps.TLS?.TLSClientAuth.serverName || ''}
/>
</InlineField>
<CertificationKey
label="Client Cert"
placeholder="Begins with -----BEGIN CERTIFICATE-----"
onChange={(e) => authProps.TLS?.TLSClientAuth.onClientCertificateChange(e.currentTarget.value)}
hasCert={!!authProps.TLS?.TLSClientAuth.clientCertificateConfigured}
onClick={() => authProps.TLS?.TLSClientAuth.onClientCertificateReset()}
/>
<CertificationKey
label="Client Key"
placeholder="Begins with -----BEGIN RSA PRIVATE KEY-----"
onChange={(e) => authProps.TLS?.TLSClientAuth.onClientKeyChange(e.currentTarget.value)}
hasCert={!!authProps.TLS?.TLSClientAuth.clientKeyConfigured}
onClick={() => authProps.TLS?.TLSClientAuth.onClientKeyReset()}
/>
</Box>
)}
</>
</Field>
</Box>
{/* CA Cert */}
<Box marginBottom={2}>
<Field noMargin>
<>
<Box display="flex" alignItems="center" data-testid="influxdb-v2-config-auth-settings-ca-cert-toggle">
<Label style={{ width: '125px' }}>CA Cert</Label>
<RadioButtonGroup
options={RADIO_BUTTON_OPTIONS}
value={authOptions.caCert}
onChange={() => toggleOption('caCert', authProps.TLS!.selfSignedCertificate.onToggle)}
size="sm"
/>
</Box>
{authOptions.caCert && (
<Box marginTop={3}>
<CertificationKey
label="CA Cert"
placeholder="Begins with -----BEGIN CERTIFICATE-----"
onChange={(e) => authProps.TLS?.selfSignedCertificate.onCertificateChange(e.currentTarget.value)}
hasCert={!!authProps.TLS?.selfSignedCertificate.certificateConfigured}
onClick={() => authProps.TLS?.selfSignedCertificate.onCertificateReset()}
/>
</Box>
)}
</>
</Field>
</Box>
{/* Skip TLS verify */}
<Box display="flex" direction="row" alignItems="center">
<Label style={{ width: '125px' }}>Skip TLS Verify</Label>
<InlineSwitch
data-testid="influxdb-v2-config-auth-settings-skip-tls-verify"
value={authOptions.skipTLS}
onChange={() => toggleOption('skipTLS', authProps.TLS!.skipTLSVerification.onToggle)}
/>
</Box>
</Box>
)}
</Stack>
);
};

@ -0,0 +1,44 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { ConfigEditor } from './ConfigEditor';
import { createTestProps } from './helpers';
jest.mock('./LeftSideBar', () => ({
LeftSideBar: () => <div data-testid="left-sidebar" />,
}));
jest.mock('./UrlAndAuthenticationSection', () => ({
UrlAndAuthenticationSection: () => <div data-testid="url-auth-section" />,
}));
jest.mock('./DatabaseConnectionSection', () => ({
DatabaseConnectionSection: () => <div data-testid="db-connection-section" />,
}));
describe('ConfigEditor', () => {
const defaultProps = createTestProps({
options: {
jsonData: {},
secureJsonData: {},
secureJsonFields: {},
},
mocks: {
onOptionsChange: jest.fn(),
},
});
it('renders the LeftSideBar, UrlAndAuthenticationSection, and DatabaseConnectionSection', () => {
render(<ConfigEditor {...defaultProps} />);
expect(screen.getByTestId('left-sidebar')).toBeInTheDocument();
expect(screen.getByTestId('url-auth-section')).toBeInTheDocument();
expect(screen.getByTestId('db-connection-section')).toBeInTheDocument();
});
it('shows the informational alert', () => {
render(<ConfigEditor {...defaultProps} />);
expect(screen.getByText(/You are viewing a new design/i)).toBeInTheDocument();
});
});

@ -0,0 +1,41 @@
import React from 'react';
import { Alert, Box, Stack, TextLink } from '@grafana/ui';
import { DatabaseConnectionSection } from './DatabaseConnectionSection';
import { LeftSideBar } from './LeftSideBar';
import { UrlAndAuthenticationSection } from './UrlAndAuthenticationSection';
import { trackInfluxDBConfigV2FeedbackButtonClicked } from './tracking';
import { Props } from './types';
export const ConfigEditor: React.FC<Props> = ({ onOptionsChange, options }: Props) => {
return (
<Stack justifyContent="space-between">
<Box width="250px" flex="0 0 250px">
<LeftSideBar pdcInjected={options?.jsonData?.pdcInjected!!} />
</Box>
<Box width="60%" flex="1 1 auto">
<Stack direction="column">
<Alert severity="info" title="You are viewing a new design for the InfluxDB configuration settings.">
<>
If something isn't working correctly, you can revert to the original configuration page design by
disabling the <code>newInfluxDSConfigPageDesign</code> feature flag.{' '}
<TextLink
href="https://docs.google.com/forms/d/1wiJJ5WFu33maVNaWDmJuW0VbhoeR6-VSuu6y2mHMb_4/viewform"
external
onClick={trackInfluxDBConfigV2FeedbackButtonClicked}
>
Submit feedback.
</TextLink>
</>
</Alert>
<UrlAndAuthenticationSection options={options} onOptionsChange={onOptionsChange} />
<DatabaseConnectionSection options={options} onOptionsChange={onOptionsChange} />
</Stack>
</Box>
<Box width="20%" flex="0 0 20%">
{/* TODO: Right sidebar */}
</Box>
</Stack>
);
};

@ -0,0 +1,96 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { InfluxVersion } from '../../../types';
import { DatabaseConnectionSection } from './DatabaseConnectionSection';
import { createTestProps } from './helpers';
jest.mock('./AdvancedDBConnectionSettings', () => ({
AdvancedDbConnectionSettings: () => <div data-testid="advanced-db-settings" />,
}));
jest.mock('./InfluxFluxDBConnection', () => ({
InfluxFluxDBConnection: () => <div data-testid="flux-connection" />,
}));
jest.mock('./InfluxSQLDBConnection', () => ({
InfluxSQLDBConnection: () => <div data-testid="sql-connection" />,
}));
jest.mock('./InfluxInfluxQLDBConnection', () => ({
InfluxInfluxQLDBConnection: () => <div data-testid="influxql-connection" />,
}));
describe('DatabaseConnectionSection', () => {
const onOptionsChangeMock = jest.fn();
const defaultProps = createTestProps({
options: {
jsonData: {},
secureJsonData: {},
secureJsonFields: {},
},
mocks: {
onOptionsChange: onOptionsChangeMock,
},
});
it('shows alert when version is missing', () => {
render(<DatabaseConnectionSection {...defaultProps} />);
expect(screen.getByText(/To view connection settings/i)).toBeInTheDocument();
});
it('renders Flux connection component when version is Flux', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: { version: InfluxVersion.Flux },
},
};
render(<DatabaseConnectionSection {...props} />);
expect(screen.getByTestId('flux-connection')).toBeInTheDocument();
});
it('renders InfluxQL connection component when version is InfluxQL', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: { version: InfluxVersion.InfluxQL },
},
};
render(<DatabaseConnectionSection {...props} />);
expect(screen.getByTestId('influxql-connection')).toBeInTheDocument();
});
it('renders SQL connection component when version is SQL', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: { version: InfluxVersion.SQL },
},
};
render(<DatabaseConnectionSection {...props} />);
expect(screen.getByTestId('sql-connection')).toBeInTheDocument();
});
it('always renders AdvancedDbConnectionSettings', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: { version: InfluxVersion.SQL },
},
};
render(<DatabaseConnectionSection {...props} />);
expect(screen.getByTestId('advanced-db-settings')).toBeInTheDocument();
});
});

@ -0,0 +1,65 @@
import { Box, CollapsableSection, Alert, Space, Text } from '@grafana/ui';
import { InfluxVersion } from '../../../types';
import { AdvancedDbConnectionSettings } from './AdvancedDBConnectionSettings';
import { InfluxFluxDBConnection } from './InfluxFluxDBConnection';
import { InfluxInfluxQLDBConnection } from './InfluxInfluxQLDBConnection';
import { InfluxSQLDBConnection } from './InfluxSQLDBConnection';
import { CONFIG_SECTION_HEADERS } from './constants';
import { Props } from './types';
export const DatabaseConnectionSection = ({ options, onOptionsChange }: Props) => (
<>
<Box borderStyle="solid" borderColor="weak" padding={2} marginBottom={4} id={`${CONFIG_SECTION_HEADERS[1].id}`}>
<CollapsableSection
label={<Text element="h3">2. {CONFIG_SECTION_HEADERS[1].label}</Text>}
isOpen={CONFIG_SECTION_HEADERS[1].isOpen}
>
{!options.jsonData.version && (
<Alert severity="info" title="Query language required">
<p>To view connection settings, first choose a query language in the URL and Connection section.</p>
</Alert>
)}
{options.jsonData.version === InfluxVersion.InfluxQL && (
<>
<Alert severity="info" title="Database Access">
<p>
Setting the database for this datasource does not deny access to other databases. The InfluxDB query
syntax allows switching the database in the query. For example:
<code>SHOW MEASUREMENTS ON _internal</code> or
<code>SELECT * FROM &quot;_internal&quot;..&quot;database&quot; LIMIT 10</code>
<br />
<br />
To support data isolation and security, make sure appropriate permissions are configured in InfluxDB.
</p>
</Alert>
</>
)}
{options.jsonData.version && (
<>
<Text color="secondary">
Provide the necessary database connection details based on your selected InfluxDB product and query
language.
</Text>
<Space v={2} />
</>
)}
<>
{options.jsonData.version === InfluxVersion.InfluxQL && (
<InfluxInfluxQLDBConnection options={options} onOptionsChange={onOptionsChange} />
)}
{options.jsonData.version === InfluxVersion.Flux && (
<InfluxFluxDBConnection options={options} onOptionsChange={onOptionsChange} />
)}
{options.jsonData.version === InfluxVersion.SQL && (
<InfluxSQLDBConnection options={options} onOptionsChange={onOptionsChange} />
)}
{options.jsonData.version && (
<AdvancedDbConnectionSettings options={options} onOptionsChange={onOptionsChange} />
)}
</>
</CollapsableSection>
</Box>
</>
);

@ -0,0 +1,46 @@
import '@testing-library/jest-dom';
import { render, screen, fireEvent } from '@testing-library/react';
import { InfluxFluxDBConnection } from './InfluxFluxDBConnection';
import { createTestProps } from './helpers';
describe('InfluxFluxDBConnection', () => {
const onOptionsChangeMock = jest.fn();
const defaultProps = createTestProps({
options: {
jsonData: {
organization: 'MyOrg',
defaultBucket: 'MyBucket',
},
secureJsonData: {
token: 'my-token',
},
secureJsonFields: {
token: true,
},
},
mocks: {
onOptionsChange: onOptionsChangeMock,
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('renders organization, bucket and token inputs', () => {
render(<InfluxFluxDBConnection {...defaultProps} />);
expect(screen.getByLabelText(/Organization/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Default Bucket/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Token/i)).toBeInTheDocument();
});
it('calls onOptionsChange on input change', () => {
render(<InfluxFluxDBConnection {...defaultProps} />);
const orgInput = screen.getByLabelText(/Organization/i);
fireEvent.change(orgInput, { target: { value: 'NewOrg' } });
expect(onOptionsChangeMock).toHaveBeenCalled();
});
});

@ -0,0 +1,58 @@
import {
onUpdateDatasourceJsonDataOption,
onUpdateDatasourceSecureJsonDataOption,
updateDatasourcePluginResetOption,
} from '@grafana/data';
import { InlineFieldRow, InlineField, Input, SecretInput } from '@grafana/ui';
import {
trackInfluxDBConfigV2FluxDBDetailsDefaultBucketInputField,
trackInfluxDBConfigV2FluxDBDetailsOrgInputField,
trackInfluxDBConfigV2FluxDBDetailsTokenInputField,
} from './tracking';
import { Props } from './types';
export const InfluxFluxDBConnection = (props: Props) => {
const {
options: { jsonData, secureJsonData, secureJsonFields },
} = props;
return (
<>
<InlineFieldRow>
<InlineField label="Organization" labelWidth={30} grow>
<Input
id="organization"
placeholder="myorg"
onBlur={trackInfluxDBConfigV2FluxDBDetailsOrgInputField}
onChange={onUpdateDatasourceJsonDataOption(props, 'organization')}
value={jsonData.organization || ''}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField labelWidth={30} label="Default Bucket" grow>
<Input
id="default-bucket"
onBlur={trackInfluxDBConfigV2FluxDBDetailsDefaultBucketInputField}
onChange={onUpdateDatasourceJsonDataOption(props, 'defaultBucket')}
placeholder="mybucket"
value={jsonData.defaultBucket || ''}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField labelWidth={30} label="Token" grow>
<SecretInput
id="token"
isConfigured={Boolean(secureJsonFields && secureJsonFields.token)}
onBlur={trackInfluxDBConfigV2FluxDBDetailsTokenInputField}
onChange={onUpdateDatasourceSecureJsonDataOption(props, 'token')}
onReset={() => updateDatasourcePluginResetOption(props, 'token')}
value={secureJsonData?.token || ''}
/>
</InlineField>
</InlineFieldRow>
</>
);
};

@ -0,0 +1,41 @@
import '@testing-library/jest-dom';
import { render, screen, fireEvent } from '@testing-library/react';
import { InfluxInfluxQLDBConnection } from './InfluxInfluxQLDBConnection';
import { createTestProps } from './helpers';
describe('InfluxInfluxQLDBConnection', () => {
const onOptionsChangeMock = jest.fn();
const defaultProps = createTestProps({
options: {
user: 'admin',
jsonData: {
dbName: 'influxdb',
},
secureJsonData: {
password: 'secret',
},
secureJsonFields: {
password: true,
},
},
mocks: {
onOptionsChange: onOptionsChangeMock,
},
});
it('renders dbName, user and password fields', () => {
render(<InfluxInfluxQLDBConnection {...defaultProps} />);
expect(screen.getByLabelText(/Database/i)).toBeInTheDocument();
expect(screen.getByLabelText(/User/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Password/i)).toBeInTheDocument();
});
it('calls onOptionsChange on input changes', () => {
render(<InfluxInfluxQLDBConnection {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/User/i), { target: { value: 'newuser' } });
expect(onOptionsChangeMock).toHaveBeenCalled();
});
});

@ -0,0 +1,57 @@
import {
onUpdateDatasourceJsonDataOption,
onUpdateDatasourceOption,
onUpdateDatasourceSecureJsonDataOption,
updateDatasourcePluginResetOption,
} from '@grafana/data';
import { InlineFieldRow, InlineField, Input, SecretInput } from '@grafana/ui';
import {
trackInfluxDBConfigV2InfluxQLDBDetailsDatabaseInputField,
trackInfluxDBConfigV2InfluxQLDBDetailsPasswordInputField,
trackInfluxDBConfigV2InfluxQLDBDetailsUserInputField,
} from './tracking';
import { Props } from './types';
export const InfluxInfluxQLDBConnection = (props: Props) => {
const { options } = props;
return (
<>
<InlineFieldRow>
<InlineField label="Database" labelWidth={30} grow>
<Input
id="database"
placeholder="mydb"
value={options.jsonData.dbName}
onChange={onUpdateDatasourceJsonDataOption(props, 'dbName')}
onBlur={trackInfluxDBConfigV2InfluxQLDBDetailsDatabaseInputField}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="User" labelWidth={30} grow>
<Input
id="user"
placeholder="myuser"
value={options.user || ''}
onChange={onUpdateDatasourceOption(props, 'user')}
onBlur={trackInfluxDBConfigV2InfluxQLDBDetailsUserInputField}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Password" labelWidth={30} grow>
<SecretInput
id="password"
isConfigured={Boolean(options.secureJsonFields && options.secureJsonFields.password)}
value={options.secureJsonData?.password || ''}
onReset={() => updateDatasourcePluginResetOption(props, 'password')}
onChange={onUpdateDatasourceSecureJsonDataOption(props, 'password')}
onBlur={trackInfluxDBConfigV2InfluxQLDBDetailsPasswordInputField}
/>
</InlineField>
</InlineFieldRow>
</>
);
};

@ -0,0 +1,39 @@
import '@testing-library/jest-dom';
import { render, screen, fireEvent } from '@testing-library/react';
import { InfluxSQLDBConnection } from './InfluxSQLDBConnection';
import { createTestProps } from './helpers';
describe('InfluxSQLDBConnection', () => {
const onOptionsChangeMock = jest.fn();
const defaultProps = createTestProps({
options: {
jsonData: {
dbName: 'testdb',
},
secureJsonData: {
token: 'abc123',
},
secureJsonFields: {
token: true,
},
},
mocks: {
onOptionsChange: onOptionsChangeMock,
},
});
it('renders database and token fields', () => {
render(<InfluxSQLDBConnection {...defaultProps} />);
expect(screen.getByLabelText(/Database/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Token/i)).toBeInTheDocument();
});
it('calls onOptionsChange on dbName change', () => {
render(<InfluxSQLDBConnection {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/Database/i), { target: { value: 'newdb' } });
expect(onOptionsChangeMock).toHaveBeenCalled();
});
});

@ -0,0 +1,45 @@
import {
onUpdateDatasourceJsonDataOption,
onUpdateDatasourceSecureJsonDataOption,
updateDatasourcePluginResetOption,
} from '@grafana/data';
import { InlineFieldRow, InlineField, Input, SecretInput } from '@grafana/ui';
import {
trackInfluxDBConfigV2SQLDBDetailsDatabaseInputField,
trackInfluxDBConfigV2SQLDBDetailsTokenInputField,
} from './tracking';
import { Props } from './types';
export const InfluxSQLDBConnection = (props: Props) => {
const { options } = props;
const { secureJsonData, secureJsonFields } = options;
return (
<>
<InlineFieldRow>
<InlineField label="Database" labelWidth={30} grow>
<Input
id="database"
placeholder="mydb"
value={options.jsonData.dbName}
onChange={onUpdateDatasourceJsonDataOption(props, 'dbName')}
onBlur={trackInfluxDBConfigV2SQLDBDetailsDatabaseInputField}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField labelWidth={30} label="Token" grow>
<SecretInput
id="token"
isConfigured={Boolean(secureJsonFields && secureJsonFields.token)}
onBlur={trackInfluxDBConfigV2SQLDBDetailsTokenInputField}
onChange={onUpdateDatasourceSecureJsonDataOption(props, 'token')}
onReset={() => updateDatasourcePluginResetOption(props, 'token')}
value={secureJsonData?.token || ''}
/>
</InlineField>
</InlineFieldRow>
</>
);
};

@ -0,0 +1,16 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { LeftSideBar } from './LeftSideBar';
describe('LeftSideBar', () => {
it('renders sidebar title when pdcInjected is true', () => {
render(<LeftSideBar pdcInjected={true} />);
expect(screen.getByTestId('Private data source connect-sidebar')).toBeInTheDocument();
});
it('does not render sidebar title when pdcInjected is false', () => {
render(<LeftSideBar pdcInjected={false} />);
expect(screen.queryByTestId('Private data source connect-sidebar')).not.toBeInTheDocument();
});
});

@ -0,0 +1,40 @@
import { Box, InlineField, LinkButton, Space, Stack, Text } from '@grafana/ui';
import { CONFIG_SECTION_HEADERS, CONFIG_SECTION_HEADERS_WITH_PDC } from './constants';
interface LeftSideBarProps {
pdcInjected: boolean;
}
export const LeftSideBar = ({ pdcInjected }: LeftSideBarProps) => {
const headers = pdcInjected ? CONFIG_SECTION_HEADERS_WITH_PDC : CONFIG_SECTION_HEADERS;
return (
<Stack>
<Box flex={1} marginY={5}>
<Text element="h4">InfluxDB</Text>
<Box paddingTop={2}>
{headers.map((header, index) => (
<div key={index} data-testid={`${header.label}-sidebar`}>
<InlineField label={`${index + 1}`} style={{ display: 'flex', alignItems: 'center' }} grow>
<LinkButton
variant="secondary"
fill="text"
onClick={(e) => {
e.preventDefault();
const target = document.getElementById(header.id);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}}
>
{header.label}
</LinkButton>
</InlineField>
<Space v={1} />
</div>
))}
</Box>
</Box>
</Stack>
);
};

@ -0,0 +1,90 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { InfluxVersion } from '../../../types';
import { UrlAndAuthenticationSection } from './UrlAndAuthenticationSection';
import { createTestProps } from './helpers';
describe('UrlAndAuthenticationSection', () => {
const onOptionsChangeMock = jest.fn();
const defaultProps = createTestProps({
options: {
jsonData: {
url: 'http://localhost:8086',
product: '',
version: '',
},
secureJsonData: {},
secureJsonFields: {},
},
mocks: {
onOptionsChange: onOptionsChangeMock,
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('calls onOptionsChange when URL is changed', () => {
render(<UrlAndAuthenticationSection {...defaultProps} />);
const input = screen.getByTestId('influxdb-v2-config-url-input');
fireEvent.change(input, { target: { value: 'http://example.com' } });
expect(onOptionsChangeMock).toHaveBeenCalled();
});
it('renders DRBP warning for InfluxDB OSS 1.x and InfluxQL', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: { product: 'InfluxDB OSS 1.x', version: InfluxVersion.InfluxQL },
},
};
render(<UrlAndAuthenticationSection {...props} />);
expect(screen.getByText(/requires DRBP mapping/i)).toBeInTheDocument();
});
it('renders DRBP warning for InfluxDB OSS 2.x and InfluxQL', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: { product: 'InfluxDB OSS 2.x', version: InfluxVersion.InfluxQL },
},
};
render(<UrlAndAuthenticationSection {...props} />);
expect(screen.getByText(/requires DRBP mapping/i)).toBeInTheDocument();
});
it('does not render DRBP warning for InfluxDB OSS 1.x and Flux', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: { product: 'InfluxDB OSS 1.x', version: InfluxVersion.Flux },
},
};
render(<UrlAndAuthenticationSection {...props} />);
expect(screen.queryByText(/requires DRBP mapping/i)).not.toBeInTheDocument();
});
it('does not render DRBP warning for InfluxDB OSS 2.x and Flux', () => {
const props = {
...defaultProps,
options: {
...defaultProps.options,
jsonData: { product: 'InfluxDB OSS 2.x', version: InfluxVersion.Flux },
},
};
render(<UrlAndAuthenticationSection {...props} />);
expect(screen.queryByText(/requires DRBP mapping/i)).not.toBeInTheDocument();
});
});

@ -0,0 +1,131 @@
import { onUpdateDatasourceJsonDataOptionSelect, onUpdateDatasourceOption } from '@grafana/data';
import {
Box,
CollapsableSection,
TextLink,
Field,
Input,
Combobox,
Space,
Stack,
Text,
ComboboxOption,
Alert,
} from '@grafana/ui';
import { InfluxVersion } from '../../../types';
import { AdvancedHttpSettings } from './AdvancedHttpSettings';
import { AuthSettings } from './AuthSettings';
import { CONFIG_SECTION_HEADERS } from './constants';
import {
trackInfluxDBConfigV2ProductSelected,
trackInfluxDBConfigV2QueryLanguageSelected,
trackInfluxDBConfigV2URLInputField,
} from './tracking';
import { Props } from './types';
import { INFLUXDB_VERSION_MAP } from './versions';
const getQueryLanguageOptions = (productName: string): Array<{ value: string }> => {
const product = INFLUXDB_VERSION_MAP.find(({ name }) => name === productName);
return product?.queryLanguages?.map(({ name }) => ({ value: name })) ?? [];
};
export const UrlAndAuthenticationSection = (props: Props) => {
const { options, onOptionsChange } = props;
const isInfluxVersion = (v: string): v is InfluxVersion =>
typeof v === 'string' && (v === InfluxVersion.Flux || v === InfluxVersion.InfluxQL || v === InfluxVersion.SQL);
// Database + Retention Policy (DBRP) mapping is required for InfluxDB OSS 1.x and 2.x when using InfluxQL
const requiresDrbpMapping =
options.jsonData.product &&
options.jsonData.version === InfluxVersion.InfluxQL &&
['InfluxDB OSS 1.x', 'InfluxDB OSS 2.x'].includes(options.jsonData.product);
const onProductChange = ({ value }: ComboboxOption) =>
onOptionsChange({ ...options, jsonData: { ...options.jsonData, product: value, version: undefined } });
const onQueryLanguageChange = (option: ComboboxOption) => {
const { value } = option;
if (isInfluxVersion(value)) {
onUpdateDatasourceJsonDataOptionSelect(props, 'version')(option);
}
};
const onUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => onUpdateDatasourceOption(props, 'url')(event);
return (
<Box borderStyle="solid" borderColor="weak" padding={2} marginBottom={4} id={`${CONFIG_SECTION_HEADERS[0].id}`}>
<CollapsableSection
label={<Text element="h3">1. {CONFIG_SECTION_HEADERS[0].label}</Text>}
isOpen={CONFIG_SECTION_HEADERS[0].isOpen}
>
<Text color="secondary">
Enter the URL of your InfluxDB instance, then select your product and query language. This will determine the
available settings and authentication methods in the next steps. If you are unsure what product you are using,
view the{' '}
<TextLink href="https://docs.influxdata.com/" external>
InfluxDB Docs.
</TextLink>
.
</Text>
<Box direction="column" gap={2} marginTop={3}>
<Field label={<div style={{ marginBottom: '5px' }}>URL</div>} noMargin>
<Input
data-testid="influxdb-v2-config-url-input"
placeholder="http://localhost:3000/"
onChange={onUrlChange}
value={options.url || ''}
onBlur={trackInfluxDBConfigV2URLInputField}
/>
</Field>
<Box marginTop={2}>
<Stack direction="row" gap={2}>
<Box flex={1}>
<Field label={<div style={{ marginBottom: '5px' }}>Product</div>} noMargin>
<Combobox
data-testid="influxdb-v2-config-product-select"
value={options.jsonData.product}
options={INFLUXDB_VERSION_MAP.map(({ name }) => ({ value: name }))}
onChange={onProductChange}
onBlur={() => trackInfluxDBConfigV2ProductSelected({ product: options.jsonData.product! })}
/>
</Field>
</Box>
<Box flex={1}>
<Field label={<div style={{ marginBottom: '5px' }}>Query language</div>} noMargin>
<Combobox
data-testid="influxdb-v2-config-query-language-select"
value={options.jsonData.product !== '' ? options.jsonData.version : ''}
options={getQueryLanguageOptions(options.jsonData.product || '')}
onChange={onQueryLanguageChange}
onBlur={() => trackInfluxDBConfigV2QueryLanguageSelected({ version: options.url })}
/>
</Field>
</Box>
</Stack>
</Box>
<Space v={2} />
{requiresDrbpMapping && (
<Alert severity="warning" title="InfluxQL requires DRBP mapping">
InfluxDB OSS 1.x and 2.x users must configure a Database + Retention Policy (DBRP) mapping via the CLI or
API before data can be queried.{' '}
<TextLink href="https://docs.influxdata.com/influxdb/cloud/query-data/influxql/dbrp/" external>
Learn how to set this up
</TextLink>
</Alert>
)}
<AdvancedHttpSettings options={options} onOptionsChange={onOptionsChange} />
<AuthSettings options={options} onOptionsChange={onOptionsChange} />
</Box>
</CollapsableSection>
</Box>
);
};

@ -0,0 +1,57 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { AuthMethod } from '@grafana/plugin-ui';
import { ComboboxOption } from '@grafana/ui';
export const RADIO_BUTTON_OPTIONS = [
{ label: 'Enabled', value: true },
{ label: 'Disabled', value: false },
];
export const AUTH_RADIO_BUTTON_OPTIONS = [
{ label: 'No Authentication', value: AuthMethod.NoAuth },
{ label: 'Basic Authentication', value: AuthMethod.BasicAuth },
{ label: 'Forward OAuth Identity', value: AuthMethod.OAuthForward },
];
export const CONFIG_SECTION_HEADERS = [
{ label: 'URL and authentication', id: 'url', isOpen: true },
{ label: 'Database settings', id: 'tls', isOpen: true },
{ label: 'Save & test', id: `${selectors.pages.DataSource.saveAndTest}`, isOpen: true },
];
export const CONFIG_SECTION_HEADERS_WITH_PDC = [
{ label: 'URL and authentication', id: 'url', isOpen: true },
{ label: 'Database settings', id: 'tls', isOpen: true },
{ label: 'Private data source connect', id: 'pdc', isOpen: true },
{ label: 'Save & test', id: `${selectors.pages.DataSource.saveAndTest}`, isOpen: true },
];
export const HTTP_MODES: ComboboxOption[] = [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
];
export const getInlineLabelStyles = (theme: GrafanaTheme2, transparent = false, width?: number | 'auto') => {
return {
label: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
padding: theme.spacing(0, 1),
fontWeight: theme.typography.fontWeightMedium,
fontSize: theme.typography.size.md,
backgroundColor: transparent ? 'transparent' : theme.colors.background.secondary,
height: theme.spacing(theme.components.height.md),
lineHeight: theme.spacing(theme.components.height.md),
marginRight: theme.spacing(0.5),
borderRadius: theme.shape.radius.default,
border: 'none',
width: '240px',
color: theme.colors.text.primary,
}),
};
};

@ -0,0 +1,33 @@
/**
* Creates a set of test props for the InfluxDB V2 config page for use in tests.
* This function allows you to override default properties for specific test cases.
*/
export const createTestProps = (overrides: { options?: object; mocks?: object }) => ({
options: {
access: 'proxy',
basicAuth: false,
basicAuthUser: '',
database: '',
id: 1,
isDefault: false,
jsonData: {
httpMode: 'POST',
timeInterval: '5',
},
name: 'InfluxDB',
orgId: 1,
readOnly: false,
secureJsonFields: {},
type: 'influxdb',
typeLogoUrl: '',
typeName: 'Influx',
uid: 'z',
url: '',
user: '',
version: 1,
withCredentials: false,
...overrides.options,
},
onOptionsChange: jest.fn(),
...overrides.mocks,
});

@ -0,0 +1,89 @@
import { reportInteraction } from '@grafana/runtime';
// Config Section
export const trackInfluxDBConfigV2FeedbackButtonClicked = () => {
reportInteraction('influxdb-config-v2-feedback-button-clicked');
};
// URL and Auth Section
export const trackInfluxDBConfigV2URLInputField = () => {
reportInteraction('influxdb-config-v2-url-input-field');
};
export const trackInfluxDBConfigV2QueryLanguageSelected = (props: { version: string }) => {
reportInteraction('influxdb-config-v2-query-language-dropdown', props);
};
export const trackInfluxDBConfigV2ProductSelected = (props: { product: string }) => {
reportInteraction('influxdb-config-v2-product-selection-dropdown', props);
};
// Flux Database Details Fields
export const trackInfluxDBConfigV2FluxDBDetailsOrgInputField = () => {
reportInteraction('influxdb-config-v2-flux-dbdetails-org-input-field');
};
export const trackInfluxDBConfigV2FluxDBDetailsDefaultBucketInputField = () => {
reportInteraction('influxdb-config-v2-flux-dbdetails-default-bucket-input-field');
};
export const trackInfluxDBConfigV2FluxDBDetailsTokenInputField = () => {
reportInteraction('influxdb-config-v2-flux-dbdetails-token-input-field');
};
// InfluxQL Database Details Fields
export const trackInfluxDBConfigV2InfluxQLDBDetailsDatabaseInputField = () => {
reportInteraction('influxdb-config-v2-influxql-dbdetails-database-input-field');
};
export const trackInfluxDBConfigV2InfluxQLDBDetailsUserInputField = () => {
reportInteraction('influxdb-config-v2-influxql-dbdetails-user-input-field');
};
export const trackInfluxDBConfigV2InfluxQLDBDetailsPasswordInputField = () => {
reportInteraction('influxdb-config-v2-influxql-dbdetails-password-input-field');
};
// SQL Database Details Fields
export const trackInfluxDBConfigV2SQLDBDetailsDatabaseInputField = () => {
reportInteraction('influxdb-config-v2-sql-dbdetails-database-input-field');
};
export const trackInfluxDBConfigV2SQLDBDetailsTokenInputField = () => {
reportInteraction('influxdb-config-v2-sql-dbdetails-token-input-field');
};
// Advanced DB Connection Settings
export const trackInfluxDBConfigV2AdvancedDbConnectionSettingsToggleClicked = () => {
reportInteraction('influxdb-config-v2-advanceddb-settings-toggle-clicked');
};
export const trackInfluxDBConfigV2AdvancedDbConnectionSettingsHTTPMethodClicked = () => {
reportInteraction('influxdb-config-v2-advanceddb-settings-http-method-clicked');
};
export const trackInfluxDBConfigV2AdvancedDbConnectionSettingsInsecureConnectClicked = () => {
reportInteraction('influxdb-config-v2-advanceddb-settings-insecure-connection-clicked');
};
export const trackInfluxDBConfigV2AdvancedDbConnectionSettingsMinTimeClicked = () => {
reportInteraction('influxdb-config-v2-advanceddb-settings-min-time-clicked');
};
// Advanced HTTP Settings
export const trackInfluxDBConfigV2AdvancedHTTPSettingsToggleClicked = () => {
reportInteraction('influxdb-config-v2-advanced-http-settings-toggle-clicked');
};
export const trackInfluxDBConfigV2AdvancedHTTPSettingsTimeoutField = () => {
reportInteraction('influxdb-config-v2-advanced-http-settings-timeout-field');
};
// Auth Settings
export const trackInfluxDBConfigV2AuthSettingsToggleClicked = () => {
reportInteraction('influxdb-config-v2-auth-settings-toggle-clicked');
};
export const trackInfluxDBConfigV2AuthSettingsAuthMethodSelected = (props: { authMethod: string }) => {
reportInteraction('influxdb-config-v2-advanced-http-settings-timeout-field', props);
};

@ -0,0 +1,22 @@
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { InfluxOptions, InfluxSecureJsonData } from '../../../types';
// As we're not using the auth component in `@grafana/plugin-ui`, we're defining the missing properties here
// to ensure the types are compatible with the existing code.
//
// They should be removed at a later point.
type InfluxBasicAuthData = {
basicAuth?: boolean;
basicAuthUser?: string;
};
type InfluxSecureBasicAuthData = {
basicAuthPassword?: string;
};
export type Props = DataSourcePluginOptionsEditorProps<
InfluxOptions & InfluxBasicAuthData,
InfluxSecureJsonData & InfluxSecureBasicAuthData
>;

@ -0,0 +1,141 @@
import { InfluxVersion } from '../../../types';
interface AuthMethod {
type: 'Basic' | 'Token';
fields: string[];
}
interface QueryLanguageConfig {
name: InfluxVersion;
fields: Array<string | AuthMethod>;
}
interface DetectionMethod {
urlContains?: string[];
pingHeaderResponse?: Record<string, string>;
}
interface InfluxDBProduct {
name: string;
queryLanguages?: QueryLanguageConfig[];
detectionMethod?: DetectionMethod;
}
// Complete Data Structure:
export const INFLUXDB_VERSION_MAP: InfluxDBProduct[] = [
{
name: 'InfluxDB Cloud Dedicated',
queryLanguages: [
{ name: InfluxVersion.SQL, fields: ['Host', 'Database', 'Token'] },
{ name: InfluxVersion.InfluxQL, fields: ['Host', 'Database', 'Token'] },
],
detectionMethod: {
urlContains: ['influxdb.io'],
},
},
{
name: 'InfluxDB Cloud Serverless',
queryLanguages: [
{ name: InfluxVersion.SQL, fields: ['Host', 'Bucket', 'Token'] },
{ name: InfluxVersion.InfluxQL, fields: ['Host', 'Bucket', 'Token'] },
{ name: InfluxVersion.Flux, fields: ['Host', 'Organization', 'Token', 'Default bucket'] },
],
detectionMethod: {
urlContains: ['us-east-1-1.aws.cloud2.influxdata.com', 'eu-central-1-1.aws.cloud2.influxdata.com'],
},
},
{
name: 'InfluxDB Clustered',
queryLanguages: [
{ name: InfluxVersion.SQL, fields: ['Host', 'Database', 'Token'] },
{ name: InfluxVersion.InfluxQL, fields: ['URL', 'Database', 'Token'] },
],
detectionMethod: {
pingHeaderResponse: {
'x-influxdb-version': '\\s*influxqlbridged-development',
},
},
},
{
name: 'InfluxDB Enterprise 1.x',
queryLanguages: [
{ name: InfluxVersion.InfluxQL, fields: ['URL', 'Database', 'User', 'Password'] },
{ name: InfluxVersion.Flux, fields: ['URL', 'User', 'Password', 'Default database'] },
],
detectionMethod: {
pingHeaderResponse: {
'x-influxdb-build': 'Enterprise (needs confirmation)',
},
},
},
{
name: 'InfluxDB Enterprise 3.x',
queryLanguages: [
{ name: InfluxVersion.SQL, fields: ['URL', 'Token'] },
{ name: InfluxVersion.InfluxQL, fields: ['URL', 'Token'] },
],
detectionMethod: {
pingHeaderResponse: {
'x-influxdb-build': 'TBD',
},
},
},
{
name: 'InfluxDB Cloud (TSM)',
queryLanguages: [
{ name: InfluxVersion.InfluxQL, fields: ['URL', 'Database', 'Token'] },
{ name: InfluxVersion.Flux, fields: ['URL', 'Organization', 'Token', 'Default bucket'] },
],
detectionMethod: {
urlContains: [
'us-west-2-1.aws.cloud2.influxdata.com',
'us-west-2-2.aws.cloud2.influxdata.com',
'us-east-1-1.aws.cloud2.influxdata.com',
'eu-central-1-1.aws.cloud2.influxdata.com',
'us-central1-1.gcp.cloud2.influxdata.com',
'westeurope-1.azure.cloud2.influxdata.com',
'eastus-1.azure.cloud2.influxdata.com',
],
},
},
{
name: 'InfluxDB Cloud 1',
queryLanguages: [{ name: InfluxVersion.InfluxQL, fields: ['URL', 'Database', 'Username', 'Password'] }],
detectionMethod: {
urlContains: ['influxcloud.net'],
},
},
{
name: 'InfluxDB OSS 1.x',
queryLanguages: [
{ name: InfluxVersion.InfluxQL, fields: ['URL', 'Database', 'Username', 'Password'] },
{ name: InfluxVersion.Flux, fields: ['URL', 'Username', 'Password', 'Default database'] },
],
detectionMethod: {
pingHeaderResponse: {
'x-influxdb-build': 'OSS',
'x-influxdb-version': '^1\\.',
},
},
},
{
name: 'InfluxDB OSS 2.x',
queryLanguages: [
{
name: InfluxVersion.InfluxQL,
fields: [
'URL',
'Database',
{ type: 'Basic', fields: ['Username', 'Password'] },
{ type: 'Token', fields: ['Token'] },
],
},
{ name: InfluxVersion.Flux, fields: ['URL', 'Token', 'Default bucket'] },
],
detectionMethod: {
pingHeaderResponse: {
'x-influxdb-build': 'OSS',
'x-influxdb-version': '^2\\.',
},
},
},
];

@ -1,11 +1,16 @@
import { DataSourcePlugin } from '@grafana/data';
import { config } from '@grafana/runtime';
import ConfigEditor from './components/editor/config/ConfigEditor';
import { ConfigEditor as ConfigEditorV1 } from './components/editor/config/ConfigEditor';
import { ConfigEditor as ConfigEditorV2 } from './components/editor/config-v2/ConfigEditor';
import { QueryEditor } from './components/editor/query/QueryEditor';
import { InfluxStartPage } from './components/editor/query/influxql/InfluxStartPage';
import InfluxDatasource from './datasource';
// ConfigEditorV2 is the new design for the InfluxDB configuration page
const configEditor = config.featureToggles.newInfluxDSConfigPageDesign ? ConfigEditorV2 : ConfigEditorV1;
export const plugin = new DataSourcePlugin(InfluxDatasource)
.setConfigEditor(ConfigEditor)
.setConfigEditor(configEditor)
.setQueryEditor(QueryEditor)
.setQueryEditorHelp(InfluxStartPage);

@ -15,6 +15,8 @@ export interface InfluxOptions extends DataSourceJsonData {
httpMode?: string;
dbName?: string;
product?: string;
pdcInjected?: boolean;
// With Flux
organization?: string;

Loading…
Cancel
Save