swagger #38

Merged
VladislavD merged 4 commits from swagger into rc 2025-09-01 16:24:24 +03:00
13 changed files with 873 additions and 496 deletions

BIN
logs.txt Normal file

Binary file not shown.

View File

@ -1,10 +0,0 @@
export interface MenuItem {
id: string;
title: string;
items?: MenuItem[];
metric?: string;
filters?: {
device: string;
source_id: string;
};
}

View File

@ -1,4 +1,4 @@
import { Controller, Get, Post, Put, Body, Param, Headers, HttpException, HttpStatus } from '@nestjs/common'; import { Controller, Get, Post, Put, Body, Param, Headers, HttpException, HttpStatus, Delete } from '@nestjs/common';
import { MenuService } from './menu.service'; import { MenuService } from './menu.service';
import { MenuItem } from './menu.interface'; import { MenuItem } from './menu.interface';
@ -44,24 +44,72 @@ export class MenuController {
return { hasUpdates }; return { hasUpdates };
} }
@Post('save') // @Post('save')
async saveMenu() { // async saveMenu() {
await this.menuService.saveMenuToFile(); // await this.menuService.saveMenuToFile();
return { status: 'saved' }; // return { status: 'saved' };
} // }
@Post('overrides') // @Post('overrides')
async saveOverrides(@Body() data: { overrides: Partial<MenuItem>[] }) { // async saveOverrides(@Body() data: { overrides: Partial<MenuItem>[] }) {
await this.menuService.saveOverrides(data.overrides); // await this.menuService.saveOverrides(data.overrides);
return { status: 'success' }; // return { status: 'success' };
} // }
@Put(':id') @Put(':id')
async updateMenuItem( async updateMenuItem(
@Param('id') id: string, @Param('id') id: string,
@Body() update: Partial<MenuItem> @Body() update: Partial<MenuItem>
) { ) {
const updatedItem = await this.menuService.updateMenuItem(id, update); try {
return updatedItem; const updatedItem = await this.menuService.updateMenuItem(id, update);
return updatedItem;
} catch (error) {
throw new HttpException(
error.message || 'Failed to update menu item',
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
@Delete('items/:id')
async deleteMenuItem(@Param('id') id: string) {
console.log(`DELETE /menu/items/${id} requested`);
try {
await this.menuService.hideMenuItem(id);
console.log(`Item ${id} hidden successfully`);
return { success: true, message: 'Item hidden successfully' };
} catch (error) {
console.error(`Error hiding item ${id}:`, error);
throw new HttpException(
error.message || 'Failed to hide menu item',
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
@Post('invalidate-cache')
async invalidateCache() {
try {
this.menuService.invalidateCache();
return { success: true, message: 'Cache invalidated successfully' };
} catch (error) {
throw new HttpException(
error.message || 'Failed to invalidate cache',
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
@Get('debug/overrides')
async debugOverrides() {
try {
return { status: 'Debug endpoint not implemented' };
} catch (error) {
throw new HttpException(
error.message || 'Debug failed',
HttpStatus.INTERNAL_SERVER_ERROR
);
}
} }
} }

View File

@ -11,4 +11,5 @@ export interface MenuItem {
max: number; max: number;
status: number; status: number;
}>; }>;
hidden?: boolean;
} }

View File

@ -10,29 +10,28 @@ export class MenuService {
private menuCache: MenuItem | null = null; private menuCache: MenuItem | null = null;
private lastModified: Date | null = null; private lastModified: Date | null = null;
private cacheInitialized = false; private cacheInitialized = false;
private userOverrides: Map<string, Partial<MenuItem>> = new Map();
constructor( constructor(
private readonly prometheusService: PrometheusService, private readonly prometheusService: PrometheusService,
private readonly rangeService: RangeService private readonly rangeService: RangeService
) { } ) { }
private readonly menuOverridesPath = path.join(process.cwd(), 'data', 'menu.json');
async saveMenuToFile(): Promise<void> { private readonly userOverridesPath = path.join(process.cwd(), 'data', 'user_menu_overrides.json');
const { menu } = await this.getFullMenuWithCache();
await fs.mkdir(path.dirname(this.menuOverridesPath), { recursive: true });
await fs.writeFile(this.menuOverridesPath, JSON.stringify(menu, null, 2), 'utf-8');
}
async getFullMenuWithCache(ifModifiedSince?: string): Promise<{ menu: MenuItem; fresh: boolean }> { async getFullMenuWithCache(ifModifiedSince?: string): Promise<{ menu: MenuItem; fresh: boolean }> {
if (this.menuCache && this.lastModified && (!ifModifiedSince || new Date(ifModifiedSince) >= this.lastModified)) { if (this.menuCache && this.lastModified && (!ifModifiedSince || new Date(ifModifiedSince) >= this.lastModified)) {
return { menu: this.menuCache, fresh: false }; return { menu: this.menuCache, fresh: false };
} }
await this.loadUserOverrides();
const dynamicItemsPromise = this.generateDynamicItems(); const dynamicItemsPromise = this.generateDynamicItems();
const baseMenu = await this.injectDynamicItems(this.getStaticStructure(), dynamicItemsPromise); const baseMenu = await this.injectDynamicItems(this.getStaticStructure(), dynamicItemsPromise);
const overrides = await this.loadOverrides();
const freshMenu = this.applyOverrides(baseMenu, overrides); const freshMenu = this.applyUserOverrides(baseMenu);
this.menuCache = freshMenu; this.menuCache = freshMenu;
this.lastModified = new Date(); this.lastModified = new Date();
@ -48,33 +47,75 @@ export class MenuService {
return !this.lastModified || new Date(ifModifiedSince) < this.lastModified; return !this.lastModified || new Date(ifModifiedSince) < this.lastModified;
} }
private applyOverrides(menu: MenuItem, overrides: Partial<MenuItem>[]): MenuItem { async hideMenuItem(id: string): Promise<void> {
const overrideMap = new Map(overrides.map(o => [o.id, o])); this.userOverrides.set(id, { id, hidden: true });
await this.saveUserOverrides();
this.invalidateCache();
}
const apply = (item: MenuItem): MenuItem => { private applyUserOverrides(menu: MenuItem): MenuItem {
const override = overrideMap.get(item.id); const apply = (item: MenuItem): MenuItem | null => {
const updated = override ? { ...item, ...override } : item; const override = this.userOverrides.get(item.id);
if (override?.hidden) {
return null;
}
const updated: MenuItem = {
...item,
...override,
hidden: undefined
};
if (updated.items) { if (updated.items) {
updated.items = updated.items.map(apply); const processedItems = updated.items
.map(apply)
.filter((item): item is MenuItem => item !== null);
updated.items = processedItems.length > 0 ? processedItems : undefined;
} }
return updated; return updated;
}; };
return apply(menu); const result = apply(menu);
return result || { title: menu.title, id: menu.id, items: [] };
} }
private async loadOverrides(): Promise<Partial<MenuItem>[]> { private async loadUserOverrides(): Promise<void> {
try { try {
const content = await fs.readFile(this.menuOverridesPath, 'utf-8'); const content = await fs.readFile(this.userOverridesPath, 'utf-8');
const parsed = JSON.parse(content); const parsed = JSON.parse(content);
return parsed.overrides || [];
this.userOverrides = new Map(
(parsed.overrides || []).map(o => [o.id, o])
);
} catch (e) { } catch (e) {
return []; this.userOverrides = new Map();
await this.saveUserOverrides(); // Создаем файл с пустыми данными
} }
} }
private async saveUserOverrides(): Promise<void> {
try {
await fs.mkdir(path.dirname(this.userOverridesPath), { recursive: true });
const overridesArray = Array.from(this.userOverrides.values());
await fs.writeFile(
this.userOverridesPath,
JSON.stringify({ overrides: overridesArray }, null, 2),
'utf-8'
);
} catch (error) {
console.error('Error saving user overrides:', error);
throw new HttpException(
'Failed to save user preferences',
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
private getStaticStructure(): MenuItem { private getStaticStructure(): MenuItem {
return { return {
title: "ЗВКС", title: "ЗВКС",
@ -166,9 +207,12 @@ export class MenuService {
metadataMap: Map<string, string> metadataMap: Map<string, string>
): Promise<MenuItem> { ): Promise<MenuItem> {
const moduleItems = await this.generateModuleItems(device, seriesData, metadataMap); const moduleItems = await this.generateModuleItems(device, seriesData, metadataMap);
const deviceName = metadataMap.get(device) ?? device;
return { return {
id: `device_${device}`, id: `device_${device}`,
title: `Graviton S2082I (${device})`, title: deviceName,
items: moduleItems, items: moduleItems,
isDynamic: true isDynamic: true
}; };
@ -187,41 +231,175 @@ export class MenuService {
private normalizeIdPart(part: string): string { private normalizeIdPart(part: string): string {
return part return part
.replace(/\$/g, '_') .replace(/\$/g, '_')
.replace(/[^a-zA-Z0-9-_]/g, ''); .replace(/,/g, '_')
.replace(/\s+/g, '_')
.replace(/[^a-zA-Z0-9-_]/g, '')
.toLowerCase();
} }
// private async generateModuleItems(
// device: string,
// seriesData: { metric: string; labels: Record<string, string> }[],
// metadataMap: Map<string, string>
// ): Promise<MenuItem[]> {
// const modules = new Map<string, string>();
// seriesData.forEach(({ labels }) => {
// if (labels.device === device && labels.source_id) {
// const sourceId = labels.source_id;
// let displayName = sourceId;
// if (sourceId.startsWith('module$')) {
// displayName = `Module ${sourceId.split('$')[1]}`;
// } else if (sourceId.startsWith('port$')) {
// displayName = `Port ${sourceId.split('$')[1]}`;
// }
// modules.set(sourceId, displayName);
// }
// });
// const modulePromises = Array.from(modules.entries()).map(
// async ([sourceId, displayName]) => ({
// id: `module_${device}_${sourceId}`,
// title: displayName,
// items: await this.generateMetricItems(device, sourceId, seriesData, metadataMap),
// isDynamic: true
// })
// );
// return Promise.all(modulePromises);
// }
// private async generateMetricItems(
// device: string,
// module: string,
// seriesData: { metric: string; labels: Record<string, string> }[],
// metadataMap: Map<string, string>
// ): Promise<MenuItem[]> {
// const ranges = await this.rangeService.getRanges();
// const filtered = seriesData.filter(
// ({ labels }) => labels.device === device && labels.source_id === module
// );
// const uniqueMetrics = new Set(filtered.map(entry => entry.metric));
// const safeDevice = this.normalizeIdPart(device);
// const safeModule = this.normalizeIdPart(module);
// return Array.from(uniqueMetrics).map(metric => {
// const description = metadataMap.get(metric) || metric;
// const safeMetric = this.normalizeIdPart(metric);
// const metricRanges = ranges[description] || [];
// return {
// id: `metric_${safeDevice}_${safeModule}_${safeMetric}`,
// title: description,
// metric,
// filters: {
// device,
// source_id: module
// },
// ranges: metricRanges,
// isDynamic: true,
// meta: {
// originalDevice: device,
// originalModule: module
// }
// };
// });
// }
//ВРЕМЕННЫЙ КОСТЫЛЬ
private async generateModuleItems( private async generateModuleItems(
device: string, device: string,
seriesData: { metric: string; labels: Record<string, string> }[], seriesData: { metric: string; labels: Record<string, string> }[],
metadataMap: Map<string, string> metadataMap: Map<string, string>
): Promise<MenuItem[]> { ): Promise<MenuItem[]> {
const modules = new Map<string, string>(); const modules = new Map<string, string>();
const specialFolders = new Map<string, Map<string, string>>();
seriesData.forEach(({ labels }) => { seriesData.forEach(({ labels }) => {
if (labels.device === device && labels.source_id) { if (labels.device === device && labels.source_id) {
const sourceId = labels.source_id; const sourceId = this.normalizeSourceId(labels.source_id);
let displayName = sourceId;
if (sourceId.startsWith('module$')) { if (sourceId.includes(', complex') || sourceId.includes(', integration')) {
displayName = `Module ${sourceId.split('$')[1]}`; const [modulePart, folderType] = sourceId.split(', ').map(s => s.trim());
} else if (sourceId.startsWith('port$')) { let displayName = modulePart;
displayName = `Port ${sourceId.split('$')[1]}`;
if (modulePart.startsWith('module$')) {
displayName = `Module ${modulePart.split('$')[1]}`;
} else if (modulePart.startsWith('port$')) {
displayName = `Port ${modulePart.split('$')[1]}`;
} else if (modulePart === 'undefined') {
displayName = 'Unknown Module';
}
if (!specialFolders.has(folderType)) {
specialFolders.set(folderType, new Map());
}
specialFolders.get(folderType)!.set(modulePart, displayName);
}
else if (sourceId.endsWith('complex') || sourceId.endsWith('integration')) {
console.warn(`Ignoring legacy format: ${sourceId} for device ${device}`);
}
else {
let displayName = sourceId;
if (sourceId.startsWith('module$')) {
displayName = `Module ${sourceId.split('$')[1]}`;
} else if (sourceId.startsWith('port$')) {
displayName = `Port ${sourceId.split('$')[1]}`;
}
modules.set(sourceId, displayName);
} }
modules.set(sourceId, displayName);
} }
}); });
const modulePromises = Array.from(modules.entries()).map( const moduleItems = Array.from(modules.entries()).map(
async ([sourceId, displayName]) => ({ async ([sourceId, displayName]) => ({
id: `module_${device}_${sourceId}`, id: `module_${device}_${this.normalizeIdPart(sourceId)}`,
title: displayName, title: displayName,
items: await this.generateMetricItems(device, sourceId, seriesData, metadataMap), items: await this.generateMetricItems(device, sourceId, seriesData, metadataMap),
isDynamic: true isDynamic: true
}) })
); );
return Promise.all(modulePromises); const specialFolderItems = Array.from(specialFolders.entries()).map(
async ([folderType, folderModules]) => {
const folderItems = await Promise.all(
Array.from(folderModules.entries()).map(
async ([sourceId, displayName]) => ({
id: `module_${device}_${this.normalizeIdPart(sourceId)}_${this.normalizeIdPart(folderType)}`,
title: displayName,
items: await this.generateMetricItems(
device,
`${sourceId}, ${folderType}`,
seriesData,
metadataMap
),
isDynamic: true
})
)
);
return {
id: `folder_${device}_${this.normalizeIdPart(folderType)}`,
title: folderType,
items: folderItems,
isDynamic: true
};
}
);
return [
...(await Promise.all(moduleItems)),
...(await Promise.all(specialFolderItems))
];
}
private normalizeSourceId(raw: string): string {
return raw.split(',').map(s => s.trim()).filter(Boolean).join(', ');
} }
private async generateMetricItems( private async generateMetricItems(
@ -231,13 +409,33 @@ export class MenuService {
metadataMap: Map<string, string> metadataMap: Map<string, string>
): Promise<MenuItem[]> { ): Promise<MenuItem[]> {
const ranges = await this.rangeService.getRanges(); const ranges = await this.rangeService.getRanges();
const filtered = seriesData.filter(
({ labels }) => labels.device === device && labels.source_id === module const normModule = this.normalizeSourceId(module);
const isPlainModule = !normModule.includes(',');
let filtered = seriesData.filter(({ labels }) =>
labels.device === device &&
this.normalizeSourceId(labels.source_id || '') === normModule
); );
if (isPlainModule) {
const base = normModule;
const shadowSuffixes = ['integration', 'complex'];
filtered = filtered.filter(entry => {
return !shadowSuffixes.some(suffix =>
seriesData.some(s =>
s.metric === entry.metric &&
s.labels.device === device &&
this.normalizeSourceId(s.labels.source_id || '') === `${base}, ${suffix}`
)
);
});
}
const uniqueMetrics = new Set(filtered.map(entry => entry.metric)); const uniqueMetrics = new Set(filtered.map(entry => entry.metric));
const safeDevice = this.normalizeIdPart(device); const safeDevice = this.normalizeIdPart(device);
const safeModule = this.normalizeIdPart(module); const safeModule = this.normalizeIdPart(normModule);
return Array.from(uniqueMetrics).map(metric => { return Array.from(uniqueMetrics).map(metric => {
const description = metadataMap.get(metric) || metric; const description = metadataMap.get(metric) || metric;
@ -250,13 +448,13 @@ export class MenuService {
metric, metric,
filters: { filters: {
device, device,
source_id: module source_id: normModule
}, },
ranges: metricRanges, ranges: metricRanges,
isDynamic: true, isDynamic: true,
meta: { meta: {
originalDevice: device, originalDevice: device,
originalModule: module originalModule: normModule
} }
}; };
}); });
@ -283,21 +481,20 @@ export class MenuService {
} }
async updateMenuItem(id: string, update: Partial<MenuItem>): Promise<MenuItem> { async updateMenuItem(id: string, update: Partial<MenuItem>): Promise<MenuItem> {
const { menu: fullMenu } = await this.getFullMenuWithCache(); const existing = this.userOverrides.get(id) || { id };
const item = this.findMenuItem(fullMenu, id); this.userOverrides.set(id, { ...existing, ...update });
if (!item) throw new Error('Menu item not found'); await this.saveUserOverrides();
Object.assign(item, update); this.invalidateCache();
const { menu } = await this.getFullMenuWithCache();
const updated = this.findMenuItem(menu, id);
this.menuCache = null; if (!updated) {
return item; throw new HttpException('Updated item not found', HttpStatus.NOT_FOUND);
} }
async saveOverrides(overrides: Partial<MenuItem>[]): Promise<void> { return updated;
await fs.writeFile(this.menuOverridesPath, JSON.stringify({ overrides }, null, 2), 'utf-8');
this.menuCache = null;
} }
invalidateCache(): void { invalidateCache(): void {

5
src/prometheus/index.ts Normal file
View File

@ -0,0 +1,5 @@
// export * from './prometheus-metric.interface';
// export * from './prometheus.service';
// export * from './prometheus-cache.service';
// export * from './prometheus-query.service';
// export * from './prometheus-metadata.service';

View File

@ -1,23 +1,39 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { Server, WebSocket } from 'ws'; import { Server, WebSocket } from 'ws';
import { createServer } from 'http'; import { createServer } from 'http';
import { PrometheusService } from './prometheus.service'; import { PrometheusService } from './prometheus.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PrometheusMetric } from './prometheus-metric.interface';
type Filters = Record<string, string>; type Filters = Record<string, string>;
interface RealtimeSubscription {
clients: Set<string>;
interval: NodeJS.Timeout;
metric: string;
filters: Filters;
lastData: PrometheusMetric[];
}
interface HistoricalRequest {
client: WebSocket;
requestId: string;
}
@Injectable() @Injectable()
export class MetricsGateway implements OnModuleInit { export class MetricsGateway implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MetricsGateway.name); private readonly logger = new Logger(MetricsGateway.name);
private wss: Server; private wss: Server;
private httpServer: ReturnType<typeof createServer>;
private activeSockets = new Map<string, WebSocket>(); // Real-time подписки (одна на метрику, много клиентов)
private metricSubscriptions = new Map< private realtimeSubscriptions = new Map<string, RealtimeSubscription>();
string,
{ stopUpdates: () => void; clients: Set<string> }
>();
private lastSentData = new Map<string, any>(); // Активные клиенты
private activeClients = new Map<string, WebSocket>();
// Исторические запросы (для отслеживания)
private historicalRequests = new Map<string, HistoricalRequest>();
constructor( constructor(
private readonly prometheusService: PrometheusService, private readonly prometheusService: PrometheusService,
@ -25,367 +41,429 @@ export class MetricsGateway implements OnModuleInit {
) { } ) { }
onModuleInit() { onModuleInit() {
const httpServer = createServer(); this.httpServer = createServer();
this.wss = new Server({ this.wss = new Server({
server: httpServer, server: this.httpServer,
path: '/metrics-ws', path: '/metrics-ws',
}); });
this.wss.on('connection', (client, request) => this.wss.on('connection', (client, request) =>
this.handleConnection(client, request) this.handleConnection(client, request)
); );
this.wss.on('error', (err) => this.wss.on('error', (err) =>
this.logger.error('WebSocket server error:', err) this.logger.error('WebSocket server error:', err)
); );
const wsPort = Number(this.configService.get('WS_PORT') || 3001); const wsPort = Number(this.configService.get('WS_PORT') || 3001);
httpServer.listen(wsPort, () => { this.httpServer.listen(wsPort, () => {
this.logger.log( this.logger.log(
`WebSocket server running at ws://localhost:${wsPort}/api/metrics-ws` `WebSocket server running at ws://localhost:${wsPort}/metrics-ws`
); );
}); });
} }
onModuleDestroy() {
// Очистка всех ресурсов
this.clearAllSubscriptions();
this.wss?.close();
this.httpServer?.close();
}
private handleConnection(client: WebSocket, request: any) { private handleConnection(client: WebSocket, request: any) {
let clientId = const clientId = this.getClientId(request?.url);
this.getQueryParams(request?.url).clientId || this.activeClients.set(clientId, client);
Math.random().toString(36).slice(2);
this.activeSockets.set(clientId, client);
this.logger.log(`Client connected: ${clientId}`); this.logger.log(`Client connected: ${clientId}`);
this.logger.debug(`Active clients: ${this.activeClients.size}, Subscriptions: ${this.realtimeSubscriptions.size}`);
client.on('message', (raw) => { client.on('message', (raw) => {
try { try {
const msg = JSON.parse(raw.toString()); const message = JSON.parse(raw.toString());
this.handleMessage(clientId, client, msg); this.handleMessage(clientId, client, message);
} catch (err) { } catch (err) {
this.sendError(client, 'Invalid JSON'); this.sendError(client, 'Invalid JSON format');
} }
}); });
client.on('close', () => this.cleanupClient(clientId)); client.on('close', () => this.handleClientDisconnect(clientId));
client.on('error', (err) => { client.on('error', (err) => {
this.logger.error(`Client ${clientId} error:`, err); this.logger.error(`Client ${clientId} error:`, err);
this.cleanupClient(clientId); this.handleClientDisconnect(clientId);
});
// Отправляем приветственное сообщение
this.sendMessage(client, {
event: 'connected',
data: { clientId, timestamp: Date.now() }
}); });
} }
private handleMessage(clientId: string, client: WebSocket, message: any) { private handleMessage(clientId: string, client: WebSocket, message: any) {
const { event, data } = message || {}; const { event, data, requestId } = message;
if (!event) return this.sendError(client, 'Event type is required');
if (!event) {
return this.sendError(client, 'Event type is required', requestId);
}
this.logger.debug(`Received event: ${event} from client: ${clientId}`);
switch (event) { switch (event) {
case 'subscribe-realtime':
return this.handleSubscribeRealtime(clientId, client, data, requestId);
case 'unsubscribe-realtime':
return this.handleUnsubscribeRealtime(clientId, data, requestId);
case 'unsubscribe-all': case 'unsubscribe-all':
return this.unsubscribeAllForClient(clientId); return this.handleUnsubscribeAll(clientId, requestId);
case 'get-metrics': case 'get-historical':
return this.handleGetMetrics(client, data); return this.handleGetHistorical(client, data, requestId);
case 'subscribe-metric': case 'get-current':
return this.handleSubscribeMetric(clientId, client, data); return this.handleGetCurrent(client, data, requestId);
case 'unsubscribe-metric':
return this.handleUnsubscribeMetric(clientId, data);
default: default:
return this.sendError(client, `Unknown event type: ${event}`); return this.sendError(client, `Unknown event type: ${event}`, requestId);
} }
} }
private cleanupClient(clientId: string) { private async handleSubscribeRealtime(
if (!this.activeSockets.has(clientId)) return; clientId: string,
this.logger.log(`Client disconnected: ${clientId}`); client: WebSocket,
this.activeSockets.delete(clientId); payload: any,
requestId?: string
setTimeout(() => { ) {
if (!this.activeSockets.has(clientId)) { const { metric, filters = {}, interval = 10000 } = payload || {};
}
}, 5000);
for (const [key, sub] of this.metricSubscriptions) {
sub.clients.delete(clientId);
if (sub.clients.size === 0) {
sub.stopUpdates();
this.metricSubscriptions.delete(key);
this.lastSentData.delete(key);
}
}
}
private unsubscribeAllForClient(clientId: string) {
for (const [key, sub] of this.metricSubscriptions) {
if (sub.clients.has(clientId)) sub.clients.delete(clientId);
if (sub.clients.size === 0) {
sub.stopUpdates();
this.metricSubscriptions.delete(key);
this.lastSentData.delete(key);
}
}
}
private async handleGetMetrics(client: WebSocket, payload: any) {
const {
metric,
start,
end,
step,
isRangeQuery,
requestId,
filters = {},
} = payload || {};
if (!metric) { if (!metric) {
return this.sendError(client, 'Metric name is required', requestId); return this.sendError(client, 'Metric name is required', requestId);
} }
if (isRangeQuery) { const subscriptionKey = this.getSubscriptionKey(metric, filters);
try {
const rangeData = await this.prometheusService.fetchMetricsRange(
metric,
start,
end,
step,
filters
);
this.logger.debug('RangeQuery result', JSON.stringify(rangeData).slice(0, 200));
return this.sendMessage(client, {
event: 'metrics-data',
data: rangeData,
metric,
requestId,
});
} catch (err: any) {
return this.sendError(client, err?.message || 'Range query error', requestId);
}
}
try { try {
const subscriptionKey = this.getSubscriptionKey(metric, filters); // Если подписка уже существует, просто добавляем клиента
if (this.realtimeSubscriptions.has(subscriptionKey)) {
const subscription = this.realtimeSubscriptions.get(subscriptionKey)!;
subscription.clients.add(clientId);
const initialData = this.logger.log(`Client ${clientId} added to existing subscription: ${subscriptionKey}`);
await this.prometheusService.fetchMetricsWithFilters(metric, filters);
// Отправляем последние данные клиенту
if (subscription.lastData) {
this.sendMessage(client, {
event: 'realtime-data',
data: {
metric,
filters,
data: subscription.lastData,
type: 'initial'
},
requestId
});
}
return;
}
// Создаем новую подписку
this.logger.log(`Creating new realtime subscription: ${subscriptionKey}`);
// Первоначальная загрузка данных
const initialData = await this.prometheusService.fetchMetricsWithFilters(metric, filters);
if (!Array.isArray(initialData)) {
throw new Error(`Expected array for metric ${metric}, got ${typeof initialData}`);
}
// Создаем интервал для обновлений
const intervalId = setInterval(
() => this.updateRealtimeData(subscriptionKey),
interval
);
// Сохраняем подписку
const subscription: RealtimeSubscription = {
clients: new Set([clientId]),
interval: intervalId,
metric,
filters,
lastData: initialData
};
this.realtimeSubscriptions.set(subscriptionKey, subscription);
// Отправляем данные клиенту
this.sendMessage(client, { this.sendMessage(client, {
event: 'metrics-data', event: 'realtime-data',
data: { metric, data: initialData, requestId }, data: {
metric,
filters,
data: initialData,
type: 'initial'
},
requestId
}); });
let lastLocal = initialData; this.logger.debug(`Subscription created for ${subscriptionKey} with ${interval}ms interval`);
const jitter = Math.floor(Math.random() * 5000); } catch (error) {
setTimeout(() => { this.logger.error(`Subscribe error for ${subscriptionKey}:`, error);
const timer = setInterval(async () => { this.sendError(client, error.message, requestId);
try {
const fresh =
await this.prometheusService.fetchMetricsWithFilters(
metric,
filters
);
if (!this.isDataEqual(lastLocal, fresh)) {
this.sendMessage(client, {
event: 'metrics-data',
data: { metric, data: fresh, requestId },
});
lastLocal = fresh;
}
} catch (e) {
this.logger.error(
`Error in on-demand periodic update for ${subscriptionKey}:`,
(e as any)?.message
);
}
}, step || 5000);
const closeOnce = () => clearInterval(timer);
client.once('close', closeOnce);
client.once('error', closeOnce);
}, jitter);
} catch (err: any) {
return this.sendError(client, err?.message || 'get-metrics error', requestId);
} }
} }
private async handleSubscribeMetric(clientId: string, client: WebSocket, payload: any) { private async updateRealtimeData(subscriptionKey: string) {
const { metric, interval = 60000, filters = {} } = payload || {}; const subscription = this.realtimeSubscriptions.get(subscriptionKey);
if (!subscription) return;
try {
const freshData = await this.prometheusService.fetchMetricsWithFilters(
subscription.metric,
subscription.filters
);
if (!this.isDataEqual(subscription.lastData, freshData)) {
subscription.lastData = freshData;
// Рассылаем обновление всем подписанным клиентам
this.broadcastToClients(Array.from(subscription.clients), {
event: 'realtime-data',
data: {
metric: subscription.metric,
filters: subscription.filters,
data: freshData,
type: 'update'
}
});
this.logger.debug(`Data updated for subscription: ${subscriptionKey}`);
}
} catch (error) {
this.logger.error(`Update error for ${subscriptionKey}:`, error);
}
}
private handleUnsubscribeRealtime(
clientId: string,
payload: any,
requestId?: string
) {
const { metric, filters = {} } = payload || {};
if (!metric) { if (!metric) {
this.sendError(client, 'Metric name is required');
return; return;
} }
const key = this.getSubscriptionKey(metric, filters); const subscriptionKey = this.getSubscriptionKey(metric, filters);
const subscription = this.realtimeSubscriptions.get(subscriptionKey);
if (!subscription) {
this.logger.debug(`No subscription found for: ${subscriptionKey}`);
return;
}
// Удаляем клиента из подписки
subscription.clients.delete(clientId);
this.logger.log(`Client ${clientId} unsubscribed from: ${subscriptionKey}`);
// Если больше нет клиентов, очищаем подписку
if (subscription.clients.size === 0) {
clearInterval(subscription.interval);
this.realtimeSubscriptions.delete(subscriptionKey);
this.logger.log(`Subscription removed: ${subscriptionKey}`);
}
}
private handleUnsubscribeAll(clientId: string, requestId?: string) {
let unsubscribedCount = 0;
for (const [key, subscription] of this.realtimeSubscriptions) {
if (subscription.clients.has(clientId)) {
subscription.clients.delete(clientId);
unsubscribedCount++;
if (subscription.clients.size === 0) {
clearInterval(subscription.interval);
this.realtimeSubscriptions.delete(key);
}
}
}
this.logger.log(`Client ${clientId} unsubscribed from ${unsubscribedCount} subscriptions`);
}
private async handleGetHistorical(
client: WebSocket,
payload: any,
requestId?: string
) {
const { metric, start, end, step = 60, filters = {} } = payload || {};
if (!metric) {
return this.sendError(client, 'Metric name is required', requestId);
}
if (!start || !end) {
return this.sendError(client, 'Start and end time are required', requestId);
}
const requestKey = `${requestId || Date.now()}-${metric}`;
try { try {
const initial = await this.prometheusService.fetchMetricsWithFilters(metric, filters); this.logger.debug(`Fetching historical data for: ${metric}, from ${new Date(start).toISOString()} to ${new Date(end).toISOString()}`);
if (!Array.isArray(initial)) { const historicalData = await this.prometheusService.fetchMetricsRange(
throw new Error(`Expected array for metric ${metric}, got ${typeof initial}`);
}
this.sendMessage(client, {
event: 'metrics-data',
data: {
metric: key,
data: initial,
type: 'initial'
}
});
this.lastSentData.set(key, initial);
const stopUpdates = await this.sendPeriodicUpdates(
metric, metric,
interval, Math.floor(start / 1000), // Convert to seconds
(freshData) => { Math.floor(end / 1000), // Convert to seconds
if (!Array.isArray(freshData)) { step,
this.logger.error(`Periodic update: expected array for ${key}, got ${typeof freshData}`);
return;
}
const lastData = this.lastSentData.get(key);
if (!this.isDataEqual(lastData, freshData)) {
this.broadcast({
event: 'metrics-data',
data: {
metric: key,
data: freshData,
type: 'update'
}
});
this.lastSentData.set(key, freshData);
}
},
filters filters
); );
this.metricSubscriptions.set(key, { this.sendMessage(client, {
stopUpdates, event: 'historical-data',
clients: new Set([clientId]), data: {
metric,
filters,
data: historicalData,
start,
end,
step
},
requestId
}); });
const unsubscribe = () => { this.logger.debug(`Historical data sent for: ${metric}, points: ${historicalData.length}`);
this.logger.log(`Unsubscribing client ${clientId} from ${key}`);
const subscription = this.metricSubscriptions.get(key);
if (subscription) {
subscription.clients.delete(clientId);
if (subscription.clients.size === 0) {
subscription.stopUpdates();
this.metricSubscriptions.delete(key);
this.lastSentData.delete(key);
}
}
};
client.on('close', unsubscribe);
client.on('error', unsubscribe);
} catch (error) { } catch (error) {
this.logger.error(`Subscription error for ${key}:`, error); this.logger.error(`Historical data error for ${metric}:`, error);
this.sendError(client, error.message); this.sendError(client, error.message, requestId);
if (!this.metricSubscriptions.has(key)) {
this.lastSentData.delete(key);
}
} }
} }
private handleUnsubscribeMetric( private async handleGetCurrent(
clientId: string, client: WebSocket,
payload: { metric: string; filters?: Filters } payload: any,
requestId?: string
) { ) {
const { metric, filters = {} } = payload || {}; const { metric, filters = {} } = payload || {};
if (!metric) return;
const key = this.getSubscriptionKey(metric, filters); if (!metric) {
const sub = this.metricSubscriptions.get(key); return this.sendError(client, 'Metric name is required', requestId);
if (!sub) return; }
sub.clients.delete(clientId); try {
if (sub.clients.size === 0) { const currentData = await this.prometheusService.fetchMetricsWithFilters(metric, filters);
sub.stopUpdates();
this.metricSubscriptions.delete(key); this.sendMessage(client, {
this.lastSentData.delete(key); event: 'current-data',
data: {
metric,
filters,
data: currentData,
timestamp: Date.now()
},
requestId
});
} catch (error) {
this.logger.error(`Current data error for ${metric}:`, error);
this.sendError(client, error.message, requestId);
} }
} }
private handleClientDisconnect(clientId: string) {
this.logger.log(`Client disconnected: ${clientId}`);
private getQueryParams(rawUrl?: string): Record<string, string> { // Удаляем клиента из всех подписок
this.handleUnsubscribeAll(clientId);
// Удаляем из активных клиентов
this.activeClients.delete(clientId);
this.logger.debug(`Active clients: ${this.activeClients.size}, Subscriptions: ${this.realtimeSubscriptions.size}`);
}
private clearAllSubscriptions() {
for (const [key, subscription] of this.realtimeSubscriptions) {
clearInterval(subscription.interval);
}
this.realtimeSubscriptions.clear();
this.logger.log('All subscriptions cleared');
}
private getClientId(url?: string): string {
const params = this.getQueryParams(url);
return params.clientId || `client-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
private getQueryParams(url?: string): Record<string, string> {
try { try {
const url = new URL(rawUrl || '', 'http://localhost'); // безопасная база if (!url) return {};
return Object.fromEntries(url.searchParams.entries()); const urlObj = new URL(url, 'http://localhost');
return Object.fromEntries(urlObj.searchParams.entries());
} catch { } catch {
return {}; return {};
} }
} }
private getSubscriptionKey(metric: string, filters: Filters): string { private getSubscriptionKey(metric: string, filters: Filters): string {
const keys = Object.keys(filters).sort(); const sortedFilters = Object.keys(filters)
const filterString = keys .sort()
.map((k) => `${k}=${encodeURIComponent(filters[k])}`) .map(key => `${key}=${encodeURIComponent(filters[key])}`)
.join('&'); .join('&');
return `${metric}${filterString ? `?${filterString}` : ''}`;
return sortedFilters ? `${metric}?${sortedFilters}` : metric;
} }
private isDataEqual(a: any[], b: any[]) { private isDataEqual(a: PrometheusMetric[], b: PrometheusMetric[]): boolean {
if (!a || !b || a.length !== b.length) return false; if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
return a.every((item, i) => { return false;
const x = b[i]; }
return a.every((itemA, index) => {
const itemB = b[index];
return ( return (
item?.value === x?.value && itemA.value === itemB.value &&
item?.status === x?.status && itemA.timestamp === itemB.timestamp &&
item?.timestamp === x?.timestamp itemA.device === itemB.device &&
itemA.source_id === itemB.source_id
); );
}); });
} }
private async sendPeriodicUpdates(
metric: string,
interval: number,
cb: (data: any) => void,
filters: Filters
) {
const initialDelay = Math.floor(Math.random() * 5000);
await new Promise((r) => setTimeout(r, initialDelay));
const timer = setInterval(async () => {
try {
const data = await this.prometheusService.fetchMetricsWithFilters(
metric,
filters
);
cb(data);
} catch (e: any) {
this.logger.error(
`Error in periodic update for ${metric}:`,
e?.message
);
}
}, interval);
return () => clearInterval(timer);
}
private sendMessage(client: WebSocket, message: any) { private sendMessage(client: WebSocket, message: any) {
if (client.readyState === WebSocket.OPEN) { if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message)); try {
client.send(JSON.stringify(message));
} catch (error) {
this.logger.error('Error sending message to client:', error);
}
} }
} }
private broadcast(message: any) { private broadcastToClients(clientIds: string[], message: any) {
const raw = JSON.stringify(message); const messageStr = JSON.stringify(message);
this.wss.clients.forEach((c) => {
if (c.readyState === WebSocket.OPEN) c.send(raw); clientIds.forEach(clientId => {
const client = this.activeClients.get(clientId);
if (client && client.readyState === WebSocket.OPEN) {
try {
client.send(messageStr);
} catch (error) {
this.logger.error(`Error broadcasting to client ${clientId}:`, error);
}
}
}); });
} }
private sendError(client: WebSocket, error: string, requestId?: string) { private sendError(client: WebSocket, error: string, requestId?: string) {
this.sendMessage(client, { this.sendMessage(client, {
event: 'metrics-error', event: 'error',
data: { error, requestId }, data: { error, requestId },
requestId
}); });
} }
} }

View File

@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { PrometheusMetric } from './prometheus-metric.interface';
interface CacheEntry<T> {
data: T;
timestamp: number;
}
interface MetadataCacheEntry {
type: string | null;
description: string | undefined;
timestamp: number;
}
@Injectable()
export class PrometheusCacheService {
private metricCache = new Map<string, CacheEntry<PrometheusMetric[]>>();
private metadataCache = new Map<string, MetadataCacheEntry>();
getMetricCache(key: string): CacheEntry<PrometheusMetric[]> | undefined {
return this.metricCache.get(key);
}
setMetricCache(key: string, data: PrometheusMetric[], ttl: number = 5000): void {
this.metricCache.set(key, { data, timestamp: Date.now() + ttl });
}
getMetadataCache(key: string): MetadataCacheEntry | undefined {
return this.metadataCache.get(key);
}
setMetadataCache(
key: string,
type: string | null,
description: string | undefined,
ttl: number = 30000
): void {
this.metadataCache.set(key, { type, description, timestamp: Date.now() + ttl });
}
isCacheValid(cacheEntry: { timestamp: number }): boolean {
return Date.now() < cacheEntry.timestamp;
}
clearCache(): void {
this.metricCache.clear();
this.metadataCache.clear();
}
}

View File

@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class PrometheusQueryService {
buildFilteredQuery(metric: string, filters: Record<string, string>): string {
const filterParts = Object.entries(filters)
.filter(([_, value]) => value !== undefined && value !== null && value !== "")
.map(([key, value]) => `${key}="${value}"`);
return filterParts.length > 0
? `${metric}{${filterParts.join(',')}}`
: metric;
}
calculateOptimalStep(start: number, end: number): number {
const duration = end - start;
return Math.max(Math.floor(duration / 1000), 15);
}
generateCacheKey(metric: string, filters: Record<string, string> = {}): string {
return `${metric}:${JSON.stringify(filters)}`;
}
generateMetadataCacheKey(metric: string, type: 'type' | 'description'): string {
return `metadata-${type}-${metric}`;
}
}

View File

@ -1,13 +1,21 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { ConfigModule } from '@nestjs/config';
import { PrometheusService } from './prometheus.service'; import { PrometheusService } from './prometheus.service';
import { MetricsController } from './metrics.controller'; import { MetricsController } from './metrics.controller';
import { MetricsGateway } from './metrics.gateway'; import { MetricsGateway } from './metrics.gateway';
import { PrometheusCacheService } from './prometheus-cache.service';
import { PrometheusQueryService } from './prometheus-query.service';
@Module({ @Module({
imports: [HttpModule], imports: [HttpModule, ConfigModule],
providers: [PrometheusService, MetricsGateway], providers: [
controllers: [MetricsController], PrometheusCacheService,
exports: [PrometheusService] PrometheusQueryService,
}) PrometheusService,
export class PrometheusModule {} MetricsGateway
],
controllers: [MetricsController],
exports: [PrometheusService]
})
export class PrometheusModule { }

View File

@ -2,46 +2,54 @@ import { Injectable } from '@nestjs/common';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { PrometheusMetric } from './prometheus-metric.interface';
import { MenuItem } from '../menu/menu.interface'; import { MenuItem } from '../menu/menu.interface';
import { PrometheusMetric } from './prometheus-metric.interface';
import { PrometheusCacheService } from './prometheus-cache.service';
import { PrometheusQueryService } from './prometheus-query.service';
interface PrometheusResponse {
status: string;
data: any;
}
@Injectable() @Injectable()
export class PrometheusService { export class PrometheusService {
private readonly prometheusUrl: string; private readonly prometheusUrl: string;
private metricCache = new Map<string, { data: any; timestamp: number }>();
private metadataCache = new Map<string, { type: string | null; description: string | undefined; timestamp: number }>();
constructor( constructor(
private readonly httpService: HttpService, private readonly httpService: HttpService,
private readonly configService: ConfigService private readonly configService: ConfigService,
private readonly cacheService: PrometheusCacheService,
private readonly queryService: PrometheusQueryService
) { ) {
this.prometheusUrl = this.configService.get<string>('PROMETHEUS_API', 'http://localhost:9090'); this.prometheusUrl = this.configService.get<string>('PROMETHEUS_API', 'http://localhost:9090');
console.log('Prometheus API URL:', this.prometheusUrl); console.log('Prometheus API URL:', this.prometheusUrl);
} }
async fetchMetricType(metric: string): Promise<string | null> { private async executeQuery(url: string, params: any): Promise<any> {
const cacheKey = `metadata-type-${metric}`; const response = await lastValueFrom(
const cacheEntry = this.metadataCache.get(cacheKey); this.httpService.get(url, { params })
);
return response.data;
}
if (cacheEntry && Date.now() - cacheEntry.timestamp < 30000) { async fetchMetricType(metric: string): Promise<string | null> {
const cacheKey = this.queryService.generateMetadataCacheKey(metric, 'type');
const cacheEntry = this.cacheService.getMetadataCache(cacheKey);
if (cacheEntry && this.cacheService.isCacheValid(cacheEntry)) {
return cacheEntry.type; return cacheEntry.type;
} }
try { try {
const response = await lastValueFrom( const data = await this.executeQuery(`${this.prometheusUrl}/metadata`, {
this.httpService.get(`${this.prometheusUrl}/metadata`, { params: { metric },
params: { metric },
})
);
const metadata = response.data.data[metric];
const result = metadata?.length ? metadata[0].type : null;
this.metadataCache.set(cacheKey, {
type: result,
description: cacheEntry?.description,
timestamp: Date.now()
}); });
const metadata = data.data[metric];
const result = metadata?.length ? metadata[0].type : null;
this.cacheService.setMetadataCache(cacheKey, result, cacheEntry?.description);
return result; return result;
} catch (error) { } catch (error) {
console.error(`Ошибка при получении типа метрики ${metric}:`, error); console.error(`Ошибка при получении типа метрики ${metric}:`, error);
@ -50,28 +58,22 @@ export class PrometheusService {
} }
async fetchMetricDescription(metric: string): Promise<string | undefined> { async fetchMetricDescription(metric: string): Promise<string | undefined> {
const cacheKey = `metadata-description-${metric}`; const cacheKey = this.queryService.generateMetadataCacheKey(metric, 'description');
const cacheEntry = this.metadataCache.get(cacheKey); const cacheEntry = this.cacheService.getMetadataCache(cacheKey);
if (cacheEntry && Date.now() - cacheEntry.timestamp < 30000) { if (cacheEntry && this.cacheService.isCacheValid(cacheEntry)) {
return cacheEntry.description; return cacheEntry.description;
} }
try { try {
const response = await lastValueFrom( const data = await this.executeQuery(`${this.prometheusUrl}/metadata`, {
this.httpService.get(`${this.prometheusUrl}/metadata`, { params: { metric },
params: { metric }, });
})
); const metadata = data.data[metric];
const metadata = response.data.data[metric];
const result = metadata?.length ? metadata[0].help : undefined; const result = metadata?.length ? metadata[0].help : undefined;
this.metadataCache.set(cacheKey, { this.cacheService.setMetadataCache(cacheKey, cacheEntry?.type ?? null, result);
type: cacheEntry?.type ?? null,
description: result,
timestamp: Date.now()
});
return result; return result;
} catch (error) { } catch (error) {
console.error(`Ошибка при получении описания метрики ${metric}:`, error); console.error(`Ошибка при получении описания метрики ${metric}:`, error);
@ -79,39 +81,46 @@ export class PrometheusService {
} }
} }
async fetchMetrics(metric: string): Promise<PrometheusMetric[]> { private transformMetricData(
const cacheKey = `${metric}:{}`; entry: any,
const cacheEntry = this.metricCache.get(cacheKey); metric: string,
type: string | null,
description: string | undefined
): PrometheusMetric {
return {
__name__: entry.metric.__name__ || metric,
device: entry.metric.device || '',
source_id: entry.metric.source_id || '',
value: parseFloat(entry.value[1]),
timestamp: entry.value[0] * 1000,
type: type || 'gauge',
description
};
}
if (cacheEntry && Date.now() - cacheEntry.timestamp < 5000) { async fetchMetrics(metric: string): Promise<PrometheusMetric[]> {
const cacheKey = this.queryService.generateCacheKey(metric);
const cacheEntry = this.cacheService.getMetricCache(cacheKey);
if (cacheEntry && this.cacheService.isCacheValid(cacheEntry)) {
return cacheEntry.data; return cacheEntry.data;
} }
try { try {
const response = await lastValueFrom( const data = await this.executeQuery(`${this.prometheusUrl}/query`, {
this.httpService.get(`${this.prometheusUrl}/query`, { query: metric
params: { query: metric }, });
})
const [type, description] = await Promise.all([
this.fetchMetricType(metric),
this.fetchMetricDescription(metric)
]);
const result = data.data.result.map((entry: any) =>
this.transformMetricData(entry, metric, type, description)
); );
const metricType = await this.fetchMetricType(metric); this.cacheService.setMetricCache(cacheKey, result);
const metricDescription = await this.fetchMetricDescription(metric);
const result = response.data.data.result.map((entry): PrometheusMetric => ({
__name__: entry.metric.__name__ || metric,
device: entry.metric.device,
instance: entry.metric.instance,
job: entry.metric.job,
source_id: entry.metric.source_id,
status: entry.metric.status || '0',
timestamp: entry.value[0] * 1000,
value: parseFloat(entry.value[1]),
type: metricType || 'gauge',
description: metricDescription,
...entry.metric
}));
this.metricCache.set(cacheKey, { data: result, timestamp: Date.now() });
return result; return result;
} catch (error) { } catch (error) {
console.error(`Error fetching metrics for ${metric}:`, error); console.error(`Error fetching metrics for ${metric}:`, error);
@ -121,39 +130,27 @@ export class PrometheusService {
} }
async fetchMetricsWithFilters(metric: string, filters: Record<string, string>): Promise<PrometheusMetric[]> { async fetchMetricsWithFilters(metric: string, filters: Record<string, string>): Promise<PrometheusMetric[]> {
const cacheKey = `${metric}:${JSON.stringify(filters)}`; const cacheKey = this.queryService.generateCacheKey(metric, filters);
const cacheEntry = this.metricCache.get(cacheKey); const cacheEntry = this.cacheService.getMetricCache(cacheKey);
if (cacheEntry && Date.now() - cacheEntry.timestamp < 5000) { if (cacheEntry && this.cacheService.isCacheValid(cacheEntry)) {
return cacheEntry.data; return cacheEntry.data;
} }
try { try {
const query = this.buildFilteredQuery(metric, filters); const query = this.queryService.buildFilteredQuery(metric, filters);
const response = await lastValueFrom( const data = await this.executeQuery(`${this.prometheusUrl}/query`, { query });
this.httpService.get(`${this.prometheusUrl}/query`, {
params: { query } const [type, description] = await Promise.all([
}) this.fetchMetricType(metric),
this.fetchMetricDescription(metric)
]);
const result = data.data.result.map((entry: any) =>
this.transformMetricData(entry, metric, type, description)
); );
const metricType = await this.fetchMetricType(metric); this.cacheService.setMetricCache(cacheKey, result);
const metricDescription = await this.fetchMetricDescription(metric);
const result = response.data.data.result.map((entry): PrometheusMetric => ({
__name__: entry.metric.__name__ || metric,
device: entry.metric.device,
instance: entry.metric.instance,
job: entry.metric.job,
source_id: entry.metric.source_id,
status: entry.metric.status || '0',
timestamp: entry.value[0] * 1000,
value: parseFloat(entry.value[1]),
type: metricType || 'gauge',
description: metricDescription,
...entry.metric
}));
this.metricCache.set(cacheKey, { data: result, timestamp: Date.now() });
return result; return result;
} catch (error) { } catch (error) {
console.error(`Error fetching metrics with filters for ${metric}:`, error); console.error(`Error fetching metrics with filters for ${metric}:`, error);
@ -162,56 +159,42 @@ export class PrometheusService {
} }
} }
private buildFilteredQuery(metric: string, filters: Record<string, string>): string { async fetchMetricsRange(
const filterParts = Object.entries(filters) metric: string,
.filter(([_, value]) => value !== undefined && value !== null && value !== "") start: number,
.map(([key, value]) => { end: number,
return `${key}="${value}"`; step: number,
}); filters: Record<string, string> = {}
): Promise<PrometheusMetric[]> {
return filterParts.length > 0 const query = this.queryService.buildFilteredQuery(metric, {
? `${metric}{${filterParts.join(',')}}`
: metric;
}
async fetchMetricsRange(metric: string, start: number, end: number, step: number, filters: Record<string, string> = {}): Promise<PrometheusMetric[]> {
// Рассчитываем оптимальный шаг, если не указан
const duration = end - start;
const optimalStep = Math.max(Math.floor(duration / 1000), 15); // Минимум 15 секунд
const query = this.buildFilteredQuery(metric, {
...filters, ...filters,
instance: '192.168.2.34:9050' instance: '192.168.2.34:9050'
}); });
const optimalStep = this.queryService.calculateOptimalStep(start, end);
try { try {
const response = await lastValueFrom( const data = await this.executeQuery(`${this.prometheusUrl}/query_range`, {
this.httpService.get(`${this.prometheusUrl}/query_range`, { query,
params: { start,
query, end,
start, step: optimalStep.toString()
end, });
step: optimalStep.toString()
},
})
);
const metricType = await this.fetchMetricType(metric); const [type, description] = await Promise.all([
const metricDescription = await this.fetchMetricDescription(metric); this.fetchMetricType(metric),
this.fetchMetricDescription(metric)
]);
return response.data.data.result.flatMap((entry) => return data.data.result.flatMap((entry: any) =>
entry.values.map((value): PrometheusMetric => ({ entry.values.map((value: any) => ({
__name__: entry.metric.__name__ || metric, __name__: entry.metric.__name__ || metric,
device: entry.metric.device, device: entry.metric.device || '',
instance: entry.metric.instance, source_id: entry.metric.source_id || '',
job: entry.metric.job,
source_id: entry.metric.source_id,
status: entry.metric.status || '0',
timestamp: value[0] * 1000,
value: parseFloat(value[1]), value: parseFloat(value[1]),
type: metricType || 'gauge', timestamp: value[0] * 1000,
description: metricDescription, type: type || 'gauge',
...entry.metric description
})) }))
); );
} catch (error) { } catch (error) {
@ -238,36 +221,30 @@ export class PrometheusService {
type?: string; type?: string;
}> { }> {
try { try {
const response = await lastValueFrom( const data = await this.executeQuery(`${this.prometheusUrl}/metadata`, {
this.httpService.get(`${this.prometheusUrl}/metadata`, { params: { metric }
params: { metric } });
})
);
const data = response.data?.data?.[metric]?.[0]; const metadata = data?.data?.[metric]?.[0];
return { return {
name: metric, name: metric,
help: data?.help, help: metadata?.help,
type: data?.type type: metadata?.type
}; };
} catch (error) { } catch (error) {
console.error(`Error fetching metadata for ${metric}:`, error); console.error(`Error fetching metadata for ${metric}:`, error);
return { return { name: metric };
name: metric
};
} }
} }
async fetchMetricSeries(metric: string): Promise<Record<string, string>[]> { async fetchMetricSeries(metric: string): Promise<Record<string, string>[]> {
try { try {
const response = await lastValueFrom( const data = await this.executeQuery(`${this.prometheusUrl}/series`, {
this.httpService.get(`${this.prometheusUrl}/series`, { 'match[]': metric
params: { 'match[]': metric } });
})
);
return response.data.data || []; return data.data || [];
} catch (error) { } catch (error) {
console.error(`Error fetching series for ${metric}:`, error); console.error(`Error fetching series for ${metric}:`, error);
return []; return [];
@ -276,22 +253,20 @@ export class PrometheusService {
async fetchAllMetrics(): Promise<string[]> { async fetchAllMetrics(): Promise<string[]> {
try { try {
const response = await lastValueFrom( const data = await this.executeQuery(`${this.prometheusUrl}/label/__name__/values`, {});
this.httpService.get(`${this.prometheusUrl}/label/__name__/values`) return data.data;
);
return response.data.data;
} catch (error) { } catch (error) {
console.error('Error fetching all metrics:', error); console.error('Error fetching all metrics:', error);
return []; return [];
} }
} }
async fetchAllMetricsWithValues(): Promise<any[]> { async fetchAllMetricsWithValues(): Promise<Array<{ metric: string; data: PrometheusMetric[] }>> {
const metricNames = await this.fetchAllMetrics(); const metricNames = await this.fetchAllMetrics();
const zvksMetrics = metricNames.filter(metric => const zvksMetrics = metricNames.filter(metric =>
metric.startsWith('zvks') || metric.startsWith('zvks') ||
metric.includes('server_li') || metric.includes('server_li') ||
metric.includes('application_li') metric.includes('application_li')
); );
const promises = zvksMetrics.map(async (metric) => { const promises = zvksMetrics.map(async (metric) => {
@ -308,7 +283,6 @@ export class PrometheusService {
} }
clearCache(): void { clearCache(): void {
this.metricCache.clear(); this.cacheService.clearCache();
this.metadataCache.clear();
} }
} }