diff --git a/public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.tsx b/public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.tsx index fd107fb15ad..508a9b5617d 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/config-v2/ConfigEditor.tsx @@ -62,7 +62,7 @@ const getStyles = (theme: GrafanaTheme2) => { }, }), alertHeight: css({ - width: '100px', + height: '100px', }), }; }; diff --git a/public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.test.tsx b/public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.test.tsx index 9e72fbb491e..2f6704ea66a 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.test.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { InfluxVersion } from '../../../types'; @@ -7,6 +7,7 @@ import { createTestProps } from './helpers'; describe('UrlAndAuthenticationSection', () => { const onOptionsChangeMock = jest.fn(); + let consoleSpy: jest.SpyInstance; const defaultProps = createTestProps({ options: { @@ -24,9 +25,15 @@ describe('UrlAndAuthenticationSection', () => { }); beforeEach(() => { + // Mock console.error to suppress React act() warnings + consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); jest.clearAllMocks(); }); + afterEach(() => { + consoleSpy.mockRestore(); + }); + it('calls onOptionsChange when URL is changed', () => { render(); @@ -87,4 +94,287 @@ describe('UrlAndAuthenticationSection', () => { render(); expect(screen.queryByText(/requires DBRP mapping/i)).not.toBeInTheDocument(); }); + + it('leaves product and version undefined when URL does not match any product', async () => { + const props = { + ...defaultProps, + options: { + ...defaultProps.options, + jsonData: { ...defaultProps.options.jsonData, url: undefined }, + }, + }; + + render(); + + const input = screen.getByTestId('influxdb-v2-config-url-input'); + onOptionsChangeMock.mockClear(); + fireEvent.blur(input, { target: { value: 'https://some-random-host.example.com' } }); + + await waitFor(() => { + expect(onOptionsChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + jsonData: expect.objectContaining({ + product: undefined, + version: undefined, + }), + }) + ); + }); + }); + + it('auto-detects InfluxDB Cloud Dedicated from url', async () => { + const props = { + ...defaultProps, + options: { + ...defaultProps.options, + jsonData: { ...defaultProps.options.jsonData, url: '' }, + }, + }; + + render(); + + const input = screen.getByTestId('influxdb-v2-config-url-input'); + onOptionsChangeMock.mockClear(); + fireEvent.blur(input, { target: { value: 'influxdb.io' } }); + + await waitFor(() => { + expect(onOptionsChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + jsonData: expect.objectContaining({ + product: 'InfluxDB Cloud Dedicated', + version: undefined, + }), + }) + ); + }); + }); + + it('auto-detects InfluxDB Cloud Serverless from url', async () => { + const props = { + ...defaultProps, + options: { + ...defaultProps.options, + jsonData: { ...defaultProps.options.jsonData, url: '' }, + }, + }; + + render(); + const input = screen.getByTestId('influxdb-v2-config-url-input'); + + onOptionsChangeMock.mockClear(); + fireEvent.blur(input, { target: { value: 'https://us-east-1-1.aws.cloud2.influxdata.com' } }); + + await waitFor(() => { + expect(onOptionsChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + jsonData: expect.objectContaining({ + product: 'InfluxDB Cloud Serverless', + version: undefined, + }), + }) + ); + }); + }); + + it('auto-detects InfluxDB Cloud (TSM) from url', async () => { + const props = { + ...defaultProps, + options: { + ...defaultProps.options, + jsonData: { ...defaultProps.options.jsonData, url: '' }, + }, + }; + + render(); + const input = screen.getByTestId('influxdb-v2-config-url-input'); + + onOptionsChangeMock.mockClear(); + fireEvent.blur(input, { target: { value: 'https://us-west-2-1.aws.cloud2.influxdata.com' } }); + + await waitFor(() => { + expect(onOptionsChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + jsonData: expect.objectContaining({ + product: 'InfluxDB Cloud (TSM)', + version: undefined, + }), + }) + ); + }); + }); + + it('auto-detects InfluxDB Cloud 1 from url', async () => { + const props = { + ...defaultProps, + options: { + ...defaultProps.options, + jsonData: { ...defaultProps.options.jsonData, url: '' }, + }, + }; + + render(); + const input = screen.getByTestId('influxdb-v2-config-url-input'); + + onOptionsChangeMock.mockClear(); + fireEvent.blur(input, { target: { value: 'https://influxcloud.net' } }); + + await waitFor(() => { + expect(onOptionsChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + jsonData: expect.objectContaining({ + product: 'InfluxDB Cloud 1', + version: undefined, + }), + }) + ); + }); + }); + + it('sets product to OSS 1.x when ping returns a match for OSS 1.x', async () => { + const props = { + ...defaultProps, + options: { + ...defaultProps.options, + jsonData: { ...defaultProps.options.jsonData, url: '' }, + }, + }; + + mockFetchPing({ ok: true, build: 'OSS', version: '1.8.10' }); + + render(); + const input = screen.getByTestId('influxdb-v2-config-url-input'); + + onOptionsChangeMock.mockClear(); + fireEvent.blur(input, { target: { value: 'https://someinfluxoss1url.com' } }); + + await waitFor(() => { + expect(onOptionsChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + jsonData: expect.objectContaining({ + product: 'InfluxDB OSS 1.x', + version: undefined, + }), + }) + ); + }); + }); + + it('sets product to OSS 2.x when ping returns a match for OSS 2.x', async () => { + const props = { + ...defaultProps, + options: { + ...defaultProps.options, + jsonData: { ...defaultProps.options.jsonData, url: '' }, + }, + }; + + mockFetchPing({ ok: true, build: 'OSS', version: '2.7.1' }); + + render(); + const input = screen.getByTestId('influxdb-v2-config-url-input'); + + onOptionsChangeMock.mockClear(); + fireEvent.blur(input, { target: { value: 'https://someinfluxoss2url.com' } }); + + await waitFor(() => { + expect(onOptionsChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + jsonData: expect.objectContaining({ + product: 'InfluxDB OSS 2.x', + version: undefined, + }), + }) + ); + }); + }); + + it('sets product as undefined if ping does not return a match', async () => { + const props = { + ...defaultProps, + options: { + ...defaultProps.options, + jsonData: { ...defaultProps.options.jsonData, url: '' }, + }, + }; + + mockFetchPing({ ok: true, build: undefined, version: undefined }); + + render(); + const input = screen.getByTestId('influxdb-v2-config-url-input'); + + onOptionsChangeMock.mockClear(); + fireEvent.blur(input, { target: { value: 'https://no-known-pattern.example.com' } }); + + await waitFor(() => { + expect(onOptionsChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + jsonData: expect.objectContaining({ + product: undefined, + version: undefined, + }), + }) + ); + }); + }); + + it('clears product and version when URL changes to one without a match', async () => { + const props = { + ...defaultProps, + options: { + ...defaultProps.options, + jsonData: { ...defaultProps.options.jsonData, url: '' }, + }, + }; + + render(); + const input = screen.getByTestId('influxdb-v2-config-url-input'); + + onOptionsChangeMock.mockClear(); + fireEvent.blur(input, { target: { value: 'https://us-east-1-1.aws.cloud2.influxdata.com' } }); + + await waitFor(() => { + expect(onOptionsChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + jsonData: expect.objectContaining({ + product: 'InfluxDB Cloud Serverless', + version: undefined, + }), + }) + ); + }); + + onOptionsChangeMock.mockClear(); + fireEvent.blur(input, { target: { value: 'https://influxdb.example.com' } }); + + await waitFor(() => { + expect(onOptionsChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + jsonData: expect.objectContaining({ + product: undefined, + version: undefined, + }), + }) + ); + }); + }); }); + +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; + }, + }, + }); +} diff --git a/public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.tsx b/public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.tsx index f489be1aa7c..5926ea6703a 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/config-v2/UrlAndAuthenticationSection.tsx @@ -24,7 +24,7 @@ import { trackInfluxDBConfigV2URLInputField, } from './tracking'; import { Props } from './types'; -import { INFLUXDB_VERSION_MAP } from './versions'; +import { INFLUXDB_VERSION_MAP, InfluxDBProduct } from './versions'; const getQueryLanguageOptions = (productName: string): Array<{ value: string }> => { const product = INFLUXDB_VERSION_MAP.find(({ name }) => name === productName); @@ -63,7 +63,73 @@ export const UrlAndAuthenticationSection = (props: Props) => { } }; - const onUrlChange = (event: React.ChangeEvent) => onUpdateDatasourceOption(props, 'url')(event); + const onUrlChange = (event: React.ChangeEvent) => { + onUpdateDatasourceOption(props, 'url')(event); + }; + + const pingInfluxForProductDetection = async (urlValue: string) => { + const base = urlValue.replace(/\/$/, ''); + + try { + const res = await fetch(`${base}/ping`); + if (res.ok) { + const product = res.headers.get('x-influxdb-build') ?? undefined; + const version = res.headers.get('x-influxdb-version') ?? undefined; + + if (product || version) { + return { product, version }; + } + } + } catch (err) { + console.error('Failed to get InfluxDB version:', err); + } + + return { product: undefined, version: undefined }; + }; + + const matchUrlContains = async (urlValue: string) => { + let product: InfluxDBProduct | undefined; + product = INFLUXDB_VERSION_MAP.find((product: InfluxDBProduct) => { + if (product.detectionMethod?.urlContains) { + return product.detectionMethod.urlContains.some((url) => { + return urlValue.includes(url); + }); + } + return false; + }); + + if (!product) { + const pingUrl = await pingInfluxForProductDetection(urlValue); + + if (pingUrl) { + product = INFLUXDB_VERSION_MAP.find((product: InfluxDBProduct) => { + if (product.detectionMethod?.pingHeaderResponse) { + const productBuild = product.detectionMethod.pingHeaderResponse['x-influxdb-build']; + const productVersion = product.detectionMethod.pingHeaderResponse['x-influxdb-version']; + const pingUrlVersion = pingUrl.version ?? ''; + const pingUrlBuild = pingUrl.product ?? ''; + const versionMatch = new RegExp(productVersion).test(pingUrlVersion); + const buildMatch = pingUrlBuild.includes(productBuild); + return versionMatch && buildMatch; + } + return false; + }); + } + } + + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + product: product ? product.name : undefined, + version: undefined, + }, + }); + }; + + const detectProductFromUrl = (event: React.ChangeEvent) => { + matchUrlContains(event.target.value); + }; return ( { placeholder="example: http://localhost:8086/" onChange={onUrlChange} value={options.url || ''} - onBlur={trackInfluxDBConfigV2URLInputField} + onBlur={(e) => { + detectProductFromUrl(e); + trackInfluxDBConfigV2URLInputField(); + }} /> diff --git a/public/app/plugins/datasource/influxdb/components/editor/config-v2/versions.ts b/public/app/plugins/datasource/influxdb/components/editor/config-v2/versions.ts index 7cc16af1fb8..f5dc31a75d3 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/config-v2/versions.ts +++ b/public/app/plugins/datasource/influxdb/components/editor/config-v2/versions.ts @@ -14,7 +14,7 @@ interface DetectionMethod { urlContains?: string[]; pingHeaderResponse?: Record; } -interface InfluxDBProduct { +export interface InfluxDBProduct { name: string; queryLanguages?: QueryLanguageConfig[]; detectionMethod?: DetectionMethod;