[InfluxDB] Detect product from URL (#110137)
This commit is contained in:
@@ -62,7 +62,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
},
|
||||
}),
|
||||
alertHeight: css({
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
+291
-1
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+72
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user