[InfluxDB] Detect product from URL (#110137)

This commit is contained in:
Alyssa Joyner
2025-08-29 09:13:26 -06:00
committed by GitHub
parent a71664c114
commit 125b56b8f5
4 changed files with 365 additions and 6 deletions
@@ -62,7 +62,7 @@ const getStyles = (theme: GrafanaTheme2) => {
},
}),
alertHeight: css({
width: '100px',
height: '100px',
}),
};
};
@@ -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(<UrlAndAuthenticationSection {...defaultProps} />);
@@ -87,4 +94,287 @@ describe('UrlAndAuthenticationSection', () => {
render(<UrlAndAuthenticationSection {...props} />);
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(<UrlAndAuthenticationSection {...props} />);
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(<UrlAndAuthenticationSection {...props} />);
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(<UrlAndAuthenticationSection {...props} />);
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(<UrlAndAuthenticationSection {...props} />);
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(<UrlAndAuthenticationSection {...props} />);
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(<UrlAndAuthenticationSection {...props} />);
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(<UrlAndAuthenticationSection {...props} />);
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(<UrlAndAuthenticationSection {...props} />);
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(<UrlAndAuthenticationSection {...props} />);
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;
},
},
});
}
@@ -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<HTMLInputElement>) => onUpdateDatasourceOption(props, 'url')(event);
const onUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
matchUrlContains(event.target.value);
};
return (
<Box
@@ -90,7 +156,10 @@ export const UrlAndAuthenticationSection = (props: Props) => {
placeholder="example: http://localhost:8086/"
onChange={onUrlChange}
value={options.url || ''}
onBlur={trackInfluxDBConfigV2URLInputField}
onBlur={(e) => {
detectProductFromUrl(e);
trackInfluxDBConfigV2URLInputField();
}}
/>
</Field>
@@ -14,7 +14,7 @@ interface DetectionMethod {
urlContains?: string[];
pingHeaderResponse?: Record<string, string>;
}
interface InfluxDBProduct {
export interface InfluxDBProduct {
name: string;
queryLanguages?: QueryLanguageConfig[];
detectionMethod?: DetectionMethod;