Skip to content

Commit 8835ec2

Browse files
Tusharjamdadeskoeva
authored andcommitted
prometheus: Add support for external Prometheus URLs
Co-authored-by: Tusharjamdade <tusharnjamdade@gmail.com> Signed-off-by: Evangelos Skopelitis <eskopelitis@microsoft.com>
1 parent 2ee3841 commit 8835ec2

File tree

5 files changed

+263
-90
lines changed

5 files changed

+263
-90
lines changed

prometheus/src/components/Settings/Settings.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,29 @@ import Switch from '@mui/material/Switch';
1111
import TextField from '@mui/material/TextField';
1212
import Typography from '@mui/material/Typography';
1313
import { useEffect, useState } from 'react';
14+
import { isHttpUrl } from '../../helpers';
1415

1516
/**
16-
* Validates if the given address string is in the correct format.
17-
* The format should be: namespace/service:port
17+
* Validates whether the given address is in a supported format.
18+
* Supports namespace/service:port and HTTP/HTTPS URLs.
19+
* Examples: monitoring/prometheus:9090, https://prometheus.example.com
1820
*
1921
* @param {string} address - The address string to validate.
2022
* @returns {boolean} True if the address is valid, false otherwise.
2123
*/
2224
function isValidAddress(address: string): boolean {
23-
const regex = /^[a-z0-9-]+\/[a-z0-9-]+:[0-9]+$/;
24-
return regex.test(address);
25+
if (!address) return false;
26+
27+
const value = address.trim();
28+
29+
// namespace/service:port
30+
const k8sRegex = /^[a-z0-9-]+\/[a-z0-9-]+:[0-9]+$/;
31+
if (k8sRegex.test(value)) {
32+
return true;
33+
}
34+
35+
// http(s)://...
36+
return isHttpUrl(value);
2537
}
2638

2739
/**
@@ -173,8 +185,8 @@ export function Settings(props: SettingsProps) {
173185
disabled={!isAddressFieldEnabled}
174186
helperText={
175187
addressError
176-
? 'Invalid format. Use: namespace/service-name:port'
177-
: 'Address of the Prometheus Service, only used when auto-detection is disabled. Format: namespace/service-name:port'
188+
? 'Invalid format. Use: namespace/service-name:port or https://prometheus.example.com'
189+
: 'Address of Prometheus. Used only when auto-detection is disabled. Examples: namespace/service-name:port or https://prometheus.example.com'
178190
}
179191
error={addressError}
180192
value={selectedClusterData.address || ''}

prometheus/src/helpers.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Checks whether the given string is a valid HTTP or HTTPS URL.
3+
*
4+
* @param {string} value - The value to validate.
5+
* @returns {boolean} True if the value is a valid http/https URL, otherwise false.
6+
*/
7+
export function isHttpUrl(value: string): boolean {
8+
try {
9+
const url = new URL(value);
10+
return url.protocol === 'http:' || url.protocol === 'https:';
11+
} catch {
12+
return false;
13+
}
14+
}

prometheus/src/request.tsx

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
2+
import { isHttpUrl } from './helpers';
23

34
const request = ApiProxy.request;
45

@@ -280,7 +281,7 @@ async function testPrometheusQuery(
280281
/**
281282
* Fetches metrics data from Prometheus using the provided parameters.
282283
* @param {object} data - The parameters for fetching metrics.
283-
* @param {string} data.prefix - The namespace prefix.
284+
* @param {string} data.prefix - Either a Kubernetes proxy prefix (namespace/services/service-name:port) or a full HTTP/HTTPS Prometheus base URL.
284285
* @param {string} data.query - The Prometheus query string.
285286
* @param {number} data.from - The start time for the query (Unix timestamp).
286287
* @param {number} data.to - The end time for the query (Unix timestamp).
@@ -309,17 +310,58 @@ export async function fetchMetrics(data: {
309310
if (data.query) {
310311
params.append('query', data.query);
311312
}
312-
var url = `/api/v1/namespaces/${data.prefix}/proxy/api/v1/query_range?${params.toString()}`;
313-
if (data.subPath && data.subPath !== '') {
314-
if (data.subPath.startsWith('/')) {
315-
data.subPath = data.subPath.slice(1);
313+
314+
const isExternal = isHttpUrl(data.prefix);
315+
var url: string;
316+
317+
if (isExternal) {
318+
let base = data.prefix;
319+
if (base.endsWith('/')) {
320+
base = base.slice(0, -1);
321+
}
322+
323+
let apiPath = 'api/v1/query_range';
324+
if (data.subPath && data.subPath !== '') {
325+
if (data.subPath.startsWith('/')) {
326+
data.subPath = data.subPath.slice(1);
327+
}
328+
if (data.subPath.endsWith('/')) {
329+
data.subPath = data.subPath.slice(0, -1);
330+
}
331+
apiPath = `${data.subPath}/api/v1/query_range`;
316332
}
317-
if (data.subPath.endsWith('/')) {
318-
data.subPath = data.subPath.slice(0, -1);
333+
334+
url = `${base}/${apiPath}?${params.toString()}`;
335+
} else {
336+
url = `/api/v1/namespaces/${data.prefix}/proxy/api/v1/query_range?${params.toString()}`;
337+
if (data.subPath && data.subPath !== '') {
338+
if (data.subPath.startsWith('/')) {
339+
data.subPath = data.subPath.slice(1);
340+
}
341+
if (data.subPath.endsWith('/')) {
342+
data.subPath = data.subPath.slice(0, -1);
343+
}
344+
url = `/api/v1/namespaces/${data.prefix}/proxy/${
345+
data.subPath
346+
}/api/v1/query_range?${params.toString()}`;
347+
}
348+
}
349+
350+
if (isExternal) {
351+
const response = await fetch(url, { method: 'GET' });
352+
if (response.ok) {
353+
return response.json();
354+
}
355+
let message = `Request failed with status ${response.status} ${response.statusText}`;
356+
try {
357+
const bodyText = await response.text();
358+
if (bodyText) {
359+
message += `: ${bodyText}`;
360+
}
361+
} catch {
362+
// Ignore errors while reading the error body; fallback to status-only message.
319363
}
320-
url = `/api/v1/namespaces/${data.prefix}/proxy/${
321-
data.subPath
322-
}/api/v1/query_range?${params.toString()}`;
364+
throw new Error(message);
323365
}
324366

325367
const response = await request(url, {

prometheus/src/util.test.ts

Lines changed: 157 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,153 @@
1-
import { getTimeRangeAndStepSize } from './util';
1+
import { getTimeRangeAndStepSize, getPrometheusPrefix } from './util';
2+
import { isHttpUrl } from './helpers';
3+
import { KubernetesType } from './request';
24

3-
beforeAll(async () => {
5+
vitest.mock('./request', () => ({
6+
isPrometheusInstalled: vitest.fn(),
7+
KubernetesType: {
8+
none: 'none',
9+
services: 'services',
10+
pods: 'pods',
11+
},
12+
}));
13+
14+
vitest.mock('@kinvolk/headlamp-plugin/lib', () => ({
15+
ConfigStore: vitest.fn(),
16+
}));
17+
18+
import { isPrometheusInstalled } from './request';
19+
import { ConfigStore } from '@kinvolk/headlamp-plugin/lib';
20+
21+
const mockIsPrometheusInstalled = vitest.mocked(isPrometheusInstalled);
22+
const MockConfigStore = vitest.mocked(ConfigStore);
23+
24+
beforeAll(() => {
425
global.TextEncoder = require('util').TextEncoder;
526
global.TextDecoder = require('util').TextDecoder;
627
});
728

29+
function mockClusterConfig(clusterName: string, config: Record<string, unknown> | null) {
30+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
31+
(MockConfigStore as any).mockImplementation(() => ({
32+
get: () => (config ? { [clusterName]: config } : null),
33+
}));
34+
}
35+
36+
describe('isHttpUrl', () => {
37+
test.each([
38+
['http://prometheus.example.com', true],
39+
['https://prometheus.example.com', true],
40+
['http://prometheus.example.com:9090', true],
41+
['https://prometheus.example.com/api/v1', true],
42+
['', false],
43+
['monitoring/prometheus', false],
44+
['ftp://example.com', false],
45+
['not a url', false],
46+
])('isHttpUrl(%s) returns %s', (input, expected) => {
47+
expect(isHttpUrl(input)).toBe(expected);
48+
});
49+
});
50+
51+
describe('getPrometheusPrefix', () => {
52+
beforeEach(() => {
53+
vitest.clearAllMocks();
54+
});
55+
56+
describe('auto-detect mode', () => {
57+
test('returns detected endpoint when prometheus is found', async () => {
58+
mockClusterConfig('test-cluster', { autoDetect: true });
59+
mockIsPrometheusInstalled.mockResolvedValue({
60+
type: KubernetesType.services,
61+
namespace: 'monitoring',
62+
name: 'prometheus',
63+
port: '9090',
64+
});
65+
66+
const result = await getPrometheusPrefix('test-cluster');
67+
expect(result).toBe('monitoring/services/prometheus:9090');
68+
});
69+
70+
test('returns endpoint without port when port is empty', async () => {
71+
mockClusterConfig('test-cluster', { autoDetect: true });
72+
mockIsPrometheusInstalled.mockResolvedValue({
73+
type: KubernetesType.services,
74+
namespace: 'monitoring',
75+
name: 'prometheus',
76+
port: '',
77+
});
78+
79+
const result = await getPrometheusPrefix('test-cluster');
80+
expect(result).toBe('monitoring/services/prometheus');
81+
});
82+
83+
test('returns null when no prometheus is found', async () => {
84+
mockClusterConfig('test-cluster', { autoDetect: true });
85+
mockIsPrometheusInstalled.mockResolvedValue({
86+
type: KubernetesType.none,
87+
namespace: '',
88+
name: '',
89+
port: '',
90+
});
91+
92+
const result = await getPrometheusPrefix('test-cluster');
93+
expect(result).toBeNull();
94+
});
95+
96+
test('takes priority over manual address', async () => {
97+
mockClusterConfig('test-cluster', {
98+
autoDetect: true,
99+
address: 'https://manual-address.com',
100+
});
101+
mockIsPrometheusInstalled.mockResolvedValue({
102+
type: KubernetesType.services,
103+
namespace: 'monitoring',
104+
name: 'prometheus',
105+
port: '9090',
106+
});
107+
108+
const result = await getPrometheusPrefix('test-cluster');
109+
expect(result).toBe('monitoring/services/prometheus:9090');
110+
});
111+
});
112+
113+
describe('manual address mode', () => {
114+
test.each([
115+
['http://prometheus.example.com:9090', 'http://prometheus.example.com:9090'],
116+
['https://prometheus.example.com', 'https://prometheus.example.com'],
117+
['https://prometheus.example.com/', 'https://prometheus.example.com'], // strips trailing slash
118+
['monitoring/prometheus', 'monitoring/services/prometheus'], // k8s service path
119+
[' monitoring/prometheus ', 'monitoring/services/prometheus'], // trims whitespace
120+
])('address "%s" returns "%s"', async (address, expected) => {
121+
mockClusterConfig('test-cluster', { autoDetect: false, address });
122+
const result = await getPrometheusPrefix('test-cluster');
123+
expect(result).toBe(expected);
124+
});
125+
126+
test('returns null for invalid address format', async () => {
127+
mockClusterConfig('test-cluster', { autoDetect: false, address: 'invalid-format' });
128+
const result = await getPrometheusPrefix('test-cluster');
129+
expect(result).toBeNull();
130+
});
131+
});
132+
133+
describe('no configuration', () => {
134+
test('returns null when cluster config is null', async () => {
135+
mockClusterConfig('test-cluster', null);
136+
const result = await getPrometheusPrefix('test-cluster');
137+
expect(result).toBeNull();
138+
});
139+
140+
test('returns null when neither autoDetect nor address is set', async () => {
141+
mockClusterConfig('test-cluster', {});
142+
const result = await getPrometheusPrefix('test-cluster');
143+
expect(result).toBeNull();
144+
});
145+
});
146+
});
147+
8148
describe('getTimeRangeAndStepSize', () => {
9-
// Mock the current timestamp for consistent testing
10149
const mockNow = 1700000000;
11-
const day = 86400; // seconds in a day
150+
const day = 86400;
12151

13152
beforeEach(() => {
14153
vitest.spyOn(Date, 'now').mockImplementation(() => mockNow * 1000);
@@ -29,77 +168,26 @@ describe('getTimeRangeAndStepSize', () => {
29168
{
30169
from: mockNow - (mockNow % day),
31170
to: mockNow,
32-
step: Math.max(
33-
Math.floor(((mockNow - (mockNow - (mockNow % day))) * 1000) / 250 / 1000),
34-
1
35-
),
36-
},
37-
],
38-
[
39-
'yesterday',
40-
'medium',
41-
{
42-
from: mockNow - (mockNow % day) - day,
43-
to: mockNow - (mockNow % day),
44-
step: 345,
171+
step: Math.max(Math.floor(((mockNow - (mockNow - (mockNow % day))) * 1000) / 250 / 1000), 1),
45172
},
46173
],
174+
['yesterday', 'medium', { from: mockNow - (mockNow % day) - day, to: mockNow - (mockNow % day), step: 345 }],
47175
['week', 'medium', { from: mockNow - 7 * day, to: mockNow, step: 2419 }],
48-
[
49-
'lastweek',
50-
'medium',
51-
{
52-
from: mockNow - 14 * day,
53-
to: mockNow - 7 * day,
54-
step: 2419,
55-
},
56-
],
57-
58-
// Different resolutions with same interval
59-
['1h', 'low', { from: mockNow - 3600, to: mockNow, step: 36 }], // timeRange / 100
60-
['1h', 'medium', { from: mockNow - 3600, to: mockNow, step: 14 }], // timeRange / 250
61-
['1h', 'high', { from: mockNow - 3600, to: mockNow, step: 4 }], // timeRange / 750
62-
63-
// Fixed step sizes with same interval
176+
['lastweek', 'medium', { from: mockNow - 14 * day, to: mockNow - 7 * day, step: 2419 }],
177+
// Different resolutions
178+
['1h', 'low', { from: mockNow - 3600, to: mockNow, step: 36 }],
179+
['1h', 'medium', { from: mockNow - 3600, to: mockNow, step: 14 }],
180+
['1h', 'high', { from: mockNow - 3600, to: mockNow, step: 4 }],
181+
// Fixed step sizes
64182
['1h', '30s', { from: mockNow - 3600, to: mockNow, step: 30 }],
65183
['1h', '15m', { from: mockNow - 3600, to: mockNow, step: 900 }],
66184
['1h', '1h', { from: mockNow - 3600, to: mockNow, step: 3600 }],
67-
68185
// Edge cases
69-
['1m', 'medium', { from: mockNow - 60, to: mockNow, step: 1 }], // Minimum step size is 1
70-
['14d', 'medium', { from: mockNow - 14 * day, to: mockNow, step: 4838 }], // Large time range
71-
[
72-
'invalid', // Falls back to 10 minutes interval
73-
'medium',
74-
{
75-
from: mockNow - 600,
76-
to: mockNow,
77-
step: 2,
78-
},
79-
],
80-
[
81-
'1h',
82-
'invalid', // Falls back to medium resolution
83-
{ from: mockNow - 3600, to: mockNow, step: 14 },
84-
],
85-
])(
86-
'should return correct timeRange and stepSize for %s interval and %s resolution',
87-
(interval, resolution, expected) => {
88-
const result = getTimeRangeAndStepSize(interval, resolution);
89-
expect(result).toEqual(expected);
90-
}
91-
);
92-
93-
test('should handle different timestamps correctly', () => {
94-
// Test with a specific timestamp
95-
const specificTime = 1600000000;
96-
vitest.spyOn(Date, 'now').mockImplementation(() => specificTime * 1000);
97-
98-
const result = getTimeRangeAndStepSize('1h', 'medium');
99-
expect(result).toEqual({
100-
from: specificTime - 3600,
101-
to: specificTime,
102-
step: 14,
103-
});
186+
['1m', 'medium', { from: mockNow - 60, to: mockNow, step: 1 }],
187+
['14d', 'medium', { from: mockNow - 14 * day, to: mockNow, step: 4838 }],
188+
['invalid', 'medium', { from: mockNow - 600, to: mockNow, step: 2 }], // falls back to 10m
189+
['1h', 'invalid', { from: mockNow - 3600, to: mockNow, step: 14 }], // falls back to medium
190+
])('interval=%s resolution=%s', (interval, resolution, expected) => {
191+
expect(getTimeRangeAndStepSize(interval, resolution)).toEqual(expected);
104192
});
105193
});

0 commit comments

Comments
 (0)