feat: add Prometheus system monitoring
This commit is contained in:
@@ -12,6 +12,7 @@ import { DictionariesModule } from './dictionaries/dictionaries.module';
|
||||
import { DeletionGovernanceModule } from './deletion-governance/deletion-governance.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { InfrastructureMonitoringModule } from './infrastructure-monitoring/infrastructure-monitoring.module';
|
||||
import { OperationsModule } from './operations/operations.module';
|
||||
import { OpenApiModule } from './open-api/open-api.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
@@ -49,6 +50,7 @@ import { SignatureRetirementModule } from './signature-retirement/signature-reti
|
||||
ReportMaterialsModule,
|
||||
SendChainModule,
|
||||
OperationsModule,
|
||||
InfrastructureMonitoringModule,
|
||||
OpenApiModule,
|
||||
SignatureRetirementModule,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
export type InfrastructureMonitoringRange = '1h' | '24h' | '7d';
|
||||
|
||||
export type InfrastructureMetricPoint = {
|
||||
timestamp: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type InfrastructureServiceStatus = {
|
||||
key: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
};
|
||||
|
||||
export type InfrastructureAlert = {
|
||||
fingerprint: string;
|
||||
name: string;
|
||||
severity: 'info' | 'warning' | 'critical';
|
||||
status: string;
|
||||
startedAt: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
currentValue?: string;
|
||||
threshold?: string;
|
||||
service?: string;
|
||||
instance?: string;
|
||||
};
|
||||
|
||||
export type InfrastructureMonitoringOverview = {
|
||||
available: boolean;
|
||||
range: InfrastructureMonitoringRange;
|
||||
collectedAt: string;
|
||||
lastSampleAt: string | null;
|
||||
error?: string;
|
||||
summary: {
|
||||
overallStatus: 'healthy' | 'warning' | 'critical' | 'unknown';
|
||||
serviceTotal: number;
|
||||
serviceHealthy: number;
|
||||
warningAlerts: number;
|
||||
criticalAlerts: number;
|
||||
activeAlerts: number;
|
||||
};
|
||||
metrics: {
|
||||
cpuUsagePercent: number | null;
|
||||
memoryUsagePercent: number | null;
|
||||
memoryTotalBytes: number | null;
|
||||
memoryAvailableBytes: number | null;
|
||||
diskUsagePercent: number | null;
|
||||
diskTotalBytes: number | null;
|
||||
diskAvailableBytes: number | null;
|
||||
networkReceiveBytesPerSecond: number | null;
|
||||
networkTransmitBytesPerSecond: number | null;
|
||||
load1: number | null;
|
||||
uptimeSeconds: number | null;
|
||||
};
|
||||
trends: {
|
||||
cpuUsagePercent: InfrastructureMetricPoint[];
|
||||
memoryUsagePercent: InfrastructureMetricPoint[];
|
||||
diskUsagePercent: InfrastructureMetricPoint[];
|
||||
networkReceiveBytesPerSecond: InfrastructureMetricPoint[];
|
||||
networkTransmitBytesPerSecond: InfrastructureMetricPoint[];
|
||||
};
|
||||
services: InfrastructureServiceStatus[];
|
||||
alerts: InfrastructureAlert[];
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
|
||||
|
||||
@ApiTags('infrastructure-monitoring')
|
||||
@Controller('admin/infrastructure-monitoring')
|
||||
export class InfrastructureMonitoringController {
|
||||
constructor(private readonly monitoring: InfrastructureMonitoringService) {}
|
||||
|
||||
@Get('overview')
|
||||
overview(@Query('range') range?: string) {
|
||||
return this.monitoring.overview(range);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InfrastructureMonitoringController } from './infrastructure-monitoring.controller';
|
||||
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
|
||||
|
||||
@Module({
|
||||
controllers: [InfrastructureMonitoringController],
|
||||
providers: [InfrastructureMonitoringService],
|
||||
})
|
||||
export class InfrastructureMonitoringModule {}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InfrastructureMonitoringService } from './infrastructure-monitoring.service';
|
||||
|
||||
function success(data: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ status: 'success', data }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe('InfrastructureMonitoringService', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('rejects ranges outside the fixed whitelist before querying Prometheus', async () => {
|
||||
const fetchSpy = jest.spyOn(global, 'fetch');
|
||||
const service = new InfrastructureMonitoringService(new ConfigService());
|
||||
|
||||
await expect(service.overview('30d')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects credential-bearing or remote plaintext Prometheus endpoints at startup', () => {
|
||||
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'http://user:secret@127.0.0.1:9090' }))).toThrow('must not contain credentials');
|
||||
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'http://monitor.example.com:9090' }))).toThrow('must use HTTPS');
|
||||
expect(() => new InfrastructureMonitoringService(new ConfigService({ PROMETHEUS_URL: 'https://monitor.example.com' }))).not.toThrow();
|
||||
});
|
||||
|
||||
it('loads real Prometheus vectors, ranges, services and active alerts', async () => {
|
||||
const requestedUrls: URL[] = [];
|
||||
jest.spyOn(global, 'fetch').mockImplementation(async (input) => {
|
||||
const url = new URL(String(input));
|
||||
requestedUrls.push(url);
|
||||
if (url.pathname.endsWith('/alerts')) {
|
||||
return success({ alerts: [{
|
||||
labels: { alertname: 'HostCpuHigh', severity: 'warning', instance: '127.0.0.1:9100' },
|
||||
annotations: { summary: 'CPU持续偏高', threshold: '85%' },
|
||||
state: 'firing',
|
||||
activeAt: '2026-08-14T03:00:00.000Z',
|
||||
value: '88.2',
|
||||
}] });
|
||||
}
|
||||
const query = url.searchParams.get('query') ?? '';
|
||||
if (url.pathname.endsWith('/query_range')) {
|
||||
return success({ result: [{ metric: {}, values: [[1_765_000_000, '12.5'], [1_765_000_060, '14.5']] }] });
|
||||
}
|
||||
if (query.includes('node_systemd_unit_state')) {
|
||||
return success({ result: [
|
||||
{ metric: { name: 'cmpp-api.service' }, value: [1_765_000_060, '1'] },
|
||||
{ metric: { name: 'cmpp-gateway.service' }, value: [1_765_000_060, '1'] },
|
||||
{ metric: { name: 'postgresql.service' }, value: [1_765_000_060, '1'] },
|
||||
{ metric: { name: 'redis-server.service' }, value: [1_765_000_060, '1'] },
|
||||
{ metric: { name: 'cmpp-minio.service' }, value: [1_765_000_060, '1'] },
|
||||
{ metric: { name: 'nginx.service' }, value: [1_765_000_060, '1'] },
|
||||
] });
|
||||
}
|
||||
if (query.includes('timestamp(node_uname_info)')) {
|
||||
return success({ result: [{ metric: {}, value: [1_765_000_060, '1765000060'] }] });
|
||||
}
|
||||
return success({ result: [{ metric: {}, value: [1_765_000_060, '25'] }] });
|
||||
});
|
||||
|
||||
const service = new InfrastructureMonitoringService(new ConfigService());
|
||||
const result = await service.overview('1h');
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.metrics.cpuUsagePercent).toBe(25);
|
||||
expect(result.trends.cpuUsagePercent).toHaveLength(2);
|
||||
expect(result.summary).toMatchObject({ overallStatus: 'warning', serviceHealthy: 6, warningAlerts: 1 });
|
||||
expect(result.services.find((item) => item.key === 'redis')).toMatchObject({ unit: 'redis-server.service', status: 'healthy' });
|
||||
expect(result.alerts[0]).toMatchObject({ name: 'HostCpuHigh', severity: 'warning', currentValue: '88.2' });
|
||||
expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range'))).toHaveLength(5);
|
||||
expect(requestedUrls.filter((url) => url.pathname.endsWith('/query_range')).every((url) => url.searchParams.get('step') === '60')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an explicit unavailable payload without stale metrics when Prometheus fails', async () => {
|
||||
jest.spyOn(global, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const service = new InfrastructureMonitoringService(new ConfigService());
|
||||
|
||||
const result = await service.overview('24h');
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.summary.overallStatus).toBe('unknown');
|
||||
expect(result.metrics.cpuUsagePercent).toBeNull();
|
||||
expect(result.trends.cpuUsagePercent).toEqual([]);
|
||||
expect(result.error).not.toContain('ECONNREFUSED');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type {
|
||||
InfrastructureAlert,
|
||||
InfrastructureMetricPoint,
|
||||
InfrastructureMonitoringOverview,
|
||||
InfrastructureMonitoringRange,
|
||||
InfrastructureServiceStatus,
|
||||
} from './infrastructure-monitoring.contracts';
|
||||
|
||||
type PrometheusSample = [number, string];
|
||||
type PrometheusSeries = {
|
||||
metric: Record<string, string>;
|
||||
value?: PrometheusSample;
|
||||
values?: PrometheusSample[];
|
||||
};
|
||||
type PrometheusQueryResponse = {
|
||||
status: 'success' | 'error';
|
||||
data?: { result?: PrometheusSeries[] };
|
||||
error?: string;
|
||||
};
|
||||
type PrometheusAlertResponse = {
|
||||
status: 'success' | 'error';
|
||||
data?: {
|
||||
alerts?: Array<{
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
state?: string;
|
||||
activeAt?: string;
|
||||
value?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const RANGE_CONFIG: Record<InfrastructureMonitoringRange, { seconds: number; step: number }> = {
|
||||
'1h': { seconds: 60 * 60, step: 60 },
|
||||
'24h': { seconds: 24 * 60 * 60, step: 300 },
|
||||
'7d': { seconds: 7 * 24 * 60 * 60, step: 1800 },
|
||||
};
|
||||
|
||||
const QUERIES = {
|
||||
cpuUsagePercent: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)',
|
||||
memoryUsagePercent: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100',
|
||||
memoryTotalBytes: 'node_memory_MemTotal_bytes',
|
||||
memoryAvailableBytes: 'node_memory_MemAvailable_bytes',
|
||||
diskUsagePercent: '(1 - (node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"})) * 100',
|
||||
diskTotalBytes: 'node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}',
|
||||
diskAvailableBytes: 'node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}',
|
||||
networkReceiveBytesPerSecond: 'sum(rate(node_network_receive_bytes_total{device!~"lo"}[5m]))',
|
||||
networkTransmitBytesPerSecond: 'sum(rate(node_network_transmit_bytes_total{device!~"lo"}[5m]))',
|
||||
load1: 'node_load1',
|
||||
uptimeSeconds: 'time() - node_boot_time_seconds',
|
||||
lastSampleAt: 'max(timestamp(node_uname_info))',
|
||||
services: 'max by (name) (node_systemd_unit_state{name=~"cmpp-api\\.service|cmpp-gateway\\.service|postgresql\\.service|redis(-server)?\\.service|cmpp-minio\\.service|nginx\\.service",state="active"})',
|
||||
} as const;
|
||||
|
||||
const SERVICE_DEFINITIONS = [
|
||||
{ key: 'api', name: 'API服务', units: ['cmpp-api.service'] },
|
||||
{ key: 'gateway', name: 'Gateway服务', units: ['cmpp-gateway.service'] },
|
||||
{ key: 'postgresql', name: 'PostgreSQL', units: ['postgresql.service'] },
|
||||
{ key: 'redis', name: 'Redis', units: ['redis.service', 'redis-server.service'] },
|
||||
{ key: 'minio', name: 'MinIO', units: ['cmpp-minio.service'] },
|
||||
{ key: 'nginx', name: 'Nginx', units: ['nginx.service'] },
|
||||
] as const;
|
||||
|
||||
function finiteNumber(value: string | number | undefined): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizePrometheusUrl(rawValue: unknown) {
|
||||
const url = new URL(String(rawValue ?? 'http://127.0.0.1:9090'));
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('PROMETHEUS_URL must use HTTP or HTTPS');
|
||||
if (url.username || url.password) throw new Error('PROMETHEUS_URL must not contain credentials');
|
||||
const privateIpv4 = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(url.hostname);
|
||||
const loopback = url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]';
|
||||
// Plain HTTP is only safe on loopback or an explicit RFC1918 address; named remote endpoints must use HTTPS.
|
||||
if (url.protocol === 'http:' && !loopback && !privateIpv4) throw new Error('Remote PROMETHEUS_URL must use HTTPS');
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function vectorValue(response: PrometheusQueryResponse): number | null {
|
||||
return finiteNumber(response.data?.result?.[0]?.value?.[1]);
|
||||
}
|
||||
|
||||
function matrixValues(response: PrometheusQueryResponse): InfrastructureMetricPoint[] {
|
||||
return (response.data?.result?.[0]?.values ?? []).flatMap(([timestamp, value]) => {
|
||||
const parsed = finiteNumber(value);
|
||||
return parsed === null ? [] : [{ timestamp: new Date(timestamp * 1000).toISOString(), value: parsed }];
|
||||
});
|
||||
}
|
||||
|
||||
function emptyMetrics(): InfrastructureMonitoringOverview['metrics'] {
|
||||
return {
|
||||
cpuUsagePercent: null,
|
||||
memoryUsagePercent: null,
|
||||
memoryTotalBytes: null,
|
||||
memoryAvailableBytes: null,
|
||||
diskUsagePercent: null,
|
||||
diskTotalBytes: null,
|
||||
diskAvailableBytes: null,
|
||||
networkReceiveBytesPerSecond: null,
|
||||
networkTransmitBytesPerSecond: null,
|
||||
load1: null,
|
||||
uptimeSeconds: null,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyTrends(): InfrastructureMonitoringOverview['trends'] {
|
||||
return {
|
||||
cpuUsagePercent: [],
|
||||
memoryUsagePercent: [],
|
||||
diskUsagePercent: [],
|
||||
networkReceiveBytesPerSecond: [],
|
||||
networkTransmitBytesPerSecond: [],
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InfrastructureMonitoringService {
|
||||
private readonly prometheusUrl: string;
|
||||
private readonly queryTimeoutMs: number;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.prometheusUrl = normalizePrometheusUrl(config.get('PROMETHEUS_URL'));
|
||||
this.queryTimeoutMs = Math.min(15_000, Math.max(1_000, Number(config.get('PROMETHEUS_QUERY_TIMEOUT_MS') ?? 5_000)));
|
||||
}
|
||||
|
||||
async overview(rawRange?: string): Promise<InfrastructureMonitoringOverview> {
|
||||
const range = this.parseRange(rawRange);
|
||||
const collectedAt = new Date().toISOString();
|
||||
try {
|
||||
const [instant, trends, serviceResponse, alertResponse] = await Promise.all([
|
||||
this.loadInstantMetrics(),
|
||||
this.loadTrends(range),
|
||||
this.query(QUERIES.services),
|
||||
this.getJson<PrometheusAlertResponse>('/api/v1/alerts'),
|
||||
]);
|
||||
const services = this.parseServices(serviceResponse);
|
||||
const alerts = this.parseAlerts(alertResponse);
|
||||
const warningAlerts = alerts.filter((item) => item.severity === 'warning').length;
|
||||
const criticalAlerts = alerts.filter((item) => item.severity === 'critical').length;
|
||||
const overallStatus = criticalAlerts > 0 ? 'critical' : warningAlerts > 0 ? 'warning' : 'healthy';
|
||||
return {
|
||||
available: true,
|
||||
range,
|
||||
collectedAt,
|
||||
lastSampleAt: instant.lastSampleAt === null ? null : new Date(instant.lastSampleAt * 1000).toISOString(),
|
||||
summary: {
|
||||
overallStatus,
|
||||
serviceTotal: services.length,
|
||||
serviceHealthy: services.filter((item) => item.status === 'healthy').length,
|
||||
warningAlerts,
|
||||
criticalAlerts,
|
||||
activeAlerts: alerts.length,
|
||||
},
|
||||
metrics: instant.metrics,
|
||||
trends,
|
||||
services,
|
||||
alerts,
|
||||
};
|
||||
} catch {
|
||||
return this.unavailable(range, collectedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private parseRange(value?: string): InfrastructureMonitoringRange {
|
||||
const range = value || '24h';
|
||||
if (!(range in RANGE_CONFIG)) throw new BadRequestException('监控时间范围只支持1h、24h或7d');
|
||||
return range as InfrastructureMonitoringRange;
|
||||
}
|
||||
|
||||
private async loadInstantMetrics() {
|
||||
const keys = Object.keys(emptyMetrics()) as Array<keyof InfrastructureMonitoringOverview['metrics']>;
|
||||
const responses = await Promise.all([...keys.map((key) => this.query(QUERIES[key])), this.query(QUERIES.lastSampleAt)]);
|
||||
const metrics = emptyMetrics();
|
||||
keys.forEach((key, index) => { metrics[key] = vectorValue(responses[index]); });
|
||||
return { metrics, lastSampleAt: vectorValue(responses[responses.length - 1]) };
|
||||
}
|
||||
|
||||
private async loadTrends(range: InfrastructureMonitoringRange) {
|
||||
const config = RANGE_CONFIG[range];
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
const start = end - config.seconds;
|
||||
const keys = Object.keys(emptyTrends()) as Array<keyof InfrastructureMonitoringOverview['trends']>;
|
||||
const responses = await Promise.all(keys.map((key) => this.queryRange(QUERIES[key], start, end, config.step)));
|
||||
return Object.fromEntries(keys.map((key, index) => [key, matrixValues(responses[index])])) as InfrastructureMonitoringOverview['trends'];
|
||||
}
|
||||
|
||||
private parseServices(response: PrometheusQueryResponse): InfrastructureServiceStatus[] {
|
||||
const values = new Map<string, number>();
|
||||
for (const item of response.data?.result ?? []) {
|
||||
if (item.metric.name) values.set(item.metric.name, vectorValue({ status: 'success', data: { result: [item] } }) ?? 0);
|
||||
}
|
||||
return SERVICE_DEFINITIONS.map((definition) => {
|
||||
const present = definition.units.filter((unit) => values.has(unit));
|
||||
const status = present.length === 0 ? 'unknown' : present.some((unit) => (values.get(unit) ?? 0) >= 1) ? 'healthy' : 'unhealthy';
|
||||
return { key: definition.key, name: definition.name, unit: present[0] ?? definition.units[0], status };
|
||||
});
|
||||
}
|
||||
|
||||
private parseAlerts(response: PrometheusAlertResponse): InfrastructureAlert[] {
|
||||
return (response.data?.alerts ?? [])
|
||||
.filter((item) => item.state === 'firing' || item.state === 'pending')
|
||||
.map<InfrastructureAlert>((item) => {
|
||||
const labels = item.labels ?? {};
|
||||
const annotations = item.annotations ?? {};
|
||||
const severity: InfrastructureAlert['severity'] = labels.severity === 'critical' ? 'critical' : labels.severity === 'warning' ? 'warning' : 'info';
|
||||
const identity = JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)));
|
||||
return {
|
||||
fingerprint: createHash('sha256').update(identity).digest('hex').slice(0, 24),
|
||||
name: labels.alertname || '未命名告警',
|
||||
severity,
|
||||
status: item.state || 'unknown',
|
||||
startedAt: item.activeAt || new Date().toISOString(),
|
||||
summary: annotations.summary || annotations.description || labels.alertname || '监控告警',
|
||||
description: annotations.description,
|
||||
currentValue: annotations.currentValue || item.value,
|
||||
threshold: annotations.threshold,
|
||||
service: labels.service,
|
||||
instance: labels.instance,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const priority: Record<InfrastructureAlert['severity'], number> = { critical: 0, warning: 1, info: 2 };
|
||||
return priority[left.severity] - priority[right.severity] || Date.parse(left.startedAt) - Date.parse(right.startedAt);
|
||||
});
|
||||
}
|
||||
|
||||
private unavailable(range: InfrastructureMonitoringRange, collectedAt: string): InfrastructureMonitoringOverview {
|
||||
const services = SERVICE_DEFINITIONS.map((item) => ({ key: item.key, name: item.name, unit: item.units[0], status: 'unknown' as const }));
|
||||
return {
|
||||
available: false,
|
||||
range,
|
||||
collectedAt,
|
||||
lastSampleAt: null,
|
||||
error: 'Prometheus监控数据当前不可用,请检查采集与服务状态',
|
||||
summary: { overallStatus: 'unknown', serviceTotal: services.length, serviceHealthy: 0, warningAlerts: 0, criticalAlerts: 0, activeAlerts: 0 },
|
||||
metrics: emptyMetrics(),
|
||||
trends: emptyTrends(),
|
||||
services,
|
||||
alerts: [],
|
||||
};
|
||||
}
|
||||
|
||||
private query(query: string) {
|
||||
return this.getJson<PrometheusQueryResponse>('/api/v1/query', { query });
|
||||
}
|
||||
|
||||
private queryRange(query: string, start: number, end: number, step: number) {
|
||||
return this.getJson<PrometheusQueryResponse>('/api/v1/query_range', { query, start: String(start), end: String(end), step: String(step) });
|
||||
}
|
||||
|
||||
private async getJson<T extends { status: 'success' | 'error'; error?: string }>(path: string, params: Record<string, string> = {}): Promise<T> {
|
||||
const url = new URL(`${this.prometheusUrl}${path}`);
|
||||
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
|
||||
const response = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(this.queryTimeoutMs) });
|
||||
if (!response.ok) throw new Error(`Prometheus HTTP ${response.status}`);
|
||||
const result = await response.json() as T;
|
||||
if (result.status !== 'success') throw new Error('Prometheus query failed');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user