[InfluxDB]: Update product selection and UI (#112074)

This commit is contained in:
Alyssa Joyner
2025-10-28 08:09:43 -06:00
committed by GitHub
parent 79a5b024e1
commit 1cb66d86b0
8 changed files with 235 additions and 95 deletions
@@ -15,7 +15,7 @@ export const ConfigEditor: React.FC<Props> = ({ onOptionsChange, options }: Prop
const styles = useStyles2(getStyles);
return (
<Stack justifyContent="space-between">
<div className={styles.hideOnSmallScreen}>
<div className={`${styles.hideOnSmallScreen} ${styles.leftSticky}`}>
<Box width="100%" flex="1 1 auto">
<LeftSideBar pdcInjected={options?.jsonData?.pdcInjected!!} />
</Box>
@@ -38,7 +38,7 @@ export const ConfigEditor: React.FC<Props> = ({ onOptionsChange, options }: Prop
to help us make it even better.
</>
</Alert>
<Text color="secondary" element="p" italic>
<Text variant="bodySmall" color="secondary">
Fields marked with * are required
</Text>
<UrlAndAuthenticationSection options={options} onOptionsChange={onOptionsChange} />
@@ -61,6 +61,13 @@ const getStyles = (theme: GrafanaTheme2) => {
display: 'none',
},
}),
leftSticky: css({
position: 'sticky',
top: '100px',
alignSelf: 'flex-start',
maxHeight: 'calc(100vh - 100px)',
overflow: 'hidden',
}),
alertHeight: css({
height: '100px',
}),
@@ -20,7 +20,7 @@ export const DatabaseConnectionSection = ({ options, onOptionsChange }: Props) =
minWidth={CONTAINER_MIN_WIDTH}
>
<CollapsableSection
label={<Text element="h3">2. {CONFIG_SECTION_HEADERS[1].label}</Text>}
label={<Text element="h3">{CONFIG_SECTION_HEADERS[1].label}</Text>}
isOpen={CONFIG_SECTION_HEADERS[1].isOpen}
>
{!options.jsonData.version && (
@@ -28,14 +28,17 @@ describe('InfluxInfluxQLDBConnection', () => {
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();
expect(screen.getByLabelText(/^Database\b/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^User\b/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^Password\b/i)).toBeInTheDocument();
});
it('calls onOptionsChange on input changes', () => {
render(<InfluxInfluxQLDBConnection {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/User/i), { target: { value: 'newuser' } });
expect(onOptionsChangeMock).toHaveBeenCalled();
});
});
@@ -1,4 +1,6 @@
import { Box, InlineField, LinkButton, Space, Stack, Text } from '@grafana/ui';
import { css } from '@emotion/css';
import { Box, Icon, LinkButton, Space, Stack, Text, useStyles2 } from '@grafana/ui';
import { CONFIG_SECTION_HEADERS, CONFIG_SECTION_HEADERS_WITH_PDC } from './constants';
@@ -8,29 +10,40 @@ interface LeftSideBarProps {
export const LeftSideBar = ({ pdcInjected }: LeftSideBarProps) => {
const headers = pdcInjected ? CONFIG_SECTION_HEADERS_WITH_PDC : CONFIG_SECTION_HEADERS;
const styles = useStyles2(getStyles);
return (
<Stack>
<Box flex={1} marginY={10}>
<Box height="75px"></Box>
<Text element="h4">InfluxDB</Text>
<Box flex={1} marginY={1}>
<Text element="h4">Connect data source</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>
<Icon name="circle" size="xs" />
<LinkButton
style={header.isOptional ? { padding: '5px 15px', height: '50px', width: '225px' } : {}}
variant="secondary"
fill="text"
onClick={(e) => {
e.preventDefault();
const target = document.getElementById(header.id);
if (target) {
const y = target.getBoundingClientRect().top + window.scrollY - 60;
window.scrollTo({ top: y, behavior: 'smooth' });
}
}}
>
<div className={styles.sidebarText}>
<div className={styles.sidebarLabel}>{header.label}</div>
{header.isOptional && (
<div className={styles.sidebarOptional}>
<Text color="secondary" variant="bodySmall">
optional
</Text>
</div>
)}
</div>
</LinkButton>
<Space v={1} />
</div>
))}
@@ -39,3 +52,27 @@ export const LeftSideBar = ({ pdcInjected }: LeftSideBarProps) => {
</Stack>
);
};
const getStyles = () => ({
inlineField: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}),
sidebarText: css({
display: 'flex',
flexDirection: 'column',
}),
sidebarLabel: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 0,
lineHeight: 1,
}),
sidebarOptional: css({
marginTop: 0,
marginBottom: 0,
lineHeight: 1,
}),
});
@@ -1,4 +1,16 @@
const backendSrv = {
fetch: jest.fn(),
} as unknown as BackendSrv;
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv,
}));
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { of } from 'rxjs';
import { BackendSrv } from '@grafana/runtime';
import { InfluxVersion } from '../../../types';
@@ -11,6 +23,7 @@ describe('UrlAndAuthenticationSection', () => {
const defaultProps = createTestProps({
options: {
id: 1234,
jsonData: {
url: 'http://localhost:8086',
product: '',
@@ -24,6 +37,29 @@ describe('UrlAndAuthenticationSection', () => {
},
});
const mockFetchPing = ({ build, version, status = 204 }: { build?: string; version?: string; status?: number }) => {
backendSrv.fetch = jest.fn().mockReturnValue(
of({
status,
ok: status >= 200 && status < 300,
data: status === 204 ? '' : {},
headers: {
get: (k: string) => {
const key = k.toLowerCase();
if (key === 'x-influxdb-build') {
return build ?? null;
}
if (key === 'x-influxdb-version') {
return version ?? null;
}
return null;
},
},
url: '/api/datasources/proxy/1234/ping',
})
);
};
beforeEach(() => {
// Mock console.error to suppress React act() warnings
consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
@@ -239,7 +275,7 @@ describe('UrlAndAuthenticationSection', () => {
},
};
mockFetchPing({ ok: true, build: 'OSS', version: '1.8.10' });
mockFetchPing({ build: 'OSS', version: '1.8.10' });
render(<UrlAndAuthenticationSection {...props} />);
const input = screen.getByTestId('influxdb-v2-config-url-input');
@@ -268,7 +304,7 @@ describe('UrlAndAuthenticationSection', () => {
},
};
mockFetchPing({ ok: true, build: 'OSS', version: '2.7.1' });
mockFetchPing({ build: 'OSS', version: '2.7.1' });
render(<UrlAndAuthenticationSection {...props} />);
const input = screen.getByTestId('influxdb-v2-config-url-input');
@@ -297,7 +333,7 @@ describe('UrlAndAuthenticationSection', () => {
},
};
mockFetchPing({ ok: true, build: undefined, version: undefined });
mockFetchPing({ build: undefined, version: undefined });
render(<UrlAndAuthenticationSection {...props} />);
const input = screen.getByTestId('influxdb-v2-config-url-input');
@@ -358,23 +394,3 @@ describe('UrlAndAuthenticationSection', () => {
});
});
});
export function mockFetchPing(resp: { ok?: boolean; build?: string; version?: string } = {}) {
const { ok = true, build, version } = resp;
global.fetch = jest.fn().mockResolvedValue({
ok,
headers: {
get: (key: string) => {
const normalized = key.toLowerCase();
if (normalized === 'x-influxdb-build') {
return build ?? null;
}
if (normalized === 'x-influxdb-version') {
return version ?? null;
}
return null;
},
},
});
}
@@ -1,4 +1,8 @@
import { css } from '@emotion/css';
import { firstValueFrom } from 'rxjs';
import { onUpdateDatasourceJsonDataOptionSelect, onUpdateDatasourceOption } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import {
Box,
CollapsableSection,
@@ -11,6 +15,7 @@ import {
Text,
ComboboxOption,
Alert,
useStyles2,
} from '@grafana/ui';
import { InfluxVersion } from '../../../types';
@@ -33,6 +38,7 @@ const getQueryLanguageOptions = (productName: string): Array<{ value: string }>
export const UrlAndAuthenticationSection = (props: Props) => {
const { options, onOptionsChange } = props;
const styles = useStyles2(getStyles);
const isInfluxVersion = (v: string): v is InfluxVersion =>
typeof v === 'string' && (v === InfluxVersion.Flux || v === InfluxVersion.InfluxQL || v === InfluxVersion.SQL);
@@ -68,13 +74,30 @@ export const UrlAndAuthenticationSection = (props: Props) => {
};
const pingInfluxForProductDetection = async (urlValue: string) => {
const base = urlValue.replace(/\/$/, '');
const dsId = options.id;
if (!dsId) {
return;
}
try {
const res = await fetch(`${base}/ping`);
const res = await firstValueFrom(
getBackendSrv().fetch({
method: 'GET',
url: `/api/datasources/proxy/${dsId}/ping`,
headers: { Accept: 'application/json' },
responseType: 'text',
showErrorAlert: false,
showSuccessAlert: false,
})
);
if (res.ok) {
const product = res.headers.get('x-influxdb-build') ?? undefined;
const version = res.headers.get('x-influxdb-version') ?? undefined;
let product: string | undefined;
let version: string | undefined;
if (res.headers && typeof res.headers.get === 'function') {
product = res.headers.get('x-influxdb-build') ?? undefined;
version = res.headers.get('x-influxdb-version') ?? undefined;
}
if (product || version) {
return { product, version };
@@ -136,21 +159,19 @@ export const UrlAndAuthenticationSection = (props: Props) => {
borderStyle="solid"
borderColor="weak"
padding={2}
marginBottom={4}
id={`${CONFIG_SECTION_HEADERS[0].id}`}
minWidth={CONTAINER_MIN_WIDTH}
>
<CollapsableSection
label={<Text element="h3">1. {CONFIG_SECTION_HEADERS[0].label}</Text>}
label={<Text element="h3">{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.
</Text>
<Box direction="column" gap={2} marginTop={3}>
<Field label={<div style={{ marginBottom: '5px' }}>URL *</div>} noMargin required>
<Box direction="column" marginTop={3}>
<Field label="URL" noMargin required>
<Input
data-testid="influxdb-v2-config-url-input"
placeholder="example: http://localhost:8086/"
@@ -162,34 +183,59 @@ export const UrlAndAuthenticationSection = (props: Props) => {
}}
/>
</Field>
<Box marginTop={2}>
<Stack direction="row" gap={2}>
<Box flex={1}>
<Field label={<div style={{ marginBottom: '5px' }}>Product *</div>} noMargin required>
<Combobox
data-testid="influxdb-v2-config-product-select"
value={options.jsonData.product}
options={INFLUXDB_VERSION_MAP.map(({ name }) => ({ value: name }))}
onChange={onProductChange}
/>
</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}
/>
</Field>
</Box>
<Stack direction="row" wrap="wrap" justifyContent="space-between">
<div className={styles.col}>
<Box width="100%" minWidth={37}>
<Field
label="Product"
description={
<div className={styles.dropdown}>
<Text color="secondary">
Use{' '}
<TextLink
href="https://docs.influxdata.com/influxdb3/enterprise/visualize-data/grafana/?section=influxdb3%252Fenterprise%252Fvisualize-data&detection_method=url_analysis"
variant="bodySmall"
external
>
InfluxDB detection
</TextLink>{' '}
to identify the product
</Text>
</div>
}
noMargin
required
>
<Combobox
data-testid="influxdb-v2-config-product-select"
value={options.jsonData.product}
options={INFLUXDB_VERSION_MAP.map(({ name }) => ({ value: name }))}
onChange={onProductChange}
/>
</Field>
</Box>
</div>
<div className={styles.col}>
<Box width="100%" minWidth={37}>
<Field
label="Query language"
description={<div className={styles.dropdown}>The query language depends on product selection</div>}
noMargin
required
>
<Combobox
data-testid="influxdb-v2-config-query-language-select"
value={options.jsonData.product !== '' ? options.jsonData.version : ''}
options={getQueryLanguageOptions(options.jsonData.product || '')}
onChange={onQueryLanguageChange}
/>
</Field>
</Box>
</div>
</Stack>
</Box>
<Space v={2} />
{requiresDbrpMapping && (
<Alert severity="warning" title="InfluxQL requires DBRP mapping">
{`${options.jsonData.product} requires a Database + Retention Policy (DBRP) mapping via the CLI or
@@ -199,7 +245,6 @@ export const UrlAndAuthenticationSection = (props: Props) => {
</TextLink>
</Alert>
)}
<AdvancedHttpSettings options={options} onOptionsChange={onOptionsChange} />
<AuthSettings options={options} onOptionsChange={onOptionsChange} />
</Box>
@@ -207,3 +252,20 @@ export const UrlAndAuthenticationSection = (props: Props) => {
</Box>
);
};
const getStyles = () => {
return {
dropdown: css({
display: 'flex',
alignItems: 'center',
height: '18px',
}),
col: css({
flex: '1 1 48%',
minWidth: '320px',
}),
'@media (max-width: 768px)': {
flexBasis: '100%',
},
};
};
@@ -17,16 +17,16 @@ export const AUTH_RADIO_BUTTON_OPTIONS = [
];
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 },
{ label: 'URL and authentication', id: 'url', isOpen: true, isOptional: false },
{ label: 'Database settings', id: 'db', isOpen: true, isOptional: false },
{ label: 'Save & test', id: `${selectors.pages.DataSource.saveAndTest}`, isOpen: true, isOptional: null },
];
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 },
{ label: 'URL and authentication', id: 'url', isOpen: true, isOptional: false },
{ label: 'Database settings', id: 'db', isOpen: true, isOptional: false },
{ label: 'Private data source connect', id: 'pdc', isOpen: false, isOptional: true },
{ label: 'Save & test', id: `${selectors.pages.DataSource.saveAndTest}`, isOpen: true, isOptional: null },
];
export const HTTP_MODES: ComboboxOption[] = [
@@ -34,7 +34,7 @@ export const HTTP_MODES: ComboboxOption[] = [
{ label: 'GET', value: 'GET' },
];
export const getInlineLabelStyles = (theme: GrafanaTheme2, transparent = false, width?: number | 'auto') => {
export const getInlineLabelStyles = (theme: GrafanaTheme2, transparent = false) => {
return {
label: css({
display: 'flex',
@@ -56,5 +56,5 @@ export const getInlineLabelStyles = (theme: GrafanaTheme2, transparent = false,
};
};
export const DB_SETTINGS_LABEL_WIDTH = 18;
export const CONTAINER_MIN_WIDTH = '450px';
export const DB_SETTINGS_LABEL_WIDTH = 22;
@@ -63,7 +63,8 @@ export const INFLUXDB_VERSION_MAP: InfluxDBProduct[] = [
],
detectionMethod: {
pingHeaderResponse: {
'x-influxdb-build': 'Enterprise (needs confirmation)',
'x-influxdb-version': '^v?1\\.',
'x-influxdb-build': 'Enterprise',
},
},
},
@@ -75,7 +76,8 @@ export const INFLUXDB_VERSION_MAP: InfluxDBProduct[] = [
],
detectionMethod: {
pingHeaderResponse: {
'x-influxdb-build': 'TBD',
'x-influxdb-version': '^v?3\\.',
'x-influxdb-build': 'Enterprise',
},
},
},
@@ -113,7 +115,7 @@ export const INFLUXDB_VERSION_MAP: InfluxDBProduct[] = [
detectionMethod: {
pingHeaderResponse: {
'x-influxdb-build': 'OSS',
'x-influxdb-version': '^1\\.',
'x-influxdb-version': '^v?1\\.',
},
},
},
@@ -134,7 +136,20 @@ export const INFLUXDB_VERSION_MAP: InfluxDBProduct[] = [
detectionMethod: {
pingHeaderResponse: {
'x-influxdb-build': 'OSS',
'x-influxdb-version': '^2\\.',
'x-influxdb-version': '^v?2\\.',
},
},
},
{
name: 'InfluxDB OSS 3.x',
queryLanguages: [
{ name: InfluxVersion.SQL, fields: ['URL', 'Token'] },
{ name: InfluxVersion.InfluxQL, fields: ['URL', 'Token'] },
],
detectionMethod: {
pingHeaderResponse: {
'x-influxdb-build': 'OSS',
'x-influxdb-version': '^v?3\\.',
},
},
},