Skip to content

Commit 22a8e56

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 b0121f2 commit 22a8e56

File tree

5 files changed

+290
-98
lines changed

5 files changed

+290
-98
lines changed

prometheus/src/components/Settings/Settings.tsx

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,29 @@ import Switch from '@mui/material/Switch';
1212
import TextField from '@mui/material/TextField';
1313
import Typography from '@mui/material/Typography';
1414
import { useEffect, useState } from 'react';
15+
import { isHttpUrl } from '../../helpers';
1516

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

2840
/**
@@ -107,17 +119,30 @@ export function Settings(props: SettingsProps) {
107119
setTestMessage(t('Testing Connection'));
108120

109121
try {
110-
const [namespace, serviceAndPort] = selectedClusterData.address.split('/');
111-
const [service, port] = serviceAndPort.split(':');
122+
const address = selectedClusterData.address.trim().replace(/\/$/, '');
123+
const normalizeSubPath = (value: string) => {
124+
const trimmed = value.trim().replace(/^\/+|\/+$/g, '');
125+
return trimmed ? `/${trimmed}` : '';
126+
};
127+
const subPath = normalizeSubPath(selectedClusterData.subPath || '');
112128

113-
let subPath = selectedClusterData.subPath || '';
114-
if (subPath && !subPath.startsWith('/')) {
115-
subPath = '/' + subPath;
129+
if (isHttpUrl(address)) {
130+
// External URL: direct fetch
131+
const url = new URL(address);
132+
const basePath = url.pathname.replace(/\/+$/g, '');
133+
url.pathname = `${basePath}${subPath}/-/healthy`;
134+
const response = await fetch(url.toString(), { method: 'GET' });
135+
if (!response.ok) {
136+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
137+
}
138+
} else {
139+
// Kubernetes service proxy: namespace/service:port
140+
const [namespace, serviceAndPort] = address.split('/');
141+
const [service, port] = serviceAndPort.split(':');
142+
const proxyUrl = `/clusters/${selectedCluster}/api/v1/namespaces/${namespace}/services/${service}:${port}/proxy${subPath}/-/healthy`;
143+
await request(proxyUrl);
116144
}
117145

118-
const proxyUrl = `/clusters/${selectedCluster}/api/v1/namespaces/${namespace}/services/${service}:${port}/proxy${subPath}/-/healthy`;
119-
await request(proxyUrl);
120-
121146
setTestStatus('success');
122147
setTestMessage(t('Connection successful!'));
123148
} catch (err) {
@@ -167,18 +192,18 @@ export function Settings(props: SettingsProps) {
167192
),
168193
},
169194
{
170-
name: t('Prometheus Service Address'),
195+
name: t('Prometheus Address'),
171196
value: (
172197
<Box display="flex" flexDirection="column" width="100%">
173198
<Box display="flex" gap={2} alignItems="flex-start">
174199
<TextField
175200
disabled={!isAddressFieldEnabled}
176201
helperText={
177202
addressError
178-
? t('Invalid format. Use: namespace/service-name:port')
203+
? t('Invalid format. Use: namespace/service-name:port or https://prometheus.example.com')
179204
: t(
180-
'Address of the Prometheus Service, only used when auto-detection is disabled. Format: namespace/service-name:port'
181-
)
205+
'Prometheus address. Used only when auto-detection is disabled. Examples: namespace/service-name:port or https://prometheus.example.com'
206+
)
182207
}
183208
error={addressError}
184209
value={selectedClusterData.address || ''}
@@ -220,13 +245,13 @@ export function Settings(props: SettingsProps) {
220245
),
221246
},
222247
{
223-
name: t('Prometheus Service Subpath'),
248+
name: t('Prometheus Subpath'),
224249
value: (
225250
<TextField
226251
value={selectedClusterData.subPath || ''}
227252
disabled={!isAddressFieldEnabled}
228253
helperText={t(
229-
"Optional subpath to the Prometheus Service endpoint. Only used when auto-detection is disabled. Examples: 'prometheus'."
254+
"Optional subpath to the Prometheus endpoint. Only used when auto-detection is disabled. Examples: 'prometheus'."
230255
)}
231256
onChange={e => {
232257
const newSubPath = e.target.value;

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: 51 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 in the form {namespace}/{pods|services}/{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,57 @@ 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+
const baseUrl = new URL(data.prefix);
319+
320+
// Build the path segments
321+
const pathSegments = [baseUrl.pathname.replace(/\/$/, '')];
322+
if (data.subPath && data.subPath !== '') {
323+
pathSegments.push(data.subPath.replace(/^\/|\/$/g, ''));
324+
}
325+
pathSegments.push('api/v1/query_range');
326+
baseUrl.pathname = pathSegments.join('/');
327+
328+
// Merge query params (preserve any existing params in the URL)
329+
params.forEach((value, key) => {
330+
baseUrl.searchParams.set(key, value);
331+
});
332+
333+
url = baseUrl.toString();
334+
} else {
335+
url = `/api/v1/namespaces/${data.prefix}/proxy/api/v1/query_range?${params.toString()}`;
336+
if (data.subPath && data.subPath !== '') {
337+
if (data.subPath.startsWith('/')) {
338+
data.subPath = data.subPath.slice(1);
339+
}
340+
if (data.subPath.endsWith('/')) {
341+
data.subPath = data.subPath.slice(0, -1);
342+
}
343+
url = `/api/v1/namespaces/${data.prefix}/proxy/${
344+
data.subPath
345+
}/api/v1/query_range?${params.toString()}`;
316346
}
317-
if (data.subPath.endsWith('/')) {
318-
data.subPath = data.subPath.slice(0, -1);
347+
}
348+
349+
if (isExternal) {
350+
const response = await fetch(url, { method: 'GET' });
351+
if (response.ok) {
352+
return response.json();
353+
}
354+
let message = `Request failed with status ${response.status} ${response.statusText}`;
355+
try {
356+
const bodyText = await response.text();
357+
if (bodyText) {
358+
message += `: ${bodyText}`;
359+
}
360+
} catch {
361+
// Ignore errors while reading the error body; fallback to status-only message.
319362
}
320-
url = `/api/v1/namespaces/${data.prefix}/proxy/${
321-
data.subPath
322-
}/api/v1/query_range?${params.toString()}`;
363+
throw new Error(message);
323364
}
324365

325366
const response = await request(url, {

0 commit comments

Comments
 (0)