From 125b56b8f589fa0dfa147100fdd0eeee2810273c Mon Sep 17 00:00:00 2001
From: Alyssa Joyner <58453566+alyssajoyner@users.noreply.github.com>
Date: Fri, 29 Aug 2025 09:13:26 -0600
Subject: [PATCH] [InfluxDB] Detect product from URL (#110137)
---
.../editor/config-v2/ConfigEditor.tsx | 2 +-
.../UrlAndAuthenticationSection.test.tsx | 292 +++++++++++++++++-
.../config-v2/UrlAndAuthenticationSection.tsx | 75 ++++-
.../components/editor/config-v2/versions.ts | 2 +-
4 files changed, 365 insertions(+), 6 deletions(-)
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;