Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions prometheus/src/components/Settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,29 @@ import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { isHttpUrl } from '../../util';

/**
* Validates if the given address string is in the correct format.
* The format should be: namespace/service:port
* Validates whether the given address is in a supported format.
* Supports namespace/service:port and HTTP/HTTPS URLs.
* Examples: monitoring/prometheus:9090, https://prometheus.example.com
*
* @param {string} address - The address string to validate.
* @returns {boolean} True if the address is valid, false otherwise.
*/
function isValidAddress(address: string): boolean {
const regex = /^[a-z0-9-]+\/[a-z0-9-]+:[0-9]+$/;
return regex.test(address);
if (!address) return true;

const value = address.trim();

// namespace/service:port
const k8sRegex = /^[a-z0-9-]+\/[a-z0-9-]+:[0-9]+$/;
if (k8sRegex.test(value)) {
return true;
}

// http(s)://...
return isHttpUrl(value);
}

/**
Expand Down Expand Up @@ -173,8 +185,8 @@ export function Settings(props: SettingsProps) {
disabled={!isAddressFieldEnabled}
helperText={
addressError
? 'Invalid format. Use: namespace/service-name:port'
: 'Address of the Prometheus Service, only used when auto-detection is disabled. Format: namespace/service-name:port'
? 'Invalid format. Use: namespace/service-name:port or https://prometheus.example.com'
: 'Address of Prometheus. Used only when auto-detection is disabled. Examples: namespace/service-name:port or https://prometheus.example.com'
}
error={addressError}
value={selectedClusterData.address || ''}
Expand Down
50 changes: 40 additions & 10 deletions prometheus/src/request.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
import { isHttpUrl } from './util';

const request = ApiProxy.request;

Expand Down Expand Up @@ -280,7 +281,7 @@ async function testPrometheusQuery(
/**
* Fetches metrics data from Prometheus using the provided parameters.
* @param {object} data - The parameters for fetching metrics.
* @param {string} data.prefix - The namespace prefix.
* @param {string} data.prefix - Either a Kubernetes proxy prefix (namespace/services/service-name:port) or a full HTTP/HTTPS Prometheus base URL.
* @param {string} data.query - The Prometheus query string.
* @param {number} data.from - The start time for the query (Unix timestamp).
* @param {number} data.to - The end time for the query (Unix timestamp).
Expand Down Expand Up @@ -309,17 +310,46 @@ export async function fetchMetrics(data: {
if (data.query) {
params.append('query', data.query);
}
var url = `/api/v1/namespaces/${data.prefix}/proxy/api/v1/query_range?${params.toString()}`;
if (data.subPath && data.subPath !== '') {
if (data.subPath.startsWith('/')) {
data.subPath = data.subPath.slice(1);

const isExternal = isHttpUrl(data.prefix);
var url: string;

Comment on lines 309 to +316
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation of fetchMetrics now supports both in-cluster Kubernetes proxy prefixes and full HTTP/HTTPS URLs (via isHttpUrl), but the JSDoc above still documents data.prefix only as a "namespace prefix". To keep the API contract clear for future callers, consider updating the JSDoc to explicitly describe that prefix can be either a Kubernetes proxy prefix (e.g. namespace/services/service:port) or a full Prometheus base URL, and how that interacts with the optional subPath.

Copilot uses AI. Check for mistakes.
if (isExternal) {
let base = data.prefix;
if (base.endsWith('/')) {
base = base.slice(0, -1);
}
if (data.subPath.endsWith('/')) {
data.subPath = data.subPath.slice(0, -1);

let apiPath = 'api/v1/query_range';
if (data.subPath && data.subPath !== '') {
if (data.subPath.startsWith('/')) {
data.subPath = data.subPath.slice(1);
}
if (data.subPath.endsWith('/')) {
data.subPath = data.subPath.slice(0, -1);
}
apiPath = `${data.subPath}/api/v1/query_range`;
}
url = `/api/v1/namespaces/${data.prefix}/proxy/${
data.subPath
}/api/v1/query_range?${params.toString()}`;

url = `${base}/${apiPath}?${params.toString()}`;
} else {
url = `/api/v1/namespaces/${data.prefix}/proxy/api/v1/query_range?${params.toString()}`;
if (data.subPath && data.subPath !== '') {
if (data.subPath.startsWith('/')) {
data.subPath = data.subPath.slice(1);
}
if (data.subPath.endsWith('/')) {
data.subPath = data.subPath.slice(0, -1);
}
url = `/api/v1/namespaces/${data.prefix}/proxy/${
data.subPath
}/api/v1/query_range?${params.toString()}`;
}
}

if (isExternal) {
const response = await fetch(url, { method: 'GET' });
return response.json();
}

const response = await request(url, {
Expand Down
38 changes: 37 additions & 1 deletion prometheus/src/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getTimeRangeAndStepSize } from './util';
import { isPrometheusInstalled, KubernetesType } from './request';
import { getClusterConfig, getTimeRangeAndStepSize } from './util';

beforeAll(async () => {
global.TextEncoder = require('util').TextEncoder;
Expand Down Expand Up @@ -103,3 +104,38 @@ describe('getTimeRangeAndStepSize', () => {
});
});
});

export async function getPrometheusPrefix(clusterName: string): Promise<string | null> {
const clusterData = getClusterConfig(clusterName);

// 1. Handle Auto-Detection logic
if (clusterData?.autoDetect) {
const prometheusEndpoint = await isPrometheusInstalled();

if (prometheusEndpoint.type === KubernetesType.none) {
return null;
}

const portStr = prometheusEndpoint.port ? `:${prometheusEndpoint.port}` : '';
return `${prometheusEndpoint.namespace}/${prometheusEndpoint.type}/${prometheusEndpoint.name}${portStr}`;
}

// 2. Handle Manual Address configuration
if (clusterData?.address) {
const address = clusterData.address.trim().replace(/\/$/, '');

// Handle full external URLs
if (address.startsWith('http://') || address.startsWith('https://')) {
return address;
}

// Handle Kubernetes shorthand (namespace/service)
const parts = address.split('/');
if (parts.length === 2) {
const [namespace, service] = parts;
return `${namespace}/services/${service}`;
}
}

return null;
}
44 changes: 37 additions & 7 deletions prometheus/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ export function getConfigStore(): ConfigStore<Conf> {
return new ConfigStore<Conf>(PLUGIN_NAME);
}

/**
* Checks whether the given string is a valid HTTP or HTTPS URL.
*
* @param {string} value - The value to validate.
* @returns {boolean} True if the value is a valid http/https URL, otherwise false.
*/
export function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}

/**
* getClusterConfig returns the configuration for a specific cluster.
* @param {string} cluster - The name of the cluster.
Expand Down Expand Up @@ -100,27 +115,42 @@ export function isMetricsEnabled(cluster: string): boolean {
}

/**
* getPrometheusPrefix returns the prefix for the Prometheus metrics.
* Resolves the base address for accessing Prometheus for a cluster.
* Supports full HTTP/HTTPS URLs or `namespace/service` Kubernetes service proxy paths.
*
* @param {string} cluster - The name of the cluster.
* @returns {Promise<string | null>} The prefix for the Prometheus metrics, or null if not found.
*/
export async function getPrometheusPrefix(cluster: string): Promise<string | null> {
// check if cluster has autoDetect enabled
// if so return the prometheus pod address
const clusterData = getClusterConfig(cluster);

// 1. Manual address (external URL or service)
if (clusterData?.address) {
const address = clusterData.address.trim().replace(/\/$/, '');

if (address.startsWith('http://') || address.startsWith('https://')) {
return address;
}

const parts = address.split('/');
if (parts.length === 2) {
const [namespace, service] = parts;
return `${namespace}/services/${service}`;
}
}

// 2. Auto-detect (restore previous behavior)
if (clusterData?.autoDetect) {
const prometheusEndpoint = await isPrometheusInstalled();
if (prometheusEndpoint.type === KubernetesType.none) {
return null;
}

const prometheusPortStr = prometheusEndpoint.port ? `:${prometheusEndpoint.port}` : '';

return `${prometheusEndpoint.namespace}/${prometheusEndpoint.type}/${prometheusEndpoint.name}${prometheusPortStr}`;
}

if (clusterData?.address) {
const [namespace, service] = clusterData?.address.split('/');
return `${namespace}/services/${service}`;
}
return null;
}

Expand Down
Loading